343 lines
20 KiB
Markdown
343 lines
20 KiB
Markdown
# Реализация 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<AppPalette>` — `Theme.of(context).extension<AppPalette>()!`. Это идиоматичнее, чем
|
||
глобальные синглтоны, и автоматически переключается с темой.
|
||
|
||
`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<int?>`.
|
||
|
||
Группировка по дням — в `home_screen.dart` через `Map<DateTime, List<Transaction>>` (ключ — дата без
|
||
времени). Заголовок дня — «Сегодня», «Вчера» или `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<Account>` — 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<Category>` — 6 категорий (food/rent/transp/cafe/enter/other),
|
||
`type: CategoryType.expense`, `colorValue`/`iconCode` хранят hex и иконку из дизайна
|
||
- `mockTransactions: List<Transaction>` — 10 строк из `design/common.jsx:165-176`, амаунты в
|
||
минорных единицах со знаком, `date` — `DateTime.now()` сдвинутый на нужное кол-во дней назад
|
||
- `mockCategorySpend: Map<int, int>` — суммы по категории для donut'а (`SPEND` из `common.jsx:142`)
|
||
|
||
Простые провайдеры:
|
||
```dart
|
||
@riverpod
|
||
List<Account> mockAccounts(...) => _mockAccountsList;
|
||
@riverpod
|
||
List<Transaction> 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.
|