Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b32eabcd12 | ||
|
|
a2aa714a83 | ||
|
|
2c278044bb | ||
|
|
a715eca947 | ||
|
|
c4c8f7a7cb | ||
|
|
c7bf16ae69 | ||
|
|
da9e17f45f | ||
|
|
2f891c9503 | ||
|
|
684ac58b73 | ||
|
|
896cc48128 | ||
|
|
579a572097 | ||
|
|
8abd2ef63e | ||
|
|
3cb048256b | ||
|
|
89b631c25a | ||
|
|
9268217aed | ||
|
|
2c7192ec44 | ||
|
|
0de9b2cf27 | ||
|
|
3e775be3ba | ||
|
|
2ceacb9c2b | ||
|
|
a11510d6fa | ||
|
|
bd9a180b20 | ||
|
|
27c26c2c4b | ||
|
|
f00ffe6a24 | ||
|
|
2249a6775a | ||
|
|
5860d55c9e | ||
|
|
59181cc78d |
No files matched your search
@@ -0,0 +1,50 @@
|
|||||||
|
name: Android CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["main"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build and Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Step 1: Check out the repository code
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Step 2: Set up JDK 17 with Gradle Caching
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: "zulu"
|
||||||
|
java-version: "17"
|
||||||
|
cache: "gradle"
|
||||||
|
|
||||||
|
# Step 3: Grant execute permission to Gradle Wrapper
|
||||||
|
- name: Grant Execute Permission for Gradlew
|
||||||
|
run: cd BalanceAndroid && chmod +x ./gradlew
|
||||||
|
|
||||||
|
# Step 4: Jetpack Compose Compiler & Lint Check
|
||||||
|
# Verifies Compose layout rules and Kotlin formatting
|
||||||
|
- name: Run Lint and Code Quality
|
||||||
|
run: cd BalanceAndroid && ./gradlew lintDebug
|
||||||
|
|
||||||
|
# Step 5: Run Unit Tests & Verify Room Schema
|
||||||
|
# Runs localized Room migrations and DAO tests that do not require an emulator
|
||||||
|
- name: Run Unit Tests
|
||||||
|
run: cd BalanceAndroid && ./gradlew testDebugUnitTest
|
||||||
|
|
||||||
|
# Step 6: Assemble Debug APK (Targeting Android 8.0 / API 26+)
|
||||||
|
- name: Build Debug APK
|
||||||
|
run: cd BalanceAndroid && ./gradlew assembleDebug
|
||||||
|
|
||||||
|
# Step 7: Upload the generated APK
|
||||||
|
- name: Upload Debug APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: android-compose-room-debug-apk
|
||||||
|
path: BalanceAndroid/app/build/outputs/apk/debug/app-debug.apk
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
name: Flutter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths: ['BalanceFlutter/**', '.github/workflows/flutter.yml', 'Balance/BalanceWatch/**', 'Balance/Balance.xcodeproj/**']
|
||||||
|
pull_request:
|
||||||
|
paths: ['BalanceFlutter/**', '.github/workflows/flutter.yml', 'Balance/BalanceWatch/**', 'Balance/Balance.xcodeproj/**']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
FLUTTER_VERSION: '3.47.8'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: BalanceFlutter
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
cache: true
|
||||||
|
- run: flutter pub get
|
||||||
|
- run: flutter analyze
|
||||||
|
- run: flutter test
|
||||||
|
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- {platform: web, os: ubuntu-latest, artifact: 'BalanceFlutter/build/web'}
|
||||||
|
- {platform: android, os: ubuntu-latest, artifact: 'BalanceFlutter/build/app/outputs/flutter-apk/*.apk'}
|
||||||
|
- {platform: linux, os: ubuntu-latest, artifact: 'BalanceFlutter/build/linux/x64/release/bundle'}
|
||||||
|
- {platform: windows, os: windows-latest, artifact: 'BalanceFlutter/build/windows/x64/runner/Release'}
|
||||||
|
- {platform: ios, os: macos-latest, artifact: 'BalanceFlutter/build/ios/iphoneos/*.app'}
|
||||||
|
- {platform: macos, os: macos-latest, artifact: 'BalanceFlutter/build/macos/Build/Products/Release/*.app'}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
working-directory: BalanceFlutter
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
if: matrix.platform == 'android'
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
- name: Linux dependencies
|
||||||
|
if: matrix.platform == 'linux'
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libstdc++-12-dev libsecret-1-dev libjsoncpp-dev
|
||||||
|
- uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
cache: true
|
||||||
|
- run: bash scripts/build.sh ${{ matrix.platform }}
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: balance-${{ matrix.platform }}
|
||||||
|
path: ${{ matrix.artifact }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
watchos:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: bash BalanceFlutter/scripts/build.sh watchos
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: balance-watchos
|
||||||
|
path: BalanceFlutter/build/watchos/Build/Products/*watchsimulator/*.app
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: iOS CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
name: Build and Test SwiftUI App
|
||||||
|
runs-on: macos-26 # Uses Apple Silicon M1/M2 runners
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Step 1: Check out the repository code
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Step 2: Select the correct Xcode version
|
||||||
|
- name: Set Xcode Version
|
||||||
|
run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
|
||||||
|
|
||||||
|
# Step 3: Load dependencies if using Swift Package Manager
|
||||||
|
- name: Resolve Swift Packages
|
||||||
|
run: xcodebuild -resolvePackageDependencies -project Balance/Balance.xcodeproj -scheme Balance
|
||||||
|
|
||||||
|
# Step 4: Build and run tests using xcodebuild
|
||||||
|
- name: Build and Test
|
||||||
|
run: |
|
||||||
|
xcodebuild test \
|
||||||
|
-project Balance/Balance.xcodeproj \
|
||||||
|
-scheme Balance \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.4.1' \
|
||||||
|
-quiet
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Build Unsigned iOS and macOS Apps
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- issue-*
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-ios:
|
||||||
|
name: Build Unsigned iOS IPA
|
||||||
|
runs-on: macos-26
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Xcode version
|
||||||
|
run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
|
||||||
|
|
||||||
|
- name: Build Xcode Workspace
|
||||||
|
run: |
|
||||||
|
cd Balance && \
|
||||||
|
xcodebuild build \
|
||||||
|
-project Balance.xcodeproj \
|
||||||
|
-scheme Balance \
|
||||||
|
-configuration Release \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-derivedDataPath build/DerivedData \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY="" \
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER=""
|
||||||
|
|
||||||
|
- name: Package into IPA
|
||||||
|
run: |
|
||||||
|
mkdir -p build/Payload
|
||||||
|
mv Balance/build/DerivedData/Build/Products/Release-iphoneos/Balance.app build/Payload/
|
||||||
|
cd build && zip -r Balance.ipa Payload/
|
||||||
|
|
||||||
|
- name: Upload iOS Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: unsigned-ios-ipa
|
||||||
|
path: build/Balance.ipa
|
||||||
|
|
||||||
|
build-macos:
|
||||||
|
name: Build macOS ZIP
|
||||||
|
runs-on: macos-26
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Xcode version
|
||||||
|
run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer
|
||||||
|
|
||||||
|
- name: Build macOS App
|
||||||
|
run: |
|
||||||
|
cd Balance && \
|
||||||
|
xcodebuild build \
|
||||||
|
-project Balance.xcodeproj \
|
||||||
|
-scheme BalanceMac \
|
||||||
|
-configuration Release \
|
||||||
|
-derivedDataPath build/DerivedData \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
CODE_SIGNING_REQUIRED=NO \
|
||||||
|
CODE_SIGN_IDENTITY=""
|
||||||
|
|
||||||
|
- name: Compress macOS App
|
||||||
|
run: |
|
||||||
|
cd Balance/build/DerivedData/Build/Products/Release
|
||||||
|
mv -v BalanceMac.app Balance.app
|
||||||
|
zip -r Balance.zip Balance.app
|
||||||
|
|
||||||
|
- name: Upload macOS Artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: unsigned-macos-zip
|
||||||
|
path: Balance/build/DerivedData/Build/Products/Release/Balance.zip
|
||||||
@@ -1 +1,2 @@
|
|||||||
*.env
|
*.env
|
||||||
|
*.DS_Store
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# AGENTS.md — корень монорепозитория Balance
|
||||||
|
|
||||||
|
Этот файл — точка входа для AI-агентов и разработчиков, работающих в
|
||||||
|
репозитории Balance. Он описывает, как устроены три подпроекта, как они
|
||||||
|
связаны друг с другом, и куда идти за подробностями. **Каждый подпроект
|
||||||
|
имеет собственный `AGENTS.md` с детальными правилами — читай его перед
|
||||||
|
любым изменением внутри соответствующей папки.**
|
||||||
|
|
||||||
|
## Что это за проект
|
||||||
|
|
||||||
|
Balance — приложение для учёта личных финансов, доступное на пяти
|
||||||
|
платформах, которые используют одну и ту же доменную модель и один и
|
||||||
|
тот же протокол синхронизации:
|
||||||
|
|
||||||
|
| Платформа | Папка | Стек |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| iOS 17+ | `Balance/` | SwiftUI + SwiftData |
|
||||||
|
| macOS 14+ | `Balance/` (`BalanceMac/`) | SwiftUI + SwiftData |
|
||||||
|
| watchOS 10+ | `Balance/` (`BalanceWatch/`) | SwiftUI + SwiftData |
|
||||||
|
| Android 8.0+ | `BalanceAndroid/` | Kotlin + Jetpack Compose + Room |
|
||||||
|
| Web (встроен в сервер) | `BalanceServer/` | Go + embedded static client |
|
||||||
|
| Сервер синхронизации | `BalanceServer/` | Go + SQLite |
|
||||||
|
|
||||||
|
Все клиенты — iOS, macOS, watchOS, Android, web — синхронизируются через
|
||||||
|
один и тот же self-hosted сервер (`BalanceServer`) по единому HTTP(S)
|
||||||
|
API: регистрация, вход, refresh-токены, push/pull изменений с курсором,
|
||||||
|
tombstone-удаления, пользовательские категории и месячные бюджеты.
|
||||||
|
|
||||||
|
## Структура репозитория
|
||||||
|
|
||||||
|
```text
|
||||||
|
Balance/ # монорепозиторий
|
||||||
|
├── AGENTS.md # этот файл — общие правила для агентов
|
||||||
|
├── README.md # общий обзор и точки входа
|
||||||
|
├── docs/ # сводная документация по всему проекту
|
||||||
|
│ ├── ARCHITECTURE.md # как связаны клиенты и сервер
|
||||||
|
│ ├── SYNC_PROTOCOL.md # единый протокол синхронизации
|
||||||
|
│ ├── DATA_MODEL.md # сопоставление сущностей между платформами
|
||||||
|
│ ├── SECURITY.md # сквозные требования безопасности
|
||||||
|
│ └── DEVELOPMENT.md # как собрать и проверить все три проекта
|
||||||
|
│
|
||||||
|
├── Balance/ # Xcode-проект: iOS + macOS + watchOS
|
||||||
|
│ ├── AGENTS.md # правила для Apple-клиента
|
||||||
|
│ ├── README.md
|
||||||
|
│ └── docs/ # ARCHITECTURE, DATA_MODEL, SYNC, DEVELOPMENT, PRODUCT
|
||||||
|
│
|
||||||
|
├── BalanceAndroid/ # Android-проект (Gradle/Kotlin)
|
||||||
|
│ ├── AGENTS.md # правила для Android-клиента
|
||||||
|
│ ├── README.md
|
||||||
|
│ └── docs/ # ARCHITECTURE, DATA_MODEL, SYNC, DEVELOPMENT, PRODUCT
|
||||||
|
│
|
||||||
|
└── BalanceServer/ # Go-сервер синхронизации + web-клиент
|
||||||
|
├── AGENTS.md # правила для сервера
|
||||||
|
├── README.md
|
||||||
|
└── docs/ # ARCHITECTURE, API, DATA_MODEL, SYNC, SECURITY, DEVELOPMENT, DEPLOYMENT, PRODUCT
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как работать в этом репозитории
|
||||||
|
|
||||||
|
### 1. Сначала пойми, в каком подпроекте изменение
|
||||||
|
|
||||||
|
Каждый подпроект — независимый билд с собственными зависимостями,
|
||||||
|
тестами и CI-шагами. Изменения в одной папке не должны требовать
|
||||||
|
правок в другой, **кроме** случаев, когда меняется общий контракт:
|
||||||
|
|
||||||
|
- HTTP API / формат sync-запросов и ответов (`BalanceServer`);
|
||||||
|
- набор синхронизируемых полей сущности (транзакция, категория, бюджет);
|
||||||
|
- правила финансовых расчётов (баланс, корректировки, аналитика).
|
||||||
|
|
||||||
|
Если меняется общий контракт — обнови все три подпроекта согласованно
|
||||||
|
и синхронизируй документацию (`docs/SYNC_PROTOCOL.md`, а также
|
||||||
|
`docs/SYNC.md`/`docs/API.md`/`docs/DATA_MODEL.md` внутри каждой папки).
|
||||||
|
|
||||||
|
### 2. Единая доменная модель
|
||||||
|
|
||||||
|
Три клиента независимо реализуют одну и ту же модель на своём стеке
|
||||||
|
(SwiftData / Room), но со семантически одинаковыми сущностями:
|
||||||
|
|
||||||
|
- **Транзакция** (доход/расход, сумма, дата, заметка, категория —
|
||||||
|
хранится snapshot'ом имени/иконки/цвета категории на момент операции);
|
||||||
|
- **Пользовательская категория** (имя, иконка/emoji, цвет);
|
||||||
|
- **Месячный бюджет** (категория, месяц, лимит);
|
||||||
|
- **Tombstone/deletion journal** для распространения удалений между
|
||||||
|
устройствами.
|
||||||
|
|
||||||
|
Общие правила расчётов (баланс = доходы − расходы; корректировки
|
||||||
|
баланса учитываются в общем балансе, но не в периодической аналитике;
|
||||||
|
расходы группируются по имени категории) продублированы в:
|
||||||
|
|
||||||
|
- `Balance/Balance/Utilities/FinanceCalculations.swift` (Apple);
|
||||||
|
- `BalanceAndroid/.../data/FinanceMath.kt` (Android).
|
||||||
|
|
||||||
|
При изменении этих правил на одной платформе — переноси изменение на
|
||||||
|
другую и обновляй оба набора тестов
|
||||||
|
(`BalanceTests/FinanceCalculationsTests.swift`, `FinanceMathTest`).
|
||||||
|
|
||||||
|
### 3. Синхронизация — общий контракт
|
||||||
|
|
||||||
|
Единый протокол описан в `docs/SYNC_PROTOCOL.md` (сводно) и подробно —
|
||||||
|
в `BalanceServer/docs/API.md` и `BalanceServer/docs/SYNC.md`. Клиентские
|
||||||
|
реализации:
|
||||||
|
|
||||||
|
- Apple: `Balance/Balance/Shared/ServerSync.swift`;
|
||||||
|
- Android: `BalanceAndroid/.../sync/SyncRepository.kt`.
|
||||||
|
|
||||||
|
Изменение cursor/sequence semantics, JSON-полей или auth-заголовков —
|
||||||
|
breaking change для всех клиентов сразу. Не меняй его в одном
|
||||||
|
подпроекте, не проверив остальные два.
|
||||||
|
|
||||||
|
### 4. Безопасность (сквозные требования)
|
||||||
|
|
||||||
|
- Секреты (пароли, access/refresh токены) никогда не хранятся в
|
||||||
|
открытом виде: Keychain (Apple), Android Keystore + AES/GCM
|
||||||
|
(Android), Argon2id-хеши + SHA-256 refresh-token хеши (сервер).
|
||||||
|
- HTTPS обязателен для удалённого сервера на всех клиентах; HTTP
|
||||||
|
разрешён только для `localhost`/`127.0.0.1`/`::1`.
|
||||||
|
- Не логируй пароли, токены или заголовок `Authorization` ни в одном
|
||||||
|
из подпроектов.
|
||||||
|
- Не коммить секреты, production-URL или credentials.
|
||||||
|
|
||||||
|
Подробности — `docs/SECURITY.md` и `BalanceServer/docs/SECURITY.md`.
|
||||||
|
|
||||||
|
### 5. Тестирование перед PR
|
||||||
|
|
||||||
|
Запусти проверки для всех подпроектов, которые ты менял:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Apple (из Balance/)
|
||||||
|
xcodebuild -project Balance.xcodeproj -scheme Balance \
|
||||||
|
-destination 'platform=iOS Simulator,name=<available iPhone>' test
|
||||||
|
|
||||||
|
# Android (из BalanceAndroid/)
|
||||||
|
./gradlew clean test assembleDebug
|
||||||
|
|
||||||
|
# Server (из BalanceServer/)
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Если менялся общий контракт (API/sync/data model) — прогони проверки
|
||||||
|
во всех трёх, а не только в изменённой папке.
|
||||||
|
|
||||||
|
### 6. Документация
|
||||||
|
|
||||||
|
- Меняешь контракт синхронизации → обнови `docs/SYNC_PROTOCOL.md` в
|
||||||
|
корне **и** `BalanceServer/docs/SYNC.md`/`API.md`, а также
|
||||||
|
`docs/SYNC.md` в `Balance/` и `BalanceAndroid/`.
|
||||||
|
- Меняешь модель данных на любой платформе → обнови `docs/DATA_MODEL.md`
|
||||||
|
в корне и в затронутом подпроекте.
|
||||||
|
- Меняешь способ сборки/запуска → обнови `docs/DEVELOPMENT.md` в
|
||||||
|
корне и соответствующий `README.md`/`docs/DEVELOPMENT.md` подпроекта.
|
||||||
|
- Каждый подпроект содержит собственный подробный `AGENTS.md` — этот
|
||||||
|
файл не заменяет их, а даёт общую картину и правила координации между
|
||||||
|
платформами.
|
||||||
|
|
||||||
|
## PR checklist (для изменений, затрагивающих несколько платформ)
|
||||||
|
|
||||||
|
- [ ] Определены все подпроекты, которые затрагивает изменение.
|
||||||
|
- [ ] Общий контракт (API/sync/data model) обновлён согласованно во
|
||||||
|
всех платформах, а не только в одной.
|
||||||
|
- [ ] Финансовые расчёты остаются идентичными на Apple и Android.
|
||||||
|
- [ ] Обновлены `docs/SYNC_PROTOCOL.md`, `docs/DATA_MODEL.md` в корне,
|
||||||
|
если менялась схема/протокол.
|
||||||
|
- [ ] Секреты и токены не добавлены в репозиторий ни в одном подпроекте.
|
||||||
|
- [ ] Тесты пройдены в каждом изменённом подпроекте.
|
||||||
|
- [ ] Локальный `AGENTS.md` соответствующего подпроекта тоже соблюдён.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Назначение проекта
|
||||||
|
|
||||||
|
**Balance** — нативное мультиплатформенное приложение для учёта личных финансов:
|
||||||
|
- iOS 17+
|
||||||
|
- macOS 14+
|
||||||
|
- watchOS 10+
|
||||||
|
|
||||||
|
Основной UI написан на SwiftUI, локальное хранилище — SwiftData. Проект поддерживает два взаимоисключающих способа синхронизации данных: CloudKit или собственный HTTP(S)-сервер.
|
||||||
|
|
||||||
|
## Структура репозитория
|
||||||
|
|
||||||
|
```text
|
||||||
|
Balance/
|
||||||
|
├── Balance/ # Общий код iOS/macOS/watchOS
|
||||||
|
│ ├── App/ # Точка входа iOS и корневой ContentView
|
||||||
|
│ ├── Models/ # SwiftData-модели и доменные типы
|
||||||
|
│ ├── Shared/ # Общее состояние, контейнер SwiftData, server sync
|
||||||
|
│ ├── Utilities/ # Чистые функции расчётов и форматирование
|
||||||
|
│ └── Views/ # Общие SwiftUI-экраны
|
||||||
|
├── BalanceMac/ # macOS entry point и macOS-specific UI
|
||||||
|
├── BalanceWatch/ # watchOS entry point и компактный UI
|
||||||
|
├── BalanceTests/ # XCTest
|
||||||
|
├── Config/ # Entitlements
|
||||||
|
├── Balance.xcodeproj/
|
||||||
|
└── docs/ # Архитектура, модель данных, sync и dev guide
|
||||||
|
```
|
||||||
|
|
||||||
|
## Правила разработки
|
||||||
|
|
||||||
|
### 1. Сначала определяй слой изменения
|
||||||
|
|
||||||
|
- **Модель/данные:** `Balance/Models/`
|
||||||
|
- **Расчёты:** `Balance/Utilities/FinanceCalculations.swift`
|
||||||
|
- **Общий сервис:** `Balance/Shared/`
|
||||||
|
- **Общий UI:** `Balance/Views/`
|
||||||
|
- **Только Mac:** `BalanceMac/`
|
||||||
|
- **Только Watch:** `BalanceWatch/`
|
||||||
|
- **Тесты:** `BalanceTests/`
|
||||||
|
|
||||||
|
Не дублируй доменную логику в iOS/macOS/watchOS-экранах, если её можно выразить общей функцией или сервисом.
|
||||||
|
|
||||||
|
### 2. SwiftData
|
||||||
|
|
||||||
|
Текущая схема зарегистрирована в `BalanceModelContainer.schema`:
|
||||||
|
|
||||||
|
- `FinanceTransaction`
|
||||||
|
- `MonthlyBudget`
|
||||||
|
- `CustomCategory`
|
||||||
|
- `SyncTombstone`
|
||||||
|
|
||||||
|
При изменении `@Model` учитывай миграции существующих локальных баз. Не удаляй и не переименовывай persisted-поля без явной стратегии миграции.
|
||||||
|
|
||||||
|
### 3. Изменения финансовых записей
|
||||||
|
|
||||||
|
Любая редактируемая сервером сущность должна обновлять `syncUpdatedAt`.
|
||||||
|
|
||||||
|
Для удаления используй:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
modelContext.deleteForSync(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
а не прямой `modelContext.delete(...)`, если сущность должна удалиться и на других устройствах. Это создаёт tombstone в `SyncDeletionStore`.
|
||||||
|
|
||||||
|
### 4. Баланс и аналитика
|
||||||
|
|
||||||
|
`FinanceCalculations` содержит центральные правила:
|
||||||
|
|
||||||
|
- общий баланс = доходы − расходы;
|
||||||
|
- `isBalanceAdjustment == true` учитывается в общем балансе;
|
||||||
|
- корректировки не учитываются в периодической аналитике;
|
||||||
|
- расходы группируются по `categoryName`.
|
||||||
|
|
||||||
|
Не меняй эти правила только в одном UI. При изменении поведения обновляй `BalanceTests/FinanceCalculationsTests.swift`.
|
||||||
|
|
||||||
|
### 5. Категории
|
||||||
|
|
||||||
|
Системные категории находятся в `FinanceCategory`.
|
||||||
|
|
||||||
|
Пользовательские категории — `CustomCategory`. Их имя является важной частью текущей модели: операции и бюджеты хранят snapshot имени/иконки/emoji/цвета. При переименовании пользовательской категории текущий код обновляет связанные операции и бюджеты — это поведение нужно сохранять.
|
||||||
|
|
||||||
|
### 6. Синхронизация
|
||||||
|
|
||||||
|
`ServerAccountStore` — единая точка серверной авторизации и синхронизации.
|
||||||
|
|
||||||
|
Не хранить пароль в `UserDefaults`, SwiftData или файлах. Сессия хранится в Keychain.
|
||||||
|
|
||||||
|
При изменении URL сервера:
|
||||||
|
1. текущая сессия очищается;
|
||||||
|
2. cursor/push state удаляется;
|
||||||
|
3. пользователь должен авторизоваться заново.
|
||||||
|
|
||||||
|
Не включай одновременно CloudKit и собственный сервер как два источника синхронизации одной и той же базы.
|
||||||
|
|
||||||
|
### 7. Безопасность
|
||||||
|
|
||||||
|
- Для удалённого сервера использовать HTTPS.
|
||||||
|
- HTTP разрешён клиентом только для `localhost`, `127.0.0.1` и `::1`.
|
||||||
|
- Access/refresh tokens — только Keychain.
|
||||||
|
- Не логируй токены, пароли или полные Authorization headers.
|
||||||
|
- Не добавляй секреты, production URLs или credentials в репозиторий.
|
||||||
|
|
||||||
|
### 8. UI
|
||||||
|
|
||||||
|
Используй SwiftUI и системные компоненты Apple.
|
||||||
|
|
||||||
|
- Общие компоненты размещай в `Balance/Views/Components/`.
|
||||||
|
- Не тащи macOS-only API в общий код без `#if os(macOS)`.
|
||||||
|
- Не тащи watchOS-only API в общий код без условной компиляции.
|
||||||
|
- На Watch предпочитай короткие списки, `Form`, `List`, `NavigationStack` и компактные действия.
|
||||||
|
|
||||||
|
### 9. Тестирование
|
||||||
|
|
||||||
|
Минимум перед PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild -project Balance.xcodeproj -scheme Balance -destination 'platform=iOS Simulator,name=<available iPhone>' test
|
||||||
|
```
|
||||||
|
|
||||||
|
Также полезно проверить Mac и Watch schemes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild -project Balance.xcodeproj -scheme BalanceMac -showBuildSettings
|
||||||
|
xcodebuild -project Balance.xcodeproj -scheme BalanceWatch -showBuildSettings
|
||||||
|
```
|
||||||
|
|
||||||
|
Если имя симулятора отличается, сначала получи список:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcrun simctl list devices available
|
||||||
|
```
|
||||||
|
|
||||||
|
Не добавляй тесты, которые зависят от текущей даты или локали без явной фиксации `Calendar`, `Date` и formatter.
|
||||||
|
|
||||||
|
## Commit/PR checklist
|
||||||
|
|
||||||
|
- [ ] Изменение находится в правильном target/layer.
|
||||||
|
- [ ] Нет дублирования финансовой логики.
|
||||||
|
- [ ] Все серверные изменения сущностей обновляют `syncUpdatedAt`.
|
||||||
|
- [ ] Удаления синхронизируемых сущностей используют tombstone.
|
||||||
|
- [ ] Изменения расчётов покрыты XCTest.
|
||||||
|
- [ ] Проверены iOS/macOS/watchOS-условия компиляции.
|
||||||
|
- [ ] Не добавлены секреты и токены.
|
||||||
|
- [ ] Документация обновлена, если изменились данные, sync API или setup.
|
||||||
@@ -729,17 +729,18 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
|
DEVELOPMENT_TEAM = K3AS449A74;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait";
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
|
||||||
MARKETING_VERSION = 3.1.1;
|
MARKETING_VERSION = 3.1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.Balance;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -753,17 +754,18 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
|
DEVELOPMENT_TEAM = K3AS449A74;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait";
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
|
||||||
MARKETING_VERSION = 3.1.1;
|
MARKETING_VERSION = 3.1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.Balance;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -820,7 +822,7 @@
|
|||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 3.1.1;
|
MARKETING_VERSION = 3.1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.Balance.mac;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.mac;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
@@ -843,7 +845,7 @@
|
|||||||
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Подключение к вашему серверу Balance для синхронизации данных.";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 3.1.1;
|
MARKETING_VERSION = 3.1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.Balance.mac;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.mac;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
@@ -858,6 +860,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
|
DEVELOPMENT_TEAM = K3AS449A74;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
||||||
@@ -866,7 +869,7 @@
|
|||||||
INFOPLIST_KEY_WKWatchKitApp = YES;
|
INFOPLIST_KEY_WKWatchKitApp = YES;
|
||||||
INFOPLIST_KEY_WKWatchOnly = YES;
|
INFOPLIST_KEY_WKWatchOnly = YES;
|
||||||
MARKETING_VERSION = 3.1.1;
|
MARKETING_VERSION = 3.1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.Balance.watchkitapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
SKIP_INSTALL = NO;
|
SKIP_INSTALL = NO;
|
||||||
@@ -884,6 +887,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 14;
|
CURRENT_PROJECT_VERSION = 14;
|
||||||
|
DEVELOPMENT_TEAM = K3AS449A74;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
INFOPLIST_KEY_CFBundleDisplayName = "Баланс";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES;
|
||||||
@@ -891,7 +895,7 @@
|
|||||||
INFOPLIST_KEY_WKWatchKitApp = YES;
|
INFOPLIST_KEY_WKWatchKitApp = YES;
|
||||||
INFOPLIST_KEY_WKWatchOnly = YES;
|
INFOPLIST_KEY_WKWatchOnly = YES;
|
||||||
MARKETING_VERSION = 3.1.1;
|
MARKETING_VERSION = 3.1.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.Balance.watchkitapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.tolamironcenko.Balance.watchkitapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = watchos;
|
SDKROOT = watchos;
|
||||||
SKIP_INSTALL = NO;
|
SKIP_INSTALL = NO;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -52,7 +52,8 @@
|
|||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
allowLocationSimulation = "YES">
|
allowLocationSimulation = "YES">
|
||||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "T00100000000000000000001"
|
BlueprintIdentifier = "T00100000000000000000001"
|
||||||
@@ -68,7 +69,8 @@
|
|||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
debugDocumentVersioning = "YES">
|
debugDocumentVersioning = "YES">
|
||||||
<BuildableProductRunnable runnableDebuggingMode = "0">
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "T00100000000000000000001"
|
BlueprintIdentifier = "T00100000000000000000001"
|
||||||
@@ -78,7 +80,8 @@
|
|||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction buildConfiguration = "Debug">
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
</AnalyzeAction>
|
</AnalyzeAction>
|
||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
|||||||
@@ -1,23 +1,78 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme LastUpgradeVersion="1600" version="1.7">
|
<Scheme
|
||||||
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
LastUpgradeVersion = "1600"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
<BuildActionEntry
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="T00400000000000000000004" BuildableName="BalanceWatch.app" BlueprintName="BalanceWatch" ReferencedContainer="container:Balance.xcodeproj"/>
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "T00400000000000000000004"
|
||||||
|
BuildableName = "BalanceWatch.app"
|
||||||
|
BlueprintName = "BalanceWatch"
|
||||||
|
ReferencedContainer = "container:Balance.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
</BuildAction>
|
</BuildAction>
|
||||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"/>
|
<TestAction
|
||||||
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
|
buildConfiguration = "Debug"
|
||||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="T00400000000000000000004" BuildableName="BalanceWatch.app" BlueprintName="BalanceWatch" ReferencedContainer="container:Balance.xcodeproj"/>
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "T00400000000000000000004"
|
||||||
|
BuildableName = "BalanceWatch.app"
|
||||||
|
BlueprintName = "BalanceWatch"
|
||||||
|
ReferencedContainer = "container:Balance.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
<ProfileAction
|
||||||
<BuildableProductRunnable runnableDebuggingMode="0">
|
buildConfiguration = "Release"
|
||||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="T00400000000000000000004" BuildableName="BalanceWatch.app" BlueprintName="BalanceWatch" ReferencedContainer="container:Balance.xcodeproj"/>
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "T00400000000000000000004"
|
||||||
|
BuildableName = "BalanceWatch.app"
|
||||||
|
BlueprintName = "BalanceWatch"
|
||||||
|
ReferencedContainer = "container:Balance.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction buildConfiguration="Debug"/>
|
<AnalyzeAction
|
||||||
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
</Scheme>
|
</Scheme>
|
||||||
@@ -9,23 +9,7 @@ enum BalanceModelContainer {
|
|||||||
SyncTombstone.self
|
SyncTombstone.self
|
||||||
])
|
])
|
||||||
|
|
||||||
static var isCloudSyncEnabled: Bool {
|
|
||||||
Bundle.main.object(forInfoDictionaryKey: "BalanceCloudKitEnabled") as? Bool == true
|
|
||||||
}
|
|
||||||
|
|
||||||
static func make() -> ModelContainer {
|
static func make() -> ModelContainer {
|
||||||
if isCloudSyncEnabled,
|
|
||||||
let cloudContainer = try? ModelContainer(
|
|
||||||
for: schema,
|
|
||||||
configurations: [ModelConfiguration(
|
|
||||||
schema: schema,
|
|
||||||
isStoredInMemoryOnly: false,
|
|
||||||
cloudKitDatabase: .automatic
|
|
||||||
)]
|
|
||||||
) {
|
|
||||||
return cloudContainer
|
|
||||||
}
|
|
||||||
|
|
||||||
let localConfiguration = ModelConfiguration(
|
let localConfiguration = ModelConfiguration(
|
||||||
schema: schema,
|
schema: schema,
|
||||||
isStoredInMemoryOnly: false,
|
isStoredInMemoryOnly: false,
|
||||||
|
|||||||
@@ -78,9 +78,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var syncDescription: String {
|
private var syncDescription: String {
|
||||||
if BalanceModelContainer.isCloudSyncEnabled {
|
return "Без входа данные остаются на устройстве. Синхронизация операций, категорий и бюджетов доступна через собственный Go-сервер."
|
||||||
return "Данные синхронизируются через приватную базу CloudKit вашего Apple ID."
|
|
||||||
}
|
|
||||||
return "Без входа данные остаются на устройстве. Собственный сервер можно подключить выше без iCloud и платной Apple Developer Team."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,7 +336,7 @@ private struct MacBudgetsView: View {
|
|||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"Бюджеты не заданы",
|
"Бюджеты не заданы",
|
||||||
systemImage: "target",
|
systemImage: "target",
|
||||||
description: Text("Настройте лимиты на iPhone — они появятся здесь через iCloud.")
|
description: Text("Задайте лимиты здесь или подключите тот же Go-сервер и аккаунт для синхронизации с другими устройствами.")
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
List(currentBudgets) { budget in
|
List(currentBudgets) { budget in
|
||||||
|
|||||||
@@ -313,6 +313,10 @@ struct MacSettingsView: View {
|
|||||||
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
@AppStorage("currencyCode") private var currencyCode = SupportedCurrency.RUB.rawValue
|
||||||
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
|
@AppStorage("appTheme") private var appTheme = AppTheme.system.rawValue
|
||||||
|
|
||||||
|
private var exportDocument: CSVDocument {
|
||||||
|
CSVDocument(transactions: transactions)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section("Отображение") {
|
Section("Отображение") {
|
||||||
@@ -329,7 +333,10 @@ struct MacSettingsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Сервер и синхронизация", systemImage: "server.rack")
|
Label("Сервер и синхронизация", systemImage: "server.rack")
|
||||||
}
|
}
|
||||||
ShareLink(item: CSVDocument(transactions: transactions)) {
|
ShareLink(item: exportDocument, preview: SharePreview(
|
||||||
|
"Операции",
|
||||||
|
image: Image(systemName: "tablecells")
|
||||||
|
)) {
|
||||||
Label("Экспортировать CSV", systemImage: "square.and.arrow.up")
|
Label("Экспортировать CSV", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
.disabled(transactions.isEmpty)
|
.disabled(transactions.isEmpty)
|
||||||
@@ -337,10 +344,8 @@ struct MacSettingsView: View {
|
|||||||
}
|
}
|
||||||
Section("Хранение") {
|
Section("Хранение") {
|
||||||
Label(
|
Label(
|
||||||
BalanceModelContainer.isCloudSyncEnabled
|
"Локальное хранилище; синхронизация через Go-сервер",
|
||||||
? "Синхронизация CloudKit включена"
|
systemImage: "internaldrive.fill"
|
||||||
: "Локальный режим для Personal Team",
|
|
||||||
systemImage: BalanceModelContainer.isCloudSyncEnabled ? "icloud.fill" : "internaldrive.fill"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
|
|||||||
@@ -1,21 +1,136 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{ "filename" : "BalanceIcon-48.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "24x24", "subtype" : "38mm" },
|
{
|
||||||
{ "filename" : "BalanceIcon-55.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "27.5x27.5", "subtype" : "42mm" },
|
"filename" : "BalanceIcon-48.png",
|
||||||
{ "filename" : "BalanceIcon-58.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "2x", "size" : "29x29" },
|
"idiom" : "watch",
|
||||||
{ "filename" : "BalanceIcon-87.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "3x", "size" : "29x29" },
|
"role" : "notificationCenter",
|
||||||
{ "filename" : "BalanceIcon-80.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "40x40", "subtype" : "38mm" },
|
"scale" : "2x",
|
||||||
{ "filename" : "BalanceIcon-88.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "44x44", "subtype" : "40mm" },
|
"size" : "24x24",
|
||||||
{ "filename" : "BalanceIcon-92.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "46x46", "subtype" : "41mm" },
|
"subtype" : "38mm"
|
||||||
{ "filename" : "BalanceIcon-100.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "50x50", "subtype" : "44mm" },
|
},
|
||||||
{ "filename" : "BalanceIcon-102.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "51x51", "subtype" : "45mm" },
|
{
|
||||||
{ "filename" : "BalanceIcon-108.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "54x54", "subtype" : "49mm" },
|
"filename" : "BalanceIcon-55.png",
|
||||||
{ "filename" : "BalanceIcon-172.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "86x86", "subtype" : "38mm" },
|
"idiom" : "watch",
|
||||||
{ "filename" : "BalanceIcon-196.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "98x98", "subtype" : "42mm" },
|
"role" : "notificationCenter",
|
||||||
{ "filename" : "BalanceIcon-216.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "108x108", "subtype" : "44mm" },
|
"scale" : "2x",
|
||||||
{ "filename" : "BalanceIcon-234.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "117x117", "subtype" : "45mm" },
|
"size" : "27.5x27.5",
|
||||||
{ "filename" : "BalanceIcon-258.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "129x129", "subtype" : "49mm" },
|
"subtype" : "42mm"
|
||||||
{ "filename" : "BalanceIcon-1024.png", "idiom" : "watch-marketing", "scale" : "1x", "size" : "1024x1024" }
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-58.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "companionSettings",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-87.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "companionSettings",
|
||||||
|
"scale" : "3x",
|
||||||
|
"size" : "29x29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "notificationCenter",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "33x33",
|
||||||
|
"subtype" : "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-80.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "appLauncher",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "40x40",
|
||||||
|
"subtype" : "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-88.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "appLauncher",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "44x44",
|
||||||
|
"subtype" : "40mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-92.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "appLauncher",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "46x46",
|
||||||
|
"subtype" : "41mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-100.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "appLauncher",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "50x50",
|
||||||
|
"subtype" : "44mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-102.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "appLauncher",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "51x51",
|
||||||
|
"subtype" : "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-108.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "appLauncher",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "54x54",
|
||||||
|
"subtype" : "49mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-172.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "quickLook",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "86x86",
|
||||||
|
"subtype" : "38mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-196.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "quickLook",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "98x98",
|
||||||
|
"subtype" : "42mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-216.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "quickLook",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "108x108",
|
||||||
|
"subtype" : "44mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-234.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "quickLook",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "117x117",
|
||||||
|
"subtype" : "45mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-258.png",
|
||||||
|
"idiom" : "watch",
|
||||||
|
"role" : "quickLook",
|
||||||
|
"scale" : "2x",
|
||||||
|
"size" : "129x129",
|
||||||
|
"subtype" : "49mm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "BalanceIcon-1024.png",
|
||||||
|
"idiom" : "watch-marketing",
|
||||||
|
"scale" : "1x",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
"author" : "xcode",
|
"author" : "xcode",
|
||||||
|
|||||||
@@ -483,8 +483,8 @@ struct WatchSettingsView: View {
|
|||||||
Label("Сервер", systemImage: "server.rack")
|
Label("Сервер", systemImage: "server.rack")
|
||||||
}
|
}
|
||||||
Label(
|
Label(
|
||||||
BalanceModelContainer.isCloudSyncEnabled ? "CloudKit" : "Локально",
|
"Локально; синхронизация через сервер",
|
||||||
systemImage: BalanceModelContainer.isCloudSyncEnabled ? "icloud.fill" : "internaldrive.fill"
|
systemImage: "internaldrive.fill"
|
||||||
)
|
)
|
||||||
LabeledContent("Версия", value: "3.1.1")
|
LabeledContent("Версия", value: "3.1.1")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict/>
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>iCloud.com.example.Balance</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
|
||||||
<array>
|
|
||||||
<string>CloudKit</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
|
||||||
<string>$(TeamIdentifierPrefix)com.example.Balance</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
</plist>
|
||||||
@@ -2,16 +2,6 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>iCloud.com.example.Balance</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
|
||||||
<array>
|
|
||||||
<string>CloudKit</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
|
||||||
<string>$(TeamIdentifierPrefix)com.example.Balance</string>
|
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict/>
|
||||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
|
||||||
<array>
|
|
||||||
<string>iCloud.com.example.Balance</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.icloud-services</key>
|
|
||||||
<array>
|
|
||||||
<string>CloudKit</string>
|
|
||||||
</array>
|
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
|
||||||
<string>$(TeamIdentifierPrefix)com.example.Balance</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
</plist>
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
- выбор валюты: RUB, EUR, USD или SEK;
|
- выбор валюты: RUB, EUR, USD или SEK;
|
||||||
- системная, светлая и тёмная тема;
|
- системная, светлая и тёмная тема;
|
||||||
- экспорт истории в CSV;
|
- экспорт истории в CSV;
|
||||||
- опциональная синхронизация финансовых данных через приватную базу iCloud CloudKit;
|
|
||||||
- синхронизация операций, категорий и бюджетов через собственный Go-сервер;
|
- синхронизация операций, категорий и бюджетов через собственный Go-сервер;
|
||||||
- регистрация, вход, безопасное хранение сессии в Keychain, ручная и автоматическая синхронизация;
|
- регистрация, вход, безопасное хранение сессии в Keychain, ручная и автоматическая синхронизация;
|
||||||
- смена сервера с обязательной повторной авторизацией;
|
- смена сервера с обязательной повторной авторизацией;
|
||||||
@@ -41,11 +40,11 @@ CSV-экспорт доступен на iPhone и Mac. На Apple Watch дос
|
|||||||
|
|
||||||
Поддерживаемые системы: iOS 17+, macOS 14+ и watchOS 10+.
|
Поддерживаемые системы: iOS 17+, macOS 14+ и watchOS 10+.
|
||||||
|
|
||||||
## Personal Team — режим по умолчанию
|
## Локальное хранение и синхронизация
|
||||||
|
|
||||||
Проект по умолчанию использует локальное SwiftData-хранилище и собирается с бесплатной Personal Team без iCloud и Push Notifications capabilities. Выберите свою Team в **Signing & Capabilities** и запускайте нужную схему.
|
Проект использует локальное SwiftData-хранилище и собирается с бесплатной Personal Team. Выберите свою Team в **Signing & Capabilities** и запускайте нужную схему.
|
||||||
|
|
||||||
В этом режиме можно подключить self-hosted сервер без iCloud-capabilities. Пока сервер не указан, каждое приложение хранит данные только локально.
|
Единственный способ синхронизации — собственный Go-сервер. Пока сервер не указан и вход не выполнен, каждое приложение хранит данные только локально.
|
||||||
|
|
||||||
## Собственный сервер
|
## Собственный сервер
|
||||||
|
|
||||||
@@ -60,30 +59,10 @@ CSV-экспорт доступен на iPhone и Mac. На Apple Watch дос
|
|||||||
При изменении адреса токены и курсор предыдущего сервера удаляются, после чего приложение просит войти заново. Пароли не сохраняются; access/refresh-токены находятся в системном Keychain каждого устройства.
|
При изменении адреса токены и курсор предыдущего сервера удаляются, после чего приложение просит войти заново. Пароли не сохраняются; access/refresh-токены находятся в системном Keychain каждого устройства.
|
||||||
В Apple-клиентах синхронизация выполняется сразу после входа, при запуске приложения, затем каждые две минуты. Android использует WorkManager с минимальным системным интервалом 15 минут. Во всех приложениях синхронизацию также можно запустить вручную. Изменения передаются ограниченными пакетами, поэтому большая история операций не загружается в память целиком. Журнал удалений хранится отдельно от SwiftData, чтобы обновления схемы старого локального хранилища не могли аварийно завершить приложение. Клиент и `BalanceServer` из архива должны обновляться вместе.
|
В Apple-клиентах синхронизация выполняется сразу после входа, при запуске приложения, затем каждые две минуты. Android использует WorkManager с минимальным системным интервалом 15 минут. Во всех приложениях синхронизацию также можно запустить вручную. Изменения передаются ограниченными пакетами, поэтому большая история операций не загружается в память целиком. Журнал удалений хранится отдельно от SwiftData, чтобы обновления схемы старого локального хранилища не могли аварийно завершить приложение. Клиент и `BalanceServer` из архива должны обновляться вместе.
|
||||||
|
|
||||||
## Настройка iCloud
|
|
||||||
|
|
||||||
CloudKit является опциональным режимом. Для него нужен платный Apple Developer Program Team и собственный контейнер.
|
|
||||||
|
|
||||||
1. В Apple Developer зарегистрируйте три уникальных Bundle ID для iOS, macOS и watchOS.
|
|
||||||
2. Создайте CloudKit-контейнер, например `iCloud.com.yourcompany.Balance`.
|
|
||||||
3. Замените `iCloud.com.example.Balance` в шаблонах entitlements папки `Config`.
|
|
||||||
4. Для каждого target задайте **Code Signing Entitlements**: `Config/Balance.entitlements`, `Config/BalanceMac.entitlements` или `Config/BalanceWatch.entitlements`.
|
|
||||||
5. В **Signing & Capabilities** укажите платную Team, добавьте iCloud capability, включите CloudKit и выберите один и тот же контейнер.
|
|
||||||
6. В Build Settings каждого target добавьте пользовательскую настройку `INFOPLIST_KEY_BalanceCloudKitEnabled = YES`.
|
|
||||||
7. Войдите в один Apple ID с включённым iCloud на всех тестовых устройствах.
|
|
||||||
8. Запустите приложение для создания development-схемы. Перед TestFlight или App Store разверните схему в production через CloudKit Console.
|
|
||||||
|
|
||||||
Если флаг не задан или CloudKit не настроен, приложение использует локальное хранилище.
|
|
||||||
|
|
||||||
Не включайте CloudKit и собственный сервер одновременно для одной базы: выберите один способ синхронизации.
|
|
||||||
|
|
||||||
Документация Apple: [Enabling CloudKit in Your App](https://developer.apple.com/documentation/cloudkit/enabling-cloudkit-in-your-app), [Syncing model data across a person's devices](https://developer.apple.com/documentation/swiftdata/syncing-model-data-across-a-persons-devices), [Creating independent watchOS apps](https://developer.apple.com/documentation/watchos-apps/creating-independent-watchos-apps/).
|
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
- SwiftUI
|
- SwiftUI
|
||||||
- SwiftData
|
- SwiftData
|
||||||
- CloudKit
|
|
||||||
- Swift Charts
|
- Swift Charts
|
||||||
- XCTest
|
- XCTest
|
||||||
- собственный Go 1.25 + SQLite сервер
|
- собственный Go 1.25 + SQLite сервер
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Архитектура Balance
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Balance использует простую feature-oriented структуру поверх SwiftUI + SwiftData:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SwiftUI Views
|
||||||
|
│
|
||||||
|
├── FinanceCalculations
|
||||||
|
│
|
||||||
|
├── SwiftData ModelContext / @Query
|
||||||
|
│
|
||||||
|
└── ServerAccountStore
|
||||||
|
│
|
||||||
|
├── Keychain (session)
|
||||||
|
├── UserDefaults (server URL, cursor, sync metadata)
|
||||||
|
└── HTTP(S) API /v1/*
|
||||||
|
```
|
||||||
|
|
||||||
|
Общий код находится в `Balance/Balance/`. Платформенные оболочки находятся в `BalanceMac/` и `BalanceWatch/`.
|
||||||
|
|
||||||
|
## Точки входа
|
||||||
|
|
||||||
|
| Platform | Entry point | Основной root |
|
||||||
|
|---|---|---|
|
||||||
|
| iOS | `Balance/App/BalanceApp.swift` | `ContentView` |
|
||||||
|
| macOS | `BalanceMac/BalanceMacApp.swift` | `MacContentView` |
|
||||||
|
| watchOS | `BalanceWatch/BalanceWatchApp.swift` | `WatchRootView` |
|
||||||
|
|
||||||
|
Каждое приложение создаёт `BalanceModelContainer.make()` и передаёт его через `.modelContainer(...)`.
|
||||||
|
|
||||||
|
## Хранение данных
|
||||||
|
|
||||||
|
`BalanceModelContainer` выбирает конфигурацию:
|
||||||
|
|
||||||
|
1. если `BalanceCloudKitEnabled == true` и CloudKit container создаётся — SwiftData + CloudKit;
|
||||||
|
2. иначе — локальная SwiftData база;
|
||||||
|
3. если локальная база не создаётся — используется in-memory recovery container.
|
||||||
|
|
||||||
|
Recovery container предназначен для предотвращения падения приложения при невозможности открыть persistent store; он не является способом восстановления данных.
|
||||||
|
|
||||||
|
## Модели
|
||||||
|
|
||||||
|
### FinanceTransaction
|
||||||
|
|
||||||
|
Основная финансовая операция:
|
||||||
|
|
||||||
|
- UUID;
|
||||||
|
- сумма `Double`;
|
||||||
|
- дата;
|
||||||
|
- заметка;
|
||||||
|
- snapshot категории;
|
||||||
|
- тип `income` / `expense`;
|
||||||
|
- флаг корректировки баланса;
|
||||||
|
- `syncUpdatedAt`.
|
||||||
|
|
||||||
|
`TransactionKind` сериализуется через `kindRawValue`, а наружу предоставляется computed property `kind`.
|
||||||
|
|
||||||
|
### CustomCategory
|
||||||
|
|
||||||
|
Пользовательская категория:
|
||||||
|
|
||||||
|
- имя;
|
||||||
|
- SF Symbol или emoji;
|
||||||
|
- цвет;
|
||||||
|
- тип операции;
|
||||||
|
- `createdAt`;
|
||||||
|
- `syncUpdatedAt`.
|
||||||
|
|
||||||
|
### MonthlyBudget
|
||||||
|
|
||||||
|
Месячный лимит для категории:
|
||||||
|
|
||||||
|
- snapshot категории;
|
||||||
|
- `limit`;
|
||||||
|
- `monthStart`;
|
||||||
|
- `syncUpdatedAt`.
|
||||||
|
|
||||||
|
### SyncTombstone
|
||||||
|
|
||||||
|
SwiftData-модель для совместимости схемы, но клиентский журнал удалений фактически ведётся через `SyncDeletionStore` в `UserDefaults`. Это сделано специально, чтобы удаление не зависело от миграции основной SwiftData-схемы.
|
||||||
|
|
||||||
|
## Расчёты
|
||||||
|
|
||||||
|
`FinanceCalculations` — чистый доменный слой:
|
||||||
|
|
||||||
|
- `totalBalance` считает весь баланс;
|
||||||
|
- `monthInterval` возвращает календарный месяц;
|
||||||
|
- `summary` считает доходы/расходы периода;
|
||||||
|
- `spendingByCategory` агрегирует расходы.
|
||||||
|
|
||||||
|
Корректировки (`isBalanceAdjustment`) влияют на `totalBalance`, но исключаются из `summary` и `spendingByCategory`.
|
||||||
|
|
||||||
|
## UI/features
|
||||||
|
|
||||||
|
Общие features:
|
||||||
|
|
||||||
|
- Dashboard
|
||||||
|
- Transactions
|
||||||
|
- Budgets
|
||||||
|
- Analytics
|
||||||
|
- Categories
|
||||||
|
- Settings
|
||||||
|
- Balance Adjustment
|
||||||
|
- CSV export
|
||||||
|
- Server account/sync
|
||||||
|
|
||||||
|
macOS добавляет полноценную desktop-навигацию и расширенный аналитический экран.
|
||||||
|
|
||||||
|
watchOS использует отдельные экраны с теми же моделями и расчётами, но адаптированным UI.
|
||||||
|
|
||||||
|
## Состояние приложения
|
||||||
|
|
||||||
|
`@Query` используется как основной способ наблюдения за SwiftData. Feature views не должны создавать собственные параллельные кеши финансовых сущностей без необходимости.
|
||||||
|
|
||||||
|
Глобальное состояние серверного аккаунта сосредоточено в `ServerAccountStore.shared`.
|
||||||
|
|
||||||
|
Настройки пользователя хранятся через `@AppStorage`, в частности:
|
||||||
|
|
||||||
|
- `currencyCode`;
|
||||||
|
- `appTheme`;
|
||||||
|
- server URL;
|
||||||
|
- sync cursor;
|
||||||
|
- last push/sync timestamps.
|
||||||
|
|
||||||
|
## Принцип изменения
|
||||||
|
|
||||||
|
Предпочтительный поток:
|
||||||
|
|
||||||
|
```text
|
||||||
|
UI action
|
||||||
|
→ update/insert/delete SwiftData
|
||||||
|
→ update syncUpdatedAt / tombstone
|
||||||
|
→ UI обновляется через @Query
|
||||||
|
→ ServerAccountStore синхронизирует изменения
|
||||||
|
```
|
||||||
|
|
||||||
|
Не следует делать HTTP-запросы непосредственно из feature view.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Модель данных
|
||||||
|
|
||||||
|
## Persisted schema
|
||||||
|
|
||||||
|
```text
|
||||||
|
FinanceTransaction
|
||||||
|
CustomCategory
|
||||||
|
MonthlyBudget
|
||||||
|
SyncTombstone
|
||||||
|
```
|
||||||
|
|
||||||
|
## FinanceTransaction
|
||||||
|
|
||||||
|
| Поле | Тип | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | UUID | стабильный идентификатор |
|
||||||
|
| `amount` | Double | положительная сумма |
|
||||||
|
| `date` | Date | дата операции |
|
||||||
|
| `note` | String | заметка |
|
||||||
|
| `categoryName` | String | snapshot имени категории |
|
||||||
|
| `categoryIcon` | String | SF Symbol |
|
||||||
|
| `categoryEmoji` | String | emoji, если используется |
|
||||||
|
| `categoryColorName` | String | имя цвета |
|
||||||
|
| `isBalanceAdjustment` | Bool | специальная корректировка |
|
||||||
|
| `kindRawValue` | String | `income` / `expense` |
|
||||||
|
| `syncUpdatedAt` | Date | версия изменения |
|
||||||
|
|
||||||
|
## CustomCategory
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| `id` | UUID |
|
||||||
|
| `name` | String |
|
||||||
|
| `icon` | String |
|
||||||
|
| `emoji` | String |
|
||||||
|
| `colorName` | String |
|
||||||
|
| `kindRawValue` | String |
|
||||||
|
| `createdAt` | Date |
|
||||||
|
| `syncUpdatedAt` | Date |
|
||||||
|
|
||||||
|
Имя пользовательской категории уникально без учёта регистра/диакритики относительно системных и других пользовательских категорий.
|
||||||
|
|
||||||
|
При редактировании категории текущая реализация распространяет новое имя и presentation metadata на связанные транзакции и бюджеты.
|
||||||
|
|
||||||
|
## MonthlyBudget
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| `id` | UUID |
|
||||||
|
| `categoryName` | String |
|
||||||
|
| `categoryIcon` | String |
|
||||||
|
| `categoryEmoji` | String |
|
||||||
|
| `limit` | Double |
|
||||||
|
| `monthStart` | Date |
|
||||||
|
| `syncUpdatedAt` | Date |
|
||||||
|
|
||||||
|
Бюджет идентифицируется UUID, но связывается с категорией по сохранённому `categoryName`.
|
||||||
|
|
||||||
|
## SyncTombstone
|
||||||
|
|
||||||
|
| Поле | Тип |
|
||||||
|
|---|---|
|
||||||
|
| `id` | UUID |
|
||||||
|
| `entity` | String |
|
||||||
|
| `recordID` | String |
|
||||||
|
| `updatedAt` | Date |
|
||||||
|
|
||||||
|
В текущем клиентском sync-flow tombstone удалений хранится в `SyncDeletionStore` (`UserDefaults`) и отправляется как `deleted: true`.
|
||||||
|
|
||||||
|
Поддерживаемые entity:
|
||||||
|
|
||||||
|
```text
|
||||||
|
transaction
|
||||||
|
category
|
||||||
|
budget
|
||||||
|
```
|
||||||
|
|
||||||
|
## Финансовые инварианты
|
||||||
|
|
||||||
|
1. `amount` не должен быть отрицательным через пользовательский UI.
|
||||||
|
2. Тип операции хранится в `kindRawValue`.
|
||||||
|
3. Баланс:
|
||||||
|
`sum(income.amount) - sum(expense.amount)`.
|
||||||
|
4. Корректировка баланса является обычной транзакцией для общего баланса, но не участвует в периодической аналитике.
|
||||||
|
5. `syncUpdatedAt` должен меняться при каждом изменении синхронизируемой сущности.
|
||||||
|
6. Удаляемая синхронизируемая сущность должна создавать tombstone.
|
||||||
|
|
||||||
|
## Почему категория хранится snapshot'ом
|
||||||
|
|
||||||
|
Операция хранит `categoryName`, `categoryIcon`, `categoryEmoji` и `categoryColorName`, а не relationship на `CustomCategory`.
|
||||||
|
|
||||||
|
Это позволяет:
|
||||||
|
- не терять отображение старой операции при удалении категории;
|
||||||
|
- синхронизировать запись без SwiftData relationship;
|
||||||
|
- поддерживать системные категории без persisted object.
|
||||||
|
|
||||||
|
Из-за этого переименование пользовательской категории должно явно обновлять связанные записи — именно так работает текущая реализация.
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Разработка
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Xcode 16+
|
||||||
|
- Swift 5
|
||||||
|
- iOS 17+
|
||||||
|
- macOS 14+
|
||||||
|
- watchOS 10+
|
||||||
|
|
||||||
|
В проекте нет Swift Package Manager-зависимостей.
|
||||||
|
|
||||||
|
## Открытие
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open Balance.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Доступные shared schemes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Balance
|
||||||
|
BalanceMac
|
||||||
|
BalanceWatch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Сборка
|
||||||
|
|
||||||
|
Сначала можно посмотреть схемы:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild -project Balance.xcodeproj -list
|
||||||
|
```
|
||||||
|
|
||||||
|
Для CI используйте явный `-scheme` и подходящий `-destination`.
|
||||||
|
|
||||||
|
Пример проверки компиляции:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild \
|
||||||
|
-project Balance.xcodeproj \
|
||||||
|
-scheme Balance \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
build
|
||||||
|
```
|
||||||
|
|
||||||
|
Для Mac:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild \
|
||||||
|
-project Balance.xcodeproj \
|
||||||
|
-scheme BalanceMac \
|
||||||
|
-destination 'generic/platform=macOS' \
|
||||||
|
build
|
||||||
|
```
|
||||||
|
|
||||||
|
Для Watch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild \
|
||||||
|
-project Balance.xcodeproj \
|
||||||
|
-scheme BalanceWatch \
|
||||||
|
-destination 'generic/platform=watchOS' \
|
||||||
|
build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
Основной тестовый target:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BalanceTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xcodebuild \
|
||||||
|
-project Balance.xcodeproj \
|
||||||
|
-scheme Balance \
|
||||||
|
-destination 'platform=iOS Simulator,name=<available iPhone>' \
|
||||||
|
test
|
||||||
|
```
|
||||||
|
|
||||||
|
Тесты сосредоточены на финансовых расчётах и sync deletion journal.
|
||||||
|
|
||||||
|
## Signing
|
||||||
|
|
||||||
|
Проект рассчитан на запуск с собственной Team.
|
||||||
|
|
||||||
|
Для обычной локальной разработки:
|
||||||
|
- выберите свою Team в Signing & Capabilities;
|
||||||
|
- используйте локальное SwiftData;
|
||||||
|
- CloudKit можно не включать.
|
||||||
|
|
||||||
|
Bundle identifiers текущего проекта:
|
||||||
|
|
||||||
|
```text
|
||||||
|
com.tolamironcenko.Balance
|
||||||
|
com.tolamironcenko.Balance.mac
|
||||||
|
com.tolamironcenko.Balance.watchkitapp
|
||||||
|
```
|
||||||
|
|
||||||
|
При публикации их следует заменить на собственные identifiers.
|
||||||
|
|
||||||
|
## Local mode
|
||||||
|
|
||||||
|
Без `BalanceCloudKitEnabled=YES` приложение использует локальное SwiftData-хранилище.
|
||||||
|
|
||||||
|
Это рекомендуемый режим для:
|
||||||
|
- UI-разработки;
|
||||||
|
- unit tests;
|
||||||
|
- Personal Team;
|
||||||
|
- отладки без Apple Developer Program.
|
||||||
|
|
||||||
|
## CloudKit mode
|
||||||
|
|
||||||
|
CloudKit является опциональным.
|
||||||
|
|
||||||
|
Нужно:
|
||||||
|
1. иметь платную Apple Developer Team;
|
||||||
|
2. создать собственный CloudKit container;
|
||||||
|
3. заменить placeholder container ID в entitlements;
|
||||||
|
4. включить iCloud/CloudKit capabilities;
|
||||||
|
5. выставить `BalanceCloudKitEnabled=YES`.
|
||||||
|
|
||||||
|
Не смешивайте CloudKit и собственный сервер как два активных sync backends для одной пользовательской базы.
|
||||||
|
|
||||||
|
## Server mode
|
||||||
|
|
||||||
|
В Settings укажите URL сервера, затем зарегистрируйтесь или войдите.
|
||||||
|
|
||||||
|
Для физического устройства удалённый адрес должен быть HTTPS.
|
||||||
|
|
||||||
|
Для локального сервера допустим:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:...
|
||||||
|
http://127.0.0.1:...
|
||||||
|
```
|
||||||
|
|
||||||
|
На физическом iPhone `localhost` означает сам iPhone, а не Mac-разработчика.
|
||||||
|
|
||||||
|
## Экспорт CSV
|
||||||
|
|
||||||
|
CSV export реализован в:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Balance/Views/Settings/CSVDocument.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
Экспорт доступен на iOS и macOS.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Xcode использует старые build artifacts
|
||||||
|
|
||||||
|
```text
|
||||||
|
Product → Clean Build Folder
|
||||||
|
```
|
||||||
|
|
||||||
|
Если проблема сохраняется, удалите DerivedData проекта и заново откройте Xcode.
|
||||||
|
|
||||||
|
### SwiftData schema error
|
||||||
|
|
||||||
|
Проверьте изменения `@Model` и не меняйте persisted fields без migration strategy.
|
||||||
|
|
||||||
|
### Sync не работает
|
||||||
|
|
||||||
|
Проверьте по порядку:
|
||||||
|
1. server URL;
|
||||||
|
2. HTTPS/localhost rule;
|
||||||
|
3. авторизацию;
|
||||||
|
4. дату последней синхронизации;
|
||||||
|
5. cursor;
|
||||||
|
6. совместимость версии Balance Server.
|
||||||
|
|
||||||
|
### Данные не удаляются на другом устройстве
|
||||||
|
|
||||||
|
Проверьте, что удаление выполнено через `deleteForSync(...)`, а не прямой `delete(...)`.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Функциональная документация
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Показывает:
|
||||||
|
- общий баланс;
|
||||||
|
- доходы текущего месяца;
|
||||||
|
- расходы текущего месяца;
|
||||||
|
- последние операции;
|
||||||
|
- быстрый переход к аналитике, операциям, бюджетам, категориям, корректировке и настройкам.
|
||||||
|
|
||||||
|
## Операции
|
||||||
|
|
||||||
|
Поддерживаются:
|
||||||
|
- доходы;
|
||||||
|
- расходы;
|
||||||
|
- категория;
|
||||||
|
- сумма;
|
||||||
|
- дата;
|
||||||
|
- заметка;
|
||||||
|
- редактирование;
|
||||||
|
- удаление;
|
||||||
|
- поиск;
|
||||||
|
- фильтрация по типу.
|
||||||
|
|
||||||
|
## Корректировка баланса
|
||||||
|
|
||||||
|
Пользователь задаёт фактический баланс.
|
||||||
|
|
||||||
|
Разница между фактическим и текущим балансом сохраняется как специальная операция с `isBalanceAdjustment=true`.
|
||||||
|
|
||||||
|
Такая запись:
|
||||||
|
- влияет на общий баланс;
|
||||||
|
- не влияет на месячные income/expenses;
|
||||||
|
- не попадает в spending-by-category.
|
||||||
|
|
||||||
|
Это позволяет исправить расхождение между приложением и реальным счётом, не искажая историю расходов.
|
||||||
|
|
||||||
|
## Аналитика
|
||||||
|
|
||||||
|
Доступны периоды:
|
||||||
|
- месяц;
|
||||||
|
- 3 месяца;
|
||||||
|
- 12 месяцев.
|
||||||
|
|
||||||
|
Используются:
|
||||||
|
- доходы;
|
||||||
|
- расходы;
|
||||||
|
- результат;
|
||||||
|
- savings rate;
|
||||||
|
- структура расходов по категориям;
|
||||||
|
- динамика доходов и расходов.
|
||||||
|
|
||||||
|
## Бюджеты
|
||||||
|
|
||||||
|
Для категории можно задать месячный лимит.
|
||||||
|
|
||||||
|
Budget хранится отдельно от транзакций и синхронизируется через собственную entity `budget`.
|
||||||
|
|
||||||
|
## Категории
|
||||||
|
|
||||||
|
Системные категории разделены на income/expense.
|
||||||
|
|
||||||
|
Пользователь может создать свою категорию:
|
||||||
|
- название;
|
||||||
|
- тип;
|
||||||
|
- SF Symbol или emoji;
|
||||||
|
- цвет.
|
||||||
|
|
||||||
|
Имя категории не должно дублировать существующее имя.
|
||||||
|
|
||||||
|
## Настройки
|
||||||
|
|
||||||
|
- валюта: RUB, EUR, USD, SEK;
|
||||||
|
- тема: system/light/dark;
|
||||||
|
- сервер и синхронизация;
|
||||||
|
- CSV export на iOS/macOS.
|
||||||
|
|
||||||
|
## watchOS
|
||||||
|
|
||||||
|
Watch-приложение предоставляет компактные версии:
|
||||||
|
- Dashboard;
|
||||||
|
- Add/Edit transaction;
|
||||||
|
- All transactions;
|
||||||
|
- Analytics;
|
||||||
|
- Budgets;
|
||||||
|
- Categories;
|
||||||
|
- Balance adjustment;
|
||||||
|
- Settings.
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
Mac-приложение предоставляет:
|
||||||
|
- dashboard;
|
||||||
|
- полную аналитику;
|
||||||
|
- операции;
|
||||||
|
- бюджеты;
|
||||||
|
- категории;
|
||||||
|
- корректировку;
|
||||||
|
- настройки;
|
||||||
|
- CSV export.
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Серверная синхронизация
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
`Balance/Shared/ServerSync.swift` реализует авторизацию и двустороннюю синхронизацию с собственным сервером.
|
||||||
|
|
||||||
|
Сервер не входит в этот Xcode-проект; клиент ожидает API под базовым URL сервера.
|
||||||
|
|
||||||
|
## Хранилище сессии
|
||||||
|
|
||||||
|
Сессия содержит:
|
||||||
|
|
||||||
|
- access token;
|
||||||
|
- refresh token;
|
||||||
|
- expiry;
|
||||||
|
- user id/email.
|
||||||
|
|
||||||
|
Сессия хранится в Keychain с service `BalanceServer`.
|
||||||
|
|
||||||
|
В `UserDefaults` хранятся только несекретные sync-настройки:
|
||||||
|
|
||||||
|
- server URL;
|
||||||
|
- device ID;
|
||||||
|
- cursor;
|
||||||
|
- last push;
|
||||||
|
- last sync.
|
||||||
|
|
||||||
|
Пароль клиента не сохраняется.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Клиент использует POST JSON:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /v1/auth/register
|
||||||
|
POST /v1/auth/login
|
||||||
|
POST /v1/auth/logout
|
||||||
|
POST /v1/auth/refresh
|
||||||
|
POST /v1/sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Для авторизованных запросов используется:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <access-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
При HTTP 401 клиент один раз обновляет access token через refresh endpoint и повторяет запрос.
|
||||||
|
|
||||||
|
## Валидация server URL
|
||||||
|
|
||||||
|
Разрешены:
|
||||||
|
|
||||||
|
- `https://...`
|
||||||
|
- `http://localhost...`
|
||||||
|
- `http://127.0.0.1...`
|
||||||
|
- `http://[::1]...`
|
||||||
|
|
||||||
|
Удалённый HTTP запрещён.
|
||||||
|
|
||||||
|
При смене адреса сервера клиент:
|
||||||
|
- удаляет сессию;
|
||||||
|
- удаляет sync cursor/push state;
|
||||||
|
- требует новую авторизацию.
|
||||||
|
|
||||||
|
## Sync request
|
||||||
|
|
||||||
|
Логическая форма:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cursor": 123,
|
||||||
|
"deviceId": "UUID",
|
||||||
|
"changes": [],
|
||||||
|
"pull": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Для push `changes` заполнен, `pull=false`.
|
||||||
|
|
||||||
|
## Sync change
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entity": "transaction",
|
||||||
|
"id": "UUID",
|
||||||
|
"deleted": false,
|
||||||
|
"updatedAt": "ISO-8601",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Также сервер может возвращать `version` и `sequence`.
|
||||||
|
|
||||||
|
Поддерживаемые entity:
|
||||||
|
|
||||||
|
```text
|
||||||
|
transaction
|
||||||
|
category
|
||||||
|
budget
|
||||||
|
```
|
||||||
|
|
||||||
|
## Payloads
|
||||||
|
|
||||||
|
### transaction
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"amount": 1000,
|
||||||
|
"date": "2026-09-08T10:00:00.000Z",
|
||||||
|
"note": "Кофе",
|
||||||
|
"categoryName": "Кофе",
|
||||||
|
"categoryIcon": "cup.and.saucer.fill",
|
||||||
|
"categoryEmoji": "☕️",
|
||||||
|
"categoryColorName": "brown",
|
||||||
|
"isBalanceAdjustment": false,
|
||||||
|
"kindRawValue": "expense"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### category
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Кофе",
|
||||||
|
"icon": "cup.and.saucer.fill",
|
||||||
|
"emoji": "☕️",
|
||||||
|
"colorName": "brown",
|
||||||
|
"kindRawValue": "expense",
|
||||||
|
"createdAt": "2026-09-01T10:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### budget
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"categoryName": "Кофе",
|
||||||
|
"categoryIcon": "cup.and.saucer.fill",
|
||||||
|
"categoryEmoji": "☕️",
|
||||||
|
"limit": 5000,
|
||||||
|
"monthStart": "2026-09-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Даты кодируются как ISO-8601; клиент принимает варианты с fractional seconds и без них.
|
||||||
|
|
||||||
|
## Push
|
||||||
|
|
||||||
|
Клиент отправляет:
|
||||||
|
1. изменённые transactions;
|
||||||
|
2. изменённые categories;
|
||||||
|
3. изменённые budgets;
|
||||||
|
4. tombstones.
|
||||||
|
|
||||||
|
Размер batch — до 100 записей.
|
||||||
|
|
||||||
|
Если тело запроса превышает 1.5 MB, batch делится рекурсивно. Один payload больше 256 KB считается слишком большим.
|
||||||
|
|
||||||
|
## Pull
|
||||||
|
|
||||||
|
Клиент отправляет `pull=true` с cursor и применяет изменения по `sequence`.
|
||||||
|
|
||||||
|
После успешного применения сохраняется новый cursor.
|
||||||
|
|
||||||
|
Если сервер сообщает `hasMore=true`, клиент продолжает pagination до завершения.
|
||||||
|
|
||||||
|
Клиент защищается от:
|
||||||
|
- уменьшения cursor;
|
||||||
|
- `hasMore=true` без продвижения cursor.
|
||||||
|
|
||||||
|
## Conflict resolution
|
||||||
|
|
||||||
|
При применении входящего изменения запись обновляется только если:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local.syncUpdatedAt < remote.updatedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
Для удаления используется аналогичная проверка:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local.syncUpdatedAt <= remote.updatedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
Это простая last-write-wins стратегия на уровне timestamp.
|
||||||
|
|
||||||
|
## Deletions
|
||||||
|
|
||||||
|
Удаление локально:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
modelContext.deleteForSync(transaction)
|
||||||
|
```
|
||||||
|
|
||||||
|
создаёт запись в `SyncDeletionStore`.
|
||||||
|
|
||||||
|
При sync она отправляется как:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entity": "transaction",
|
||||||
|
"id": "...",
|
||||||
|
"deleted": true,
|
||||||
|
"updatedAt": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
После получения соответствующего server change tombstone удаляется из локального журнала.
|
||||||
|
|
||||||
|
## Полная пересинхронизация
|
||||||
|
|
||||||
|
`resetSynchronization()` очищает cursor и last-push timestamp текущего server/user context, после чего выполняет обычный sync.
|
||||||
|
|
||||||
|
Это не удаляет локальные финансовые записи автоматически.
|
||||||
|
|
||||||
|
## Автосинхронизация
|
||||||
|
|
||||||
|
Apple clients запускают sync:
|
||||||
|
- после авторизации;
|
||||||
|
- при запуске/появлении root view;
|
||||||
|
- затем примерно каждые 120 секунд, пока цикл жив;
|
||||||
|
- вручную из Settings.
|
||||||
|
|
||||||
|
Фактическая фоновая работа всё равно зависит от lifecycle ОС.
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
Клиент специально распознаёт `invalid_json` на `/v1/sync` и сообщает, что Balance Server нужно обновить до версии, совместимой с текущим архивом.
|
||||||
|
|
||||||
|
Клиент и сервер желательно обновлять согласованно.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Проект
|
||||||
|
|
||||||
|
**Balance Android** — нативный Android-клиент приложения Balance для учёта личных финансов. Он повторяет доменную модель iOS/macOS/watchOS-клиентов и синхронизируется с тем же self-hosted BalanceServer.
|
||||||
|
|
||||||
|
Стек:
|
||||||
|
- Kotlin 2.3.21
|
||||||
|
- Jetpack Compose
|
||||||
|
- Room
|
||||||
|
- Coroutines / Flow
|
||||||
|
- WorkManager
|
||||||
|
- Android Keystore
|
||||||
|
- minSdk 26 / compileSdk 36
|
||||||
|
- Java 17 bytecode
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```text
|
||||||
|
BalanceAndroid/
|
||||||
|
├── app/src/main/java/com/example/balanceandroid/
|
||||||
|
│ ├── data/ # Room entities, DAO, финансовая математика
|
||||||
|
│ ├── sync/ # HTTP API, session, preferences, sync repository/worker
|
||||||
|
│ ├── ui/ # Compose UI и theme
|
||||||
|
│ ├── MainActivity.kt
|
||||||
|
│ ├── MainViewModel.kt
|
||||||
|
│ └── BalanceApplication.kt
|
||||||
|
├── app/src/test/ # unit tests
|
||||||
|
├── app/src/main/res/
|
||||||
|
└── docs/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
### Data layer
|
||||||
|
|
||||||
|
- Все persisted финансовые сущности находятся в `data/`.
|
||||||
|
- Доступ к Room выполняется через `FinanceDao`.
|
||||||
|
- Не помещай HTTP или Compose-логику в DAO.
|
||||||
|
- Изменение Room entities требует проверки schema/migration.
|
||||||
|
- `updatedAt` — timestamp версии записи и обязателен для syncable entities.
|
||||||
|
|
||||||
|
### Финансовая логика
|
||||||
|
|
||||||
|
`FinanceMath` является единым источником правил расчёта.
|
||||||
|
|
||||||
|
- balance = income − expense;
|
||||||
|
- balance adjustments влияют на общий баланс;
|
||||||
|
- adjustments исключаются из периодической аналитики;
|
||||||
|
- spending группируется по `categoryName`.
|
||||||
|
|
||||||
|
При изменении правил обновляй `FinanceMathTest`.
|
||||||
|
|
||||||
|
### Удаления
|
||||||
|
|
||||||
|
Для синхронизируемых объектов нельзя использовать только прямой `DELETE`.
|
||||||
|
|
||||||
|
Используй DAO-методы:
|
||||||
|
- `deleteTransaction`
|
||||||
|
- `deleteCategory`
|
||||||
|
- `deleteBudget`
|
||||||
|
|
||||||
|
Они сначала создают запись в `deletion_journal`, затем удаляют объект.
|
||||||
|
|
||||||
|
### Категории
|
||||||
|
|
||||||
|
Категория хранится snapshot-полями в transactions/budgets. Поэтому при переименовании пользовательской категории текущая реализация обновляет связанные операции и бюджеты. Сохраняй это поведение.
|
||||||
|
|
||||||
|
### Sync
|
||||||
|
|
||||||
|
`SyncRepository` — единственная точка orchestration синхронизации.
|
||||||
|
|
||||||
|
- `ServerApi` отвечает только за HTTP/auth.
|
||||||
|
- `ServerPreferences` отвечает за URL, cursor и sync metadata.
|
||||||
|
- `SecureSessionStore` отвечает за зашифрованную сессию.
|
||||||
|
- `SyncWorker` запускает периодическую sync-задачу.
|
||||||
|
|
||||||
|
Не делай network calls непосредственно из Compose UI.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Access/refresh tokens не должны попадать в обычные SharedPreferences.
|
||||||
|
- Не логируй tokens/passwords/Authorization.
|
||||||
|
- Session хранится через AES/GCM с ключом Android Keystore.
|
||||||
|
- Для удалённого сервера нужен HTTPS.
|
||||||
|
- HTTP разрешён только локальным адресам, предусмотренным `ServerPreferences`.
|
||||||
|
- Не добавляй credentials или production secrets в репозиторий.
|
||||||
|
|
||||||
|
### Compose
|
||||||
|
|
||||||
|
- UI state идёт из `MainViewModel`.
|
||||||
|
- Данные Room наблюдаются через Flow.
|
||||||
|
- Общие компоненты — `ui/Components.kt`; feature screens — `ui/BalanceRoot.kt` и `ui/Editors.kt`.
|
||||||
|
- Не переносить бизнес-логику в composables.
|
||||||
|
|
||||||
|
### Sync invariants
|
||||||
|
|
||||||
|
При изменении syncable entity:
|
||||||
|
1. сохранить новое значение;
|
||||||
|
2. обновить `updatedAt`;
|
||||||
|
3. дождаться следующего sync.
|
||||||
|
|
||||||
|
При удалении:
|
||||||
|
1. создать deletion journal;
|
||||||
|
2. удалить локальную запись;
|
||||||
|
3. отправить tombstone на сервер.
|
||||||
|
|
||||||
|
Remote change применяется только если его `updatedAt` новее локального.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
Минимум:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед PR желательно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew clean test assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Если менялся sync/data layer, добавь unit tests для новых инвариантов.
|
||||||
|
|
||||||
|
## PR checklist
|
||||||
|
|
||||||
|
- [ ] Изменён правильный слой.
|
||||||
|
- [ ] Финансовая логика остаётся в `FinanceMath`.
|
||||||
|
- [ ] Изменения persisted schema учтены.
|
||||||
|
- [ ] `updatedAt` обновляется.
|
||||||
|
- [ ] Deletes создают tombstones.
|
||||||
|
- [ ] Sync не вызывается напрямую из UI.
|
||||||
|
- [ ] Нет секретов в репозитории/logcat.
|
||||||
|
- [ ] Unit tests проходят.
|
||||||
|
- [ ] README/docs обновлены при изменении setup/API.
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 1,
|
||||||
|
"identityHash": "e5e368788e85c41e4ad977ca43851521",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "transactions",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `amount` REAL NOT NULL, `date` INTEGER NOT NULL, `note` TEXT NOT NULL, `categoryName` TEXT NOT NULL, `categoryIcon` TEXT NOT NULL, `categoryEmoji` TEXT NOT NULL, `categoryColorName` TEXT NOT NULL, `isBalanceAdjustment` INTEGER NOT NULL, `kindRawValue` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "amount",
|
||||||
|
"columnName": "amount",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "date",
|
||||||
|
"columnName": "date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "note",
|
||||||
|
"columnName": "note",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categoryName",
|
||||||
|
"columnName": "categoryName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categoryIcon",
|
||||||
|
"columnName": "categoryIcon",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categoryEmoji",
|
||||||
|
"columnName": "categoryEmoji",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categoryColorName",
|
||||||
|
"columnName": "categoryColorName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isBalanceAdjustment",
|
||||||
|
"columnName": "isBalanceAdjustment",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "kindRawValue",
|
||||||
|
"columnName": "kindRawValue",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_transactions_updatedAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_transactions_date",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_date` ON `${TABLE_NAME}` (`date`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "categories",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `emoji` TEXT NOT NULL, `colorName` TEXT NOT NULL, `kindRawValue` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "icon",
|
||||||
|
"columnName": "icon",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "emoji",
|
||||||
|
"columnName": "emoji",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "colorName",
|
||||||
|
"columnName": "colorName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "kindRawValue",
|
||||||
|
"columnName": "kindRawValue",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_categories_updatedAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_categories_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_categories_name_kindRawValue",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name",
|
||||||
|
"kindRawValue"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_categories_name_kindRawValue` ON `${TABLE_NAME}` (`name`, `kindRawValue`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "budgets",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `categoryName` TEXT NOT NULL, `categoryIcon` TEXT NOT NULL, `categoryEmoji` TEXT NOT NULL, `limitAmount` REAL NOT NULL, `monthStart` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categoryName",
|
||||||
|
"columnName": "categoryName",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categoryIcon",
|
||||||
|
"columnName": "categoryIcon",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categoryEmoji",
|
||||||
|
"columnName": "categoryEmoji",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "limitAmount",
|
||||||
|
"columnName": "limitAmount",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "monthStart",
|
||||||
|
"columnName": "monthStart",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_budgets_updatedAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_budgets_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_budgets_monthStart",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"monthStart"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_budgets_monthStart` ON `${TABLE_NAME}` (`monthStart`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "deletion_journal",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `entity` TEXT NOT NULL, `recordId` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "entity",
|
||||||
|
"columnName": "entity",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recordId",
|
||||||
|
"columnName": "recordId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "updatedAt",
|
||||||
|
"columnName": "updatedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_deletion_journal_updatedAt",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_deletion_journal_updatedAt` ON `${TABLE_NAME}` (`updatedAt`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e5e368788e85c41e4ad977ca43851521')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Архитектура Balance Android
|
||||||
|
|
||||||
|
## Слои
|
||||||
|
|
||||||
|
```text
|
||||||
|
Jetpack Compose
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
MainViewModel
|
||||||
|
│
|
||||||
|
├──────────────► FinanceMath
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
FinanceDao ────────► Room / SQLite
|
||||||
|
│
|
||||||
|
└──────────────► SyncRepository
|
||||||
|
│
|
||||||
|
┌─────────┴─────────┐
|
||||||
|
▼ ▼
|
||||||
|
ServerApi SecureSessionStore
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
BalanceServer Android Keystore
|
||||||
|
```
|
||||||
|
|
||||||
|
## Application
|
||||||
|
|
||||||
|
`BalanceApplication` создаёт singleton-зависимости:
|
||||||
|
|
||||||
|
- `AppDatabase`
|
||||||
|
- `ServerPreferences`
|
||||||
|
- `SecureSessionStore`
|
||||||
|
- `SyncRepository`
|
||||||
|
|
||||||
|
Также регистрирует уникальный WorkManager job `balance-periodic-sync`.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
`MainActivity` запускает Compose root.
|
||||||
|
|
||||||
|
`BalanceRoot` содержит навигацию между:
|
||||||
|
- Обзор;
|
||||||
|
- Аналитика;
|
||||||
|
- Операции;
|
||||||
|
- Бюджеты;
|
||||||
|
- Настройки;
|
||||||
|
- Категории;
|
||||||
|
- Сервер и синхронизация.
|
||||||
|
|
||||||
|
`MainViewModel` объединяет Room flows, sync status/session и настройки в `FinanceUiState`.
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
Room database называется `balance-android.db`.
|
||||||
|
|
||||||
|
Entities:
|
||||||
|
- `TransactionEntity`
|
||||||
|
- `CategoryEntity`
|
||||||
|
- `BudgetEntity`
|
||||||
|
- `DeletionEntity`
|
||||||
|
|
||||||
|
`FinanceDao` предоставляет Flow для UI и suspend-операции для записи/sync.
|
||||||
|
|
||||||
|
## Sync
|
||||||
|
|
||||||
|
Sync двухфазный:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local changes → push
|
||||||
|
server changes → pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Push идёт пакетами до 100 изменений. Большой JSON (> 1.5 MB) рекурсивно разбивается.
|
||||||
|
|
||||||
|
Pull использует server cursor и `hasMore`.
|
||||||
|
|
||||||
|
Remote changes применяются атомарно через Room transaction.
|
||||||
|
|
||||||
|
## Conflict resolution
|
||||||
|
|
||||||
|
Стратегия — timestamp-based last-write-wins:
|
||||||
|
|
||||||
|
```text
|
||||||
|
remote.updatedAt > local.updatedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
Если локальная запись новее или равна, remote payload не заменяет её.
|
||||||
|
|
||||||
|
## Background sync
|
||||||
|
|
||||||
|
WorkManager запускается с:
|
||||||
|
- периодом 15 минут;
|
||||||
|
- `NetworkType.CONNECTED`.
|
||||||
|
|
||||||
|
Worker ничего не делает без server session/server URL.
|
||||||
|
|
||||||
|
Ограничения Android могут отложить фактический запуск; 15 минут — минимальный период WorkManager, а не гарантия точного времени запуска.
|
||||||
|
|
||||||
|
## Платформа
|
||||||
|
|
||||||
|
Android-клиент не использует CloudKit. Локальное хранение всегда Room; синхронизация с другими клиентами выполняется через BalanceServer.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Модель данных
|
||||||
|
|
||||||
|
## Room schema
|
||||||
|
|
||||||
|
### transactions
|
||||||
|
|
||||||
|
`TransactionEntity`:
|
||||||
|
|
||||||
|
| Поле | Тип | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | String | UUID |
|
||||||
|
| `amount` | Double | сумма |
|
||||||
|
| `date` | Long | epoch millis |
|
||||||
|
| `note` | String | заметка |
|
||||||
|
| `categoryName` | String | snapshot категории |
|
||||||
|
| `categoryIcon` | String | иконка |
|
||||||
|
| `categoryEmoji` | String | emoji |
|
||||||
|
| `categoryColorName` | String | цвет |
|
||||||
|
| `isBalanceAdjustment` | Boolean | корректировка |
|
||||||
|
| `kindRawValue` | String | `income`/`expense` |
|
||||||
|
| `updatedAt` | Long | версия для sync |
|
||||||
|
|
||||||
|
Индексы: `updatedAt`, `date`.
|
||||||
|
|
||||||
|
### categories
|
||||||
|
|
||||||
|
Пользовательские категории:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `icon`
|
||||||
|
- `emoji`
|
||||||
|
- `colorName`
|
||||||
|
- `kindRawValue`
|
||||||
|
- `createdAt`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
Индекс `(name, kindRawValue)` помогает находить категории по имени и типу.
|
||||||
|
|
||||||
|
### budgets
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `categoryName`
|
||||||
|
- `categoryIcon`
|
||||||
|
- `categoryEmoji`
|
||||||
|
- `limitAmount`
|
||||||
|
- `monthStart`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
### deletion_journal
|
||||||
|
|
||||||
|
Локальный журнал tombstones:
|
||||||
|
|
||||||
|
- `id = entity:recordId`
|
||||||
|
- `entity`
|
||||||
|
- `recordId`
|
||||||
|
- `updatedAt`
|
||||||
|
|
||||||
|
## Built-in categories
|
||||||
|
|
||||||
|
Expense:
|
||||||
|
- Продукты
|
||||||
|
- Транспорт
|
||||||
|
- Дом
|
||||||
|
- Развлечения
|
||||||
|
- Здоровье
|
||||||
|
- Покупки
|
||||||
|
- Подписки
|
||||||
|
- Образование
|
||||||
|
- Другое
|
||||||
|
|
||||||
|
Income:
|
||||||
|
- Зарплата
|
||||||
|
- Подработка
|
||||||
|
- Инвестиции
|
||||||
|
- Подарок
|
||||||
|
- Другое
|
||||||
|
|
||||||
|
Они не являются Room entities.
|
||||||
|
|
||||||
|
## Финансовые правила
|
||||||
|
|
||||||
|
### Общий баланс
|
||||||
|
|
||||||
|
```text
|
||||||
|
sum(income.amount) - sum(expense.amount)
|
||||||
|
```
|
||||||
|
|
||||||
|
`isBalanceAdjustment` здесь не исключается.
|
||||||
|
|
||||||
|
### Периодическая аналитика
|
||||||
|
|
||||||
|
`FinanceMath.summary` исключает:
|
||||||
|
- все balance adjustments;
|
||||||
|
- записи вне `[start, endExclusive)`.
|
||||||
|
|
||||||
|
### Spending
|
||||||
|
|
||||||
|
`FinanceMath.spending`:
|
||||||
|
- берёт только expenses;
|
||||||
|
- исключает adjustments;
|
||||||
|
- группирует по category name;
|
||||||
|
- сортирует по убыванию суммы.
|
||||||
|
|
||||||
|
## Category snapshot
|
||||||
|
|
||||||
|
Transaction и budget не имеют Room relationship на CategoryEntity. Они хранят presentation data непосредственно.
|
||||||
|
|
||||||
|
Это позволяет удалять пользовательскую категорию, не разрушая исторические операции.
|
||||||
|
|
||||||
|
Поэтому rename категории распространяется вручную на связанные transactions/budgets.
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# Разработка
|
||||||
|
|
||||||
|
## Toolchain
|
||||||
|
|
||||||
|
Текущая версия проекта:
|
||||||
|
|
||||||
|
- Android Studio Quail 1 (2026.1.1) или новее
|
||||||
|
- Gradle 8.13
|
||||||
|
- Android Gradle Plugin 8.13.0
|
||||||
|
- Kotlin 2.3.21
|
||||||
|
- Compose BOM 2026.06.00
|
||||||
|
- compileSdk 36
|
||||||
|
- minSdk 26
|
||||||
|
- Java 17 target
|
||||||
|
- Room 2.8.4
|
||||||
|
- Lifecycle 2.10.0
|
||||||
|
|
||||||
|
Проект не использует сторонние runtime-библиотеки вне стандартного Android/Jetpack stack.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Откройте `BalanceAndroid/` в Android Studio.
|
||||||
|
|
||||||
|
Затем:
|
||||||
|
|
||||||
|
1. Gradle Sync.
|
||||||
|
2. Установить Android SDK Platform 36.
|
||||||
|
3. Создать/подключить Android 8+ устройство или emulator.
|
||||||
|
4. Запустить configuration `app`.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Полная чистая проверка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew clean test assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверить JVM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Используется Java 17–23; bytecode target — Java 17.
|
||||||
|
|
||||||
|
## SDK
|
||||||
|
|
||||||
|
Можно использовать:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANDROID_HOME="/path/to/Android/Sdk"
|
||||||
|
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||||
|
```
|
||||||
|
|
||||||
|
или `local.properties`:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
sdk.dir=/home/USER/Android/Sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
`local.properties` не должен попадать в Git.
|
||||||
|
|
||||||
|
## Unit tests
|
||||||
|
|
||||||
|
Тесты находятся в:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/src/test/
|
||||||
|
```
|
||||||
|
|
||||||
|
Основной текущий тестовый класс:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FinanceMathTest
|
||||||
|
```
|
||||||
|
|
||||||
|
Тестируй отдельно:
|
||||||
|
- balance;
|
||||||
|
- period summary;
|
||||||
|
- adjustment exclusion;
|
||||||
|
- spending grouping;
|
||||||
|
- edge cases around period boundaries.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
Приложение работает без сервера. Локальные данные хранятся в Room.
|
||||||
|
|
||||||
|
Для Android Emulator host server:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://10.0.2.2:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Для физического Android device `localhost` указывает на сам телефон, поэтому нужен доступный по сети HTTPS endpoint.
|
||||||
|
|
||||||
|
## Application ID
|
||||||
|
|
||||||
|
Текущий пример:
|
||||||
|
|
||||||
|
```text
|
||||||
|
com.example.balanceandroid
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед Google Play release замени его на уникальный production application ID.
|
||||||
|
|
||||||
|
## Release checklist
|
||||||
|
|
||||||
|
- [ ] Production applicationId.
|
||||||
|
- [ ] Release signing key.
|
||||||
|
- [ ] HTTPS server.
|
||||||
|
- [ ] Проверен backup/data extraction policy.
|
||||||
|
- [ ] Проверена миграция Room.
|
||||||
|
- [ ] Проверена background sync.
|
||||||
|
- [ ] Проверена авторизация и token refresh.
|
||||||
|
- [ ] Нет секретов в APK source/config.
|
||||||
|
- [ ] `./gradlew clean test assembleDebug` проходит.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Gradle не находит SDK
|
||||||
|
|
||||||
|
Проверь `ANDROID_HOME`, `ANDROID_SDK_ROOT` или `local.properties`.
|
||||||
|
|
||||||
|
### Неверная Java
|
||||||
|
|
||||||
|
Android Studio → Gradle JDK → Embedded JDK.
|
||||||
|
|
||||||
|
Нужна Java 17–23.
|
||||||
|
|
||||||
|
### Старый Gradle daemon
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew --stop
|
||||||
|
./gradlew clean test assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sync не работает
|
||||||
|
|
||||||
|
Проверь:
|
||||||
|
1. server URL;
|
||||||
|
2. HTTPS/local HTTP rule;
|
||||||
|
3. login/session;
|
||||||
|
4. network connection;
|
||||||
|
5. cursor/lastPush;
|
||||||
|
6. совместимость BalanceServer protocol.
|
||||||
|
|
||||||
|
### Данные не удаляются на другом устройстве
|
||||||
|
|
||||||
|
Убедись, что вызывается `FinanceDao.deleteTransaction/deleteCategory/deleteBudget`, а не `delete*Direct`.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Функциональная документация
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Balance Android — локально-first приложение личных финансов.
|
||||||
|
|
||||||
|
Основные функции:
|
||||||
|
- доходы;
|
||||||
|
- расходы;
|
||||||
|
- категории;
|
||||||
|
- бюджеты;
|
||||||
|
- аналитика;
|
||||||
|
- корректировка баланса;
|
||||||
|
- server sync;
|
||||||
|
- тёмная/светлая тема;
|
||||||
|
- несколько валют.
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Показывает:
|
||||||
|
- общий баланс;
|
||||||
|
- доходы текущего месяца;
|
||||||
|
- расходы текущего месяца;
|
||||||
|
- расходы по категориям;
|
||||||
|
- последние операции.
|
||||||
|
|
||||||
|
## Операции
|
||||||
|
|
||||||
|
Поддерживаются:
|
||||||
|
- добавление;
|
||||||
|
- редактирование;
|
||||||
|
- удаление;
|
||||||
|
- поиск;
|
||||||
|
- фильтрация по income/expense;
|
||||||
|
- дата;
|
||||||
|
- сумма;
|
||||||
|
- категория;
|
||||||
|
- заметка.
|
||||||
|
|
||||||
|
## Корректировка
|
||||||
|
|
||||||
|
Пользователь задаёт целевой фактический баланс.
|
||||||
|
|
||||||
|
Приложение вычисляет:
|
||||||
|
|
||||||
|
```text
|
||||||
|
difference = target - current
|
||||||
|
```
|
||||||
|
|
||||||
|
и создаёт специальную transaction с `isBalanceAdjustment=true`.
|
||||||
|
|
||||||
|
Корректировка меняет общий баланс, но не включается в периодическую аналитику.
|
||||||
|
|
||||||
|
## Аналитика
|
||||||
|
|
||||||
|
Периоды:
|
||||||
|
- месяц;
|
||||||
|
- 3 месяца;
|
||||||
|
- 12 месяцев.
|
||||||
|
|
||||||
|
Показываются:
|
||||||
|
- доходы;
|
||||||
|
- расходы;
|
||||||
|
- результат;
|
||||||
|
- savings rate;
|
||||||
|
- структура расходов по категориям.
|
||||||
|
|
||||||
|
## Бюджеты
|
||||||
|
|
||||||
|
Можно задать месячный лимит для категории.
|
||||||
|
|
||||||
|
Бюджет хранится отдельно и синхронизируется с сервером.
|
||||||
|
|
||||||
|
## Категории
|
||||||
|
|
||||||
|
Можно создавать и редактировать пользовательские категории.
|
||||||
|
|
||||||
|
Поддерживаются:
|
||||||
|
- имя;
|
||||||
|
- тип;
|
||||||
|
- иконка;
|
||||||
|
- emoji;
|
||||||
|
- цвет.
|
||||||
|
|
||||||
|
Есть 50 вариантов иконок, 30 готовых emoji и палитра из 24 цветов согласно текущему UI.
|
||||||
|
|
||||||
|
## Настройки
|
||||||
|
|
||||||
|
- RUB;
|
||||||
|
- EUR;
|
||||||
|
- USD;
|
||||||
|
- SEK;
|
||||||
|
- system/light/dark;
|
||||||
|
- server URL;
|
||||||
|
- регистрация/login/logout;
|
||||||
|
- ручная sync;
|
||||||
|
- reset sync.
|
||||||
|
|
||||||
|
## Offline-first
|
||||||
|
|
||||||
|
Все основные операции выполняются локально через Room. Сервер нужен только для авторизации и межустройственной синхронизации.
|
||||||
|
|
||||||
|
## Background sync
|
||||||
|
|
||||||
|
При наличии аккаунта и server URL WorkManager пытается синхронизировать данные примерно каждые 15 минут при наличии сети.
|
||||||
|
|
||||||
|
Точное время зависит от Android WorkManager и системных ограничений батареи/фоновой работы.
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# Серверная синхронизация
|
||||||
|
|
||||||
|
Android-клиент использует тот же self-hosted BalanceServer, что и остальные клиенты Balance.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
POST endpoints:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/v1/auth/register
|
||||||
|
/v1/auth/login
|
||||||
|
/v1/auth/logout
|
||||||
|
/v1/auth/refresh
|
||||||
|
/v1/sync
|
||||||
|
```
|
||||||
|
|
||||||
|
Авторизованный запрос:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <access-token>
|
||||||
|
Content-Type: application/json; charset=utf-8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session
|
||||||
|
|
||||||
|
`ServerSession` содержит:
|
||||||
|
|
||||||
|
- access token;
|
||||||
|
- refresh token;
|
||||||
|
- expiresAt;
|
||||||
|
- userId;
|
||||||
|
- email.
|
||||||
|
|
||||||
|
`SecureSessionStore` сериализует session JSON и шифрует его AES/GCM.
|
||||||
|
|
||||||
|
AES key создаётся и хранится в Android Keystore.
|
||||||
|
|
||||||
|
При ошибке расшифровки store очищается.
|
||||||
|
|
||||||
|
## Server URL
|
||||||
|
|
||||||
|
`ServerPreferences.normalizeServer` принимает полный URL без:
|
||||||
|
- user info;
|
||||||
|
- query;
|
||||||
|
- fragment.
|
||||||
|
|
||||||
|
Разрешены HTTPS и локальный HTTP.
|
||||||
|
|
||||||
|
HTTP допускается для:
|
||||||
|
- `localhost`
|
||||||
|
- `127.0.0.1`
|
||||||
|
- `10.0.2.2`
|
||||||
|
|
||||||
|
`10.0.2.2` — специальный адрес Android Emulator для host machine.
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Перед авторизованным запросом клиент проверяет expiry.
|
||||||
|
|
||||||
|
Если осталось менее 60 секунд, выполняется refresh.
|
||||||
|
|
||||||
|
При HTTP 401 клиент один раз refresh-ит session и повторяет исходный запрос.
|
||||||
|
|
||||||
|
Если refresh не удался, session очищается.
|
||||||
|
|
||||||
|
## Sync request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cursor": 0,
|
||||||
|
"deviceId": "UUID",
|
||||||
|
"changes": [],
|
||||||
|
"pull": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Push использует тот же endpoint с `pull=false`.
|
||||||
|
|
||||||
|
## Change envelope
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entity": "transaction",
|
||||||
|
"id": "UUID",
|
||||||
|
"deleted": false,
|
||||||
|
"updatedAt": "2026-09-08T10:00:00Z",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Entities:
|
||||||
|
|
||||||
|
```text
|
||||||
|
transaction
|
||||||
|
category
|
||||||
|
budget
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transaction payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"amount": 1000,
|
||||||
|
"date": "2026-09-08T10:00:00Z",
|
||||||
|
"note": "Кофе",
|
||||||
|
"categoryName": "Кофе",
|
||||||
|
"categoryIcon": "cup.and.saucer.fill",
|
||||||
|
"categoryEmoji": "☕️",
|
||||||
|
"categoryColorName": "brown",
|
||||||
|
"isBalanceAdjustment": false,
|
||||||
|
"kindRawValue": "expense"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Category payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Кофе",
|
||||||
|
"icon": "cup.and.saucer.fill",
|
||||||
|
"emoji": "☕️",
|
||||||
|
"colorName": "brown",
|
||||||
|
"kindRawValue": "expense",
|
||||||
|
"createdAt": "2026-09-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Budget payload
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"categoryName": "Кофе",
|
||||||
|
"categoryIcon": "cup.and.saucer.fill",
|
||||||
|
"categoryEmoji": "☕️",
|
||||||
|
"limit": 5000,
|
||||||
|
"monthStart": "2026-09-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Push
|
||||||
|
|
||||||
|
Изменения выбираются по `updatedAt` между `lastPush` и cutoff.
|
||||||
|
|
||||||
|
Batch size: 100.
|
||||||
|
|
||||||
|
Если JSON batch больше 1.5 MB, он рекурсивно делится.
|
||||||
|
|
||||||
|
## Pull
|
||||||
|
|
||||||
|
Клиент отправляет текущий cursor.
|
||||||
|
|
||||||
|
Сервер возвращает:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cursor": 42,
|
||||||
|
"changes": [],
|
||||||
|
"hasMore": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `hasMore=true`, клиент повторяет pull с новым cursor.
|
||||||
|
|
||||||
|
Есть защитные проверки:
|
||||||
|
- cursor не может уменьшиться;
|
||||||
|
- `hasMore=true` должен сопровождаться продвижением cursor.
|
||||||
|
|
||||||
|
## Applying remote changes
|
||||||
|
|
||||||
|
Изменения сортируются по `sequence`.
|
||||||
|
|
||||||
|
Для обычного изменения:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apply remote iff remote.updatedAt > local.updatedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
Для удаления:
|
||||||
|
|
||||||
|
```text
|
||||||
|
delete iff local.updatedAt <= remote.updatedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
После применения соответствующий deletion journal очищается.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Cursor и lastPush привязаны к:
|
||||||
|
|
||||||
|
```text
|
||||||
|
serverUrl + userId
|
||||||
|
```
|
||||||
|
|
||||||
|
Это позволяет иметь независимое состояние sync для разных серверов/аккаунтов.
|
||||||
|
|
||||||
|
## Reset
|
||||||
|
|
||||||
|
`resetAndSync()` очищает cursor и lastPush текущего scope, затем запускает обычную sync.
|
||||||
|
|
||||||
|
Локальные финансовые данные при этом не удаляются.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
`SyncWorker`:
|
||||||
|
- запускается WorkManager;
|
||||||
|
- требует network connection;
|
||||||
|
- пропускает работу без session/server URL;
|
||||||
|
- возвращает `retry`, если sync завершилась ошибкой.
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
README проекта указывает совместимость Android-клиента с BalanceServer protocol 3.1. При изменении контракта сервера необходимо синхронно обновлять `ServerApi`, `SyncRepository` и документацию.
|
||||||
@@ -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/
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Appearance update 4.1
|
||||||
|
|
||||||
|
Flutter supports system/light/dark mode plus five persisted presets (forest, indigo, ocean, rose, OLED). Selecting OLED initially enables dark mode; system mode subsequently follows OS brightness. Pure-black surfaces are checked both as theme colors and actual rendered pixels. Total Flutter tests: 62 passing. The native Watch client retains its existing appearance controls.
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# Баланс — Flutter / Material 3
|
||||||
|
|
||||||
|
Личный бюджет с локальным хранилищем и синхронизацией с существующим `BalanceServer`. Сервер и его протокол не переписаны. CloudKit удалён по запросу: данные синхронизируются только через Go-сервер.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Доходы и расходы: создание, редактирование, удаление, дата/время, заметки, поиск, фильтр типа.
|
||||||
|
- Общий баланс, ручная корректировка отдельной операцией без влияния на аналитику.
|
||||||
|
- Аналитика за 1, 3 и 12 календарных месяцев, доходы/расходы, результат, сбережения, средний расход, крупнейшая категория, динамика и структура расходов.
|
||||||
|
- Месячные бюджеты по категориям, остаток и превышение; выбор месяца.
|
||||||
|
- Встроенные и собственные категории, 50 значков Material с совместимыми серверными именами, 30 эмодзи и собственный эмодзи, 24 цвета. Переименование обновляет связанные записи; удаление сохраняет историю.
|
||||||
|
- RUB, EUR, USD, SEK; системная, светлая и тёмная тема; пресеты Лес, Индиго, Океан, Роза и OLED; CSV-экспорт.
|
||||||
|
- OLED использует чистый чёрный фон `#000000` в тёмном режиме (включая карточки, навигацию и диалоги). При выборе OLED включается тёмная тема; затем можно выбрать системный режим. Режим и пресет сохраняются локально.
|
||||||
|
- Локальная работа без аккаунта; сервер, регистрация, вход, выход, refresh, ручная и автоматическая синхронизация, полная пересинхронизация.
|
||||||
|
- Транзакционное сохранение данных и курсора, очередь изменений и удалений, повтор после сетевого сбоя; отдельные хранилища аккаунтов.
|
||||||
|
- Адаптивная навигация: нижняя панель на телефоне, боковая на широком экране.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
OLED-пресет:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Платформы
|
||||||
|
|
||||||
|
| Платформа | Реализация / сборка |
|
||||||
|
| --- | --- |
|
||||||
|
| 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`.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Проверки переноса
|
||||||
|
|
||||||
|
Дата: 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, даты обновления, корректировки, категории, бюджеты и удаления совместимы с исходным сервером.
|
||||||
|
|
||||||
|
## Обновление 4.1 — режимы и пресеты
|
||||||
|
|
||||||
|
Добавлены Лес, Индиго, Океан, Роза и OLED. Режим (system/light/dark) и пресет сохраняются независимо в локальных настройках. При выборе OLED сначала включается dark; затем разрешено выбрать system. В тёмном OLED все основные Material surfaces, карточки, app bar, навигация и диалоги используют `#000000`, overlay tint отключён.
|
||||||
|
|
||||||
|
Общий запуск после изменений: 62 Flutter-теста PASS; анализатор No issues found. Новые проверки: точные цвета OLED, разные акценты пресетов, сохранение/перезапуск, реакция системного режима на OS brightness, выбор на экране 320px и проверка чёрных пикселей снимков 390/1280px.
|
||||||
|
|
||||||
|
APK 4.1 использует новый тестовый ключ, поскольку временный ключ предыдущей тестовой сборки не сохранился после смены среды. Прямая установка поверх APK 4.0 с другой подписью невозможна. Перед заменой старой тестовой сборки синхронизируйте её с Go-сервером и убедитесь в сохранности данных. Для последующих сборок ключ 4.1 сохранён отдельно от репозитория и исходного архива. Для публикации нужен собственный release keystore.
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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.
|
||||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -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 "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 978 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||