# Реализация UI: главный экран (V1) и каркас навигации ## Context `PLAN.md` описывает каркас приложения учёта личных финансов на Flutter; data/domain/application слои уже разложены по фичам, но кодогенерация (`*.g.dart`, `*.freezed.dart`) ещё не запускалась, а `lib/main.dart` — это дефолтный counter-шаблон Flutter. UI отсутствует. Пользователь просит начать реализацию интерфейса по дизайну `design/index.html`. В HTML рендерится **только вариант V1** (`design/variants.jsx:82` — `function V1()`) — главный экран: - хедер «Бюджет / Май 2026» + иконки поиска и колокольчика - горизонтальные таб-пиллы счетов (`design/common.jsx:157` — `ACCOUNTS`) - карточка KPI: баланс + доходы/расходы - donut-диаграмма расходов по категориям + легенда (`design/common.jsx:76` — `Donut`) - пилл-триггер фильтра по категории - список транзакций, сгруппированный по дням (`design/common.jsx:179` — `TxRow`) - FAB (`design/common.jsx:262`) и bottom-nav из 4 вкладок (`design/common.jsx:230` — `BottomNav`) - две темы; в `design/index.html:74` дефолт `theme: dark` Уточнено пользователем: **только V1** (с навигационным каркасом для остальных вкладок), **mock-данные в presentation**, **обе темы** с переключением в Профиле. Цель — собрать запускаемое приложение, где главный экран визуально соответствует V1, а добавление реальных данных потом сведётся к замене mock-источника на Riverpod-контроллеры из `application/`. ## Подход Mock-данные складываются в `lib/src/features/home/presentation/_mock_data.dart` и типизируются **существующими доменными сущностями** (`Account`, `Transaction`, `Category`) — так UI с самого начала работает с теми же типами, на которые потом будут переключены реальные репозитории. Это требует, чтобы скелет компилировался, поэтому перед запуском нужно прогнать `build_runner`. UI делится на маленькие виджеты-композиты, каждый получает данные через конструктор (никакого обращения к `ref` внутри презентационных виджетов, кроме экранов-контейнеров и темы) — это держит их близкими к тому, чем будут render-функции вроде `KPI`/`TxRow` из `common.jsx`. ## План работ ### 1. Зависимости и кодогенерация `pubspec.yaml`: добавить - `google_fonts: ^6.2.1` — для DM Sans / JetBrains Mono (минимум кода, без bundle .ttf) - `fl_chart: ^0.69.0` — donut-диаграмма категорий через `PieChart`, без рисования вручную Запустить: - `flutter pub get` - `dart run build_runner build --delete-conflicting-outputs` — генерирует `*.g.dart` и `*.freezed.dart` для всех файлов с `part`-директивами. Без этого скелет (`Account`, `Transaction`, `@riverpod`) не компилируется и UI, типизированный доменными сущностями, не соберётся. ### 2. Дизайн-токены Создать `lib/src/app/theme/app_colors.dart` — мапнуть CSS-переменные из `design/index.html:13-41` в `Color`: | CSS-токен | Light | Dark | Назначение | |----------------|-------------|-------------|---------------------------| | `--paper` | `#f6f4ef` | `#19191a` | основной фон | | `--paper-2` | `#efece5` | `#232325` | поднятые поверхности | | `--card-soft` | `#edeae3` | `#232325` | мягкие чипы | | `--ink` | `#1c1c1a` | `#ece9e2` | основной текст | | `--ink-2` | `#6b6b66` | `#8d8a83` | вторичный текст | | `--line` | `#d8d5cc` | `#2e2d2a` | границы | | `--line-2` | `#b8b5ac` | `#4a4845` | рамка устройства | | `--accent` | `#4a8a82` | `#76b3a9` | FAB, активная вкладка | | `--accent-soft`| `#dde9e6` | `#23332f` | пастель акцента | | `--pos` | `#6f8c69` | `#92b58a` | доходы | | `--neg` | `#b3675a` | `#d18d7e` | расходы | Класс `AppPalette` — immutable, два экземпляра `light`/`dark`. Доступ через `ThemeExtension` — `Theme.of(context).extension()!`. Это идиоматичнее, чем глобальные синглтоны, и автоматически переключается с темой. `lib/src/app/theme/app_theme.dart` — `ThemeData light()` / `dark()`: - `useMaterial3: true` - `colorScheme` через `ColorScheme.fromSeed(seedColor: accent, brightness: ...)`, затем `copyWith` для `surface`, `onSurface`, `outline` — чтобы материаловские виджеты (Material, Card, Divider) на дефолте уже использовали наши токены - `textTheme: GoogleFonts.dmSansTextTheme(...)` - `extensions: [AppPalette.light / .dark]` - семейство для числовых стилей — отдельный публичный helper `Text monoText(...)`, который применяет `GoogleFonts.jetBrainsMono(fontFeatures: [tabular-nums])`. Использовать там, где в дизайне `fontFamily: 'JetBrains Mono'` (балансы, суммы транзакций). ### 3. Контроллер темы `lib/src/app/theme/theme_mode_controller.dart`: ```dart @riverpod class ThemeModeController extends _$ThemeModeController { @override ThemeMode build() => ThemeMode.dark; // дефолт совпадает с design/index.html void set(ThemeMode mode) => state = mode; void toggle() => state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark; } ``` Простой `@riverpod` Notifier; персистентность не требуется в этой итерации (отмечено в «Будущие шаги»). Использовать через `ref.watch(themeModeControllerProvider)` в `App`. ### 4. Навигация `lib/src/app/router/app_routes.dart` — константы: ```dart class AppRoutes { static const home = '/home'; static const analytics = '/analytics'; static const accounts = '/accounts'; static const profile = '/profile'; } ``` `lib/src/app/router/app_router.dart` — `@riverpod` провайдер `GoRouter`: - корневая `StatefulShellRoute.indexedStack` с 4 ветками (home, analytics, accounts, profile); каждая ветка — обычный `GoRoute` без вложенности - `initialLocation: AppRoutes.home` - shell-builder возвращает `AppScaffold` (см. ниже) — он рисует bottom nav и индекс активной вкладки ### 5. Каркасный Scaffold с bottom nav `lib/src/shared/widgets/app_scaffold.dart`: - получает `StatefulNavigationShell`, рендерит `Scaffold(body: shell, bottomNavigationBar: AppBottomNav(...))` - `AppBottomNav` — собственная реализация, не `NavigationBar`, чтобы повторить визуал из `design/common.jsx:230` (иконка в пилюле с `--accent-soft` фоном для активной) - 4 пункта: Главная (Icons.home_outlined), Аналитика (Icons.bar_chart_outlined), Счета (Icons.account_balance_wallet_outlined), Профиль (Icons.person_outline) — иконки берём из Material, иконки дизайна (`wallet`, `stats`...) близки к материаловским аналогам ### 6. Главный экран (V1) `lib/src/features/home/presentation/screens/home_screen.dart` — `ConsumerWidget`, который читает mock-провайдеры и собирает композицию. Структура `build`: ``` Scaffold( body: Stack( children: [ CustomScrollView(slivers: [ SliverToBoxAdapter(MonthHeader) SliverToBoxAdapter(AccountTabs) SliverToBoxAdapter(MonthKpiCard) SliverToBoxAdapter(CategoryDonutCard) SliverToBoxAdapter(TransactionsSectionHeader) SliverToBoxAdapter(CategoryFilterPill) SliverList(... DayHeader + TxRow ...) SliverPadding(80px) ]) Positioned(FabAddTransaction, bottom: 16, right: 16) ] ) ) ``` Виджеты, по одному на файл, под `lib/src/features/home/presentation/widgets/`: | Виджет | Соответствие в variants.jsx | |-----------------------------|---------------------------------------| | `MonthHeader` | строки 108-116 | | `AccountTabs` | строки 119-133 | | `MonthKpiCard` | строки 136-165 | | `CategoryDonutCard` | строки 167-205 | | `CategoryDonut` (CustomPainter) | `design/common.jsx:76` — `Donut` | | `TransactionsSectionHeader` | строки 207-211 | | `CategoryFilterPill` | строки 213-239 | | `DayHeader` | `design/common.jsx:215` — `DayHeader` | | `TxRow` | `design/common.jsx:179` — `TxRow` | | `FabAddTransaction` | `design/common.jsx:262` — `FAB` | Поведение фильтра (выбор сегмента donut фильтрует список транзакций) — состояние храним в локальном `StateProvider`/`Notifier` внутри `home` фичи: `selected_category_filter.dart` — `@riverpod` `int? selectedCategoryFilter(ref)` либо просто `StateProvider`. Группировка по дням — в `home_screen.dart` через `Map>` (ключ — дата без времени). Заголовок дня — «Сегодня», «Вчера» или `dd MMM` через `intl`. ### 7. Donut-диаграмма (через `fl_chart`) `lib/src/features/home/presentation/widgets/category_donut.dart`: - обёртка над `PieChart` из `fl_chart`: ```dart PieChart(PieChartData( sections: [ for (final s in spend) PieChartSectionData( value: s.amount.toDouble(), color: s.color, radius: isActive(s) ? 26 : 22, // визуальный аналог rOff=4 showTitle: false, ), ], centerSpaceRadius: 44, // совпадает с `thickness: 22` при size: 130 sectionsSpace: 0, pieTouchData: PieTouchData( touchCallback: (event, response) { if (event is FlTapUpEvent) { final idx = response?.touchedSection?.touchedSectionIndex; onSegment?.call(idx); } }, ), )) ``` - центральный текст («Расходы / 67 200 ₽») рисуем поверх через `Stack` — `PieChart` сам центр не занимает (это и есть «дырка» donut'а) - активный сегмент получает увеличенный `radius`; для не-активных, когда выбран другой, можно понизить `opacity` через `color.withOpacity(0.4)` — равнозначно `opacity: 0.35` из референса - получаем встроенный hit-test по сегментам — никакого ручного расчёта угла ### 8. Mock-данные `lib/src/features/home/presentation/_mock_data.dart`: - `mockUserId = 1` - `mockAccounts: List` — 4 счёта из `design/common.jsx:157` (all / card / cash / pig), с балансами в **минорных единицах** (умножить на 100): `184_320` → `18_432_000`. Поле `type` мапим в `AccountType`; `iconCode`/`colorValue` — `null`, иконку выбираем в `AccountTabs` по `type`. Виртуальный «Все счета» — отдельная константа `kAllAccountsId = 0` (т.к. это не реальный счёт, а агрегат); в UI он рендерится первой пиллой и фильтрует список «все». - `mockCategories: List` — 6 категорий (food/rent/transp/cafe/enter/other), `type: CategoryType.expense`, `colorValue`/`iconCode` хранят hex и иконку из дизайна - `mockTransactions: List` — 10 строк из `design/common.jsx:165-176`, амаунты в минорных единицах со знаком, `date` — `DateTime.now()` сдвинутый на нужное кол-во дней назад - `mockCategorySpend: Map` — суммы по категории для donut'а (`SPEND` из `common.jsx:142`) Простые провайдеры: ```dart @riverpod List mockAccounts(...) => _mockAccountsList; @riverpod List mockTransactions(...) => _mockTransactionsList; // и т.д. ``` Так замена на реальные данные потом — это **переименование провайдеров** в виджетах с `mockAccountsProvider` на `accountsStreamProvider(userId)` (уже существует в скелете) — UI не меняется. ### 9. Палитра категорий и иконок В дизайне категории имеют hex-цвета и кастомные SVG. В Flutter: - цвет хранится прямо в mock-данных в `colorValue` (`int` уже есть в `Category`) - иконку выбираем через статическую мапу `categoryIconFor(Category c)` в `lib/src/features/categories/presentation/category_icon.dart` — ключ это `iconCode` или имя категории; значение — `IconData` из `Icons` (cart → `shopping_cart_outlined`, house → `home_outlined`, car → `directions_car_outlined`, food → `restaurant_outlined`, film → `movie_outlined`, more → `more_horiz`). Дешевле SVG, ближе к материаловскому стилю. ### 10. Экраны-заглушки остальных вкладок `lib/src/features/{analytics,accounts_screen,profile}/presentation/screens/*.dart` — `AnalyticsScreen`, `AccountsScreen`, `ProfileScreen`. Каждый — `Scaffold` с заголовком в стиле V1 хедера и `Center(child: Text('Скоро'))` либо `EmptyState` виджетом. `ProfileScreen` содержит **рабочий** `SwitchListTile` темы (читает/пишет `themeModeControllerProvider`) — это и есть единственный нон-плейсхолдер вне главного экрана. `AnalyticsScreen` и `AccountsScreen` создаём в существующих фичах (`features/categories` не подходит — добавим `features/analytics/presentation/` — presentation-only). `AccountsScreen` логично положить в `features/accounts/presentation/screens/`. ### 11. Точка входа `lib/main.dart` — переписать: ```dart void main() { runApp(const ProviderScope(child: NewBudgetApp())); } ``` `lib/src/app/app.dart` — `NewBudgetApp` (`ConsumerWidget`): - читает `themeModeControllerProvider` и `appRouterProvider` - возвращает `MaterialApp.router(theme: AppTheme.light(), darkTheme: AppTheme.dark(), themeMode: ..., routerConfig: ...)` ### 12. Удалить мусор - `test/widget_test.dart` — ссылается на `MyApp` из counter-шаблона, удалить или заменить smoke-тестом, который только инициализирует `NewBudgetApp` под `ProviderScope`. ## Файлы **Создать (presentation/app):** - `lib/src/app/app.dart` - `lib/src/app/theme/app_colors.dart` - `lib/src/app/theme/app_theme.dart` - `lib/src/app/theme/theme_mode_controller.dart` - `lib/src/app/router/app_routes.dart` - `lib/src/app/router/app_router.dart` - `lib/src/shared/widgets/app_scaffold.dart` - `lib/src/shared/widgets/app_bottom_nav.dart` - `lib/src/shared/text/mono_text.dart` (helper для JetBrains Mono) **Создать (home feature):** - `lib/src/features/home/presentation/screens/home_screen.dart` - `lib/src/features/home/presentation/widgets/month_header.dart` - `lib/src/features/home/presentation/widgets/account_tabs.dart` - `lib/src/features/home/presentation/widgets/month_kpi_card.dart` - `lib/src/features/home/presentation/widgets/category_donut_card.dart` - `lib/src/features/home/presentation/widgets/category_donut.dart` - `lib/src/features/home/presentation/widgets/transactions_section.dart` (header + filter pill) - `lib/src/features/home/presentation/widgets/day_header.dart` - `lib/src/features/home/presentation/widgets/tx_row.dart` - `lib/src/features/home/presentation/widgets/fab_add_transaction.dart` - `lib/src/features/home/presentation/state/selected_category_filter.dart` - `lib/src/features/home/presentation/_mock_data.dart` **Создать (другие вкладки):** - `lib/src/features/analytics/presentation/screens/analytics_screen.dart` - `lib/src/features/accounts/presentation/screens/accounts_screen.dart` - `lib/src/features/profile/presentation/screens/profile_screen.dart` - `lib/src/features/categories/presentation/category_icon.dart` **Изменить:** - `lib/main.dart` — runApp + ProviderScope - `pubspec.yaml` — добавить `google_fonts` **Удалить/заменить:** - `test/widget_test.dart` — заменить на smoke-тест приложения ## Сборка и проверка 1. `flutter pub get` 2. `dart run build_runner build --delete-conflicting-outputs` — без ошибок 3. `flutter analyze` — без ошибок (исключения для `*.g.dart`/`*.freezed.dart` уже настроены в `analysis_options.yaml`) 4. `flutter run` на Android-эмуляторе: - открывается главный экран в тёмной теме - визуально совпадает с V1 в `design/index.html` (структура, отступы, цвета, шрифты) - таб-пиллы счетов переключаются (хайлайт активного) - тап по сегменту donut'а / по pill-фильтру меняет список транзакций - bottom-nav переключает между 4 вкладками; состояние главного экрана сохраняется (благодаря `StatefulShellRoute`) - в Профиле есть переключатель темы; нажатие действительно меняет палитру всего приложения - FAB виден над списком, тап — пока no-op (TODO: открыть экран добавления транзакции) ## Будущие шаги (вне этой итерации) - Заменить mock-провайдеры на стримы из `application/` (`accountsStreamProvider`, `transactionsStreamProvider` и т.д.) — UI остаётся прежним. - Персистентность `ThemeMode` через `SettingsRepository` (`SettingsEntity.themeMode` уже существует). - Точный hit-test сегментов donut'а (по углу относительно центра) для тапа. - Экран добавления/редактирования транзакции (FAB-таргет). - Реализация Аналитики и Счетов поверх существующих DAO.