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

20 KiB
Raw Blame History

Реализация 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:82function V1()) — главный экран:

  • хедер «Бюджет / Май 2026» + иконки поиска и колокольчика
  • горизонтальные таб-пиллы счетов (design/common.jsx:157ACCOUNTS)
  • карточка KPI: баланс + доходы/расходы
  • donut-диаграмма расходов по категориям + легенда (design/common.jsx:76Donut)
  • пилл-триггер фильтра по категории
  • список транзакций, сгруппированный по дням (design/common.jsx:179TxRow)
  • FAB (design/common.jsx:262) и bottom-nav из 4 вкладок (design/common.jsx:230BottomNav)
  • две темы; в 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.dartThemeData 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:

@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.dartConsumerWidget, который читает 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:76Donut
TransactionsSectionHeader строки 207-211
CategoryFilterPill строки 213-239
DayHeader design/common.jsx:215DayHeader
TxRow design/common.jsx:179TxRow
FabAddTransaction design/common.jsx:262FAB

Поведение фильтра (выбор сегмента 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 ₽») рисуем поверх через StackPieChart сам центр не занимает (это и есть «дырка» 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_32018_432_000. Поле type мапим в AccountType; iconCode/colorValuenull, иконку выбираем в 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, амаунты в минорных единицах со знаком, dateDateTime.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/*.dartAnalyticsScreen, 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.dartNewBudgetApp (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.