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-контроллеры (
@riverpodNotifier/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_annotationdrift,drift_flutter(открытие БД на Android, путь через path_provider под капотом)go_routerintl(форматирование валюты/дат)freezed_annotation(immutable-сущности)
Dev:
build_runnerriverpod_generator,riverpod_lint,custom_lintdrift_devfreezedflutter_lints(илиvery_good_analysis)
analysis_options.yaml подключает custom_lint (для riverpod_lint) и исключает *.g.dart/*.freezed.dart
из анализа.
Последовательность сборки каркаса
flutter create . --org com.example --platforms=androidв текущей директории (генерирует Android-обвязку).- Прописать зависимости в
pubspec.yaml,flutter pub get. core/database: таблицы → конвертеры →AppDatabase(schemaVersion=1, пустая стратегия миграций) → DAO.core/providers/database_provider.dart— провайдерAppDatabase(keepAlive).- По каждой фиче, по шаблону:
domain(entity + abstract repo) →data(mapper + repo impl на DAO) →application(провайдер репозитория + контроллеры) →presentation(экраны-заглушки). app/: тема,go_router(маршруты: выбор профиля, дашборд, счета, категории, транзакции, добавление/редактирование транзакции, настройки),app.dart,main.dart.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сейчас vsUUID/textпри появлении облачной синхронизации. - «Активный пользователь»: отдельная таблица
app_preferencesvsshared_preferences. - Переводы между счетами: одна запись с
transferToAccountIdvs парные транзакции.