20 KiB
Реализация 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 getdart 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: truecolorSchemeчерез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:
@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 — константы:
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: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 = 1mockAccounts: 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)
Простые провайдеры:
@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 — переписать:
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.dartlib/src/app/theme/app_colors.dartlib/src/app/theme/app_theme.dartlib/src/app/theme/theme_mode_controller.dartlib/src/app/router/app_routes.dartlib/src/app/router/app_router.dartlib/src/shared/widgets/app_scaffold.dartlib/src/shared/widgets/app_bottom_nav.dartlib/src/shared/text/mono_text.dart(helper для JetBrains Mono)
Создать (home feature):
lib/src/features/home/presentation/screens/home_screen.dartlib/src/features/home/presentation/widgets/month_header.dartlib/src/features/home/presentation/widgets/account_tabs.dartlib/src/features/home/presentation/widgets/month_kpi_card.dartlib/src/features/home/presentation/widgets/category_donut_card.dartlib/src/features/home/presentation/widgets/category_donut.dartlib/src/features/home/presentation/widgets/transactions_section.dart(header + filter pill)lib/src/features/home/presentation/widgets/day_header.dartlib/src/features/home/presentation/widgets/tx_row.dartlib/src/features/home/presentation/widgets/fab_add_transaction.dartlib/src/features/home/presentation/state/selected_category_filter.dartlib/src/features/home/presentation/_mock_data.dart
Создать (другие вкладки):
lib/src/features/analytics/presentation/screens/analytics_screen.dartlib/src/features/accounts/presentation/screens/accounts_screen.dartlib/src/features/profile/presentation/screens/profile_screen.dartlib/src/features/categories/presentation/category_icon.dart
Изменить:
lib/main.dart— runApp + ProviderScopepubspec.yaml— добавитьgoogle_fonts
Удалить/заменить:
test/widget_test.dart— заменить на smoke-тест приложения
Сборка и проверка
flutter pub getdart run build_runner build --delete-conflicting-outputs— без ошибокflutter analyze— без ошибок (исключения для*.g.dart/*.freezed.dartуже настроены вanalysis_options.yaml)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.