Files
OnBudget/UI_PLAN.md
T
2026-05-27 10:10:38 +03:00

343 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Реализация 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.