diff --git a/AGENTS.md b/AGENTS.md index 5a23782..affd06f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,19 +28,25 @@ BalanceWatch/ ├── AGENTS.md ├── README.md ├── .github/workflows/build.yml # CI: сборка для симулятора и устройства -├── branding/ # аватар репозитория, не входит в бандл +├── docs/CONTRACT_V2.md # общий контракт экосистемы v2 (копия) +├── branding/ # icon.svg, avatar, banner, render.sh; не входит в бандл +├── docs/ # CONTRACT_V2.md, скриншоты для баннера ├── BalanceWatch.xcodeproj/ # один таргет и одна схема — BalanceWatch └── BalanceWatch/ ├── App/ │ ├── BalanceWatchApp.swift # @main, ModelContainer, тема │ ├── WatchRootView.swift # главный экран: баланс, месяц, навигация - │ ├── WatchAddTransactionView.swift # добавление операции + │ ├── WatchAddTransactionView.swift # добавление операции (+ выбор счёта) + │ ├── WatchAccountsView.swift # CurrencyContext в окружении, счета, + │ │ # выбор пространства (ledger) │ └── WatchFeatures.swift # аналитика, операции, бюджеты, │ # категории, корректировка баланса, настройки - ├── Models/ # SwiftData-модели и системные категории + ├── Models/ # SwiftData-модели и системные категории; + │ # V2Records.swift — счета, переводы, курсы, SyncRecord ├── Shared/ # BalanceModelContainer, AppTheme, │ # CategoryDesignOptions, ServerSync (ServerAccountStore) - ├── Utilities/ # FinanceCalculations, форматирование валют и дат + ├── Utilities/ # FinanceCalculations, Money (сотые, курсы, конвертация), + │ # форматирование валют и дат ├── Views/Components/CategoryIconView.swift └── Assets.xcassets ``` @@ -81,7 +87,20 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`), ### 2. SwiftData Схема зарегистрирована в `BalanceModelContainer.schema`: -`FinanceTransaction`, `MonthlyBudget`, `CustomCategory`, `SyncTombstone`. +`FinanceTransaction`, `MonthlyBudget`, `CustomCategory`, `SyncTombstone`, +`FinanceAccount`, `FinanceTransfer`, `ExchangeRate`, `SyncRecord`. + +- Каждое пространство данных (§7 контракта) — отдельное хранилище: + личное — прежнее хранилище по умолчанию, общее — + `BalanceLedger-`, где 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-поля без стратегии миграции; новые поля делай со значением @@ -107,13 +126,18 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`), Центральные правила — в `FinanceCalculations`: -- общий баланс = доходы − расходы; +- суммы считаются в целых сотых (`Money`), курсы — десятичные строки, + округление половиной от нуля; интервалы полуоткрытые `[начало, конец)`; +- общий баланс = доходы − расходы + переводы, всё в базовой валюте + (`CurrencyContext`: `setting baseCurrency`, иначе `@AppStorage("currencyCode")`); + нет курса — сумма не входит, показывается «нет курса C→B»; +- баланс счёта — в его валюте без конвертации; - операции с `isBalanceAdjustment == true` входят в общий баланс, но **не** входят в периодическую аналитику (месяц/3 мес./12 мес.); - расходы группируются по `categoryName`. -Эти правила **одинаковы во всех клиентах Balance** (Apple, Android, web). -Не меняй их только здесь. +Эти правила **одинаковы во всех клиентах Balance** (Apple, Android, web) +и описаны в `docs/CONTRACT_V2.md` §2–3. Не меняй их только здесь. ### 5. Синхронизация (общий контракт) @@ -129,7 +153,15 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`), изменения сервера и остальных клиентов сломает синхронизацию. - При смене URL сервера сессия очищается, cursor/push-state сбрасываются, пользователь логинится заново (`saveServer`). Сохраняй - это поведение. + это поведение. Смена сервера и выход возвращают личное пространство. +- v2: перед синхронизацией запрашивается `GET /v1/capabilities`; 404 — + сервер v1, тогда `supportsV2 == false`: счета и общие бюджеты скрыты, + `setting` не отправляется. Незнакомые сущности при pull пропускаются. +- Общее пространство передаётся заголовком `X-Balance-Ledger` на `v1/sync`; + cursor, push-state и журнал удалений имеют суффикс `LedgerSelection.keySuffix`. +- Из сущностей v2 часы отправляют только `setting` (базовая валюта); + счета, переводы, курсы, регулярные операции и прочее только читают. + Регулярные операции часы не создают. ### 6. Безопасность @@ -162,7 +194,10 @@ CI (`.github/workflows/build.yml`) собирает проект на `macos-15` симулятора и для устройства без подписи. Перед PR сборка должна быть зелёной в обоих вариантах. -Тестового таргета пока нет. Если добавляешь тесты, не завязывай их на +Тестового таргета пока нет. `Utilities/Money.swift` не зависит от +SwiftData/SwiftUI, а `FinanceCalculations` работает через протоколы +`LedgerEntry`/`LedgerTransfer`, поэтому денежную математику можно проверить +отдельным `swiftc`-прогоном на macOS (с заглушкой `FinanceTransaction`). Если добавляешь тесты, не завязывай их на текущую дату и локаль — явно фиксируй `Calendar`, `Date` и formatter'ы. Добавив тестовый таргет, добавь и шаг `test` в CI. @@ -176,3 +211,11 @@ CI (`.github/workflows/build.yml`) собирает проект на `macos-15` - [ ] Секреты и токены не попали в репозиторий и логи. - [ ] CI собирает проект для симулятора и для устройства. - [ ] README/AGENTS.md обновлены, если поменялась структура или сборка. + +## Оформление + +- Цвета интерфейса — только из `BalancePalette` (`Shared/AppTheme.swift`), + палитра «Лес», общая с BalanceFlutter. Не возвращай indigo/purple. +- Иконка, аватар и баннер рендерятся из SVG: `bash branding/render.sh` + (нужен `resvg`). PNG в `AppIcon.appiconset` руками не правь. +- Аватар репозитория в Gitea обновляется из `branding/avatar.png`. diff --git a/BalanceWatch.xcodeproj/project.pbxproj b/BalanceWatch.xcodeproj/project.pbxproj index 90f89ec..07fd414 100644 --- a/BalanceWatch.xcodeproj/project.pbxproj +++ b/BalanceWatch.xcodeproj/project.pbxproj @@ -238,7 +238,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = K3AS449A74; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -247,7 +247,7 @@ INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных."; INFOPLIST_KEY_WKWatchKitApp = YES; INFOPLIST_KEY_WKWatchOnly = YES; - MARKETING_VERSION = 3.1.1; + MARKETING_VERSION = 3.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -265,7 +265,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 14; + CURRENT_PROJECT_VERSION = 15; DEVELOPMENT_TEAM = K3AS449A74; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Баланс"; @@ -273,7 +273,7 @@ INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных."; INFOPLIST_KEY_WKWatchKitApp = YES; INFOPLIST_KEY_WKWatchOnly = YES; - MARKETING_VERSION = 3.1.1; + MARKETING_VERSION = 3.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; diff --git a/BalanceWatch/App/BalanceWatchApp.swift b/BalanceWatch/App/BalanceWatchApp.swift index 896f9c3..e1aae93 100644 --- a/BalanceWatch/App/BalanceWatchApp.swift +++ b/BalanceWatch/App/BalanceWatchApp.swift @@ -3,15 +3,17 @@ import SwiftUI @main struct BalanceWatchApp: App { - private let modelContainer = BalanceModelContainer.make() + private let containers = LedgerContainers() @AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue + @AppStorage(LedgerSelection.scopeKey) private var ledgerScope = "" var body: some Scene { WindowGroup { WatchRootView() - .tint(.indigo) + .id(ledgerScope) + .tint(BalancePalette.accent) .preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme) + .modelContainer(containers.container(for: ledgerScope)) } - .modelContainer(modelContainer) } } diff --git a/BalanceWatch/App/WatchAccountsView.swift b/BalanceWatch/App/WatchAccountsView.swift new file mode 100644 index 0000000..feb0796 --- /dev/null +++ b/BalanceWatch/App/WatchAccountsView.swift @@ -0,0 +1,136 @@ +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: View { + @Query private var accounts: [FinanceAccount] + @Query private var rates: [ExchangeRate] + @Query(filter: #Predicate { $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) + } +} diff --git a/BalanceWatch/App/WatchAddTransactionView.swift b/BalanceWatch/App/WatchAddTransactionView.swift index 49bfceb..42a4e57 100644 --- a/BalanceWatch/App/WatchAddTransactionView.swift +++ b/BalanceWatch/App/WatchAddTransactionView.swift @@ -4,8 +4,12 @@ import SwiftUI struct WatchAddTransactionView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext + @Environment(\.currencyContext) private var currency + @StateObject private var account = ServerAccountStore.shared @Query(sort: \CustomCategory.createdAt) private var customCategories: [CustomCategory] + @Query(sort: [SortDescriptor(\FinanceAccount.sortOrder), SortDescriptor(\FinanceAccount.name)]) + private var accounts: [FinanceAccount] private let transaction: FinanceTransaction? @State private var kind: TransactionKind @@ -13,6 +17,8 @@ struct WatchAddTransactionView: View { @State private var categoryName: String @State private var date: Date @State private var note: String + /// Пустая строка — «Без счёта». + @State private var accountID: String init(transaction: FinanceTransaction? = nil) { self.transaction = transaction @@ -23,6 +29,31 @@ struct WatchAddTransactionView: View { ?? FinanceCategory.categories(for: initialKind)[0].name) _date = State(initialValue: transaction?.date ?? .now) _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] { @@ -54,6 +85,13 @@ struct WatchAddTransactionView: View { 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) { ForEach(categories) { category in Text((category.emoji.isEmpty ? "" : category.emoji + " ") + category.name) @@ -80,7 +118,10 @@ struct WatchAddTransactionView: View { } private func save() { + let amount = Money.double(Money.cents(amount)) guard amount > 0 else { return } + let newAccount = accountID.isEmpty ? nil : accountID + let newCurrency = account.supportsV2 ? effectiveCurrency : nil let category = categories.first { $0.name == categoryName } ?? FinanceCategory.categories(for: kind)[0] if let transaction { @@ -92,6 +133,10 @@ struct WatchAddTransactionView: View { transaction.categoryColorName = category.colorName transaction.date = date transaction.note = note + if transaction.accountID != newAccount { + transaction.accountID = newAccount + transaction.currency = newCurrency + } transaction.syncUpdatedAt = .now } else { modelContext.insert(FinanceTransaction( @@ -102,7 +147,9 @@ struct WatchAddTransactionView: View { categoryIcon: category.icon, categoryEmoji: category.emoji, categoryColorName: category.colorName, - kind: kind + kind: kind, + accountID: newAccount, + currency: newCurrency )) } dismiss() diff --git a/BalanceWatch/App/WatchFeatures.swift b/BalanceWatch/App/WatchFeatures.swift index 162b6f7..33053f9 100644 --- a/BalanceWatch/App/WatchFeatures.swift +++ b/BalanceWatch/App/WatchFeatures.swift @@ -29,17 +29,19 @@ private enum WatchAnalyticsPeriod: String, CaseIterable, Identifiable { struct WatchAnalyticsView: View { @Query private var transactions: [FinanceTransaction] - @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @Environment(\.currencyContext) private var currency @State private var period = WatchAnalyticsPeriod.month + private var currencyCode: String { currency.base } private var interval: DateInterval { period.interval() } private var summary: FinancePeriodSummary { - FinanceCalculations.summary(transactions: transactions, in: interval) + FinanceCalculations.summary(transactions: transactions, in: interval, context: currency) } private var spending: [CategorySpending] { Array(FinanceCalculations.spendingByCategory( transactions: transactions, - in: interval + in: interval, + context: currency ).prefix(6)) } private var savingsRate: Double { @@ -53,13 +55,17 @@ struct WatchAnalyticsView: View { } Section("Итоги") { - metric("Доходы", summary.income, .green) - metric("Расходы", summary.expenses, .red) + metric("Доходы", summary.income, BalancePalette.income) + metric("Расходы", summary.expenses, BalancePalette.expense) LabeledContent("Результат", value: summary.balance.formattedCurrency(code: currencyCode)) LabeledContent( "Сбережения", value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%" ) + if let warning = currency.missingRateWarning(summary.missing) { + Label(warning, systemImage: "exclamationmark.triangle") + .font(.caption2).foregroundStyle(.orange) + } } Section("Категории") { @@ -94,7 +100,7 @@ struct WatchAnalyticsView: View { private func tint(for item: CategorySpending) -> Color { item.colorName == "auto" - ? FinanceCategory.find(named: item.name)?.tint ?? .indigo + ? FinanceCategory.find(named: item.name)?.tint ?? BalancePalette.forest : FinanceCategory.color(named: item.colorName) } } @@ -103,7 +109,7 @@ struct WatchTransactionsView: View { @Environment(\.modelContext) private var modelContext @Query(sort: \FinanceTransaction.date, order: .reverse) private var transactions: [FinanceTransaction] - @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @Environment(\.currencyContext) private var currency @State private var selectedKind: TransactionKind? @State private var searchText = "" @State private var showingAdd = false @@ -141,8 +147,11 @@ struct WatchTransactionsView: View { VStack(alignment: .leading) { Text(transaction.categoryName).lineLimit(1) Text((transaction.kind == .income ? "+" : "−") - + transaction.amount.formattedCurrency(code: currencyCode)) + + transaction.amount.formattedCurrency(code: currency.currency(of: transaction))) .font(.caption2) + if let title = currency.accountTitle(transaction.accountID) { + Text(title).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } } } } @@ -164,7 +173,7 @@ struct WatchTransactionsView: View { private func tint(_ transaction: FinanceTransaction) -> Color { transaction.categoryColorName == "auto" - ? FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo + ? FinanceCategory.find(named: transaction.categoryName)?.tint ?? BalancePalette.forest : FinanceCategory.color(named: transaction.categoryColorName) } } @@ -172,9 +181,11 @@ struct WatchTransactionsView: View { struct WatchBudgetsView: View { @Query private var budgets: [MonthlyBudget] @Query private var transactions: [FinanceTransaction] - @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @Environment(\.currencyContext) private var currency @State private var showingEditor = false + private var currencyCode: String { currency.base } + private var interval: DateInterval { FinanceCalculations.monthInterval(containing: .now) } @@ -184,7 +195,8 @@ struct WatchBudgetsView: View { private var spending: [String: Double] { Dictionary(uniqueKeysWithValues: FinanceCalculations.spendingByCategory( transactions: transactions, - in: interval + in: interval, + context: currency ).map { ($0.name, $0.amount) }) } @@ -255,7 +267,7 @@ private struct WatchBudgetEditor: View { private func save() { for category in categories { - let value = limits[category.name, default: 0] + let value = Money.double(Money.cents(limits[category.name, default: 0])) let existing = budgets.first { $0.categoryName == category.name } if value > 0 { if let existing { @@ -429,11 +441,16 @@ struct WatchBalanceAdjustmentView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @Query private var transactions: [FinanceTransaction] - @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @Query private var transfers: [FinanceTransfer] + @Environment(\.currencyContext) private var currency + @StateObject private var account = ServerAccountStore.shared @State private var desired = 0.0 @State private var note = "" - private var current: Double { FinanceCalculations.totalBalance(transactions: transactions) } + private var currencyCode: String { currency.base } + private var current: Double { + FinanceCalculations.totalBalance(transactions: transactions, transfers: transfers, context: currency).value + } private var difference: Double { desired - current } var body: some View { @@ -453,26 +470,53 @@ struct WatchBalanceAdjustmentView: View { private func save() { guard abs(difference) >= 0.005 else { return } modelContext.insert(FinanceTransaction( - amount: abs(difference), + amount: Money.double(Money.cents(abs(difference))), note: note.isEmpty ? "Ручная корректировка баланса" : note, categoryName: "Корректировка", categoryIcon: "slider.horizontal.3", categoryColorName: "indigo", isBalanceAdjustment: true, - kind: difference > 0 ? .income : .expense + kind: difference > 0 ? .income : .expense, + currency: account.supportsV2 ? currency.base : nil )) dismiss() } } 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("appTheme") private var appTheme = AppTheme.system.rawValue + @Query(filter: #Predicate { $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 { + 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 { Form { - Picker("Валюта", selection: $currencyCode) { - ForEach(SupportedCurrency.allCases) { Text($0.rawValue).tag($0.rawValue) } + Picker("Валюта", selection: baseCurrency) { + ForEach(currencyCodes, id: \.self) { Text($0).tag($0) } } Picker("Тема", selection: $appTheme) { ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) } diff --git a/BalanceWatch/App/WatchRootView.swift b/BalanceWatch/App/WatchRootView.swift index fdd89fb..39ce147 100644 --- a/BalanceWatch/App/WatchRootView.swift +++ b/BalanceWatch/App/WatchRootView.swift @@ -2,16 +2,29 @@ import SwiftData import SwiftUI struct WatchRootView: View { + var body: some View { + CurrencyContextProvider { WatchHomeView() } + } +} + +private struct WatchHomeView: View { @Environment(\.modelContext) private var modelContext + @Environment(\.currencyContext) private var currency + @StateObject private var account = ServerAccountStore.shared @Query(sort: \FinanceTransaction.date, order: .reverse) private var transactions: [FinanceTransaction] - @AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue + @Query private var transfers: [FinanceTransfer] @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 { FinanceCalculations.summary( transactions: transactions, - in: FinanceCalculations.monthInterval(containing: .now) + in: FinanceCalculations.monthInterval(containing: .now), + context: currency ) } @@ -20,19 +33,27 @@ struct WatchRootView: View { ScrollView { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 3) { - Text("Общий баланс") - .font(.caption) - .foregroundStyle(.secondary) - Text(FinanceCalculations.totalBalance(transactions: transactions) - .formattedCurrency(code: currencyCode)) + HStack(spacing: 5) { + Image(systemName: "wallet.bifold") + .font(.caption2.weight(.semibold)) + Text("Общий баланс") + .font(.caption) + } + .foregroundStyle(BalancePalette.mint.opacity(0.85)) + Text(total.value.formattedCurrency(code: currencyCode)) .font(.title3.bold()) .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) .padding(12) .background( LinearGradient( - colors: [.indigo, .purple], + colors: [BalancePalette.cardTop, BalancePalette.cardBottom], startPoint: .topLeading, endPoint: .bottomTrailing ), @@ -43,13 +64,15 @@ struct WatchRootView: View { HStack(spacing: 8) { watchMetric( title: "Доходы", + symbol: "arrow.down.left", amount: monthSummary.income, - tint: .green + tint: BalancePalette.income ) watchMetric( title: "Расходы", + symbol: "arrow.up.right", amount: monthSummary.expenses, - tint: .red + tint: BalancePalette.expense ) } @@ -60,8 +83,16 @@ struct WatchRootView: View { .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) + .tint(BalancePalette.forest) VStack(spacing: 4) { + if account.supportsV2 { + NavigationLink { + WatchAccountsView() + } label: { + Label("Счета", systemImage: "creditcard") + } + } NavigationLink { WatchAnalyticsView() } label: { @@ -111,9 +142,9 @@ struct WatchRootView: View { .font(.caption) .lineLimit(1) Text((transaction.kind == .income ? "+" : "−") - + transaction.amount.formattedCurrency(code: currencyCode)) + + transaction.amount.formattedCurrency(code: currency.currency(of: transaction))) .font(.caption2.weight(.semibold)) - .foregroundStyle(transaction.kind == .income ? .green : .secondary) + .foregroundStyle(transaction.kind == .income ? BalancePalette.income : .secondary) } Spacer() } @@ -121,7 +152,7 @@ struct WatchRootView: View { } } } - .navigationTitle("Баланс") + .navigationTitle(account.ledgerID.isEmpty ? "Баланс" : account.ledgerName) .sheet(isPresented: $showingAddTransaction) { WatchAddTransactionView() } @@ -134,25 +165,38 @@ struct WatchRootView: View { } } - private func watchMetric(title: String, amount: Double, tint: Color) -> some View { + private func watchMetric(title: String, symbol: String, amount: Double, tint: Color) -> some View { VStack(alignment: .leading, spacing: 3) { - Text(title) + Label(title, systemImage: symbol) .font(.caption2) .foregroundStyle(.secondary) + .labelStyle(TintedIconLabelStyle(tint: tint)) Text(amount.formattedCurrency(code: currencyCode)) .font(.caption.bold()) + .foregroundStyle(tint) .lineLimit(1) .minimumScaleFactor(0.65) } .frame(maxWidth: .infinity, alignment: .leading) .padding(9) - .background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + .background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 12)) } private func transactionTint(_ transaction: FinanceTransaction) -> Color { if transaction.categoryColorName == "auto" { - return FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo + return FinanceCategory.find(named: transaction.categoryName)?.tint ?? BalancePalette.forest } 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 + } + } +} diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-100.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-100.png index 5375cd4..94e3168 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-100.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-100.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-102.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-102.png index bd70565..f764bdf 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-102.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-102.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png index 2c212db..52ce2df 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-108.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-108.png index 6c375f4..64d25e6 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-108.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-108.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-172.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-172.png index 043000f..68243a6 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-172.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-172.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-196.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-196.png index 3983c3d..7862670 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-196.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-196.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-216.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-216.png index 2f00659..e14c6b2 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-216.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-216.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-234.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-234.png index 7dc91af..5bcb394 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-234.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-234.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-258.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-258.png index 6be4a87..62d4619 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-258.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-258.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-48.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-48.png index 990b9a8..7bd2d90 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-48.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-48.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-55.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-55.png index 9abc1e5..4f7132f 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-55.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-55.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-58.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-58.png index 726cb0d..fe17a43 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-58.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-58.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-80.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-80.png index 73f3023..f83d323 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-80.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-80.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-87.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-87.png index 4608e52..5f5fb77 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-87.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-87.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-88.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-88.png index b81ecc8..be8d9a6 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-88.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-88.png differ diff --git a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-92.png b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-92.png index 4a41132..b280e52 100644 Binary files a/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-92.png and b/BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-92.png differ diff --git a/BalanceWatch/Models/CustomCategory.swift b/BalanceWatch/Models/CustomCategory.swift index e2e32ae..344b308 100644 --- a/BalanceWatch/Models/CustomCategory.swift +++ b/BalanceWatch/Models/CustomCategory.swift @@ -11,6 +11,8 @@ final class CustomCategory { var kindRawValue: String = "expense" var createdAt: Date = Date.now var syncUpdatedAt: Date = Date.now + var syncID: String? + var extraPayload: Data? init( id: UUID = UUID(), @@ -29,9 +31,12 @@ final class CustomCategory { self.colorName = colorName self.kindRawValue = kind.rawValue self.createdAt = createdAt + self.syncID = id.uuidString.lowercased() self.syncUpdatedAt = syncUpdatedAt } + var wireID: String { syncID ?? id.uuidString } + var kind: TransactionKind { get { TransactionKind(rawValue: kindRawValue) ?? .expense } set { kindRawValue = newValue.rawValue } diff --git a/BalanceWatch/Models/FinanceTransaction.swift b/BalanceWatch/Models/FinanceTransaction.swift index abe46c8..b76bc7e 100644 --- a/BalanceWatch/Models/FinanceTransaction.swift +++ b/BalanceWatch/Models/FinanceTransaction.swift @@ -28,6 +28,15 @@ final class FinanceTransaction { var isBalanceAdjustment: Bool = false var kindRawValue: String = "expense" 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( id: UUID = UUID(), @@ -40,6 +49,8 @@ final class FinanceTransaction { categoryColorName: String = "auto", isBalanceAdjustment: Bool = false, kind: TransactionKind, + accountID: String? = nil, + currency: String? = nil, syncUpdatedAt: Date = .now ) { self.id = id @@ -52,9 +63,14 @@ final class FinanceTransaction { self.categoryColorName = categoryColorName self.isBalanceAdjustment = isBalanceAdjustment self.kindRawValue = kind.rawValue + self.accountID = accountID + self.currency = currency + self.syncID = id.uuidString.lowercased() self.syncUpdatedAt = syncUpdatedAt } + var wireID: String { syncID ?? id.uuidString } + var kind: TransactionKind { get { TransactionKind(rawValue: kindRawValue) ?? .expense } set { kindRawValue = newValue.rawValue } diff --git a/BalanceWatch/Models/MonthlyBudget.swift b/BalanceWatch/Models/MonthlyBudget.swift index df51cef..52d0a16 100644 --- a/BalanceWatch/Models/MonthlyBudget.swift +++ b/BalanceWatch/Models/MonthlyBudget.swift @@ -10,6 +10,8 @@ final class MonthlyBudget { var limit: Double = 0 var monthStart: Date = Date.now var syncUpdatedAt: Date = Date.now + var syncID: String? + var extraPayload: Data? init( id: UUID = UUID(), @@ -26,6 +28,9 @@ final class MonthlyBudget { self.categoryEmoji = categoryEmoji self.limit = limit self.monthStart = monthStart + self.syncID = id.uuidString.lowercased() self.syncUpdatedAt = syncUpdatedAt } + + var wireID: String { syncID ?? id.uuidString } } diff --git a/BalanceWatch/Models/SyncTombstone.swift b/BalanceWatch/Models/SyncTombstone.swift index a37460c..27072bb 100644 --- a/BalanceWatch/Models/SyncTombstone.swift +++ b/BalanceWatch/Models/SyncTombstone.swift @@ -30,8 +30,9 @@ struct SyncDeletionRecord: Codable, Identifiable { } enum SyncDeletionStore { - private static let storageKey = "balance.server.deletionJournal.v1" - private static let quarantineKey = "balance.server.deletionJournal.corrupt" + // Журнал свой у каждого пространства (§7): у личного — прежний ключ. + private static var storageKey: String { "balance.server.deletionJournal.v1" + LedgerSelection.keySuffix } + private static var quarantineKey: String { "balance.server.deletionJournal.corrupt" + LedgerSelection.keySuffix } private static let lock = NSLock() static func add(entity: String, recordID: String, updatedAt: Date = .now) { @@ -93,17 +94,17 @@ enum SyncDeletionStore { extension ModelContext { func deleteForSync(_ transaction: FinanceTransaction) { - SyncDeletionStore.add(entity:"transaction", recordID:transaction.id.uuidString) + SyncDeletionStore.add(entity:"transaction", recordID:transaction.wireID) delete(transaction) } func deleteForSync(_ category: CustomCategory) { - SyncDeletionStore.add(entity:"category", recordID:category.id.uuidString) + SyncDeletionStore.add(entity:"category", recordID:category.wireID) delete(category) } func deleteForSync(_ budget: MonthlyBudget) { - SyncDeletionStore.add(entity:"budget", recordID:budget.id.uuidString) + SyncDeletionStore.add(entity:"budget", recordID:budget.wireID) delete(budget) } } diff --git a/BalanceWatch/Models/V2Records.swift b/BalanceWatch/Models/V2Records.swift new file mode 100644 index 0000000..d6232ca --- /dev/null +++ b/BalanceWatch/Models/V2Records.swift @@ -0,0 +1,102 @@ +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 = ["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 } +} diff --git a/BalanceWatch/Shared/AppTheme.swift b/BalanceWatch/Shared/AppTheme.swift index 56347e6..3358d86 100644 --- a/BalanceWatch/Shared/AppTheme.swift +++ b/BalanceWatch/Shared/AppTheme.swift @@ -23,3 +23,20 @@ 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) +} diff --git a/BalanceWatch/Shared/BalanceModelContainer.swift b/BalanceWatch/Shared/BalanceModelContainer.swift index 96222c0..1a6bb83 100644 --- a/BalanceWatch/Shared/BalanceModelContainer.swift +++ b/BalanceWatch/Shared/BalanceModelContainer.swift @@ -6,15 +6,19 @@ enum BalanceModelContainer { FinanceTransaction.self, MonthlyBudget.self, CustomCategory.self, - SyncTombstone.self + SyncTombstone.self, + FinanceAccount.self, + FinanceTransfer.self, + ExchangeRate.self, + SyncRecord.self ]) - static func make() -> ModelContainer { - let localConfiguration = ModelConfiguration( - schema: schema, - isStoredInMemoryOnly: false, - cloudKitDatabase: .none - ) + /// `scope` пустой — личные данные (прежнее хранилище по умолчанию); + /// иначе — отдельное хранилище общего пространства (сервер + пользователь + ledger). + static func make(scope: String = "") -> ModelContainer { + let localConfiguration = scope.isEmpty + ? ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none) + : ModelConfiguration("BalanceLedger-\(scope)", schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none) do { return try ModelContainer( @@ -23,7 +27,7 @@ enum BalanceModelContainer { ) } catch { let recoveryConfiguration = ModelConfiguration( - "BalanceRecovery", + scope.isEmpty ? "BalanceRecovery" : "BalanceRecovery-\(scope)", schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none @@ -40,3 +44,17 @@ 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 + } +} diff --git a/BalanceWatch/Shared/ServerSync.swift b/BalanceWatch/Shared/ServerSync.swift index 75b54c9..1355c8a 100644 --- a/BalanceWatch/Shared/ServerSync.swift +++ b/BalanceWatch/Shared/ServerSync.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import Security import SwiftData @@ -88,12 +89,53 @@ private struct SyncResponse: Decodable { private struct TransactionPayload: Codable { 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 + var accountId: String? = nil; var currency: String? = nil + static let keys: Set = ["amount", "date", "note", "categoryName", "categoryIcon", "categoryEmoji", "categoryColorName", "isBalanceAdjustment", "kindRawValue", "accountId", "currency"] } private struct CategoryPayload: Codable { let name: String; let icon: String; let emoji: String; let colorName: String; let kindRawValue: String; let createdAt: Date + static let keys: Set = ["name", "icon", "emoji", "colorName", "kindRawValue", "createdAt"] } private struct BudgetPayload: Codable { let categoryName: String; let categoryIcon: String; let categoryEmoji: String; let limit: Double; let monthStart: Date + static let keys: Set = ["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 = ["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 = ["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 = ["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 { @@ -124,6 +166,21 @@ private enum ServerCoding { } return value }() + + /// Известные поля поверх сохранённых незнакомых (§0.2). + static func payload(_ 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) -> 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 { @@ -145,8 +202,12 @@ final class ServerAccountStore: ObservableObject { @Published private(set) var isBusy = false @Published private(set) var lastSyncAt: Date? @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 capabilitiesKey = "balance.server.capabilities" private var session: ServerSession? private let sessionKey = "balance.server.session" private let serverKey = "balance.server.url" @@ -154,12 +215,17 @@ final class ServerAccountStore: ObservableObject { var serverURL: String { defaults.string(forKey: serverKey) ?? "" } 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() { if let data = KeychainStore.read(sessionKey), let value = try? ServerCoding.decoder.decode(ServerSession.self, from: data) { session = value; signedInEmail = value.user.email } 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 { @@ -167,7 +233,10 @@ final class ServerAccountStore: ObservableObject { throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.") } let normalized = try normalizedServer(value) - if normalized != serverURL { clearSession(); defaults.set(normalized, forKey: serverKey); clearSyncState() } + if normalized != serverURL { + 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) } @@ -175,7 +244,7 @@ final class ServerAccountStore: ObservableObject { func logout() async { if let refresh = session?.refreshToken { _ = try? await request(path: "v1/auth/logout", body: ["refreshToken": refresh], authorized: false) as EmptyResponse } - clearSession(); message = "Вы вышли из аккаунта." + clearSession(); selectLedger(nil); message = "Вы вышли из аккаунта." } func sync(context: ModelContext) async { @@ -184,6 +253,10 @@ final class ServerAccountStore: ObservableObject { defer { isBusy = false } do { try context.save() + await refreshCapabilities() + if !ledgerID.isEmpty && !supportsLedgers { + throw ServerSyncError.response("Сервер не поддерживает общие бюджеты. Выберите личное пространство.") + } let cutoff = Date.now let lastPush = defaults.object(forKey: pushKey()) as? Date ?? .distantPast let threshold = lastPush <= cutoff.addingTimeInterval(5) @@ -194,6 +267,7 @@ final class ServerAccountStore: ObservableObject { try await pushTransactions(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) + if supportsV2 { try await pushSettings(context: context, after: threshold, through: cutoff, cursor: initialCursor) } try await pushTombstones(through: cutoff, cursor: initialCursor) try await pullChanges(context: context, cursor: initialCursor) @@ -222,22 +296,36 @@ final class ServerAccountStore: ObservableObject { } catch { message = error.localizedDescription } } - private func request(path: String, body: Body, authorized: Bool, retry: Bool = true) async throws -> Response { + private func request(path: String, method: String = "POST", body: (any Encodable)?, authorized: Bool, retry: Bool = true) async throws -> Response { if authorized { try await refreshIfNeeded() } let base = try normalizedServer(serverURL) guard let url = URL(string: base + "/" + path) else { throw ServerSyncError.invalidURL } - var request = URLRequest(url: url); request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") + var request = URLRequest(url: url); request.httpMethod = method if authorized, let token = session?.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - request.httpBody = try ServerCoding.encoder.encode(body) + if path == "v1/sync" || path.hasPrefix("v1/attachments/"), !ledgerID.isEmpty { + 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) 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, body:body, authorized:true, retry:false) } + if http.statusCode == 401, authorized, retry { try await refreshSession(); return try await self.request(path:path, method:method, body:body, authorized:true, retry:false) } guard (200..<300).contains(http.statusCode) else { let envelope = try? ServerCoding.decoder.decode(ServerErrorEnvelope.self, from: data) if path == "v1/sync", envelope?.error.code == "invalid_json" { 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)") } if Response.self == EmptyResponse.self { return EmptyResponse() as! Response } @@ -260,14 +348,69 @@ final class ServerAccountStore: ObservableObject { } 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() { for key in defaults.dictionaryRepresentation().keys where key.hasPrefix("balance.server.cursor.") || key.hasPrefix("balance.server.lastPush.") { defaults.removeObject(forKey: key) } } - private func cursorKey() -> String { "balance.server.cursor.\(serverURL).\(session?.user.id ?? "anonymous")" } - private func pushKey() -> String { "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" } + private func cursorKey() -> String { "balance.server.cursor.\(serverURL).\(session?.user.id ?? "anonymous")" + LedgerSelection.keySuffix } + private func pushKey() -> String { "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" + LedgerSelection.keySuffix } private func deviceID() -> String { if let value = defaults.string(forKey: "balance.server.deviceID") { return value } let value = UUID().uuidString; defaults.set(value, forKey: "balance.server.deviceID"); return value @@ -289,8 +432,8 @@ final class ServerAccountStore: ObservableObject { let items = try context.fetch(descriptor) guard !items.isEmpty else { break } 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) - return SyncChange(entity:"transaction", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value)) + 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) + return SyncChange(entity:"transaction", id:item.wireID, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.payload(value, extras:item.extraPayload)) } try await sendPush(changes, cursor: cursor) offset += items.count @@ -316,7 +459,7 @@ final class ServerAccountStore: ObservableObject { guard !items.isEmpty else { break } 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) - return SyncChange(entity:"category", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value)) + return SyncChange(entity:"category", id:item.wireID, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.payload(value, extras:item.extraPayload)) } try await sendPush(changes, cursor: cursor) offset += items.count @@ -342,7 +485,7 @@ final class ServerAccountStore: ObservableObject { guard !items.isEmpty else { break } 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) - return SyncChange(entity:"budget", id:item.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value)) + return SyncChange(entity:"budget", id:item.wireID, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.payload(value, extras:item.extraPayload)) } try await sendPush(changes, cursor: cursor) offset += items.count @@ -351,6 +494,15 @@ 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(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 { var offset = 0 while true { @@ -408,36 +560,114 @@ final class ServerAccountStore: ObservableObject { private func apply(_ changes: [SyncChange], context: ModelContext) throws { for change in changes.sorted(by: { ($0.sequence ?? 0) < ($1.sequence ?? 0) }) { - if let id = UUID(uuidString:change.id) { - 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 - } - } 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 - } - } + switch change.entity { + case "transaction", "category", "budget": + if let id = UUID(uuidString:change.id) { try applyV1(change, id:id, context:context) } + case "account", "transfer", "rate": + try applyV2(change, context:context) + case let entity where SyncRecord.storedEntities.contains(entity): + try applyRecord(change, context:context) + default: + break // незнакомая сущность: пропускаем, курсор двигается (§0.1) } 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(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(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(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(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? { let recordID = id var descriptor = FetchDescriptor(predicate:#Predicate { item in item.id == recordID }) @@ -504,6 +734,15 @@ struct ServerAccountView: View { 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 { Section("Синхронизация") { Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy) diff --git a/BalanceWatch/Utilities/CurrencyFormatting.swift b/BalanceWatch/Utilities/CurrencyFormatting.swift index 04b78d8..7eeed58 100644 --- a/BalanceWatch/Utilities/CurrencyFormatting.swift +++ b/BalanceWatch/Utilities/CurrencyFormatting.swift @@ -10,10 +10,13 @@ enum SupportedCurrency: String, CaseIterable, Identifiable { } extension Double { + /// Целые суммы без копеек, остальные — ровно с двумя знаками («8 480,50 ₽»), + /// как во Flutter-клиенте. func formattedCurrency(code: String) -> String { - formatted( + let whole = (self * 100).rounded().truncatingRemainder(dividingBy: 100) == 0 + return formatted( .currency(code: code) - .precision(.fractionLength(0 ... 2)) + .precision(.fractionLength(whole ? 0 : 2)) .presentation(.narrow) ) } diff --git a/BalanceWatch/Utilities/FinanceCalculations.swift b/BalanceWatch/Utilities/FinanceCalculations.swift index b0ccd88..f87bd16 100644 --- a/BalanceWatch/Utilities/FinanceCalculations.swift +++ b/BalanceWatch/Utilities/FinanceCalculations.swift @@ -3,6 +3,7 @@ import Foundation struct FinancePeriodSummary { let income: Double let expenses: Double + var missing: Set = [] var balance: Double { income - expenses } } @@ -17,11 +18,58 @@ struct CategorySpending: Identifiable { var id: String { name } } +/// Финансовые правила (общие для экосистемы, см. docs/CONTRACT_V2.md §2–3). +/// Расчёты ведутся в целых сотых; общие итоги — в базовой валюте. enum FinanceCalculations { - static func totalBalance(transactions: [FinanceTransaction]) -> Double { - transactions.reduce(0) { balance, transaction in - balance + (transaction.kind == .income ? transaction.amount : -transaction.amount) + /// Общий баланс: все операции (включая корректировки и будущие даты) + /// плюс входящие минус исходящие части переводов — всё в базовой валюте. + static func totalBalance( + 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 { @@ -29,43 +77,52 @@ enum FinanceCalculations { ?? DateInterval(start: date, duration: 30 * 24 * 60 * 60) } + /// Доходы и расходы периода `[начало, конец)` без корректировок, в базовой валюте. static func summary( - transactions: [FinanceTransaction], - in interval: DateInterval + transactions: [LedgerEntry], + in interval: DateInterval, + context: CurrencyContext ) -> FinancePeriodSummary { - let periodTransactions = transactions.filter { - interval.contains($0.date) && !$0.isBalanceAdjustment + var income = MoneyTotal(), expenses = MoneyTotal() + for item in transactions where contains(interval, item.date) && !item.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) } } - let income = periodTransactions - .filter { $0.kind == .income } - .reduce(0) { $0 + $1.amount } - let expenses = periodTransactions - .filter { $0.kind == .expense } - .reduce(0) { $0 + $1.amount } - - return FinancePeriodSummary(income: income, expenses: expenses) + return FinancePeriodSummary( + income: income.value, + expenses: expenses.value, + missing: income.missing.union(expenses.missing) + ) } static func spendingByCategory( transactions: [FinanceTransaction], - in interval: DateInterval + in interval: DateInterval, + context: CurrencyContext ) -> [CategorySpending] { let expenses = transactions.filter { $0.kind == .expense && !$0.isBalanceAdjustment - && interval.contains($0.date) + && contains(interval, $0.date) } let grouped = Dictionary(grouping: expenses, by: \FinanceTransaction.categoryName) return grouped.map { name, items in - CategorySpending( + var total = MoneyTotal() + for item in items { total.add(Money.cents(item.amount), context.currency(of: item), context.converter) } + return CategorySpending( name: name, icon: items.first?.categoryIcon ?? "circle.fill", emoji: items.first?.categoryEmoji ?? "", colorName: items.first?.categoryColorName ?? "auto", - amount: items.reduce(0) { $0 + $1.amount } + amount: total.value ) } .sorted { $0.amount > $1.amount } } + + /// Полуоткрытый интервал: `DateInterval.contains` включает конец. + static func contains(_ interval: DateInterval, _ date: Date) -> Bool { + date >= interval.start && date < interval.end + } } diff --git a/BalanceWatch/Utilities/Money.swift b/BalanceWatch/Utilities/Money.swift new file mode 100644 index 0000000..063abda --- /dev/null +++ b/BalanceWatch/Utilities/Money.swift @@ -0,0 +1,159 @@ +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 = [] + + 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? { + guard !missing.isEmpty else { return nil } + return missing.sorted().map { "нет курса \($0)→\(base)" }.joined(separator: ", ") + } +} diff --git a/README.md b/README.md index cb60b6d..54675e7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- BalanceWatch — учёт личных финансов на Apple Watch + BalanceWatch — личные финансы на Apple Watch

