delete CloudKit and added BalanceFlutter
Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt committed 2026-10-28 15:37:32 +07:00
1 parent a715eca947
commit 2c278044bb
214 files changed
+13411 -2290

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/
+45
View File
@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "737ba8f885dc774b53b442759999a312c9461e4e"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 737ba8f885dc774b53b442759999a312c9461e4e
base_revision: 737ba8f885dc774b53b442759999a312c9461e4e
- platform: android
create_revision: 737ba8f885dc774b53b442759999a312c9461e4e
base_revision: 737ba8f885dc774b53b442759999a312c9461e4e
- platform: ios
create_revision: 737ba8f885dc774b53b442759999a312c9461e4e
base_revision: 737ba8f885dc774b53b442759999a312c9461e4e
- platform: linux
create_revision: 737ba8f885dc774b53b442759999a312c9461e4e
base_revision: 737ba8f885dc774b53b442759999a312c9461e4e
- platform: macos
create_revision: 737ba8f885dc774b53b442759999a312c9461e4e
base_revision: 737ba8f885dc774b53b442759999a312c9461e4e
- platform: web
create_revision: 737ba8f885dc774b53b442759999a312c9461e4e
base_revision: 737ba8f885dc774b53b442759999a312c9461e4e
- platform: windows
create_revision: 737ba8f885dc774b53b442759999a312c9461e4e
base_revision: 737ba8f885dc774b53b442759999a312c9461e4e
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+137
View File
@@ -0,0 +1,137 @@
# Native → Flutter feature audit
Source audit of the Swift iOS/macOS/watchOS and Kotlin Android applications. Statuses distinguish source implementation, executed automated tests and remaining platform checks; none implies complete device verification. Paths are relative to the repository root. The Apple CloudKit removal was explicitly requested; finance features and the native Watch target are retained.
## Functional matrix
| Native feature | Source | Required behavior / native differences | Flutter status |
| --- | --- | --- | --- |
| Navigation | `Balance/Balance/App/ContentView.swift`; `Balance/BalanceMac/MacContentView.swift`; `BalanceAndroid/app/src/main/java/com/example/balanceandroid/ui/BalanceRoot.kt` | Overview, analytics, transactions, budgets, settings; categories and server settings; Mac sidebar, phone bottom tabs | Implemented in `lib/ui/app.dart`; responsive rail/bottom tabs; widget verification in progress |
| Transaction model | `Balance/Balance/Models/FinanceTransaction.swift`; Android `data/Entities.kt` | UUID, positive Double amount, date, note, category name/icon/emoji/color snapshot, kind, adjustment flag, updatedAt | Implemented in `lib/domain/finance.dart`; serialization tests pass; uses integer hundredths (see deviations) |
| Total balance | `Balance/Balance/Utilities/FinanceCalculations.swift`; Android `data/FinanceMath.kt` | All income minus all expenses, including adjustments and future dates; no calculation rounding | Implemented; finance tests pass |
| Period summary | Same | Income, expenses, result; exclude adjustments | Implemented with half-open periods; finance tests pass |
| Category spending | Same | Expense only, exclude adjustments; group by category name; descending sum; first record supplies design | Implemented; grouping/exclusion tests pass |
| Calendar periods | `Balance/Balance/Views/Analytics/AnalyticsView.swift`; Android `ui/Components.kt` | Current calendar month; 3 months start two months before current month, 12 start eleven before; end is next month's start; local timezone | Implemented in source; boundary math tests pass; DST/device checks pending |
| Overview | `Balance/Balance/Views/Dashboard/DashboardView.swift`; Android `ui/BalanceRoot.kt` | Overall balance, current-month income/expenses, category bars, recent operations, add/adjust actions. iOS top 5 categories / 4 recent; Android top 7 / 5 recent; Mac top 8 / no recent; Watch 4 recent | Implemented; top 5 categories / 5 recent; widget verification in progress |
| Analytics metrics | `Balance/Balance/Views/Analytics/AnalyticsView.swift`; `Balance/BalanceMac/MacFeatures.swift`; Android `ui/BalanceRoot.kt` | 1/3/12-month selector, income/expense/result, savings `(income-expenses)/income*100` or 0 when income ≤ 0 | Implemented in source, including result and largest category; visual verification pending |
| Cash-flow graph | iOS analytics; `Balance/BalanceMac/MacFeatures.swift` | Separate income/expense sums, buckets day/weekOfYear/month for 1/3/12-month periods; no inserted zero buckets; line + points. Absent on Android/Watch | Implemented as paired bars with explicit zero buckets; calendar weeks Monday-based; different visual representation from Apple lines |
| Spending structure | iOS analytics; Mac features; Android components; Watch features | iOS donut uses top 6, legend top 5; Mac top 8 donut; Android top 7 bars; Watch top 6 bars normalized against max(first amount, 1) | Implemented as category bars and an analytical donut; all category totals retained |
| Insights | iOS analytics | Largest expense category; average daily spending = all selected-period expenses / elapsed calendar days inclusive, minimum one | Implemented in source; average daily + largest category; DST daily denominator checks pending |
| Transaction list | `Balance/Balance/Views/Transactions/TransactionsView.swift`; Android root | Date descending, all/income/expense filter, case-insensitive category/note search; include adjustments | Implemented in source; filters/search/day grouping; widget verification in progress |
| Transaction CRUD | `Balance/Balance/Views/Transactions/NewTransactionView.swift`; Android `ui/Editors.kt`, `MainViewModel.kt`; `Balance/BalanceMac/MacEditors.swift`; `Balance/BalanceWatch/WatchAddTransactionView.swift` | Add/edit amount/type/category/date/note; delete with deletion journal; kind change selects first built-in category; update timestamp on edit | Implemented in store/UI; store tests pass; widget verification in progress |
| Adjustment editing | Same | Apple locks type/category and preserves archived snapshot; Android currently permits changing type/category while flag remains true | Implemented Apple behavior in source: locked kind/category, archived snapshot; dedicated widget test pending |
| Balance adjustment | `Balance/Balance/Views/Dashboard/BalanceAdjustmentView.swift`; Mac/Watch features; Android view model | Target-current; create abs(diff), income if positive else expense; minimum abs(diff) .005; flag=true; category `Корректировка`; default note `Ручная корректировка баланса` | Implemented in source with integer-cent difference; dedicated behavioral/UI test pending |
| Built-in categories | `Balance/Balance/Models/FinanceCategory.swift`; Android entities | 9 expense / 5 income categories, locked. `Другое` appears for both kinds with distinct icons | Implemented; 9/5 catalog test passes |
| Custom category CRUD | `Balance/Balance/Views/Settings/CategoriesView.swift`; Mac editors; Watch features; Android editors/view model/DAO | Name, immutable-after-create kind, icon or emoji, color; retain transaction snapshots after deletion; rename propagates snapshots; delete associated budgets | Implemented; rename/delete cascade store tests pass; widget verification in progress |
| Category design | `Balance/Balance/Shared/CategoryDesignOptions.swift`; Android editors/components | 50 icon names, 30 suggested emojis, 24 named colors; custom emoji input; unknown color→indigo; Apple `auto` resolves built-in color by name | Implemented; 50/30/24 catalog tests pass; auto-color fallback differs |
| Monthly budgets | `Balance/Balance/Views/Budgets/*.swift`; Mac/Watch features; Android root/editors/view model | Current month only; expense built-ins + custom categories; >0 create/update, empty/invalid/≤0 delete; expense totals exclude adjustments | Implemented; explicit delete and month navigation added; UTC-to-local month regression passes under Europe/Moscow |
| Budget feedback | iOS budgets | Progress capped at 1; ≥1 red, ≥.8 orange, otherwise indigo; spent/limit, remaining, overage. Android/Mac/Watch show simpler progress + limit | Implemented in source; 80/100% colors, remaining/overage; visual checks pending |
| Currency | `Balance/Balance/Utilities/CurrencyFormatting.swift`; Android settings/components | RUB default; EUR/USD/SEK; display-only setting, no conversion or per-record currency; locale-aware formatting | Implemented; fixed ru_RU locale and two decimals differ from native device-locale 0–2 digits |
| Theme | `Balance/Balance/Shared/AppTheme.swift`; Android `ui/theme/Theme.kt` | system default, light, dark; persisted locally | Implemented in source; secure device settings round-trip not tested |
| Settings details | iOS settings; Mac/Watch features; Android root | Category/server links, operation count, storage/privacy description, version; local preferences excluded from finance sync | Implemented links/categories/about/storage messaging; saved-operation count absent at review |
| CSV export | `Balance/Balance/Views/Settings/CSVDocument.swift` | Apple iOS/Mac only; all transactions descending; `balance-transactions.csv`, UTF-8, LF, ISO8601 UTC; raw positive amount; header `Дата,Тип,Категория,Сумма,Заметка`; every value quoted and embedded quotes doubled; adjustment type `Корректировка`; disabled for empty data | CSV encoding tests pass; mobile share and desktop/web save paths implemented; OS share/save dialogs unverified |
| Local persistence | `Balance/Balance/Shared/BalanceModelContainer.swift`; Android `data/AppDatabase.kt` | Durable finance entities; Apple SwiftData, Android Room v1; Apple recovery falls back to in-memory if local creation fails | Implemented using transactional Sembast / IndexedDB snapshots; restart tests use memory adapter, not real disk |
| Server account | `Balance/Balance/Shared/ServerSync.swift`; Android `sync/*` | URL save, login/register (registration password ≥8), logout, manual sync, full resync, busy/message/last-sync state; retain local finance records on logout/server change | Implemented; scoped account/server isolation tests pass; device secure-storage and registration integration pending |
| Session security | Same; Android `sync/SecureSessionStore.kt` | Apple Keychain AfterFirstUnlockThisDeviceOnly; Android AES-GCM encrypted session with Android Keystore; tokens never finance payload | Implemented through flutter_secure_storage with session-only fallback warning; physical-device secure storage unverified |
| Server protocol | Same | POST auth/register/login/refresh/logout and sync; bearer token; ISO8601 dates; stable device UUID; transaction/category/budget payloads; refresh within 60s of expiry and one retry on 401 | Implemented in `lib/services/sync_api.dart`; mock server store tests pass; see separate protocol integration evidence |
| Incremental sync | Same | 100-record pages; cutoff fixed for run; 5s overlap on lastPush; push entities + tombstones, then pull; per-server/user cursor; cursor monotonic; hasMore must advance cursor | Implemented durable dirty outbox, 100-record batching, push/pull cursor; differs from native 5-second watermark scanning |
| Conflict/deletion handling | Same; `Balance/Balance/Models/SyncTombstone.swift`; Android DAO | Remote upsert only when strictly newer, remote delete when local timestamp ≤ remote; order changes by sequence; deletion journal dedup by entity/id, clear after corresponding pull | Implemented; pending-delete conflict and failed-page rollback tests pass; delete-wins policy differs from native LWW |
| Automatic sync | Apple content/root views; Android `BalanceApplication.kt`, `sync/SyncWorker.kt` | Apple immediately and each 120 seconds while root task lives; Android ViewModel initialization and connected-network WorkManager every 15min, retry on failure | Foreground startup/resume and 2-minute timer plus Android 15-minute connected-network WorkManager implemented; native lock JVM tests pass; real suspended-app execution pending |
| CloudKit | Apple local store and entitlements | Intentionally removed by user request; synchronization uses the Go server only | **Removed by request; not a parity gap** |
| Apple Watch | `Balance/BalanceWatch/*.swift` | Native Watch overview, analytics, transaction/category CRUD, budgets, adjustment, theme/currency/server settings; independent server login, no CSV, no WatchConnectivity bridge or complications | Retained native Watch target via `scripts/build.sh watchos`; no Flutter watchOS runner; Apple SDK build unverified |
## Edge cases and deliberate parity decisions
1. **Period end:** Swift `DateInterval.contains` includes its end, Android uses `[start,end)`. A transaction exactly at next month's start can be counted twice by Apple month views. Choose and document one Flutter rule; do not accidentally use different rules for charts/budgets/summary.
2. **Future records:** overall balance includes every date. Period analytics includes future records within the period, while iOS daily-average denominator only runs through today.
3. **Money parsing:** iOS removes ASCII spaces, replaces comma with dot, requires >0. Android only replaces comma. Mac/Watch use native number input. Explicit finite-number validation is absent from native editors; NaN/Infinity must not corrupt persisted/exported finance data.
4. **Archived category:** Apple adds missing transaction category snapshots to editor choices; Android falls back to first built-in, losing archived design on save.
5. **Duplicate category names:** Apple blocks names matching any built-in/custom category across both kinds, case/diacritic insensitive. Android checks custom categories of the same kind only; built-in names can collide.
6. **Rename cascade:** iOS matches name+kind for transactions and only updates budgets for expense categories. Android/Mac/Watch match name only and can affect an opposite-kind collision.
7. **Delete cascade:** iOS deletes category budgets only for expense kind; Android/Mac/Watch delete all matching-name budgets. Transactions remain on every platform.
8. **Duplicate budgets:** no category/month uniqueness constraint. Apple editor's `Dictionary(uniqueKeysWithValues:)` traps on duplicate names. Android updates the first matching budget. Flutter should merge or reject duplicates explicitly.
9. **Month timezone:** Apple includes budget monthStart within current interval; Android requires exact equality with locally calculated month start. A timezone change can hide Android imported budgets.
10. **Limit precision:** iOS pre-fills budget limits with `%.0f`, so saving unchanged rounds fractional limits. Mac/Watch/Android retain fractions.
11. **Limit equality:** iOS displays "Лимит превышен на 0" at exact limit. Android progress divides by max(limit,1), which differs for sub-unit limits.
12. **Emoji:** iOS limits input to one grapheme; Mac/Watch take first Swift Character on save. Android accepts an arbitrary string.
13. **Notes:** iOS/Android transaction editors trim notes, Mac/Watch do not. Adjustment defaults: iOS trims; Android uses ifBlank without trimming a nonblank note; Mac/Watch use isEmpty.
14. **Unknown kind:** Apple getter defaults to expense. Android totalBalance subtracts unknown kind but summary/spending exclude it because they test literal `expense`.
15. **CSV is lossy:** no UUID, kind for adjustment, flag, timestamps, category design, custom-category definitions or budgets; an expense adjustment exports positive amount with no recoverable sign. It is not a complete migration format. Formula-like notes are quoted but not neutralized by native export.
16. **Sync sizes:** Apple rejects individual payload >256 KiB and splits full bodies >1.5 MB. Android splits changes arrays >1.5 MB, without the Apple single-payload guard.
17. **Server URL:** Apple HTTP allows localhost/127.0.0.1/::1; Android localhost/127.0.0.1/10.0.2.2. Android rejects userinfo/query/fragment; Apple has no explicit equivalent rejection.
18. **Deletion journal:** Apple UserDefaults journal quarantines corrupt data and validates UUID/entity; Android Room journal is transactional with local deletion. A successful push alone must not discard tombstones.
19. **Preferences:** theme, currency, server session remain device-local, including Watch; they are not in synced finance payloads.
20. **Rounding:** Apple currency display allows 0–2 fractional digits and narrow symbol; Android NumberFormat uses 0 fractional digits for integral values, otherwise up to 2. Display formatting must not alter stored amounts.
## Storage and migration
CloudKit was intentionally removed by user request. The Apple model container now always uses a persistent local SwiftData store (`cloudKitDatabase: .none`), with the existing in-memory recovery fallback. Cloud capabilities, configuration flags and setup instructions have been removed. Finance synchronization uses only the Go server; there is no CloudKit bridge or CloudKit parity requirement.
No explicit old-store importer exists in native Swift/Kotlin. A new Flutter JSON/SQLite store does not automatically discover SwiftData or Room databases, and a new app identity does not grant access to the old sandbox. Preserve original installations until transfer is verified.
The practical complete-model transfer is to sync the original app with its Go server, sign into the same server/account from Flutter, then pull transactions, custom categories, budgets and deletion records. Preserve UUIDs, timestamps, category snapshots and adjustment flags. Validate counts, per-month summaries, total balance and representative adjustments before retiring the original store. CSV is an export report, not a complete migration format (see edge case 15).
Local-only data without a server still requires either this server route or a tested native upgrade/export plus complete Flutter importer. Data that was never downloaded from an earlier cloud-backed deployment is not migrated automatically by this change; keep that original deployment available until its records have been transferred. Do not claim automatic existing-data migration.
## Native test baseline
`Balance/BalanceTests/FinanceCalculationsTests.swift`: summary income/expenses, outside-period filtering, spending grouping with emoji/color, all-time balance including adjustments, adjustment exclusion, deletion journal dedup/removal.
`BalanceAndroid/app/src/test/java/com/example/balanceandroid/FinanceMathTest.kt`: income/expense/adjustment summary and category grouping.
Native tests do not cover period end, timezone changes, duplicate budgets, CSV or migration. Flutter verification should exercise those behaviors in addition to the baseline math tests.
## Flutter review evidence and remaining checks
The audit executed 12 `finance_test.dart` tests successfully before the additional exact-local-storage regression was added with `TZ=Europe/Moscow`, including an imported native budget at local January 1 serialized as December 31 UTC. `budgetUsed` now recovers the local calendar month. The store owner also normalizes budget duplicate comparisons with `.toLocal()`.
Six `app_store_test.dart` tests passed in the audit run: restart snapshot, rename cascade, delete conflict, category deletion, failed pull rollback, account/server isolation. Storage uses the memory adapter in those tests; durable on-disk and OS keychain checks are separate requirements.
Narrow-editor action overflows found in the initial run were corrected. The final 22 UI/screenshot tests pass on 320/390/1280 widths, including 1.5 text scale, dark mode, CRUD, negative adjustment and mocked native CSV selector/share. Screen-reader, real software keyboard, native system dialogs, suspended-app and production secure-storage behavior remain device checks.
Intentional/current Flutter differences from the native source:
- Money is stored as integer hundredths. Incoming native sub-cent amounts are rounded on import, even though native models accept arbitrary Double precision. New inputs permit at most two decimals; this is a data-model policy change, not bit-for-bit Double parity.
- Half-open month intervals avoid Apple's inclusive-end overlap. Unknown kinds default to expense consistently.
- UI uses a responsive Material 3 layout, paired cash-flow bars, category bars and an analytical donut. The cash-flow graph retains period totals with paired bars instead of the Apple line style. Zero periods are explicit.
- Locale is fixed to Russian and currency always uses two decimals. Native follows device locale and allows zero decimals for whole amounts.
- Budgets can navigate other months and are edited individually. Empty/zero does not delete through the editor; an explicit delete action is available. Categories are sorted by name rather than native creation order.
- Account/server ledgers are isolated. Native keeps one shared ledger across account/server changes. First guest import is conditional and once per server; preserve guest data and explain this condition in the UI.
- The Flutter outbox retains deletion knowledge and prefers an unsent local deletion over a newer server edit by issuing a newer delete. Native uses simple last-write-wins timestamps.
Additional safety/data checks from source review:
- Main ledger records do not contain auth tokens; session data goes through secure storage. Failed secure-store access produces a visible session-only fallback rather than writing plaintext credentials.
- Server URLs are validated by the same transport rules before account changes; insecure HTTP is limited to loopback and the Android emulator address. Android network-security and Apple local-network ATS exceptions are configured; real-device connectivity remains a device check.
- Transaction editor choices deduplicate imported category names to avoid duplicate dropdown-value assertions while retaining archived snapshot categories.
- A native preexisting SwiftData/Room database is not automatically imported by the new app identity. Use the Go transfer route above; the native Watch ledger also requires the same server/account.
- All scoped snapshots and their dirty outboxes are fully loaded into memory and rewritten as a complete snapshot on each edit/page. Large-ledger performance has not been measured.
- Corrupt local records can currently fail initialization; the startup error view offers retry without deleting data. A quarantined backup/recovery workflow is not implemented.
- Remote payload validation checks envelope shape but model decoding can still reject malformed fields and block a pull page. This preserves the cursor but needs diagnostics/recovery testing.
- Nonpositive imported budget limits do not produce division by zero; local UI requires a positive replacement limit. Invalid server data can be repaired through the editor.
- CSV retains the native lossy adjustment-sign behavior and does not neutralize formula-like text; treat it as an export report, not a restore format.
## Android background synchronization verification
`lib/services/background.dart` registers unique periodic WorkManager task `balance-periodic-sync` with a 15-minute frequency, connected-network constraint and update policy. Its preserved entry-point dispatcher initializes Flutter plugins, creates `AppStore(background: true)`, restores the current account and performs the same full push/pull/refresh flow as the foreground. Errors return false for WorkManager retry; credentials are not logged or copied to a plaintext mirror. No scheduler calls are made on web or non-Android platforms. `main.dart` surfaces scheduler initialization failure while keeping foreground use available.
`packages/balance_runtime` is a real Android FlutterPlugin registered in foreground and headless engines. Its `balance/runtime` channel acquires a process-wide fair Semaphore and an OS FileChannel lock on one app-private lock file. Acquisition runs off the Android main thread. An engine-specific token is required to release the lock; detach cancels waiters and releases held locks. Overlapping acquisition from the same engine fails rather than deadlocking a single acquisition executor.
AppStore holds this lock across each complete serialized operation, reloads preferences and protected credentials/ledger after acquisition, and releases after completion. Sembast connections open and close for each read/write so a headless isolate does not read a stale open-connection cache. Consequently OAuth refresh rotation, account changes, pushes, pulled-page commits and foreground edits share the same lock, rather than racing an independently refreshed token mirror. Background stores have no timer or lifecycle observer, and the worker awaits close before it exits.
Executed checks:
- Android release Java compilation passed for the local plugin.
- Three JVM tests passed: concurrent thread ownership + idempotent release; interrupted waiter recovery; a separate JVM process blocked on the real OS lock until the first owner released it.
- Three Dart channel tests passed: engine token supplied on release, absent token fails closed, non-Android calls are no-ops.
- Targeted Dart analysis of background/runtime modules passed.
These checks do **not** prove actual Android OS wake-up, force-stop/reboot behavior, OEM battery restrictions, keychain access in a suspended process, or engine teardown during a real network request. Run a signed-device test with UI closed, an expired access token, queued edits/deletions, remote changes and simultaneous foreground resume. Fifteen minutes is the requested minimum interval; Android controls the actual execution time. The same native file lock guards engines/processes of this app; other platforms retain their foreground-only behavior.
## Web multi-tab coordination
`RuntimeLock` conditionally loads `runtime_lock_web.dart`. Each real AppStore operation acquires the exclusive origin-wide Web Lock `balance-ledger-v1`, then reloads shared preferences, credentials and the IndexedDB snapshot before mutating or refreshing tokens. The lock callback returns an unresolved JS Promise held until `release()` completes it, so another tab cannot overwrite a stale whole-ledger snapshot or rotate the same refresh token concurrently. Browser teardown releases ownership. Acquisition failures reject the waiting Dart caller; the app fails closed with a clear HTTPS/localhost or unsupported-Web-Locks message instead of silently using an unsafe no-op lock.
The web helper uses SDK `dart:js_interop` only. The audit compiled `scripts/probe_web_lock.dart` to JavaScript and ran it against Node 24's native `navigator.locks`: two separate owner instances serialized, ownership released/reacquired, duplicate acquire/release rejected, insecure origin rejected, unsupported API rejected. The existing three Android channel tests still pass, and targeted analyzer checks pass. This exercises native Web Locks Promise semantics, not a real browser two-tab IndexedDB/keychain scenario; that integration remains an explicit runtime check. The platform owner rebuilds the full release web bundle after this change.
Desktop multiple independent app processes are not coordinated by this Web/Android helper. Standard desktop runners have one foreground AppStore; simultaneous desktop instances require a separately tested OS file-lock implementation before they can be claimed safe.
+128
View File
@@ -0,0 +1,128 @@
# Баланс — Flutter / Material 3
Личный бюджет с локальным хранилищем и синхронизацией с существующим `BalanceServer`. Сервер и его протокол не переписаны. CloudKit удалён по запросу: данные синхронизируются только через Go-сервер.
## Возможности
- Доходы и расходы: создание, редактирование, удаление, дата/время, заметки, поиск, фильтр типа.
- Общий баланс, ручная корректировка отдельной операцией без влияния на аналитику.
- Аналитика за 1, 3 и 12 календарных месяцев, доходы/расходы, результат, сбережения, средний расход, крупнейшая категория, динамика и структура расходов.
- Месячные бюджеты по категориям, остаток и превышение; выбор месяца.
- Встроенные и собственные категории, 50 значков Material с совместимыми серверными именами, 30 эмодзи и собственный эмодзи, 24 цвета. Переименование обновляет связанные записи; удаление сохраняет историю.
- RUB, EUR, USD, SEK; системная, светлая и тёмная тема; CSV-экспорт.
- Локальная работа без аккаунта; сервер, регистрация, вход, выход, refresh, ручная и автоматическая синхронизация, полная пересинхронизация.
- Транзакционное сохранение данных и курсора, очередь изменений и удалений, повтор после сетевого сбоя; отдельные хранилища аккаунтов.
- Адаптивная навигация: нижняя панель на телефоне, боковая на широком экране.
![Интерфейс на компьютере](docs/desktop.png)
## Платформы
| Платформа | Реализация / сборка |
| --- | --- |
| Android | Flutter, Android SDK и Java; APK/AAB |
| iPhone, iPad | Один iOS Flutter target; сборка на macOS с Xcode |
| macOS | Flutter runner; сборка на macOS с Xcode |
| Windows | Flutter runner; сборка на Windows с Visual Studio C++ Desktop |
| Linux | Flutter runner; GTK3, libsecret, jsoncpp, Clang, CMake, Ninja |
| Web | Flutter JavaScript release; отдельная статическая раздача |
| Apple Watch | Сохранён полноценный **нативный SwiftUI** target `BalanceWatch` в `../Balance/Balance.xcodeproj` |
Flutter не предоставляет официального watchOS runner. Часы используют исходный независимый клиент и напрямую тот же Go-сервер: обзор, аналитика, операции, категории, бюджеты, корректировка и настройки сохранены. Интерфейс Material 3 реализован во Flutter; часы используют SwiftUI. Не добавлен фиктивный `flutter build watchos`.
Проверенный SDK: Flutter **3.47.8**, Dart **3.13.7**. Зависимости закреплены в `pubspec.lock`. Точные выполненные проверки и ограничения среды перечислены в [VALIDATION.md](VALIDATION.md).
## Запуск
```sh
cd BalanceFlutter
flutter pub get
flutter run -d chrome # или устройство из flutter devices
flutter analyze
flutter test
```
Linux зависимости (Ubuntu):
```sh
sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev
```
Сборка из корня Flutter-проекта:
```sh
bash scripts/build.sh web
bash scripts/build.sh android
bash scripts/build.sh ios # macOS; без подписи, для проверки сборки
bash scripts/build.sh macos # macOS
bash scripts/build.sh windows # Windows, Git Bash
bash scripts/build.sh linux
bash scripts/build.sh watchos # macOS; исходный native Watch target, simulator
flutter build appbundle # Android публикация
```
iPad использует тот же iOS target. В Xcode откройте `ios/Runner.xcworkspace`, выберите Team, свой Bundle ID и устройство. Для macOS — `macos/Runner.xcworkspace`. Для часов — `../Balance/Balance.xcodeproj`, схема `BalanceWatch`, Team и уникальный Bundle ID. Сборка на симулятор без подписи не заменяет проверку на устройстве и настройку distribution signing.
Перед публикацией настройте собственные идентификаторы пакетов и Android release keystore. Пример настроек подписи находится в `android/key.properties.example`; реальные ключи и пароли не коммитьте. Подпись и публикация в магазины не выполнялись.
CI `.github/workflows/flutter.yml` содержит анализатор, тесты, отдельные сборки шести Flutter targets и нативного Watch. Это конфигурация для запуска в GitHub Actions; на Gitea её потребуется запускать совместимым runner либо перенести шаги в Gitea Actions.
## Подключение существующего сервера и перенос данных
1. Синхронизируйте старое приложение со своим работающим Go-сервером.
2. В новом приложении откройте **Настройки → Адрес сервера**, сохраните HTTPS-адрес.
3. Войдите с тем же аккаунтом и выполните синхронизацию.
4. Сравните общий баланс, операции, категории, бюджеты и корректировки, затем переходите на новый клиент.
Сохраняются UUID, даты обновления, типы, снимки оформления категорий и флаг корректировки. Flutter не читает напрямую старые SwiftData/Room sandbox. CSV — отчёт, а не полный резервный формат, через него нельзя без потерь перенести бюджеты и корректировки.
При первом подключении нового аккаунта текущие локальные записи переносятся в него. Последующие аккаунты и серверы имеют отдельные базы; выход не удаляет их данные. Валюта и тема остаются настройками устройства. Валюта меняет формат, обменных курсов и пересчёта нет.
Внутренние суммы Flutter хранятся целыми сотыми долями валюты, серверный формат `amount`/`limit` остаётся числовым. Для старых записей с более чем двумя десятичными знаками используется округление до сотых. Интервалы везде `[начало, конец)`, чтобы полночь следующего месяца не учитывалась дважды.
## Web и обратный прокси
`flutter build web` создаёт `build/web`. Раздавайте его отдельной статикой, например Nginx, с fallback на `index.html`. Go-сервер продолжает отдавать свой встроенный старый Web-интерфейс; новая Flutter-сборка не заменяет его автоматически.
Укажите origin Flutter-сайта в существующей настройке сервера `BALANCE_CORS_ORIGINS`. Например: `https://balance.example.org`. API можно проксировать под `/api/` с удалением префикса; тогда адрес сервера в приложении — `https://balance.example.org/api`.
```nginx
server {
listen 443 ssl;
server_name balance.example.org;
# ssl_certificate /...; ssl_certificate_key /...;
root /var/www/balance;
location / { try_files $uri $uri/ /index.html; }
location /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
Не переносите CSP заголовок встроенного Go Web-клиента на Flutter-статику: это другой frontend. На Web используйте HTTPS; browser storage доступен тому же origin и не обладает изоляцией системного Keychain/Keystore. При недоступном защищённом хранилище сессия сохраняется только в памяти, приложение показывает предупреждение. Финансовые записи остаются локально в IndexedDB.
## Автоматическая синхронизация
На всех платформах синхронизация запускается при входе/открытии, возвращении в приложение и раз в две минуты, пока оно активно. Android дополнительно регистрирует WorkManager-задачу раз в 15 минут при наличии сети. ОС может откладывать её по энергосбережению или после принудительной остановки приложения.
Фоновая задача выполняет полный push/pull и обновление токенов. Общая нативная блокировка защищает сессию и хранилище между открытым приложением и фоновым Flutter engine. Проверены компиляция и тесты блокировки; выполнение задачи после закрытия приложения ещё нужно проверить на Android-устройстве.
Web использует Web Locks API для защиты общей базы и refresh-токенов между вкладками. Требуется современный браузер и HTTPS (или localhost). На desktop рекомендуется один запущенный экземпляр приложения: межпроцессная координация desktop-клиентов отдельно не реализована.
Roboto и CanvasKit раздаются вместе с Web-сборкой: сборочный скрипт использует `--no-web-resources-cdn`. Стандартный сгенерированный Flutter service worker кэширует оболочку; после первого открытия Web может работать с локальными данными без API, а синхронизация требует связи с сервером. Реальная установка PWA и восстановление без сети в браузере в этой среде не проверялись.
## Устройство проекта
- `lib/domain/finance.dart`: модели, расчёты, категории, CSV.
- `lib/services/app_store.dart`: CRUD, очередь, сессии, атомарное применение страниц.
- `lib/services/sync_api.dart`: существующий wire-протокол сервера.
- `lib/services/storage*.dart`: Sembast / IndexedDB.
- `lib/ui/`: интерфейс и формы Material 3.
- `test/`: расчёты, хранилище, HTTP-протокол, формы и адаптивные экраны.
- `tool/integration_sync.dart`: проверка с настоящим Go HTTP-сервером.
- [SYNC_PROTOCOL.md](SYNC_PROTOCOL.md), [FEATURE_PARITY.md](FEATURE_PARITY.md): протокол и аудит переноса.
Для Linux в пользовательской сессии нужен работающий Secret Service, например GNOME Keyring или KWallet с поддержкой Secret Service, и библиотека `libsecret-1-0`: через него `flutter_secure_storage` сохраняет токены. Если служба недоступна или связка ключей заблокирована, приложение сохраняет сессию только в памяти и показывает предупреждение; локальные финансовые записи сохраняются отдельно в Sembast. Для сборки нужны `clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev libjsoncpp-dev`.
+94
View File
@@ -0,0 +1,94 @@
# Balance sync wire protocol
`lib/services/sync_api.dart` implements the existing Go `/v1` API using
`package:http`. It uses JSON maps independently of local model classes. Add
`http` to the app's dependencies. No server modifications are required.
## Authentication
- `register(email, password)` and `login(email, password)` POST to
`/v1/auth/register` and `/v1/auth/login` with `{email,password}`.
- They return `{accessToken,refreshToken,expiresAt,user:{id,email}}`.
- `refresh(refreshToken)` POSTs `{refreshToken}` to `/v1/auth/refresh` and
**rotates** the refresh token. Serialize refresh operations and save the full
returned session atomically in the separate secure session store.
- `me(accessToken)` GETs `/v1/me` using `Authorization: Bearer …`.
- `logout(refreshToken)` POSTs to `/v1/auth/logout` (204). Clear the local store
in `finally`; offline logout cannot revoke the server session.
The client is stateless and never persists tokens. Its caller should refresh
60 seconds before `expiresAt`, retain sessions on transient network/5xx errors,
clear them on invalid refresh credentials, and retry an authorized request at
most once following a successful refresh on 401. Concurrent refreshes must share
one in-flight operation because refresh tokens are single-use. `synchronize`
accepts an async `accessToken` provider for this external session manager; it
does not automatically replay failed HTTP requests. HTTP errors expose status,
server error code and message through `SyncApiException`; network, timeout and
format errors remain distinct exceptions. Never log session maps or passwords.
## Sync and payloads
POST `/v1/sync` with:
```json
{"cursor":0,"deviceId":"stable-device-id","changes":[{"entity":"category","id":"UUID","deleted":false,"updatedAt":"2026-10-28T12:00:00Z","payload":{"name":"Food","icon":"cart","emoji":"","colorName":"indigo","kindRawValue":"expense","createdAt":"2026-10-28T12:00:00Z"}}],"pull":false}
```
The server returns `{cursor,changes,serverTime,hasMore}`. Changes include server
`version` and global `sequence`; neither is an optimistic concurrency precondition.
Timestamps use RFC3339 UTC (Dart: `date.toUtc().toIso8601String()`). Preserve
record IDs exactly, including UUID case; Swift uses UUID IDs. The current server
does not use `deviceId` for conflict resolution.
| Entity | Active payload fields |
| --- | --- |
| `transaction` | `amount` number, `date` timestamp, `note`, `categoryName`, `categoryIcon`, `categoryEmoji`, `categoryColorName`, `isBalanceAdjustment` boolean, `kindRawValue` (`income` / `expense`) |
| `category` | `name`, `icon`, `emoji`, `colorName`, `kindRawValue`, `createdAt` timestamp |
| `budget` | `categoryName`, `categoryIcon`, `categoryEmoji`, `limit` number, `monthStart` timestamp |
Deletion uses the same envelope with `deleted:true`, an updated timestamp and
no payload. Keep tombstones until confirmed by a pulled change. The server limits
requests to 2 MiB, 5000 changes and each payload to 256 KiB; it rejects timestamps
over 24 hours into its future. `synchronize` uploads immutable snapshots in batches
of 100, splitting batches beyond 1.5 MB, then downloads 500-record pages. Push-only
requests do not advance the cursor. `sync` exposes a single page for custom flows.
## Durable merge contract
Scope cursor and push watermark by normalized server URL **and user ID**. Never
reuse another account's cursor. Serialize full sync runs. Capture a cutoff and a
stable snapshot of local edits up to that cutoff before starting. Native clients
query edits after `lastPush - 5 seconds` (or from zero after a clock rollback),
plus pending tombstones; overlapping uploads are safe.
The server keeps a change only when `incoming.updatedAt > existing.updatedAt`:
last timestamp wins, and equal timestamps leave the existing server value. Pull
in sequence order. Apply an active record only when it is newer than the local
record; apply a deletion when its timestamp is at least the local timestamp.
A pending local tombstone must also participate in this comparison, preventing
an older remote active record from resurrecting a newer local deletion. Remove
a pending tombstone only when a confirmed remote change is at least as new.
`synchronize(..., applyPage: (changes, cursor) async { ... })` requires the caller
to merge a complete page and store its cursor in one durable transaction. Throw
on malformed/unapplied records; advancing the cursor would otherwise lose data.
It validates page order and cursor progress before invoking this callback. Record
the push cutoff only after the entire method succeeds. A failed run can replay
its snapshot safely; do not remove edits made concurrently after the cutoff.
## Browser considerations
Use HTTPS. Local HTTP development requires explicit `allowInsecureHttp:true`;
browsers still block HTTPS-to-HTTP mixed content. Cross-origin Flutter Web needs
the exact frontend origin in the existing server CORS configuration. This client
uses bearer headers and refresh tokens in JSON, not credentialed browser cookies;
the server currently has no `Access-Control-Allow-Credentials`, and its cookies
are `SameSite=Strict`. A cross-site cookie session cannot be assumed to work.
Browser token persistence is a separate security decision: do not use
localStorage/SharedPreferences for tokens; memory-only sessions require login
after reload. JavaScript execution can access any tokens returned to the app,
so XSS remains a risk even with encrypted browser persistence. Native platforms
should use their OS secure storage. Cursors above 2^53−1 are rejected to avoid
silent precision loss in Flutter Web. The transport disables redirect following
where supported; browsers may manage redirects themselves, so configure the
final HTTPS API URL directly.
+31
View File
@@ -0,0 +1,31 @@
# Проверки переноса
Дата: 28 октября 2026. SDK Flutter 3.47.8 / Dart 3.13.7, Linux x64.
## Подтверждено
- Финансовые модели и расчёты: 13 unit-тестов, включая исключение корректировок, границы месяца, CSV, точное локальное хранение целых сотых и локальный месяц бюджета после серверного UTC round trip. Проверка часового пояса также запускалась с `TZ=Europe/Moscow`.
- Хранилище: 10 тестов — восстановление, dirty/tombstone, каскады, rollback курсора, scoped accounts, headless store, два экземпляра store, logout и ротация токенов между engines.
- HTTP-протокол: 7 MockClient-тестов — auth, пакетная отправка, страницы, ошибки и валидация.
- RuntimeLock: 3 Dart channel-теста. Web origin-wide Web Locks проверены компиляцией JavaScript и Node 24 native navigator.locks (владение, ожидание, освобождение, повторное получение и отказ без secure context/API). Android Java release compilation и 3 JVM-теста: сериализация потоков, повторное освобождение, прерывание ожидания, реальное межпроцессное состязание за file lock.
- Интерфейс: 22 widget/screenshot-теста. Все пять экранов с пустой и заполненной базой на 320×568, 390×844 и 1280×900, формы и ошибки ввода, CRUD операций/категорий/бюджетов, текст 1.5×, тёмная тема, отрицательная корректировка, CSV через save selector и share sheet с проверкой байтов.
- Настоящий Go HTTP-сервер: регистрация, вход, `/me`, 1102 записи, страницы `[500,500,102]`, изменения, удаление, replay старой записи, изоляция двух пользователей, refresh rotation и отзыв refresh после logout. Запуск: `bash tool/run_integration.sh` (нужны Go 1.25+ и Dart).
- Общий финальный запуск: **55 Flutter-тестов прошли**, `flutter analyze` — **No issues found**.
- Web JavaScript release с локальным CanvasKit и Linux x64 release собраны. Android universal release APK также собран и снабжён отдельной тестовой подписью; проверяются apksigner и 16 KiB zipalign. WorkManager Kotlin/AGP compatibility настроена в runner Gradle.
- Go-сервер не изменялся; cloud-capabilities удалены из Apple templates, SwiftData явно локальный.
В исходном checkout отсутствует `BalanceServer/cmd/balance-server`, хотя README и Dockerfile на него ссылаются (каталог исключается исходным ignore pattern). Поэтому integration runner создаёт временный `main` **только во временной копии**, вызывая исходные config/database/httpapi; исходный сервер остаётся без изменений. Это ограничение исходного репозитория, а не изменение протокола или сервера в переносе. Для обычной серверной Docker-сборки нужен оригинальный entry point; уже работающий Go-сервер можно использовать напрямую.
## Что ещё требует целевой среды
- iOS/iPadOS, macOS, Windows и watchOS не собирались в этой Linux-среде. Runners и CI созданы; реальные результаты CI не получены.
- watchOS — сохранённый нативный SwiftUI target, не Flutter. Material 3 относится к Flutter-клиенту.
- Полная проверка WorkManager после закрытия/остановки приложения, system Keychain/Keystore, iPad share popover, файловых диалогов, доступности и реальной клавиатуры требует устройств.
- Linux release слинкован, но запуск GTK под Xvfb/DBus был заблокирован ограничением сокетов среды. Локальные финансы и запуск store проверены unit/widget-тестами; реальный Secret Service пользователя здесь не проверен.
- Web runtime в настоящем браузере, CORS с пользовательским сервером, PWA install/offline reload и обновление кэша здесь не проверялись.
- Подпись Apple, публикация и магазины не выполнялись. Android тестовая подпись, если приложена, предназначена для проверки установки.
- Перенос старой SwiftData/Room базы выполняется через существующий сервер; прямого чтения чужого sandbox нет. Старое приложение следует сохранить до сверки данных.
## Принятые различия
Material 3 и адаптивная навигация; денежные значения в целых сотых (старые доли менее сотой округляются); границы периодов `[start,end)`; отдельные данные аккаунтов; график динамики — пары столбцов. CloudKit удалён по прямому запросу. Финансовые payload, UUID, даты обновления, корректировки, категории, бюджеты и удаления совместимы с исходным сервером.
+13
View File
@@ -0,0 +1,13 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:
prefer_single_quotes: true
+16
View File
@@ -0,0 +1,16 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
.kotlin/
@@ -0,0 +1,71 @@
import java.util.Properties
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val releaseSigningFile = rootProject.file("key.properties")
val releaseSigning = Properties().apply {
if (releaseSigningFile.exists()) {
releaseSigningFile.inputStream().use { load(it) }
}
}
android {
namespace = "com.balance.balance"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.balance.balance"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
if (releaseSigningFile.exists()) {
create("release") {
keyAlias = releaseSigning.getProperty("keyAlias")
keyPassword = releaseSigning.getProperty("keyPassword")
storeFile = file(releaseSigning.getProperty("storeFile"))
storePassword = releaseSigning.getProperty("storePassword")
}
}
}
buildTypes {
release {
// CI builds remain unsigned until a private key.properties is supplied.
signingConfig = if (releaseSigningFile.exists()) {
signingConfigs.getByName("release")
} else {
null
}
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Balance"
android:networkSecurityConfig="@xml/network_security_config"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.balance.balance
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">::1</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+37
View File
@@ -0,0 +1,37 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
// Workmanager 0.9.3 assumes AGP 9 always compiles Kotlin itself. Flutter's
// current compatibility mode disables built-in Kotlin, so enable KGP for it.
subprojects {
if (name == "workmanager_android" &&
providers.gradleProperty("android.builtInKotlin").orNull == "false") {
pluginManager.withPlugin("com.android.library") {
pluginManager.apply("org.jetbrains.kotlin.android")
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions.jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8)
}
}
}
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
@@ -0,0 +1,4 @@
storePassword=YOUR_STORE_PASSWORD
keyPassword=YOUR_KEY_PASSWORD
keyAlias=upload
storeFile=../upload-keystore.jks
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+203
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1 @@
#include "Generated.xcconfig"
@@ -0,0 +1,650 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.balance.balance;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+75
View File
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Balance</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>balance</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</plist>
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
+685
View File
@@ -0,0 +1,685 @@
/// Finance values use hundredths of a currency unit internally. JSON retains
/// the existing iOS / Android / Go synchronization protocol.
enum TransactionKind {
expense,
income;
String get title => this == income ? 'Доход' : 'Расход';
static TransactionKind parse(String value) =>
value == 'income' ? income : expense;
}
int moneyToMinor(num value) {
if (!value.isFinite) throw const FormatException('Invalid money amount');
return (value * 100).round();
}
/// Parse user-entered amounts without passing through binary floating point.
/// An empty value, exponent, sign or more than two decimal places is rejected.
int parseMoney(String value) {
final normalized = value.trim().replaceAll(',', '.');
if (!RegExp(r'^\d+(?:\.\d{1,2})?$').hasMatch(normalized)) {
throw const FormatException(
'Введите положительную сумму, максимум 2 знака после запятой',
);
}
final parts = normalized.split('.');
return int.parse(parts[0]) * 100 +
(parts.length == 1 ? 0 : int.parse(parts[1].padRight(2, '0')));
}
String formatMinor(int value) {
final absolute = value.abs();
return '${value < 0 ? '-' : ''}${absolute ~/ 100}.${(absolute % 100).toString().padLeft(2, '0')}';
}
DateTime _date(dynamic value) => DateTime.parse(value as String);
String _timestamp(DateTime value) => value.toUtc().toIso8601String();
class TransactionRecord {
final String id;
final int amountMinor;
final DateTime date;
final String note,
categoryName,
categoryIcon,
categoryEmoji,
categoryColorName;
final bool isBalanceAdjustment;
final String kindRawValue;
final DateTime updatedAt;
const TransactionRecord({
required this.id,
required this.amountMinor,
required this.date,
required this.categoryName,
required this.updatedAt,
this.note = '',
this.categoryIcon = 'circle.fill',
this.categoryEmoji = '',
this.categoryColorName = 'auto',
this.isBalanceAdjustment = false,
this.kindRawValue = 'expense',
});
double get amount => amountMinor / 100;
TransactionKind get kind => TransactionKind.parse(kindRawValue);
Map<String, dynamic> payload() => {
'amount': amount,
'date': _timestamp(date),
'note': note,
'categoryName': categoryName,
'categoryIcon': categoryIcon,
'categoryEmoji': categoryEmoji,
'categoryColorName': categoryColorName,
'isBalanceAdjustment': isBalanceAdjustment,
'kindRawValue': kindRawValue,
};
Map<String, dynamic> toJson() => {
'id': id,
'updatedAt': _timestamp(updatedAt),
...payload(),
'amountMinor': amountMinor,
};
factory TransactionRecord.fromJson(Map<String, dynamic> json) =>
TransactionRecord.fromPayload(
id: json['id'] as String,
updatedAt: _date(json['updatedAt']),
payload: json,
).copyWith(amountMinor: json['amountMinor'] as int?);
factory TransactionRecord.fromPayload({
required String id,
required DateTime updatedAt,
required Map<String, dynamic> payload,
}) => TransactionRecord(
id: id,
updatedAt: updatedAt,
amountMinor: moneyToMinor(payload['amount'] as num),
date: _date(payload['date']),
categoryName: payload['categoryName'] as String,
note: payload['note'] as String? ?? '',
categoryIcon: payload['categoryIcon'] as String? ?? 'circle.fill',
categoryEmoji: payload['categoryEmoji'] as String? ?? '',
categoryColorName: payload['categoryColorName'] as String? ?? 'auto',
isBalanceAdjustment: payload['isBalanceAdjustment'] as bool? ?? false,
kindRawValue: payload['kindRawValue'] as String? ?? 'expense',
);
TransactionRecord copyWith({
String? id,
int? amountMinor,
DateTime? date,
String? note,
String? categoryName,
String? categoryIcon,
String? categoryEmoji,
String? categoryColorName,
bool? isBalanceAdjustment,
String? kindRawValue,
DateTime? updatedAt,
}) => TransactionRecord(
id: id ?? this.id,
amountMinor: amountMinor ?? this.amountMinor,
date: date ?? this.date,
updatedAt: updatedAt ?? this.updatedAt,
note: note ?? this.note,
categoryName: categoryName ?? this.categoryName,
categoryIcon: categoryIcon ?? this.categoryIcon,
categoryEmoji: categoryEmoji ?? this.categoryEmoji,
categoryColorName: categoryColorName ?? this.categoryColorName,
isBalanceAdjustment: isBalanceAdjustment ?? this.isBalanceAdjustment,
kindRawValue: kindRawValue ?? this.kindRawValue,
);
}
class CategoryRecord {
final String id, name, icon, emoji, colorName, kindRawValue;
final DateTime createdAt, updatedAt;
const CategoryRecord({
required this.id,
required this.name,
required this.icon,
required this.colorName,
required this.createdAt,
required this.updatedAt,
this.emoji = '',
this.kindRawValue = 'expense',
});
TransactionKind get kind => TransactionKind.parse(kindRawValue);
Map<String, dynamic> payload() => {
'name': name,
'icon': icon,
'emoji': emoji,
'colorName': colorName,
'kindRawValue': kindRawValue,
'createdAt': _timestamp(createdAt),
};
Map<String, dynamic> toJson() => {
'id': id,
'updatedAt': _timestamp(updatedAt),
...payload(),
};
factory CategoryRecord.fromJson(Map<String, dynamic> json) =>
CategoryRecord.fromPayload(
id: json['id'] as String,
updatedAt: _date(json['updatedAt']),
payload: json,
);
factory CategoryRecord.fromPayload({
required String id,
required DateTime updatedAt,
required Map<String, dynamic> payload,
}) => CategoryRecord(
id: id,
updatedAt: updatedAt,
name: payload['name'] as String,
icon: payload['icon'] as String? ?? 'star.fill',
emoji: payload['emoji'] as String? ?? '',
colorName: payload['colorName'] as String? ?? 'indigo',
kindRawValue: payload['kindRawValue'] as String? ?? 'expense',
createdAt: payload['createdAt'] == null
? updatedAt
: _date(payload['createdAt']),
);
CategoryRecord copyWith({
String? id,
String? name,
String? icon,
String? emoji,
String? colorName,
String? kindRawValue,
DateTime? createdAt,
DateTime? updatedAt,
}) => CategoryRecord(
id: id ?? this.id,
name: name ?? this.name,
icon: icon ?? this.icon,
emoji: emoji ?? this.emoji,
colorName: colorName ?? this.colorName,
kindRawValue: kindRawValue ?? this.kindRawValue,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
class BudgetRecord {
final String id, categoryName, categoryIcon, categoryEmoji;
final int limitMinor;
final DateTime monthStart, updatedAt;
const BudgetRecord({
required this.id,
required this.categoryName,
required this.limitMinor,
required this.monthStart,
required this.updatedAt,
this.categoryIcon = 'circle.fill',
this.categoryEmoji = '',
});
double get limit => limitMinor / 100;
Map<String, dynamic> payload() => {
'categoryName': categoryName,
'categoryIcon': categoryIcon,
'categoryEmoji': categoryEmoji,
'limit': limit,
'monthStart': _timestamp(monthStart),
};
Map<String, dynamic> toJson() => {
'id': id,
'updatedAt': _timestamp(updatedAt),
...payload(),
'limitMinor': limitMinor,
};
factory BudgetRecord.fromJson(Map<String, dynamic> json) =>
BudgetRecord.fromPayload(
id: json['id'] as String,
updatedAt: _date(json['updatedAt']),
payload: json,
).copyWith(limitMinor: json['limitMinor'] as int?);
factory BudgetRecord.fromPayload({
required String id,
required DateTime updatedAt,
required Map<String, dynamic> payload,
}) => BudgetRecord(
id: id,
updatedAt: updatedAt,
categoryName: payload['categoryName'] as String,
categoryIcon: payload['categoryIcon'] as String? ?? 'circle.fill',
categoryEmoji: payload['categoryEmoji'] as String? ?? '',
limitMinor: moneyToMinor(payload['limit'] as num),
monthStart: _date(payload['monthStart']),
);
BudgetRecord copyWith({
String? id,
String? categoryName,
String? categoryIcon,
String? categoryEmoji,
int? limitMinor,
DateTime? monthStart,
DateTime? updatedAt,
}) => BudgetRecord(
id: id ?? this.id,
categoryName: categoryName ?? this.categoryName,
categoryIcon: categoryIcon ?? this.categoryIcon,
categoryEmoji: categoryEmoji ?? this.categoryEmoji,
limitMinor: limitMinor ?? this.limitMinor,
monthStart: monthStart ?? this.monthStart,
updatedAt: updatedAt ?? this.updatedAt,
);
}
class Tombstone {
final String entity, id;
final DateTime updatedAt;
const Tombstone({
required this.entity,
required this.id,
required this.updatedAt,
});
String get recordID => id;
Map<String, dynamic> toJson() => {
'entity': entity,
'id': id,
'deleted': true,
'updatedAt': _timestamp(updatedAt),
};
factory Tombstone.fromJson(Map<String, dynamic> json) => Tombstone(
entity: json['entity'] as String,
id: json['id'] as String,
updatedAt: _date(json['updatedAt']),
);
}
class FinanceSummary {
final int incomeMinor, expensesMinor;
const FinanceSummary({
required this.incomeMinor,
required this.expensesMinor,
});
int get balanceMinor => incomeMinor - expensesMinor;
double get income => incomeMinor / 100;
double get expenses => expensesMinor / 100;
double get balance => balanceMinor / 100;
}
class CategorySpending {
final String name, icon, emoji, colorName;
final int amountMinor;
const CategorySpending({
required this.name,
required this.icon,
required this.emoji,
required this.colorName,
required this.amountMinor,
});
double get amount => amountMinor / 100;
}
class MonthlyTotal {
final DateTime month;
final FinanceSummary summary;
const MonthlyTotal(this.month, this.summary);
}
class FinanceMath {
static int totalBalance(Iterable<TransactionRecord> items) => items.fold(
0,
(value, item) =>
value +
(item.kind == TransactionKind.income
? item.amountMinor
: -item.amountMinor),
);
static DateTime monthStart(DateTime date) => date.isUtc
? DateTime.utc(date.year, date.month)
: DateTime(date.year, date.month);
static DateTime nextMonth(DateTime date) => date.isUtc
? DateTime.utc(date.year, date.month + 1)
: DateTime(date.year, date.month + 1);
static bool _inPeriod(TransactionRecord item, DateTime start, DateTime end) =>
!item.isBalanceAdjustment &&
!item.date.isBefore(start) &&
item.date.isBefore(end);
static FinanceSummary summary(
Iterable<TransactionRecord> items,
DateTime start,
DateTime endExclusive,
) {
var income = 0;
var expenses = 0;
for (final item in items) {
if (!_inPeriod(item, start, endExclusive)) continue;
if (item.kind == TransactionKind.income) {
income += item.amountMinor;
} else {
expenses += item.amountMinor;
}
}
return FinanceSummary(incomeMinor: income, expensesMinor: expenses);
}
static List<CategorySpending> spending(
Iterable<TransactionRecord> items,
DateTime start,
DateTime endExclusive,
) {
final grouped = <String, CategorySpending>{};
for (final item in items) {
if (item.kind != TransactionKind.expense ||
!_inPeriod(item, start, endExclusive)) {
continue;
}
final existing = grouped[item.categoryName];
grouped[item.categoryName] = CategorySpending(
name: item.categoryName,
icon: existing?.icon ?? item.categoryIcon,
emoji: existing?.emoji ?? item.categoryEmoji,
colorName: existing?.colorName ?? item.categoryColorName,
amountMinor: (existing?.amountMinor ?? 0) + item.amountMinor,
);
}
return grouped.values.toList()..sort((a, b) {
final byAmount = b.amountMinor.compareTo(a.amountMinor);
return byAmount == 0 ? a.name.compareTo(b.name) : byAmount;
});
}
/// Includes empty months, ordered from oldest to newest.
static List<MonthlyTotal> monthlyTrend(
Iterable<TransactionRecord> items,
DateTime through, {
int months = 6,
}) {
if (months < 0) throw ArgumentError.value(months, 'months');
final snapshot = items.toList();
return List.generate(months, (index) {
final month = through.isUtc
? DateTime.utc(through.year, through.month - months + 1 + index)
: DateTime(through.year, through.month - months + 1 + index);
return MonthlyTotal(month, summary(snapshot, month, nextMonth(month)));
});
}
static int budgetUsed(
BudgetRecord budget,
Iterable<TransactionRecord> items,
) {
// Native clients serialize local month starts as UTC instants. In positive
// offsets January 1 becomes December 31 on the wire; recover the local
// calendar month before building its spending interval.
final start = monthStart(budget.monthStart.toLocal());
return items
.where(
(item) =>
item.categoryName == budget.categoryName &&
item.kind == TransactionKind.expense &&
_inPeriod(item, start, nextMonth(start)),
)
.fold(0, (value, item) => value + item.amountMinor);
}
static String escapeCsv(String value) => '"${value.replaceAll('"', '""')}"';
static String exportCsv(Iterable<TransactionRecord> items) {
final ordered = items.toList()..sort((a, b) => b.date.compareTo(a.date));
return [
'Дата,Тип,Категория,Сумма,Заметка',
...ordered.map(
(item) => [
_timestamp(item.date),
item.isBalanceAdjustment ? 'Корректировка' : item.kind.title,
item.categoryName,
formatMinor(item.amountMinor),
item.note,
].map(escapeCsv).join(','),
),
].join('\n');
}
}
class CategoryDesignOptions {
static const icons = <String>[
'star.fill',
'heart.fill',
'sparkles',
'bolt.fill',
'flame.fill',
'drop.fill',
'cart.fill',
'bag.fill',
'creditcard.fill',
'banknote.fill',
'gift.fill',
'tag.fill',
'cup.and.saucer.fill',
'fork.knife',
'takeoutbag.and.cup.and.straw.fill',
'birthday.cake.fill',
'car.fill',
'bus.fill',
'tram.fill',
'bicycle',
'airplane',
'fuelpump.fill',
'house.fill',
'bed.double.fill',
'lightbulb.fill',
'wifi',
'phone.fill',
'printer.fill',
'pawprint.fill',
'leaf.fill',
'figure.walk',
'figure.run',
'dumbbell.fill',
'cross.case.fill',
'pills.fill',
'stethoscope',
'book.fill',
'graduationcap.fill',
'laptopcomputer',
'briefcase.fill',
'gamecontroller.fill',
'music.note',
'film.fill',
'camera.fill',
'paintpalette.fill',
'tshirt.fill',
'hammer.fill',
'wrench.and.screwdriver.fill',
'shippingbox.fill',
'globe.europe.africa.fill',
];
static const emojis = <String>[
'🍔',
'☕️',
'🛒',
'🚕',
'✈️',
'🏠',
'💡',
'📱',
'💻',
'🎮',
'🎬',
'🎵',
'📚',
'🎓',
'💊',
'🏋️',
'🐶',
'🐱',
'👕',
'🎁',
'💰',
'💳',
'📈',
'🔧',
'🌿',
'❤️',
'⭐️',
'🎯',
'🧾',
'🏖️',
];
static const colors = <String>[
'red',
'orange',
'yellow',
'green',
'mint',
'teal',
'cyan',
'blue',
'indigo',
'purple',
'pink',
'gray',
'coral',
'peach',
'gold',
'lime',
'forest',
'turquoise',
'sky',
'navy',
'lavender',
'magenta',
'brown',
'slate',
];
}
class BuiltInCategories {
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
static final expense = List<CategoryRecord>.unmodifiable([
CategoryRecord(
id: 'food',
name: 'Продукты',
icon: 'cart.fill',
colorName: 'orange',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'transport',
name: 'Транспорт',
icon: 'car.fill',
colorName: 'blue',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'home',
name: 'Дом',
icon: 'house.fill',
colorName: 'indigo',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'entertainment',
name: 'Развлечения',
icon: 'gamecontroller.fill',
colorName: 'pink',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'health',
name: 'Здоровье',
icon: 'cross.case.fill',
colorName: 'red',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'shopping',
name: 'Покупки',
icon: 'bag.fill',
colorName: 'purple',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'subscriptions',
name: 'Подписки',
icon: 'repeat',
colorName: 'cyan',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'education',
name: 'Образование',
icon: 'book.fill',
colorName: 'mint',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'other-expense',
name: 'Другое',
icon: 'ellipsis.circle.fill',
colorName: 'gray',
kindRawValue: 'expense',
createdAt: _epoch,
updatedAt: _epoch,
),
]);
static final income = List<CategoryRecord>.unmodifiable([
CategoryRecord(
id: 'salary',
name: 'Зарплата',
icon: 'banknote.fill',
colorName: 'green',
kindRawValue: 'income',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'freelance',
name: 'Подработка',
icon: 'laptopcomputer',
colorName: 'teal',
kindRawValue: 'income',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'investments',
name: 'Инвестиции',
icon: 'chart.line.uptrend.xyaxis',
colorName: 'blue',
kindRawValue: 'income',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'gift',
name: 'Подарок',
icon: 'gift.fill',
colorName: 'pink',
kindRawValue: 'income',
createdAt: _epoch,
updatedAt: _epoch,
),
CategoryRecord(
id: 'other-income',
name: 'Другое',
icon: 'plus.circle.fill',
colorName: 'gray',
kindRawValue: 'income',
createdAt: _epoch,
updatedAt: _epoch,
),
]);
static List<CategoryRecord> forKind(String kind) =>
kind == 'income' ? income : expense;
}
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'
show LicenseRegistry, LicenseEntryWithLineBreaks;
import 'package:flutter/services.dart' show rootBundle;
import 'services/app_store.dart';
import 'services/background.dart';
import 'ui/app.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
LicenseRegistry.addLicense(() async* {
yield LicenseEntryWithLineBreaks([
'Roboto',
], await rootBundle.loadString('assets/fonts/LICENSE.txt'));
});
final store = AppStore();
try {
await store.initialize();
try {
await configureBackgroundSync();
} catch (_) {
store.error =
'Не удалось включить фоновую синхронизацию. Синхронизация при открытии приложения доступна.';
}
runApp(BalanceApp(store: store));
} catch (error) {
runApp(
MaterialApp(
home: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.storage_outlined, size: 48),
const SizedBox(height: 16),
const Text(
'Не удалось открыть хранилище',
style: TextStyle(fontSize: 24),
),
const SizedBox(height: 12),
SelectableText('$error'),
const SizedBox(height: 20),
FilledButton(
onPressed: main,
child: const Text('Повторить'),
),
],
),
),
),
),
),
),
);
}
}
+796
View File
@@ -0,0 +1,796 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../domain/finance.dart';
import 'storage.dart';
import 'runtime_lock.dart';
import 'sync_api.dart';
class AuthSession {
AuthSession(this.data) {
if (userId.isEmpty ||
email.isEmpty ||
accessToken.isEmpty ||
refreshToken.isEmpty ||
expiresAt.millisecondsSinceEpoch <= 0) {
throw const FormatException('Invalid saved session');
}
}
final Map<String, dynamic> data;
String get userId => (data['user'] as Map)['id'] as String;
String get email => (data['user'] as Map)['email'] as String;
String get accessToken => data['accessToken'] as String;
String get refreshToken => data['refreshToken'] as String;
DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String);
}
/// All durable mutations and account transitions share one queue. A pull page
/// writes records, tombstones, dirty changes and cursor in one database commit.
class AppStore extends ChangeNotifier with WidgetsBindingObserver {
AppStore({
SnapshotStorage? storage,
FlutterSecureStorage? secureStorage,
SyncApi Function(String serverUrl)? apiFactory,
this.background = false,
}) : _storage = storage,
_injectedStorage = storage != null,
_apiFactory = apiFactory,
_secureStorage = secureStorage ?? const FlutterSecureStorage();
SnapshotStorage? _storage;
final bool background;
final bool _injectedStorage;
final RuntimeLock _runtimeLock = RuntimeLock();
AuthSession? _volatileSession;
String? _volatileRevision;
String? _volatileServer;
final SyncApi Function(String serverUrl)? _apiFactory;
final FlutterSecureStorage _secureStorage;
SharedPreferences? _preferences;
Future<void> _tail = Future<void>.value();
Timer? _timer;
bool _disposed = false;
bool ready = false;
bool syncing = false;
String? error;
String? syncMessage;
DateTime? lastSyncAt;
String serverUrl = '';
String currency = 'RUB';
String theme = 'system';
AuthSession? session;
String? sessionPersistenceWarning;
String get email => session?.email ?? '';
String _deviceId = '';
String _scope = '';
int _cursor = 0;
final Map<String, TransactionRecord> _transactions = {};
final Map<String, CategoryRecord> _categories = {};
final Map<String, BudgetRecord> _budgets = {};
final Map<String, Map<String, dynamic>> _tombstones = {};
final Map<String, Map<String, dynamic>> _dirty = {};
List<TransactionRecord> get transactions =>
_transactions.values.toList()..sort((a, b) => b.date.compareTo(a.date));
List<CategoryRecord> get categories =>
_categories.values.toList()..sort((a, b) => a.name.compareTo(b.name));
List<BudgetRecord> get budgets =>
_budgets.values.toList()
..sort((a, b) => b.monthStart.compareTo(a.monthStart));
int get balanceMinor => _transactions.values.fold(
0,
(sum, t) =>
sum + (t.kindRawValue == 'income' ? t.amountMinor : -t.amountMinor),
);
int get pendingChangeCount => _dirty.length;
int get cursor => _cursor;
static String newId() => const Uuid().v4();
Future<T> _exclusive<T>(Future<T> Function() body) {
final result = _tail.then((_) async {
if (_disposed) throw StateError('AppStore is closed');
if (!_injectedStorage) await _runtimeLock.acquire();
try {
if (ready) await _reloadSharedState();
return await body();
} finally {
if (!_injectedStorage) await _runtimeLock.release();
}
});
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
return result;
}
void _notify() {
if (!_disposed) notifyListeners();
}
void clearError() {
error = null;
_notify();
}
Future<void> initialize() => _exclusive(() async {
if (ready) return;
_preferences = await SharedPreferences.getInstance();
await _preferences!.reload();
serverUrl = _preferences!.getString('serverUrl') ?? serverUrl;
currency = _preferences!.getString('currency') ?? currency;
theme = _preferences!.getString('theme') ?? theme;
_deviceId = _preferences!.getString('deviceId') ?? newId();
await _preferences!.setString('deviceId', _deviceId);
_storage ??= await LedgerStorage.open();
await _restoreSession();
await _loadScope();
if (!background) {
WidgetsBinding.instance.addObserver(this);
_timer = Timer.periodic(const Duration(minutes: 2), (_) {
_backgroundSync();
});
}
ready = true;
_notify();
if (!background) _backgroundSync();
});
Future<void> _reloadSharedState() async {
await _preferences!.reload();
final nextServer = _preferences!.getString('serverUrl') ?? '';
if (nextServer != serverUrl) {
_apiInstance?.close();
_apiInstance = null;
serverUrl = nextServer;
}
currency = _preferences!.getString('currency') ?? 'RUB';
theme = _preferences!.getString('theme') ?? 'system';
_deviceId = _preferences!.getString('deviceId') ?? _deviceId;
await _restoreSession();
await _loadScope();
_notify();
}
Future<void> _restoreSession() async {
session = null;
final revision = _preferences!.getString('sessionRevision.$serverUrl');
if (_volatileServer == serverUrl &&
_volatileRevision == revision &&
_volatileSession != null) {
session = _volatileSession;
return;
}
_volatileSession = null;
_volatileRevision = null;
_volatileServer = null;
String? saved;
try {
if (!(_preferences!.getBool('sessionDisabled.$serverUrl') ?? false)) {
saved = await _secureStorage.read(key: _credentialKey);
}
} catch (_) {
_sessionStorageUnavailable();
}
if (saved != null) {
try {
session = AuthSession(
Map<String, dynamic>.from(jsonDecode(saved) as Map),
);
} catch (_) {
await _deleteCredentials();
}
}
}
void _sessionStorageUnavailable() {
sessionPersistenceWarning =
'Защищённое хранилище недоступно. Вход действует до закрытия приложения.';
syncMessage = sessionPersistenceWarning;
}
Future<void> _saveCredentials(Map<String, dynamic> value) async {
try {
await _secureStorage.write(key: _credentialKey, value: jsonEncode(value));
await _preferences!.setBool('sessionDisabled.$serverUrl', false);
await _preferences!.setString('sessionRevision.$serverUrl', newId());
_volatileSession = null;
_volatileRevision = null;
_volatileServer = null;
} catch (_) {
await _preferences!.setBool('sessionDisabled.$serverUrl', true);
_volatileRevision = newId();
await _preferences!.setString(
'sessionRevision.$serverUrl',
_volatileRevision!,
);
_volatileSession = AuthSession(value);
_volatileServer = serverUrl;
_sessionStorageUnavailable();
}
}
Future<void> _deleteCredentials() async {
// A failed secure delete must not restore a signed-out account next launch.
await _preferences!.setBool('sessionDisabled.$serverUrl', true);
await _preferences!.setString('sessionRevision.$serverUrl', newId());
_volatileSession = null;
_volatileRevision = null;
_volatileServer = null;
try {
await _secureStorage.delete(key: _credentialKey);
} catch (_) {
_sessionStorageUnavailable();
}
}
String get _credentialKey =>
'balance.session.${Uri.encodeComponent(serverUrl)}';
String _scopeFor(String? userId) => '$serverUrl|${userId ?? 'guest'}';
SyncApi? _apiInstance;
SyncApi get _api => _apiInstance ??=
_apiFactory?.call(serverUrl) ??
SyncApi(
serverUrl: serverUrl,
allowInsecureHttp: [
'localhost',
'127.0.0.1',
'::1',
'10.0.2.2',
].contains(Uri.parse(serverUrl).host),
);
List<CategoryRecord> categoriesForKind(Object kind) {
final raw = kind is TransactionKind ? kind.name : kind.toString();
final combined = <String, CategoryRecord>{
for (final category in BuiltInCategories.forKind(raw))
category.name: category,
for (final category in categories.where((c) => c.kindRawValue == raw))
category.name: category,
};
return combined.values.toList();
}
Future<void> _loadScope() async {
_scope = _scopeFor(session?.userId);
final raw = await _storage!.read(_scope);
_transactions.clear();
_categories.clear();
_budgets.clear();
_dirty.clear();
_tombstones.clear();
_cursor = 0;
lastSyncAt = null;
if (raw == null) return;
for (final item in (raw['transactions'] as List? ?? [])) {
final record = TransactionRecord.fromJson(
Map<String, dynamic>.from(item as Map),
);
_transactions[record.id] = record;
}
for (final item in (raw['categories'] as List? ?? [])) {
final record = CategoryRecord.fromJson(
Map<String, dynamic>.from(item as Map),
);
_categories[record.id] = record;
}
for (final item in (raw['budgets'] as List? ?? [])) {
final record = BudgetRecord.fromJson(
Map<String, dynamic>.from(item as Map),
);
_budgets[record.id] = record;
}
for (final item in (raw['dirty'] as List? ?? [])) {
final change = Map<String, dynamic>.from(item as Map);
_dirty[_key(change)] = change;
}
for (final item in (raw['tombstones'] as List? ?? [])) {
final change = Map<String, dynamic>.from(item as Map);
_tombstones[_key(change)] = change;
}
_cursor = (raw['cursor'] as num?)?.toInt() ?? 0;
lastSyncAt = DateTime.tryParse(raw['lastSyncAt'] as String? ?? '');
}
Map<String, dynamic> _snapshot() => {
'transactions': _transactions.values.map((e) => e.toJson()).toList(),
'categories': _categories.values.map((e) => e.toJson()).toList(),
'budgets': _budgets.values.map((e) => e.toJson()).toList(),
'dirty': _dirty.values.toList(),
'tombstones': _tombstones.values.toList(),
'cursor': _cursor,
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
};
Future<void> _persist() => _storage!.write(_scope, _snapshot());
String _key(Map<String, dynamic> change) =>
'${change['entity']}:${change['id']}';
Map<String, dynamic> _change(
String entity,
String id,
DateTime time,
Map<String, dynamic>? payload,
) => {
'entity': entity,
'id': id,
'updatedAt': time.toUtc().toIso8601String(),
'deleted': payload == null,
'payload': ?payload,
};
DateTime _now() {
// Strictly advance local timestamps even for successive edits in one tick.
var now = DateTime.now().toUtc();
for (final change in _dirty.values) {
final previous = DateTime.parse(change['updatedAt'] as String);
if (!now.isAfter(previous)) {
now = previous.add(const Duration(microseconds: 1));
}
}
return now;
}
void _queueChange(Map<String, dynamic> change) {
final key = _key(change);
_dirty[key] = change;
if (change['deleted'] == true) {
_tombstones[key] = change;
} else {
_tombstones.remove(key);
}
}
Future<void> _edited() async {
try {
await _persist();
} catch (_) {
await _loadScope();
rethrow;
}
_notify();
}
Future<void> saveTransaction(TransactionRecord record) =>
_exclusive(() async {
if (record.amountMinor <= 0) {
throw ArgumentError('Сумма должна быть больше нуля');
}
final saved = record.copyWith(updatedAt: _now());
_transactions[saved.id] = saved;
_queueChange(
_change('transaction', saved.id, saved.updatedAt, saved.payload()),
);
await _edited();
});
Future<void> deleteTransaction(String id) => _delete('transaction', id);
Future<void> deleteBudget(String id) => _delete('budget', id);
Future<void> deleteCategory(String id) => _delete('category', id);
Future<void> _delete(String entity, String id) => _exclusive(() async {
if (entity == 'transaction') _transactions.remove(id);
if (entity == 'category') {
final category = _categories.remove(id);
if (category != null && category.kindRawValue == 'expense') {
for (final budget
in _budgets.values
.where((b) => b.categoryName == category.name)
.toList()) {
_budgets.remove(budget.id);
_queueChange(_change('budget', budget.id, _now(), null));
}
}
}
if (entity == 'budget') _budgets.remove(id);
_queueChange(_change(entity, id, _now(), null));
await _edited();
});
Future<void> saveCategory(CategoryRecord record) => _exclusive(() async {
if (record.name.trim().isEmpty) {
throw ArgumentError('Введите название категории');
}
final saved = record.copyWith(name: record.name.trim(), updatedAt: _now());
if (_categories.values.any(
(c) =>
c.id != saved.id &&
c.name == saved.name &&
c.kindRawValue == saved.kindRawValue,
)) {
throw ArgumentError('Такая категория уже существует');
}
final previous = _categories[saved.id];
_categories[saved.id] = saved;
_queueChange(
_change('category', saved.id, saved.updatedAt, saved.payload()),
);
if (previous != null) {
for (final tx in _transactions.values.toList()) {
if (tx.categoryName != previous.name ||
tx.kindRawValue != previous.kindRawValue) {
continue;
}
final changed = tx.copyWith(
categoryName: saved.name,
categoryIcon: saved.icon,
categoryEmoji: saved.emoji,
categoryColorName: saved.colorName,
updatedAt: saved.updatedAt,
);
_transactions[tx.id] = changed;
_queueChange(
_change('transaction', tx.id, changed.updatedAt, changed.payload()),
);
}
for (final budget in _budgets.values.toList()) {
if (budget.categoryName != previous.name) continue;
final changed = budget.copyWith(
categoryName: saved.name,
categoryIcon: saved.icon,
categoryEmoji: saved.emoji,
updatedAt: saved.updatedAt,
);
_budgets[budget.id] = changed;
_queueChange(
_change('budget', budget.id, changed.updatedAt, changed.payload()),
);
}
}
await _edited();
});
Future<void> saveBudget(BudgetRecord record) => _exclusive(() async {
if (record.limitMinor <= 0) {
throw ArgumentError('Лимит должен быть больше нуля');
}
final localMonth = record.monthStart.toLocal();
final month = DateTime(localMonth.year, localMonth.month);
if (_budgets.values.any(
(b) =>
b.id != record.id &&
b.categoryName == record.categoryName &&
b.monthStart.toLocal().year == month.year &&
b.monthStart.toLocal().month == month.month,
)) {
throw ArgumentError('Бюджет для этой категории уже существует');
}
final saved = record.copyWith(monthStart: month, updatedAt: _now());
_budgets[saved.id] = saved;
_queueChange(_change('budget', saved.id, saved.updatedAt, saved.payload()));
await _edited();
});
Future<void> adjustBalance(int targetMinor, {String note = ''}) =>
_exclusive(() async {
final delta = targetMinor - balanceMinor;
if (delta == 0) return;
final now = _now();
final record = TransactionRecord(
id: newId(),
amountMinor: delta.abs(),
date: now,
categoryName: 'Корректировка',
updatedAt: now,
note: note.isEmpty ? 'Ручная корректировка баланса' : note,
isBalanceAdjustment: true,
kindRawValue: delta > 0 ? 'income' : 'expense',
);
_transactions[record.id] = record;
_queueChange(_change('transaction', record.id, now, record.payload()));
await _edited();
});
Future<void> setCurrency(String value) => _exclusive(() async {
currency = value;
await _preferences!.setString('currency', value);
_notify();
});
Future<void> setTheme(String value) => _exclusive(() async {
theme = value;
await _preferences!.setString('theme', value);
_notify();
});
Future<void> setServerUrl(String value) => _exclusive(() async {
final normalized = value.trim().replaceAll(RegExp(r'/+$'), '');
final uri = Uri.tryParse(normalized);
if (uri == null ||
!uri.hasAuthority ||
!['http', 'https'].contains(uri.scheme)) {
throw ArgumentError('Укажите полный адрес сервера');
}
// Validate the transport before clearing an existing account or changing its scope.
final validation = SyncApi(
serverUrl: normalized,
allowInsecureHttp: const [
'localhost',
'127.0.0.1',
'::1',
'10.0.2.2',
].contains(uri.host),
);
validation.close();
if (normalized == serverUrl) return;
final unconfiguredGuest = serverUrl.isEmpty && session == null
? _snapshot()
: null;
await _deleteCredentials();
session = null;
_apiInstance?.close();
_apiInstance = null;
serverUrl = normalized;
await _preferences!.setString('serverUrl', normalized);
// Credentials are deliberately not restored when switching servers.
await _deleteCredentials();
if (unconfiguredGuest != null &&
await _storage!.read(_scopeFor(null)) == null) {
await _storage!.write(_scopeFor(null), unconfiguredGuest);
}
await _loadScope();
error = null;
syncMessage = null;
_notify();
});
Future<void> signIn(String email, String password) =>
_authenticate(email, password, false);
Future<void> register(String email, String password) =>
_authenticate(email, password, true);
Future<void> _authenticate(
String email,
String password,
bool register,
) => _exclusive(() async {
final response = register
? await _api.register(email, password)
: await _api.login(email, password);
final nextSession = AuthSession(response);
final nextScope = _scopeFor(nextSession.userId);
final exists = await _storage!.read(nextScope);
final marker = 'importedGuest.$serverUrl';
final guest =
session == null &&
exists == null &&
!(_preferences!.getBool(marker) ?? false)
? _snapshot()
: null;
session = nextSession;
await _saveCredentials(response);
await _loadScope();
if (guest != null) {
await _storage!.write(_scope, guest);
await _loadScope();
_cursor = 0;
// Imported rows may have been synced under another prior guest snapshot.
for (final t in _transactions.values) {
_queueChange(_change('transaction', t.id, t.updatedAt, t.payload()));
}
for (final c in _categories.values) {
_queueChange(_change('category', c.id, c.updatedAt, c.payload()));
}
for (final b in _budgets.values) {
_queueChange(_change('budget', b.id, b.updatedAt, b.payload()));
}
await _persist();
await _preferences!.setBool(marker, true);
}
error = null;
_notify();
await _syncLocked();
});
Future<void> signOut() => _exclusive(() async {
final old = session;
// Local logout succeeds even if the network is unavailable.
session = null;
await _deleteCredentials();
await _loadScope();
error = null;
syncMessage = null;
_notify();
if (old != null) {
try {
await _api.logout(old.refreshToken);
} catch (_) {}
}
});
Future<String> _accessToken({bool force = false}) async {
final current = session;
if (current == null) throw StateError('Войдите в аккаунт');
if (!force &&
current.expiresAt.isAfter(
DateTime.now().add(const Duration(minutes: 1)),
)) {
return current.accessToken;
}
final refreshed = await _api.refresh(current.refreshToken);
session = AuthSession(refreshed);
await _saveCredentials(refreshed);
return session!.accessToken;
}
Future<void> sync() => _exclusive(_syncLocked);
Future<void> resetAndSync() => _exclusive(() async {
_cursor = 0;
for (final t in _transactions.values) {
_queueChange(_change('transaction', t.id, t.updatedAt, t.payload()));
}
for (final c in _categories.values) {
_queueChange(_change('category', c.id, c.updatedAt, c.payload()));
}
for (final b in _budgets.values) {
_queueChange(_change('budget', b.id, b.updatedAt, b.payload()));
}
for (final tombstone in _tombstones.values.toList()) {
_queueChange(tombstone);
}
await _persist();
await _syncLocked();
});
Future<void> _syncLocked() async {
if (session == null || syncing) return;
syncing = true;
error = null;
syncMessage = 'Синхронизация…';
_notify();
final sent = Map<String, Map<String, dynamic>>.from(_dirty);
try {
Future<int> run() => _api.synchronize(
accessToken: _accessToken,
cursor: _cursor,
deviceId: _deviceId,
changes: sent.values.toList(),
applyPage: _applyPage,
);
try {
await run();
} on SyncApiException catch (e) {
if (e.statusCode != 401) rethrow;
await _accessToken(force: true);
await run();
}
// Only acknowledge precisely the pushed revision, never a newer local edit.
for (final entry in sent.entries) {
if (_dirty[entry.key]?['updatedAt'] == entry.value['updatedAt']) {
_dirty.remove(entry.key);
}
}
lastSyncAt = DateTime.now();
await _persist();
syncMessage =
sessionPersistenceWarning ??
(_dirty.isEmpty
? 'Синхронизировано'
: 'Есть изменения для следующей синхронизации');
} catch (e) {
error = e is SyncApiException
? e.message
: 'Не удалось синхронизировать: $e';
syncMessage = 'Изменения сохранены на устройстве';
rethrow;
} finally {
syncing = false;
_notify();
}
}
Future<void> _applyPage(
List<Map<String, dynamic>> changes,
int cursor,
) async {
try {
for (final change in changes) {
_merge(change);
}
_cursor = cursor;
await _persist();
} catch (_) {
// Disk failure must not advance the in-memory cursor past an uncommitted page.
await _loadScope();
rethrow;
}
}
void _merge(Map<String, dynamic> change) {
final key = _key(change);
final entity = change['entity'] as String;
final id = change['id'] as String;
final incoming = DateTime.parse(change['updatedAt'] as String);
final pending = _dirty[key];
// Preserve the user's pending deletion. A newer server edit means its LWW
// delete needs a newer revision, which remains dirty for the next push.
if (pending?['deleted'] == true && change['deleted'] != true) {
if (!DateTime.parse(pending!['updatedAt'] as String).isAfter(incoming)) {
_queueChange(
_change(
entity,
id,
incoming.add(const Duration(microseconds: 1)),
null,
),
);
}
return;
}
if (pending != null &&
!incoming.isAfter(DateTime.parse(pending['updatedAt'] as String))) {
return;
}
final tombstone = _tombstones[key];
if (tombstone != null &&
!incoming.isAfter(DateTime.parse(tombstone['updatedAt'] as String))) {
return;
}
DateTime? local;
if (entity == 'transaction') local = _transactions[id]?.updatedAt;
if (entity == 'category') local = _categories[id]?.updatedAt;
if (entity == 'budget') local = _budgets[id]?.updatedAt;
if (local != null && incoming.isBefore(local)) return;
if (change['deleted'] == true) {
if (entity == 'transaction') _transactions.remove(id);
if (entity == 'category') _categories.remove(id);
if (entity == 'budget') _budgets.remove(id);
_tombstones[key] = Map<String, dynamic>.from(change);
} else {
final payload = Map<String, dynamic>.from(change['payload'] as Map);
if (entity == 'transaction') {
_transactions[id] = TransactionRecord.fromPayload(
id: id,
updatedAt: incoming,
payload: payload,
);
}
if (entity == 'category') {
_categories[id] = CategoryRecord.fromPayload(
id: id,
updatedAt: incoming,
payload: payload,
);
}
if (entity == 'budget') {
_budgets[id] = BudgetRecord.fromPayload(
id: id,
updatedAt: incoming,
payload: payload,
);
}
_tombstones.remove(key);
}
if (pending != null &&
incoming.isAfter(DateTime.parse(pending['updatedAt'] as String))) {
_dirty.remove(key);
}
}
void _backgroundSync() {
if (ready && session != null) unawaited(sync().catchError((Object _) {}));
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_backgroundSync();
}
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.hidden) {
_timer?.cancel();
_timer = null;
} else if (state == AppLifecycleState.resumed) {
_timer ??= Timer.periodic(const Duration(minutes: 2), (_) {
_backgroundSync();
});
}
}
/// Wait for all serialized work before a headless engine exits.
Future<void> close() async {
await _tail;
_apiInstance?.close();
_apiInstance = null;
await _storage?.close();
}
@override
void dispose() {
_disposed = true;
_timer?.cancel();
if (!background) WidgetsBinding.instance.removeObserver(this);
unawaited(close());
super.dispose();
}
}
@@ -0,0 +1,51 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:workmanager/workmanager.dart';
import 'app_store.dart';
const _periodicSyncName = 'balance-periodic-sync';
const _syncTask = 'balance.finance.sync';
@pragma('vm:entry-point')
void balanceBackgroundDispatcher() {
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
Workmanager().executeTask((task, inputData) async {
if (task != _syncTask) return true;
final store = AppStore(background: true);
var success = false;
try {
await store.initialize();
await store.sync();
success = true;
} catch (_) {
// WorkManager retries with backoff; details/tokens are never logged.
success = false;
} finally {
try {
await store.close();
} catch (_) {
success = false;
}
store.dispose();
}
return success;
});
}
/// Android OS scheduling survives UI engine shutdown. Other platforms retain
/// their existing foreground startup/resume/two-minute synchronization.
Future<void> configureBackgroundSync() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
await Workmanager().initialize(balanceBackgroundDispatcher);
await Workmanager().registerPeriodicTask(
_periodicSyncName,
_syncTask,
frequency: const Duration(minutes: 15),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
);
}
@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'runtime_lock_stub.dart'
if (dart.library.js_interop) 'runtime_lock_web.dart'
as browser;
/// Native Android lock shared by the UI engine and WorkManager headless engines.
/// Callers must hold it over reloading preferences/session, ledger IO and sync.
class RuntimeLock {
static const _channel = MethodChannel('balance/runtime');
final _browserLock = browser.BrowserRuntimeLock();
String? _token;
bool get _android =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
Future<void> acquire() async {
if (kIsWeb) return _browserLock.acquire();
if (!_android) return;
if (_token != null) throw StateError('Ledger lock is already held');
final token = await _channel.invokeMethod<String>('acquireLock');
if (token == null || token.isEmpty) {
throw StateError('Native ledger lock returned no ownership token');
}
_token = token;
}
Future<void> release() async {
if (kIsWeb) return _browserLock.release();
if (!_android) return;
final token = _token;
if (token == null) throw StateError('Ledger lock is not held');
await _channel.invokeMethod<void>('releaseLock', {'token': token});
_token = null;
}
}
@@ -0,0 +1,5 @@
/// Non-web storage does not use browser-origin coordination.
class BrowserRuntimeLock {
Future<void> acquire() async {}
Future<void> release() async {}
}
@@ -0,0 +1,96 @@
import 'dart:async';
import 'dart:js_interop';
@JS('isSecureContext')
external bool get _secureContext;
@JS('navigator.locks')
external JSObject? get _navigatorLocks;
extension type _LockManager(JSObject _) implements JSObject {
external JSPromise<JSAny?> request(JSString name, JSFunction callback);
}
/// Holds the origin-wide Web Lock until release, including IndexedDB commits
/// and rotated refresh-token persistence. Browser teardown releases the lock.
class BrowserRuntimeLock {
bool _waiting = false;
Completer<JSAny?>? _release;
Future<JSAny?>? _request;
Future<void> acquire() async {
if (_waiting || _release != null) {
throw StateError('Browser ledger lock is already held or pending');
}
if (!_secureContext) {
throw StateError(
'Для безопасного хранения данных в нескольких вкладках требуется HTTPS '
'(или localhost). Откройте приложение по защищённому адресу.',
);
}
final manager = _navigatorLocks;
if (manager == null) {
throw StateError(
'Браузер не поддерживает Web Locks API. Обновите браузер: '
'без блокировки вкладок безопасная синхронизация недоступна.',
);
}
final ready = Completer<void>();
final release = Completer<JSAny?>();
_waiting = true;
try {
final request = _LockManager(manager)
.request(
'balance-ledger-v1'.toJS,
((JSAny? lock) {
_waiting = false;
_release = release;
ready.complete();
return release.future.toJS;
}).toJS,
)
.toDart;
_request = request;
// A rejected request must wake an acquiring caller, not leave it waiting
// forever. Keep a rejection handler attached even before release awaits it.
unawaited(
request.then<void>(
(_) {
if (!ready.isCompleted) {
ready.completeError(
StateError('Browser ledger lock was not granted'),
);
}
},
onError: (Object error, StackTrace stack) {
if (!ready.isCompleted) {
ready.completeError(
StateError('Не удалось получить блокировку данных браузера'),
stack,
);
}
},
),
);
await ready.future;
} catch (_) {
_waiting = false;
_release = null;
_request = null;
rethrow;
}
}
Future<void> release() async {
final release = _release;
if (release == null) throw StateError('Browser ledger lock is not held');
release.complete(null);
try {
// Resolves only after navigator.locks has actually released ownership.
await _request;
} finally {
_release = null;
_request = null;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:sembast/sembast.dart';
import 'storage_native.dart'
if (dart.library.js_interop) 'storage_web.dart'
as platform;
/// A single transaction replaces the complete ledger, pending changes and cursor.
abstract class SnapshotStorage {
Future<Map<String, dynamic>?> read(String key);
Future<void> write(String key, Map<String, dynamic> value);
Future<void> close();
}
/// Connections are opened lazily and closed after every operation, so another
/// Flutter engine never reads through an old Sembast in-memory cache. Android
/// callers serialize complete operations with the shared native store lock.
class LedgerStorage implements SnapshotStorage {
LedgerStorage._();
final _store = stringMapStoreFactory.store('snapshots');
static Future<LedgerStorage> open() async => LedgerStorage._();
@override
Future<Map<String, dynamic>?> read(String key) async {
final db = await platform.openLedgerDatabase();
try {
return await _store.record(key).get(db);
} finally {
await db.close();
}
}
@override
Future<void> write(String key, Map<String, dynamic> value) async {
final db = await platform.openLedgerDatabase();
try {
await db.transaction((txn) async {
await _store.record(key).put(txn, value);
});
} finally {
await db.close();
}
}
@override
Future<void> close() async {}
}
class MemorySnapshotStorage implements SnapshotStorage {
final Map<String, Map<String, dynamic>> values = {};
@override
Future<Map<String, dynamic>?> read(String key) async => values[key];
@override
Future<void> write(String key, Map<String, dynamic> value) async {
values[key] = value;
}
@override
Future<void> close() async {}
}
@@ -0,0 +1,8 @@
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast_io.dart';
Future<Database> openLedgerDatabase() async {
final directory = await getApplicationSupportDirectory();
await directory.create(recursive: true);
return databaseFactoryIo.openDatabase('${directory.path}/balance.sembast');
}
@@ -0,0 +1,4 @@
import 'package:sembast_web/sembast_web.dart';
Future<Database> openLedgerDatabase() =>
databaseFactoryWeb.openDatabase('balance');
+287
View File
@@ -0,0 +1,287 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// JSON wire objects, deliberately independent of local database models.
typedef WireMap = Map<String, dynamic>;
typedef AccessTokenProvider = Future<String> Function();
class SyncApiException implements Exception {
const SyncApiException(this.code, this.message, {this.statusCode});
final String code;
final String message;
final int? statusCode;
// Do not include response bodies, request headers or tokens in diagnostics.
@override
String toString() => 'SyncApiException($code, HTTP $statusCode): $message';
}
/// Stateless HTTP transport. Session persistence belongs to the caller's secure
/// store. Never persist returned session maps in preferences or browser storage.
class SyncApi {
SyncApi({
required String serverUrl,
http.Client? client,
this.timeout = const Duration(seconds: 30),
bool allowInsecureHttp = false,
}) : _client = client ?? http.Client(),
_ownsClient = client == null,
baseUri = _normalizeServer(serverUrl, allowInsecureHttp);
final Uri baseUri;
final Duration timeout;
final http.Client _client;
final bool _ownsClient;
/// Register/login/refresh return {accessToken, refreshToken, expiresAt,
/// user: {id, email}}. Refresh rotates both tokens; save the result atomically.
Future<WireMap> register(String email, String password) =>
_authenticate('register', email, password);
Future<WireMap> login(String email, String password) =>
_authenticate('login', email, password);
Future<WireMap> _authenticate(
String action,
String email,
String password,
) async => _session(await _request('POST', 'v1/auth/$action', body: {
'email': email.trim(),
'password': password,
}));
Future<WireMap> refresh(String refreshToken) async =>
_session(await _request('POST', 'v1/auth/refresh', body: {
'refreshToken': refreshToken,
}));
/// Clear the external local session in a finally block, even when offline.
/// An offline logout cannot revoke the server refresh token.
Future<void> logout(String refreshToken) async {
await _request('POST', 'v1/auth/logout', body: {
'refreshToken': refreshToken,
});
}
Future<WireMap> me(String accessToken) async {
final result = await _request('GET', 'v1/me', accessToken: accessToken);
_string(result, 'id');
_string(result, 'email');
return result;
}
/// One wire request. Push-only calls leave cursor unchanged. The caller must
/// merge a pulled page durably before committing its returned cursor.
Future<WireMap> sync({
required String accessToken,
required int cursor,
required String deviceId,
required List<WireMap> changes,
bool pull = true,
}) async {
_cursor(cursor);
if (changes.length > 5000) {
throw ArgumentError('A sync request supports at most 5000 changes');
}
for (final change in changes) {
_validateChange(change, outgoing: true);
}
final page = await _request('POST', 'v1/sync', accessToken: accessToken,
body: _syncBody(cursor, deviceId, changes, pull));
final next = _integer(page, 'cursor');
_cursor(next);
if (next < cursor || page['hasMore'] is! bool || page['changes'] is! List) {
throw const FormatException('Invalid sync page');
}
if (page['hasMore'] == true && next <= cursor) {
throw const FormatException('Sync cursor did not advance');
}
DateTime.parse(_string(page, 'serverTime'));
final records = <WireMap>[];
var previousSequence = cursor;
for (final raw in page['changes'] as List) {
final change = _object(raw);
_validateChange(change);
final sequence = _integer(change, 'sequence');
if (sequence <= previousSequence || sequence > next) {
throw const FormatException('Invalid change sequence');
}
previousSequence = sequence;
records.add(change);
}
if (records.isNotEmpty && previousSequence != next) {
throw const FormatException('Cursor does not match the last change');
}
if (records.isEmpty && next != cursor) {
throw const FormatException('Empty page advanced the cursor');
}
return {...page, 'changes': records};
}
/// Pushes a stable snapshot in bounded batches, then pulls all pages. The
/// provider may refresh expired tokens through an external session manager.
/// applyPage must atomically merge changes and persist cursor, and must throw
/// on failure. Keep the push watermark unchanged until this method succeeds.
Future<int> synchronize({
required AccessTokenProvider accessToken,
required int cursor,
required String deviceId,
required List<WireMap> changes,
required Future<void> Function(List<WireMap> changes, int cursor) applyPage,
}) async {
// Copy the complete JSON snapshot before the first await so concurrent edits
// cannot mutate a batch while it is being uploaded.
final snapshot = (jsonDecode(jsonEncode(changes)) as List)
.map(_object).toList(growable: false);
_cursor(cursor);
for (final change in snapshot) {
_validateChange(change, outgoing: true);
}
for (var start = 0; start < snapshot.length; start += 100) {
final end = start + 100 < snapshot.length ? start + 100 : snapshot.length;
await _pushBatch(accessToken, cursor, deviceId, snapshot.sublist(start, end));
}
var next = cursor;
while (true) {
final page = await sync(accessToken: await accessToken(), cursor: next,
deviceId: deviceId, changes: const []);
next = page['cursor'] as int;
await applyPage((page['changes'] as List).cast<WireMap>(), next);
if (page['hasMore'] == false) return next;
}
}
Future<void> _pushBatch(AccessTokenProvider token, int cursor, String deviceId,
List<WireMap> changes) async {
final size = utf8.encode(jsonEncode(_syncBody(cursor, deviceId, changes, false))).length;
if (size > 1500000 && changes.length > 1) {
final middle = changes.length ~/ 2;
await _pushBatch(token, cursor, deviceId, changes.sublist(0, middle));
await _pushBatch(token, cursor, deviceId, changes.sublist(middle));
return;
}
await sync(accessToken: await token(), cursor: cursor, deviceId: deviceId,
changes: changes, pull: false);
}
Future<WireMap> _request(String method, String path, {
WireMap? body,
String? accessToken,
}) async {
final request = http.Request(method, baseUri.resolve(path));
request.followRedirects = false;
request.headers['Accept'] = 'application/json';
if (accessToken != null) {
request.headers['Authorization'] = 'Bearer $accessToken';
}
if (body != null) {
request.headers['Content-Type'] = 'application/json; charset=utf-8';
request.bodyBytes = utf8.encode(jsonEncode(body));
if (request.bodyBytes.length > 2 * 1024 * 1024) {
throw ArgumentError('Request exceeds the server 2 MiB limit');
}
}
final response = await (() async {
final stream = await _client.send(request);
return http.Response.fromStream(stream);
})().timeout(timeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
var code = 'http_error';
var message = 'Server request failed';
try {
final detail = _object(_object(jsonDecode(response.body))['error']);
code = _string(detail, 'code');
message = _string(detail, 'message');
} on FormatException {
// Proxies may return HTML. Never surface raw bodies containing secrets.
}
throw SyncApiException(code, message, statusCode: response.statusCode);
}
if (response.statusCode == 204) return <String, dynamic>{};
return _object(jsonDecode(utf8.decode(response.bodyBytes)));
}
void close() {
if (_ownsClient) _client.close();
}
static WireMap _syncBody(int cursor, String deviceId,
List<WireMap> changes, bool pull) => {
'cursor': cursor, 'deviceId': deviceId, 'changes': changes, 'pull': pull,
};
static WireMap _session(WireMap value) {
_string(value, 'accessToken');
_string(value, 'refreshToken');
DateTime.parse(_string(value, 'expiresAt'));
final user = _object(value['user']);
_string(user, 'id');
_string(user, 'email');
return value;
}
static void _validateChange(WireMap change, {bool outgoing = false}) {
if (!const {'transaction', 'category', 'budget'}.contains(change['entity'])) {
throw const FormatException('Unknown sync entity');
}
final id = _string(change, 'id');
if (utf8.encode(id).length > 128 || change['deleted'] is! bool) {
throw const FormatException('Invalid record ID or deletion flag');
}
DateTime.parse(_string(change, 'updatedAt'));
if (change['deleted'] == false) {
_object(change['payload']);
}
if (outgoing) {
const keys = {'entity', 'id', 'deleted', 'updatedAt', 'payload', 'version', 'sequence'};
if (change.keys.any((key) => !keys.contains(key))) {
throw const FormatException('Unknown change property');
}
if (change.containsKey('payload') &&
utf8.encode(jsonEncode(change['payload'])).length > 256 * 1024) {
throw const FormatException('Payload exceeds 256 KiB');
}
}
}
static WireMap _object(dynamic value) {
if (value is! Map<String, dynamic>) {
throw const FormatException('Expected a JSON object');
}
return value;
}
static String _string(WireMap value, String key) {
final result = value[key];
if (result is! String || result.isEmpty) {
throw FormatException('Missing or invalid $key');
}
return result;
}
static int _integer(WireMap value, String key) {
final result = value[key];
if (result is! int) throw FormatException('Missing or invalid $key');
return result;
}
static void _cursor(int value) {
// JS numbers cannot faithfully round-trip the full server int64 range.
if (value < 0 || value > 9007199254740991) {
throw ArgumentError('Cursor is outside the portable integer range');
}
}
static Uri _normalizeServer(String value, bool allowInsecureHttp) {
final uri = Uri.parse(value.trim());
if (!uri.hasAuthority || uri.host.isEmpty || uri.userInfo.isNotEmpty ||
uri.hasQuery || uri.hasFragment ||
!(uri.scheme == 'https' || (allowInsecureHttp && uri.scheme == 'http'))) {
throw ArgumentError('Use an HTTPS server URL without credentials, query or fragment');
}
return uri.replace(path: '${uri.path.replaceAll(RegExp(r'/+$'), '')}/');
}
}
File diff suppressed because it is too large. Load diff
+314
View File
@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
String money(int minor, String currency) => NumberFormat.currency(
locale: 'ru_RU',
name: currency,
symbol:
{'RUB': '₽', 'USD': r'$', 'EUR': '€', 'SEK': 'kr'}[currency] ?? currency,
).format(minor / 100);
int? parseAmount(String value, {bool signed = false}) {
final clean = value
.replaceAll(RegExp(r'[\s\u00a0\u202f]'), '')
.replaceAll(',', '.');
if (!RegExp(
signed ? r'^-?\d{1,12}(\.\d{1,2})?$' : r'^\d{1,12}(\.\d{1,2})?$',
).hasMatch(clean)) {
return null;
}
final parts = clean.replaceFirst('-', '').split('.');
final result =
int.parse(parts[0]) * 100 +
(parts.length > 1 ? int.parse(parts[1].padRight(2, '0')) : 0);
return clean.startsWith('-') ? -result : result;
}
String amountInput(int minor) => (minor / 100).toStringAsFixed(2);
const categoryColors = <String, Color>{
'red': Colors.red,
'orange': Colors.orange,
'yellow': Colors.amber,
'green': Colors.green,
'mint': Color(0xff39b997),
'teal': Colors.teal,
'cyan': Colors.cyan,
'blue': Colors.blue,
'indigo': Colors.indigo,
'purple': Colors.purple,
'pink': Colors.pink,
'gray': Colors.blueGrey,
'coral': Color(0xffee7063),
'peach': Color(0xffe9a077),
'gold': Color(0xffbf930c),
'lime': Color(0xff91ad21),
'forest': Color(0xff28734a),
'turquoise': Color(0xff27a9a0),
'sky': Color(0xff459bc8),
'navy': Color(0xff364d94),
'lavender': Color(0xff9b82ce),
'magenta': Color(0xffbd439d),
'brown': Colors.brown,
'slate': Color(0xff627389),
};
const categoryIcons = <String, IconData>{
'star.fill': Icons.star_rounded,
'heart.fill': Icons.favorite_rounded,
'sparkles': Icons.auto_awesome,
'bolt.fill': Icons.bolt,
'flame.fill': Icons.local_fire_department,
'drop.fill': Icons.water_drop,
'cart.fill': Icons.shopping_cart_outlined,
'bag.fill': Icons.shopping_bag_outlined,
'creditcard.fill': Icons.credit_card,
'banknote.fill': Icons.payments_outlined,
'gift.fill': Icons.card_giftcard,
'tag.fill': Icons.sell_outlined,
'cup.and.saucer.fill': Icons.coffee,
'fork.knife': Icons.restaurant,
'takeoutbag.and.cup.and.straw.fill': Icons.takeout_dining,
'birthday.cake.fill': Icons.cake_outlined,
'car.fill': Icons.directions_car_outlined,
'bus.fill': Icons.directions_bus,
'tram.fill': Icons.tram,
'bicycle': Icons.pedal_bike,
'airplane': Icons.flight,
'fuelpump.fill': Icons.local_gas_station,
'house.fill': Icons.home_outlined,
'bed.double.fill': Icons.bed_outlined,
'lightbulb.fill': Icons.lightbulb_outline,
'wifi': Icons.wifi,
'phone.fill': Icons.phone_outlined,
'printer.fill': Icons.print_outlined,
'pawprint.fill': Icons.pets,
'leaf.fill': Icons.eco_outlined,
'figure.walk': Icons.directions_walk,
'figure.run': Icons.directions_run,
'dumbbell.fill': Icons.fitness_center,
'cross.case.fill': Icons.medical_services_outlined,
'pills.fill': Icons.medication_outlined,
'stethoscope': Icons.monitor_heart_outlined,
'book.fill': Icons.menu_book,
'graduationcap.fill': Icons.school_outlined,
'laptopcomputer': Icons.laptop,
'briefcase.fill': Icons.work_outline,
'gamecontroller.fill': Icons.sports_esports_outlined,
'music.note': Icons.music_note,
'film.fill': Icons.movie_outlined,
'camera.fill': Icons.photo_camera_outlined,
'paintpalette.fill': Icons.palette_outlined,
'tshirt.fill': Icons.checkroom,
'hammer.fill': Icons.gavel,
'wrench.and.screwdriver.fill': Icons.handyman_outlined,
'shippingbox.fill': Icons.inventory_2_outlined,
'globe.europe.africa.fill': Icons.public,
'repeat': Icons.repeat,
'chart.line.uptrend.xyaxis': Icons.trending_up,
'ellipsis.circle.fill': Icons.more_horiz,
'plus.circle.fill': Icons.add_circle_outline,
'slider.horizontal.3': Icons.tune,
'tune': Icons.tune,
};
class CategoryAvatar extends StatelessWidget {
const CategoryAvatar({
super.key,
required this.icon,
this.emoji = '',
this.color = 'indigo',
this.size = 44,
});
final String icon, emoji, color;
final double size;
@override
Widget build(BuildContext context) {
final c = categoryColors[color] ?? Theme.of(context).colorScheme.primary;
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: c.withValues(alpha: .13),
borderRadius: BorderRadius.circular(size * .32),
),
child: emoji.isNotEmpty
? Text(emoji, style: TextStyle(fontSize: size * .48))
: Icon(
categoryIcons[icon] ?? Icons.category_outlined,
color: c,
size: size * .52,
),
);
}
}
class Panel extends StatelessWidget {
const Panel({
super.key,
required this.child,
this.padding = const EdgeInsets.all(24),
});
final Widget child;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) => Card(
margin: EdgeInsets.zero,
elevation: 0,
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: Padding(padding: padding, child: child),
);
}
class EmptyView extends StatelessWidget {
const EmptyView({
super.key,
required this.title,
required this.subtitle,
this.action,
this.icon = Icons.receipt_long_outlined,
});
final String title, subtitle;
final Widget? action;
final IconData icon;
@override
Widget build(BuildContext context) => Panel(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(subtitle, textAlign: TextAlign.center),
if (action != null) ...[const SizedBox(height: 20), action!],
],
),
),
),
);
}
class Metric extends StatelessWidget {
const Metric({
super.key,
required this.label,
required this.value,
required this.icon,
this.color,
});
final String label, value;
final IconData icon;
final Color? color;
@override
Widget build(BuildContext context) => Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: color ?? Theme.of(context).colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(label, style: Theme.of(context).textTheme.labelLarge),
),
],
),
const SizedBox(height: 14),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
),
],
),
);
}
class SpendingRing extends StatelessWidget {
const SpendingRing({super.key, required this.segments, required this.total});
final List<(int, Color)> segments;
final String total;
@override
Widget build(BuildContext context) => Semantics(
label: 'Всего расходов: $total. Структура по категориям приведена ниже.',
child: SizedBox(
height: 200,
child: Center(
child: SizedBox.square(
dimension: 190,
child: CustomPaint(
painter: _RingPainter(segments),
child: Center(
child: Padding(
padding: const EdgeInsets.all(30),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Расходы',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 6),
FittedBox(
child: Text(
total,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
],
),
),
),
),
),
),
),
);
}
class _RingPainter extends CustomPainter {
_RingPainter(this.segments);
final List<(int, Color)> segments;
@override
void paint(Canvas canvas, Size size) {
final total = segments.fold<int>(0, (sum, s) => sum + s.$1);
if (total <= 0) return;
final rect = Offset.zero & size;
var angle = -1.5707963267948966;
for (final segment in segments) {
final sweep = segment.$1 / total * 6.283185307179586;
canvas.drawArc(
rect.deflate(13),
angle,
sweep,
false,
Paint()
..color = segment.$2
..style = PaintingStyle.stroke
..strokeWidth = 20,
);
angle += sweep;
}
}
@override
bool shouldRepaint(covariant _RingPainter oldDelegate) =>
oldDelegate.segments != segments;
}
+585
View File
@@ -0,0 +1,585 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:uuid/uuid.dart';
import '../domain/finance.dart';
import 'design.dart';
const _uuid = Uuid();
Future<T?> editDialog<T>(BuildContext context, Widget child) => showDialog<T>(
context: context,
builder: (_) => Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: child,
),
),
);
class TransactionEditor extends StatefulWidget {
const TransactionEditor({super.key, this.existing, required this.categories});
final TransactionRecord? existing;
final List<CategoryRecord> categories;
@override
State<TransactionEditor> createState() => _TransactionEditorState();
}
class _TransactionEditorState extends State<TransactionEditor> {
final form = GlobalKey<FormState>();
late final TextEditingController amount, note;
late DateTime date;
late String kind;
late CategoryRecord selected;
List<CategoryRecord> get choices {
final list = [
...BuiltInCategories.forKind(kind),
...widget.categories.where((c) => c.kindRawValue == kind),
];
if (!list.any((c) => c.name == selected.name)) list.add(selected);
return {for (final c in list) c.name: c}.values.toList();
}
@override
void initState() {
super.initState();
final t = widget.existing;
amount = TextEditingController(
text: t == null ? '' : amountInput(t.amountMinor),
);
note = TextEditingController(text: t?.note ?? '');
date = t?.date.toLocal() ?? DateTime.now();
kind = t?.kindRawValue ?? 'expense';
selected = t == null
? BuiltInCategories.expense.first
: CategoryRecord(
id: 'archived',
name: t.categoryName,
icon: t.categoryIcon,
emoji: t.categoryEmoji,
colorName: t.categoryColorName,
kindRawValue: kind,
createdAt: date,
updatedAt: date,
);
}
@override
void dispose() {
amount.dispose();
note.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: form,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.existing == null ? 'Новая операция' : 'Изменить операцию',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
SegmentedButton<String>(
segments: const [
ButtonSegment(
value: 'expense',
label: Text('Расход'),
icon: Icon(Icons.north_east),
),
ButtonSegment(
value: 'income',
label: Text('Доход'),
icon: Icon(Icons.south_west),
),
],
selected: {kind},
onSelectionChanged: widget.existing?.isBalanceAdjustment == true
? null
: (s) => setState(() {
kind = s.first;
selected = BuiltInCategories.forKind(kind).first;
}),
),
const SizedBox(height: 20),
TextFormField(
controller: amount,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Сумма',
border: OutlineInputBorder(),
),
validator: (v) {
final m = parseAmount(v ?? '');
return m == null || m <= 0
? 'Введите положительную сумму, до 2 знаков после запятой'
: null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: selected.name,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Категория',
border: OutlineInputBorder(),
),
items: choices
.map(
(c) => DropdownMenuItem(
value: c.name,
child: Row(
children: [
CategoryAvatar(
icon: c.icon,
emoji: c.emoji,
color: c.colorName,
size: 28,
),
const SizedBox(width: 12),
Flexible(
child: Text(c.name, overflow: TextOverflow.ellipsis),
),
],
),
),
)
.toList(),
onChanged: widget.existing?.isBalanceAdjustment == true
? null
: (v) => setState(
() => selected = choices.firstWhere((c) => c.name == v),
),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today_outlined),
title: Text(DateFormat('d MMMM y, HH:mm', 'ru').format(date)),
trailing: const Icon(Icons.edit_outlined),
onTap: () async {
final d = await showDatePicker(
context: context,
initialDate: date,
firstDate: DateTime(1900),
lastDate: DateTime(2200),
);
if (d == null || !context.mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(date),
);
if (time != null && mounted) {
setState(
() => date = DateTime(
d.year,
d.month,
d.day,
time.hour,
time.minute,
),
);
}
},
),
const SizedBox(height: 12),
TextFormField(
controller: note,
maxLines: 3,
maxLength: 4000,
decoration: const InputDecoration(
labelText: 'Заметка',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () {
if (!form.currentState!.validate()) return;
final now = DateTime.now();
Navigator.pop(
context,
TransactionRecord(
id: widget.existing?.id ?? _uuid.v4(),
amountMinor: parseAmount(amount.text)!,
date: date,
note: note.text.trim(),
categoryName: selected.name,
categoryIcon: selected.icon,
categoryEmoji: selected.emoji,
categoryColorName: selected.colorName,
kindRawValue: kind,
isBalanceAdjustment:
widget.existing?.isBalanceAdjustment ?? false,
updatedAt: now,
),
);
},
child: const Text('Сохранить'),
),
],
),
],
),
),
);
}
class CategoryEditor extends StatefulWidget {
const CategoryEditor({super.key, this.existing, required this.categories});
final CategoryRecord? existing;
final List<CategoryRecord> categories;
@override
State<CategoryEditor> createState() => _CategoryEditorState();
}
class _CategoryEditorState extends State<CategoryEditor> {
final form = GlobalKey<FormState>();
late final TextEditingController name, emoji;
late String kind, icon, color;
@override
void initState() {
super.initState();
final c = widget.existing;
name = TextEditingController(text: c?.name ?? '');
emoji = TextEditingController(text: c?.emoji ?? '');
kind = c?.kindRawValue ?? 'expense';
icon = c?.icon ?? 'star.fill';
color = c?.colorName ?? 'indigo';
}
@override
void dispose() {
name.dispose();
emoji.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: form,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.existing == null ? 'Новая категория' : 'Изменить категорию',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 20),
TextFormField(
controller: name,
maxLength: 80,
decoration: const InputDecoration(
labelText: 'Название',
border: OutlineInputBorder(),
),
validator: (s) {
final n = (s ?? '').trim().toLowerCase();
if (n.isEmpty) return 'Введите название';
if ([
...BuiltInCategories.expense,
...BuiltInCategories.income,
...widget.categories,
].any(
(c) => c.id != widget.existing?.id && c.name.toLowerCase() == n,
)) {
return 'Категория с таким названием уже есть';
}
return null;
},
),
const SizedBox(height: 16),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'expense', label: Text('Расход')),
ButtonSegment(value: 'income', label: Text('Доход')),
],
selected: {kind},
onSelectionChanged: widget.existing != null
? null
: (s) => setState(() => kind = s.first),
),
const SizedBox(height: 20),
Row(
children: [
CategoryAvatar(
icon: icon,
emoji: emoji.text,
color: color,
size: 60,
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: emoji,
decoration: InputDecoration(
labelText: 'Свой эмодзи',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: 'Убрать эмодзи',
icon: const Icon(Icons.close),
onPressed: () => setState(() => emoji.clear()),
),
),
onChanged: (_) => setState(() {}),
maxLength: 8,
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 4,
runSpacing: 4,
children: CategoryDesignOptions.emojis
.map(
(e) => IconButton(
onPressed: () => setState(() => emoji.text = e),
tooltip: e,
icon: Text(e, style: const TextStyle(fontSize: 23)),
),
)
.toList(),
),
const SizedBox(height: 16),
Text('Значок', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Wrap(
spacing: 4,
runSpacing: 4,
children: CategoryDesignOptions.icons
.map(
(i) => IconButton.filledTonal(
isSelected: i == icon && emoji.text.isEmpty,
onPressed: () => setState(() {
icon = i;
emoji.clear();
}),
tooltip: i,
icon: Icon(categoryIcons[i] ?? Icons.category_outlined),
),
)
.toList(),
),
const SizedBox(height: 20),
Text('Цвет', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: CategoryDesignOptions.colors
.map(
(c) => IconButton(
style: IconButton.styleFrom(
backgroundColor: categoryColors[c],
),
tooltip: c,
onPressed: () => setState(() => color = c),
icon: Icon(
c == color ? Icons.check : Icons.circle,
color: c == color ? Colors.white : Colors.transparent,
),
),
)
.toList(),
),
const SizedBox(height: 24),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () {
if (!form.currentState!.validate()) return;
final now = DateTime.now();
Navigator.pop(
context,
CategoryRecord(
id: widget.existing?.id ?? _uuid.v4(),
name: name.text.trim(),
icon: icon,
emoji: emoji.text.characters.take(1).toString(),
colorName: color,
kindRawValue: kind,
createdAt: widget.existing?.createdAt ?? now,
updatedAt: now,
),
);
},
child: const Text('Сохранить'),
),
],
),
],
),
),
);
}
class BudgetEditor extends StatefulWidget {
const BudgetEditor({
super.key,
this.existing,
required this.categories,
required this.month,
});
final BudgetRecord? existing;
final List<CategoryRecord> categories;
final DateTime month;
@override
State<BudgetEditor> createState() => _BudgetEditorState();
}
class _BudgetEditorState extends State<BudgetEditor> {
final form = GlobalKey<FormState>();
late final TextEditingController amount;
late CategoryRecord selected;
@override
void initState() {
super.initState();
amount = TextEditingController(
text: widget.existing == null
? ''
: amountInput(widget.existing!.limitMinor),
);
selected =
widget.categories
.where((c) => c.name == widget.existing?.categoryName)
.firstOrNull ??
widget.categories.first;
}
@override
void dispose() {
amount.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: form,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Лимит на ${DateFormat('LLLL y', 'ru').format(widget.month)}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
DropdownButtonFormField<String>(
initialValue: selected.name,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Категория',
border: OutlineInputBorder(),
),
items: widget.categories
.map(
(c) => DropdownMenuItem(value: c.name, child: Text(c.name)),
)
.toList(),
onChanged: (n) => setState(
() => selected = widget.categories.firstWhere((c) => c.name == n),
),
),
const SizedBox(height: 16),
TextFormField(
controller: amount,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Месячный лимит',
border: OutlineInputBorder(),
),
validator: (v) =>
parseAmount(v ?? '') == null || parseAmount(v ?? '')! <= 0
? 'Введите положительный лимит'
: null,
),
const SizedBox(height: 24),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
if (!form.currentState!.validate()) return;
Navigator.pop(
context,
BudgetRecord(
id: widget.existing?.id ?? _uuid.v4(),
categoryName: selected.name,
categoryIcon: selected.icon,
categoryEmoji: selected.emoji,
limitMinor: parseAmount(amount.text)!,
monthStart: widget.month,
updatedAt: DateTime.now(),
),
);
},
child: const Text('Сохранить'),
),
],
),
],
),
),
);
}
Future<bool> confirmDelete(
BuildContext context,
String title, {
String message =
'Это действие будет синхронизировано с другими устройствами.',
}) async =>
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Удалить'),
),
],
),
) ??
false;
+1
View File
@@ -0,0 +1 @@
flutter/ephemeral
+128
View File
@@ -0,0 +1,128 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "balance")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.balance.balance")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
@@ -0,0 +1,88 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)
@@ -0,0 +1,23 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}
@@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_
@@ -0,0 +1,27 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage_linux
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)
@@ -0,0 +1,26 @@
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")
+6
View File
@@ -0,0 +1,6 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}
@@ -0,0 +1,148 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "balance");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "balance");
}
gtk_window_set_default_size(window, 1280, 720);
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(
project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
GdkRGBA background_color;
// Background defaults to black, override it here if necessary, e.g. #00000000
// for transparent.
gdk_rgba_parse(&background_color, "#000000");
fl_view_set_background_color(view, &background_color);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
// Show the window when Flutter renders.
// Requires the view to be realized so we can start rendering.
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb),
self);
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application,
gchar*** arguments,
int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GApplication::startup.
static void my_application_startup(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup.
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
// Implements GApplication::shutdown.
static void my_application_shutdown(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application shutdown.
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line =
my_application_local_command_line;
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
// Set the program name to the application ID, which helps various systems
// like GTK and desktop environments map this running application to its
// corresponding .desktop file. This ensures better integration by allowing
// the application to be recognized beyond its binary name.
g_set_prgname(APPLICATION_ID);
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID, "flags",
G_APPLICATION_NON_UNIQUE, nullptr));
}
@@ -0,0 +1,21 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication,
my_application,
MY,
APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_
Loaded 100 of 214 files, more files were not shown because too many files have changed in this diff. Show more