Update plan

This commit is contained in:
2026-05-27 11:03:34 +03:00
parent 985896f2dd
commit cbabfb0972
+152 -107
View File
@@ -1,20 +1,21 @@
# Каркас Flutter-приложения для учёта личных финансов (Android)
# План разработки Flutter-приложения для учёта личных финансов (Android)
## Контекст
Стартует новый проект (`C:\Sanders\Flutter\NewBudget` — пустая директория, Flutter 3.35.1 / Dart 3.9.0).
Цель — заложить **качественную структуру каркаса (скелета) без реализации фич**: сущности, таблицы,
контракты репозиториев, провайдеры, роутинг, тема, заглушки экранов. После этого `flutter run` должен
запускаться и показывать плейсхолдер-экраны, а добавление реальной логики сводилось бы к заполнению
заранее подготовленных слоёв.
Каркас приложения собран и запускается (`C:\Sanders\Flutter\NewBudget`, Flutter 3.35.1 / Dart 3.9.0).
Главный экран отрисовывается на **мок-данных** — следующий этап работы переключить UI на реальные
стримы из Drift и закрыть оставшиеся экраны.
Решения, согласованные с пользователем:
- **БД:** Drift (SQLite) реляционные связи account→transaction→category, реактивные стримы, миграции, SQL-агрегаты.
- **Riverpod:** кодогенерация (`@riverpod` + `riverpod_generator` + `build_runner`).
Зафиксированные технические решения:
- **БД:** Drift (SQLite), реляционные связи account→transaction→category, реактивные стримы.
- **State:** Riverpod с кодогенерацией (`@riverpod` + `riverpod_generator` + `build_runner`).
- **Навигация:** `go_router` + `StatefulShellRoute.indexedStack` (нижний таб-бар на 4 ветки).
- **Локализация:** `flutter_localizations` + ARB через `flutter gen-l10n` (см. `lib/l10n/`).
- **Тема:** свой `Palette` extension (`app_colors.dart`) поверх `Theme.of(context)`.
- **Пользователи:** несколько локальных профилей; все доменные таблицы ссылаются на `userId`.
- **Архитектура:** чёткое разделение отображения (UI) от логики работы (см. ниже).
- **Активный пользователь:** key-value таблица `app_preferences` (`settings_table.dart`), а не `shared_preferences`.
## Архитектура: разделение UI и логики
## Архитектура: разделение UI и логики (НЕ МЕНЯТЬ)
Каркас строится по **feature-first** с явными слоями внутри каждой фичи. Зависимости направлены строго
внутрь (presentation → application → domain ← data), внешние слои не знают о Drift:
@@ -33,133 +34,177 @@ presentation → application → domain ← data
Никаких зависимостей от Flutter/Drift. Это контракт между логикой и данными.
- **data** — реализации репозиториев + Drift (таблицы, DAO, мапперы row↔entity). Только здесь пишется SQL.
Так «фронт» (presentation) физически отделён от «логики» (application) и от «данных» (data): UI можно
менять, не трогая логику; источник данных (Drift) можно заменить, не трогая UI и логику — достаточно дать
новую реализацию интерфейса из `domain`. Слой usecase-классов сознательно **опускаем** — для приложения
такого размера контроллеры вызывают репозитории напрямую (прагматичный baseline, без лишних абстракций).
Слой usecase-классов сознательно **опускаем** — контроллеры вызывают репозитории напрямую.
## Структура каталогов
## Структура каталогов (фактическая)
```
lib/
main.dart # точка входа: runApp(ProviderScope(child: App()))
main.dart # runApp(ProviderScope(child: NewBudgetApp()))
l10n/ # сгенерированные AppLocalizations (flutter gen-l10n)
src/
app/
app.dart # MaterialApp.router, тема, локализация
app.dart # MaterialApp.router, тема, локализация
l10n/l10n.dart # context.l10n extension + re-export
router/
app_router.dart # go_router (провайдер конфигурации)
app_routes.dart # константы путей/имён
app_router.dart # GoRouter, StatefulShellRoute (4 ветки)
app_routes.dart # константы путей
theme/
app_theme.dart # light/dark ThemeData
app_colors.dart
core/ # инфраструктура, без бизнес-логики фич
app_theme.dart # light/dark ThemeData
app_colors.dart # Palette extension (paper/ink/line/accent/positive/negative)
theme_mode_controller.dart # @Riverpod(keepAlive) ThemeMode — пока in-memory
core/
database/
app_database.dart # @DriftDatabase, schemaVersion, миграции (stub)
tables/ # users/accounts/categories/transactions (Drift Tables)
daos/ # *_dao.dart — реактивные запросы (watch/insert/update)
converters/ # TypeConverter для enum, денег, дат
providers/
database_provider.dart # @Riverpod(keepAlive) AppDatabase
money/
money.dart # хранение в минорных единицах (int), форматирование
errors/
failures.dart
app_database.dart # @DriftDatabase, schemaVersion=1
tables/ # users / settings / accounts / categories / transactions
# + AppPreferencesTable (key-value)
daos/ # *_dao.dart с .watch()-методами
converters/enum_converters.dart # TypeConverter для enum + ре-экспорт enum-ов
providers/database_provider.dart # @Riverpod(keepAlive) AppDatabase
money/money.dart # хранение в минорных единицах (int)
errors/failures.dart
constants/
features/
user/
domain/
entities/user.dart
repositories/user_repository.dart # abstract
data/
mappers/user_mapper.dart
repositories/user_repository_impl.dart # реализация поверх UsersDao
user/ (domain / data / application — реализованы; presentation пуст)
application/
user_providers.dart # провайдер репозитория (DI)
active_user_controller.dart # текущий выбранный профиль
users_controller.dart # список/создание профилей
presentation/
screens/ # заглушки
widgets/
settings/ # (та же структура: настройки на профиль — валюта, тема, локаль)
accounts/ # (та же структура)
categories/ # (та же структура)
transactions/ # (та же структура)
user_providers.dart # provider репозитория (DI)
users_controller.dart # CRUD профилей
active_user_controller.dart # текущий активный профиль (через app_preferences)
settings/ (domain / data / application — реализованы; presentation пуст)
accounts/ (domain / data / application — реализованы; presentation — placeholder)
categories/ (domain / data / application — реализованы; presentation пуст)
transactions/ (domain / data / application — реализованы; presentation пуст)
home/ presentation/
screens/home_screen.dart # ConsumerWidget, собирает виджеты ниже
widgets/ # MonthHeader, AccountTabs, MonthKpiCard, CategoryDonutCard,
# TransactionsSection, DayHeader, TxRow, MoneyText, FabAddTransaction
state/selected_category_filter.dart # фильтры (account/category) — @riverpod
month_summary.dart # агрегаты для KPI/донат — СЧИТАЮТСЯ НА КЛИЕНТЕ
_mock_data.dart # ВРЕМЕННЫЕ моки + iconForCategory/iconForAccount
analytics/ presentation/screens/ # placeholder
profile/ presentation/screens/ # переключатель темы + placeholder
shared/
widgets/ # общие виджеты (AppScaffold, EmptyState, ...)
formatters/ # форматирование валюты/дат (intl)
widgets/
app_scaffold.dart # обёртка StatefulShellRoute + AppBottomNav
app_bottom_nav.dart
placeholder_screen.dart
formatters/ # пусто (планируется intl-форматирование)
```
Каждая фича повторяет один и тот же шаблон `domain/ data/ application/ presentation/`. В скелете методы
репозиториев/DAO определены сигнатурами, реализации минимальны или содержат `TODO`/`UnimplementedError`,
экраны — плейсхолдеры.
## Модель данных (Drift, schemaVersion=1)
## Модель данных (Drift)
Деньги хранятся как **целые минорные единицы** (копейки/центы) в `int`. Идентификаторы — `int autoIncrement`.
Все доменные таблицы имеют `userId` (FK → users).
Деньги хранятся как **целые минорные единицы** (копейки/центы) в `int` — чтобы избежать ошибок float.
Идентификаторы — `int autoIncrement` (просто для локального оффлайна; для будущей облачной синхронизации
можно перейти на UUID/text — отмечено как развилка). Все доменные таблицы имеют `userId` (FK → users).
- **users**: `id`, `name`, `createdAt`. Активный профиль хранится отдельно (настройка), не флагом в строке.
- **settings** (на пользователя): `userId` (FK), `baseCurrency`, `themeMode` (enum), `locale`,
`firstDayOfMonth`. Хранит «активного пользователя» — либо отдельная key-value таблица `app_preferences`.
- **users**: `id`, `name`, `createdAt`.
- **app_preferences**: `key` (PK), `value`. Хранит, в частности, `active_user_id`.
- **settings** (на пользователя): `userId` (FK), `baseCurrency`, `themeMode` (enum), `locale`, `firstDayOfMonth`.
- **accounts**: `id`, `userId` (FK), `name`, `type` (enum: cash/card/bank/savings), `currency`,
`initialBalance` (int), `iconCode`, `colorValue`, `archived`, `createdAt`.
- **categories**: `id`, `userId` (FK), `name`, `type` (enum: income/expense), `iconCode`, `colorValue`,
`parentId` (nullable, для подкатегорий), `archived`.
`parentId` (nullable), `archived`.
- **transactions**: `id`, `userId` (FK), `accountId` (FK), `categoryId` (FK, nullable),
`type` (enum: income/expense/transfer), `amount` (int, минорные единицы), `date`, `note` (nullable),
`transferToAccountId` (nullable, для переводов), `createdAt`.
`transferToAccountId` (nullable), `createdAt`.
Для каждой таблицы — соответствующая чистая сущность в `domain/entities` (immutable, через `freezed`) и
маппер в `data/mappers`. Enum'ы кодируются Drift `TypeConverter`'ами в `core/database/converters`.
Чистые сущности в `domain/entities` (immutable, `freezed`), мапперы в `data/mappers`, enum'ы — через
Drift `TypeConverter` в `core/database/converters/enum_converters.dart` (этот файл также реэкспортирует
enum'ы — UI берёт их оттуда, не из Drift-таблиц).
DAO (`core/database/daos`) предоставляют реактивные методы (`Stream` через `.watch()`), например:
`watchAccountsByUser(userId)`, `watchTransactions(filter)`, агрегаты `watchAccountBalance(accountId)`,
`watchTotalsByCategory(period)`. В скелете — сигнатуры + базовые запросы, сложные агрегаты как `TODO`.
DAO предоставляют реактивные методы (`Stream` через `.watch()`): `watchByUser`, `watchTransactions(filter)`,
`watchAccountBalance(accountId)`. Сложные агрегаты по категориям/периоду пока считаются на клиенте
(см. `home/presentation/month_summary.dart`) — позже стоит вынести в SQL-агрегаты DAO.
## Зависимости (pubspec.yaml)
## Зависимости (фактические в pubspec.yaml)
Runtime:
- `flutter_riverpod`, `riverpod_annotation`
- `drift`, `drift_flutter` (открытие БД на Android, путь через path_provider под капотом)
- `go_router`
- `intl` (форматирование валюты/дат)
- `freezed_annotation` (immutable-сущности)
Runtime: `flutter_riverpod`, `riverpod_annotation`, `drift`, `drift_flutter`, `go_router`, `intl`,
`freezed_annotation`, `json_annotation`, `google_fonts`, `fl_chart`.
Dev:
- `build_runner`
- `riverpod_generator`, `riverpod_lint`, `custom_lint`
- `drift_dev`
- `freezed`
- `flutter_lints` (или `very_good_analysis`)
Dev: `build_runner`, `riverpod_generator`, `drift_dev`, `freezed`, `json_serializable`, `flutter_lints`.
`analysis_options.yaml` подключает `custom_lint` (для riverpod_lint) и исключает `*.g.dart`/`*.freezed.dart`
из анализа.
> `riverpod_lint` / `custom_lint` НЕ подключены (отступление от исходного плана). Если решим вернуть —
> добавить в dev_deps и подключить `custom_lint` в `analysis_options.yaml`.
## Последовательность сборки каркаса
## Статус каркаса
1. `flutter create . --org com.example --platforms=android` в текущей директории (генерирует Android-обвязку).
2. Прописать зависимости в `pubspec.yaml`, `flutter pub get`.
3. `core/database`: таблицы → конвертеры → `AppDatabase` (schemaVersion=1, пустая стратегия миграций) → DAO.
4. `core/providers/database_provider.dart` — провайдер `AppDatabase` (keepAlive).
5. По каждой фиче, по шаблону: `domain` (entity + abstract repo) → `data` (mapper + repo impl на DAO) →
`application` (провайдер репозитория + контроллеры) → `presentation` (экраны-заглушки).
6. `app/`: тема, `go_router` (маршруты: выбор профиля, дашборд, счета, категории, транзакции,
добавление/редактирование транзакции, настройки), `app.dart`, `main.dart`.
7. `dart run build_runner build --delete-conflicting-outputs` — генерация `*.g.dart` / `*.freezed.dart`.
### Готово
- [x] Структура каталогов, все 5 фич имеют `domain/data/application`.
- [x] Drift: таблицы, DAO с `watch*`-методами, конвертеры enum, миграция onCreate.
- [x] `AppDatabase` и `appDatabaseProvider` (keepAlive).
- [x] Доменные сущности (User/Settings/Account/Category/Transaction) + мапперы + impl-репозитории.
- [x] Контроллеры: `usersController`, `activeUserController`, `accountsController`,
`categoriesController`, `transactionsController`, `settingsController`.
- [x] Roвтинг (`StatefulShellRoute` + 4 таба), `AppScaffold`, `AppBottomNav`.
- [x] Тема (`Palette` extension, light/dark), `themeModeController` (in-memory).
- [x] Локализация ru/en (ARB → `flutter gen-l10n``lib/l10n/`), `context.l10n`.
- [x] Главный экран (`HomeScreen`): MonthHeader, AccountTabs, MonthKpiCard, CategoryDonutCard,
TransactionsSection с группировкой по дням, FAB (no-op).
## Проверка
### НЕ готово (приоритет сверху вниз)
- `flutter pub get` и `dart run build_runner build` проходят без ошибок (кодогенерация Drift+Riverpod+Freezed).
- `flutter analyze` — без ошибок (с учётом исключений для сгенерированных файлов).
- `flutter run` на Android-эмуляторе/устройстве: приложение запускается, открывается стартовый экран
(выбор/создание профиля → дашборд), навигация между экранами-заглушками работает, БД инициализируется
без падений.
- Каркас считается готовым, когда добавление реальной фичи требует только: запрос в DAO → метод в repo impl →
метод в контроллере → отображение в экране, не затрагивая остальные слои.
1. **Переключить Home с моков на DAO-стримы.**
- Сейчас `home/presentation/_mock_data.dart` экспортирует `mockAccountsProvider`,
`mockCategoriesProvider`, `mockTransactionsProvider` — на них завязаны все виджеты Home.
- Заменить вызовы `ref.watch(mockXxxProvider)` на соответствующие `*Stream`-провайдеры
из `application/`, обернув в `AsyncValue.when(...)`.
- `iconForCategory(c)` / `iconForAccount(a)` / `shortAccountLabel(a)` из `_mock_data.dart`
это не моки, а маппинг enum/iconCode → IconData. Их надо вынести в
`shared/formatters/` или `features/{accounts,categories}/presentation/widgets/icon_for_*.dart`,
после чего удалить `_mock_data.dart` целиком.
- `month_summary.dart` сейчас считает агрегаты в Provider на клиенте. После перехода на стримы —
либо оставить (источник `transactionsStream`), либо вынести в SQL-агрегаты DAO
(`watchAccountBalance`, `watchTotalsByCategory`).
## Открытые развилки (на будущее, вне скелета)
2. **Активный пользователь во всём UI.**
- В `_mock_data.dart` зашит `mockUserId = 1`. После п.1 надо тянуть `userId` из
`activeUserControllerProvider` и прокидывать его в `accountsStream(userId)`,
`transactionsStream(userId)` и т.д.
- Onboarding: первый запуск → создать профиль (`usersController.createUser`) →
`activeUserController.setActiveUser`. Если активный пользователь null — редирект на экран профилей
(`go_router` redirect-callback).
- ID: `int autoIncrement` сейчас vs `UUID/text` при появлении облачной синхронизации.
- «Активный пользователь»: отдельная таблица `app_preferences` vs `shared_preferences`.
- Переводы между счетами: одна запись с `transferToAccountId` vs парные транзакции.
3. **Add/Edit Transaction.**
- FAB на Home сейчас `onPressed: () {}`. Маршрут `/transactions/new` + bottom-sheet или экран:
выбор счёта, типа, категории, суммы, даты, заметки. На сабмит — `transactionsController.createTransaction`.
4. **Экраны фич:**
- **Accounts** (`AccountsScreen` сейчас placeholder): список через `accountsStream`, баланс через
`accountBalance`, CRUD через `accountsController`.
- **Analytics**: графики по `fl_chart` (расходы/доходы по месяцам, по категориям). Источник — те же
стримы транзакций + агрегация.
- **Profile**: список профилей (`usersStream`), переключение активного, переименование/удаление,
настройки (валюта, локаль, первый день месяца) через `settingsController`.
5. **Settings ↔ Theme/Locale.**
- Сейчас `themeModeController` хранит ThemeMode в памяти. Подменить на чтение/запись через
`settingsController` (поля `themeMode`, `locale`) для активного пользователя.
- `MaterialApp.router` должен брать `locale` из настроек.
6. **Переводы между счетами.**
- В `Transaction.transferToAccountId` поле есть. UI и контроллер `createTransaction` принимают,
но баланс счёта-получателя пока не учитывает их в `MonthSummary` (см. `case transfer: break;`).
Решить, как считать: одна запись с `transferTo` vs парные транзакции (см. развилку ниже).
7. **Прочее:**
- `shared/formatters/` пуст — добавить `intl`-форматтер денег (использует `currency` из настроек)
и дат, на который перейдут `MoneyText` и группировка дней в `home_screen.dart`.
- Очистить ненужный re-export enum'ов из `enum_converters.dart`, либо явно задокументировать
паттерн «enum живёт рядом с Drift-таблицей, импортируется через конвертер».
- Тесты: ни одного теста не написано. Минимум — unit на репозитории через `AppDatabase.forTesting`.
## Команды
- `flutter pub get`
- `dart run build_runner build --delete-conflicting-outputs` — после изменения `@DriftDatabase`,
`@riverpod`, `@freezed`, ARB-файлов локализации.
- `flutter analyze`
- `flutter run` (Android-эмулятор).
## Открытые развилки (не принимать решение без согласования)
- **ID**: `int autoIncrement` сейчас vs `UUID/text` при появлении облачной синхронизации.
- **Переводы между счетами**: одна запись с `transferToAccountId` vs парные транзакции
(income на одном счёте + expense на другом). Текущая модель — первое; агрегаты их игнорируют.
- **Агрегаты для аналитики**: считать на клиенте в Provider (сейчас) vs SQL-агрегаты в DAO
(`watchAccountBalance`, `watchTotalsByCategory(period)`). Перейти на SQL при росте объёма данных.
- **`riverpod_lint`/`custom_lint`**: возвращать или нет.