Files
OnBudget/PLAN.md
T
2026-05-27 09:19:19 +03:00

12 KiB

Каркас Flutter-приложения для учёта личных финансов (Android)

Контекст

Стартует новый проект (C:\Sanders\Flutter\NewBudget — пустая директория, Flutter 3.35.1 / Dart 3.9.0). Цель — заложить качественную структуру каркаса (скелета) без реализации фич: сущности, таблицы, контракты репозиториев, провайдеры, роутинг, тема, заглушки экранов. После этого flutter run должен запускаться и показывать плейсхолдер-экраны, а добавление реальной логики сводилось бы к заполнению заранее подготовленных слоёв.

Решения, согласованные с пользователем:

  • БД: Drift (SQLite) — реляционные связи account→transaction→category, реактивные стримы, миграции, SQL-агрегаты.
  • Riverpod: кодогенерация (@riverpod + riverpod_generator + build_runner).
  • Пользователи: несколько локальных профилей; все доменные таблицы ссылаются на userId.
  • Архитектура: чёткое разделение отображения (UI) от логики работы (см. ниже).

Архитектура: разделение UI и логики

Каркас строится по feature-first с явными слоями внутри каждой фичи. Зависимости направлены строго внутрь (presentation → application → domain ← data), внешние слои не знают о Drift:

presentation  →  application  →  domain  ←  data
   (UI)            (логика)       (контракты)   (Drift+реализации)
  • presentation — только Widgets/экраны. Читает состояние через ref.watch(...), вызывает методы контроллеров. Не содержит бизнес-логики, не знает про Drift, не делает запросов к БД.
  • application — Riverpod-контроллеры (@riverpod Notifier/AsyncNotifier). Здесь живёт логика: валидация, оркестрация вызовов репозиториев, формирование состояния для UI. Зависит только от абстракций domain.
  • domain — чистый Dart: сущности (User, Account, ...) и абстрактные интерфейсы репозиториев. Никаких зависимостей от Flutter/Drift. Это контракт между логикой и данными.
  • data — реализации репозиториев + Drift (таблицы, DAO, мапперы row↔entity). Только здесь пишется SQL.

Так «фронт» (presentation) физически отделён от «логики» (application) и от «данных» (data): UI можно менять, не трогая логику; источник данных (Drift) можно заменить, не трогая UI и логику — достаточно дать новую реализацию интерфейса из domain. Слой usecase-классов сознательно опускаем — для приложения такого размера контроллеры вызывают репозитории напрямую (прагматичный baseline, без лишних абстракций).

Структура каталогов

lib/
  main.dart                         # точка входа: runApp(ProviderScope(child: App()))
  src/
    app/
      app.dart                      # MaterialApp.router, тема, локализация
      router/
        app_router.dart             # go_router (провайдер конфигурации)
        app_routes.dart             # константы путей/имён
      theme/
        app_theme.dart              # light/dark ThemeData
        app_colors.dart
    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
      constants/
    features/
      user/
        domain/
          entities/user.dart
          repositories/user_repository.dart        # abstract
        data/
          mappers/user_mapper.dart
          repositories/user_repository_impl.dart    # реализация поверх UsersDao
        application/
          user_providers.dart                       # провайдер репозитория (DI)
          active_user_controller.dart               # текущий выбранный профиль
          users_controller.dart                     # список/создание профилей
        presentation/
          screens/                                   # заглушки
          widgets/
      settings/      # (та же структура: настройки на профиль — валюта, тема, локаль)
      accounts/      # (та же структура)
      categories/    # (та же структура)
      transactions/  # (та же структура)
    shared/
      widgets/                       # общие виджеты (AppScaffold, EmptyState, ...)
      formatters/                    # форматирование валюты/дат (intl)

Каждая фича повторяет один и тот же шаблон domain/ data/ application/ presentation/. В скелете методы репозиториев/DAO определены сигнатурами, реализации минимальны или содержат TODO/UnimplementedError, экраны — плейсхолдеры.

Модель данных (Drift)

Деньги хранятся как целые минорные единицы (копейки/центы) в 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.
  • 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.
  • transactions: id, userId (FK), accountId (FK), categoryId (FK, nullable), type (enum: income/expense/transfer), amount (int, минорные единицы), date, note (nullable), transferToAccountId (nullable, для переводов), createdAt.

Для каждой таблицы — соответствующая чистая сущность в domain/entities (immutable, через freezed) и маппер в data/mappers. Enum'ы кодируются Drift TypeConverter'ами в core/database/converters.

DAO (core/database/daos) предоставляют реактивные методы (Stream через .watch()), например: watchAccountsByUser(userId), watchTransactions(filter), агрегаты watchAccountBalance(accountId), watchTotalsByCategory(period). В скелете — сигнатуры + базовые запросы, сложные агрегаты как TODO.

Зависимости (pubspec.yaml)

Runtime:

  • flutter_riverpod, riverpod_annotation
  • drift, drift_flutter (открытие БД на Android, путь через path_provider под капотом)
  • go_router
  • intl (форматирование валюты/дат)
  • freezed_annotation (immutable-сущности)

Dev:

  • build_runner
  • riverpod_generator, riverpod_lint, custom_lint
  • drift_dev
  • freezed
  • flutter_lints (или very_good_analysis)

analysis_options.yaml подключает custom_lint (для riverpod_lint) и исключает *.g.dart/*.freezed.dart из анализа.

Последовательность сборки каркаса

  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.

Проверка

  • flutter pub get и dart run build_runner build проходят без ошибок (кодогенерация Drift+Riverpod+Freezed).
  • flutter analyze — без ошибок (с учётом исключений для сгенерированных файлов).
  • flutter run на Android-эмуляторе/устройстве: приложение запускается, открывается стартовый экран (выбор/создание профиля → дашборд), навигация между экранами-заглушками работает, БД инициализируется без падений.
  • Каркас считается готовым, когда добавление реальной фичи требует только: запрос в DAO → метод в repo impl → метод в контроллере → отображение в экране, не затрагивая остальные слои.

Открытые развилки (на будущее, вне скелета)

  • ID: int autoIncrement сейчас vs UUID/text при появлении облачной синхронизации.
  • «Активный пользователь»: отдельная таблица app_preferences vs shared_preferences.
  • Переводы между счетами: одна запись с transferToAccountId vs парные транзакции.