Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30a0ca6520 | ||
|
|
800dc77a39 | ||
|
|
9cfac1797c | ||
|
|
94177dbb0b | ||
|
|
d0b023b1ba | ||
|
|
0ccb7e1861 |
No files matched your search
@@ -28,19 +28,25 @@ BalanceWatch/
|
|||||||
├── AGENTS.md
|
├── AGENTS.md
|
||||||
├── README.md
|
├── README.md
|
||||||
├── .github/workflows/build.yml # CI: сборка для симулятора и устройства
|
├── .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.xcodeproj/ # один таргет и одна схема — BalanceWatch
|
||||||
└── BalanceWatch/
|
└── BalanceWatch/
|
||||||
├── App/
|
├── App/
|
||||||
│ ├── BalanceWatchApp.swift # @main, ModelContainer, тема
|
│ ├── BalanceWatchApp.swift # @main, ModelContainer, тема
|
||||||
│ ├── WatchRootView.swift # главный экран: баланс, месяц, навигация
|
│ ├── WatchRootView.swift # главный экран: баланс, месяц, навигация
|
||||||
│ ├── WatchAddTransactionView.swift # добавление операции
|
│ ├── WatchAddTransactionView.swift # добавление операции (+ выбор счёта)
|
||||||
|
│ ├── WatchAccountsView.swift # CurrencyContext в окружении, счета,
|
||||||
|
│ │ # выбор пространства (ledger)
|
||||||
│ └── WatchFeatures.swift # аналитика, операции, бюджеты,
|
│ └── WatchFeatures.swift # аналитика, операции, бюджеты,
|
||||||
│ # категории, корректировка баланса, настройки
|
│ # категории, корректировка баланса, настройки
|
||||||
├── Models/ # SwiftData-модели и системные категории
|
├── Models/ # SwiftData-модели и системные категории;
|
||||||
|
│ # V2Records.swift — счета, переводы, курсы, SyncRecord
|
||||||
├── Shared/ # BalanceModelContainer, AppTheme,
|
├── Shared/ # BalanceModelContainer, AppTheme,
|
||||||
│ # CategoryDesignOptions, ServerSync (ServerAccountStore)
|
│ # CategoryDesignOptions, ServerSync (ServerAccountStore)
|
||||||
├── Utilities/ # FinanceCalculations, форматирование валют и дат
|
├── Utilities/ # FinanceCalculations, Money (сотые, курсы, конвертация),
|
||||||
|
│ # форматирование валют и дат
|
||||||
├── Views/Components/CategoryIconView.swift
|
├── Views/Components/CategoryIconView.swift
|
||||||
└── Assets.xcassets
|
└── Assets.xcassets
|
||||||
```
|
```
|
||||||
@@ -81,7 +87,20 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
|
|||||||
### 2. SwiftData
|
### 2. SwiftData
|
||||||
|
|
||||||
Схема зарегистрирована в `BalanceModelContainer.schema`:
|
Схема зарегистрирована в `BalanceModelContainer.schema`:
|
||||||
`FinanceTransaction`, `MonthlyBudget`, `CustomCategory`, `SyncTombstone`.
|
`FinanceTransaction`, `MonthlyBudget`, `CustomCategory`, `SyncTombstone`,
|
||||||
|
`FinanceAccount`, `FinanceTransfer`, `ExchangeRate`, `SyncRecord`.
|
||||||
|
|
||||||
|
- Каждое пространство данных (§7 контракта) — отдельное хранилище:
|
||||||
|
личное — прежнее хранилище по умолчанию, общее —
|
||||||
|
`BalanceLedger-<scope>`, где scope — хеш сервер + пользователь + ledger
|
||||||
|
(`LedgerContainers`, `LedgerSelection`). Корневой view пересоздаётся
|
||||||
|
через `.id(scope)`.
|
||||||
|
- `syncID` — id записи на проводе. У записей, созданных до v2, он `nil`, и
|
||||||
|
отправляется прежний `id.uuidString` (иначе на сервере появятся дубли);
|
||||||
|
новые записи получают UUID в нижнем регистре, полученные — id с сервера.
|
||||||
|
- `extraPayload` хранит незнакомые поля payload и отправляет их обратно
|
||||||
|
(§0.2). `SyncRecord` хранит `recurring`, `goal`, `debt`, `attachment`,
|
||||||
|
`setting` целиком.
|
||||||
|
|
||||||
- У пользователей уже есть локальные базы. Не удаляй и не переименовывай
|
- У пользователей уже есть локальные базы. Не удаляй и не переименовывай
|
||||||
persisted-поля без стратегии миграции; новые поля делай со значением
|
persisted-поля без стратегии миграции; новые поля делай со значением
|
||||||
@@ -107,13 +126,18 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
|
|||||||
|
|
||||||
Центральные правила — в `FinanceCalculations`:
|
Центральные правила — в `FinanceCalculations`:
|
||||||
|
|
||||||
- общий баланс = доходы − расходы;
|
- суммы считаются в целых сотых (`Money`), курсы — десятичные строки,
|
||||||
|
округление половиной от нуля; интервалы полуоткрытые `[начало, конец)`;
|
||||||
|
- общий баланс = доходы − расходы + переводы, всё в базовой валюте
|
||||||
|
(`CurrencyContext`: `setting baseCurrency`, иначе `@AppStorage("currencyCode")`);
|
||||||
|
нет курса — сумма не входит, показывается «нет курса C→B»;
|
||||||
|
- баланс счёта — в его валюте без конвертации;
|
||||||
- операции с `isBalanceAdjustment == true` входят в общий баланс, но
|
- операции с `isBalanceAdjustment == true` входят в общий баланс, но
|
||||||
**не** входят в периодическую аналитику (месяц/3 мес./12 мес.);
|
**не** входят в периодическую аналитику (месяц/3 мес./12 мес.);
|
||||||
- расходы группируются по `categoryName`.
|
- расходы группируются по `categoryName`.
|
||||||
|
|
||||||
Эти правила **одинаковы во всех клиентах Balance** (Apple, Android, web).
|
Эти правила **одинаковы во всех клиентах Balance** (Apple, Android, web)
|
||||||
Не меняй их только здесь.
|
и описаны в `docs/CONTRACT_V2.md` §2–3. Не меняй их только здесь.
|
||||||
|
|
||||||
### 5. Синхронизация (общий контракт)
|
### 5. Синхронизация (общий контракт)
|
||||||
|
|
||||||
@@ -129,7 +153,15 @@ Info.plist нет — ключи задаются через `INFOPLIST_KEY_*`),
|
|||||||
изменения сервера и остальных клиентов сломает синхронизацию.
|
изменения сервера и остальных клиентов сломает синхронизацию.
|
||||||
- При смене URL сервера сессия очищается, cursor/push-state
|
- При смене URL сервера сессия очищается, cursor/push-state
|
||||||
сбрасываются, пользователь логинится заново (`saveServer`). Сохраняй
|
сбрасываются, пользователь логинится заново (`saveServer`). Сохраняй
|
||||||
это поведение.
|
это поведение. Смена сервера и выход возвращают личное пространство.
|
||||||
|
- v2: перед синхронизацией запрашивается `GET /v1/capabilities`; 404 —
|
||||||
|
сервер v1, тогда `supportsV2 == false`: счета и общие бюджеты скрыты,
|
||||||
|
`setting` не отправляется. Незнакомые сущности при pull пропускаются.
|
||||||
|
- Общее пространство передаётся заголовком `X-Balance-Ledger` на `v1/sync`;
|
||||||
|
cursor, push-state и журнал удалений имеют суффикс `LedgerSelection.keySuffix`.
|
||||||
|
- Из сущностей v2 часы отправляют только `setting` (базовая валюта);
|
||||||
|
счета, переводы, курсы, регулярные операции и прочее только читают.
|
||||||
|
Регулярные операции часы не создают.
|
||||||
|
|
||||||
### 6. Безопасность
|
### 6. Безопасность
|
||||||
|
|
||||||
@@ -162,7 +194,10 @@ CI (`.github/workflows/build.yml`) собирает проект на `macos-15`
|
|||||||
симулятора и для устройства без подписи. Перед PR сборка должна быть
|
симулятора и для устройства без подписи. Перед PR сборка должна быть
|
||||||
зелёной в обоих вариантах.
|
зелёной в обоих вариантах.
|
||||||
|
|
||||||
Тестового таргета пока нет. Если добавляешь тесты, не завязывай их на
|
Тестового таргета пока нет. `Utilities/Money.swift` не зависит от
|
||||||
|
SwiftData/SwiftUI, а `FinanceCalculations` работает через протоколы
|
||||||
|
`LedgerEntry`/`LedgerTransfer`, поэтому денежную математику можно проверить
|
||||||
|
отдельным `swiftc`-прогоном на macOS (с заглушкой `FinanceTransaction`). Если добавляешь тесты, не завязывай их на
|
||||||
текущую дату и локаль — явно фиксируй `Calendar`, `Date` и formatter'ы.
|
текущую дату и локаль — явно фиксируй `Calendar`, `Date` и formatter'ы.
|
||||||
Добавив тестовый таргет, добавь и шаг `test` в CI.
|
Добавив тестовый таргет, добавь и шаг `test` в CI.
|
||||||
|
|
||||||
@@ -176,3 +211,11 @@ CI (`.github/workflows/build.yml`) собирает проект на `macos-15`
|
|||||||
- [ ] Секреты и токены не попали в репозиторий и логи.
|
- [ ] Секреты и токены не попали в репозиторий и логи.
|
||||||
- [ ] CI собирает проект для симулятора и для устройства.
|
- [ ] CI собирает проект для симулятора и для устройства.
|
||||||
- [ ] README/AGENTS.md обновлены, если поменялась структура или сборка.
|
- [ ] README/AGENTS.md обновлены, если поменялась структура или сборка.
|
||||||
|
|
||||||
|
## Оформление
|
||||||
|
|
||||||
|
- Цвета интерфейса — только из `BalancePalette` (`Shared/AppTheme.swift`),
|
||||||
|
палитра «Лес», общая с BalanceFlutter. Не возвращай indigo/purple.
|
||||||
|
- Иконка, аватар и баннер рендерятся из SVG: `bash branding/render.sh`
|
||||||
|
(нужен `resvg`). PNG в `AppIcon.appiconset` руками не правь.
|
||||||
|
- Аватар репозитория в Gitea обновляется из `branding/avatar.png`.
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 15;
|
||||||
DEVELOPMENT_TEAM = K3AS449A74;
|
DEVELOPMENT_TEAM = K3AS449A74;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
||||||
INFOPLIST_KEY_WKWatchKitApp = YES;
|
INFOPLIST_KEY_WKWatchKitApp = YES;
|
||||||
INFOPLIST_KEY_WKWatchOnly = YES;
|
INFOPLIST_KEY_WKWatchOnly = YES;
|
||||||
MARKETING_VERSION = 3.1.1;
|
MARKETING_VERSION = 3.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
@@ -265,7 +265,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 15;
|
||||||
DEVELOPMENT_TEAM = K3AS449A74;
|
DEVELOPMENT_TEAM = K3AS449A74;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
||||||
INFOPLIST_KEY_WKWatchKitApp = YES;
|
INFOPLIST_KEY_WKWatchKitApp = YES;
|
||||||
INFOPLIST_KEY_WKWatchOnly = YES;
|
INFOPLIST_KEY_WKWatchOnly = YES;
|
||||||
MARKETING_VERSION = 3.1.1;
|
MARKETING_VERSION = 3.2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct BalanceWatchApp: App {
|
struct BalanceWatchApp: App {
|
||||||
private let modelContainer = BalanceModelContainer.make()
|
private let containers = LedgerContainers()
|
||||||
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
|
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
|
||||||
|
@AppStorage(LedgerSelection.scopeKey) private var ledgerScope = ""
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
WatchRootView()
|
WatchRootView()
|
||||||
.tint(.indigo)
|
.id(ledgerScope)
|
||||||
|
.tint(BalancePalette.accent)
|
||||||
.preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme)
|
.preferredColorScheme(AppTheme(rawValue: appTheme)?.colorScheme)
|
||||||
|
.modelContainer(containers.container(for: ledgerScope))
|
||||||
}
|
}
|
||||||
.modelContainer(modelContainer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<Content: View>: View {
|
||||||
|
@Query private var accounts: [FinanceAccount]
|
||||||
|
@Query private var rates: [ExchangeRate]
|
||||||
|
@Query(filter: #Predicate<SyncRecord> { $0.entityName == "setting" && $0.recordID == "baseCurrency" })
|
||||||
|
private var baseSetting: [SyncRecord]
|
||||||
|
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content.environment(\.currencyContext, CurrencyContext(
|
||||||
|
base: baseSetting.first?.stringValue ?? currencyCode,
|
||||||
|
accounts: accounts.map { ($0.syncID, $0.name, $0.currency) },
|
||||||
|
quotes: rates.map(\.quote)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchAccountsView: View {
|
||||||
|
@Environment(\.currencyContext) private var currency
|
||||||
|
@Query(sort: [SortDescriptor(\FinanceAccount.sortOrder), SortDescriptor(\FinanceAccount.name)])
|
||||||
|
private var accounts: [FinanceAccount]
|
||||||
|
@Query private var transactions: [FinanceTransaction]
|
||||||
|
@Query private var transfers: [FinanceTransfer]
|
||||||
|
|
||||||
|
private var total: MoneyTotal {
|
||||||
|
FinanceCalculations.totalBalance(transactions: transactions, transfers: transfers, context: currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section("Всего, \(currency.base)") {
|
||||||
|
Text(total.value.formattedCurrency(code: currency.base)).font(.headline)
|
||||||
|
if let warning = currency.missingRateWarning(total.missing) {
|
||||||
|
Label(warning, systemImage: "exclamationmark.triangle").font(.caption2).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if accounts.isEmpty {
|
||||||
|
Text("Счетов пока нет. Их создают на телефоне или компьютере.")
|
||||||
|
.font(.footnote).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach(accounts) { account in
|
||||||
|
row(
|
||||||
|
account.name + (account.isArchived ? " (архив)" : ""),
|
||||||
|
icon: account.icon,
|
||||||
|
tint: FinanceCategory.color(named: account.colorName),
|
||||||
|
cents: FinanceCalculations.accountBalance(accountID: account.syncID, transactions: transactions, transfers: transfers),
|
||||||
|
code: currency.accountCurrency(account.syncID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let unassigned = FinanceCalculations.unassignedBalances(transactions: transactions, context: currency)
|
||||||
|
ForEach(unassigned.keys.sorted(), id: \.self) { code in
|
||||||
|
row("Без счёта", icon: "tray.fill", tint: .gray, cents: unassigned[code] ?? 0, code: code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Счета")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ title: String, icon: String, tint: Color, cents: Int64, code: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
CategoryIconView(systemName: icon, emoji: "", tint: tint, size: 13, containerSize: 30)
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(title).lineLimit(1)
|
||||||
|
Text(Money.double(cents).formattedCurrency(code: code))
|
||||||
|
.font(.caption2).foregroundStyle(cents < 0 ? BalancePalette.expense : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WatchLedgersView: View {
|
||||||
|
@StateObject private var account = ServerAccountStore.shared
|
||||||
|
@State private var ledgers: [ServerLedger] = []
|
||||||
|
@State private var code = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
choice("Личный", id: "", ledger: nil)
|
||||||
|
ForEach(ledgers) { choice($0.name, id: $0.id, ledger: $0) }
|
||||||
|
} footer: {
|
||||||
|
Text("Данные каждого бюджета хранятся на часах отдельно.")
|
||||||
|
}
|
||||||
|
Section("Присоединиться") {
|
||||||
|
TextField("Код приглашения", text: $code)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Button("Присоединиться") {
|
||||||
|
Task {
|
||||||
|
if let ledger = await account.joinLedger(code: code) {
|
||||||
|
code = ""
|
||||||
|
if !ledgers.contains(ledger) { ledgers.append(ledger) }
|
||||||
|
account.selectLedger(ledger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(code.trimmingCharacters(in: .whitespaces).isEmpty || account.isBusy)
|
||||||
|
}
|
||||||
|
if account.isBusy { ProgressView() }
|
||||||
|
if let message = account.message { Text(message).font(.footnote).foregroundStyle(.secondary) }
|
||||||
|
}
|
||||||
|
.navigationTitle("Бюджет")
|
||||||
|
.task { if let value = await account.loadLedgers() { ledgers = value } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func choice(_ title: String, id: String, ledger: ServerLedger?) -> some View {
|
||||||
|
Button {
|
||||||
|
account.selectLedger(ledger)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(title).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
if account.ledgerID == id { Image(systemName: "checkmark") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(account.isBusy)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,12 @@ import SwiftUI
|
|||||||
struct WatchAddTransactionView: View {
|
struct WatchAddTransactionView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.currencyContext) private var currency
|
||||||
|
@StateObject private var account = ServerAccountStore.shared
|
||||||
@Query(sort: \CustomCategory.createdAt)
|
@Query(sort: \CustomCategory.createdAt)
|
||||||
private var customCategories: [CustomCategory]
|
private var customCategories: [CustomCategory]
|
||||||
|
@Query(sort: [SortDescriptor(\FinanceAccount.sortOrder), SortDescriptor(\FinanceAccount.name)])
|
||||||
|
private var accounts: [FinanceAccount]
|
||||||
|
|
||||||
private let transaction: FinanceTransaction?
|
private let transaction: FinanceTransaction?
|
||||||
@State private var kind: TransactionKind
|
@State private var kind: TransactionKind
|
||||||
@@ -13,6 +17,8 @@ struct WatchAddTransactionView: View {
|
|||||||
@State private var categoryName: String
|
@State private var categoryName: String
|
||||||
@State private var date: Date
|
@State private var date: Date
|
||||||
@State private var note: String
|
@State private var note: String
|
||||||
|
/// Пустая строка — «Без счёта».
|
||||||
|
@State private var accountID: String
|
||||||
|
|
||||||
init(transaction: FinanceTransaction? = nil) {
|
init(transaction: FinanceTransaction? = nil) {
|
||||||
self.transaction = transaction
|
self.transaction = transaction
|
||||||
@@ -23,6 +29,31 @@ struct WatchAddTransactionView: View {
|
|||||||
?? FinanceCategory.categories(for: initialKind)[0].name)
|
?? FinanceCategory.categories(for: initialKind)[0].name)
|
||||||
_date = State(initialValue: transaction?.date ?? .now)
|
_date = State(initialValue: transaction?.date ?? .now)
|
||||||
_note = State(initialValue: transaction?.note ?? "")
|
_note = State(initialValue: transaction?.note ?? "")
|
||||||
|
_accountID = State(initialValue: transaction?.accountID ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Активные счета; текущий счёт операции остаётся в списке, даже если
|
||||||
|
/// он в архиве или удалён.
|
||||||
|
private var accountChoices: [(id: String, title: String)] {
|
||||||
|
var values = accounts.filter { !$0.isArchived }.map { ($0.syncID, $0.name) }
|
||||||
|
if let current = transaction?.accountID,
|
||||||
|
!values.contains(where: { $0.0.lowercased() == current.lowercased() }) {
|
||||||
|
values.append((current, currency.accountTitle(current) ?? ""))
|
||||||
|
}
|
||||||
|
return [("", "Без счёта")] + values
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showsAccounts: Bool {
|
||||||
|
account.supportsV2 && (!accounts.isEmpty || transaction?.accountID != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Валюта следует за счётом; у существующей операции без смены счёта
|
||||||
|
/// валюта не меняется.
|
||||||
|
private var effectiveCurrency: String {
|
||||||
|
if let transaction, (transaction.accountID ?? "") == accountID {
|
||||||
|
return currency.currency(of: transaction)
|
||||||
|
}
|
||||||
|
return accountID.isEmpty ? currency.base : currency.accountCurrency(accountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var categories: [FinanceCategory] {
|
private var categories: [FinanceCategory] {
|
||||||
@@ -54,6 +85,13 @@ struct WatchAddTransactionView: View {
|
|||||||
|
|
||||||
TextField("Сумма", value: $amount, format: .number)
|
TextField("Сумма", value: $amount, format: .number)
|
||||||
|
|
||||||
|
if showsAccounts {
|
||||||
|
Picker("Счёт", selection: $accountID) {
|
||||||
|
ForEach(accountChoices, id: \.id) { Text($0.title).tag($0.id) }
|
||||||
|
}
|
||||||
|
LabeledContent("Валюта", value: effectiveCurrency)
|
||||||
|
}
|
||||||
|
|
||||||
Picker("Категория", selection: $categoryName) {
|
Picker("Категория", selection: $categoryName) {
|
||||||
ForEach(categories) { category in
|
ForEach(categories) { category in
|
||||||
Text((category.emoji.isEmpty ? "" : category.emoji + " ") + category.name)
|
Text((category.emoji.isEmpty ? "" : category.emoji + " ") + category.name)
|
||||||
@@ -80,7 +118,10 @@ struct WatchAddTransactionView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
|
let amount = Money.double(Money.cents(amount))
|
||||||
guard amount > 0 else { return }
|
guard amount > 0 else { return }
|
||||||
|
let newAccount = accountID.isEmpty ? nil : accountID
|
||||||
|
let newCurrency = account.supportsV2 ? effectiveCurrency : nil
|
||||||
let category = categories.first { $0.name == categoryName }
|
let category = categories.first { $0.name == categoryName }
|
||||||
?? FinanceCategory.categories(for: kind)[0]
|
?? FinanceCategory.categories(for: kind)[0]
|
||||||
if let transaction {
|
if let transaction {
|
||||||
@@ -92,6 +133,10 @@ struct WatchAddTransactionView: View {
|
|||||||
transaction.categoryColorName = category.colorName
|
transaction.categoryColorName = category.colorName
|
||||||
transaction.date = date
|
transaction.date = date
|
||||||
transaction.note = note
|
transaction.note = note
|
||||||
|
if transaction.accountID != newAccount {
|
||||||
|
transaction.accountID = newAccount
|
||||||
|
transaction.currency = newCurrency
|
||||||
|
}
|
||||||
transaction.syncUpdatedAt = .now
|
transaction.syncUpdatedAt = .now
|
||||||
} else {
|
} else {
|
||||||
modelContext.insert(FinanceTransaction(
|
modelContext.insert(FinanceTransaction(
|
||||||
@@ -102,7 +147,9 @@ struct WatchAddTransactionView: View {
|
|||||||
categoryIcon: category.icon,
|
categoryIcon: category.icon,
|
||||||
categoryEmoji: category.emoji,
|
categoryEmoji: category.emoji,
|
||||||
categoryColorName: category.colorName,
|
categoryColorName: category.colorName,
|
||||||
kind: kind
|
kind: kind,
|
||||||
|
accountID: newAccount,
|
||||||
|
currency: newCurrency
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
|
|||||||
@@ -29,17 +29,19 @@ private enum WatchAnalyticsPeriod: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
struct WatchAnalyticsView: View {
|
struct WatchAnalyticsView: View {
|
||||||
@Query private var transactions: [FinanceTransaction]
|
@Query private var transactions: [FinanceTransaction]
|
||||||
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
@Environment(\.currencyContext) private var currency
|
||||||
@State private var period = WatchAnalyticsPeriod.month
|
@State private var period = WatchAnalyticsPeriod.month
|
||||||
|
|
||||||
|
private var currencyCode: String { currency.base }
|
||||||
private var interval: DateInterval { period.interval() }
|
private var interval: DateInterval { period.interval() }
|
||||||
private var summary: FinancePeriodSummary {
|
private var summary: FinancePeriodSummary {
|
||||||
FinanceCalculations.summary(transactions: transactions, in: interval)
|
FinanceCalculations.summary(transactions: transactions, in: interval, context: currency)
|
||||||
}
|
}
|
||||||
private var spending: [CategorySpending] {
|
private var spending: [CategorySpending] {
|
||||||
Array(FinanceCalculations.spendingByCategory(
|
Array(FinanceCalculations.spendingByCategory(
|
||||||
transactions: transactions,
|
transactions: transactions,
|
||||||
in: interval
|
in: interval,
|
||||||
|
context: currency
|
||||||
).prefix(6))
|
).prefix(6))
|
||||||
}
|
}
|
||||||
private var savingsRate: Double {
|
private var savingsRate: Double {
|
||||||
@@ -53,13 +55,17 @@ struct WatchAnalyticsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section("Итоги") {
|
Section("Итоги") {
|
||||||
metric("Доходы", summary.income, .green)
|
metric("Доходы", summary.income, BalancePalette.income)
|
||||||
metric("Расходы", summary.expenses, .red)
|
metric("Расходы", summary.expenses, BalancePalette.expense)
|
||||||
LabeledContent("Результат", value: summary.balance.formattedCurrency(code: currencyCode))
|
LabeledContent("Результат", value: summary.balance.formattedCurrency(code: currencyCode))
|
||||||
LabeledContent(
|
LabeledContent(
|
||||||
"Сбережения",
|
"Сбережения",
|
||||||
value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%"
|
value: savingsRate.formatted(.number.precision(.fractionLength(0))) + "%"
|
||||||
)
|
)
|
||||||
|
if let warning = currency.missingRateWarning(summary.missing) {
|
||||||
|
Label(warning, systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.caption2).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Категории") {
|
Section("Категории") {
|
||||||
@@ -94,7 +100,7 @@ struct WatchAnalyticsView: View {
|
|||||||
|
|
||||||
private func tint(for item: CategorySpending) -> Color {
|
private func tint(for item: CategorySpending) -> Color {
|
||||||
item.colorName == "auto"
|
item.colorName == "auto"
|
||||||
? FinanceCategory.find(named: item.name)?.tint ?? .indigo
|
? FinanceCategory.find(named: item.name)?.tint ?? BalancePalette.forest
|
||||||
: FinanceCategory.color(named: item.colorName)
|
: FinanceCategory.color(named: item.colorName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +109,7 @@ struct WatchTransactionsView: View {
|
|||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Query(sort: \FinanceTransaction.date, order: .reverse)
|
@Query(sort: \FinanceTransaction.date, order: .reverse)
|
||||||
private var transactions: [FinanceTransaction]
|
private var transactions: [FinanceTransaction]
|
||||||
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
@Environment(\.currencyContext) private var currency
|
||||||
@State private var selectedKind: TransactionKind?
|
@State private var selectedKind: TransactionKind?
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var showingAdd = false
|
@State private var showingAdd = false
|
||||||
@@ -141,8 +147,11 @@ struct WatchTransactionsView: View {
|
|||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(transaction.categoryName).lineLimit(1)
|
Text(transaction.categoryName).lineLimit(1)
|
||||||
Text((transaction.kind == .income ? "+" : "−")
|
Text((transaction.kind == .income ? "+" : "−")
|
||||||
+ transaction.amount.formattedCurrency(code: currencyCode))
|
+ transaction.amount.formattedCurrency(code: currency.currency(of: transaction)))
|
||||||
.font(.caption2)
|
.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 {
|
private func tint(_ transaction: FinanceTransaction) -> Color {
|
||||||
transaction.categoryColorName == "auto"
|
transaction.categoryColorName == "auto"
|
||||||
? FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo
|
? FinanceCategory.find(named: transaction.categoryName)?.tint ?? BalancePalette.forest
|
||||||
: FinanceCategory.color(named: transaction.categoryColorName)
|
: FinanceCategory.color(named: transaction.categoryColorName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,9 +181,11 @@ struct WatchTransactionsView: View {
|
|||||||
struct WatchBudgetsView: View {
|
struct WatchBudgetsView: View {
|
||||||
@Query private var budgets: [MonthlyBudget]
|
@Query private var budgets: [MonthlyBudget]
|
||||||
@Query private var transactions: [FinanceTransaction]
|
@Query private var transactions: [FinanceTransaction]
|
||||||
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
@Environment(\.currencyContext) private var currency
|
||||||
@State private var showingEditor = false
|
@State private var showingEditor = false
|
||||||
|
|
||||||
|
private var currencyCode: String { currency.base }
|
||||||
|
|
||||||
private var interval: DateInterval {
|
private var interval: DateInterval {
|
||||||
FinanceCalculations.monthInterval(containing: .now)
|
FinanceCalculations.monthInterval(containing: .now)
|
||||||
}
|
}
|
||||||
@@ -184,7 +195,8 @@ struct WatchBudgetsView: View {
|
|||||||
private var spending: [String: Double] {
|
private var spending: [String: Double] {
|
||||||
Dictionary(uniqueKeysWithValues: FinanceCalculations.spendingByCategory(
|
Dictionary(uniqueKeysWithValues: FinanceCalculations.spendingByCategory(
|
||||||
transactions: transactions,
|
transactions: transactions,
|
||||||
in: interval
|
in: interval,
|
||||||
|
context: currency
|
||||||
).map { ($0.name, $0.amount) })
|
).map { ($0.name, $0.amount) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +267,7 @@ private struct WatchBudgetEditor: View {
|
|||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
for category in categories {
|
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 }
|
let existing = budgets.first { $0.categoryName == category.name }
|
||||||
if value > 0 {
|
if value > 0 {
|
||||||
if let existing {
|
if let existing {
|
||||||
@@ -429,11 +441,16 @@ struct WatchBalanceAdjustmentView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@Query private var transactions: [FinanceTransaction]
|
@Query private var transactions: [FinanceTransaction]
|
||||||
@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 desired = 0.0
|
||||||
@State private var note = ""
|
@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 }
|
private var difference: Double { desired - current }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -453,26 +470,53 @@ struct WatchBalanceAdjustmentView: View {
|
|||||||
private func save() {
|
private func save() {
|
||||||
guard abs(difference) >= 0.005 else { return }
|
guard abs(difference) >= 0.005 else { return }
|
||||||
modelContext.insert(FinanceTransaction(
|
modelContext.insert(FinanceTransaction(
|
||||||
amount: abs(difference),
|
amount: Money.double(Money.cents(abs(difference))),
|
||||||
note: note.isEmpty ? "Ручная корректировка баланса" : note,
|
note: note.isEmpty ? "Ручная корректировка баланса" : note,
|
||||||
categoryName: "Корректировка",
|
categoryName: "Корректировка",
|
||||||
categoryIcon: "slider.horizontal.3",
|
categoryIcon: "slider.horizontal.3",
|
||||||
categoryColorName: "indigo",
|
categoryColorName: "indigo",
|
||||||
isBalanceAdjustment: true,
|
isBalanceAdjustment: true,
|
||||||
kind: difference > 0 ? .income : .expense
|
kind: difference > 0 ? .income : .expense,
|
||||||
|
currency: account.supportsV2 ? currency.base : nil
|
||||||
))
|
))
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WatchSettingsView: View {
|
struct WatchSettingsView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.currencyContext) private var currency
|
||||||
|
@StateObject private var account = ServerAccountStore.shared
|
||||||
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
||||||
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
|
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
|
||||||
|
@Query(filter: #Predicate<SyncRecord> { $0.entityName == "setting" && $0.recordID == "baseCurrency" })
|
||||||
|
private var baseSetting: [SyncRecord]
|
||||||
|
|
||||||
|
private var currencyCodes: [String] {
|
||||||
|
let codes = SupportedCurrency.allCases.map(\.rawValue)
|
||||||
|
return codes.contains(currency.base) ? codes : codes + [currency.base]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Базовая валюта: на сервере v2 — общая настройка `baseCurrency`,
|
||||||
|
/// иначе — локальная настройка часов (как в v1).
|
||||||
|
private var baseCurrency: Binding<String> {
|
||||||
|
Binding(get: { currency.base }, set: { code in
|
||||||
|
currencyCode = code
|
||||||
|
guard account.supportsV2 || !baseSetting.isEmpty,
|
||||||
|
let payload = try? JSONSerialization.data(withJSONObject: ["value": code]) else { return }
|
||||||
|
if let record = baseSetting.first {
|
||||||
|
record.payload = payload
|
||||||
|
record.syncUpdatedAt = .now
|
||||||
|
} else {
|
||||||
|
modelContext.insert(SyncRecord(entityName: "setting", recordID: SyncRecord.baseCurrencyKey, payload: payload, syncUpdatedAt: .now))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Picker("Валюта", selection: $currencyCode) {
|
Picker("Валюта", selection: baseCurrency) {
|
||||||
ForEach(SupportedCurrency.allCases) { Text($0.rawValue).tag($0.rawValue) }
|
ForEach(currencyCodes, id: \.self) { Text($0).tag($0) }
|
||||||
}
|
}
|
||||||
Picker("Тема", selection: $appTheme) {
|
Picker("Тема", selection: $appTheme) {
|
||||||
ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) }
|
ForEach(AppTheme.allCases) { Text($0.title).tag($0.rawValue) }
|
||||||
|
|||||||
@@ -2,16 +2,29 @@ import SwiftData
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct WatchRootView: View {
|
struct WatchRootView: View {
|
||||||
|
var body: some View {
|
||||||
|
CurrencyContextProvider { WatchHomeView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct WatchHomeView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.currencyContext) private var currency
|
||||||
|
@StateObject private var account = ServerAccountStore.shared
|
||||||
@Query(sort: \FinanceTransaction.date, order: .reverse)
|
@Query(sort: \FinanceTransaction.date, order: .reverse)
|
||||||
private var transactions: [FinanceTransaction]
|
private var transactions: [FinanceTransaction]
|
||||||
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
@Query private var transfers: [FinanceTransfer]
|
||||||
@State private var showingAddTransaction = false
|
@State private var showingAddTransaction = false
|
||||||
|
|
||||||
|
private var currencyCode: String { currency.base }
|
||||||
|
private var total: MoneyTotal {
|
||||||
|
FinanceCalculations.totalBalance(transactions: transactions, transfers: transfers, context: currency)
|
||||||
|
}
|
||||||
private var monthSummary: FinancePeriodSummary {
|
private var monthSummary: FinancePeriodSummary {
|
||||||
FinanceCalculations.summary(
|
FinanceCalculations.summary(
|
||||||
transactions: transactions,
|
transactions: transactions,
|
||||||
in: FinanceCalculations.monthInterval(containing: .now)
|
in: FinanceCalculations.monthInterval(containing: .now),
|
||||||
|
context: currency
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,19 +33,27 @@ struct WatchRootView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text("Общий баланс")
|
HStack(spacing: 5) {
|
||||||
.font(.caption)
|
Image(systemName: "wallet.bifold")
|
||||||
.foregroundStyle(.secondary)
|
.font(.caption2.weight(.semibold))
|
||||||
Text(FinanceCalculations.totalBalance(transactions: transactions)
|
Text("Общий баланс")
|
||||||
.formattedCurrency(code: currencyCode))
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(BalancePalette.mint.opacity(0.85))
|
||||||
|
Text(total.value.formattedCurrency(code: currencyCode))
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
|
if let warning = currency.missingRateWarning(total.missing.union(monthSummary.missing)) {
|
||||||
|
Label(warning, systemImage: "exclamationmark.triangle")
|
||||||
|
.font(.caption2)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(
|
.background(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [.indigo, .purple],
|
colors: [BalancePalette.cardTop, BalancePalette.cardBottom],
|
||||||
startPoint: .topLeading,
|
startPoint: .topLeading,
|
||||||
endPoint: .bottomTrailing
|
endPoint: .bottomTrailing
|
||||||
),
|
),
|
||||||
@@ -43,13 +64,15 @@ struct WatchRootView: View {
|
|||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
watchMetric(
|
watchMetric(
|
||||||
title: "Доходы",
|
title: "Доходы",
|
||||||
|
symbol: "arrow.down.left",
|
||||||
amount: monthSummary.income,
|
amount: monthSummary.income,
|
||||||
tint: .green
|
tint: BalancePalette.income
|
||||||
)
|
)
|
||||||
watchMetric(
|
watchMetric(
|
||||||
title: "Расходы",
|
title: "Расходы",
|
||||||
|
symbol: "arrow.up.right",
|
||||||
amount: monthSummary.expenses,
|
amount: monthSummary.expenses,
|
||||||
tint: .red
|
tint: BalancePalette.expense
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +83,16 @@ struct WatchRootView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(BalancePalette.forest)
|
||||||
|
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
|
if account.supportsV2 {
|
||||||
|
NavigationLink {
|
||||||
|
WatchAccountsView()
|
||||||
|
} label: {
|
||||||
|
Label("Счета", systemImage: "creditcard")
|
||||||
|
}
|
||||||
|
}
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
WatchAnalyticsView()
|
WatchAnalyticsView()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -111,9 +142,9 @@ struct WatchRootView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text((transaction.kind == .income ? "+" : "−")
|
Text((transaction.kind == .income ? "+" : "−")
|
||||||
+ transaction.amount.formattedCurrency(code: currencyCode))
|
+ transaction.amount.formattedCurrency(code: currency.currency(of: transaction)))
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundStyle(transaction.kind == .income ? .green : .secondary)
|
.foregroundStyle(transaction.kind == .income ? BalancePalette.income : .secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -121,7 +152,7 @@ struct WatchRootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Баланс")
|
.navigationTitle(account.ledgerID.isEmpty ? "Баланс" : account.ledgerName)
|
||||||
.sheet(isPresented: $showingAddTransaction) {
|
.sheet(isPresented: $showingAddTransaction) {
|
||||||
WatchAddTransactionView()
|
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) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(title)
|
Label(title, systemImage: symbol)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.labelStyle(TintedIconLabelStyle(tint: tint))
|
||||||
Text(amount.formattedCurrency(code: currencyCode))
|
Text(amount.formattedCurrency(code: currencyCode))
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(tint)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.65)
|
.minimumScaleFactor(0.65)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(9)
|
.padding(9)
|
||||||
.background(tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 12))
|
.background(tint.opacity(0.14), in: RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func transactionTint(_ transaction: FinanceTransaction) -> Color {
|
private func transactionTint(_ transaction: FinanceTransaction) -> Color {
|
||||||
if transaction.categoryColorName == "auto" {
|
if transaction.categoryColorName == "auto" {
|
||||||
return FinanceCategory.find(named: transaction.categoryName)?.tint ?? .indigo
|
return FinanceCategory.find(named: transaction.categoryName)?.tint ?? BalancePalette.forest
|
||||||
}
|
}
|
||||||
return FinanceCategory.color(named: transaction.categoryColorName)
|
return FinanceCategory.color(named: transaction.categoryColorName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Иконка метрики в цвете метрики, подпись — вторичным цветом.
|
||||||
|
private struct TintedIconLabelStyle: LabelStyle {
|
||||||
|
let tint: Color
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
configuration.icon.foregroundStyle(tint)
|
||||||
|
configuration.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.3 KiB |
@@ -11,6 +11,8 @@ final class CustomCategory {
|
|||||||
var kindRawValue: String = "expense"
|
var kindRawValue: String = "expense"
|
||||||
var createdAt: Date = Date.now
|
var createdAt: Date = Date.now
|
||||||
var syncUpdatedAt: Date = Date.now
|
var syncUpdatedAt: Date = Date.now
|
||||||
|
var syncID: String?
|
||||||
|
var extraPayload: Data?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -29,9 +31,12 @@ final class CustomCategory {
|
|||||||
self.colorName = colorName
|
self.colorName = colorName
|
||||||
self.kindRawValue = kind.rawValue
|
self.kindRawValue = kind.rawValue
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
|
self.syncID = id.uuidString.lowercased()
|
||||||
self.syncUpdatedAt = syncUpdatedAt
|
self.syncUpdatedAt = syncUpdatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var wireID: String { syncID ?? id.uuidString }
|
||||||
|
|
||||||
var kind: TransactionKind {
|
var kind: TransactionKind {
|
||||||
get { TransactionKind(rawValue: kindRawValue) ?? .expense }
|
get { TransactionKind(rawValue: kindRawValue) ?? .expense }
|
||||||
set { kindRawValue = newValue.rawValue }
|
set { kindRawValue = newValue.rawValue }
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ final class FinanceTransaction {
|
|||||||
var isBalanceAdjustment: Bool = false
|
var isBalanceAdjustment: Bool = false
|
||||||
var kindRawValue: String = "expense"
|
var kindRawValue: String = "expense"
|
||||||
var syncUpdatedAt: Date = Date.now
|
var syncUpdatedAt: Date = Date.now
|
||||||
|
/// v2: счёт (`accountId` на проводе), `nil` — «Без счёта».
|
||||||
|
var accountID: String?
|
||||||
|
/// v2: валюта суммы; `nil` — валюта счёта, иначе базовая.
|
||||||
|
var currency: String?
|
||||||
|
/// Идентификатор записи на проводе. `nil` у записей, созданных до v2:
|
||||||
|
/// для них остаётся `id.uuidString`, чтобы не плодить дубли на сервере.
|
||||||
|
var syncID: String?
|
||||||
|
/// Незнакомые поля payload (теги, recurringId, …) — отправляются обратно как есть.
|
||||||
|
var extraPayload: Data?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -40,6 +49,8 @@ final class FinanceTransaction {
|
|||||||
categoryColorName: String = "auto",
|
categoryColorName: String = "auto",
|
||||||
isBalanceAdjustment: Bool = false,
|
isBalanceAdjustment: Bool = false,
|
||||||
kind: TransactionKind,
|
kind: TransactionKind,
|
||||||
|
accountID: String? = nil,
|
||||||
|
currency: String? = nil,
|
||||||
syncUpdatedAt: Date = .now
|
syncUpdatedAt: Date = .now
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -52,9 +63,14 @@ final class FinanceTransaction {
|
|||||||
self.categoryColorName = categoryColorName
|
self.categoryColorName = categoryColorName
|
||||||
self.isBalanceAdjustment = isBalanceAdjustment
|
self.isBalanceAdjustment = isBalanceAdjustment
|
||||||
self.kindRawValue = kind.rawValue
|
self.kindRawValue = kind.rawValue
|
||||||
|
self.accountID = accountID
|
||||||
|
self.currency = currency
|
||||||
|
self.syncID = id.uuidString.lowercased()
|
||||||
self.syncUpdatedAt = syncUpdatedAt
|
self.syncUpdatedAt = syncUpdatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var wireID: String { syncID ?? id.uuidString }
|
||||||
|
|
||||||
var kind: TransactionKind {
|
var kind: TransactionKind {
|
||||||
get { TransactionKind(rawValue: kindRawValue) ?? .expense }
|
get { TransactionKind(rawValue: kindRawValue) ?? .expense }
|
||||||
set { kindRawValue = newValue.rawValue }
|
set { kindRawValue = newValue.rawValue }
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ final class MonthlyBudget {
|
|||||||
var limit: Double = 0
|
var limit: Double = 0
|
||||||
var monthStart: Date = Date.now
|
var monthStart: Date = Date.now
|
||||||
var syncUpdatedAt: Date = Date.now
|
var syncUpdatedAt: Date = Date.now
|
||||||
|
var syncID: String?
|
||||||
|
var extraPayload: Data?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
@@ -26,6 +28,9 @@ final class MonthlyBudget {
|
|||||||
self.categoryEmoji = categoryEmoji
|
self.categoryEmoji = categoryEmoji
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.monthStart = monthStart
|
self.monthStart = monthStart
|
||||||
|
self.syncID = id.uuidString.lowercased()
|
||||||
self.syncUpdatedAt = syncUpdatedAt
|
self.syncUpdatedAt = syncUpdatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var wireID: String { syncID ?? id.uuidString }
|
||||||
}
|
}
|
||||||
@@ -30,8 +30,9 @@ struct SyncDeletionRecord: Codable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SyncDeletionStore {
|
enum SyncDeletionStore {
|
||||||
private static let storageKey = "balance.server.deletionJournal.v1"
|
// Журнал свой у каждого пространства (§7): у личного — прежний ключ.
|
||||||
private static let quarantineKey = "balance.server.deletionJournal.corrupt"
|
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()
|
private static let lock = NSLock()
|
||||||
|
|
||||||
static func add(entity: String, recordID: String, updatedAt: Date = .now) {
|
static func add(entity: String, recordID: String, updatedAt: Date = .now) {
|
||||||
@@ -93,17 +94,17 @@ enum SyncDeletionStore {
|
|||||||
|
|
||||||
extension ModelContext {
|
extension ModelContext {
|
||||||
func deleteForSync(_ transaction: FinanceTransaction) {
|
func deleteForSync(_ transaction: FinanceTransaction) {
|
||||||
SyncDeletionStore.add(entity:"transaction", recordID:transaction.id.uuidString)
|
SyncDeletionStore.add(entity:"transaction", recordID:transaction.wireID)
|
||||||
delete(transaction)
|
delete(transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteForSync(_ category: CustomCategory) {
|
func deleteForSync(_ category: CustomCategory) {
|
||||||
SyncDeletionStore.add(entity:"category", recordID:category.id.uuidString)
|
SyncDeletionStore.add(entity:"category", recordID:category.wireID)
|
||||||
delete(category)
|
delete(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteForSync(_ budget: MonthlyBudget) {
|
func deleteForSync(_ budget: MonthlyBudget) {
|
||||||
SyncDeletionStore.add(entity:"budget", recordID:budget.id.uuidString)
|
SyncDeletionStore.add(entity:"budget", recordID:budget.wireID)
|
||||||
delete(budget)
|
delete(budget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<String> = ["recurring", "goal", "debt", "attachment", "setting"]
|
||||||
|
static let baseCurrencyKey = "baseCurrency"
|
||||||
|
|
||||||
|
/// Значение `setting` (`{"value": "USD"}`), если это строка.
|
||||||
|
var stringValue: String? {
|
||||||
|
(try? JSONSerialization.jsonObject(with: payload) as? [String: Any])?["value"] as? String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FinanceTransaction: LedgerEntry {
|
||||||
|
var isIncome: Bool { kind == .income }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FinanceTransfer: LedgerTransfer {
|
||||||
|
var incomingAmount: Double { toAmount ?? amount }
|
||||||
|
}
|
||||||
@@ -23,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)
|
||||||
|
}
|
||||||
@@ -6,15 +6,19 @@ enum BalanceModelContainer {
|
|||||||
FinanceTransaction.self,
|
FinanceTransaction.self,
|
||||||
MonthlyBudget.self,
|
MonthlyBudget.self,
|
||||||
CustomCategory.self,
|
CustomCategory.self,
|
||||||
SyncTombstone.self
|
SyncTombstone.self,
|
||||||
|
FinanceAccount.self,
|
||||||
|
FinanceTransfer.self,
|
||||||
|
ExchangeRate.self,
|
||||||
|
SyncRecord.self
|
||||||
])
|
])
|
||||||
|
|
||||||
static func make() -> ModelContainer {
|
/// `scope` пустой — личные данные (прежнее хранилище по умолчанию);
|
||||||
let localConfiguration = ModelConfiguration(
|
/// иначе — отдельное хранилище общего пространства (сервер + пользователь + ledger).
|
||||||
schema: schema,
|
static func make(scope: String = "") -> ModelContainer {
|
||||||
isStoredInMemoryOnly: false,
|
let localConfiguration = scope.isEmpty
|
||||||
cloudKitDatabase: .none
|
? ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none)
|
||||||
)
|
: ModelConfiguration("BalanceLedger-\(scope)", schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
return try ModelContainer(
|
return try ModelContainer(
|
||||||
@@ -23,7 +27,7 @@ enum BalanceModelContainer {
|
|||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
let recoveryConfiguration = ModelConfiguration(
|
let recoveryConfiguration = ModelConfiguration(
|
||||||
"BalanceRecovery",
|
scope.isEmpty ? "BalanceRecovery" : "BalanceRecovery-\(scope)",
|
||||||
schema: schema,
|
schema: schema,
|
||||||
isStoredInMemoryOnly: true,
|
isStoredInMemoryOnly: true,
|
||||||
cloudKitDatabase: .none
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
import SwiftData
|
import SwiftData
|
||||||
@@ -88,12 +89,53 @@ private struct SyncResponse: Decodable {
|
|||||||
private struct TransactionPayload: Codable {
|
private struct TransactionPayload: Codable {
|
||||||
let amount: Double; let date: Date; let note: String; let categoryName: String; let categoryIcon: String
|
let amount: Double; let date: Date; let note: String; let categoryName: String; let categoryIcon: String
|
||||||
let categoryEmoji: String; let categoryColorName: String; let isBalanceAdjustment: Bool; let kindRawValue: String
|
let categoryEmoji: String; let categoryColorName: String; let isBalanceAdjustment: Bool; let kindRawValue: String
|
||||||
|
var accountId: String? = nil; var currency: String? = nil
|
||||||
|
static let keys: Set<String> = ["amount", "date", "note", "categoryName", "categoryIcon", "categoryEmoji", "categoryColorName", "isBalanceAdjustment", "kindRawValue", "accountId", "currency"]
|
||||||
}
|
}
|
||||||
private struct CategoryPayload: Codable {
|
private struct CategoryPayload: Codable {
|
||||||
let name: String; let icon: String; let emoji: String; let colorName: String; let kindRawValue: String; let createdAt: Date
|
let name: String; let icon: String; let emoji: String; let colorName: String; let kindRawValue: String; let createdAt: Date
|
||||||
|
static let keys: Set<String> = ["name", "icon", "emoji", "colorName", "kindRawValue", "createdAt"]
|
||||||
}
|
}
|
||||||
private struct BudgetPayload: Codable {
|
private struct BudgetPayload: Codable {
|
||||||
let categoryName: String; let categoryIcon: String; let categoryEmoji: String; let limit: Double; let monthStart: Date
|
let categoryName: String; let categoryIcon: String; let categoryEmoji: String; let limit: Double; let monthStart: Date
|
||||||
|
static let keys: Set<String> = ["categoryName", "categoryIcon", "categoryEmoji", "limit", "monthStart"]
|
||||||
|
}
|
||||||
|
// v2: все поля необязательные (§0.3), отсутствие — значение по умолчанию.
|
||||||
|
private struct AccountPayload: Decodable {
|
||||||
|
let name: String?; let kindRawValue: String?; let icon: String?; let colorName: String?; let currency: String?
|
||||||
|
let createdAt: Date?; let isArchived: Bool?; let sortOrder: Int?
|
||||||
|
static let keys: Set<String> = ["name", "kindRawValue", "icon", "colorName", "currency", "createdAt", "isArchived", "sortOrder"]
|
||||||
|
}
|
||||||
|
private struct TransferPayload: Decodable {
|
||||||
|
let fromAccountId: String?; let toAccountId: String?; let amount: Double?; let toAmount: Double?; let date: Date?; let note: String?
|
||||||
|
static let keys: Set<String> = ["fromAccountId", "toAccountId", "amount", "toAmount", "date", "note"]
|
||||||
|
}
|
||||||
|
private struct RatePayload: Decodable {
|
||||||
|
let from: String?; let to: String?; let rate: String?; let date: Date?
|
||||||
|
static let keys: Set<String> = ["from", "to", "rate", "date"]
|
||||||
|
}
|
||||||
|
struct ServerCapabilities: Codable {
|
||||||
|
let contract: Int
|
||||||
|
let ledgers: Bool?
|
||||||
|
}
|
||||||
|
struct ServerLedger: Codable, Identifiable, Hashable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let role: String?
|
||||||
|
}
|
||||||
|
private struct LedgerList: Decodable { let ledgers: [ServerLedger] }
|
||||||
|
|
||||||
|
/// Выбранное пространство данных (§7). Пустой id — личное.
|
||||||
|
enum LedgerSelection {
|
||||||
|
static let idKey = "balance.server.ledger.id"
|
||||||
|
static let nameKey = "balance.server.ledger.name"
|
||||||
|
/// Ключ хранилища: хеш сервер + пользователь + ledger. Пустой — личное.
|
||||||
|
static let scopeKey = "balance.server.ledger.scope"
|
||||||
|
|
||||||
|
static var id: String { UserDefaults.standard.string(forKey: idKey) ?? "" }
|
||||||
|
static var scope: String { UserDefaults.standard.string(forKey: scopeKey) ?? "" }
|
||||||
|
/// Суффикс ключей курсора, push-state и журнала удалений.
|
||||||
|
static var keySuffix: String { scope.isEmpty ? "" : ".ledger.\(scope)" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum ServerCoding {
|
private enum ServerCoding {
|
||||||
@@ -124,6 +166,21 @@ private enum ServerCoding {
|
|||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Известные поля поверх сохранённых незнакомых (§0.2).
|
||||||
|
static func payload<T: Encodable>(_ value: T, extras: Data?) throws -> Data {
|
||||||
|
var object = extras.flatMap { try? decoder.decode([String: JSONValue].self, from: $0) } ?? [:]
|
||||||
|
let known = try decoder.decode([String: JSONValue].self, from: encoder.encode(value))
|
||||||
|
object.merge(known) { $1 }
|
||||||
|
return try encoder.encode(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Незнакомые поля payload; `nil`, если их нет.
|
||||||
|
static func extras(_ data: Data, known: Set<String>) -> Data? {
|
||||||
|
guard var object = try? decoder.decode([String: JSONValue].self, from: data) else { return nil }
|
||||||
|
for key in known { object.removeValue(forKey: key) }
|
||||||
|
return object.isEmpty ? nil : try? encoder.encode(object)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ServerSyncError: LocalizedError {
|
enum ServerSyncError: LocalizedError {
|
||||||
@@ -145,8 +202,12 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
@Published private(set) var isBusy = false
|
@Published private(set) var isBusy = false
|
||||||
@Published private(set) var lastSyncAt: Date?
|
@Published private(set) var lastSyncAt: Date?
|
||||||
@Published var message: String?
|
@Published var message: String?
|
||||||
|
@Published private(set) var capabilities: ServerCapabilities?
|
||||||
|
@Published private(set) var ledgerID = LedgerSelection.id
|
||||||
|
@Published private(set) var ledgerName = UserDefaults.standard.string(forKey: LedgerSelection.nameKey) ?? ""
|
||||||
|
|
||||||
private let defaults = UserDefaults.standard
|
private let defaults = UserDefaults.standard
|
||||||
|
private let capabilitiesKey = "balance.server.capabilities"
|
||||||
private var session: ServerSession?
|
private var session: ServerSession?
|
||||||
private let sessionKey = "balance.server.session"
|
private let sessionKey = "balance.server.session"
|
||||||
private let serverKey = "balance.server.url"
|
private let serverKey = "balance.server.url"
|
||||||
@@ -154,12 +215,17 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
|
|
||||||
var serverURL: String { defaults.string(forKey: serverKey) ?? "" }
|
var serverURL: String { defaults.string(forKey: serverKey) ?? "" }
|
||||||
var isSignedIn: Bool { session != nil }
|
var isSignedIn: Bool { session != nil }
|
||||||
|
/// Сервер v2 (`GET /v1/capabilities` ответил). На v1 (404) счета,
|
||||||
|
/// курсы, общие пространства скрыты и новые сущности не отправляются.
|
||||||
|
var supportsV2: Bool { isSignedIn && (capabilities?.contract ?? 1) >= 2 }
|
||||||
|
var supportsLedgers: Bool { supportsV2 && capabilities?.ledgers == true }
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
if let data = KeychainStore.read(sessionKey), let value = try? ServerCoding.decoder.decode(ServerSession.self, from: data) {
|
if let data = KeychainStore.read(sessionKey), let value = try? ServerCoding.decoder.decode(ServerSession.self, from: data) {
|
||||||
session = value; signedInEmail = value.user.email
|
session = value; signedInEmail = value.user.email
|
||||||
}
|
}
|
||||||
lastSyncAt = defaults.object(forKey: "balance.server.lastSync") as? Date
|
lastSyncAt = defaults.object(forKey: "balance.server.lastSync") as? Date
|
||||||
|
if let data = defaults.data(forKey: capabilitiesKey) { capabilities = try? JSONDecoder().decode(ServerCapabilities.self, from: data) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveServer(_ value: String) throws {
|
func saveServer(_ value: String) throws {
|
||||||
@@ -167,7 +233,10 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.")
|
throw ServerSyncError.response("Дождитесь завершения текущей синхронизации.")
|
||||||
}
|
}
|
||||||
let normalized = try normalizedServer(value)
|
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) }
|
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 {
|
func logout() async {
|
||||||
if let refresh = session?.refreshToken { _ = try? await request(path: "v1/auth/logout", body: ["refreshToken": refresh], authorized: false) as EmptyResponse }
|
if let refresh = session?.refreshToken { _ = try? await request(path: "v1/auth/logout", body: ["refreshToken": refresh], authorized: false) as EmptyResponse }
|
||||||
clearSession(); message = "Вы вышли из аккаунта."
|
clearSession(); selectLedger(nil); message = "Вы вышли из аккаунта."
|
||||||
}
|
}
|
||||||
|
|
||||||
func sync(context: ModelContext) async {
|
func sync(context: ModelContext) async {
|
||||||
@@ -184,6 +253,10 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
defer { isBusy = false }
|
defer { isBusy = false }
|
||||||
do {
|
do {
|
||||||
try context.save()
|
try context.save()
|
||||||
|
await refreshCapabilities()
|
||||||
|
if !ledgerID.isEmpty && !supportsLedgers {
|
||||||
|
throw ServerSyncError.response("Сервер не поддерживает общие бюджеты. Выберите личное пространство.")
|
||||||
|
}
|
||||||
let cutoff = Date.now
|
let cutoff = Date.now
|
||||||
let lastPush = defaults.object(forKey: pushKey()) as? Date ?? .distantPast
|
let lastPush = defaults.object(forKey: pushKey()) as? Date ?? .distantPast
|
||||||
let threshold = lastPush <= cutoff.addingTimeInterval(5)
|
let threshold = lastPush <= cutoff.addingTimeInterval(5)
|
||||||
@@ -194,6 +267,7 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
try await pushTransactions(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
try await pushTransactions(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
||||||
try await pushCategories(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
try await pushCategories(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
||||||
try await pushBudgets(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
try await pushBudgets(context: context, after: threshold, through: cutoff, cursor: initialCursor)
|
||||||
|
if supportsV2 { try await pushSettings(context: context, after: threshold, through: cutoff, cursor: initialCursor) }
|
||||||
try await pushTombstones(through: cutoff, cursor: initialCursor)
|
try await pushTombstones(through: cutoff, cursor: initialCursor)
|
||||||
try await pullChanges(context: context, cursor: initialCursor)
|
try await pullChanges(context: context, cursor: initialCursor)
|
||||||
|
|
||||||
@@ -222,22 +296,36 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
} catch { message = error.localizedDescription }
|
} catch { message = error.localizedDescription }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func request<Response: Decodable, Body: Encodable>(path: String, body: Body, authorized: Bool, retry: Bool = true) async throws -> Response {
|
private func request<Response: Decodable>(path: String, method: String = "POST", body: (any Encodable)?, authorized: Bool, retry: Bool = true) async throws -> Response {
|
||||||
if authorized { try await refreshIfNeeded() }
|
if authorized { try await refreshIfNeeded() }
|
||||||
let base = try normalizedServer(serverURL)
|
let base = try normalizedServer(serverURL)
|
||||||
guard let url = URL(string: base + "/" + path) else { throw ServerSyncError.invalidURL }
|
guard let url = URL(string: base + "/" + path) else { throw ServerSyncError.invalidURL }
|
||||||
var request = URLRequest(url: url); request.httpMethod = "POST"
|
var request = URLRequest(url: url); request.httpMethod = method
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
if authorized, let token = session?.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
if authorized, let token = session?.accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
|
||||||
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)
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
guard let http = response as? HTTPURLResponse else { throw ServerSyncError.response("Сервер не вернул HTTP-ответ.") }
|
guard let http = response as? HTTPURLResponse else { throw ServerSyncError.response("Сервер не вернул HTTP-ответ.") }
|
||||||
if http.statusCode == 401, authorized, retry { try await refreshSession(); return try await self.request(path:path, 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 {
|
guard (200..<300).contains(http.statusCode) else {
|
||||||
let envelope = try? ServerCoding.decoder.decode(ServerErrorEnvelope.self, from: data)
|
let envelope = try? ServerCoding.decoder.decode(ServerErrorEnvelope.self, from: data)
|
||||||
if path == "v1/sync", envelope?.error.code == "invalid_json" {
|
if path == "v1/sync", envelope?.error.code == "invalid_json" {
|
||||||
throw ServerSyncError.response("Обновите Balance Server до версии из текущего архива приложения.")
|
throw ServerSyncError.response("Обновите Balance Server до версии из текущего архива приложения.")
|
||||||
}
|
}
|
||||||
|
if path.hasPrefix("v1/ledgers") || (path == "v1/sync" && !ledgerID.isEmpty) {
|
||||||
|
switch envelope?.error.code {
|
||||||
|
case "invalid_code": throw ServerSyncError.response("Код приглашения неверный или устарел.")
|
||||||
|
case "forbidden": throw ServerSyncError.response("Нет доступа к общему бюджету.")
|
||||||
|
case "not_found": throw ServerSyncError.response("Общий бюджет не найден.")
|
||||||
|
case "owner_cannot_leave": throw ServerSyncError.response("Владелец не может выйти, пока есть участники.")
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
throw ServerSyncError.response(envelope?.error.message ?? "Ошибка сервера: HTTP \(http.statusCode)")
|
throw ServerSyncError.response(envelope?.error.message ?? "Ошибка сервера: HTTP \(http.statusCode)")
|
||||||
}
|
}
|
||||||
if Response.self == EmptyResponse.self { return EmptyResponse() as! Response }
|
if Response.self == EmptyResponse.self { return EmptyResponse() as! Response }
|
||||||
@@ -260,14 +348,69 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func clearSession() { session = nil; signedInEmail = nil; KeychainStore.delete(sessionKey) }
|
private func clearSession() { session = nil; signedInEmail = nil; KeychainStore.delete(sessionKey) }
|
||||||
|
|
||||||
|
// MARK: Возможности сервера и пространства (v2)
|
||||||
|
|
||||||
|
private func setCapabilities(_ value: ServerCapabilities?) {
|
||||||
|
capabilities = value
|
||||||
|
if let value, let data = try? JSONEncoder().encode(value) { defaults.set(data, forKey: capabilitiesKey) }
|
||||||
|
else { defaults.removeObject(forKey: capabilitiesKey) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 404 — сервер v1. Сетевая ошибка оставляет прежнее значение.
|
||||||
|
private func refreshCapabilities() async {
|
||||||
|
guard let base = try? normalizedServer(serverURL), let url = URL(string: base + "/v1/capabilities"),
|
||||||
|
let (data, response) = try? await URLSession.shared.data(from: url),
|
||||||
|
let http = response as? HTTPURLResponse else { return }
|
||||||
|
if http.statusCode == 404 { setCapabilities(ServerCapabilities(contract: 1, ledgers: false)) }
|
||||||
|
else if (200..<300).contains(http.statusCode), let value = try? JSONDecoder().decode(ServerCapabilities.self, from: data) {
|
||||||
|
setCapabilities(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Общие пространства пользователя (личное в список не входит).
|
||||||
|
func loadLedgers() async -> [ServerLedger]? {
|
||||||
|
guard session != nil, !isBusy else { return nil }
|
||||||
|
isBusy = true; defer { isBusy = false }
|
||||||
|
do {
|
||||||
|
let list: LedgerList = try await request(path: "v1/ledgers", method: "GET", body: nil, authorized: true)
|
||||||
|
return list.ledgers
|
||||||
|
} catch { message = error.localizedDescription; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinLedger(code: String) async -> ServerLedger? {
|
||||||
|
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard session != nil, !isBusy, !trimmed.isEmpty else { return nil }
|
||||||
|
isBusy = true; defer { isBusy = false }
|
||||||
|
do {
|
||||||
|
let ledger: ServerLedger = try await request(path: "v1/ledgers/join", body: ["code": trimmed], authorized: true)
|
||||||
|
message = "Вы присоединились к «\(ledger.name)»."
|
||||||
|
return ledger
|
||||||
|
} catch { message = error.localizedDescription; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nil` — личное пространство. Данные, курсор и очередь у каждого
|
||||||
|
/// пространства свои; записи при переключении не переносятся.
|
||||||
|
func selectLedger(_ ledger: ServerLedger?) {
|
||||||
|
guard !isBusy || ledger == nil else { return }
|
||||||
|
ledgerID = ledger?.id ?? ""; ledgerName = ledger?.name ?? ""
|
||||||
|
defaults.set(ledgerID, forKey: LedgerSelection.idKey)
|
||||||
|
defaults.set(ledgerName, forKey: LedgerSelection.nameKey)
|
||||||
|
defaults.set(ledger.map { ledgerScope(for: $0.id) } ?? "", forKey: LedgerSelection.scopeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ledgerScope(for id: String) -> String {
|
||||||
|
let source = "\(serverURL)|\(session?.user.id ?? "")|\(id.lowercased())"
|
||||||
|
return SHA256.hash(data: Data(source.utf8)).prefix(16).map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
private func clearSyncState() {
|
private func clearSyncState() {
|
||||||
for key in defaults.dictionaryRepresentation().keys
|
for key in defaults.dictionaryRepresentation().keys
|
||||||
where key.hasPrefix("balance.server.cursor.") || key.hasPrefix("balance.server.lastPush.") {
|
where key.hasPrefix("balance.server.cursor.") || key.hasPrefix("balance.server.lastPush.") {
|
||||||
defaults.removeObject(forKey: key)
|
defaults.removeObject(forKey: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private func cursorKey() -> String { "balance.server.cursor.\(serverURL).\(session?.user.id ?? "anonymous")" }
|
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")" }
|
private func pushKey() -> String { "balance.server.lastPush.\(serverURL).\(session?.user.id ?? "anonymous")" + LedgerSelection.keySuffix }
|
||||||
private func deviceID() -> String {
|
private func deviceID() -> String {
|
||||||
if let value = defaults.string(forKey: "balance.server.deviceID") { return value }
|
if let value = defaults.string(forKey: "balance.server.deviceID") { return value }
|
||||||
let value = UUID().uuidString; defaults.set(value, forKey: "balance.server.deviceID"); return value
|
let value = UUID().uuidString; defaults.set(value, forKey: "balance.server.deviceID"); return value
|
||||||
@@ -289,8 +432,8 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
let items = try context.fetch(descriptor)
|
let items = try context.fetch(descriptor)
|
||||||
guard !items.isEmpty else { break }
|
guard !items.isEmpty else { break }
|
||||||
let changes = try items.map { item in
|
let changes = try items.map { item in
|
||||||
let value = TransactionPayload(amount:item.amount, date:item.date, note:item.note, categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, categoryColorName:item.categoryColorName, isBalanceAdjustment:item.isBalanceAdjustment, kindRawValue:item.kindRawValue)
|
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.id.uuidString, deleted:false, updatedAt:item.syncUpdatedAt, payload:try ServerCoding.encoder.encode(value))
|
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)
|
try await sendPush(changes, cursor: cursor)
|
||||||
offset += items.count
|
offset += items.count
|
||||||
@@ -316,7 +459,7 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
guard !items.isEmpty else { break }
|
guard !items.isEmpty else { break }
|
||||||
let changes = try items.map { item in
|
let changes = try items.map { item in
|
||||||
let value = CategoryPayload(name:item.name, icon:item.icon, emoji:item.emoji, colorName:item.colorName, kindRawValue:item.kindRawValue, createdAt:item.createdAt)
|
let value = CategoryPayload(name:item.name, icon:item.icon, emoji:item.emoji, colorName:item.colorName, kindRawValue:item.kindRawValue, createdAt:item.createdAt)
|
||||||
return SyncChange(entity:"category", id:item.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)
|
try await sendPush(changes, cursor: cursor)
|
||||||
offset += items.count
|
offset += items.count
|
||||||
@@ -342,7 +485,7 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
guard !items.isEmpty else { break }
|
guard !items.isEmpty else { break }
|
||||||
let changes = try items.map { item in
|
let changes = try items.map { item in
|
||||||
let value = BudgetPayload(categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, limit:item.limit, monthStart:item.monthStart)
|
let value = BudgetPayload(categoryName:item.categoryName, categoryIcon:item.categoryIcon, categoryEmoji:item.categoryEmoji, limit:item.limit, monthStart:item.monthStart)
|
||||||
return SyncChange(entity:"budget", id:item.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)
|
try await sendPush(changes, cursor: cursor)
|
||||||
offset += items.count
|
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<SyncRecord>(predicate: #Predicate { item in
|
||||||
|
item.entityName == "setting" && item.syncUpdatedAt >= lowerBound && item.syncUpdatedAt <= upperBound
|
||||||
|
}))
|
||||||
|
try await sendPush(items.map { SyncChange(entity:$0.entityName, id:$0.recordID, deleted:false, updatedAt:$0.syncUpdatedAt, payload:$0.payload) }, cursor:cursor)
|
||||||
|
}
|
||||||
|
|
||||||
private func pushTombstones(through cutoff: Date, cursor: Int64) async throws {
|
private func pushTombstones(through cutoff: Date, cursor: Int64) async throws {
|
||||||
var offset = 0
|
var offset = 0
|
||||||
while true {
|
while true {
|
||||||
@@ -408,36 +560,114 @@ final class ServerAccountStore: ObservableObject {
|
|||||||
|
|
||||||
private func apply(_ changes: [SyncChange], context: ModelContext) throws {
|
private func apply(_ changes: [SyncChange], context: ModelContext) throws {
|
||||||
for change in changes.sorted(by: { ($0.sequence ?? 0) < ($1.sequence ?? 0) }) {
|
for change in changes.sorted(by: { ($0.sequence ?? 0) < ($1.sequence ?? 0) }) {
|
||||||
if let id = UUID(uuidString:change.id) {
|
switch change.entity {
|
||||||
if change.deleted {
|
case "transaction", "category", "budget":
|
||||||
switch change.entity {
|
if let id = UUID(uuidString:change.id) { try applyV1(change, id:id, context:context) }
|
||||||
case "transaction": if let item = try transaction(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
case "account", "transfer", "rate":
|
||||||
case "category": if let item = try category(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
try applyV2(change, context:context)
|
||||||
case "budget": if let item = try budget(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
case let entity where SyncRecord.storedEntities.contains(entity):
|
||||||
default: break
|
try applyRecord(change, context:context)
|
||||||
}
|
default:
|
||||||
} else if let data = change.payload {
|
break // незнакомая сущность: пропускаем, курсор двигается (§0.1)
|
||||||
switch change.entity {
|
|
||||||
case "transaction":
|
|
||||||
guard let value = try? ServerCoding.decoder.decode(TransactionPayload.self, from:data) else { continue }
|
|
||||||
if let item = try transaction(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
|
|
||||||
else { context.insert(FinanceTransaction(id:id, amount:value.amount, date:value.date, note:value.note, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, categoryColorName:value.categoryColorName, isBalanceAdjustment:value.isBalanceAdjustment, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, syncUpdatedAt:change.updatedAt)) }
|
|
||||||
case "category":
|
|
||||||
guard let value = try? ServerCoding.decoder.decode(CategoryPayload.self, from:data) else { continue }
|
|
||||||
if let item = try category(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
|
|
||||||
else { context.insert(CustomCategory(id:id, name:value.name, icon:value.icon, emoji:value.emoji, colorName:value.colorName, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, createdAt:value.createdAt, syncUpdatedAt:change.updatedAt)) }
|
|
||||||
case "budget":
|
|
||||||
guard let value = try? ServerCoding.decoder.decode(BudgetPayload.self, from:data) else { continue }
|
|
||||||
if let item = try budget(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt) } }
|
|
||||||
else { context.insert(MonthlyBudget(id:id, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, limit:value.limit, monthStart:value.monthStart, syncUpdatedAt:change.updatedAt)) }
|
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
SyncDeletionStore.remove(entity:change.entity, recordID:change.id, through:change.updatedAt)
|
SyncDeletionStore.remove(entity:change.entity, recordID:change.id, through:change.updatedAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyV1(_ change: SyncChange, id: UUID, context: ModelContext) throws {
|
||||||
|
if change.deleted {
|
||||||
|
switch change.entity {
|
||||||
|
case "transaction": if let item = try transaction(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
||||||
|
case "category": if let item = try category(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
||||||
|
case "budget": if let item = try budget(id:id, context:context), item.syncUpdatedAt <= change.updatedAt { context.delete(item) }
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = change.payload else { return }
|
||||||
|
switch change.entity {
|
||||||
|
case "transaction":
|
||||||
|
guard let value = try? ServerCoding.decoder.decode(TransactionPayload.self, from:data) else { return }
|
||||||
|
let extras = ServerCoding.extras(data, known:TransactionPayload.keys)
|
||||||
|
if let item = try transaction(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt); item.syncID = change.id; item.extraPayload = extras } }
|
||||||
|
else {
|
||||||
|
let item = FinanceTransaction(id:id, amount:value.amount, date:value.date, note:value.note, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, categoryColorName:value.categoryColorName, isBalanceAdjustment:value.isBalanceAdjustment, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, accountID:value.accountId, currency:value.currency, syncUpdatedAt:change.updatedAt)
|
||||||
|
item.syncID = change.id; item.extraPayload = extras
|
||||||
|
context.insert(item)
|
||||||
|
}
|
||||||
|
case "category":
|
||||||
|
guard let value = try? ServerCoding.decoder.decode(CategoryPayload.self, from:data) else { return }
|
||||||
|
let extras = ServerCoding.extras(data, known:CategoryPayload.keys)
|
||||||
|
if let item = try category(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt); item.syncID = change.id; item.extraPayload = extras } }
|
||||||
|
else {
|
||||||
|
let item = CustomCategory(id:id, name:value.name, icon:value.icon, emoji:value.emoji, colorName:value.colorName, kind:TransactionKind(rawValue:value.kindRawValue) ?? .expense, createdAt:value.createdAt, syncUpdatedAt:change.updatedAt)
|
||||||
|
item.syncID = change.id; item.extraPayload = extras
|
||||||
|
context.insert(item)
|
||||||
|
}
|
||||||
|
case "budget":
|
||||||
|
guard let value = try? ServerCoding.decoder.decode(BudgetPayload.self, from:data) else { return }
|
||||||
|
let extras = ServerCoding.extras(data, known:BudgetPayload.keys)
|
||||||
|
if let item = try budget(id:id, context:context) { if item.syncUpdatedAt < change.updatedAt { update(item, value, change.updatedAt); item.syncID = change.id; item.extraPayload = extras } }
|
||||||
|
else {
|
||||||
|
let item = MonthlyBudget(id:id, categoryName:value.categoryName, categoryIcon:value.categoryIcon, categoryEmoji:value.categoryEmoji, limit:value.limit, monthStart:value.monthStart, syncUpdatedAt:change.updatedAt)
|
||||||
|
item.syncID = change.id; item.extraPayload = extras
|
||||||
|
context.insert(item)
|
||||||
|
}
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyV2(_ change: SyncChange, context: ModelContext) throws {
|
||||||
|
let recordID = change.id
|
||||||
|
switch change.entity {
|
||||||
|
case "account":
|
||||||
|
let existing = try context.fetch(FetchDescriptor<FinanceAccount>(predicate:#Predicate { $0.syncID == recordID })).first
|
||||||
|
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
|
||||||
|
if change.deleted { if let existing { context.delete(existing) }; return }
|
||||||
|
guard let data = change.payload, let value = try? ServerCoding.decoder.decode(AccountPayload.self, from:data) else { return }
|
||||||
|
let item = existing ?? { let item = FinanceAccount(syncID:recordID, syncUpdatedAt:change.updatedAt); context.insert(item); return item }()
|
||||||
|
item.name = value.name ?? ""
|
||||||
|
let kind = value.kindRawValue ?? "other"
|
||||||
|
item.kindRawValue = ["cash", "card", "deposit", "other"].contains(kind) ? kind : "other"
|
||||||
|
item.icon = value.icon ?? "creditcard.fill"; item.colorName = value.colorName ?? "indigo"
|
||||||
|
item.currency = value.currency?.uppercased() ?? ""; item.createdAt = value.createdAt ?? change.updatedAt
|
||||||
|
item.isArchived = value.isArchived ?? false; item.sortOrder = value.sortOrder ?? 0
|
||||||
|
item.extraPayload = ServerCoding.extras(data, known:AccountPayload.keys); item.syncUpdatedAt = change.updatedAt
|
||||||
|
case "transfer":
|
||||||
|
let existing = try context.fetch(FetchDescriptor<FinanceTransfer>(predicate:#Predicate { $0.syncID == recordID })).first
|
||||||
|
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
|
||||||
|
if change.deleted { if let existing { context.delete(existing) }; return }
|
||||||
|
guard let data = change.payload, let value = try? ServerCoding.decoder.decode(TransferPayload.self, from:data) else { return }
|
||||||
|
let item = existing ?? { let item = FinanceTransfer(syncID:recordID, syncUpdatedAt:change.updatedAt); context.insert(item); return item }()
|
||||||
|
item.fromAccountID = value.fromAccountId ?? ""; item.toAccountID = value.toAccountId ?? ""
|
||||||
|
item.amount = value.amount ?? 0; item.toAmount = value.toAmount
|
||||||
|
item.date = value.date ?? change.updatedAt; item.note = value.note ?? ""
|
||||||
|
item.extraPayload = ServerCoding.extras(data, known:TransferPayload.keys); item.syncUpdatedAt = change.updatedAt
|
||||||
|
case "rate":
|
||||||
|
let existing = try context.fetch(FetchDescriptor<ExchangeRate>(predicate:#Predicate { $0.syncID == recordID })).first
|
||||||
|
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
|
||||||
|
if change.deleted { if let existing { context.delete(existing) }; return }
|
||||||
|
guard let data = change.payload, let value = try? ServerCoding.decoder.decode(RatePayload.self, from:data) else { return }
|
||||||
|
let item = existing ?? { let item = ExchangeRate(syncID:recordID, syncUpdatedAt:change.updatedAt); context.insert(item); return item }()
|
||||||
|
item.from = value.from?.uppercased() ?? ""; item.to = value.to?.uppercased() ?? ""
|
||||||
|
item.rate = value.rate ?? ""; item.date = value.date ?? change.updatedAt
|
||||||
|
item.extraPayload = ServerCoding.extras(data, known:RatePayload.keys); item.syncUpdatedAt = change.updatedAt
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// recurring, goal, debt, attachment, setting — payload целиком.
|
||||||
|
/// Регулярные операции часы не исполняют (§4).
|
||||||
|
private func applyRecord(_ change: SyncChange, context: ModelContext) throws {
|
||||||
|
let entity = change.entity, recordID = change.id
|
||||||
|
let existing = try context.fetch(FetchDescriptor<SyncRecord>(predicate:#Predicate { $0.entityName == entity && $0.recordID == recordID })).first
|
||||||
|
if let existing, existing.syncUpdatedAt > change.updatedAt { return }
|
||||||
|
if change.deleted { if let existing { context.delete(existing) }; return }
|
||||||
|
guard let data = change.payload else { return }
|
||||||
|
if let existing { existing.payload = data; existing.syncUpdatedAt = change.updatedAt }
|
||||||
|
else { context.insert(SyncRecord(entityName:entity, recordID:recordID, payload:data, syncUpdatedAt:change.updatedAt)) }
|
||||||
|
}
|
||||||
|
|
||||||
private func transaction(id: UUID, context: ModelContext) throws -> FinanceTransaction? {
|
private func transaction(id: UUID, context: ModelContext) throws -> FinanceTransaction? {
|
||||||
let recordID = id
|
let recordID = id
|
||||||
var descriptor = FetchDescriptor<FinanceTransaction>(predicate:#Predicate { item in item.id == recordID })
|
var descriptor = FetchDescriptor<FinanceTransaction>(predicate:#Predicate { item in item.id == recordID })
|
||||||
@@ -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)
|
Button("Зарегистрироваться") { Task { await account.register(email:email, password:password, context:modelContext) } }.disabled(server.isEmpty || email.isEmpty || password.count < 8 || account.isBusy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if account.isSignedIn && (account.supportsLedgers || !account.ledgerID.isEmpty) {
|
||||||
|
Section("Пространство") {
|
||||||
|
NavigationLink {
|
||||||
|
WatchLedgersView()
|
||||||
|
} label: {
|
||||||
|
LabeledContent("Бюджет", value: account.ledgerID.isEmpty ? "Личный" : account.ledgerName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if account.isSignedIn {
|
if account.isSignedIn {
|
||||||
Section("Синхронизация") {
|
Section("Синхронизация") {
|
||||||
Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy)
|
Button("Синхронизировать сейчас") { Task { await account.sync(context:modelContext) } }.disabled(account.isBusy)
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ enum SupportedCurrency: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Double {
|
extension Double {
|
||||||
|
/// Целые суммы без копеек, остальные — ровно с двумя знаками («8 480,50 ₽»),
|
||||||
|
/// как во Flutter-клиенте.
|
||||||
func formattedCurrency(code: String) -> String {
|
func formattedCurrency(code: String) -> String {
|
||||||
formatted(
|
let whole = (self * 100).rounded().truncatingRemainder(dividingBy: 100) == 0
|
||||||
|
return formatted(
|
||||||
.currency(code: code)
|
.currency(code: code)
|
||||||
.precision(.fractionLength(0 ... 2))
|
.precision(.fractionLength(whole ? 0 : 2))
|
||||||
.presentation(.narrow)
|
.presentation(.narrow)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Foundation
|
|||||||
struct FinancePeriodSummary {
|
struct FinancePeriodSummary {
|
||||||
let income: Double
|
let income: Double
|
||||||
let expenses: Double
|
let expenses: Double
|
||||||
|
var missing: Set<String> = []
|
||||||
|
|
||||||
var balance: Double { income - expenses }
|
var balance: Double { income - expenses }
|
||||||
}
|
}
|
||||||
@@ -17,11 +18,58 @@ struct CategorySpending: Identifiable {
|
|||||||
var id: String { name }
|
var id: String { name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Финансовые правила (общие для экосистемы, см. docs/CONTRACT_V2.md §2–3).
|
||||||
|
/// Расчёты ведутся в целых сотых; общие итоги — в базовой валюте.
|
||||||
enum FinanceCalculations {
|
enum FinanceCalculations {
|
||||||
static func totalBalance(transactions: [FinanceTransaction]) -> Double {
|
/// Общий баланс: все операции (включая корректировки и будущие даты)
|
||||||
transactions.reduce(0) { balance, transaction in
|
/// плюс входящие минус исходящие части переводов — всё в базовой валюте.
|
||||||
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 {
|
static func monthInterval(containing date: Date, calendar: Calendar = .current) -> DateInterval {
|
||||||
@@ -29,43 +77,52 @@ enum FinanceCalculations {
|
|||||||
?? DateInterval(start: date, duration: 30 * 24 * 60 * 60)
|
?? DateInterval(start: date, duration: 30 * 24 * 60 * 60)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Доходы и расходы периода `[начало, конец)` без корректировок, в базовой валюте.
|
||||||
static func summary(
|
static func summary(
|
||||||
transactions: [FinanceTransaction],
|
transactions: [LedgerEntry],
|
||||||
in interval: DateInterval
|
in interval: DateInterval,
|
||||||
|
context: CurrencyContext
|
||||||
) -> FinancePeriodSummary {
|
) -> FinancePeriodSummary {
|
||||||
let periodTransactions = transactions.filter {
|
var income = MoneyTotal(), expenses = MoneyTotal()
|
||||||
interval.contains($0.date) && !$0.isBalanceAdjustment
|
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
|
return FinancePeriodSummary(
|
||||||
.filter { $0.kind == .income }
|
income: income.value,
|
||||||
.reduce(0) { $0 + $1.amount }
|
expenses: expenses.value,
|
||||||
let expenses = periodTransactions
|
missing: income.missing.union(expenses.missing)
|
||||||
.filter { $0.kind == .expense }
|
)
|
||||||
.reduce(0) { $0 + $1.amount }
|
|
||||||
|
|
||||||
return FinancePeriodSummary(income: income, expenses: expenses)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func spendingByCategory(
|
static func spendingByCategory(
|
||||||
transactions: [FinanceTransaction],
|
transactions: [FinanceTransaction],
|
||||||
in interval: DateInterval
|
in interval: DateInterval,
|
||||||
|
context: CurrencyContext
|
||||||
) -> [CategorySpending] {
|
) -> [CategorySpending] {
|
||||||
let expenses = transactions.filter {
|
let expenses = transactions.filter {
|
||||||
$0.kind == .expense
|
$0.kind == .expense
|
||||||
&& !$0.isBalanceAdjustment
|
&& !$0.isBalanceAdjustment
|
||||||
&& interval.contains($0.date)
|
&& contains(interval, $0.date)
|
||||||
}
|
}
|
||||||
let grouped = Dictionary(grouping: expenses, by: \FinanceTransaction.categoryName)
|
let grouped = Dictionary(grouping: expenses, by: \FinanceTransaction.categoryName)
|
||||||
|
|
||||||
return grouped.map { name, items in
|
return grouped.map { name, items in
|
||||||
CategorySpending(
|
var total = MoneyTotal()
|
||||||
|
for item in items { total.add(Money.cents(item.amount), context.currency(of: item), context.converter) }
|
||||||
|
return CategorySpending(
|
||||||
name: name,
|
name: name,
|
||||||
icon: items.first?.categoryIcon ?? "circle.fill",
|
icon: items.first?.categoryIcon ?? "circle.fill",
|
||||||
emoji: items.first?.categoryEmoji ?? "",
|
emoji: items.first?.categoryEmoji ?? "",
|
||||||
colorName: items.first?.categoryColorName ?? "auto",
|
colorName: items.first?.categoryColorName ?? "auto",
|
||||||
amount: items.reduce(0) { $0 + $1.amount }
|
amount: total.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sorted { $0.amount > $1.amount }
|
.sorted { $0.amount > $1.amount }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Полуоткрытый интервал: `DateInterval.contains` включает конец.
|
||||||
|
static func contains(_ interval: DateInterval, _ date: Date) -> Bool {
|
||||||
|
date >= interval.start && date < interval.end
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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<String> = []
|
||||||
|
|
||||||
|
var value: Double { Money.double(cents) }
|
||||||
|
|
||||||
|
mutating func add(_ cents: Int64, _ currency: String, _ converter: CurrencyConverter) {
|
||||||
|
if let converted = converter.toBase(cents, from: currency) { self.cents += converted }
|
||||||
|
else { missing.insert(currency.uppercased()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Всё, что нужно для расчётов в базовой валюте: базовая валюта,
|
||||||
|
/// валюты и названия счетов, курсы.
|
||||||
|
struct CurrencyContext {
|
||||||
|
let converter: CurrencyConverter
|
||||||
|
private let accountCurrencies: [String: String]
|
||||||
|
private let accountNames: [String: String]
|
||||||
|
|
||||||
|
var base: String { converter.base }
|
||||||
|
|
||||||
|
init(base: String, accounts: [(id: String, name: String, currency: String)] = [], quotes: [RateQuote] = [], asOf: Date = .now) {
|
||||||
|
let base = base.uppercased()
|
||||||
|
converter = CurrencyConverter(base: base, quotes: quotes, asOf: asOf)
|
||||||
|
var currencies: [String: String] = [:], names: [String: String] = [:]
|
||||||
|
for account in accounts {
|
||||||
|
let key = account.id.lowercased()
|
||||||
|
currencies[key] = account.currency.isEmpty ? base : account.currency.uppercased()
|
||||||
|
names[key] = account.name
|
||||||
|
}
|
||||||
|
accountCurrencies = currencies
|
||||||
|
accountNames = names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Валюта счёта; удалённый счёт или пустая валюта — базовая.
|
||||||
|
func accountCurrency(_ id: String?) -> String {
|
||||||
|
guard let id else { return base }
|
||||||
|
return accountCurrencies[id.lowercased()] ?? base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `currency`, иначе валюта счёта, иначе базовая.
|
||||||
|
func currency(of entry: LedgerEntry) -> String {
|
||||||
|
if let code = entry.currency, !code.isEmpty { return code.uppercased() }
|
||||||
|
return accountCurrency(entry.accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Название счёта, «Удалённый счёт» или `nil` для «Без счёта».
|
||||||
|
func accountTitle(_ id: String?) -> String? {
|
||||||
|
guard let id else { return nil }
|
||||||
|
return accountNames[id.lowercased()] ?? "Удалённый счёт"
|
||||||
|
}
|
||||||
|
|
||||||
|
func missingRateWarning(_ missing: Set<String>) -> String? {
|
||||||
|
guard !missing.isEmpty else { return nil }
|
||||||
|
return missing.sorted().map { "нет курса \($0)→\(base)" }.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="branding/banner.png" alt="BalanceWatch — учёт личных финансов на Apple Watch" width="100%">
|
<img src="branding/banner.png" alt="BalanceWatch — личные финансы на Apple Watch" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# BalanceWatch
|
# BalanceWatch
|
||||||
@@ -13,13 +13,15 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
BalanceWatch/
|
BalanceWatch/
|
||||||
├── branding/ # аватар и баннер для README (PNG + SVG-исходники)
|
├── branding/ # иконка, аватар и баннер (SVG-исходники, PNG, render.sh)
|
||||||
|
├── docs/ # скриншоты для баннера
|
||||||
|
├── docs/CONTRACT_V2.md # общий контракт экосистемы v2 (копия, одинаковая во всех репозиториях)
|
||||||
├── BalanceWatch.xcodeproj/
|
├── BalanceWatch.xcodeproj/
|
||||||
└── BalanceWatch/
|
└── BalanceWatch/
|
||||||
├── App/ # точка входа и экраны часов
|
├── App/ # точка входа и экраны часов
|
||||||
├── Models/ # SwiftData-модели (копия из монорепы)
|
├── Models/ # SwiftData-модели (копия из монорепы)
|
||||||
├── Shared/ # контейнер SwiftData, тема, ServerSync
|
├── Shared/ # контейнер SwiftData, тема, ServerSync
|
||||||
├── Utilities/ # FinanceCalculations, форматирование валют
|
├── Utilities/ # FinanceCalculations, Money (сотые, курсы), форматирование валют
|
||||||
├── Views/Components/ # CategoryIconView
|
├── Views/Components/ # CategoryIconView
|
||||||
└── Assets.xcassets # иконка приложения
|
└── Assets.xcassets # иконка приложения
|
||||||
```
|
```
|
||||||
@@ -28,6 +30,24 @@ BalanceWatch/
|
|||||||
любой `.swift`-файл, положенный в `BalanceWatch/`, автоматически попадает в
|
любой `.swift`-файл, положенный в `BalanceWatch/`, автоматически попадает в
|
||||||
таргет — править `project.pbxproj` вручную не нужно.
|
таргет — править `project.pbxproj` вручную не нужно.
|
||||||
|
|
||||||
|
## Контракт v2
|
||||||
|
|
||||||
|
Часы реализуют клиентскую часть `docs/CONTRACT_V2.md` в компактном виде:
|
||||||
|
|
||||||
|
- синхронизируются и хранятся счета, переводы, курсы и настройка
|
||||||
|
`baseCurrency`; общий баланс, аналитика и бюджеты считаются в базовой
|
||||||
|
валюте, при отсутствии курса показывается «нет курса C→B»;
|
||||||
|
- экран «Счета» (только чтение) с балансами счетов и «Без счёта»; при
|
||||||
|
добавлении операции можно выбрать счёт, валюта следует за счётом;
|
||||||
|
- общие бюджеты: в «Настройки → Сервер → Бюджет» выбирается личное или общее
|
||||||
|
пространство, можно присоединиться по коду; данные каждого пространства
|
||||||
|
лежат в отдельном хранилище;
|
||||||
|
- регулярные операции, цели, долги, вложения и теги хранятся и не теряются
|
||||||
|
при пересохранении, но интерфейса для них нет; регулярные операции часы не
|
||||||
|
создают;
|
||||||
|
- если `GET /v1/capabilities` отвечает 404 (сервер v1), счета и общие
|
||||||
|
бюджеты скрыты, новые сущности не отправляются.
|
||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
|
|
||||||
1. Открыть `BalanceWatch.xcodeproj` в Xcode 16 или новее.
|
1. Открыть `BalanceWatch.xcodeproj` в Xcode 16 или новее.
|
||||||
@@ -56,3 +76,15 @@ Simulator и watchOS device (без подписи). Запускается на
|
|||||||
кода из монорепозитория. Протокол синхронизации и правила расчётов должны
|
кода из монорепозитория. Протокол синхронизации и правила расчётов должны
|
||||||
совпадать с остальными клиентами Balance: при изменении контракта на
|
совпадать с остальными клиентами Balance: при изменении контракта на
|
||||||
сервере переносите правки и сюда.
|
сервере переносите правки и сюда.
|
||||||
|
|
||||||
|
## Оформление
|
||||||
|
|
||||||
|
Палитра «Лес» — та же, что у пресета по умолчанию в BalanceFlutter: карточка
|
||||||
|
баланса в зелёном градиенте, доходы бирюзовые (↙), расходы оранжевые (↗),
|
||||||
|
мятный акцент (`BalancePalette` в `Shared/AppTheme.swift`). Иконка — тот же
|
||||||
|
рисунок, что у Flutter-приложения (`branding/icon.svg`).
|
||||||
|
|
||||||
|
После правки SVG или скриншотов в `docs/` выполни `bash branding/render.sh`
|
||||||
|
(нужен `resvg`): скрипт перерисует все размеры иконки в
|
||||||
|
`Assets.xcassets/AppIcon.appiconset`, `branding/avatar.png` (512×512) и
|
||||||
|
`branding/banner.png` (1280×640). Шрифты Roboto лежат в `branding/fonts`.
|
||||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 77 KiB |
@@ -1,60 +1,30 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<!-- Repository avatar: the watch with the app icon (palette «Лес»).
|
||||||
|
Render with branding/render.sh (needs resvg). -->
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="bg" cx="50%" cy="40%" r="75%">
|
<radialGradient id="bg" cx="50%" cy="42%" r="75%">
|
||||||
<stop offset="0" stop-color="#2a2368"/>
|
<stop offset="0" stop-color="#25594c"/>
|
||||||
<stop offset="1" stop-color="#0d0b26"/>
|
<stop offset="1" stop-color="#0c241e"/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
<linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
|
<linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0" stop-color="#2b2660"/>
|
<stop offset="0" stop-color="#173f35"/>
|
||||||
<stop offset="0.5" stop-color="#3b3580"/>
|
<stop offset="0.5" stop-color="#245a4b"/>
|
||||||
<stop offset="1" stop-color="#2b2660"/>
|
<stop offset="1" stop-color="#173f35"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0" stop-color="#6d68c9"/>
|
<stop offset="0" stop-color="#5a9c87"/>
|
||||||
<stop offset="1" stop-color="#3a3488"/>
|
<stop offset="1" stop-color="#1f5245"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="teal" x1="0" y1="0" x2="1" y2="1">
|
<clipPath id="screen"><rect x="132" y="142" width="248" height="228" rx="58"/></clipPath>
|
||||||
<stop offset="0" stop-color="#2fc7b0"/>
|
<filter id="haze" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="40"/></filter>
|
||||||
<stop offset="1" stop-color="#3f6fd8"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="coral" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#ff8a5c"/>
|
|
||||||
<stop offset="1" stop-color="#e2475f"/>
|
|
||||||
</linearGradient>
|
|
||||||
<radialGradient id="ball" cx="38%" cy="35%" r="70%">
|
|
||||||
<stop offset="0" stop-color="#ffb08f"/>
|
|
||||||
<stop offset="0.5" stop-color="#f2614f"/>
|
|
||||||
<stop offset="1" stop-color="#b7323f"/>
|
|
||||||
</radialGradient>
|
|
||||||
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
|
||||||
<feGaussianBlur stdDeviation="10"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- background -->
|
|
||||||
<rect width="512" height="512" fill="url(#bg)"/>
|
<rect width="512" height="512" fill="url(#bg)"/>
|
||||||
|
<ellipse cx="256" cy="256" rx="170" ry="150" fill="#39b997" opacity="0.22" filter="url(#haze)"/>
|
||||||
<!-- straps -->
|
|
||||||
<path d="M186 40 h140 l-10 100 h-120 z" fill="url(#strap)"/>
|
<path d="M186 40 h140 l-10 100 h-120 z" fill="url(#strap)"/>
|
||||||
<path d="M196 372 h120 l10 100 h-140 z" fill="url(#strap)"/>
|
<path d="M196 372 h120 l10 100 h-140 z" fill="url(#strap)"/>
|
||||||
|
<rect x="394" y="196" width="22" height="50" rx="8" fill="#4f8f7b"/>
|
||||||
<!-- crown and side button -->
|
<rect x="396" y="270" width="14" height="40" rx="6" fill="#3f7d6b"/>
|
||||||
<rect x="394" y="196" width="22" height="50" rx="8" fill="#5b56b8"/>
|
<rect x="106" y="116" width="300" height="280" rx="78" fill="url(#case)"/>
|
||||||
<rect x="396" y="270" width="14" height="40" rx="6" fill="#4a4599"/>
|
<rect x="124" y="134" width="264" height="244" rx="62" fill="#04120e"/>
|
||||||
|
<image x="132" y="132" width="248" height="248" xlink:href="../BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png" clip-path="url(#screen)"/>
|
||||||
<!-- case -->
|
|
||||||
<rect x="106" y="116" width="294" height="280" rx="78" fill="url(#case)"/>
|
|
||||||
<!-- screen -->
|
|
||||||
<rect x="124" y="134" width="258" height="244" rx="62" fill="#07061a"/>
|
|
||||||
|
|
||||||
<!-- soft glow behind the mark -->
|
|
||||||
<ellipse cx="253" cy="256" rx="80" ry="90" fill="#4c45c2" opacity="0.45" filter="url(#glow)"/>
|
|
||||||
|
|
||||||
<!-- balance mark: two loops and a ball, as in the app icon -->
|
|
||||||
<rect x="190" y="166" width="126" height="94" rx="47" fill="none" stroke="url(#teal)" stroke-width="28"/>
|
|
||||||
<rect x="190" y="252" width="126" height="94" rx="47" fill="none" stroke="url(#coral)" stroke-width="28"/>
|
|
||||||
<!-- teal loop overlaps coral on the left, like the original figure -->
|
|
||||||
<path d="M204 213 v40" stroke="url(#teal)" stroke-width="28" stroke-linecap="round"/>
|
|
||||||
<circle cx="253" cy="256" r="24" fill="url(#ball)"/>
|
|
||||||
<ellipse cx="246" cy="247" rx="8" ry="5" fill="#ffffff" opacity="0.35"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 264 KiB |
@@ -1,80 +1,78 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 560" width="1280" height="560">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1280 640" width="1280" height="640" font-family="Roboto, sans-serif">
|
||||||
|
<!-- Source of banner.png; same layout and palette («Лес») as the BalanceFlutter
|
||||||
|
banner. Render with branding/render.sh (needs resvg). Screenshots: docs/. -->
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id="bg" cx="30%" cy="45%" r="90%">
|
<radialGradient id="bg" cx="70%" cy="45%" r="85%">
|
||||||
<stop offset="0" stop-color="#2a2368"/>
|
<stop offset="0" stop-color="#25594c"/>
|
||||||
<stop offset="1" stop-color="#0b0a22"/>
|
<stop offset="1" stop-color="#0c241e"/>
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
<linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
|
|
||||||
<stop offset="0" stop-color="#2b2660"/>
|
|
||||||
<stop offset="0.5" stop-color="#3b3580"/>
|
|
||||||
<stop offset="1" stop-color="#2b2660"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="case" x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0" stop-color="#6d68c9"/>
|
<stop offset="0" stop-color="#5a9c87"/>
|
||||||
<stop offset="1" stop-color="#3a3488"/>
|
<stop offset="1" stop-color="#1f5245"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="teal" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="strap" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0" stop-color="#2fc7b0"/>
|
<stop offset="0" stop-color="#143a31"/>
|
||||||
<stop offset="1" stop-color="#3f6fd8"/>
|
<stop offset="0.5" stop-color="#22574a"/>
|
||||||
|
<stop offset="1" stop-color="#143a31"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="coral" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
|
||||||
<stop offset="0" stop-color="#ff8a5c"/>
|
<stop offset="0" stop-color="#26b3a3"/>
|
||||||
<stop offset="1" stop-color="#e2475f"/>
|
<stop offset="0.5" stop-color="#a2f2d8"/>
|
||||||
|
<stop offset="1" stop-color="#f4511e"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="title" x1="0" y1="0" x2="1" y2="0">
|
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
|
||||||
<stop offset="0" stop-color="#ffffff"/>
|
<feGaussianBlur stdDeviation="40"/>
|
||||||
<stop offset="1" stop-color="#c9c5ff"/>
|
|
||||||
</linearGradient>
|
|
||||||
<radialGradient id="ball" cx="38%" cy="35%" r="70%">
|
|
||||||
<stop offset="0" stop-color="#ffb08f"/>
|
|
||||||
<stop offset="0.5" stop-color="#f2614f"/>
|
|
||||||
<stop offset="1" stop-color="#b7323f"/>
|
|
||||||
</radialGradient>
|
|
||||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
|
||||||
<feGaussianBlur stdDeviation="10"/>
|
|
||||||
</filter>
|
</filter>
|
||||||
<filter id="haze" x="-50%" y="-50%" width="200%" height="200%">
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
<feGaussianBlur stdDeviation="60"/>
|
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#04120e" flood-opacity="0.7"/>
|
||||||
</filter>
|
</filter>
|
||||||
|
<clipPath id="iconClip"><rect x="72" y="88" width="120" height="120" rx="28"/></clipPath>
|
||||||
|
<clipPath id="backScreen"><rect x="704" y="150" width="206" height="246" rx="44"/></clipPath>
|
||||||
|
<clipPath id="frontScreen"><rect x="884" y="228" width="250" height="298" rx="54"/></clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<rect width="1280" height="560" fill="url(#bg)"/>
|
<rect width="1280" height="640" fill="url(#bg)"/>
|
||||||
<!-- ambient colour haze -->
|
<ellipse cx="930" cy="340" rx="300" ry="210" fill="#39b997" opacity="0.25" filter="url(#glow)"/>
|
||||||
<circle cx="330" cy="200" r="150" fill="#2fc7b0" opacity="0.18" filter="url(#haze)"/>
|
|
||||||
<circle cx="360" cy="380" r="150" fill="#e2475f" opacity="0.16" filter="url(#haze)"/>
|
|
||||||
|
|
||||||
<!-- watch (same drawing as avatar.svg), centred at (340, 280) -->
|
<!-- left: identity -->
|
||||||
<g transform="translate(340 280) scale(0.95) translate(-256 -256)">
|
<image x="72" y="88" width="120" height="120" xlink:href="../BalanceWatch/Assets.xcassets/AppIcon.appiconset/BalanceIcon-1024.png" clip-path="url(#iconClip)"/>
|
||||||
<path d="M186 40 h140 l-10 100 h-120 z" fill="url(#strap)"/>
|
<text x="68" y="306" font-size="84" font-weight="700" fill="#ffffff">BalanceWatch</text>
|
||||||
<path d="M196 372 h120 l10 100 h-140 z" fill="url(#strap)"/>
|
<rect x="74" y="330" width="120" height="6" rx="3" fill="url(#accent)"/>
|
||||||
<rect x="394" y="196" width="22" height="50" rx="8" fill="#5b56b8"/>
|
<text x="72" y="384" font-size="28" font-weight="500" fill="#a2f2d8">Личные финансы на Apple Watch</text>
|
||||||
<rect x="396" y="270" width="14" height="40" rx="6" fill="#4a4599"/>
|
<text x="72" y="428" font-size="22" fill="#9cc4b6">Самостоятельное приложение для часов,</text>
|
||||||
<rect x="106" y="116" width="294" height="280" rx="78" fill="url(#case)"/>
|
<text x="72" y="458" font-size="22" fill="#9cc4b6">синхронизация с self-hosted BalanceServer</text>
|
||||||
<rect x="124" y="134" width="258" height="244" rx="62" fill="#07061a"/>
|
|
||||||
<ellipse cx="253" cy="256" rx="80" ry="90" fill="#4c45c2" opacity="0.45" filter="url(#glow)"/>
|
<g font-size="18" font-weight="500" fill="#dff7ee">
|
||||||
<rect x="190" y="166" width="126" height="94" rx="47" fill="none" stroke="url(#teal)" stroke-width="28"/>
|
<rect x="72" y="494" width="128" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
|
||||||
<rect x="190" y="252" width="126" height="94" rx="47" fill="none" stroke="url(#coral)" stroke-width="28"/>
|
<text x="136" y="518" text-anchor="middle">watchOS 10+</text>
|
||||||
<circle cx="253" cy="256" r="24" fill="url(#ball)"/>
|
<rect x="210" y="494" width="96" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
|
||||||
<ellipse cx="246" cy="247" rx="8" ry="5" fill="#ffffff" opacity="0.35"/>
|
<text x="258" y="518" text-anchor="middle">SwiftUI</text>
|
||||||
|
<rect x="316" y="494" width="112" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
|
||||||
|
<text x="372" y="518" text-anchor="middle">SwiftData</text>
|
||||||
|
<rect x="72" y="540" width="214" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
|
||||||
|
<text x="179" y="564" text-anchor="middle">Счета и валюты</text>
|
||||||
|
<rect x="296" y="540" width="186" height="36" rx="18" fill="#173f35" stroke="#3f7d6b"/>
|
||||||
|
<text x="389" y="564" text-anchor="middle">Общие бюджеты</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- text -->
|
<!-- back watch: accounts screen -->
|
||||||
<g font-family="Noto Sans, DejaVu Sans, sans-serif">
|
<path d="M752 60 h110 l-8 76 h-94 z" fill="url(#strap)"/>
|
||||||
<text x="640" y="240" font-size="80" font-weight="700" fill="url(#title)" letter-spacing="-1">BalanceWatch</text>
|
<path d="M760 410 h94 l8 76 h-110 z" fill="url(#strap)"/>
|
||||||
<text x="644" y="300" font-size="32" fill="#b9b5e8">Учёт личных финансов на Apple Watch</text>
|
<g filter="url(#shadow)">
|
||||||
|
<rect x="918" y="216" width="14" height="40" rx="6" fill="#4f8f7b"/>
|
||||||
<!-- tags -->
|
<rect x="690" y="136" width="234" height="274" rx="62" fill="url(#case)"/>
|
||||||
<g font-size="22" font-weight="600" fill="#e8e6ff">
|
|
||||||
<rect x="644" y="344" width="166" height="44" rx="22" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.18"/>
|
|
||||||
<text x="727" y="374" text-anchor="middle">watchOS 10+</text>
|
|
||||||
<rect x="824" y="344" width="122" height="44" rx="22" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.18"/>
|
|
||||||
<text x="885" y="374" text-anchor="middle">SwiftUI</text>
|
|
||||||
<rect x="960" y="344" width="142" height="44" rx="22" fill="#ffffff" fill-opacity="0.08" stroke="#ffffff" stroke-opacity="0.18"/>
|
|
||||||
<text x="1031" y="374" text-anchor="middle">SwiftData</text>
|
|
||||||
</g>
|
|
||||||
<g font-size="22" font-weight="600">
|
|
||||||
<rect x="644" y="402" width="288" height="44" rx="22" fill="#2fc7b0" fill-opacity="0.16" stroke="#2fc7b0" stroke-opacity="0.5"/>
|
|
||||||
<text x="788" y="432" text-anchor="middle" fill="#8ff0df">Sync с BalanceServer</text>
|
|
||||||
</g>
|
|
||||||
</g>
|
</g>
|
||||||
|
<rect x="700" y="146" width="214" height="254" rx="50" fill="#000000"/>
|
||||||
|
<image x="704" y="150" width="206" height="246" preserveAspectRatio="xMidYMid slice" xlink:href="../docs/accounts.png" clip-path="url(#backScreen)"/>
|
||||||
|
|
||||||
|
<!-- front watch: home screen -->
|
||||||
|
<path d="M944 120 h130 l-10 92 h-110 z" fill="url(#strap)"/>
|
||||||
|
<path d="M954 542 h110 l10 92 h-130 z" fill="url(#strap)"/>
|
||||||
|
<g filter="url(#shadow)">
|
||||||
|
<rect x="1150" y="300" width="18" height="52" rx="8" fill="#4f8f7b"/>
|
||||||
|
<rect x="1152" y="370" width="12" height="40" rx="5" fill="#3f7d6b"/>
|
||||||
|
<rect x="868" y="212" width="282" height="330" rx="74" fill="url(#case)"/>
|
||||||
|
</g>
|
||||||
|
<rect x="880" y="224" width="258" height="306" rx="60" fill="#000000"/>
|
||||||
|
<image x="884" y="228" width="250" height="298" preserveAspectRatio="xMidYMid slice" xlink:href="../docs/home.png" clip-path="url(#frontScreen)"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -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.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
||||||
|
<!-- Watch app icon, same artwork as BalanceFlutter: the in-app wallet mark in the «Лес» palette. Income (teal) and
|
||||||
|
expense (deep orange) cards stick out of the wallet. -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0.35" y2="1">
|
||||||
|
<stop offset="0" stop-color="#3f7d6b"/>
|
||||||
|
<stop offset="1" stop-color="#1a4a3e"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#bdf7e3"/>
|
||||||
|
<stop offset="1" stop-color="#8fe6c9"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="income" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#26b3a3"/>
|
||||||
|
<stop offset="1" stop-color="#00897b"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="expense" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff8a5c"/>
|
||||||
|
<stop offset="1" stop-color="#f4511e"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#0b2620" flood-opacity="0.45"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="1024" height="1024" fill="url(#bg)"/>
|
||||||
|
|
||||||
|
<g filter="url(#shadow)">
|
||||||
|
<!-- cards behind the wallet; arrows match the app's income/expense icons -->
|
||||||
|
<g transform="rotate(-10 400 330)">
|
||||||
|
<rect x="236" y="176" width="320" height="300" rx="36" fill="url(#income)"/>
|
||||||
|
<path d="M454 236 L338 352 M338 262 V352 H428" fill="none" stroke="#ffffff" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<g transform="rotate(10 624 330)">
|
||||||
|
<rect x="468" y="176" width="320" height="300" rx="36" fill="url(#expense)"/>
|
||||||
|
<path d="M570 352 L686 236 M596 236 H686 V326" fill="none" stroke="#ffffff" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- wallet -->
|
||||||
|
<rect x="196" y="420" width="632" height="420" rx="72" fill="url(#body)"/>
|
||||||
|
<!-- clasp tab -->
|
||||||
|
<rect x="590" y="560" width="268" height="152" rx="76" fill="#116b57"/>
|
||||||
|
<circle cx="666" cy="636" r="30" fill="#bdf7e3"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -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
|
||||||
@@ -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:<recurringId>:<occurrence>` (нижний регистр). Два устройства
|
||||||
|
создают одну и ту же запись — дублей нет.
|
||||||
|
- `auto`: клиент создаёт все повторения в `(generatedThrough, сегодня UTC]`
|
||||||
|
(с `startDate`, если `generatedThrough` пуст) и ставит
|
||||||
|
`generatedThrough` = последнее обработанное. Удалённое пользователем
|
||||||
|
повторение не воскрешается, т.к. `generatedThrough` уже позади. Не более
|
||||||
|
366 повторений за раз.
|
||||||
|
- `remind`: клиент показывает повторения из того же окна как «ожидают»;
|
||||||
|
«Записать» создаёт транзакцию с тем же детерминированным ID и двигает
|
||||||
|
`generatedThrough` только через подряд обработанные повторения (если
|
||||||
|
записать более позднее раньше, более раннее остаётся ожидать).
|
||||||
|
«Пропустить» двигает `generatedThrough` до этого повторения включительно —
|
||||||
|
более ранние ожидающие тоже считаются пропущенными.
|
||||||
|
- `isPaused` — ничего не создаётся и не напоминается.
|
||||||
|
- Часы регулярные операции не создают (их создают телефон и десктоп).
|
||||||
|
|
||||||
|
## 5. Цели, долги — см. §1. Цели и долги не меняют баланс.
|
||||||
|
|
||||||
|
## 6. Теги
|
||||||
|
|
||||||
|
Строки после `trim`, 1–32 символа, без учёта регистра уникальны в операции,
|
||||||
|
не больше 10 на операцию. Порядок сохраняется. Аналитика и бюджеты тегами не
|
||||||
|
фильтруются; список операций — фильтруется.
|
||||||
|
|
||||||
|
## 7. Общие бюджеты (ledgers)
|
||||||
|
|
||||||
|
Пространство данных (ledger) — отдельный набор записей со своим курсором.
|
||||||
|
У каждого пользователя есть **личное** пространство (как в v1). Общее
|
||||||
|
пространство создаёт владелец и приглашает участников по коду.
|
||||||
|
|
||||||
|
| Метод | Путь | Тело / ответ |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/v1/ledgers` | `{"ledgers":[{"id","name","role","members":[{"userId","email","role"}]}]}` — только общие |
|
||||||
|
| POST | `/v1/ledgers` | `{"name"}` → ledger, `role:"owner"` |
|
||||||
|
| PATCH | `/v1/ledgers/{id}` | `{"name"}` (владелец) |
|
||||||
|
| DELETE | `/v1/ledgers/{id}` | владелец: удалить пространство со всеми записями и вложениями |
|
||||||
|
| POST | `/v1/ledgers/{id}/invites` | → `{"code","expiresAt"}`; код из 10 символов, 7 дней, одноразовый (владелец) |
|
||||||
|
| POST | `/v1/ledgers/join` | `{"code"}` → ledger, `role:"member"` |
|
||||||
|
| DELETE | `/v1/ledgers/{id}/members/{userId}` | владелец удаляет участника; участник — себя (`userId` = свой) |
|
||||||
|
|
||||||
|
- Пространство выбирается заголовком `X-Balance-Ledger: <id>` на
|
||||||
|
`/v1/sync` и `/v1/attachments/*`. Без заголовка — личное пространство.
|
||||||
|
Не участник → `403 forbidden`, несуществующее → `404 not_found`.
|
||||||
|
- Владелец не может выйти (`409 owner_cannot_leave`), даже из пустого
|
||||||
|
пространства — только удалить его.
|
||||||
|
- Ответы: создание пространства и приглашения — `201`; вход по коду и
|
||||||
|
переименование — `200`; удаление пространства и участника — `204`.
|
||||||
|
Повторный вход участника возвращает пространство и не тратит код.
|
||||||
|
`/v1/ledgers/join` ограничен по частоте вместе со входом (30 запросов в
|
||||||
|
минуту с IP).
|
||||||
|
- Клиент, получивший `403`/`404` для выбранного пространства, может сам
|
||||||
|
вернуться в личное (Flutter) или показать ошибку и предложить переключиться
|
||||||
|
(часы).
|
||||||
|
- Все участники равны в правах на записи (last-write-wins как в v1).
|
||||||
|
- Клиент хранит данные пространства отдельно: scope = сервер + пользователь
|
||||||
|
+ пространство. Переключение пространства не переносит записи. Курсор и
|
||||||
|
очередь изменений — свои у каждого пространства.
|
||||||
|
- Ошибки: `invalid_code` (400), `invalid_name` (400: пустое после `trim`,
|
||||||
|
длиннее 100 символов или с управляющими символами), `invalid_json` (400),
|
||||||
|
`forbidden` (403), `not_found` (404, в том числе удаление не-участника),
|
||||||
|
`owner_cannot_leave` (409). Коды приглашений хранятся на сервере только
|
||||||
|
как хеш.
|
||||||
|
|
||||||
|
## 8. Вложения
|
||||||
|
|
||||||
|
| Метод | Путь | Смысл |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PUT | `/v1/attachments/{sha256}` | тело — байты файла, `Content-Type` из списка; сервер проверяет SHA-256 и размер; повтор идемпотентен (`200`, первый раз `201`) |
|
||||||
|
| GET | `/v1/attachments/{sha256}` | байты и `Content-Type`; `404`, если в пространстве нет такого файла |
|
||||||
|
|
||||||
|
- Порядок клиента: сначала PUT байтов, потом запись `attachment` через sync.
|
||||||
|
- Файлы хранятся в пространстве; один и тот же файл в двух пространствах
|
||||||
|
хранится дважды.
|
||||||
|
- Ответ PUT: `{"sha256","size","contentType"}`. GET отдаёт
|
||||||
|
`Content-Disposition: attachment`.
|
||||||
|
- Когда в пространстве не осталось живых записей `attachment` с этим
|
||||||
|
`sha256`, сервер может удалить файл. Файл, на который ещё не ссылалась ни
|
||||||
|
одна запись, хранится не меньше 24 часов после загрузки (клиент загружает
|
||||||
|
байты раньше записи).
|
||||||
|
- Лимит тела 10 МиБ действует только для загрузки вложения; остальные
|
||||||
|
запросы — 2 МиБ, как в v1.
|
||||||
|
- Ошибки: `payload_too_large` (413), `unsupported_media_type` (415),
|
||||||
|
`hash_mismatch` (400, в том числе неверный формат хеша в пути).
|
||||||
|
- Клиент скачивает файл лениво при просмотре и кеширует локально.
|
||||||
|
- Сообщение ошибки для незнакомой сущности на сервере осталось как в v1
|
||||||
|
(«entity must be transaction, category, or budget») ради совместимости с
|
||||||
|
записанными ответами; код `invalid_change` верный.
|
||||||
|
|
||||||
|
## 9. Совместимость с клиентами v1
|
||||||
|
|
||||||
|
- Старый клиент не видит счета, переводы, цели, долги, курсы, вложения и
|
||||||
|
общие пространства; транзакции в других валютах складывает как есть.
|
||||||
|
- При редактировании транзакции старый клиент теряет `accountId`,
|
||||||
|
`currency`, `tags`, `recurringId`, `occurrence`. Поэтому все клиенты
|
||||||
|
экосистемы обновляются вместе с сервером.
|
||||||
|
- Нативные iOS/macOS и Android-клиенты (не входят в эти репозитории) остаются
|
||||||
|
на v1 до своего обновления.
|
||||||
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 102 KiB |