# BalanceWatch @@ -13,13 +13,15 @@ ```text BalanceWatch/ -├── branding/ # аватар и баннер для README (PNG + SVG-исходники) +├── branding/ # иконка, аватар и баннер (SVG-исходники, PNG, render.sh) +├── docs/ # скриншоты для баннера +├── docs/CONTRACT_V2.md # общий контракт экосистемы v2 (копия, одинаковая во всех репозиториях) ├── BalanceWatch.xcodeproj/ └── BalanceWatch/ ├── App/ # точка входа и экраны часов ├── Models/ # SwiftData-модели (копия из монорепы) ├── Shared/ # контейнер SwiftData, тема, ServerSync - ├── Utilities/ # FinanceCalculations, форматирование валют + ├── Utilities/ # FinanceCalculations, Money (сотые, курсы), форматирование валют ├── Views/Components/ # CategoryIconView └── Assets.xcassets # иконка приложения ``` @@ -28,6 +30,24 @@ BalanceWatch/ любой `.swift`-файл, положенный в `BalanceWatch/`, автоматически попадает в таргет — править `project.pbxproj` вручную не нужно. +## Контракт v2 + +Часы реализуют клиентскую часть `docs/CONTRACT_V2.md` в компактном виде: + +- синхронизируются и хранятся счета, переводы, курсы и настройка + `baseCurrency`; общий баланс, аналитика и бюджеты считаются в базовой + валюте, при отсутствии курса показывается «нет курса C→B»; +- экран «Счета» (только чтение) с балансами счетов и «Без счёта»; при + добавлении операции можно выбрать счёт, валюта следует за счётом; +- общие бюджеты: в «Настройки → Сервер → Бюджет» выбирается личное или общее + пространство, можно присоединиться по коду; данные каждого пространства + лежат в отдельном хранилище; +- регулярные операции, цели, долги, вложения и теги хранятся и не теряются + при пересохранении, но интерфейса для них нет; регулярные операции часы не + создают; +- если `GET /v1/capabilities` отвечает 404 (сервер v1), счета и общие + бюджеты скрыты, новые сущности не отправляются. + ## Сборка 1. Открыть `BalanceWatch.xcodeproj` в Xcode 16 или новее. @@ -56,3 +76,15 @@ Simulator и watchOS device (без подписи). Запускается на кода из монорепозитория. Протокол синхронизации и правила расчётов должны совпадать с остальными клиентами 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`. diff --git a/branding/avatar.png b/branding/avatar.png index d82c6c6..ca42d70 100644 Binary files a/branding/avatar.png and b/branding/avatar.png differ diff --git a/branding/avatar.svg b/branding/avatar.svg index 5549534..1220ded 100644 --- a/branding/avatar.svg +++ b/branding/avatar.svg @@ -1,60 +1,30 @@ - + + - - - + + + - - - + + + - - + + - - - - - - - - - - - - - - - - + + - - - - + - - - - - - - - - - - - - - - - - - - - + + + + + diff --git a/branding/banner.png b/branding/banner.png index 615434c..20cf132 100644 Binary files a/branding/banner.png and b/branding/banner.png differ diff --git a/branding/banner.svg b/branding/banner.svg index c1cc9ae..47fbb50 100644 --- a/branding/banner.svg +++ b/branding/banner.svg @@ -1,80 +1,78 @@ - + + - - - + + + - - - - - - - + + - - - + + + + - - - + + + + - - - - - - - - - - - + + - - + + + + + - - - - + + - - - - - - - - - - - - - + + + BalanceWatch + + Личные финансы на Apple Watch + Самостоятельное приложение для часов, + синхронизация с self-hosted BalanceServer + + + + watchOS 10+ + + SwiftUI + + SwiftData + + Счета и валюты + + Общие бюджеты - - - BalanceWatch - Учёт личных финансов на Apple Watch - - - - - watchOS 10+ - - SwiftUI - - SwiftData - - - - Sync с BalanceServer - + + + + + + + + + + + + + + + + + + + diff --git a/branding/fonts/LICENSE.txt b/branding/fonts/LICENSE.txt new file mode 100644 index 0000000..364bb75 --- /dev/null +++ b/branding/fonts/LICENSE.txt @@ -0,0 +1,203 @@ +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. diff --git a/branding/fonts/Roboto-Bold.ttf b/branding/fonts/Roboto-Bold.ttf new file mode 100644 index 0000000..d3f01ad Binary files /dev/null and b/branding/fonts/Roboto-Bold.ttf differ diff --git a/branding/fonts/Roboto-Medium.ttf b/branding/fonts/Roboto-Medium.ttf new file mode 100644 index 0000000..1a7f3b0 Binary files /dev/null and b/branding/fonts/Roboto-Medium.ttf differ diff --git a/branding/fonts/Roboto-Regular.ttf b/branding/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..2c97eea Binary files /dev/null and b/branding/fonts/Roboto-Regular.ttf differ diff --git a/branding/icon.svg b/branding/icon.svg new file mode 100644 index 0000000..0316743 --- /dev/null +++ b/branding/icon.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/branding/render.sh b/branding/render.sh new file mode 100755 index 0000000..25b0354 --- /dev/null +++ b/branding/render.sh @@ -0,0 +1,21 @@ +#!/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 diff --git a/docs/CONTRACT_V2.md b/docs/CONTRACT_V2.md new file mode 100644 index 0000000..968da03 --- /dev/null +++ b/docs/CONTRACT_V2.md @@ -0,0 +1,287 @@ +# Контракт экосистемы 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::` (нижний регистр). Два устройства + создают одну и ту же запись — дублей нет. +- `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: ` на + `/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 до своего обновления. diff --git a/docs/accounts.png b/docs/accounts.png new file mode 100644 index 0000000..492780d Binary files /dev/null and b/docs/accounts.png differ diff --git a/docs/home.png b/docs/home.png new file mode 100644 index 0000000..d928c9e Binary files /dev/null and b/docs/home.png differ