Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00cc11fb0 |
No files matched your search
@@ -28,25 +28,19 @@ BalanceWatch/
|
|||||||
├── AGENTS.md
|
├── AGENTS.md
|
||||||
├── README.md
|
├── README.md
|
||||||
├── .github/workflows/build.yml # CI: сборка для симулятора и устройства
|
├── .github/workflows/build.yml # CI: сборка для симулятора и устройства
|
||||||
├── docs/CONTRACT_V2.md # общий контракт экосистемы v2 (копия)
|
├── branding/ # аватар репозитория, не входит в бандл
|
||||||
├── branding/ # icon.svg, avatar, banner, render.sh; не входит в бандл
|
|
||||||
├── docs/ # CONTRACT_V2.md, скриншоты для баннера
|
|
||||||
├── BalanceWatch.xcodeproj/ # один таргет и одна схема — BalanceWatch
|
├── BalanceWatch.xcodeproj/ # один таргет и одна схема — BalanceWatch
|
||||||
└── BalanceWatch/
|
└── BalanceWatch/
|
||||||
├── App/
|
├── App/
|
||||||
│ ├── BalanceWatchApp.swift # @main, ModelContainer, тема
|
│ ├── BalanceWatchApp.swift # @main, ModelContainer, тема
|
||||||
│ ├── WatchRootView.swift # главный экран: баланс, месяц, навигация
|
│ ├── WatchRootView.swift # главный экран: баланс, месяц, навигация
|
||||||
│ ├── WatchAddTransactionView.swift # добавление операции (+ выбор счёта)
|
│ ├── WatchAddTransactionView.swift # добавление операции
|
||||||
│ ├── WatchAccountsView.swift # CurrencyContext в окружении, счета,
|
|
||||||
│ │ # выбор пространства (ledger)
|
|
||||||
│ └── WatchFeatures.swift # аналитика, операции, бюджеты,
|
│ └── WatchFeatures.swift # аналитика, операции, бюджеты,
|
||||||
│ # категории, корректировка баланса, настройки
|
│ # категории, корректировка баланса, настройки
|
||||||
├── Models/ # SwiftData-модели и системные категории;
|
├── Models/ # SwiftData-модели и системные категории
|
||||||
│ # V2Records.swift — счета, переводы, курсы, SyncRecord
|
|
||||||
├── Shared/ # BalanceModelContainer, AppTheme,
|
├── Shared/ # BalanceModelContainer, AppTheme,
|
||||||
│ # CategoryDesignOptions, ServerSync (ServerAccountStore)
|
│ # CategoryDesignOptions, ServerSync (ServerAccountStore)
|
||||||
├── Utilities/ # FinanceCalculations, Money (сотые, курсы, конвертация),
|
├── Utilities/ # FinanceCalculations, форматирование валют и дат
|
||||||
│ # форматирование валют и дат
|
|
||||||
├── Views/Components/CategoryIconView.swift
|
├── Views/Components/CategoryIconView.swift
|
||||||
└── Assets.xcassets
|
└── Assets.xcassets
|
||||||
```
|
```
|
||||||
@@ -87,20 +81,7 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
|
|||||||
### 2. SwiftData
|
### 2. SwiftData
|
||||||
|
|
||||||
Схема зарегистрирована в `BalanceModelContainer.schema`:
|
Схема зарегистрирована в `BalanceModelContainer.schema`:
|
||||||
`FinanceTransaction`, `MonthlyBudget`, `CustomCategory`, `SyncTombstone`,
|
`FinanceTransaction`, `MonthlyBudget`, `CustomCategory`, `SyncTombstone`.
|
||||||
`FinanceAccount`, `FinanceTransfer`, `ExchangeRate`, `SyncRecord`.
|
|
||||||
|
|
||||||
- Каждое пространство данных (§7 контракта) — отдельное хранилище:
|
|
||||||
личное — прежнее хранилище по умолчанию, общее —
|
|
||||||
`BalanceLedger-<scope>`, где scope — хеш сервер + пользователь + ledger
|
|
||||||
(`LedgerContainers`, `LedgerSelection`). Корневой view пересоздаётся
|
|
||||||
через `.id(scope)`.
|
|
||||||
- `syncID` — id записи на проводе. У записей, созданных до v2, он `nil`, и
|
|
||||||
отправляется прежний `id.uuidString` (иначе на сервере появятся дубли);
|
|
||||||
новые записи получают UUID в нижнем регистре, полученные — id с сервера.
|
|
||||||
- `extraPayload` хранит незнакомые поля payload и отправляет их обратно
|
|
||||||
(§0.2). `SyncRecord` хранит `recurring`, `goal`, `debt`, `attachment`,
|
|
||||||
`setting` целиком.
|
|
||||||
|
|
||||||
- У пользователей уже есть локальные базы. Не удаляй и не переименовывай
|
- У пользователей уже есть локальные базы. Не удаляй и не переименовывай
|
||||||
persisted-поля без стратегии миграции; новые поля делай со значением
|
persisted-поля без стратегии миграции; новые поля делай со значением
|
||||||
@@ -126,18 +107,13 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
|
|||||||
|
|
||||||
Центральные правила — в `FinanceCalculations`:
|
Центральные правила — в `FinanceCalculations`:
|
||||||
|
|
||||||
- суммы считаются в целых сотых (`Money`), курсы — десятичные строки,
|
- общий баланс = доходы − расходы;
|
||||||
округление половиной от нуля; интервалы полуоткрытые `[начало, конец)`;
|
|
||||||
- общий баланс = доходы − расходы + переводы, всё в базовой валюте
|
|
||||||
(`CurrencyContext`: `setting baseCurrency`, иначе `@AppStorage("currencyCode")`);
|
|
||||||
нет курса — сумма не входит, показывается «нет курса C→B»;
|
|
||||||
- баланс счёта — в его валюте без конвертации;
|
|
||||||
- операции с `isBalanceAdjustment == true` входят в общий баланс, но
|
- операции с `isBalanceAdjustment == true` входят в общий баланс, но
|
||||||
**не** входят в периодическую аналитику (месяц/3 мес./12 мес.);
|
**не** входят в периодическую аналитику (месяц/3 мес./12 мес.);
|
||||||
- расходы группируются по `categoryName`.
|
- расходы группируются по `categoryName`.
|
||||||
|
|
||||||
Эти правила **одинаковы во всех клиентах Balance** (Apple, Android, web)
|
Эти правила **одинаковы во всех клиентах Balance** (Apple, Android, web).
|
||||||
и описаны в `docs/CONTRACT_V2.md` §2–3. Не меняй их только здесь.
|
Не меняй их только здесь.
|
||||||
|
|
||||||
### 5. Синхронизация (общий контракт)
|
### 5. Синхронизация (общий контракт)
|
||||||
|
|
||||||
@@ -153,15 +129,7 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
|
|||||||
изменения сервера и остальных клиентов сломает синхронизацию.
|
изменения сервера и остальных клиентов сломает синхронизацию.
|
||||||
- При смене URL сервера сессия очищается, cursor/push-state
|
- При смене URL сервера сессия очищается, cursor/push-state
|
||||||
сбрасываются, пользователь логинится заново (`saveServer`). Сохраняй
|
сбрасываются, пользователь логинится заново (`saveServer`). Сохраняй
|
||||||
это поведение. Смена сервера и выход возвращают личное пространство.
|
это поведение.
|
||||||
- v2: перед синхронизацией запрашивается `GET /v1/capabilities`; 404 —
|
|
||||||
сервер v1, тогда `supportsV2 == false`: счета и общие бюджеты скрыты,
|
|
||||||
`setting` не отправляется. Незнакомые сущности при pull пропускаются.
|
|
||||||
- Общее пространство передаётся заголовком `X-Balance-Ledger` на `v1/sync`;
|
|
||||||
cursor, push-state и журнал удалений имеют суффикс `LedgerSelection.keySuffix`.
|
|
||||||
- Из сущностей v2 часы отправляют только `setting` (базовая валюта);
|
|
||||||
счета, переводы, курсы, регулярные операции и прочее только читают.
|
|
||||||
Регулярные операции часы не создают.
|
|
||||||
|
|
||||||
### 6. Безопасность
|
### 6. Безопасность
|
||||||
|
|
||||||
@@ -194,10 +162,7 @@ CI (`.github/workflows/build.yml`) собирает проект на `macos-15`
|
|||||||
симулятора и для устройства без подписи. Перед PR сборка должна быть
|
симулятора и для устройства без подписи. Перед PR сборка должна быть
|
||||||
зелёной в обоих вариантах.
|
зелёной в обоих вариантах.
|
||||||
|
|
||||||
Тестового таргета пока нет. `Utilities/Money.swift` не зависит от
|
Тестового таргета пока нет. Если добавляешь тесты, не завязывай их на
|
||||||
SwiftData/SwiftUI, а `FinanceCalculations` работает через протоколы
|
|
||||||
`LedgerEntry`/`LedgerTransfer`, поэтому денежную математику можно проверить
|
|
||||||
отдельным `swiftc`-прогоном на macOS (с заглушкой `FinanceTransaction`). Если добавляешь тесты, не завязывай их на
|
|
||||||
текущую дату и локаль — явно фиксируй `Calendar`, `Date` и formatter'ы.
|
текущую дату и локаль — явно фиксируй `Calendar`, `Date` и formatter'ы.
|
||||||
Добавив тестовый таргет, добавь и шаг `test` в CI.
|
Добавив тестовый таргет, добавь и шаг `test` в CI.
|
||||||
|
|
||||||
@@ -211,11 +176,3 @@ SwiftData/SwiftUI, а `FinanceCalculations` работает через прот
|
|||||||
- [ ] Секреты и токены не попали в репозиторий и логи.
|
- [ ] Секреты и токены не попали в репозиторий и логи.
|
||||||
- [ ] CI собирает проект для симулятора и для устройства.
|
- [ ] CI собирает проект для симулятора и для устройства.
|
||||||
- [ ] README/AGENTS.md обновлены, если поменялась структура или сборка.
|
- [ ] README/AGENTS.md обновлены, если поменялась структура или сборка.
|
||||||
|
|
||||||
## Оформление
|
|
||||||
|
|
||||||
- Цвета интерфейса — только из `BalancePalette` (`Shared/AppTheme.swift`),
|
|
||||||
палитра «Лес», общая с BalanceFlutter. Не возвращай indigo/purple.
|
|
||||||
- Иконка, аватар и баннер рендерятся из SVG: `bash branding/render.sh`
|
|
||||||
(нужен `resvg`). PNG в `AppIcon.appiconset` руками не правь.
|
|
||||||
- Аватар репозитория в Gitea обновляется из `branding/avatar.png`.
|
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 15;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_TEAM = K3AS449A74;
|
DEVELOPMENT_TEAM = K3AS449A74;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
||||||
INFOPLIST_KEY_WKWatchKitApp = YES;
|
INFOPLIST_KEY_WKWatchKitApp = YES;
|
||||||
INFOPLIST_KEY_WKWatchOnly = YES;
|
INFOPLIST_KEY_WKWatchOnly = YES;
|
||||||
MARKETING_VERSION = 3.2.0;
|
MARKETING_VERSION = 3.1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
@@ -265,7 +265,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 15;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
DEVELOPMENT_TEAM = K3AS449A74;
|
DEVELOPMENT_TEAM = K3AS449A74;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
||||||
INFOPLIST_KEY_WKWatchKitApp = YES;
|
INFOPLIST_KEY_WKWatchKitApp = YES;
|
||||||
INFOPLIST_KEY_WKWatchOnly = YES;
|
INFOPLIST_KEY_WKWatchOnly = YES;
|
||||||
MARKETING_VERSION = 3.2.0;
|
MARKETING_VERSION = 3.1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
|
|||||||
@@ -3,17 +3,15 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct BalanceWatchApp: App {
|
struct BalanceWatchApp: App {
|
||||||
private let containers = LedgerContainers()
|
private let modelContainer = BalanceModelContainer.make()
|
||||||
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
|
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
|
||||||
@AppStorage(LedgerSelection.scopeKey) private var ledgerScope = ""
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
WatchRootView()
|
WatchRootView()
|
||||||
.id(ledgerScope)
|
.tint(.indigo)
|
||||||
.tint(BalancePalette.accent)
|
|
||||||
.preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme)
|
.preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme)
|
||||||
.modelContainer(containers.container(for: ledgerScope))
|
|
||||||
}
|
}
|
||||||
|
.modelContainer(modelContainer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import SwiftData
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// Контракт v2 на часах: контекст валют, список счетов (только чтение)
|
|
||||||
// и выбор пространства (личное или общий бюджет).
|
|
||||||
|
|
||||||
private struct CurrencyContextKey: EnvironmentKey {
|
|
||||||
static let defaultValue = CurrencyContext(base: SupportedCurrency.RUB.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
/// Базовая валюта, валюты счетов и курсы текущего пространства.
|
|
||||||
var currencyContext: CurrencyContext {
|
|
||||||
get { self[CurrencyContextKey.self] }
|
|
||||||
set { self[CurrencyContextKey.self] = newValue }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Строит `CurrencyContext` из данных пространства и кладёт в окружение.
|
|
||||||
struct CurrencyContextProvider<Content: View>: View {
|
|
||||||
@Query private var accounts: [FinanceAccount]
|
|
||||||
@Query private var rates: [ExchangeRate]
|
|
||||||
@Query(filter: #Predicate<SyncRecord> { $0.entityName == "setting" && $0.recordID == "baseCurrency" })
|
|
||||||
private var baseSetting: [SyncRecord]
|
|
||||||
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
|
||||||
@ViewBuilder let content: Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
content.environment(\.currencyContext, CurrencyContext(
|
|
||||||
base: baseSetting.first?.stringValue ?? currencyCode,
|
|
||||||
accounts: accounts.map { ($0.syncID, $0.name, $0.currency) },
|
|
||||||
quotes: rates.map(\.quote)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WatchAccountsView: View {
|
|
||||||
@Environment(\.currencyContext) private var currency
|
|
||||||
@Query(sort: [SortDescriptor(\FinanceAccount.sortOrder), SortDescriptor(\FinanceAccount.name)])
|
|
||||||
private var accounts: [FinanceAccount]
|
|
||||||
@Query private var transactions: [FinanceTransaction]
|
|
||||||
@Query private var transfers: [FinanceTransfer]
|
|
||||||
|
|
||||||
private var total: MoneyTotal {
|
|
||||||
FinanceCalculations.totalBalance(transactions: transactions, transfers: transfers, context: currency)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
Section("Всего, \(currency.base)") {
|
|
||||||
Text(total.value.formattedCurrency(code: currency.base)).font(.headline)
|
|
||||||
if let warning = currency.missingRateWarning(total.missing) {
|
|
||||||
Label(warning, systemImage: "exclamationmark.triangle").font(.caption2).foregroundStyle(.orange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if accounts.isEmpty {
|
|
||||||
Text("Счетов пока нет. Их создают на телефоне или компьютере.")
|
|
||||||
.font(.footnote).foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
ForEach(accounts) { account in
|
|
||||||
row(
|
|
||||||
account.name + (account.isArchived ? " (архив)" : ""),
|
|
||||||
icon: account.icon,
|
|
||||||
tint: FinanceCategory.color(named: account.colorName),
|
|
||||||
cents: FinanceCalculations.accountBalance(accountID: account.syncID, transactions: transactions, transfers: transfers),
|
|
||||||
code: currency.accountCurrency(account.syncID)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
let unassigned = FinanceCalculations.unassignedBalances(transactions: transactions, context: currency)
|
|
||||||
ForEach(unassigned.keys.sorted(), id: \.self) { code in
|
|
||||||
row("Без счёта", icon: "tray.fill", tint: .gray, cents: unassigned[code] ?? 0, code: code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Счета")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func row(_ title: String, icon: String, tint: Color, cents: Int64, code: String) -> some View {
|
|
||||||
HStack {
|
|
||||||
CategoryIconView(systemName: icon, emoji: "", tint: tint, size: 13, containerSize: 30)
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(title).lineLimit(1)
|
|
||||||
Text(Money.double(cents).formattedCurrency(code: code))
|
|
||||||
.font(.caption2).foregroundStyle(cents < 0 ? BalancePalette.expense : .secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WatchLedgersView: View {
|
|
||||||
@StateObject private var account = ServerAccountStore.shared
|
|
||||||
@State private var ledgers: [ServerLedger] = []
|
|
||||||
@State private var code = ""
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
Section {
|
|
||||||
choice("Личный", id: "", ledger: nil)
|
|
||||||
ForEach(ledgers) { choice($0.name, id: $0.id, ledger: $0) }
|
|
||||||
} footer: {
|
|
||||||
Text("Данные каждого бюджета хранятся на часах отдельно.")
|
|
||||||
}
|
|
||||||
Section("Присоединиться") {
|
|
||||||
TextField("Код приглашения", text: $code)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
Button("Присоединиться") {
|
|
||||||
Task {
|
|
||||||
if let ledger = await account.joinLedger(code: code) {
|
|
||||||
code = ""
|
|
||||||
if !ledgers.contains(ledger) { ledgers.append(ledger) }
|
|
||||||
account.selectLedger(ledger)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(code.trimmingCharacters(in: .whitespaces).isEmpty || account.isBusy)
|
|
||||||
}
|
|
||||||
if account.isBusy { ProgressView() }
|
|
||||||
if let message = account.message { Text(message).font(.footnote).foregroundStyle(.secondary) }
|
|
||||||
}
|
|
||||||
.navigationTitle("Бюджет")
|
|
||||||
.task { if let value = await account.loadLedgers() { ledgers = value } }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func choice(_ title: String, id: String, ledger: ServerLedger?) -> some View {
|
|
||||||
Button {
|
|
||||||
account.selectLedger(ledger)
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text(title).lineLimit(1)
|
|
||||||
Spacer()
|
|
||||||
if account.ledgerID == id { Image(systemName: "checkmark") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(account.isBusy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,8 @@ import SwiftUI
|
|||||||
struct WatchAddTransactionView: View {
|
struct WatchAddTransactionView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Environment(\.currencyContext) private var currency
|
|
||||||
@StateObject private var account = ServerAccountStore.shared
|
|
||||||
@Query(sort: \CustomCategory.createdAt)
|
@Query(sort: \CustomCategory.createdAt)
|
||||||
private var customCategories: [CustomCategory]
|
private var customCategories: [CustomCategory]
|
||||||
@Query(sort: [SortDescriptor(\FinanceAccount.sortOrder), SortDescriptor(\FinanceAccount.name)])
|
|
||||||
private var accounts: [FinanceAccount]
|
|
||||||
|
|
||||||
private let transaction: FinanceTransaction?
|
private let transaction: FinanceTransaction?
|
||||||
@State private var kind: TransactionKind
|
@State private var kind: TransactionKind
|
||||||
@@ -17,8 +13,6 @@ struct WatchAddTransactionView: View {
|
|||||||
@State private var categoryName: String
|
@State private var categoryName: String
|
||||||
@State private var date: Date
|
@State private var date: Date
|
||||||
@State private var note: String
|
@State private var note: String
|
||||||
/// Пустая строка — «Без счёта».
|
|
||||||
@State private var accountID: String
|
|
||||||
|
|
||||||
init(transaction: FinanceTransaction? = nil) {
|
init(transaction: FinanceTransaction? = nil) {
|
||||||
self.transaction = transaction
|
self.transaction = transaction
|
||||||
@@ -29,31 +23,6 @@ struct WatchAddTransactionView: View {
|
|||||||
?? FinanceCategory.categories(for: initialKind)[0].name)
|
?? FinanceCategory.categories(for: initialKind)[0].name)
|
||||||
_date = State(initialValue: transaction?.date ?? .now)
|
_date = State(initialValue: transaction?.date ?? .now)
|
||||||
_note = State(initialValue: transaction?.note ?? "")
|
_note = State(initialValue: transaction?.note ?? "")
|
||||||
_accountID = State(initialValue: transaction?.accountID ?? "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Активные счета; текущий счёт операции остаётся в списке, даже если
|
|
||||||
/// он в архиве или удалён.
|
|
||||||
private var accountChoices: [(id: String, title: String)] {
|
|
||||||
var values = accounts.filter { !$0.isArchived }.map { ($0.syncID, $0.name) }
|
|
||||||
if let current = transaction?.accountID,
|
|
||||||
!values.contains(where: { $0.0.lowercased() == current.lowercased() }) {
|
|
||||||
values.append((current, currency.accountTitle(current) ?? ""))
|
|
||||||
}
|
|
||||||
return [("", "Без счёта")] + values
|
|
||||||
}
|
|
||||||
|
|
||||||
private var showsAccounts: Bool {
|
|
||||||
account.supportsV2 && (!accounts.isEmpty || transaction?.accountID != nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Валюта следует за счётом; у существующей операции без смены счёта
|
|
||||||
/// валюта не меняется.
|
|
||||||
private var effectiveCurrency: String {
|
|
||||||
if let transaction, (transaction.accountID ?? "") == accountID {
|
|
||||||
return currency.currency(of: transaction)
|
|
||||||
}
|
|
||||||
return accountID.isEmpty ? currency.base : currency.accountCurrency(accountID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var categories: [FinanceCategory] {
|
private var categories: [FinanceCategory] {
|
||||||
@@ -85,13 +54,6 @@ struct WatchAddTransactionView: View {
|
|||||||
|
|
||||||
TextField("Сумма", value: $amount, format: .number)
|
TextField("Сумма", value: $amount, format: .number)
|
||||||
|
|
||||||
if showsAccounts {
|
|
||||||
Picker("Счёт", selection: $accountID) {
|
|
||||||
ForEach(accountChoices, id: \.id) { Text($0.title).tag($0.id) }
|
|
||||||
}
|
|
||||||
LabeledContent("Валюта", value: effectiveCurrency)
|
|
||||||
}
|
|
||||||
|
|
||||||
Picker("Категория", selection: $categoryName) {
|
Picker("Категория", selection: $categoryName) {
|
||||||
ForEach(categories) { category in
|
ForEach(categories) { category in
|
||||||
Text((category.emoji.isEmpty ? "" : category.emoji + " ") + category.name)
|
Text((category.emoji.isEmpty ? "" : category.emoji + " ") + category.name)
|
||||||
@@ -118,10 +80,7 @@ struct WatchAddTransactionView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
let amount = Money.double(Money.cents(amount))
|
|
||||||
guard amount > 0 else { return }
|
guard amount > 0 else { return }
|
||||||
let newAccount = accountID.isEmpty ? nil : accountID
|
|
||||||
let newCurrency = account.supportsV2 ? effectiveCurrency : nil
|
|
||||||
let category = categories.first { $0.name == categoryName }
|
let category = categories.first { $0.name == categoryName }
|
||||||
?? FinanceCategory.categories(for: kind)[0]
|
?? FinanceCategory.categories(for: kind)[0]
|
||||||
if let transaction {
|
if let transaction {
|
||||||
@@ -133,10 +92,6 @@ struct WatchAddTransactionView: View {
|
|||||||
transaction.categoryColorName = category.colorName
|
transaction.categoryColorName = category.colorName
|
||||||
transaction.date = date
|
transaction.date = date
|
||||||
transaction.note = note
|
transaction.note = note
|
||||||
if transaction.accountID != newAccount {
|
|
||||||
transaction.accountID = newAccount
|
|
||||||
transaction.currency = newCurrency
|
|
||||||
}
|
|
||||||
transaction.syncUpdatedAt = .now
|
transaction.syncUpdatedAt = .now
|
||||||
} else {
|
} else {
|
||||||
modelContext.insert(FinanceTransaction(
|
modelContext.insert(FinanceTransaction(
|
||||||
@@ -147,9 +102,7 @@ struct WatchAddTransactionView: View {
|
|||||||
categoryIcon: category.icon,
|
categoryIcon: category.icon,
|
||||||
categoryEmoji: category.emoji,
|
categoryEmoji: category.emoji,
|
||||||
categoryColorName: category.colorName,
|
categoryColorName: category.colorName,
|
||||||
kind: kind,
|
kind: kind
|
||||||
accountID: newAccount,
|
|
||||||
currency: newCurrency
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
|
|||||||
@@ -29,19 +29,17 @@ private enum WatchAnalyticsPeriod: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
struct WatchAnalyticsView: View {
|
struct WatchAnalyticsView: View {
|
||||||
@Query private var transactions: [FinanceTransaction]
|
@Query private var transactions: [FinanceTransaction]
|
||||||
@Environment(\.currencyContext) private var currency
|
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
||||||
@State private var period = WatchAnalyticsPeriod.month
|
@State private var period = WatchAnalyticsPeriod.month
|
||||||
|
|
||||||
private var currencyCode: String { currency.base }
|
|
||||||
private var interval: DateInterval { period.interval() }
|
private var interval: DateInterval { period.interval() }
|
||||||
private var summary: FinancePeriodSummary {
|
private var summary: FinancePeriodSummary {
|
||||||
FinanceCalculations.summary(transactions: transactions, in: interval, context: currency)
|
FinanceCalculations.summary(transactions: transactions, in: interval)
|
||||||
}
|
}
|
||||||
private var spending: [CategorySpending] {
|
private var spending: [CategorySpending] {
|
||||||
Array(FinanceCalculations.spendingByCategory(
|
Array(FinanceCalculations.spendingByCategory(
|
||||||
transactions: transactions,
|
transactions: transactions,
|
||||||
in: interval,
|
in: interval
|
||||||
context: currency
|
|
||||||
).prefix(6))
|
).prefix(6))
|
||||||
}
|
}
|
||||||
private var savingsRate: Double {
|
private var savingsRate: Double {
|
||||||
@@ -55,17 +53,13 @@ struct WatchAnalyticsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Итоги") {
|
Section("Итоги") {
|
||||||
metric("Доходы", summary.income, BalancePalette.income)
|
metric("Доходы", summary.income, .green)
|
||||||
metric("Расходы", summary.expenses, BalancePalette.expense)
|
metric("Расходы", summary.expenses, .red)
|
||||||
LabeledContent("Результат", value: summary.balance.formattedCurrency(code: currencyCode))
|
LabeledContent("Результат", value: summary.balance.formattedCurrency(code: currencyCode))
|
||||||
LabeledContent(
|
LabeledContent(
|
||||||
"Сбережения",
|
"Сбережения",
|
||||||
value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%"
|
value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%"
|
||||||
)
|
)
|
||||||
if let warning = currency.missingRateWarning(summary.missing) {
|
|
||||||
Label(warning, systemImage: "exclamationmark.triangle")
|
|
||||||
.font(.caption2).foregroundStyle(.orange)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Категории") {
|
Section("Категории") {
|
||||||
@@ -100,7 +94,7 @@ struct WatchAnalyticsView: View {
|
|||||||
|
|
||||||
private func tint(for item: CategorySpending) -> Color {
|
private func tint(for item: CategorySpending) -> Color {
|
||||||
item.colorName == "auto"
|
item.colorName == "auto"
|
||||||
? FinanceCategory.find(named: item.name)?.tint ?? BalancePalette.forest
|
? FinanceCategory.find(named: item.name)?.tint ?? .indigo
|
||||||
: FinanceCategory.color(named: item.colorName)
|
: FinanceCategory.color(named: item.colorName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +103,7 @@ struct WatchTransactionsView: View {
|
|||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Query(sort: \FinanceTransaction.date, order: .reverse)
|
@Query(sort: \FinanceTransaction.date, order: .reverse)
|
||||||
private var transactions: [FinanceTransaction]
|
private var transactions: [FinanceTransaction]
|
||||||
@Environment(\.currencyContext) private var currency
|
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
||||||
@State private var selectedKind: TransactionKind?
|
@State private var selectedKind: TransactionKind?
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var showingAdd = false
|
@State private var showingAdd = false
|
||||||
@@ -147,11 +141,8 @@ struct WatchTransactionsView: View {
|
|||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(transaction.categoryName).lineLimit(1)
|
Text(transaction.categoryName).lineLimit(1)
|
||||||
Text((transaction.kind == .income ? "+" : "−")
|
Text((transaction.kind == .income ? "+" : "−")
|
||||||
+ transaction.amount.formattedCurrency(code: currency.currency(of: transaction)))
|
+ transaction.amount.formattedCurrency(code: currencyCode))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
if let title = currency.accountTitle(transaction.accountID) {
|
|
||||||
Text(title).font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,7 +164,7 @@ struct WatchTransactionsView: View {
|
|||||||
|
|
||||||
private func tint(_ transaction: FinanceTransaction) -> Color {
|
private func tint(_ transaction: FinanceTransaction) -> Color {
|
||||||
transaction.categoryColorName == "auto"
|
transaction.categoryColorName == "auto"
|
||||||
? FinanceCategory.find(named: transaction.categoryName)?.tint ?? BalancePalette.forest
|
? FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo
|
||||||
: FinanceCategory.color(named: transaction.categoryColorName)
|
: FinanceCategory.color(named: transaction.categoryColorName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,11 +172,9 @@ struct WatchTransactionsView: View {
|
|||||||
struct WatchBudgetsView: View {
|
struct WatchBudgetsView: View {
|
||||||
@Query private var budgets: [MonthlyBudget]
|
@Query private var budgets: [MonthlyBudget]
|
||||||
@Query private var transactions: [FinanceTransaction]
|
@Query private var transactions: [FinanceTransaction]
|
||||||
@Environment(\.currencyContext) private var currency
|
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
||||||
@State private var showingEditor = false
|
@State private var showingEditor = false
|
||||||
|
|
||||||
private var currencyCode: String { currency.base }
|
|
||||||
|
|
||||||
private var interval: DateInterval {
|
private var interval: DateInterval {
|
||||||
FinanceCalculations.monthInterval(containing: .now)
|
FinanceCalculations.monthInterval(containing: .now)
|
||||||
}
|
}
|
||||||
@@ -195,8 +184,7 @@ struct WatchBudgetsView: View {
|
|||||||
private var spending: [String: Double] {
|
private var spending: [String: Double] {
|
||||||
Dictionary(uniqueKeysWithValues: FinanceCalculations.spendingByCategory(
|
Dictionary(uniqueKeysWithValues: FinanceCalculations.spendingByCategory(
|
||||||
transactions: transactions,
|
transactions: transactions,
|
||||||
in: interval,
|
in: interval
|
||||||
context: currency
|
|
||||||
).map { ($0.name, $0.amount) })
|
).map { ($0.name, $0.amount) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +255,7 @@ private struct WatchBudgetEditor: View {
|
|||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
for category in categories {
|
for category in categories {
|
||||||
let value = Money.double(Money.cents(limits[category.name, default: 0]))
|
let value = limits[category.name, default: 0]
|
||||||
let existing = budgets.first { $0.categoryName == category.name }
|
let existing = budgets.first { $0.categoryName == category.name }
|
||||||
if value > 0 {
|
if value > 0 {
|
||||||
if let existing {
|
if let existing {
|
||||||
@@ -441,16 +429,11 @@ struct WatchBalanceAdjustmentView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Query private var transactions: [FinanceTransaction]
|
@Query private var transactions: [FinanceTransaction]
|
||||||
@Query private var transfers: [FinanceTransfer]
|
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
||||||
@Environment(\.currencyContext) private var currency
|
|
||||||
@StateObject private var account = ServerAccountStore.shared
|
|
||||||
@State private var desired = 0.0
|
@State private var desired = 0.0
|
||||||
@State private var note = ""
|
@State private var note = ""
|
||||||
|
|
||||||
private var currencyCode: String { currency.base }
|
private var current: Double { FinanceCalculations.totalBalance(transactions: transactions) }
|
||||||
private var current: Double {
|
|
||||||
FinanceCalculations.totalBalance(transactions: transactions, transfers: transfers, context: currency).value
|
|
||||||
}
|
|
||||||
private var difference: Double { desired - current }
|
private var difference: Double { desired - current }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -470,53 +453,26 @@ struct WatchBalanceAdjustmentView: View {
|
|||||||
private func save() {
|
private func save() {
|
||||||
guard abs(difference) >= 0.005 else { return }
|
guard abs(difference) >= 0.005 else { return }
|
||||||
modelContext.insert(FinanceTransaction(
|
modelContext.insert(FinanceTransaction(
|
||||||
amount: Money.double(Money.cents(abs(difference))),
|
amount: abs(difference),
|
||||||
note: note.isEmpty ? "Ручная корректировка баланса" : note,
|
note: note.isEmpty ? "Ручная корректировка баланса" : note,
|
||||||
categoryName: "Корректировка",
|
categoryName: "Корректировка",
|
||||||
categoryIcon: "slider.horizontal.3",
|
categoryIcon: "slider.horizontal.3",
|
||||||
categoryColorName: "indigo",
|
categoryColorName: "indigo",
|
||||||
isBalanceAdjustment: true,
|
isBalanceAdjustment: true,
|
||||||
kind: difference > 0 ? .income : .expense,
|
kind: difference > 0 ? .income : .expense
|
||||||
currency: account.supportsV2 ? currency.base : nil
|
|
||||||
))
|
))
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchSettingsView: View {
|
struct WatchSettingsView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
|
||||||
@Environment(\.currencyContext) private var currency
|
|
||||||
@StateObject private var account = ServerAccountStore.shared
|
|
||||||
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
||||||
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
|
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
|
||||||
@Query(filter: #Predicate<SyncRecord> { $0.entityName == "setting" && $0.recordID == "baseCurrency" })
|
|
||||||
private var baseSetting: [SyncRecord]
|
|
||||||
|
|
||||||
private var currencyCodes: [String] {
|
|
||||||
let codes = SupportedCurrency.allCases.map(\.rawValue)
|
|
||||||
return codes.contains(currency.base) ? codes : codes + [currency.base]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Базовая валюта: на сервере v2 — общая настройка `baseCurrency`,
|
|
||||||
/// иначе — локальная настройка часов (как в v1).
|
|
||||||
private var baseCurrency: Binding<String> {
|
|
||||||
Binding(get: { currency.base }, set: { code in
|
|
||||||
currencyCode = code
|
|
||||||
guard account.supportsV2 || !baseSetting.isEmpty,
|
|
||||||
let payload = try? JSONSerialization.data(withJSONObject: ["value": code]) else { return }
|
|
||||||
if let record = baseSetting.first {
|
|
||||||
record.payload = payload
|
|
||||||
record.syncUpdatedAt = .now
|
|
||||||
} else {
|
|
||||||
modelContext.insert(SyncRecord(entityName: "setting", recordID: SyncRecord.baseCurrencyKey, payload: payload, syncUpdatedAt: .now))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Picker("Валюта", selection: baseCurrency) {
|
Picker("Валюта", selection: $currencyCode) {
|
||||||
ForEach(currencyCodes, id: \.self) { Text($0).tag($0) }
|
ForEach(SupportedCurrency.allCases) { Text($0.rawValue).tag($0.rawValue) }
|
||||||
}
|
}
|
||||||
Picker("Тема", selection: $appTheme) {
|
Picker("Тема", selection: $appTheme) {
|
||||||
ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) }
|
ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) }
|
||||||
|
|||||||
@@ -2,29 +2,16 @@ import SwiftData
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct WatchRootView: View {
|
struct WatchRootView: View {
|
||||||
var body: some View {
|
|
||||||
CurrencyContextProvider { WatchHomeView() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct WatchHomeView: View {
|
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Environment(\.currencyContext) private var currency
|
|
||||||
@StateObject private var account = ServerAccountStore.shared
|
|
||||||
@Query(sort: \FinanceTransaction.date, order: .reverse)
|
@Query(sort: \FinanceTransaction.date, order: .reverse)
|
||||||
private var transactions: [FinanceTransaction]
|
private var transactions: [FinanceTransaction]
|
||||||
@Query private var transfers: [FinanceTransfer]
|
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
||||||
@State private var showingAddTransaction = false
|
@State private var showingAddTransaction = false
|
||||||
|
|
||||||
private var currencyCode: String { currency.base }
|
|
||||||
private var total: MoneyTotal {
|
|
||||||
FinanceCalculations.totalBalance(transactions: transactions, transfers: transfers, context: currency)
|
|
||||||
}
|
|
||||||
private var monthSummary: FinancePeriodSummary {
|
private var monthSummary: FinancePeriodSummary {
|
||||||
FinanceCalculations.summary(
|
FinanceCalculations.summary(
|
||||||
transactions: transactions,
|
transactions: transactions,
|
||||||
in: FinanceCalculations.monthInterval(containing: .now),
|
in: FinanceCalculations.monthInterval(containing: .now)
|
||||||
context: currency
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,27 +20,19 @@ private struct WatchHomeView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack(spacing: 5) {
|
Text("Общий баланс")
|
||||||
Image(systemName: "wallet.bifold")
|
.font(.caption)
|
||||||
.font(.caption2.weight(.semibold))
|
.foregroundStyle(.secondary)
|
||||||
Text("Общий баланс")
|
Text(FinanceCalculations.totalBalance(transactions: transactions)
|
||||||
.font(.caption)
|
.formattedCurrency(code: currencyCode))
|
||||||
}
|
|
||||||
.foregroundStyle(BalancePalette.mint.opacity(0.85))
|
|
||||||
Text(total.value.formattedCurrency(code: currencyCode))
|
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
if let warning = currency.missingRateWarning(total.missing.union(monthSummary.missing)) {
|
|
||||||
Label(warning, systemImage: "exclamationmark.triangle")
|
|
||||||
.font(.caption2)
|
|
||||||
.lineLimit(2)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(
|
.background(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [BalancePalette.cardTop, BalancePalette.cardBottom],
|
colors: [.indigo, .purple],
|
||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
),
|
),
|
||||||
@@ -64,15 +43,13 @@ private struct WatchHomeView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
watchMetric(
|
watchMetric(
|
||||||
title: "Доходы",
|
title: "Доходы",
|
||||||
symbol: "arrow.down.left",
|
|
||||||
amount: monthSummary.income,
|
amount: monthSummary.income,
|
||||||
tint: BalancePalette.income
|
tint: .green
|
||||||
)
|
)
|
||||||
watchMetric(
|
watchMetric(
|
||||||
title: "Расходы",
|
title: "Расходы",
|
||||||
symbol: "arrow.up.right",
|
|
||||||
amount: monthSummary.expenses,
|
amount: monthSummary.expenses,
|
||||||
tint: BalancePalette.expense
|
tint: .red
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,16 +60,8 @@ private struct WatchHomeView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.tint(BalancePalette.forest)
|
|
||||||
|
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
if account.supportsV2 {
|
|
||||||
NavigationLink {
|
|
||||||
WatchAccountsView()
|
|
||||||
} label: {
|
|
||||||
Label("Счета", systemImage: "creditcard")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
WatchAnalyticsView()
|
WatchAnalyticsView()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -142,9 +111,9 @@ private struct WatchHomeView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text((transaction.kind == .income ? "+" : "−")
|
Text((transaction.kind == .income ? "+" : "−")
|
||||||
+ transaction.amount.formattedCurrency(code: currency.currency(of: transaction)))
|
+ transaction.amount.formattedCurrency(code: currencyCode))
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundStyle(transaction.kind == .income ? BalancePalette.income : .secondary)
|
.foregroundStyle(transaction.kind == .income ? .green : .secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -152,7 +121,7 @@ private struct WatchHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(account.ledgerID.isEmpty ? "Баланс" : account.ledgerName)
|
.navigationTitle("Баланс")
|
||||||
.sheet(isPresented: $showingAddTransaction) {
|
.sheet(isPresented: $showingAddTransaction) {
|
||||||
WatchAddTransactionView()
|
WatchAddTransactionView()
|
||||||
}
|
}
|
||||||
@@ -165,38 +134,25 @@ private struct WatchHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func watchMetric(title: String, symbol: String, amount: Double, tint: Color) -> some View {
|
private func watchMetric(title: String, amount: Double, tint: Color) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Label(title, systemImage: symbol)
|
Text(title)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.labelStyle(TintedIconLabelStyle(tint: tint))
|
|
||||||
Text(amount.formattedCurrency(code: currencyCode))
|
Text(amount.formattedCurrency(code: currencyCode))
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
.foregroundStyle(tint)
|
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.65)
|
.minimumScaleFactor(0.65)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(9)
|
.padding(9)
|
||||||
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 12))
|
.background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func transactionTint(_ transaction: FinanceTransaction) -> Color {
|
private func transactionTint(_ transaction: FinanceTransaction) -> Color {
|
||||||
if transaction.categoryColorName == "auto" {
|
if transaction.categoryColorName == "auto" {
|
||||||
return FinanceCategory.find(named: transaction.categoryName)?.tint ?? BalancePalette.forest
|
return FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo
|
||||||
}
|
}
|
||||||
return FinanceCategory.color(named: transaction.categoryColorName)
|
return FinanceCategory.color(named: transaction.categoryColorName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Иконка метрики в цвете метрики, подпись — вторичным цветом.
|
|
||||||
private struct TintedIconLabelStyle: LabelStyle {
|
|
||||||
let tint: Color
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
configuration.icon.foregroundStyle(tint)
|
|
||||||
configuration.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 11 KiB |
@@ -11,8 +11,6 @@ final class CustomCategory {
|
|||||||
var kindRawValue: String = "expense"
|
var kindRawValue: String = "expense"
|
||||||
var createdAt: Date = Date.now
|
var createdAt: Date = Date.now
|
||||||
var syncUpdatedAt: Date = Date.now
|
var syncUpdatedAt: Date = Date.now
|
||||||
var syncID: String?
|
|
||||||
var extraPayload: Data?
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -31,12 +29,9 @@ final class CustomCategory {
|
|||||||
self.colorName = colorName
|
self.colorName = colorName
|
||||||
self.kindRawValue = kind.rawValue
|
self.kindRawValue = kind.rawValue
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.syncID = id.uuidString.lowercased()
|
|
||||||
self.syncUpdatedAt = syncUpdatedAt
|
self.syncUpdatedAt = syncUpdatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
var wireID: String { syncID ?? id.uuidString }
|
|
||||||
|
|
||||||
var kind: TransactionKind {
|
var kind: TransactionKind {
|
||||||
get { TransactionKind(rawValue: kindRawValue) ?? .expense }
|
get { TransactionKind(rawValue: kindRawValue) ?? .expense }
|
||||||
set { kindRawValue = newValue.rawValue }
|
set { kindRawValue = newValue.rawValue }
|
||||||
|
|||||||
@@ -28,15 +28,6 @@ final class FinanceTransaction {
|
|||||||
var isBalanceAdjustment: Bool = false
|
var isBalanceAdjustment: Bool = false
|
||||||
var kindRawValue: String = "expense"
|
var kindRawValue: String = "expense"
|
||||||
var syncUpdatedAt: Date = Date.now
|
var syncUpdatedAt: Date = Date.now
|
||||||
/// v2: счёт (`accountId` на проводе), `nil` — «Без счёта».
|
|
||||||
var accountID: String?
|
|
||||||
/// v2: валюта суммы; `nil` — валюта счёта, иначе базовая.
|
|
||||||
var currency: String?
|
|
||||||
/// Идентификатор записи на проводе. `nil` у записей, созданных до v2:
|
|
||||||
/// для них остаётся `id.uuidString`, чтобы не плодить дубли на сервере.
|
|
||||||
var syncID: String?
|
|
||||||
/// Незнакомые поля payload (теги, recurringId, …) — отправляются обратно как есть.
|
|
||||||
var extraPayload: Data?
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -49,8 +40,6 @@ final class FinanceTransaction {
|
|||||||
categoryColorName: String = "auto",
|
categoryColorName: String = "auto",
|
||||||
isBalanceAdjustment: Bool = false,
|
isBalanceAdjustment: Bool = false,
|
||||||
kind: TransactionKind,
|
kind: TransactionKind,
|
||||||
accountID: String? = nil,
|
|
||||||
currency: String? = nil,
|
|
||||||
syncUpdatedAt: Date = .now
|
syncUpdatedAt: Date = .now
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -63,14 +52,9 @@ final class FinanceTransaction {
|
|||||||
self.categoryColorName = categoryColorName
|
self.categoryColorName = categoryColorName
|
||||||
self.isBalanceAdjustment = isBalanceAdjustment
|
self.isBalanceAdjustment = isBalanceAdjustment
|
||||||
self.kindRawValue = kind.rawValue
|
self.kindRawValue = kind.rawValue
|
||||||
self.accountID = accountID
|
|
||||||
self.currency = currency
|
|
||||||
self.syncID = id.uuidString.lowercased()
|
|
||||||
self.syncUpdatedAt = syncUpdatedAt
|
self.syncUpdatedAt = syncUpdatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
var wireID: String { syncID ?? id.uuidString }
|
|
||||||
|
|
||||||
var kind: TransactionKind {
|
var kind: TransactionKind {
|
||||||
get { TransactionKind(rawValue: kindRawValue) ?? .expense }
|
get { TransactionKind(rawValue: kindRawValue) ?? .expense }
|
||||||
set { kindRawValue = newValue.rawValue }
|
set { kindRawValue = newValue.rawValue }
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ final class MonthlyBudget {
|
|||||||
var limit: Double = 0
|
var limit: Double = 0
|
||||||
var monthStart: Date = Date.now
|
var monthStart: Date = Date.now
|
||||||
var syncUpdatedAt: Date = Date.now
|
var syncUpdatedAt: Date = Date.now
|
||||||
var syncID: String?
|
|
||||||
var extraPayload: Data?
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -28,9 +26,6 @@ final class MonthlyBudget {
|
|||||||
self.categoryEmoji = categoryEmoji
|
self.categoryEmoji = categoryEmoji
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.monthStart = monthStart
|
self.monthStart = monthStart
|
||||||
self.syncID = id.uuidString.lowercased()
|
|
||||||
self.syncUpdatedAt = syncUpdatedAt
|
self.syncUpdatedAt = syncUpdatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
var wireID: String { syncID ?? id.uuidString }
|
|
||||||
}
|
}
|
||||||
@@ -30,9 +30,8 @@ struct SyncDeletionRecord: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SyncDeletionStore {
|
enum SyncDeletionStore {
|
||||||
// Журнал свой у каждого пространства (§7): у личного — прежний ключ.
|
private static let storageKey = "balance.server.deletionJournal.v1"
|
||||||
private static var storageKey: String { "balance.server.deletionJournal.v1" + LedgerSelection.keySuffix }
|
private static let quarantineKey = "balance.server.deletionJournal.corrupt"
|
||||||
private static var quarantineKey: String { "balance.server.deletionJournal.corrupt" + LedgerSelection.keySuffix }
|
|
||||||
private static let lock = NSLock()
|
private static let lock = NSLock()
|
||||||
|
|
||||||
static func add(entity: String, recordID: String, updatedAt: Date = .now) {
|
static func add(entity: String, recordID: String, updatedAt: Date = .now) {
|
||||||
@@ -94,17 +93,17 @@ enum SyncDeletionStore {
|
|||||||
|
|
||||||
extension ModelContext {
|
extension ModelContext {
|
||||||
func deleteForSync(_ transaction: FinanceTransaction) {
|
func deleteForSync(_ transaction: FinanceTransaction) {
|
||||||
SyncDeletionStore.add(entity:"transaction", recordID:transaction.wireID)
|
SyncDeletionStore.add(entity:"transaction", recordID:transaction.id.uuidString)
|
||||||
delete(transaction)
|
delete(transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteForSync(_ category: CustomCategory) {
|
func deleteForSync(_ category: CustomCategory) {
|
||||||
SyncDeletionStore.add(entity:"category", recordID:category.wireID)
|
SyncDeletionStore.add(entity:"category", recordID:category.id.uuidString)
|
||||||
delete(category)
|
delete(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteForSync(_ budget: MonthlyBudget) {
|
func deleteForSync(_ budget: MonthlyBudget) {
|
||||||
SyncDeletionStore.add(entity:"budget", recordID:budget.wireID)
|
SyncDeletionStore.add(entity:"budget", recordID:budget.id.uuidString)
|
||||||
delete(budget)
|
delete(budget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import SwiftData
|
|
||||||
|
|
||||||
// Сущности контракта v2 (docs/CONTRACT_V2.md). Часы их только читают:
|
|
||||||
// создают и меняют их телефон и десктоп. `syncID` — id записи на проводе,
|
|
||||||
// `extraPayload` — незнакомые поля payload.
|
|
||||||
|
|
||||||
@Model
|
|
||||||
final class FinanceAccount {
|
|
||||||
var syncID: String = ""
|
|
||||||
var name: String = ""
|
|
||||||
var kindRawValue: String = "other"
|
|
||||||
var icon: String = "creditcard.fill"
|
|
||||||
var colorName: String = "indigo"
|
|
||||||
/// Пустая строка — базовая валюта.
|
|
||||||
var currency: String = ""
|
|
||||||
var createdAt: Date = Date.now
|
|
||||||
var isArchived: Bool = false
|
|
||||||
var sortOrder: Int = 0
|
|
||||||
var extraPayload: Data?
|
|
||||||
var syncUpdatedAt: Date = Date.now
|
|
||||||
|
|
||||||
init(syncID: String, syncUpdatedAt: Date) {
|
|
||||||
self.syncID = syncID
|
|
||||||
self.syncUpdatedAt = syncUpdatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
var key: String { syncID.lowercased() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Model
|
|
||||||
final class FinanceTransfer {
|
|
||||||
var syncID: String = ""
|
|
||||||
var fromAccountID: String = ""
|
|
||||||
var toAccountID: String = ""
|
|
||||||
var amount: Double = 0
|
|
||||||
/// `nil` — равна `amount`.
|
|
||||||
var toAmount: Double?
|
|
||||||
var date: Date = Date.now
|
|
||||||
var note: String = ""
|
|
||||||
var extraPayload: Data?
|
|
||||||
var syncUpdatedAt: Date = Date.now
|
|
||||||
|
|
||||||
init(syncID: String, syncUpdatedAt: Date) {
|
|
||||||
self.syncID = syncID
|
|
||||||
self.syncUpdatedAt = syncUpdatedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Model
|
|
||||||
final class ExchangeRate {
|
|
||||||
var syncID: String = ""
|
|
||||||
var from: String = ""
|
|
||||||
var to: String = ""
|
|
||||||
/// Десятичная строка, сколько единиц `to` стоит одна единица `from`.
|
|
||||||
var rate: String = ""
|
|
||||||
var date: Date = Date.now
|
|
||||||
var extraPayload: Data?
|
|
||||||
var syncUpdatedAt: Date = Date.now
|
|
||||||
|
|
||||||
init(syncID: String, syncUpdatedAt: Date) {
|
|
||||||
self.syncID = syncID
|
|
||||||
self.syncUpdatedAt = syncUpdatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
var quote: RateQuote { RateQuote(from: from, to: to, rate: rate, date: date, updatedAt: syncUpdatedAt) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Записи, которые часы хранят без разбора: `recurring`, `goal`, `debt`,
|
|
||||||
/// `attachment`, `setting`. Payload хранится целиком, поэтому все поля сохраняются.
|
|
||||||
@Model
|
|
||||||
final class SyncRecord {
|
|
||||||
/// Не `entity`: так называется встроенное свойство CoreData, и предикат
|
|
||||||
/// по нему ломает SQL-запрос SwiftData.
|
|
||||||
var entityName: String = ""
|
|
||||||
var recordID: String = ""
|
|
||||||
var payload: Data = Data()
|
|
||||||
var syncUpdatedAt: Date = Date.now
|
|
||||||
|
|
||||||
init(entityName: String, recordID: String, payload: Data, syncUpdatedAt: Date) {
|
|
||||||
self.entityName = entityName
|
|
||||||
self.recordID = recordID
|
|
||||||
self.payload = payload
|
|
||||||
self.syncUpdatedAt = syncUpdatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
static let storedEntities: Set<String> = ["recurring", "goal", "debt", "attachment", "setting"]
|
|
||||||
static let baseCurrencyKey = "baseCurrency"
|
|
||||||
|
|
||||||
/// Значение `setting` (`{"value": "USD"}`), если это строка.
|
|
||||||
var stringValue: String? {
|
|
||||||
(try? JSONSerialization.jsonObject(with: payload) as? [String: Any])?["value"] as? String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FinanceTransaction: LedgerEntry {
|
|
||||||
var isIncome: Bool { kind == .income }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension FinanceTransfer: LedgerTransfer {
|
|
||||||
var incomingAmount: Double { toAmount ?? amount }
|
|
||||||
}
|
|
||||||
@@ -23,20 +23,3 @@ enum AppTheme: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Палитра «Лес» — та же, что у пресета по умолчанию во Flutter-клиенте и у
|
|
||||||
/// иконки приложения.
|
|
||||||
enum BalancePalette {
|
|
||||||
/// Акцент интерфейса (кнопки, ссылки, выбор) — мятный, читается на чёрном.
|
|
||||||
static let accent = Color(red: 0x7f / 255, green: 0xd6 / 255, blue: 0xb9 / 255)
|
|
||||||
/// Основной цвет пресета «Лес».
|
|
||||||
static let forest = Color(red: 0x35 / 255, green: 0x68 / 255, blue: 0x59 / 255)
|
|
||||||
/// Градиент карточки общего баланса (как фон иконки).
|
|
||||||
static let cardTop = Color(red: 0x3f / 255, green: 0x7d / 255, blue: 0x6b / 255)
|
|
||||||
static let cardBottom = Color(red: 0x1a / 255, green: 0x4a / 255, blue: 0x3e / 255)
|
|
||||||
/// Текст на карточке баланса.
|
|
||||||
static let mint = Color(red: 0xbd / 255, green: 0xf7 / 255, blue: 0xe3 / 255)
|
|
||||||
/// Доходы — teal, расходы — deep orange, как во Flutter.
|
|
||||||
static let income = Color(red: 0x26 / 255, green: 0xb3 / 255, blue: 0xa3 / 255)
|
|
||||||
static let expense = Color(red: 0xff / 255, green: 0x6e / 255, blue: 0x40 / 255)
|
|
||||||
}
|
|
||||||
@@ -6,19 +6,15 @@ enum BalanceModelContainer {
|
|||||||
FinanceTransaction.self,
|
FinanceTransaction.self,
|
||||||
MonthlyBudget.self,
|
MonthlyBudget.self,
|
||||||
CustomCategory.self,
|
CustomCategory.self,
|
||||||
SyncTombstone.self,
|
SyncTombstone.self
|
||||||
FinanceAccount.self,
|
|
||||||
FinanceTransfer.self,
|
|
||||||
ExchangeRate.self,
|
|
||||||
SyncRecord.self
|
|
||||||
])
|
])
|
||||||
|
|
||||||
/// `scope` пустой — личные данные (прежнее хранилище по умолчанию);
|
static func make() -> ModelContainer {
|
||||||
/// иначе — отдельное хранилище общего пространства (сервер + пользователь + ledger).
|
let localConfiguration = ModelConfiguration(
|
||||||
static func make(scope: String = "") -> ModelContainer {
|
schema: schema,
|
||||||
let localConfiguration = scope.isEmpty
|
isStoredInMemoryOnly: false,
|
||||||
? ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none)
|
cloudKitDatabase: .none
|
||||||
: ModelConfiguration("BalanceLedger-\(scope)", schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none)
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try ModelContainer(
|
return try ModelContainer(
|
||||||
@@ -27,7 +23,7 @@ enum BalanceModelContainer {
|
|||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
let recoveryConfiguration = ModelConfiguration(
|
let recoveryConfiguration = ModelConfiguration(
|
||||||
scope.isEmpty ? "BalanceRecovery" : "BalanceRecovery-\(scope)",
|
"BalanceRecovery",
|
||||||
schema: schema,
|
schema: schema,
|
||||||
isStoredInMemoryOnly: true,
|
isStoredInMemoryOnly: true,
|
||||||
cloudKitDatabase: .none
|
cloudKitDatabase: .none
|
||||||
@@ -44,17 +40,3 @@ enum BalanceModelContainer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Кеш контейнеров по пространствам: переключение туда-обратно не
|
|
||||||
/// открывает хранилище заново.
|
|
||||||
@MainActor
|
|
||||||
final class LedgerContainers {
|
|
||||||
private var items: [String: ModelContainer] = [:]
|
|
||||||
|
|
||||||
func container(for scope: String) -> ModelContainer {
|
|
||||||
if let item = items[scope] { return item }
|
|
||||||
let item = BalanceModelContainer.make(scope: scope)
|
|
||||||
items[scope] = item
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import CryptoKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
import SwiftData
|
import SwiftData
|
||||||
@@ -89,53 +88,12 @@ private struct SyncResponse: Decodable {
|
|||||||
private struct TransactionPayload: Codable {
|
private struct TransactionPayload: Codable {
|
||||||
let amount: Double; let date: Date; let note: String; let categoryName: String; let categoryIcon: String
|
let amount: Double; let date: Date; let note: String; let categoryName: String; let categoryIcon: String
|
||||||
let categoryEmoji: String; let categoryColorName: String; let isBalanceAdjustment: Bool; let kindRawValue: String
|
let categoryEmoji: String; let categoryColorName: String; let isBalanceAdjustment: Bool; let kindRawValue: String
|
||||||
var accountId: String? = nil; var currency: String? = nil
|
|
||||||
static let keys: Set<String> = ["amount", "date", "note", "categoryName", "categoryIcon", "categoryEmoji", "categoryColorName", "isBalanceAdjustment", "kindRawValue", "accountId", "currency"]
|
|
||||||
}
|
}
|
||||||
private struct CategoryPayload: Codable {
|
private struct CategoryPayload: Codable {
|
||||||
let name: String; let icon: String; let emoji: String; let colorName: String; let kindRawValue: String; let createdAt: Date
|
let name: String; let icon: String; let emoji: String; let colorName: String; let kindRawValue: String; let createdAt: Date
|
||||||
static let keys: Set<String> = ["name", "icon", "emoji", "colorName", "kindRawValue", "createdAt"]
|
|
||||||
}
|
}
|
||||||
private struct BudgetPayload: Codable {
|
private struct BudgetPayload: Codable {
|
||||||
let categoryName: String; let categoryIcon: String; let categoryEmoji: String; let limit: Double; let monthStart: Date
|
let categoryName: String; let categoryIcon: String; let categoryEmoji: String; let limit: Double; let monthStart: Date
|
||||||
static let keys: Set<String> = ["categoryName", "categoryIcon", "categoryEmoji", "limit", "monthStart"]
|
|
||||||
}
|
|
||||||
// v2: все поля необязательные (§0.3), отсутствие — значение по умолчанию.
|
|
||||||
private struct AccountPayload: Decodable {
|
|
||||||
let name: String?; let kindRawValue: String?; let icon: String?; let colorName: String?; let currency: String?
|
|
||||||
let createdAt: Date?; let isArchived: Bool?; let sortOrder: Int?
|
|
||||||
static let keys: Set<String> = ["name", "kindRawValue", "icon", "colorName", "currency", "createdAt", "isArchived", "sortOrder"]
|
|
||||||
}
|
|
||||||
private struct TransferPayload: Decodable {
|
|
||||||
let fromAccountId: String?; let toAccountId: String?; let amount: Double?; let toAmount: Double?; let date: Date?; let note: String?
|
|
||||||
static let keys: Set<String> = ["fromAccountId", "toAccountId", "amount", "toAmount", "date", "note"]
|
|
||||||
}
|
|
||||||
private struct RatePayload: Decodable {
|
|
||||||
let from: String?; let to: String?; let rate: String?; let date: Date?
|
|
||||||
static let keys: Set<String> = ["from", "to", "rate", "date"]
|
|
||||||
}
|
|
||||||
struct ServerCapabilities: Codable {
|
|
||||||
let contract: Int
|
|
||||||
let ledgers: Bool?
|
|
||||||
}
|
|
||||||
struct ServerLedger: Codable, Identifiable, Hashable {
|
|
||||||
let id: String
|
|
||||||
let name: String
|
|
||||||
let role: String?
|
|
||||||
}
|
|
||||||
private struct LedgerList: Decodable { let ledgers: [ServerLedger] }
|
|
||||||
|
|
||||||
/// Выбранное пространство данных (§7). Пустой id — личное.
|
|
||||||
enum LedgerSelection {
|
|
||||||
static let idKey = "balance.server.ledger.id"
|
|
||||||
static let nameKey = "balance.server.ledger.name"
|
|
||||||
/// Ключ хранилища: хеш сервер + пользователь + ledger. Пустой — личное.
|
|
||||||
static let scopeKey = "balance.server.ledger.scope"
|
|
||||||
|
|
||||||
static var id: String { UserDefaults.standard.string(forKey: idKey) ?? "" }
|
|
||||||
static var scope: String { UserDefaults.standard.string(forKey: scopeKey) ?? "" }
|
|
||||||
/// Суффикс ключей курсора, push-state и журнала удалений.
|
|
||||||
static var keySuffix: String { scope.isEmpty ? "" : ".ledger.\(scope)" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum ServerCoding {
|
private enum ServerCoding {
|
||||||
@@ -166,21 +124,6 @@ private enum ServerCoding {
|
|||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// Известные поля поверх сохранённых незнакомых (§0.2).
|
|
||||||
static func payload<T: Encodable>(_ value: T, extras: Data?) throws -> Data {
|
|
||||||
var object = extras.flatMap { try? decoder.decode([String: JSONValue].self, from: $0) } ?? [:]
|
|
||||||
let known = try decoder.decode([String: JSONValue].self, from: encoder.encode(value))
|
|
||||||
object.merge(known) { $1 }
|
|
||||||
return try encoder.encode(object)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Незнакомые поля payload; `nil`, если их нет.
|
|
||||||
static func extras(_ data: Data, known: Set<String>) -> Data? {
|
|
||||||
guard var object = try? decoder.decode([String: JSONValue].self, from: data) else { return nil }
|
|
||||||
for key in known { object.removeValue(forKey: key) }
|
|
||||||
return object.isEmpty ? nil : try? encoder.encode(object)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ServerSyncError: LocalizedError {
|
enum ServerSyncError: LocalizedError {
|
||||||
@@ -202,12 +145,8 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
@Published private(set) var isBusy = false
|
@Published private(set) var isBusy = false
|
||||||
@Published private(set) var lastSyncAt: Date?
|
@Published private(set) var lastSyncAt: Date?
|
||||||
@Published var message: String?
|
@Published var message: String?
|
||||||
@Published private(set) var capabilities: ServerCapabilities?
|
|
||||||
@Published private(set) var ledgerID = LedgerSelection.id
|
|
||||||
@Published private(set) var ledgerName = UserDefaults.standard.string(forKey: LedgerSelection.nameKey) ?? ""
|
|
||||||
|
|
||||||
private let defaults = UserDefaults.standard
|
private let defaults = UserDefaults.standard
|
||||||
private let capabilitiesKey = "balance.server.capabilities"
|
|
||||||
private var session: ServerSession?
|
private var session: ServerSession?
|
||||||
private let sessionKey = "balance.server.session"
|
private let sessionKey = "balance.server.session"
|
||||||
private let serverKey = "balance.server.url"
|
private let serverKey = "balance.server.url"
|
||||||
@@ -215,17 +154,12 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
|
|
||||||
var serverURL: String { defaults.string(forKey: serverKey) ?? "" }
|
var serverURL: String { defaults.string(forKey: serverKey) ?? "" }
|
||||||
var isSignedIn: Bool { session != nil }
|
var isSignedIn: Bool { session != nil }
|
||||||
/// Сервер v2 (`GET /v1/capabilities` ответил). На v1 (404) счета,
|
|
||||||
/// курсы, общие пространства скрыты и новые сущности не отправляются.
|
|
||||||
var supportsV2: Bool { isSignedIn && (capabilities?.contract ?? 1) >= 2 }
|
|
||||||
var supportsLedgers: Bool { supportsV2 && capabilities?.ledgers == true }
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
if let data = KeychainStore.read(sessionKey), let value = try? ServerCoding.decoder.decode(ServerSession.self, from: data) {
|
if let data = KeychainStore.read(sessionKey), let value = try? ServerCoding.decoder.decode(ServerSession.self, from: data) {
|
||||||
session = value; signedInEmail = value.user.email
|
session = value; signedInEmail = value.user.email
|
||||||
}
|
}
|
||||||
lastSyncAt = defaults.object(forKey: "balance.server.lastSync") as? Date
|
lastSyncAt = defaults.object(forKey: "balance.server.lastSync") as? Date
|
||||||
if let data = defaults.data(forKey: capabilitiesKey) { capabilities = try? JSONDecoder().decode(ServerCapabilities.self, from: data) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveServer(_ value: String) throws {
|
func saveServer(_ value: String) throws {
|
||||||
@@ -233,10 +167,7 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.")
|
throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.")
|
||||||
}
|
}
|
||||||
let normalized = try normalizedServer(value)
|
let normalized = try normalizedServer(value)
|
||||||
if normalized != serverURL {
|
if normalized != serverURL { clearSession(); defaults.set(normalized, forKey: serverKey); clearSyncState() }
|
||||||
clearSession(); defaults.set(normalized, forKey: serverKey); clearSyncState()
|
|
||||||
setCapabilities(nil); selectLedger(nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func register(email: String, password: String, context: ModelContext) async { await authenticate(path: "v1/auth/register", email: email, password: password, context: context) }
|
func register(email: String, password: String, context: ModelContext) async { await authenticate(path: "v1/auth/register", email: email, password: password, context: context) }
|
||||||
@@ -244,7 +175,7 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
|
|
||||||
func logout() async {
|
func logout() async {
|
||||||
if let refresh = session?.refreshToken { _ = try? await request(path: "v1/auth/logout", body: ["refreshToken": refresh], authorized: false) as EmptyResponse }
|
if let refresh = session?.refreshToken { _ = try? await request(path: "v1/auth/logout", body: ["refreshToken": refresh], authorized: false) as EmptyResponse }
|
||||||
clearSession(); selectLedger(nil); message = "Вы вышли из аккаунта."
|
clearSession(); message = "Вы вышли из аккаунта."
|
||||||
}
|
}
|
||||||
|
|
||||||
func sync(context: ModelContext) async {
|
func sync(context: ModelContext) async {
|
||||||
@@ -253,10 +184,6 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
await refreshCapabilities()
|
|
||||||
if !ledgerID.isEmpty && !supportsLedgers {
|
|
||||||
throw ServerSyncError.response("Сервер не поддерживает общие бюджеты. Выберите личное пространство.")
|
|
||||||
}
|
|
||||||
let cutoff = Date.now
|
let cutoff = Date.now
|
||||||
let lastPush = defaults.object(forKey: pushKey()) as? Date ?? .distantPast
|
let lastPush = defaults.object(forKey: pushKey()) as? Date ?? .distantPast
|
||||||
let threshold = lastPush <= cutoff.addingTimeInterval(5)
|
let threshold = lastPush <= cutoff.addingTimeInterval(5)
|
||||||
@@ -267,7 +194,6 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
try await pushTransactions(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
try await pushTransactions(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
||||||
try await pushCategories(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
try await pushCategories(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
||||||
try await pushBudgets(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
try await pushBudgets(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
||||||
if supportsV2 { try await pushSettings(context: context, after: threshold, through: cutoff, cursor: initialCursor) }
|
|
||||||
try await pushTombstones(through: cutoff, cursor: initialCursor)
|
try await pushTombstones(through: cutoff, cursor: initialCursor)
|
||||||
try await pullChanges(context: context, cursor: initialCursor)
|
try await pullChanges(context: context, cursor: initialCursor)
|
||||||
|
|
||||||
@@ -296,36 +222,22 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
} catch { message = error.localizedDescription }
|
} catch { message = error.localizedDescription }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func request<Response: Decodable>(path: String, method: String = "POST", body: (any Encodable)?, authorized: Bool, retry: Bool = true) async throws -> Response {
|
private func request<Response: Decodable, Body: Encodable>(path: String, body: Body, authorized: Bool, retry: Bool = true) async throws -> Response {
|
||||||
if authorized { try await refreshIfNeeded() }
|
if authorized { try await refreshIfNeeded() }
|
||||||
let base = try normalizedServer(serverURL)
|
let base = try normalizedServer(serverURL)
|
||||||
guard let url = URL(string: base + "/" + path) else { throw ServerSyncError.invalidURL }
|
guard let url = URL(string: base + "/" + path) else { throw ServerSyncError.invalidURL }
|
||||||
var request = URLRequest(url: url); request.httpMethod = method
|
var request = URLRequest(url: url); request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
if authorized, let token = session?.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
if authorized, let token = session?.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||||
if path == "v1/sync" || path.hasPrefix("v1/attachments/"), !ledgerID.isEmpty {
|
request.httpBody = try ServerCoding.encoder.encode(body)
|
||||||
request.setValue(ledgerID, forHTTPHeaderField: "X-Balance-Ledger")
|
|
||||||
}
|
|
||||||
if let body {
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.httpBody = try ServerCoding.encoder.encode(body)
|
|
||||||
}
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
guard let http = response as? HTTPURLResponse else { throw ServerSyncError.response("Сервер не вернул HTTP-ответ.") }
|
guard let http = response as? HTTPURLResponse else { throw ServerSyncError.response("Сервер не вернул HTTP-ответ.") }
|
||||||
if http.statusCode == 401, authorized, retry { try await refreshSession(); return try await self.request(path:path, method:method, body:body, authorized:true, retry:false) }
|
if http.statusCode == 401, authorized, retry { try await refreshSession(); return try await self.request(path:path, body:body, authorized:true, retry:false) }
|
||||||
guard (200..<300).contains(http.statusCode) else {
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
let envelope = try? ServerCoding.decoder.decode(ServerErrorEnvelope.self, from: data)
|
let envelope = try? ServerCoding.decoder.decode(ServerErrorEnvelope.self, from: data)
|
||||||
if path == "v1/sync", envelope?.error.code == "invalid_json" {
|
if path == "v1/sync", envelope?.error.code == "invalid_json" {
|
||||||
throw ServerSyncError.response("Обновите Balance Server до версии из текущего архива приложения.")
|
throw ServerSyncError.response("Обновите Balance Server до версии из текущего архива приложения.")
|
||||||
}
|
}
|
||||||
if path.hasPrefix("v1/ledgers") || (path == "v1/sync" && !ledgerID.isEmpty) {
|
|
||||||
switch envelope?.error.code {
|
|
||||||
case "invalid_code": throw ServerSyncError.response("Код приглашения неверный или устарел.")
|
|
||||||
case "forbidden": throw ServerSyncError.response("Нет доступа к общему бюджету.")
|
|
||||||
case "not_found": throw ServerSyncError.response("Общий бюджет не найден.")
|
|
||||||
case "owner_cannot_leave": throw ServerSyncError.response("Владелец не может выйти, пока есть участники.")
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw ServerSyncError.response(envelope?.error.message ?? "Ошибка сервера: HTTP \(http.statusCode)")
|
throw ServerSyncError.response(envelope?.error.message ?? "Ошибка сервера: HTTP \(http.statusCode)")
|
||||||
}
|
}
|
||||||
if Response.self == EmptyResponse.self { return EmptyResponse() as! Response }
|
if Response.self == EmptyResponse.self { return EmptyResponse() as! Response }
|
||||||
@@ -348,69 +260,14 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func clearSession() { session = nil; signedInEmail = nil; KeychainStore.delete(sessionKey) }
|
private func clearSession() { session = nil; signedInEmail = nil; KeychainStore.delete(sessionKey) }
|
||||||
|
|
||||||
// MARK: Возможности сервера и пространства (v2)
|
|
||||||
|
|
||||||
private func setCapabilities(_ value: ServerCapabilities?) {
|
|
||||||
capabilities = value
|
|
||||||
if let value, let data = try? JSONEncoder().encode(value) { defaults.set(data, forKey: capabilitiesKey) }
|
|
||||||
else { defaults.removeObject(forKey: capabilitiesKey) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 404 — сервер v1. Сетевая ошибка оставляет прежнее значение.
|
|
||||||
private func refreshCapabilities() async {
|
|
||||||
guard let base = try? normalizedServer(serverURL), let url = URL(string: base + "/v1/capabilities"),
|
|
||||||
let (data, response) = try? await URLSession.shared.data(from: url),
|
|
||||||
let http = response as? HTTPURLResponse else { return }
|
|
||||||
if http.statusCode == 404 { setCapabilities(ServerCapabilities(contract: 1, ledgers: false)) }
|
|
||||||
else if (200..<300).contains(http.statusCode), let value = try? JSONDecoder().decode(ServerCapabilities.self, from: data) {
|
|
||||||
setCapabilities(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Общие пространства пользователя (личное в список не входит).
|
|
||||||
func loadLedgers() async -> [ServerLedger]? {
|
|
||||||
guard session != nil, !isBusy else { return nil }
|
|
||||||
isBusy = true; defer { isBusy = false }
|
|
||||||
do {
|
|
||||||
let list: LedgerList = try await request(path: "v1/ledgers", method: "GET", body: nil, authorized: true)
|
|
||||||
return list.ledgers
|
|
||||||
} catch { message = error.localizedDescription; return nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
func joinLedger(code: String) async -> ServerLedger? {
|
|
||||||
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard session != nil, !isBusy, !trimmed.isEmpty else { return nil }
|
|
||||||
isBusy = true; defer { isBusy = false }
|
|
||||||
do {
|
|
||||||
let ledger: ServerLedger = try await request(path: "v1/ledgers/join", body: ["code": trimmed], authorized: true)
|
|
||||||
message = "Вы присоединились к «\(ledger.name)»."
|
|
||||||
return ledger
|
|
||||||
} catch { message = error.localizedDescription; return nil }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `nil` — личное пространство. Данные, курсор и очередь у каждого
|
|
||||||
/// пространства свои; записи при переключении не переносятся.
|
|
||||||
func selectLedger(_ ledger: ServerLedger?) {
|
|
||||||
guard !isBusy || ledger == nil else { return }
|
|
||||||
ledgerID = ledger?.id ?? ""; ledgerName = ledger?.name ?? ""
|
|
||||||
defaults.set(ledgerID, forKey: LedgerSelection.idKey)
|
|
||||||
defaults.set(ledgerName, forKey: LedgerSelection.nameKey)
|
|
||||||
defaults.set(ledger.map { ledgerScope(for: $0.id) } ?? "", forKey: LedgerSelection.scopeKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ledgerScope(for id: String) -> String {
|
|
||||||
let source = "\(serverURL)|\(session?.user.id ?? "")|\(id.lowercased())"
|
|
||||||
return SHA256.hash(data: Data(source.utf8)).prefix(16).map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
private func clearSyncState() {
|
private func clearSyncState() {
|
||||||
for key in defaults.dictionaryRepresentation().keys
|
for key in defaults.dictionaryRepresentation().keys
|
||||||
where key.hasPrefix("balance.server.cursor.") || key.hasPrefix("balance.server.lastPush.") {
|
where key.hasPrefix("balance.server.cursor.") || key.hasPrefix("balance.server.lastPush.") {
|
||||||
defaults.removeObject(forKey: key)
|
defaults.removeObject(forKey: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private func cursorKey() -> String { "balance.server.cursor.\(serverURL).\(session?.user.id ?? "anonymous")" + LedgerSelection.keySuffix }
|
private func cursorKey() -> String { "balance.server.cursor.\(serverURL).\(session?.user.id ?? "anonymous")" }
|
||||||
private func pushKey() -> String { "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" + LedgerSelection.keySuffix }
|
private func pushKey() -> String { "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" }
|
||||||
private func deviceID() -> String {
|
private func deviceID() -> String {
|
||||||
if let value = defaults.string(forKey: "balance.server.deviceID") { return value }
|
if let value = defaults.string(forKey: "balance.server.deviceID") { return value }
|
||||||
let value = UUID().uuidString; defaults.set(value, forKey: "balance.server.deviceID"); return value
|
let value = UUID().uuidString; defaults.set(value, forKey: "balance.server.deviceID"); return value
|
||||||
@@ -432,8 +289,8 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
let items = try context.fetch(descriptor)
|
let items = try context.fetch(descriptor)
|
||||||
guard !items.isEmpty else { break }
|
guard !items.isEmpty else { break }
|
||||||
let changes = try items.map { item in
|
let changes = try items.map { item in
|
||||||
let value = TransactionPayload(amount:item.amount, date:item.date, note:item.note, categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, categoryColorName:item.categoryColorName, isBalanceAdjustment:item.isBalanceAdjustment, kindRawValue:item.kindRawValue, accountId:item.accountID, currency:item.currency)
|
let value = TransactionPayload(amount:item.amount, date:item.date, note:item.note, categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, categoryColorName:item.categoryColorName, isBalanceAdjustment:item.isBalanceAdjustment, kindRawValue:item.kindRawValue)
|
||||||
return SyncChange(entity:"transaction", id:item.wireID, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.payload(value, extras:item.extraPayload))
|
return SyncChange(entity:"transaction", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value))
|
||||||
}
|
}
|
||||||
try await sendPush(changes, cursor: cursor)
|
try await sendPush(changes, cursor: cursor)
|
||||||
offset += items.count
|
offset += items.count
|
||||||
@@ -459,7 +316,7 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
guard !items.isEmpty else { break }
|
guard !items.isEmpty else { break }
|
||||||
let changes = try items.map { item in
|
let changes = try items.map { item in
|
||||||
let value = CategoryPayload(name:item.name, icon:item.icon, emoji:item.emoji, colorName:item.colorName, kindRawValue:item.kindRawValue, createdAt:item.createdAt)
|
let value = CategoryPayload(name:item.name, icon:item.icon, emoji:item.emoji, colorName:item.colorName, kindRawValue:item.kindRawValue, createdAt:item.createdAt)
|
||||||
return SyncChange(entity:"category", id:item.wireID, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.payload(value, extras:item.extraPayload))
|
return SyncChange(entity:"category", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value))
|
||||||
}
|
}
|
||||||
try await sendPush(changes, cursor: cursor)
|
try await sendPush(changes, cursor: cursor)
|
||||||
offset += items.count
|
offset += items.count
|
||||||
@@ -485,7 +342,7 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
guard !items.isEmpty else { break }
|
guard !items.isEmpty else { break }
|
||||||
let changes = try items.map { item in
|
let changes = try items.map { item in
|
||||||
let value = BudgetPayload(categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, limit:item.limit, monthStart:item.monthStart)
|
let value = BudgetPayload(categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, limit:item.limit, monthStart:item.monthStart)
|
||||||
return SyncChange(entity:"budget", id:item.wireID, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.payload(value, extras:item.extraPayload))
|
return SyncChange(entity:"budget", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value))
|
||||||
}
|
}
|
||||||
try await sendPush(changes, cursor: cursor)
|
try await sendPush(changes, cursor: cursor)
|
||||||
offset += items.count
|
offset += items.count
|
||||||
@@ -494,15 +351,6 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Из сущностей v2 часы меняют только `setting` (базовая валюта).
|
|
||||||
private func pushSettings(context: ModelContext, after threshold: Date, through cutoff: Date, cursor: Int64) async throws {
|
|
||||||
let lowerBound = threshold, upperBound = cutoff
|
|
||||||
let items = try context.fetch(FetchDescriptor<SyncRecord>(predicate: #Predicate { item in
|
|
||||||
item.entityName == "setting" && item.syncUpdatedAt >= lowerBound && item.syncUpdatedAt <= upperBound
|
|
||||||
}))
|
|
||||||
try await sendPush(items.map { SyncChange(entity:$0.entityName, id:$0.recordID, deleted:false, updatedAt:$0.syncUpdatedAt, payload:$0.payload) }, cursor:cursor)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pushTombstones(through cutoff: Date, cursor: Int64) async throws {
|
private func pushTombstones(through cutoff: Date, cursor: Int64) async throws {
|
||||||
var offset = 0
|
var offset = 0
|
||||||
while true {
|
while true {
|
||||||
@@ -560,114 +408,36 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
|
|
||||||
private func apply(_ changes: [SyncChange], context: ModelContext) throws {
|
private func apply(_ changes: [SyncChange], context: ModelContext) throws {
|
||||||
for change in changes.sorted(by: { ($0.sequence ?? 0) < ($1.sequence ?? 0) }) {
|
for change in changes.sorted(by: { ($0.sequence ?? 0) < ($1.sequence ?? 0) }) {
|
||||||
switch change.entity {
|
if let id = UUID(uuidString:change.id) {
|
||||||
case "transaction", "category", "budget":
|
if change.deleted {
|
||||||
if let id = UUID(uuidString:change.id) { try applyV1(change, id:id, context:context) }
|
switch change.entity {
|
||||||
case "account", "transfer", "rate":
|
case "transaction": if let item = try transaction(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
||||||
try applyV2(change, context:context)
|
case "category": if let item = try category(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
||||||
case let entity where SyncRecord.storedEntities.contains(entity):
|
case "budget": if let item = try budget(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
||||||
try applyRecord(change, context:context)
|
default: break
|
||||||
default:
|
}
|
||||||
break // незнакомая сущность: пропускаем, курсор двигается (§0.1)
|
} else if let data = change.payload {
|
||||||
|
switch change.entity {
|
||||||
|
case "transaction":
|
||||||
|
guard let value = try? ServerCoding.decoder.decode(TransactionPayload.self, from:data) else { continue }
|
||||||
|
if let item = try transaction(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
|
||||||
|
else { context.insert(FinanceTransaction(id:id, amount:value.amount, date:value.date, note:value.note, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, categoryColorName:value.categoryColorName, isBalanceAdjustment:value.isBalanceAdjustment, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, syncUpdatedAt:change.updatedAt)) }
|
||||||
|
case "category":
|
||||||
|
guard let value = try? ServerCoding.decoder.decode(CategoryPayload.self, from:data) else { continue }
|
||||||
|
if let item = try category(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
|
||||||
|
else { context.insert(CustomCategory(id:id, name:value.name, icon:value.icon, emoji:value.emoji, colorName:value.colorName, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, createdAt:value.createdAt, syncUpdatedAt:change.updatedAt)) }
|
||||||
|
case "budget":
|
||||||
|
guard let value = try? ServerCoding.decoder.decode(BudgetPayload.self, from:data) else { continue }
|
||||||
|
if let item = try budget(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
|
||||||
|
else { context.insert(MonthlyBudget(id:id, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, limit:value.limit, monthStart:value.monthStart, syncUpdatedAt:change.updatedAt)) }
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SyncDeletionStore.remove(entity:change.entity, recordID:change.id, through:change.updatedAt)
|
SyncDeletionStore.remove(entity:change.entity, recordID:change.id, through:change.updatedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyV1(_ change: SyncChange, id: UUID, context: ModelContext) throws {
|
|
||||||
if change.deleted {
|
|
||||||
switch change.entity {
|
|
||||||
case "transaction": if let item = try transaction(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
|
||||||
case "category": if let item = try category(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
|
||||||
case "budget": if let item = try budget(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let data = change.payload else { return }
|
|
||||||
switch change.entity {
|
|
||||||
case "transaction":
|
|
||||||
guard let value = try? ServerCoding.decoder.decode(TransactionPayload.self, from:data) else { return }
|
|
||||||
let extras = ServerCoding.extras(data, known:TransactionPayload.keys)
|
|
||||||
if let item = try transaction(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt); item.syncID = change.id; item.extraPayload = extras } }
|
|
||||||
else {
|
|
||||||
let item = FinanceTransaction(id:id, amount:value.amount, date:value.date, note:value.note, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, categoryColorName:value.categoryColorName, isBalanceAdjustment:value.isBalanceAdjustment, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, accountID:value.accountId, currency:value.currency, syncUpdatedAt:change.updatedAt)
|
|
||||||
item.syncID = change.id; item.extraPayload = extras
|
|
||||||
context.insert(item)
|
|
||||||
}
|
|
||||||
case "category":
|
|
||||||
guard let value = try? ServerCoding.decoder.decode(CategoryPayload.self, from:data) else { return }
|
|
||||||
let extras = ServerCoding.extras(data, known:CategoryPayload.keys)
|
|
||||||
if let item = try category(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt); item.syncID = change.id; item.extraPayload = extras } }
|
|
||||||
else {
|
|
||||||
let item = CustomCategory(id:id, name:value.name, icon:value.icon, emoji:value.emoji, colorName:value.colorName, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, createdAt:value.createdAt, syncUpdatedAt:change.updatedAt)
|
|
||||||
item.syncID = change.id; item.extraPayload = extras
|
|
||||||
context.insert(item)
|
|
||||||
}
|
|
||||||
case "budget":
|
|
||||||
guard let value = try? ServerCoding.decoder.decode(BudgetPayload.self, from:data) else { return }
|
|
||||||
let extras = ServerCoding.extras(data, known:BudgetPayload.keys)
|
|
||||||
if let item = try budget(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt); item.syncID = change.id; item.extraPayload = extras } }
|
|
||||||
else {
|
|
||||||
let item = MonthlyBudget(id:id, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, limit:value.limit, monthStart:value.monthStart, syncUpdatedAt:change.updatedAt)
|
|
||||||
item.syncID = change.id; item.extraPayload = extras
|
|
||||||
context.insert(item)
|
|
||||||
}
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyV2(_ change: SyncChange, context: ModelContext) throws {
|
|
||||||
let recordID = change.id
|
|
||||||
switch change.entity {
|
|
||||||
case "account":
|
|
||||||
let existing = try context.fetch(FetchDescriptor<FinanceAccount>(predicate:#Predicate { $0.syncID == recordID })).first
|
|
||||||
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
|
|
||||||
if change.deleted { if let existing { context.delete(existing) }; return }
|
|
||||||
guard let data = change.payload, let value = try? ServerCoding.decoder.decode(AccountPayload.self, from:data) else { return }
|
|
||||||
let item = existing ?? { let item = FinanceAccount(syncID:recordID, syncUpdatedAt:change.updatedAt); context.insert(item); return item }()
|
|
||||||
item.name = value.name ?? ""
|
|
||||||
let kind = value.kindRawValue ?? "other"
|
|
||||||
item.kindRawValue = ["cash", "card", "deposit", "other"].contains(kind) ? kind : "other"
|
|
||||||
item.icon = value.icon ?? "creditcard.fill"; item.colorName = value.colorName ?? "indigo"
|
|
||||||
item.currency = value.currency?.uppercased() ?? ""; item.createdAt = value.createdAt ?? change.updatedAt
|
|
||||||
item.isArchived = value.isArchived ?? false; item.sortOrder = value.sortOrder ?? 0
|
|
||||||
item.extraPayload = ServerCoding.extras(data, known:AccountPayload.keys); item.syncUpdatedAt = change.updatedAt
|
|
||||||
case "transfer":
|
|
||||||
let existing = try context.fetch(FetchDescriptor<FinanceTransfer>(predicate:#Predicate { $0.syncID == recordID })).first
|
|
||||||
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
|
|
||||||
if change.deleted { if let existing { context.delete(existing) }; return }
|
|
||||||
guard let data = change.payload, let value = try? ServerCoding.decoder.decode(TransferPayload.self, from:data) else { return }
|
|
||||||
let item = existing ?? { let item = FinanceTransfer(syncID:recordID, syncUpdatedAt:change.updatedAt); context.insert(item); return item }()
|
|
||||||
item.fromAccountID = value.fromAccountId ?? ""; item.toAccountID = value.toAccountId ?? ""
|
|
||||||
item.amount = value.amount ?? 0; item.toAmount = value.toAmount
|
|
||||||
item.date = value.date ?? change.updatedAt; item.note = value.note ?? ""
|
|
||||||
item.extraPayload = ServerCoding.extras(data, known:TransferPayload.keys); item.syncUpdatedAt = change.updatedAt
|
|
||||||
case "rate":
|
|
||||||
let existing = try context.fetch(FetchDescriptor<ExchangeRate>(predicate:#Predicate { $0.syncID == recordID })).first
|
|
||||||
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
|
|
||||||
if change.deleted { if let existing { context.delete(existing) }; return }
|
|
||||||
guard let data = change.payload, let value = try? ServerCoding.decoder.decode(RatePayload.self, from:data) else { return }
|
|
||||||
let item = existing ?? { let item = ExchangeRate(syncID:recordID, syncUpdatedAt:change.updatedAt); context.insert(item); return item }()
|
|
||||||
item.from = value.from?.uppercased() ?? ""; item.to = value.to?.uppercased() ?? ""
|
|
||||||
item.rate = value.rate ?? ""; item.date = value.date ?? change.updatedAt
|
|
||||||
item.extraPayload = ServerCoding.extras(data, known:RatePayload.keys); item.syncUpdatedAt = change.updatedAt
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// recurring, goal, debt, attachment, setting — payload целиком.
|
|
||||||
/// Регулярные операции часы не исполняют (§4).
|
|
||||||
private func applyRecord(_ change: SyncChange, context: ModelContext) throws {
|
|
||||||
let entity = change.entity, recordID = change.id
|
|
||||||
let existing = try context.fetch(FetchDescriptor<SyncRecord>(predicate:#Predicate { $0.entityName == entity && $0.recordID == recordID })).first
|
|
||||||
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
|
|
||||||
if change.deleted { if let existing { context.delete(existing) }; return }
|
|
||||||
guard let data = change.payload else { return }
|
|
||||||
if let existing { existing.payload = data; existing.syncUpdatedAt = change.updatedAt }
|
|
||||||
else { context.insert(SyncRecord(entityName:entity, recordID:recordID, payload:data, syncUpdatedAt:change.updatedAt)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func transaction(id: UUID, context: ModelContext) throws -> FinanceTransaction? {
|
private func transaction(id: UUID, context: ModelContext) throws -> FinanceTransaction? {
|
||||||
let recordID = id
|
let recordID = id
|
||||||
var descriptor = FetchDescriptor<FinanceTransaction>(predicate:#Predicate { item in item.id == recordID })
|
var descriptor = FetchDescriptor<FinanceTransaction>(predicate:#Predicate { item in item.id == recordID })
|
||||||
@@ -734,15 +504,6 @@ struct ServerAccountView: View {
|
|||||||
Button("Зарегистрироваться") { Task { await account.register(email:email, password:password, context:modelContext) } }.disabled(server.isEmpty || email.isEmpty || password.count < 8 || account.isBusy)
|
Button("Зарегистрироваться") { Task { await account.register(email:email, password:password, context:modelContext) } }.disabled(server.isEmpty || email.isEmpty || password.count < 8 || account.isBusy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if account.isSignedIn && (account.supportsLedgers || !account.ledgerID.isEmpty) {
|
|
||||||
Section("Пространство") {
|
|
||||||
NavigationLink {
|
|
||||||
WatchLedgersView()
|
|
||||||
} label: {
|
|
||||||
LabeledContent("Бюджет", value: account.ledgerID.isEmpty ? "Личный" : account.ledgerName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if account.isSignedIn {
|
if account.isSignedIn {
|
||||||
Section("Синхронизация") {
|
Section("Синхронизация") {
|
||||||
Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy)
|
Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy)
|
||||||
|
|||||||
@@ -10,13 +10,10 @@ enum SupportedCurrency: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Double {
|
extension Double {
|
||||||
/// Целые суммы без копеек, остальные — ровно с двумя знаками («8 480,50 ₽»),
|
|
||||||
/// как во Flutter-клиенте.
|
|
||||||
func formattedCurrency(code: String) -> String {
|
func formattedCurrency(code: String) -> String {
|
||||||
let whole = (self * 100).rounded().truncatingRemainder(dividingBy: 100) == 0
|
formatted(
|
||||||
return formatted(
|
|
||||||
.currency(code: code)
|
.currency(code: code)
|
||||||
.precision(.fractionLength(whole ? 0 : 2))
|
.precision(.fractionLength(0 ... 2))
|
||||||
.presentation(.narrow)
|
.presentation(.narrow)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Foundation
|
|||||||
struct FinancePeriodSummary {
|
struct FinancePeriodSummary {
|
||||||
let income: Double
|
let income: Double
|
||||||
let expenses: Double
|
let expenses: Double
|
||||||
var missing: Set<String> = []
|
|
||||||
|
|
||||||
var balance: Double { income - expenses }
|
var balance: Double { income - expenses }
|
||||||
}
|
}
|
||||||
@@ -18,58 +17,11 @@ struct CategorySpending: Identifiable {
|
|||||||
var id: String { name }
|
var id: String { name }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Финансовые правила (общие для экосистемы, см. docs/CONTRACT_V2.md §2–3).
|
|
||||||
/// Расчёты ведутся в целых сотых; общие итоги — в базовой валюте.
|
|
||||||
enum FinanceCalculations {
|
enum FinanceCalculations {
|
||||||
/// Общий баланс: все операции (включая корректировки и будущие даты)
|
static func totalBalance(transactions: [FinanceTransaction]) -> Double {
|
||||||
/// плюс входящие минус исходящие части переводов — всё в базовой валюте.
|
transactions.reduce(0) { balance, transaction in
|
||||||
static func totalBalance(
|
balance + (transaction.kind == .income ? transaction.amount : -transaction.amount)
|
||||||
transactions: [LedgerEntry],
|
|
||||||
transfers: [LedgerTransfer] = [],
|
|
||||||
context: CurrencyContext
|
|
||||||
) -> MoneyTotal {
|
|
||||||
var total = MoneyTotal()
|
|
||||||
for item in transactions {
|
|
||||||
let cents = Money.cents(item.amount)
|
|
||||||
total.add(item.isIncome ? cents : -cents, context.currency(of: item), context.converter)
|
|
||||||
}
|
}
|
||||||
for transfer in transfers {
|
|
||||||
total.add(Money.cents(transfer.incomingAmount), context.accountCurrency(transfer.toAccountID), context.converter)
|
|
||||||
total.add(-Money.cents(transfer.amount), context.accountCurrency(transfer.fromAccountID), context.converter)
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Баланс счёта в его валюте, без конвертации.
|
|
||||||
static func accountBalance(
|
|
||||||
accountID: String,
|
|
||||||
transactions: [LedgerEntry],
|
|
||||||
transfers: [LedgerTransfer]
|
|
||||||
) -> Int64 {
|
|
||||||
let key = accountID.lowercased()
|
|
||||||
var cents: Int64 = 0
|
|
||||||
for item in transactions where item.accountID?.lowercased() == key {
|
|
||||||
let value = Money.cents(item.amount)
|
|
||||||
cents += item.isIncome ? value : -value
|
|
||||||
}
|
|
||||||
for transfer in transfers {
|
|
||||||
if transfer.toAccountID.lowercased() == key { cents += Money.cents(transfer.incomingAmount) }
|
|
||||||
if transfer.fromAccountID.lowercased() == key { cents -= Money.cents(transfer.amount) }
|
|
||||||
}
|
|
||||||
return cents
|
|
||||||
}
|
|
||||||
|
|
||||||
/// «Без счёта»: операции без `accountId`, по валютам.
|
|
||||||
static func unassignedBalances(
|
|
||||||
transactions: [LedgerEntry],
|
|
||||||
context: CurrencyContext
|
|
||||||
) -> [String: Int64] {
|
|
||||||
var result: [String: Int64] = [:]
|
|
||||||
for item in transactions where item.accountID == nil {
|
|
||||||
let value = Money.cents(item.amount)
|
|
||||||
result[context.currency(of: item), default: 0] += item.isIncome ? value : -value
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func monthInterval(containing date: Date, calendar: Calendar = .current) -> DateInterval {
|
static func monthInterval(containing date: Date, calendar: Calendar = .current) -> DateInterval {
|
||||||
@@ -77,52 +29,43 @@ enum FinanceCalculations {
|
|||||||
?? DateInterval(start: date, duration: 30 * 24 * 60 * 60)
|
?? DateInterval(start: date, duration: 30 * 24 * 60 * 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Доходы и расходы периода `[начало, конец)` без корректировок, в базовой валюте.
|
|
||||||
static func summary(
|
static func summary(
|
||||||
transactions: [LedgerEntry],
|
transactions: [FinanceTransaction],
|
||||||
in interval: DateInterval,
|
in interval: DateInterval
|
||||||
context: CurrencyContext
|
|
||||||
) -> FinancePeriodSummary {
|
) -> FinancePeriodSummary {
|
||||||
var income = MoneyTotal(), expenses = MoneyTotal()
|
let periodTransactions = transactions.filter {
|
||||||
for item in transactions where contains(interval, item.date) && !item.isBalanceAdjustment {
|
interval.contains($0.date) && !$0.isBalanceAdjustment
|
||||||
if item.isIncome { income.add(Money.cents(item.amount), context.currency(of: item), context.converter) }
|
|
||||||
else { expenses.add(Money.cents(item.amount), context.currency(of: item), context.converter) }
|
|
||||||
}
|
}
|
||||||
return FinancePeriodSummary(
|
let income = periodTransactions
|
||||||
income: income.value,
|
.filter { $0.kind == .income }
|
||||||
expenses: expenses.value,
|
.reduce(0) { $0 + $1.amount }
|
||||||
missing: income.missing.union(expenses.missing)
|
let expenses = periodTransactions
|
||||||
)
|
.filter { $0.kind == .expense }
|
||||||
|
.reduce(0) { $0 + $1.amount }
|
||||||
|
|
||||||
|
return FinancePeriodSummary(income: income, expenses: expenses)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func spendingByCategory(
|
static func spendingByCategory(
|
||||||
transactions: [FinanceTransaction],
|
transactions: [FinanceTransaction],
|
||||||
in interval: DateInterval,
|
in interval: DateInterval
|
||||||
context: CurrencyContext
|
|
||||||
) -> [CategorySpending] {
|
) -> [CategorySpending] {
|
||||||
let expenses = transactions.filter {
|
let expenses = transactions.filter {
|
||||||
$0.kind == .expense
|
$0.kind == .expense
|
||||||
&& !$0.isBalanceAdjustment
|
&& !$0.isBalanceAdjustment
|
||||||
&& contains(interval, $0.date)
|
&& interval.contains($0.date)
|
||||||
}
|
}
|
||||||
let grouped = Dictionary(grouping: expenses, by: \FinanceTransaction.categoryName)
|
let grouped = Dictionary(grouping: expenses, by: \FinanceTransaction.categoryName)
|
||||||
|
|
||||||
return grouped.map { name, items in
|
return grouped.map { name, items in
|
||||||
var total = MoneyTotal()
|
CategorySpending(
|
||||||
for item in items { total.add(Money.cents(item.amount), context.currency(of: item), context.converter) }
|
|
||||||
return CategorySpending(
|
|
||||||
name: name,
|
name: name,
|
||||||
icon: items.first?.categoryIcon ?? "circle.fill",
|
icon: items.first?.categoryIcon ?? "circle.fill",
|
||||||
emoji: items.first?.categoryEmoji ?? "",
|
emoji: items.first?.categoryEmoji ?? "",
|
||||||
colorName: items.first?.categoryColorName ?? "auto",
|
colorName: items.first?.categoryColorName ?? "auto",
|
||||||
amount: total.value
|
amount: items.reduce(0) { $0 + $1.amount }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sorted { $0.amount > $1.amount }
|
.sorted { $0.amount > $1.amount }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Полуоткрытый интервал: `DateInterval.contains` включает конец.
|
|
||||||
static func contains(_ interval: DateInterval, _ date: Date) -> Bool {
|
|
||||||
date >= interval.start && date < interval.end
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Денежная арифметика контракта v2 (§0, §2): суммы — целые сотые,
|
|
||||||
/// курсы — десятичные строки, округление до сотых половиной от нуля.
|
|
||||||
/// Файл не зависит от SwiftUI/SwiftData.
|
|
||||||
enum Money {
|
|
||||||
private static let posix = Locale(identifier: "en_US_POSIX")
|
|
||||||
|
|
||||||
/// Число с провода → целые сотые (лишние знаки округляются).
|
|
||||||
static func cents(_ value: Double) -> Int64 {
|
|
||||||
guard value.isFinite else { return 0 }
|
|
||||||
let decimal = Decimal(string: String(value), locale: posix) ?? Decimal(value)
|
|
||||||
return rounded(decimal * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func double(_ cents: Int64) -> Double { Double(cents) / 100 }
|
|
||||||
|
|
||||||
/// Курс из строки: только цифры и не более одной точки, больше нуля.
|
|
||||||
static func rate(_ text: String) -> Decimal? {
|
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespaces)
|
|
||||||
guard !trimmed.isEmpty, trimmed.filter({ $0 == "." }).count <= 1,
|
|
||||||
trimmed.allSatisfy({ $0.isASCII && ($0.isNumber || $0 == ".") }),
|
|
||||||
trimmed.first != ".", trimmed.last != ".",
|
|
||||||
let value = Decimal(string: trimmed, locale: posix), value > 0
|
|
||||||
else { return nil }
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `cents · rate` (или `cents / rate` для обратной пары), округлено до сотых.
|
|
||||||
static func convert(_ cents: Int64, rate: Decimal, inverse: Bool) -> Int64 {
|
|
||||||
let value = Decimal(cents)
|
|
||||||
return rounded(inverse ? value / rate : value * rate)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func rounded(_ value: Decimal) -> Int64 {
|
|
||||||
var source = value
|
|
||||||
var result = Decimal()
|
|
||||||
NSDecimalRound(&result, &source, 0, .plain) // половина — от нуля
|
|
||||||
return NSDecimalNumber(decimal: result).int64Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RateQuote {
|
|
||||||
let from: String
|
|
||||||
let to: String
|
|
||||||
let rate: String
|
|
||||||
let date: Date
|
|
||||||
let updatedAt: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Перевод сумм в базовую валюту по последнему курсу на дату расчёта.
|
|
||||||
struct CurrencyConverter {
|
|
||||||
let base: String
|
|
||||||
private var best: [String: (rate: Decimal, inverse: Bool, date: Date, updatedAt: Date)] = [:]
|
|
||||||
|
|
||||||
init(base: String, quotes: [RateQuote], asOf: Date) {
|
|
||||||
let base = base.uppercased()
|
|
||||||
self.base = base
|
|
||||||
for quote in quotes where quote.date <= asOf {
|
|
||||||
let from = quote.from.uppercased(), to = quote.to.uppercased()
|
|
||||||
guard from != to, let rate = Money.rate(quote.rate) else { continue }
|
|
||||||
let other: String, inverse: Bool
|
|
||||||
if to == base { other = from; inverse = false }
|
|
||||||
else if from == base { other = to; inverse = true }
|
|
||||||
else { continue }
|
|
||||||
if let current = best[other] {
|
|
||||||
// Последняя по дате; при равной дате прямая пара важнее
|
|
||||||
// обратной; затем — более поздний updatedAt.
|
|
||||||
let newer = (quote.date, inverse ? 0 : 1, quote.updatedAt)
|
|
||||||
let old = (current.date, current.inverse ? 0 : 1, current.updatedAt)
|
|
||||||
guard newer > old else { continue }
|
|
||||||
}
|
|
||||||
best[other] = (rate, inverse, quote.date, quote.updatedAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `nil` — нет курса.
|
|
||||||
func toBase(_ cents: Int64, from currency: String) -> Int64? {
|
|
||||||
let code = currency.uppercased()
|
|
||||||
if code == base || cents == 0 { return cents }
|
|
||||||
guard let quote = best[code] else { return nil }
|
|
||||||
return Money.convert(cents, rate: quote.rate, inverse: quote.inverse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol LedgerEntry {
|
|
||||||
var amount: Double { get }
|
|
||||||
var isIncome: Bool { get }
|
|
||||||
var isBalanceAdjustment: Bool { get }
|
|
||||||
var date: Date { get }
|
|
||||||
var accountID: String? { get }
|
|
||||||
var currency: String? { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol LedgerTransfer {
|
|
||||||
var fromAccountID: String { get }
|
|
||||||
var toAccountID: String { get }
|
|
||||||
var amount: Double { get }
|
|
||||||
var incomingAmount: Double { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Сумма в базовой валюте и валюты, для которых не нашлось курса.
|
|
||||||
struct MoneyTotal {
|
|
||||||
var cents: Int64 = 0
|
|
||||||
var missing: Set<String> = []
|
|
||||||
|
|
||||||
var value: Double { Money.double(cents) }
|
|
||||||
|
|
||||||
mutating func add(_ cents: Int64, _ currency: String, _ converter: CurrencyConverter) {
|
|
||||||
if let converted = converter.toBase(cents, from: currency) { self.cents += converted }
|
|
||||||
else { missing.insert(currency.uppercased()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Всё, что нужно для расчётов в базовой валюте: базовая валюта,
|
|
||||||
/// валюты и названия счетов, курсы.
|
|
||||||
struct CurrencyContext {
|
|
||||||
let converter: CurrencyConverter
|
|
||||||
private let accountCurrencies: [String: String]
|
|
||||||
private let accountNames: [String: String]
|
|
||||||
|
|
||||||
var base: String { converter.base }
|
|
||||||
|
|
||||||
init(base: String, accounts: [(id: String, name: String, currency: String)] = [], quotes: [RateQuote] = [], asOf: Date = .now) {
|
|
||||||
let base = base.uppercased()
|
|
||||||
converter = CurrencyConverter(base: base, quotes: quotes, asOf: asOf)
|
|
||||||
var currencies: [String: String] = [:], names: [String: String] = [:]
|
|
||||||
for account in accounts {
|
|
||||||
let key = account.id.lowercased()
|
|
||||||
currencies[key] = account.currency.isEmpty ? base : account.currency.uppercased()
|
|
||||||
names[key] = account.name
|
|
||||||
}
|
|
||||||
accountCurrencies = currencies
|
|
||||||
accountNames = names
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Валюта счёта; удалённый счёт или пустая валюта — базовая.
|
|
||||||
func accountCurrency(_ id: String?) -> String {
|
|
||||||
guard let id else { return base }
|
|
||||||
return accountCurrencies[id.lowercased()] ?? base
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `currency`, иначе валюта счёта, иначе базовая.
|
|
||||||
func currency(of entry: LedgerEntry) -> String {
|
|
||||||
if let code = entry.currency, !code.isEmpty { return code.uppercased() }
|
|
||||||
return accountCurrency(entry.accountID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Название счёта, «Удалённый счёт» или `nil` для «Без счёта».
|
|
||||||
func accountTitle(_ id: String?) -> String? {
|
|
||||||
guard let id else { return nil }
|
|
||||||
return accountNames[id.lowercased()] ?? "Удалённый счёт"
|
|
||||||
}
|
|
||||||
|
|
||||||
func missingRateWarning(_ missing: Set<String>) -> String? {
|
|
||||||
guard !missing.isEmpty else { return nil }
|
|
||||||
return missing.sorted().map { "нет курса \($0)→\(base)" }.joined(separator: ", ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="branding/banner.png" alt="BalanceWatch — личные финансы на Apple Watch" width="100%">
|
<img src="branding/banner.png" alt="BalanceWatch — учёт личных финансов на Apple Watch" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# BalanceWatch
|
# BalanceWatch
|
||||||
@@ -13,15 +13,13 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
BalanceWatch/
|
BalanceWatch/
|
||||||
├── branding/ # иконка, аватар и баннер (SVG-исходники, PNG, render.sh)
|
├── branding/ # аватар и баннер для README (PNG + SVG-исходники)
|
||||||
├── docs/ # скриншоты для баннера
|
|
||||||
├── docs/CONTRACT_V2.md # общий контракт экосистемы v2 (копия, одинаковая во всех репозиториях)
|
|
||||||
├── BalanceWatch.xcodeproj/
|
├── BalanceWatch.xcodeproj/
|
||||||
└── BalanceWatch/
|
└── BalanceWatch/
|
||||||
├── App/ # точка входа и экраны часов
|
├── App/ # точка входа и экраны часов
|
||||||
├── Models/ # SwiftData-модели (копия из монорепы)
|
├── Models/ # SwiftData-модели (копия из монорепы)
|
||||||
├── Shared/ # контейнер SwiftData, тема, ServerSync
|
├── Shared/ # контейнер SwiftData, тема, ServerSync
|
||||||
├── Utilities/ # FinanceCalculations, Money (сотые, курсы), форматирование валют
|
├── Utilities/ # FinanceCalculations, форматирование валют
|
||||||
├── Views/Components/ # CategoryIconView
|
├── Views/Components/ # CategoryIconView
|
||||||
└── Assets.xcassets # иконка приложения
|
└── Assets.xcassets # иконка приложения
|
||||||
```
|
```
|
||||||
@@ -30,24 +28,6 @@ BalanceWatch/
|
|||||||
любой `.swift`-файл, положенный в `BalanceWatch/`, автоматически попадает в
|
любой `.swift`-файл, положенный в `BalanceWatch/`, автоматически попадает в
|
||||||
таргет — править `project.pbxproj` вручную не нужно.
|
таргет — править `project.pbxproj` вручную не нужно.
|
||||||
|
|
||||||
## Контракт v2
|
|
||||||
|
|
||||||
Часы реализуют клиентскую часть `docs/CONTRACT_V2.md` в компактном виде:
|
|
||||||
|
|
||||||
- синхронизируются и хранятся счета, переводы, курсы и настройка
|
|
||||||
`baseCurrency`; общий баланс, аналитика и бюджеты считаются в базовой
|
|
||||||
валюте, при отсутствии курса показывается «нет курса C→B»;
|
|
||||||
- экран «Счета» (только чтение) с балансами счетов и «Без счёта»; при
|
|
||||||
добавлении операции можно выбрать счёт, валюта следует за счётом;
|
|
||||||
- общие бюджеты: в «Настройки → Сервер → Бюджет» выбирается личное или общее
|
|
||||||
пространство, можно присоединиться по коду; данные каждого пространства
|
|
||||||
лежат в отдельном хранилище;
|
|
||||||
- регулярные операции, цели, долги, вложения и теги хранятся и не теряются
|
|
||||||
при пересохранении, но интерфейса для них нет; регулярные операции часы не
|
|
||||||
создают;
|
|
||||||
- если `GET /v1/capabilities` отвечает 404 (сервер v1), счета и общие
|
|
||||||
бюджеты скрыты, новые сущности не отправляются.
|
|
||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
|
|
||||||
1. Открыть `BalanceWatch.xcodeproj` в Xcode 16 или новее.
|
1. Открыть `BalanceWatch.xcodeproj` в Xcode 16 или новее.
|
||||||
@@ -76,15 +56,3 @@ Simulator и watchOS device (без подписи). Запускается на
|
|||||||
кода из монорепозитория. Протокол синхронизации и правила расчётов должны
|
кода из монорепозитория. Протокол синхронизации и правила расчётов должны
|
||||||
совпадать с остальными клиентами Balance: при изменении контракта на
|
совпадать с остальными клиентами Balance: при изменении контракта на
|
||||||
сервере переносите правки и сюда.
|
сервере переносите правки и сюда.
|
||||||
|
|
||||||
## Оформление
|
|
||||||
|
|
||||||
Палитра «Лес» — та же, что у пресета по умолчанию в BalanceFlutter: карточка
|
|
||||||
баланса в зелёном градиенте, доходы бирюзовые (↙), расходы оранжевые (↗),
|
|
||||||
мятный акцент (`BalancePalette` в `Shared/AppTheme.swift`). Иконка — тот же
|
|
||||||
рисунок, что у Flutter-приложения (`branding/icon.svg`).
|
|
||||||
|
|
||||||
После правки SVG или скриншотов в `docs/` выполни `bash branding/render.sh`
|
|
||||||
(нужен `resvg`): скрипт перерисует все размеры иконки в
|
|
||||||
`Assets.xcassets/AppIcon.appiconset`, `branding/avatar.png` (512×512) и
|
|
||||||
`branding/banner.png` (1280×640). Шрифты Roboto лежат в `branding/fonts`.
|
|
||||||
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 56 KiB |
@@ -1,30 +1,60 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
<!-- Repository avatar: the watch with the app icon (palette «Лес»).
|
|
||||||
Render with branding/render.sh (needs resvg). -->
|
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="bg" cx="50%" cy="42%" r="75%">
|
<radialGradient id="bg" cx="50%" cy="40%" r="75%">
|
||||||
<stop offset="0" stop-color="#25594c"/>
|
<stop offset="0" stop-color="#2a2368"/>
|
||||||
<stop offset="1" stop-color="#0c241e"/>
|
<stop offset="1" stop-color="#0d0b26"/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
<linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0" stop-color="#173f35"/>
|
<stop offset="0" stop-color="#2b2660"/>
|
||||||
<stop offset="0.5" stop-color="#245a4b"/>
|
<stop offset="0.5" stop-color="#3b3580"/>
|
||||||
<stop offset="1" stop-color="#173f35"/>
|
<stop offset="1" stop-color="#2b2660"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0" stop-color="#5a9c87"/>
|
<stop offset="0" stop-color="#6d68c9"/>
|
||||||
<stop offset="1" stop-color="#1f5245"/>
|
<stop offset="1" stop-color="#3a3488"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<clipPath id="screen"><rect x="132" y="142" width="248" height="228" rx="58"/></clipPath>
|
<linearGradient id="teal" x1="0" y1="0" x2="1" y2="1">
|
||||||
<filter id="haze" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="40"/></filter>
|
<stop offset="0" stop-color="#2fc7b0"/>
|
||||||
|
<stop offset="1" stop-color="#3f6fd8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="coral" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff8a5c"/>
|
||||||
|
<stop offset="1" stop-color="#e2475f"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="ball" cx="38%" cy="35%" r="70%">
|
||||||
|
<stop offset="0" stop-color="#ffb08f"/>
|
||||||
|
<stop offset="0.5" stop-color="#f2614f"/>
|
||||||
|
<stop offset="1" stop-color="#b7323f"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
|
<feGaussianBlur stdDeviation="10"/>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
<!-- background -->
|
||||||
<rect width="512" height="512" fill="url(#bg)"/>
|
<rect width="512" height="512" fill="url(#bg)"/>
|
||||||
<ellipse cx="256" cy="256" rx="170" ry="150" fill="#39b997" opacity="0.22" filter="url(#haze)"/>
|
|
||||||
|
<!-- straps -->
|
||||||
<path d="M186 40 h140 l-10 100 h-120 z" fill="url(#strap)"/>
|
<path d="M186 40 h140 l-10 100 h-120 z" fill="url(#strap)"/>
|
||||||
<path d="M196 372 h120 l10 100 h-140 z" fill="url(#strap)"/>
|
<path d="M196 372 h120 l10 100 h-140 z" fill="url(#strap)"/>
|
||||||
<rect x="394" y="196" width="22" height="50" rx="8" fill="#4f8f7b"/>
|
|
||||||
<rect x="396" y="270" width="14" height="40" rx="6" fill="#3f7d6b"/>
|
<!-- crown and side button -->
|
||||||
<rect x="106" y="116" width="300" height="280" rx="78" fill="url(#case)"/>
|
<rect x="394" y="196" width="22" height="50" rx="8" fill="#5b56b8"/>
|
||||||
<rect x="124" y="134" width="264" height="244" rx="62" fill="#04120e"/>
|
<rect x="396" y="270" width="14" height="40" rx="6" fill="#4a4599"/>
|
||||||
<image x="132" y="132" width="248" height="248" xlink:href="../BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png" clip-path="url(#screen)"/>
|
|
||||||
|
<!-- case -->
|
||||||
|
<rect x="106" y="116" width="294" height="280" rx="78" fill="url(#case)"/>
|
||||||
|
<!-- screen -->
|
||||||
|
<rect x="124" y="134" width="258" height="244" rx="62" fill="#07061a"/>
|
||||||
|
|
||||||
|
<!-- soft glow behind the mark -->
|
||||||
|
<ellipse cx="253" cy="256" rx="80" ry="90" fill="#4c45c2" opacity="0.45" filter="url(#glow)"/>
|
||||||
|
|
||||||
|
<!-- balance mark: two loops and a ball, as in the app icon -->
|
||||||
|
<rect x="190" y="166" width="126" height="94" rx="47" fill="none" stroke="url(#teal)" stroke-width="28"/>
|
||||||
|
<rect x="190" y="252" width="126" height="94" rx="47" fill="none" stroke="url(#coral)" stroke-width="28"/>
|
||||||
|
<!-- teal loop overlaps coral on the left, like the original figure -->
|
||||||
|
<path d="M204 213 v40" stroke="url(#teal)" stroke-width="28" stroke-linecap="round"/>
|
||||||
|
<circle cx="253" cy="256" r="24" fill="url(#ball)"/>
|
||||||
|
<ellipse cx="246" cy="247" rx="8" ry="5" fill="#ffffff" opacity="0.35"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 171 KiB |
@@ -1,78 +1,80 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 640" width="1280" height="640" font-family="Roboto, sans-serif">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 560" width="1280" height="560">
|
||||||
<!-- Source of banner.png; same layout and palette («Лес») as the BalanceFlutter
|
|
||||||
banner. Render with branding/render.sh (needs resvg). Screenshots: docs/. -->
|
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="bg" cx="70%" cy="45%" r="85%">
|
<radialGradient id="bg" cx="30%" cy="45%" r="90%">
|
||||||
<stop offset="0" stop-color="#25594c"/>
|
<stop offset="0" stop-color="#2a2368"/>
|
||||||
<stop offset="1" stop-color="#0c241e"/>
|
<stop offset="1" stop-color="#0b0a22"/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#5a9c87"/>
|
|
||||||
<stop offset="1" stop-color="#1f5245"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0" stop-color="#143a31"/>
|
<stop offset="0" stop-color="#2b2660"/>
|
||||||
<stop offset="0.5" stop-color="#22574a"/>
|
<stop offset="0.5" stop-color="#3b3580"/>
|
||||||
<stop offset="1" stop-color="#143a31"/>
|
<stop offset="1" stop-color="#2b2660"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0" stop-color="#26b3a3"/>
|
<stop offset="0" stop-color="#6d68c9"/>
|
||||||
<stop offset="0.5" stop-color="#a2f2d8"/>
|
<stop offset="1" stop-color="#3a3488"/>
|
||||||
<stop offset="1" stop-color="#f4511e"/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
<linearGradient id="teal" x1="0" y1="0" x2="1" y2="1">
|
||||||
<feGaussianBlur stdDeviation="40"/>
|
<stop offset="0" stop-color="#2fc7b0"/>
|
||||||
|
<stop offset="1" stop-color="#3f6fd8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="coral" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff8a5c"/>
|
||||||
|
<stop offset="1" stop-color="#e2475f"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="title" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#ffffff"/>
|
||||||
|
<stop offset="1" stop-color="#c9c5ff"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="ball" cx="38%" cy="35%" r="70%">
|
||||||
|
<stop offset="0" stop-color="#ffb08f"/>
|
||||||
|
<stop offset="0.5" stop-color="#f2614f"/>
|
||||||
|
<stop offset="1" stop-color="#b7323f"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="10"/>
|
||||||
</filter>
|
</filter>
|
||||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
<filter id="haze" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#04120e" flood-opacity="0.7"/>
|
<feGaussianBlur stdDeviation="60"/>
|
||||||
</filter>
|
</filter>
|
||||||
<clipPath id="iconClip"><rect x="72" y="88" width="120" height="120" rx="28"/></clipPath>
|
|
||||||
<clipPath id="backScreen"><rect x="704" y="150" width="206" height="246" rx="44"/></clipPath>
|
|
||||||
<clipPath id="frontScreen"><rect x="884" y="228" width="250" height="298" rx="54"/></clipPath>
|
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<rect width="1280" height="640" fill="url(#bg)"/>
|
<rect width="1280" height="560" fill="url(#bg)"/>
|
||||||
<ellipse cx="930" cy="340" rx="300" ry="210" fill="#39b997" opacity="0.25" filter="url(#glow)"/>
|
<!-- ambient colour haze -->
|
||||||
|
<circle cx="330" cy="200" r="150" fill="#2fc7b0" opacity="0.18" filter="url(#haze)"/>
|
||||||
|
<circle cx="360" cy="380" r="150" fill="#e2475f" opacity="0.16" filter="url(#haze)"/>
|
||||||
|
|
||||||
<!-- left: identity -->
|
<!-- watch (same drawing as avatar.svg), centred at (340, 280) -->
|
||||||
<image x="72" y="88" width="120" height="120" xlink:href="../BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png" clip-path="url(#iconClip)"/>
|
<g transform="translate(340 280) scale(0.95) translate(-256 -256)">
|
||||||
<text x="68" y="306" font-size="84" font-weight="700" fill="#ffffff">BalanceWatch</text>
|
<path d="M186 40 h140 l-10 100 h-120 z" fill="url(#strap)"/>
|
||||||
<rect x="74" y="330" width="120" height="6" rx="3" fill="url(#accent)"/>
|
<path d="M196 372 h120 l10 100 h-140 z" fill="url(#strap)"/>
|
||||||
<text x="72" y="384" font-size="28" font-weight="500" fill="#a2f2d8">Личные финансы на Apple Watch</text>
|
<rect x="394" y="196" width="22" height="50" rx="8" fill="#5b56b8"/>
|
||||||
<text x="72" y="428" font-size="22" fill="#9cc4b6">Самостоятельное приложение для часов,</text>
|
<rect x="396" y="270" width="14" height="40" rx="6" fill="#4a4599"/>
|
||||||
<text x="72" y="458" font-size="22" fill="#9cc4b6">синхронизация с self-hosted BalanceServer</text>
|
<rect x="106" y="116" width="294" height="280" rx="78" fill="url(#case)"/>
|
||||||
|
<rect x="124" y="134" width="258" height="244" rx="62" fill="#07061a"/>
|
||||||
<g font-size="18" font-weight="500" fill="#dff7ee">
|
<ellipse cx="253" cy="256" rx="80" ry="90" fill="#4c45c2" opacity="0.45" filter="url(#glow)"/>
|
||||||
<rect x="72" y="494" width="128" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
|
<rect x="190" y="166" width="126" height="94" rx="47" fill="none" stroke="url(#teal)" stroke-width="28"/>
|
||||||
<text x="136" y="518" text-anchor="middle">watchOS 10+</text>
|
<rect x="190" y="252" width="126" height="94" rx="47" fill="none" stroke="url(#coral)" stroke-width="28"/>
|
||||||
<rect x="210" y="494" width="96" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
|
<circle cx="253" cy="256" r="24" fill="url(#ball)"/>
|
||||||
<text x="258" y="518" text-anchor="middle">SwiftUI</text>
|
<ellipse cx="246" cy="247" rx="8" ry="5" fill="#ffffff" opacity="0.35"/>
|
||||||
<rect x="316" y="494" width="112" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
|
|
||||||
<text x="372" y="518" text-anchor="middle">SwiftData</text>
|
|
||||||
<rect x="72" y="540" width="214" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
|
|
||||||
<text x="179" y="564" text-anchor="middle">Счета и валюты</text>
|
|
||||||
<rect x="296" y="540" width="186" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
|
|
||||||
<text x="389" y="564" text-anchor="middle">Общие бюджеты</text>
|
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- back watch: accounts screen -->
|
<!-- text -->
|
||||||
<path d="M752 60 h110 l-8 76 h-94 z" fill="url(#strap)"/>
|
<g font-family="Noto Sans, DejaVu Sans, sans-serif">
|
||||||
<path d="M760 410 h94 l8 76 h-110 z" fill="url(#strap)"/>
|
<text x="640" y="240" font-size="80" font-weight="700" fill="url(#title)" letter-spacing="-1">BalanceWatch</text>
|
||||||
<g filter="url(#shadow)">
|
<text x="644" y="300" font-size="32" fill="#b9b5e8">Учёт личных финансов на Apple Watch</text>
|
||||||
<rect x="918" y="216" width="14" height="40" rx="6" fill="#4f8f7b"/>
|
|
||||||
<rect x="690" y="136" width="234" height="274" rx="62" fill="url(#case)"/>
|
|
||||||
</g>
|
|
||||||
<rect x="700" y="146" width="214" height="254" rx="50" fill="#000000"/>
|
|
||||||
<image x="704" y="150" width="206" height="246" preserveAspectRatio="xMidYMid slice" xlink:href="../docs/accounts.png" clip-path="url(#backScreen)"/>
|
|
||||||
|
|
||||||
<!-- front watch: home screen -->
|
<!-- tags -->
|
||||||
<path d="M944 120 h130 l-10 92 h-110 z" fill="url(#strap)"/>
|
<g font-size="22" font-weight="600" fill="#e8e6ff">
|
||||||
<path d="M954 542 h110 l10 92 h-130 z" fill="url(#strap)"/>
|
<rect x="644" y="344" width="166" height="44" rx="22" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.18"/>
|
||||||
<g filter="url(#shadow)">
|
<text x="727" y="374" text-anchor="middle">watchOS 10+</text>
|
||||||
<rect x="1150" y="300" width="18" height="52" rx="8" fill="#4f8f7b"/>
|
<rect x="824" y="344" width="122" height="44" rx="22" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.18"/>
|
||||||
<rect x="1152" y="370" width="12" height="40" rx="5" fill="#3f7d6b"/>
|
<text x="885" y="374" text-anchor="middle">SwiftUI</text>
|
||||||
<rect x="868" y="212" width="282" height="330" rx="74" fill="url(#case)"/>
|
<rect x="960" y="344" width="142" height="44" rx="22" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.18"/>
|
||||||
|
<text x="1031" y="374" text-anchor="middle">SwiftData</text>
|
||||||
|
</g>
|
||||||
|
<g font-size="22" font-weight="600">
|
||||||
|
<rect x="644" y="402" width="288" height="44" rx="22" fill="#2fc7b0" fill-opacity="0.16" stroke="#2fc7b0" stroke-opacity="0.5"/>
|
||||||
|
<text x="788" y="432" text-anchor="middle" fill="#8ff0df">Sync с BalanceServer</text>
|
||||||
|
</g>
|
||||||
</g>
|
</g>
|
||||||
<rect x="880" y="224" width="258" height="306" rx="60" fill="#000000"/>
|
|
||||||
<image x="884" y="228" width="250" height="298" preserveAspectRatio="xMidYMid slice" xlink:href="../docs/home.png" clip-path="url(#frontScreen)"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -1,203 +0,0 @@
|
|||||||
Copyright 2011 Google Inc. All Rights Reserved.
|
|
||||||
|
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright [yyyy] [name of copyright owner]
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
|
||||||
<!-- Watch app icon, same artwork as BalanceFlutter: the in-app wallet mark in the «Лес» palette. Income (teal) and
|
|
||||||
expense (deep orange) cards stick out of the wallet. -->
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="0" y1="0" x2="0.35" y2="1">
|
|
||||||
<stop offset="0" stop-color="#3f7d6b"/>
|
|
||||||
<stop offset="1" stop-color="#1a4a3e"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0" stop-color="#bdf7e3"/>
|
|
||||||
<stop offset="1" stop-color="#8fe6c9"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="income" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#26b3a3"/>
|
|
||||||
<stop offset="1" stop-color="#00897b"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="expense" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#ff8a5c"/>
|
|
||||||
<stop offset="1" stop-color="#f4511e"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
|
||||||
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b2620" flood-opacity="0.45"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect width="1024" height="1024" fill="url(#bg)"/>
|
|
||||||
|
|
||||||
<g filter="url(#shadow)">
|
|
||||||
<!-- cards behind the wallet; arrows match the app's income/expense icons -->
|
|
||||||
<g transform="rotate(-10 400 330)">
|
|
||||||
<rect x="236" y="176" width="320" height="300" rx="36" fill="url(#income)"/>
|
|
||||||
<path d="M454 236 L338 352 M338 262 V352 H428" fill="none" stroke="#ffffff" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</g>
|
|
||||||
<g transform="rotate(10 624 330)">
|
|
||||||
<rect x="468" y="176" width="320" height="300" rx="36" fill="url(#expense)"/>
|
|
||||||
<path d="M570 352 L686 236 M596 236 H686 V326" fill="none" stroke="#ffffff" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- wallet -->
|
|
||||||
<rect x="196" y="420" width="632" height="420" rx="72" fill="url(#body)"/>
|
|
||||||
<!-- clasp tab -->
|
|
||||||
<rect x="590" y="560" width="268" height="152" rx="76" fill="#116b57"/>
|
|
||||||
<circle cx="666" cy="636" r="30" fill="#bdf7e3"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Re-renders the watch app icon (every size in AppIcon.appiconset), avatar.png
|
|
||||||
# and banner.png from their SVG sources. Requires resvg
|
|
||||||
# (https://github.com/linebender/resvg). Uses the bundled Roboto fonts, so the
|
|
||||||
# result does not depend on system fonts. icon.svg is the same artwork as the
|
|
||||||
# BalanceFlutter app icon (palette «Лес»).
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
|
||||||
fonts=(
|
|
||||||
--skip-system-fonts
|
|
||||||
--use-font-file fonts/Roboto-Regular.ttf
|
|
||||||
--use-font-file fonts/Roboto-Medium.ttf
|
|
||||||
--use-font-file fonts/Roboto-Bold.ttf
|
|
||||||
)
|
|
||||||
icons=../BalanceWatch/Assets.xcassets/AppIcon.appiconset
|
|
||||||
for file in "$icons"/BalanceIcon-*.png; do
|
|
||||||
size="${file##*-}"; size="${size%.png}"
|
|
||||||
resvg -w "$size" -h "$size" icon.svg "$file"
|
|
||||||
done
|
|
||||||
resvg "${fonts[@]}" -w 512 -h 512 avatar.svg avatar.png
|
|
||||||
resvg "${fonts[@]}" banner.svg banner.png
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
# Контракт экосистемы v2
|
|
||||||
|
|
||||||
> Общий документ BalanceServer, BalanceFlutter и BalanceWatch. Одинаковая
|
|
||||||
> копия лежит в каждом репозитории: `docs/ecosystem/CONTRACT_V2.md`
|
|
||||||
> (Flutter), `docs/CONTRACT_V2.md` (сервер, часы). Меняется только во всех
|
|
||||||
> трёх сразу.
|
|
||||||
|
|
||||||
v2 добавляет счета и переводы, регулярные операции, мультивалютность, цели,
|
|
||||||
теги, вложения (фото чеков), общие бюджеты и долги. Всё сделано
|
|
||||||
**добавлением**: формат `/v1/sync`, курсор, `sequence`, `updatedAt`,
|
|
||||||
tombstone и last-write-wins не меняются. Клиент v1 продолжает работать с
|
|
||||||
сервером v2, но не видит новых данных (ограничения — в конце).
|
|
||||||
|
|
||||||
## 0. Общие правила
|
|
||||||
|
|
||||||
1. **Незнакомая сущность** при pull пропускается молча (курсор двигается).
|
|
||||||
2. **Незнакомые поля** payload знакомой сущности клиент v2 **сохраняет** и
|
|
||||||
отправляет обратно без изменений при следующем сохранении записи
|
|
||||||
(forward compatibility). Клиент v1 их теряет — поэтому обновлять нужно все
|
|
||||||
клиенты.
|
|
||||||
3. Все новые поля payload необязательные. Отсутствие поля = значение по
|
|
||||||
умолчанию, указанное ниже.
|
|
||||||
4. Деньги на проводе — числа (`amount`, `limit`, `target`…), как в v1;
|
|
||||||
внутри клиентов — целые сотые. Курс валют — **строка** десятичного числа.
|
|
||||||
5. Даты — ISO 8601 UTC. Интервалы — `[начало, конец)`.
|
|
||||||
6. Коды валют — ISO 4217 в верхнем регистре (`RUB`, `EUR`, `USD`, `SEK`, …).
|
|
||||||
7. Новые записи получают UUID в нижнем регистре (кроме `setting`, где `id` —
|
|
||||||
ключ). Сервер сравнивает `id` как есть, с учётом регистра, поэтому клиент
|
|
||||||
хранит `id` полученной записи в точности как пришёл и отправляет его так же
|
|
||||||
(старые клиенты могли создавать UUID в верхнем регистре).
|
|
||||||
|
|
||||||
### Возможности сервера
|
|
||||||
|
|
||||||
`GET /v1/capabilities` (без авторизации):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"contract": 2,
|
|
||||||
"entities": ["transaction","category","budget","account","transfer",
|
|
||||||
"recurring","goal","debt","rate","setting","attachment"],
|
|
||||||
"attachments": {"maxBytes": 10485760,
|
|
||||||
"types": ["image/jpeg","image/png","image/heic","application/pdf"]},
|
|
||||||
"ledgers": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Сервер v1 отвечает 404 — клиент v2 тогда скрывает вложения и общие бюджеты и
|
|
||||||
не отправляет новые сущности.
|
|
||||||
|
|
||||||
## 1. Сущности
|
|
||||||
|
|
||||||
Белый список сервера: `transaction`, `category`, `budget`, `account`,
|
|
||||||
`transfer`, `recurring`, `goal`, `debt`, `rate`, `setting`, `attachment`.
|
|
||||||
|
|
||||||
### transaction — новые поля
|
|
||||||
|
|
||||||
| Поле | Тип | По умолчанию | Смысл |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `accountId` | string? | нет | счёт; без него — «Без счёта» |
|
|
||||||
| `currency` | string? | валюта счёта, иначе базовая | валюта суммы |
|
|
||||||
| `tags` | [string]? | `[]` | теги, см. §6 |
|
|
||||||
| `recurringId` | string? | нет | создана из регулярной операции |
|
|
||||||
| `occurrence` | string? | нет | дата повторения `YYYY-MM-DD` |
|
|
||||||
|
|
||||||
`kindRawValue` остаётся `income` / `expense`. Перевод — **не** транзакция.
|
|
||||||
|
|
||||||
### account
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"name":"Карта","kindRawValue":"card","icon":"creditcard.fill",
|
|
||||||
"colorName":"blue","currency":"RUB","createdAt":"…","isArchived":false,
|
|
||||||
"sortOrder":0}
|
|
||||||
```
|
|
||||||
|
|
||||||
`kindRawValue`: `cash` | `card` | `deposit` | `other` (незнакомое → `other`).
|
|
||||||
Клиент v2 записывает `currency` явно при создании счёта. Отсутствие поля
|
|
||||||
(старая запись) = текущая базовая валюта.
|
|
||||||
|
|
||||||
### transfer
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"fromAccountId":"…","toAccountId":"…","amount":1000.0,"toAmount":10.5,
|
|
||||||
"date":"…","note":""}
|
|
||||||
```
|
|
||||||
|
|
||||||
`toAmount` нужен, когда валюты счетов различаются; по умолчанию = `amount`.
|
|
||||||
`amount` — в валюте счёта-источника, `toAmount` — в валюте счёта-получателя.
|
|
||||||
|
|
||||||
### recurring
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"kindRawValue":"expense","amount":990.0,"currency":"RUB","accountId":null,
|
|
||||||
"categoryName":"Подписки","categoryIcon":"repeat","categoryEmoji":"",
|
|
||||||
"categoryColorName":"cyan","note":"Связь","tags":[],
|
|
||||||
"frequency":"monthly","interval":1,"startDate":"2026-01-05T09:00:00Z",
|
|
||||||
"endDate":null,"mode":"auto","isPaused":false,"generatedThrough":null}
|
|
||||||
```
|
|
||||||
|
|
||||||
`frequency`: `daily` | `weekly` | `monthly` | `yearly`; `interval` ≥ 1;
|
|
||||||
`mode`: `auto` (создаётся само) | `remind` (напоминание, пользователь
|
|
||||||
подтверждает). `generatedThrough` — дата `YYYY-MM-DD` последнего
|
|
||||||
обработанного повторения.
|
|
||||||
|
|
||||||
### goal
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"name":"Отпуск","target":150000.0,"currency":"RUB","deadline":null,
|
|
||||||
"accountId":null,"saved":20000.0,"icon":"airplane","colorName":"sky",
|
|
||||||
"createdAt":"…","isArchived":false}
|
|
||||||
```
|
|
||||||
|
|
||||||
Прогресс = баланс счёта `accountId`, если он задан, иначе `saved`.
|
|
||||||
|
|
||||||
### debt
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"person":"Иван","direction":"owedToMe","amount":5000.0,"currency":"RUB",
|
|
||||||
"date":"…","dueDate":null,"note":"","repaid":1000.0,"isClosed":false}
|
|
||||||
```
|
|
||||||
|
|
||||||
`direction`: `iOwe` | `owedToMe`. Остаток = `amount − repaid`. Долги **не**
|
|
||||||
входят в баланс и аналитику: деньги, которые реально ушли или пришли,
|
|
||||||
пользователь записывает обычной операцией.
|
|
||||||
|
|
||||||
### rate
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"from":"USD","to":"RUB","rate":"92.35","date":"2026-10-31T00:00:00Z"}
|
|
||||||
```
|
|
||||||
|
|
||||||
`rate` — сколько единиц `to` стоит одна единица `from`, до 6 знаков после
|
|
||||||
точки.
|
|
||||||
|
|
||||||
### setting
|
|
||||||
|
|
||||||
`id` — ключ, payload `{"value": …}`. Сейчас ключ один: `baseCurrency`
|
|
||||||
(строка ISO 4217) — валюта, в которой считаются общие суммы. Если записи нет,
|
|
||||||
базовая валюта — локальная настройка устройства (поведение v1).
|
|
||||||
|
|
||||||
### attachment
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"transactionId":"…","sha256":"<64 hex>","contentType":"image/jpeg",
|
|
||||||
"size":182345,"name":"чек.jpg","createdAt":"…"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Метаданные синхронизируются обычным `/v1/sync`; сами байты — через §8.
|
|
||||||
|
|
||||||
## 2. Валюты и курсы
|
|
||||||
|
|
||||||
- Валюта транзакции: `currency`, иначе валюта её счёта, иначе базовая.
|
|
||||||
- Перевод суммы `x` из `C` в базовую `B`: при `C == B` без изменений; иначе
|
|
||||||
берётся запись `rate` с `from=C,to=B` или обратная `from=B,to=C` (тогда
|
|
||||||
делим). Выбор: самая поздняя `date` не позже даты расчёта (курсы с будущей
|
|
||||||
датой не используются); при равной дате прямая пара важнее обратной; затем
|
|
||||||
более поздний `updatedAt`.
|
|
||||||
- Результат округляется до сотых половиной вверх (от нуля). Считать через
|
|
||||||
целые сотые и десятичную строку курса, не через `double`-накопление.
|
|
||||||
- Нет курса → сумма **не входит** в общие итоги, клиент показывает
|
|
||||||
предупреждение «нет курса C→B». Внутри счёта суммы складываются в валюте
|
|
||||||
счёта без конвертации.
|
|
||||||
- Курс для периода — последний на дату расчёта (не на дату операции).
|
|
||||||
|
|
||||||
## 3. Баланс (расширяет финансовые правила v1)
|
|
||||||
|
|
||||||
- Баланс счёта (в его валюте) = доходы − расходы с этим `accountId`
|
|
||||||
(включая корректировки) + `toAmount` входящих переводов − `amount`
|
|
||||||
исходящих.
|
|
||||||
- «Без счёта» = транзакции без `accountId` (в своих валютах).
|
|
||||||
- Общий баланс = сумма всех транзакций в базовой валюте + переводы
|
|
||||||
(входящая часть − исходящая, каждая в базовой валюте). При одной валюте
|
|
||||||
переводы дают ноль, и общий баланс совпадает с v1.
|
|
||||||
- Переводы не входят в аналитику и бюджеты. Корректировки — как в v1.
|
|
||||||
- Аналитика и бюджеты складывают суммы в базовой валюте.
|
|
||||||
- Архивный счёт скрыт из выбора, но его баланс входит в общий.
|
|
||||||
- Транзакции и переводы, ссылающиеся на удалённый счёт, остаются; клиент
|
|
||||||
показывает их как «Удалённый счёт».
|
|
||||||
|
|
||||||
## 4. Регулярные операции
|
|
||||||
|
|
||||||
- Повторения считаются в календаре **UTC** от даты `startDate` (её UTC-дата
|
|
||||||
и время суток): `daily` — каждые `interval` дней, `weekly` — каждые
|
|
||||||
`7·interval` дней, `monthly` — каждые `interval` месяцев в тот же день
|
|
||||||
(если дня нет — последний день месяца), `yearly` — то же с годами
|
|
||||||
(29 февраля → 28 февраля). `endDate` сравнивается по UTC-дате и включается:
|
|
||||||
повторение в день `endDate` создаётся, после — нет.
|
|
||||||
- `occurrence` = UTC-дата `YYYY-MM-DD`; дата транзакции = эта дата + время
|
|
||||||
суток `startDate`.
|
|
||||||
- ID транзакции **детерминирован**: UUID v5 от пространства имён
|
|
||||||
`6f1c3f5e-8a8d-4c3a-9d2b-4b1f0e7a2c11` и строки
|
|
||||||
`recurring:<recurringId>:<occurrence>` (нижний регистр). Два устройства
|
|
||||||
создают одну и ту же запись — дублей нет.
|
|
||||||
- `auto`: клиент создаёт все повторения в `(generatedThrough, сегодня UTC]`
|
|
||||||
(с `startDate`, если `generatedThrough` пуст) и ставит
|
|
||||||
`generatedThrough` = последнее обработанное. Удалённое пользователем
|
|
||||||
повторение не воскрешается, т.к. `generatedThrough` уже позади. Не более
|
|
||||||
366 повторений за раз.
|
|
||||||
- `remind`: клиент показывает повторения из того же окна как «ожидают»;
|
|
||||||
«Записать» создаёт транзакцию с тем же детерминированным ID и двигает
|
|
||||||
`generatedThrough` только через подряд обработанные повторения (если
|
|
||||||
записать более позднее раньше, более раннее остаётся ожидать).
|
|
||||||
«Пропустить» двигает `generatedThrough` до этого повторения включительно —
|
|
||||||
более ранние ожидающие тоже считаются пропущенными.
|
|
||||||
- `isPaused` — ничего не создаётся и не напоминается.
|
|
||||||
- Часы регулярные операции не создают (их создают телефон и десктоп).
|
|
||||||
|
|
||||||
## 5. Цели, долги — см. §1. Цели и долги не меняют баланс.
|
|
||||||
|
|
||||||
## 6. Теги
|
|
||||||
|
|
||||||
Строки после `trim`, 1–32 символа, без учёта регистра уникальны в операции,
|
|
||||||
не больше 10 на операцию. Порядок сохраняется. Аналитика и бюджеты тегами не
|
|
||||||
фильтруются; список операций — фильтруется.
|
|
||||||
|
|
||||||
## 7. Общие бюджеты (ledgers)
|
|
||||||
|
|
||||||
Пространство данных (ledger) — отдельный набор записей со своим курсором.
|
|
||||||
У каждого пользователя есть **личное** пространство (как в v1). Общее
|
|
||||||
пространство создаёт владелец и приглашает участников по коду.
|
|
||||||
|
|
||||||
| Метод | Путь | Тело / ответ |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| GET | `/v1/ledgers` | `{"ledgers":[{"id","name","role","members":[{"userId","email","role"}]}]}` — только общие |
|
|
||||||
| POST | `/v1/ledgers` | `{"name"}` → ledger, `role:"owner"` |
|
|
||||||
| PATCH | `/v1/ledgers/{id}` | `{"name"}` (владелец) |
|
|
||||||
| DELETE | `/v1/ledgers/{id}` | владелец: удалить пространство со всеми записями и вложениями |
|
|
||||||
| POST | `/v1/ledgers/{id}/invites` | → `{"code","expiresAt"}`; код из 10 символов, 7 дней, одноразовый (владелец) |
|
|
||||||
| POST | `/v1/ledgers/join` | `{"code"}` → ledger, `role:"member"` |
|
|
||||||
| DELETE | `/v1/ledgers/{id}/members/{userId}` | владелец удаляет участника; участник — себя (`userId` = свой) |
|
|
||||||
|
|
||||||
- Пространство выбирается заголовком `X-Balance-Ledger: <id>` на
|
|
||||||
`/v1/sync` и `/v1/attachments/*`. Без заголовка — личное пространство.
|
|
||||||
Не участник → `403 forbidden`, несуществующее → `404 not_found`.
|
|
||||||
- Владелец не может выйти (`409 owner_cannot_leave`), даже из пустого
|
|
||||||
пространства — только удалить его.
|
|
||||||
- Ответы: создание пространства и приглашения — `201`; вход по коду и
|
|
||||||
переименование — `200`; удаление пространства и участника — `204`.
|
|
||||||
Повторный вход участника возвращает пространство и не тратит код.
|
|
||||||
`/v1/ledgers/join` ограничен по частоте вместе со входом (30 запросов в
|
|
||||||
минуту с IP).
|
|
||||||
- Клиент, получивший `403`/`404` для выбранного пространства, может сам
|
|
||||||
вернуться в личное (Flutter) или показать ошибку и предложить переключиться
|
|
||||||
(часы).
|
|
||||||
- Все участники равны в правах на записи (last-write-wins как в v1).
|
|
||||||
- Клиент хранит данные пространства отдельно: scope = сервер + пользователь
|
|
||||||
+ пространство. Переключение пространства не переносит записи. Курсор и
|
|
||||||
очередь изменений — свои у каждого пространства.
|
|
||||||
- Ошибки: `invalid_code` (400), `invalid_name` (400: пустое после `trim`,
|
|
||||||
длиннее 100 символов или с управляющими символами), `invalid_json` (400),
|
|
||||||
`forbidden` (403), `not_found` (404, в том числе удаление не-участника),
|
|
||||||
`owner_cannot_leave` (409). Коды приглашений хранятся на сервере только
|
|
||||||
как хеш.
|
|
||||||
|
|
||||||
## 8. Вложения
|
|
||||||
|
|
||||||
| Метод | Путь | Смысл |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| PUT | `/v1/attachments/{sha256}` | тело — байты файла, `Content-Type` из списка; сервер проверяет SHA-256 и размер; повтор идемпотентен (`200`, первый раз `201`) |
|
|
||||||
| GET | `/v1/attachments/{sha256}` | байты и `Content-Type`; `404`, если в пространстве нет такого файла |
|
|
||||||
|
|
||||||
- Порядок клиента: сначала PUT байтов, потом запись `attachment` через sync.
|
|
||||||
- Файлы хранятся в пространстве; один и тот же файл в двух пространствах
|
|
||||||
хранится дважды.
|
|
||||||
- Ответ PUT: `{"sha256","size","contentType"}`. GET отдаёт
|
|
||||||
`Content-Disposition: attachment`.
|
|
||||||
- Когда в пространстве не осталось живых записей `attachment` с этим
|
|
||||||
`sha256`, сервер может удалить файл. Файл, на который ещё не ссылалась ни
|
|
||||||
одна запись, хранится не меньше 24 часов после загрузки (клиент загружает
|
|
||||||
байты раньше записи).
|
|
||||||
- Лимит тела 10 МиБ действует только для загрузки вложения; остальные
|
|
||||||
запросы — 2 МиБ, как в v1.
|
|
||||||
- Ошибки: `payload_too_large` (413), `unsupported_media_type` (415),
|
|
||||||
`hash_mismatch` (400, в том числе неверный формат хеша в пути).
|
|
||||||
- Клиент скачивает файл лениво при просмотре и кеширует локально.
|
|
||||||
- Сообщение ошибки для незнакомой сущности на сервере осталось как в v1
|
|
||||||
(«entity must be transaction, category, or budget») ради совместимости с
|
|
||||||
записанными ответами; код `invalid_change` верный.
|
|
||||||
|
|
||||||
## 9. Совместимость с клиентами v1
|
|
||||||
|
|
||||||
- Старый клиент не видит счета, переводы, цели, долги, курсы, вложения и
|
|
||||||
общие пространства; транзакции в других валютах складывает как есть.
|
|
||||||
- При редактировании транзакции старый клиент теряет `accountId`,
|
|
||||||
`currency`, `tags`, `recurringId`, `occurrence`. Поэтому все клиенты
|
|
||||||
экосистемы обновляются вместе с сервером.
|
|
||||||
- Нативные iOS/macOS и Android-клиенты (не входят в эти репозитории) остаются
|
|
||||||
на v1 до своего обновления.
|
|
||||||
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 102 KiB |