diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 26a4ee6..0948644 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,10 @@ { "permissions": { "allow": [ - "PowerShell(flutter *)" + "PowerShell(flutter *)", + "Bash(flutter analyze *)", + "Bash(flutter gen-l10n *)", + "Bash(dart run *)" ] } } diff --git a/PLAN.md b/PLAN.md index 516850d..1ec1d91 100644 --- a/PLAN.md +++ b/PLAN.md @@ -91,9 +91,9 @@ lib/ formatters/ # пусто (планируется intl-форматирование) ``` -## Модель данных (Drift, schemaVersion=1) +## Модель данных (Drift, schemaVersion=2) -Деньги хранятся как **целые минорные единицы** (копейки/центы) в `int`. Идентификаторы — `int autoIncrement`. +Деньги хранятся как **целые минорные единицы** (копейки/центы) в `int`. Идентификаторы — `String UUID v4` (генерируются на клиенте, готовы к облачной синхронизации). Все доменные таблицы имеют `userId` (FK → users). - **users**: `id`, `name`, `createdAt`. @@ -142,26 +142,34 @@ Dev: `build_runner`, `riverpod_generator`, `drift_dev`, `freezed`, `json_seriali ### НЕ готово (приоритет сверху вниз) -1. **Переключить Home с моков на DAO-стримы.** - - Сейчас `home/presentation/_mock_data.dart` экспортирует `mockAccountsProvider`, - `mockCategoriesProvider`, `mockTransactionsProvider` — на них завязаны все виджеты Home. - - Заменить вызовы `ref.watch(mockXxxProvider)` на соответствующие `*Stream`-провайдеры - из `application/`, обернув в `AsyncValue.when(...)`. - - `iconForCategory(c)` / `iconForAccount(a)` / `shortAccountLabel(a)` из `_mock_data.dart` — - это не моки, а маппинг enum/iconCode → IconData. Их надо вынести в - `shared/formatters/` или `features/{accounts,categories}/presentation/widgets/icon_for_*.dart`, - после чего удалить `_mock_data.dart` целиком. - - `month_summary.dart` сейчас считает агрегаты в Provider на клиенте. После перехода на стримы — - либо оставить (источник `transactionsStream`), либо вынести в SQL-агрегаты DAO - (`watchAccountBalance`, `watchTotalsByCategory`). +1. ~~**Переключить Home с моков на DAO-стримы.**~~ ✅ + - `_mock_data.dart` удалён. + - `iconForCategory` + `colorForCategory` → `features/categories/presentation/widgets/category_icon.dart`. + - `iconForAccount` + `shortAccountLabel` → `features/accounts/presentation/widgets/account_icon.dart`. + - `kAllAccountsId` теперь живёт в `home/presentation/state/selected_category_filter.dart`. + - `month_summary.dart` пересажен на `accountsStreamProvider(userId)` и + `transactionsStreamProvider(userId)`; `filteredTransactionsProvider` и + `monthSummaryProvider` — `@riverpod` с параметром `userId`. + - Все виджеты Home (`HomeScreen`, `AccountTabs`, `MonthKpiCard`, `CategoryDonutCard`, + `TransactionsSectionHeader`, `CategoryFilterPill`) принимают `userId` через + конструктор и читают данные через `.value` AsyncValue (во время первичной загрузки + возвращается пустой список). -2. **Активный пользователь во всём UI.** - - В `_mock_data.dart` зашит `mockUserId = 1`. После п.1 надо тянуть `userId` из - `activeUserControllerProvider` и прокидывать его в `accountsStream(userId)`, - `transactionsStream(userId)` и т.д. - - Onboarding: первый запуск → создать профиль (`usersController.createUser`) → - `activeUserController.setActiveUser`. Если активный пользователь null — редирект на экран профилей - (`go_router` redirect-callback). +2. ~~**Активный пользователь во всём UI.**~~ ✅ + - `HomeScreen` тянет активного пользователя из `activeUserControllerProvider` + и прокидывает `user.id` во все дочерние виджеты. + - Onboarding: `features/user/presentation/screens/onboarding_screen.dart` + (поле имени → `usersController.createUser` → `activeUserController.setActiveUser`). + Маршрут `/onboarding` — отдельный top-level GoRoute. + - В `appRouter` добавлен `redirect`-callback + `refreshListenable` на + `activeUserControllerProvider`: пока пользователь не задан — редирект на + `/onboarding`; после задания — обратно на `/home`. + - При создании пользователя через `usersController.createUser` запускается + `UserSeeder.seedForNewUser(userId)` ([user_seeder.dart](lib/src/features/user/application/user_seeder.dart)), + который засевает дефолтные счета (Карта/Наличные/Копилка), категории + (Продукты/Жильё/Транспорт/Кафе/Досуг/Зарплата) и **демонстрационные + транзакции**. Демо-транзакции (`_seedDemoTransactions`) — временные, + удалить после п.3 (Add Transaction UI). 3. **Add/Edit Transaction.** - FAB на Home сейчас `onPressed: () {}`. Маршрут `/transactions/new` + bottom-sheet или экран: @@ -202,7 +210,7 @@ Dev: `build_runner`, `riverpod_generator`, `drift_dev`, `freezed`, `json_seriali ## Открытые развилки (не принимать решение без согласования) -- **ID**: `int autoIncrement` сейчас vs `UUID/text` при появлении облачной синхронизации. +- **ID**: ~~`int autoIncrement`~~ → решено: `UUID v4 / text` (schemaVersion=2). Готово к облачной синхронизации. - **Переводы между счетами**: одна запись с `transferToAccountId` vs парные транзакции (income на одном счёте + expense на другом). Текущая модель — первое; агрегаты их игнорируют. - **Агрегаты для аналитики**: считать на клиенте в Provider (сейчас) vs SQL-агрегаты в DAO diff --git a/UI_PLAN.md b/UI_PLAN.md deleted file mode 100644 index 6c6973e..0000000 --- a/UI_PLAN.md +++ /dev/null @@ -1,342 +0,0 @@ -# Реализация 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. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2fc4a73..3b54253 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -63,5 +63,11 @@ "profileSubtitle": "Settings", "darkTheme": "Dark theme", - "profileHint": "User profile, currency, locale and other settings will appear here." + "profileHint": "User profile, currency, locale and other settings will appear here.", + + "onboardingTitle": "Welcome", + "onboardingSubtitle": "Tell us your name to get started.", + "onboardingNameLabel": "Your name", + "onboardingNameHint": "How should we address you?", + "onboardingContinue": "Continue" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9ee8bc1..120848a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -235,6 +235,36 @@ abstract class AppLocalizations { /// In ru, this message translates to: /// **'Здесь появится профиль пользователя, валюта, локаль и другие настройки.'** String get profileHint; + + /// No description provided for @onboardingTitle. + /// + /// In ru, this message translates to: + /// **'Добро пожаловать'** + String get onboardingTitle; + + /// No description provided for @onboardingSubtitle. + /// + /// In ru, this message translates to: + /// **'Расскажите, как к вам обращаться.'** + String get onboardingSubtitle; + + /// No description provided for @onboardingNameLabel. + /// + /// In ru, this message translates to: + /// **'Ваше имя'** + String get onboardingNameLabel; + + /// No description provided for @onboardingNameHint. + /// + /// In ru, this message translates to: + /// **'Например, Алекс'** + String get onboardingNameHint; + + /// No description provided for @onboardingContinue. + /// + /// In ru, this message translates to: + /// **'Продолжить'** + String get onboardingContinue; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 94c8fd0..be49d91 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -97,4 +97,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get profileHint => 'User profile, currency, locale and other settings will appear here.'; + + @override + String get onboardingTitle => 'Welcome'; + + @override + String get onboardingSubtitle => 'Tell us your name to get started.'; + + @override + String get onboardingNameLabel => 'Your name'; + + @override + String get onboardingNameHint => 'How should we address you?'; + + @override + String get onboardingContinue => 'Continue'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index b697021..2cf4c57 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -101,4 +101,19 @@ class AppLocalizationsRu extends AppLocalizations { @override String get profileHint => 'Здесь появится профиль пользователя, валюта, локаль и другие настройки.'; + + @override + String get onboardingTitle => 'Добро пожаловать'; + + @override + String get onboardingSubtitle => 'Расскажите, как к вам обращаться.'; + + @override + String get onboardingNameLabel => 'Ваше имя'; + + @override + String get onboardingNameHint => 'Например, Алекс'; + + @override + String get onboardingContinue => 'Продолжить'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index fa045d6..995d634 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -63,5 +63,11 @@ "profileSubtitle": "Настройки", "darkTheme": "Тёмная тема", - "profileHint": "Здесь появится профиль пользователя, валюта, локаль и другие настройки." + "profileHint": "Здесь появится профиль пользователя, валюта, локаль и другие настройки.", + + "onboardingTitle": "Добро пожаловать", + "onboardingSubtitle": "Расскажите, как к вам обращаться.", + "onboardingNameLabel": "Ваше имя", + "onboardingNameHint": "Например, Алекс", + "onboardingContinue": "Продолжить" } diff --git a/lib/src/app/router/app_router.dart b/lib/src/app/router/app_router.dart index 89fa0ee..246c2c7 100644 --- a/lib/src/app/router/app_router.dart +++ b/lib/src/app/router/app_router.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -5,6 +6,8 @@ import '../../features/accounts/presentation/screens/accounts_screen.dart'; import '../../features/analytics/presentation/screens/analytics_screen.dart'; import '../../features/home/presentation/screens/home_screen.dart'; import '../../features/profile/presentation/screens/profile_screen.dart'; +import '../../features/user/application/active_user_controller.dart'; +import '../../features/user/presentation/screens/onboarding_screen.dart'; import '../../shared/widgets/app_scaffold.dart'; import 'app_routes.dart'; @@ -12,9 +15,33 @@ part 'app_router.g.dart'; @Riverpod(keepAlive: true) GoRouter appRouter(Ref ref) { + // ValueNotifier, который дёргается каждый раз, когда меняется состояние + // активного пользователя — это сигнал для GoRouter пересчитать redirect. + final refresh = ValueNotifier(0); + ref.listen>( + activeUserControllerProvider, + (_, _) => refresh.value++, + ); + ref.onDispose(refresh.dispose); + return GoRouter( initialLocation: AppRoutes.home, + refreshListenable: refresh, + redirect: (context, state) { + final active = ref.read(activeUserControllerProvider); + // Пока активный пользователь загружается из БД — никуда не уводим. + if (active.isLoading) return null; + final hasUser = active.value != null; + final atOnboarding = state.matchedLocation == AppRoutes.onboarding; + if (!hasUser) return atOnboarding ? null : AppRoutes.onboarding; + if (atOnboarding) return AppRoutes.home; + return null; + }, routes: [ + GoRoute( + path: AppRoutes.onboarding, + builder: (context, state) => const OnboardingScreen(), + ), StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) => AppScaffold( navigationShell: navigationShell, diff --git a/lib/src/app/router/app_routes.dart b/lib/src/app/router/app_routes.dart index 5a1e44b..e79dd60 100644 --- a/lib/src/app/router/app_routes.dart +++ b/lib/src/app/router/app_routes.dart @@ -5,4 +5,5 @@ class AppRoutes { static const analytics = '/analytics'; static const accounts = '/accounts'; static const profile = '/profile'; + static const onboarding = '/onboarding'; } diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart index 56ca3f6..04be67a 100644 --- a/lib/src/core/database/app_database.dart +++ b/lib/src/core/database/app_database.dart @@ -1,6 +1,7 @@ import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; +import 'converters/enum_converters.dart'; import 'tables/users_table.dart'; import 'tables/settings_table.dart'; import 'tables/accounts_table.dart'; @@ -38,7 +39,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 1; + int get schemaVersion => 2; @override MigrationStrategy get migration => MigrationStrategy( @@ -46,7 +47,16 @@ class AppDatabase extends _$AppDatabase { await m.createAll(); }, onUpgrade: (m, from, to) async { - // Будущие миграции добавляются здесь. + if (from < 2) { + // v1 → v2: PK сменились с int autoIncrement на UUID text. + // Пересоздаём схему — в dev-режиме данные не нужны. + await m.drop(transactionsTable); + await m.drop(categoriesTable); + await m.drop(accountsTable); + await m.drop(settingsTable); + await m.drop(usersTable); + await m.createAll(); + } }, ); diff --git a/lib/src/core/database/daos/accounts_dao.dart b/lib/src/core/database/daos/accounts_dao.dart index e313d5a..3665915 100644 --- a/lib/src/core/database/daos/accounts_dao.dart +++ b/lib/src/core/database/daos/accounts_dao.dart @@ -11,33 +11,34 @@ class AccountsDao extends DatabaseAccessor AccountsDao(super.db); /// Реактивный поток счетов пользователя (не архивных). - Stream> watchAccountsByUser(int userId) => + Stream> watchAccountsByUser(String userId) => (select(accountsTable) ..where((t) => t.userId.equals(userId) & t.archived.equals(false)) ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) .watch(); - Future> getAccountsByUser(int userId) => + Future> getAccountsByUser(String userId) => (select(accountsTable) ..where((t) => t.userId.equals(userId) & t.archived.equals(false))) .get(); - Future findById(int id) => + Future findById(String id) => (select(accountsTable)..where((t) => t.id.equals(id))).getSingleOrNull(); - Future insertAccount(AccountsTableCompanion companion) => + /// UUID передаётся в companion. + Future insertAccount(AccountsTableCompanion companion) => into(accountsTable).insert(companion); Future updateAccount(AccountsTableCompanion companion) => update(accountsTable).replace(companion); - Future archiveAccount(int id) => (update(accountsTable) + Future archiveAccount(String id) => (update(accountsTable) ..where((t) => t.id.equals(id))) .write(const AccountsTableCompanion(archived: Value(true))); /// Реактивный текущий баланс счёта (начальный + сумма транзакций). /// TODO: добавить сложную SQL-агрегацию с учётом типа транзакции. - Stream watchAccountBalance(int accountId) { + Stream watchAccountBalance(String accountId) { // Stub: возвращает только initialBalance пока не реализована агрегация. return (select(accountsTable)..where((t) => t.id.equals(accountId))) .watchSingleOrNull() diff --git a/lib/src/core/database/daos/categories_dao.dart b/lib/src/core/database/daos/categories_dao.dart index 36cfb52..5ec4745 100644 --- a/lib/src/core/database/daos/categories_dao.dart +++ b/lib/src/core/database/daos/categories_dao.dart @@ -10,7 +10,7 @@ class CategoriesDao extends DatabaseAccessor with _$CategoriesDaoMixin { CategoriesDao(super.db); - Stream> watchCategoriesByUser(int userId) => + Stream> watchCategoriesByUser(String userId) => (select(categoriesTable) ..where( (t) => t.userId.equals(userId) & t.archived.equals(false)) @@ -18,7 +18,7 @@ class CategoriesDao extends DatabaseAccessor .watch(); Stream> watchByType( - int userId, + String userId, CategoryType type, ) => (select(categoriesTable) @@ -28,23 +28,24 @@ class CategoriesDao extends DatabaseAccessor t.archived.equals(false))) .watch(); - Future> getCategoriesByUser(int userId) => + Future> getCategoriesByUser(String userId) => (select(categoriesTable) ..where( (t) => t.userId.equals(userId) & t.archived.equals(false))) .get(); - Future findById(int id) => + Future findById(String id) => (select(categoriesTable)..where((t) => t.id.equals(id))) .getSingleOrNull(); - Future insertCategory(CategoriesTableCompanion companion) => + /// UUID передаётся в companion. + Future insertCategory(CategoriesTableCompanion companion) => into(categoriesTable).insert(companion); Future updateCategory(CategoriesTableCompanion companion) => update(categoriesTable).replace(companion); - Future archiveCategory(int id) => + Future archiveCategory(String id) => (update(categoriesTable)..where((t) => t.id.equals(id))) .write(const CategoriesTableCompanion(archived: Value(true))); } diff --git a/lib/src/core/database/daos/settings_dao.dart b/lib/src/core/database/daos/settings_dao.dart index c76b17d..20de655 100644 --- a/lib/src/core/database/daos/settings_dao.dart +++ b/lib/src/core/database/daos/settings_dao.dart @@ -11,11 +11,11 @@ class SettingsDao extends DatabaseAccessor // ── Settings per user ────────────────────────────────────────────────────── - Stream watchSettingsByUser(int userId) => + Stream watchSettingsByUser(String userId) => (select(settingsTable)..where((t) => t.userId.equals(userId))) .watchSingleOrNull(); - Future getSettingsByUser(int userId) => + Future getSettingsByUser(String userId) => (select(settingsTable)..where((t) => t.userId.equals(userId))) .getSingleOrNull(); diff --git a/lib/src/core/database/daos/transactions_dao.dart b/lib/src/core/database/daos/transactions_dao.dart index 2a9c3bb..9d2ee81 100644 --- a/lib/src/core/database/daos/transactions_dao.dart +++ b/lib/src/core/database/daos/transactions_dao.dart @@ -16,9 +16,9 @@ class TransactionFilter { this.to, }); - final int userId; - final int? accountId; - final int? categoryId; + final String userId; + final String? accountId; + final String? categoryId; final TransactionType? type; final DateTime? from; final DateTime? to; @@ -59,19 +59,20 @@ class TransactionsDao extends DatabaseAccessor TransactionFilter filter) => watchTransactions(filter).first; - Future findById(int id) => + Future findById(String id) => (select(transactionsTable)..where((t) => t.id.equals(id))) .getSingleOrNull(); - Future insertTransaction(TransactionsTableCompanion companion) => + /// UUID передаётся в companion. + Future insertTransaction(TransactionsTableCompanion companion) => into(transactionsTable).insert(companion); Future updateTransaction(TransactionsTableCompanion companion) => update(transactionsTable).replace(companion); - Future deleteTransaction(int id) => + Future deleteTransaction(String id) => (delete(transactionsTable)..where((t) => t.id.equals(id))).go(); /// TODO: сложные SQL-агрегаты: суммы по категориям за период. - /// Stream> watchTotalsByCategory(int userId, DateTime from, DateTime to) + /// Stream> watchTotalsByCategory(String userId, DateTime from, DateTime to) } diff --git a/lib/src/core/database/daos/users_dao.dart b/lib/src/core/database/daos/users_dao.dart index 1ab7c5a..9a4b647 100644 --- a/lib/src/core/database/daos/users_dao.dart +++ b/lib/src/core/database/daos/users_dao.dart @@ -15,11 +15,11 @@ class UsersDao extends DatabaseAccessor with _$UsersDaoMixin { Future> getAll() => select(usersTable).get(); /// Найти пользователя по id. - Future findById(int id) => + Future findById(String id) => (select(usersTable)..where((t) => t.id.equals(id))).getSingleOrNull(); - /// Создать пользователя. Возвращает id. - Future insertUser(UsersTableCompanion companion) => + /// Создать пользователя. UUID передаётся в companion. + Future insertUser(UsersTableCompanion companion) => into(usersTable).insert(companion); /// Обновить пользователя. @@ -27,6 +27,6 @@ class UsersDao extends DatabaseAccessor with _$UsersDaoMixin { update(usersTable).replace(companion); /// Удалить пользователя. - Future deleteUser(int id) => + Future deleteUser(String id) => (delete(usersTable)..where((t) => t.id.equals(id))).go(); } diff --git a/lib/src/core/database/tables/accounts_table.dart b/lib/src/core/database/tables/accounts_table.dart index 9d7bf84..61c4486 100644 --- a/lib/src/core/database/tables/accounts_table.dart +++ b/lib/src/core/database/tables/accounts_table.dart @@ -7,9 +7,9 @@ class AccountsTable extends Table { @override String get tableName => 'accounts'; - IntColumn get id => integer().autoIncrement()(); - IntColumn get userId => - integer().references(UsersTable, #id, onDelete: KeyAction.cascade)(); + TextColumn get id => text()(); + TextColumn get userId => + text().references(UsersTable, #id, onDelete: KeyAction.cascade)(); TextColumn get name => text().withLength(min: 1, max: 50)(); @@ -27,4 +27,7 @@ class AccountsTable extends Table { BoolColumn get archived => boolean().withDefault(const Constant(false))(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {id}; } diff --git a/lib/src/core/database/tables/categories_table.dart b/lib/src/core/database/tables/categories_table.dart index d72d285..601e630 100644 --- a/lib/src/core/database/tables/categories_table.dart +++ b/lib/src/core/database/tables/categories_table.dart @@ -7,9 +7,9 @@ class CategoriesTable extends Table { @override String get tableName => 'categories'; - IntColumn get id => integer().autoIncrement()(); - IntColumn get userId => - integer().references(UsersTable, #id, onDelete: KeyAction.cascade)(); + TextColumn get id => text()(); + TextColumn get userId => + text().references(UsersTable, #id, onDelete: KeyAction.cascade)(); TextColumn get name => text().withLength(min: 1, max: 50)(); @@ -21,7 +21,10 @@ class CategoriesTable extends Table { IntColumn get colorValue => integer().nullable()(); /// Родительская категория (для подкатегорий). null = корневая. - IntColumn get parentId => integer().nullable()(); + TextColumn get parentId => text().nullable()(); BoolColumn get archived => boolean().withDefault(const Constant(false))(); + + @override + Set get primaryKey => {id}; } diff --git a/lib/src/core/database/tables/settings_table.dart b/lib/src/core/database/tables/settings_table.dart index d6c7b2d..f346120 100644 --- a/lib/src/core/database/tables/settings_table.dart +++ b/lib/src/core/database/tables/settings_table.dart @@ -8,8 +8,8 @@ class SettingsTable extends Table { String get tableName => 'settings'; /// FK → users.id (1:1 per user). - IntColumn get userId => - integer().references(UsersTable, #id, onDelete: KeyAction.cascade)(); + TextColumn get userId => + text().references(UsersTable, #id, onDelete: KeyAction.cascade)(); TextColumn get baseCurrency => text().withDefault(const Constant('RUB'))(); diff --git a/lib/src/core/database/tables/transactions_table.dart b/lib/src/core/database/tables/transactions_table.dart index 2da3436..7938b03 100644 --- a/lib/src/core/database/tables/transactions_table.dart +++ b/lib/src/core/database/tables/transactions_table.dart @@ -9,14 +9,14 @@ class TransactionsTable extends Table { @override String get tableName => 'transactions'; - IntColumn get id => integer().autoIncrement()(); - IntColumn get userId => - integer().references(UsersTable, #id, onDelete: KeyAction.cascade)(); - IntColumn get accountId => - integer().references(AccountsTable, #id, onDelete: KeyAction.cascade)(); + TextColumn get id => text()(); + TextColumn get userId => + text().references(UsersTable, #id, onDelete: KeyAction.cascade)(); + TextColumn get accountId => + text().references(AccountsTable, #id, onDelete: KeyAction.cascade)(); /// Категория (nullable — для переводов). - IntColumn get categoryId => integer() + TextColumn get categoryId => text() .references(CategoriesTable, #id, onDelete: KeyAction.setNull) .nullable()(); @@ -31,7 +31,10 @@ class TransactionsTable extends Table { TextColumn get note => text().withLength(max: 255).nullable()(); /// Для типа transfer: целевой счёт. - IntColumn get transferToAccountId => integer().nullable()(); + TextColumn get transferToAccountId => text().nullable()(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {id}; } diff --git a/lib/src/core/database/tables/users_table.dart b/lib/src/core/database/tables/users_table.dart index fd6ba0c..fe0ed46 100644 --- a/lib/src/core/database/tables/users_table.dart +++ b/lib/src/core/database/tables/users_table.dart @@ -5,7 +5,10 @@ class UsersTable extends Table { @override String get tableName => 'users'; - IntColumn get id => integer().autoIncrement()(); + TextColumn get id => text()(); TextColumn get name => text().withLength(min: 1, max: 50)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {id}; } diff --git a/lib/src/features/accounts/application/accounts_controller.dart b/lib/src/features/accounts/application/accounts_controller.dart index 5be6d89..8112622 100644 --- a/lib/src/features/accounts/application/accounts_controller.dart +++ b/lib/src/features/accounts/application/accounts_controller.dart @@ -7,12 +7,12 @@ part 'accounts_controller.g.dart'; /// Реактивный список счетов для активного пользователя. @riverpod -Stream> accountsStream(Ref ref, int userId) => +Stream> accountsStream(Ref ref, String userId) => ref.watch(accountRepositoryProvider).watchByUser(userId); /// Текущий баланс счёта. @riverpod -Stream accountBalance(Ref ref, int accountId) => +Stream accountBalance(Ref ref, String accountId) => ref.watch(accountRepositoryProvider).watchBalance(accountId); /// Контроллер CRUD-операций над счетами. @@ -22,7 +22,7 @@ class AccountsController extends _$AccountsController { AsyncValue build() => const AsyncData(null); Future createAccount({ - required int userId, + required String userId, required String name, required AccountType type, required String currency, @@ -59,7 +59,7 @@ class AccountsController extends _$AccountsController { return result.value!; } - Future archiveAccount(int id) async { + Future archiveAccount(String id) async { state = const AsyncLoading(); state = await AsyncValue.guard( () => ref.read(accountRepositoryProvider).archive(id), diff --git a/lib/src/features/accounts/data/repositories/account_repository_impl.dart b/lib/src/features/accounts/data/repositories/account_repository_impl.dart index 9ee8a20..7e53d27 100644 --- a/lib/src/features/accounts/data/repositories/account_repository_impl.dart +++ b/lib/src/features/accounts/data/repositories/account_repository_impl.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; import '../../../../core/database/app_database.dart'; import '../../../../core/database/daos/accounts_dao.dart'; import '../../../../core/database/converters/enum_converters.dart'; @@ -11,18 +12,18 @@ class AccountRepositoryImpl implements AccountRepository { final AccountsDao _dao; @override - Stream> watchByUser(int userId) => + Stream> watchByUser(String userId) => _dao.watchAccountsByUser(userId).map((rows) => rows.map((r) => r.toDomain()).toList()); @override - Future findById(int id) async { + Future findById(String id) async { final row = await _dao.findById(id); return row?.toDomain(); } @override Future create({ - required int userId, + required String userId, required String name, required AccountType type, required String currency, @@ -30,7 +31,9 @@ class AccountRepositoryImpl implements AccountRepository { int? iconCode, int? colorValue, }) async { - final id = await _dao.insertAccount(AccountsTableCompanion.insert( + final id = const Uuid().v4(); + await _dao.insertAccount(AccountsTableCompanion.insert( + id: id, userId: userId, name: name, type: Value(type), @@ -60,8 +63,8 @@ class AccountRepositoryImpl implements AccountRepository { } @override - Future archive(int id) => _dao.archiveAccount(id); + Future archive(String id) => _dao.archiveAccount(id); @override - Stream watchBalance(int accountId) => _dao.watchAccountBalance(accountId); + Stream watchBalance(String accountId) => _dao.watchAccountBalance(accountId); } diff --git a/lib/src/features/accounts/domain/entities/account.dart b/lib/src/features/accounts/domain/entities/account.dart index 4186547..6bf4271 100644 --- a/lib/src/features/accounts/domain/entities/account.dart +++ b/lib/src/features/accounts/domain/entities/account.dart @@ -6,8 +6,8 @@ part 'account.freezed.dart'; @freezed abstract class Account with _$Account { const factory Account({ - required int id, - required int userId, + required String id, + required String userId, required String name, required AccountType type, required String currency, diff --git a/lib/src/features/accounts/domain/repositories/account_repository.dart b/lib/src/features/accounts/domain/repositories/account_repository.dart index 321f793..12442da 100644 --- a/lib/src/features/accounts/domain/repositories/account_repository.dart +++ b/lib/src/features/accounts/domain/repositories/account_repository.dart @@ -2,10 +2,10 @@ import '../entities/account.dart'; import '../../../../core/database/converters/enum_converters.dart'; abstract interface class AccountRepository { - Stream> watchByUser(int userId); - Future findById(int id); + Stream> watchByUser(String userId); + Future findById(String id); Future create({ - required int userId, + required String userId, required String name, required AccountType type, required String currency, @@ -14,8 +14,8 @@ abstract interface class AccountRepository { int? colorValue, }); Future update(Account account); - Future archive(int id); + Future archive(String id); /// Текущий баланс счёта (начальный + агрегат транзакций). - Stream watchBalance(int accountId); + Stream watchBalance(String accountId); } diff --git a/lib/src/features/categories/application/categories_controller.dart b/lib/src/features/categories/application/categories_controller.dart index cb52abd..5ce9d3d 100644 --- a/lib/src/features/categories/application/categories_controller.dart +++ b/lib/src/features/categories/application/categories_controller.dart @@ -7,14 +7,14 @@ part 'categories_controller.g.dart'; /// Реактивный список категорий для активного пользователя. @riverpod -Stream> categoriesStream(Ref ref, int userId) => +Stream> categoriesStream(Ref ref, String userId) => ref.watch(categoryRepositoryProvider).watchByUser(userId); /// Реактивный список категорий, фильтрованный по типу (income / expense). @riverpod Stream> categoriesByTypeStream( Ref ref, - int userId, + String userId, CategoryType type, ) => ref.watch(categoryRepositoryProvider).watchByType(userId, type); @@ -26,12 +26,12 @@ class CategoriesController extends _$CategoriesController { AsyncValue build() => const AsyncData(null); Future createCategory({ - required int userId, + required String userId, required String name, required CategoryType type, int? iconCode, int? colorValue, - int? parentId, + String? parentId, }) async { state = const AsyncLoading(); final result = await AsyncValue.guard( @@ -61,7 +61,7 @@ class CategoriesController extends _$CategoriesController { return result.value!; } - Future archiveCategory(int id) async { + Future archiveCategory(String id) async { state = const AsyncLoading(); state = await AsyncValue.guard( () => ref.read(categoryRepositoryProvider).archive(id), diff --git a/lib/src/features/categories/data/repositories/category_repository_impl.dart b/lib/src/features/categories/data/repositories/category_repository_impl.dart index 38e8c12..c3c7cb8 100644 --- a/lib/src/features/categories/data/repositories/category_repository_impl.dart +++ b/lib/src/features/categories/data/repositories/category_repository_impl.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; import '../../../../core/database/app_database.dart'; import '../../../../core/database/daos/categories_dao.dart'; import '../../../../core/database/converters/enum_converters.dart'; @@ -11,29 +12,31 @@ class CategoryRepositoryImpl implements CategoryRepository { final CategoriesDao _dao; @override - Stream> watchByUser(int userId) => + Stream> watchByUser(String userId) => _dao.watchCategoriesByUser(userId).map((rows) => rows.map((r) => r.toDomain()).toList()); @override - Stream> watchByType(int userId, CategoryType type) => + Stream> watchByType(String userId, CategoryType type) => _dao.watchByType(userId, type).map((rows) => rows.map((r) => r.toDomain()).toList()); @override - Future findById(int id) async { + Future findById(String id) async { final row = await _dao.findById(id); return row?.toDomain(); } @override Future create({ - required int userId, + required String userId, required String name, required CategoryType type, int? iconCode, int? colorValue, - int? parentId, + String? parentId, }) async { - final id = await _dao.insertCategory(CategoriesTableCompanion.insert( + final id = const Uuid().v4(); + await _dao.insertCategory(CategoriesTableCompanion.insert( + id: id, userId: userId, name: name, type: Value(type), @@ -61,5 +64,5 @@ class CategoryRepositoryImpl implements CategoryRepository { } @override - Future archive(int id) => _dao.archiveCategory(id); + Future archive(String id) => _dao.archiveCategory(id); } diff --git a/lib/src/features/categories/domain/entities/category.dart b/lib/src/features/categories/domain/entities/category.dart index d74be70..12fb311 100644 --- a/lib/src/features/categories/domain/entities/category.dart +++ b/lib/src/features/categories/domain/entities/category.dart @@ -6,13 +6,13 @@ part 'category.freezed.dart'; @freezed abstract class Category with _$Category { const factory Category({ - required int id, - required int userId, + required String id, + required String userId, required String name, required CategoryType type, int? iconCode, int? colorValue, - int? parentId, + String? parentId, required bool archived, }) = _Category; } diff --git a/lib/src/features/categories/domain/repositories/category_repository.dart b/lib/src/features/categories/domain/repositories/category_repository.dart index bcbe43c..58e88ce 100644 --- a/lib/src/features/categories/domain/repositories/category_repository.dart +++ b/lib/src/features/categories/domain/repositories/category_repository.dart @@ -2,17 +2,17 @@ import '../../../../core/database/converters/enum_converters.dart'; import '../entities/category.dart'; abstract interface class CategoryRepository { - Stream> watchByUser(int userId); - Stream> watchByType(int userId, CategoryType type); - Future findById(int id); + Stream> watchByUser(String userId); + Stream> watchByType(String userId, CategoryType type); + Future findById(String id); Future create({ - required int userId, + required String userId, required String name, required CategoryType type, int? iconCode, int? colorValue, - int? parentId, + String? parentId, }); Future update(Category category); - Future archive(int id); + Future archive(String id); } diff --git a/lib/src/features/home/presentation/_mock_data.dart b/lib/src/features/home/presentation/_mock_data.dart deleted file mode 100644 index 1b88d40..0000000 --- a/lib/src/features/home/presentation/_mock_data.dart +++ /dev/null @@ -1,311 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../core/database/converters/enum_converters.dart'; -import '../../accounts/domain/entities/account.dart'; -import '../../categories/domain/entities/category.dart'; -import '../../transactions/domain/entities/transaction.dart'; - -const int mockUserId = 1; - -/// Sentinel id для виртуального счёта «Все счета» — это агрегат, а не запись. -const int kAllAccountsId = 0; - -/// Конвертирует hex `0xRRGGBB` в `argb` (без прозрачности) для хранения в -/// `colorValue` доменных сущностей (Account/Category). -int _hex(int rgb) => 0xFF000000 | rgb; - -// ─── Категории (соответствуют design/common.jsx:132 — CATS) ────────── -const int _icoFood = 1; // shopping_cart_outlined -const int _icoHouse = 2; // home_outlined -const int _icoTransp = 3; // directions_car_outlined -const int _icoCafe = 4; // restaurant_outlined -const int _icoEnter = 5; // movie_outlined -const int _icoOther = 6; // more_horiz - -const int catFoodId = 11; -const int catRentId = 12; -const int catTranspId = 13; -const int catCafeId = 14; -const int catEnterId = 15; -const int catOtherId = 16; - -final List _mockCategories = [ - Category( - id: catFoodId, - userId: mockUserId, - name: 'Продукты', - type: CategoryType.expense, - iconCode: _icoFood, - colorValue: _hex(0x8AA6A0), - archived: false, - ), - Category( - id: catRentId, - userId: mockUserId, - name: 'Жильё', - type: CategoryType.expense, - iconCode: _icoHouse, - colorValue: _hex(0xC89A86), - archived: false, - ), - Category( - id: catTranspId, - userId: mockUserId, - name: 'Транспорт', - type: CategoryType.expense, - iconCode: _icoTransp, - colorValue: _hex(0xB3A589), - archived: false, - ), - Category( - id: catCafeId, - userId: mockUserId, - name: 'Кафе', - type: CategoryType.expense, - iconCode: _icoCafe, - colorValue: _hex(0x9FB38A), - archived: false, - ), - Category( - id: catEnterId, - userId: mockUserId, - name: 'Досуг', - type: CategoryType.expense, - iconCode: _icoEnter, - colorValue: _hex(0xA99CB9), - archived: false, - ), - Category( - id: catOtherId, - userId: mockUserId, - name: 'Другое', - type: CategoryType.expense, - iconCode: _icoOther, - colorValue: _hex(0xB8B5AC), - archived: false, - ), -]; - -IconData iconForCategory(Category c) { - switch (c.iconCode) { - case _icoFood: - return Icons.shopping_cart_outlined; - case _icoHouse: - return Icons.home_outlined; - case _icoTransp: - return Icons.directions_car_outlined; - case _icoCafe: - return Icons.restaurant_outlined; - case _icoEnter: - return Icons.movie_outlined; - default: - return Icons.more_horiz; - } -} - -// ─── Счета (3 реальных; «Все» — виртуальная вкладка) ────────────────── -const int accCardId = 21; -const int accCashId = 22; -const int accSaveId = 23; - -final DateTime _now = DateTime.now(); -final DateTime _createdAt = _now.subtract(const Duration(days: 60)); - -final List _mockAccounts = [ - Account( - id: accCardId, - userId: mockUserId, - name: 'Карта', - type: AccountType.card, - currency: 'RUB', - initialBalance: 14250000, // 142 500 ₽ - iconCode: null, - colorValue: null, - archived: false, - createdAt: _createdAt, - ), - Account( - id: accCashId, - userId: mockUserId, - name: 'Наличные', - type: AccountType.cash, - currency: 'RUB', - initialBalance: 1282000, // 12 820 ₽ - iconCode: null, - colorValue: null, - archived: false, - createdAt: _createdAt, - ), - Account( - id: accSaveId, - userId: mockUserId, - name: 'Копилка', - type: AccountType.savings, - currency: 'RUB', - initialBalance: 2900000, // 29 000 ₽ - iconCode: null, - colorValue: null, - archived: false, - createdAt: _createdAt, - ), -]; - -IconData iconForAccount(Account a) { - switch (a.type) { - case AccountType.cash: - return Icons.payments_outlined; - case AccountType.card: - return Icons.credit_card_outlined; - case AccountType.bank: - return Icons.account_balance_outlined; - case AccountType.savings: - return Icons.savings_outlined; - } -} - -String shortAccountLabel(Account a) { - switch (a.type) { - case AccountType.cash: - return 'Кэш'; - case AccountType.card: - return 'Карта'; - case AccountType.bank: - return 'Банк'; - case AccountType.savings: - return 'Копилка'; - } -} - -// ─── Транзакции (из design/common.jsx:165 — TX) ────────────────────── -Transaction _tx({ - required int id, - required int categoryId, - required int accountId, - required TransactionType type, - required int amount, - required DateTime date, - required String merchant, -}) { - return Transaction( - id: id, - userId: mockUserId, - accountId: accountId, - categoryId: categoryId, - type: type, - amount: amount, - date: date, - note: merchant, - transferToAccountId: null, - createdAt: date, - ); -} - -DateTime _atToday(int h, int m) => - DateTime(_now.year, _now.month, _now.day, h, m); -DateTime _atYesterday(int h, int m) => - _atToday(h, m).subtract(const Duration(days: 1)); -DateTime _daysAgo(int d, [int h = 12, int m = 0]) => - DateTime(_now.year, _now.month, _now.day, h, m) - .subtract(Duration(days: d)); - -final List _mockTransactions = [ - _tx( - id: 1, - categoryId: catFoodId, - accountId: accCardId, - type: TransactionType.expense, - amount: 234000, - date: _atToday(19, 42), - merchant: 'Лента', - ), - _tx( - id: 2, - categoryId: catCafeId, - accountId: accCardId, - type: TransactionType.expense, - amount: 48000, - date: _atToday(9, 15), - merchant: 'Кофе Хауз', - ), - _tx( - id: 3, - categoryId: catTranspId, - accountId: accCardId, - type: TransactionType.expense, - amount: 6200, - date: _atToday(8, 50), - merchant: 'Метро', - ), - _tx( - id: 4, - categoryId: catFoodId, - accountId: accCashId, - type: TransactionType.expense, - amount: 112000, - date: _atYesterday(21, 8), - merchant: 'Перекрёсток', - ), - _tx( - id: 5, - categoryId: catEnterId, - accountId: accCardId, - type: TransactionType.expense, - amount: 65000, - date: _atYesterday(19, 30), - merchant: 'Кинотеатр', - ), - _tx( - id: 6, - categoryId: catRentId, - accountId: accCardId, - type: TransactionType.expense, - amount: 3200000, - date: _daysAgo(3, 12, 0), - merchant: 'Аренда квартиры', - ), - _tx( - id: 7, - categoryId: catOtherId, - accountId: accCardId, - type: TransactionType.income, - amount: 9500000, - date: _daysAgo(4, 11, 0), - merchant: 'Зарплата', - ), - _tx( - id: 8, - categoryId: catTranspId, - accountId: accCardId, - type: TransactionType.expense, - amount: 34000, - date: _daysAgo(4, 18, 30), - merchant: 'Яндекс Такси', - ), - _tx( - id: 9, - categoryId: catCafeId, - accountId: accCashId, - type: TransactionType.expense, - amount: 72000, - date: _daysAgo(5, 14, 0), - merchant: 'Шоколадница', - ), - _tx( - id: 10, - categoryId: catFoodId, - accountId: accCardId, - type: TransactionType.expense, - amount: 89000, - date: _daysAgo(5, 9, 20), - merchant: 'Магнит', - ), -]; - -// ─── Provider-обёртки. Когда придёт время — заменяются на стримы из -// application/ слоя без изменений на стороне виджетов. ──────────── -final mockAccountsProvider = Provider>((ref) => _mockAccounts); -final mockCategoriesProvider = - Provider>((ref) => _mockCategories); -final mockTransactionsProvider = - Provider>((ref) => _mockTransactions); diff --git a/lib/src/features/home/presentation/month_summary.dart b/lib/src/features/home/presentation/month_summary.dart index 5d994ff..4bedde0 100644 --- a/lib/src/features/home/presentation/month_summary.dart +++ b/lib/src/features/home/presentation/month_summary.dart @@ -1,13 +1,15 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../../core/database/converters/enum_converters.dart'; +import '../../accounts/application/accounts_controller.dart'; import '../../accounts/domain/entities/account.dart'; import '../../categories/domain/entities/category.dart'; -import '../../../core/database/converters/enum_converters.dart'; +import '../../transactions/application/transactions_controller.dart'; import '../../transactions/domain/entities/transaction.dart'; -import '_mock_data.dart'; import 'state/selected_category_filter.dart'; +part 'month_summary.g.dart'; + /// Сводка по месяцу — баланс, доходы, расходы для текущего выбранного счёта. class MonthSummary { const MonthSummary({ @@ -21,40 +23,47 @@ class MonthSummary { final int balanceMinor; final int incomeMinor; final int expensesMinor; - final Map spendByCategory; // categoryId -> sum in minor units + final Map spendByCategory; // categoryId → sum in minor units final int transactionsCount; int get spendTotalMinor => spendByCategory.values.fold(0, (sum, v) => sum + v); } -/// Возвращает транзакции отфильтрованные по выбранному счёту и опционально -/// по выбранной категории. -final filteredTransactionsProvider = Provider>((ref) { - final all = ref.watch(mockTransactionsProvider); +/// Транзакции пользователя, отфильтрованные по выбранному счёту и (опционально) +/// по выбранной категории. Источник — `transactionsStream`. Во время первичной +/// загрузки возвращается пустой список. +@riverpod +List filteredTransactions(Ref ref, String userId) { + final all = ref.watch(transactionsStreamProvider(userId)).value ?? + const []; final accountId = ref.watch(selectedAccountProvider); final categoryId = ref.watch(selectedCategoryFilterProvider); return all.where((t) { - if (accountId != 0 && t.accountId != accountId) return false; + if (accountId.isNotEmpty && t.accountId != accountId) return false; if (categoryId != null && t.categoryId != categoryId) return false; return true; }).toList(); -}); +} -final monthSummaryProvider = Provider((ref) { - final accounts = ref.watch(mockAccountsProvider); - final txs = ref.watch(mockTransactionsProvider); +/// Сводка по месяцу для активного пользователя. Балансы считаются без учёта +/// фильтра категории — баланс счёта от неё не зависит. +@riverpod +MonthSummary monthSummary(Ref ref, String userId) { + final accounts = ref.watch(accountsStreamProvider(userId)).value ?? + const []; + final txs = ref.watch(transactionsStreamProvider(userId)).value ?? + const []; final selectedAccount = ref.watch(selectedAccountProvider); - // Балансы считаем не учитывая фильтр категории (баланс счёта от неё не зависит). - final scopedTxs = selectedAccount == 0 + final scopedTxs = selectedAccount.isEmpty ? txs : txs.where((t) => t.accountId == selectedAccount).toList(); var income = 0; var expense = 0; - final spendByCat = {}; + final spendByCat = {}; for (final t in scopedTxs) { switch (t.type) { case TransactionType.income: @@ -70,8 +79,7 @@ final monthSummaryProvider = Provider((ref) { } } - // Баланс = сумма initialBalance по выбранным счетам + доход - расход. - final selectedAccounts = selectedAccount == 0 + final selectedAccounts = selectedAccount.isEmpty ? accounts : accounts.where((a) => a.id == selectedAccount); final baseBalance = selectedAccounts.fold( @@ -86,11 +94,11 @@ final monthSummaryProvider = Provider((ref) { spendByCategory: spendByCat, transactionsCount: scopedTxs.length, ); -}); +} -/// Возвращает категории, отсортированные по сумме трат (по убыванию). +/// Категории, отсортированные по сумме трат (по убыванию). List> categoriesBySpend( - Map spend, + Map spend, List categories, ) { final result = >[]; @@ -101,5 +109,3 @@ List> categoriesBySpend( result.sort((a, b) => b.value.compareTo(a.value)); return result; } - -Color colorFor(Category c) => Color(c.colorValue ?? 0xFFB8B5AC); diff --git a/lib/src/features/home/presentation/screens/home_screen.dart b/lib/src/features/home/presentation/screens/home_screen.dart index 347f50c..1729d95 100644 --- a/lib/src/features/home/presentation/screens/home_screen.dart +++ b/lib/src/features/home/presentation/screens/home_screen.dart @@ -4,9 +4,11 @@ import 'package:intl/intl.dart'; import '../../../../app/l10n/l10n.dart'; import '../../../../app/theme/app_colors.dart'; +import '../../../../core/database/converters/enum_converters.dart'; +import '../../../categories/application/categories_controller.dart'; import '../../../categories/domain/entities/category.dart'; import '../../../transactions/domain/entities/transaction.dart'; -import '../_mock_data.dart'; +import '../../../user/application/active_user_controller.dart'; import '../month_summary.dart'; import '../widgets/account_tabs.dart'; import '../widgets/category_donut_card.dart'; @@ -23,57 +25,84 @@ class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final p = context.palette; - final l10n = context.l10n; - final locale = Localizations.localeOf(context).toString(); - final categories = ref.watch(mockCategoriesProvider); - final categoryById = {for (final c in categories) c.id: c}; - final txs = ref.watch(filteredTransactionsProvider); - final groups = _groupByDay(txs, locale, l10n); + final activeUserAsync = ref.watch(activeUserControllerProvider); return Scaffold( backgroundColor: p.paper, - body: Stack( - children: [ - SafeArea( - child: CustomScrollView( - slivers: [ - const SliverToBoxAdapter(child: MonthHeader()), - const SliverToBoxAdapter(child: AccountTabs()), - const SliverToBoxAdapter(child: SizedBox(height: 0)), - const SliverToBoxAdapter(child: MonthKpiCard()), - const SliverToBoxAdapter(child: CategoryDonutCard()), - const SliverToBoxAdapter(child: TransactionsSectionHeader()), - const SliverToBoxAdapter(child: CategoryFilterPill()), - const SliverToBoxAdapter(child: SizedBox(height: 4)), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, i) => _DayGroupBlock( - group: groups[i], - categoryById: categoryById, - ), - childCount: groups.length, - ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 96)), - ], - ), - ), - Positioned( - right: 16, - bottom: 16, - child: FabAddTransaction(onPressed: () {}), - ), - ], + body: activeUserAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e')), + data: (user) { + // Redirect в роутере уже отправит на /onboarding, если user == null. + // Этот кейс — короткое окно между сменой состояния и переходом. + if (user == null) { + return const SizedBox.shrink(); + } + return _HomeContent(userId: user.id); + }, ), ); } } +class _HomeContent extends ConsumerWidget { + const _HomeContent({required this.userId}); + + final String userId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final locale = Localizations.localeOf(context).toString(); + final categories = + ref.watch(categoriesStreamProvider(userId)).value ?? + const []; + final categoryById = {for (final c in categories) c.id: c}; + final txs = ref.watch(filteredTransactionsProvider(userId)); + final groups = _groupByDay(txs, locale, l10n); + + return Stack( + children: [ + SafeArea( + child: CustomScrollView( + slivers: [ + const SliverToBoxAdapter(child: MonthHeader()), + SliverToBoxAdapter(child: AccountTabs(userId: userId)), + const SliverToBoxAdapter(child: SizedBox(height: 0)), + SliverToBoxAdapter(child: MonthKpiCard(userId: userId)), + SliverToBoxAdapter(child: CategoryDonutCard(userId: userId)), + SliverToBoxAdapter( + child: TransactionsSectionHeader(userId: userId)), + SliverToBoxAdapter(child: CategoryFilterPill(userId: userId)), + const SliverToBoxAdapter(child: SizedBox(height: 4)), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, i) => _DayGroupBlock( + group: groups[i], + categoryById: categoryById, + ), + childCount: groups.length, + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 96)), + ], + ), + ), + Positioned( + right: 16, + bottom: 16, + child: FabAddTransaction(onPressed: () {}), + ), + ], + ); + } +} + class _DayGroupBlock extends StatelessWidget { const _DayGroupBlock({required this.group, required this.categoryById}); final _DayGroup group; - final Map categoryById; + final Map categoryById; @override Widget build(BuildContext context) { @@ -88,7 +117,11 @@ class _DayGroupBlock extends StatelessWidget { } class _DayGroup { - _DayGroup({required this.label, required this.items, required this.totalSpentMinor}); + _DayGroup({ + required this.label, + required this.items, + required this.totalSpentMinor, + }); final String label; final List items; final int totalSpentMinor; @@ -116,21 +149,12 @@ List<_DayGroup> _groupByDay( label: _dayLabel(k, today, fmt, l10n), items: map[k]!, totalSpentMinor: map[k]! - .where((t) => t.amount > 0 && t.note != null) - .where((t) => _isExpense(t)) + .where((t) => t.type == TransactionType.expense) .fold(0, (s, t) => s + t.amount), ), ]; } -bool _isExpense(Transaction t) { - return t.amount > 0 && t.categoryId != null && _isExpenseType(t); -} - -bool _isExpenseType(Transaction t) { - return t.type.name == 'expense'; -} - String _dayLabel( DateTime day, DateTime today, diff --git a/lib/src/features/home/presentation/state/selected_category_filter.dart b/lib/src/features/home/presentation/state/selected_category_filter.dart index b34c480..74c05fc 100644 --- a/lib/src/features/home/presentation/state/selected_category_filter.dart +++ b/lib/src/features/home/presentation/state/selected_category_filter.dart @@ -2,21 +2,24 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'selected_category_filter.g.dart'; +/// Sentinel id для виртуального счёта «Все счета» — это агрегат, а не запись. +const String kAllAccountsId = ''; + /// id выбранной категории для фильтра списка транзакций. /// `null` — фильтр снят, отображаются все категории. @riverpod class SelectedCategoryFilter extends _$SelectedCategoryFilter { @override - int? build() => null; + String? build() => null; - void select(int? id) => state = id; + void select(String? id) => state = id; } -/// id выбранного счёта в табах. `kAllAccountsId` (0) — «Все счета». +/// id выбранного счёта в табах. `kAllAccountsId` ('') — «Все счета». @riverpod class SelectedAccount extends _$SelectedAccount { @override - int build() => 0; + String build() => kAllAccountsId; - void select(int id) => state = id; + void select(String id) => state = id; } diff --git a/lib/src/features/home/presentation/widgets/account_tabs.dart b/lib/src/features/home/presentation/widgets/account_tabs.dart index c8b7cf0..e282c34 100644 --- a/lib/src/features/home/presentation/widgets/account_tabs.dart +++ b/lib/src/features/home/presentation/widgets/account_tabs.dart @@ -3,16 +3,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../app/l10n/l10n.dart'; import '../../../../app/theme/app_colors.dart'; +import '../../../accounts/application/accounts_controller.dart'; import '../../../accounts/domain/entities/account.dart'; -import '../_mock_data.dart'; +import '../../../accounts/presentation/widgets/account_icon.dart'; import '../state/selected_category_filter.dart'; class AccountTabs extends ConsumerWidget { - const AccountTabs({super.key}); + const AccountTabs({super.key, required this.userId}); + + final String userId; @override Widget build(BuildContext context, WidgetRef ref) { - final accounts = ref.watch(mockAccountsProvider); + final accounts = + ref.watch(accountsStreamProvider(userId)).value ?? + const []; final selected = ref.watch(selectedAccountProvider); return SizedBox( @@ -25,8 +30,9 @@ class AccountTabs extends ConsumerWidget { icon: Icons.account_balance_wallet_outlined, label: context.l10n.allAccounts, active: selected == kAllAccountsId, - onTap: () => - ref.read(selectedAccountProvider.notifier).select(kAllAccountsId), + onTap: () => ref + .read(selectedAccountProvider.notifier) + .select(kAllAccountsId), ), for (final a in accounts) ...[ const SizedBox(width: 8), diff --git a/lib/src/features/home/presentation/widgets/category_donut.dart b/lib/src/features/home/presentation/widgets/category_donut.dart index 0ce1797..28dc491 100644 --- a/lib/src/features/home/presentation/widgets/category_donut.dart +++ b/lib/src/features/home/presentation/widgets/category_donut.dart @@ -8,7 +8,7 @@ class DonutSlice { required this.color, }); - final int id; + final String id; final double value; final Color color; } @@ -27,8 +27,8 @@ class CategoryDonut extends StatelessWidget { final List slices; final double size; final double thickness; - final int? activeId; - final ValueChanged? onSegmentTap; + final String? activeId; + final ValueChanged? onSegmentTap; final Widget? centerChild; @override diff --git a/lib/src/features/home/presentation/widgets/category_donut_card.dart b/lib/src/features/home/presentation/widgets/category_donut_card.dart index 9e8dae3..8688cc6 100644 --- a/lib/src/features/home/presentation/widgets/category_donut_card.dart +++ b/lib/src/features/home/presentation/widgets/category_donut_card.dart @@ -3,22 +3,27 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../app/l10n/l10n.dart'; import '../../../../app/theme/app_colors.dart'; +import '../../../categories/application/categories_controller.dart'; import '../../../categories/domain/entities/category.dart'; -import '../_mock_data.dart'; +import '../../../categories/presentation/widgets/category_icon.dart'; import '../month_summary.dart'; import '../state/selected_category_filter.dart'; import 'category_donut.dart'; import 'money_text.dart'; class CategoryDonutCard extends ConsumerWidget { - const CategoryDonutCard({super.key}); + const CategoryDonutCard({super.key, required this.userId}); + + final String userId; @override Widget build(BuildContext context, WidgetRef ref) { final p = context.palette; final l10n = context.l10n; - final summary = ref.watch(monthSummaryProvider); - final categories = ref.watch(mockCategoriesProvider); + final summary = ref.watch(monthSummaryProvider(userId)); + final categories = + ref.watch(categoriesStreamProvider(userId)).value ?? + const []; final selectedCat = ref.watch(selectedCategoryFilterProvider); final sorted = categoriesBySpend(summary.spendByCategory, categories); @@ -27,7 +32,7 @@ class CategoryDonutCard extends ConsumerWidget { DonutSlice( id: entry.key.id, value: entry.value.toDouble(), - color: colorFor(entry.key), + color: colorForCategory(entry.key), ), ]; @@ -74,7 +79,9 @@ class CategoryDonutCard extends ConsumerWidget { ), ), const SizedBox(width: 14), - Expanded(child: _Legend(entries: sorted, totalMinor: summary.spendTotalMinor)), + Expanded( + child: + _Legend(entries: sorted, totalMinor: summary.spendTotalMinor)), ], ), ); @@ -107,7 +114,7 @@ class _Legend extends StatelessWidget { width: 8, height: 8, decoration: BoxDecoration( - color: colorFor(e.key), + color: colorForCategory(e.key), borderRadius: BorderRadius.circular(2), ), ), @@ -120,9 +127,7 @@ class _Legend extends StatelessWidget { ), ), MoneyText( - totalMinor == 0 - ? 0 - : (e.value * 100 ~/ totalMinor), + totalMinor == 0 ? 0 : (e.value * 100 ~/ totalMinor), color: p.ink2, fontSize: 12, withCurrency: false, diff --git a/lib/src/features/home/presentation/widgets/month_kpi_card.dart b/lib/src/features/home/presentation/widgets/month_kpi_card.dart index b5ae30a..96cb0a9 100644 --- a/lib/src/features/home/presentation/widgets/month_kpi_card.dart +++ b/lib/src/features/home/presentation/widgets/month_kpi_card.dart @@ -7,13 +7,15 @@ import '../month_summary.dart'; import 'money_text.dart'; class MonthKpiCard extends ConsumerWidget { - const MonthKpiCard({super.key}); + const MonthKpiCard({super.key, required this.userId}); + + final String userId; @override Widget build(BuildContext context, WidgetRef ref) { final p = context.palette; final l10n = context.l10n; - final s = ref.watch(monthSummaryProvider); + final s = ref.watch(monthSummaryProvider(userId)); return Container( margin: const EdgeInsets.fromLTRB(16, 12, 16, 14), diff --git a/lib/src/features/home/presentation/widgets/transactions_section.dart b/lib/src/features/home/presentation/widgets/transactions_section.dart index edf9dc9..1ce8e7b 100644 --- a/lib/src/features/home/presentation/widgets/transactions_section.dart +++ b/lib/src/features/home/presentation/widgets/transactions_section.dart @@ -3,18 +3,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../app/l10n/l10n.dart'; import '../../../../app/theme/app_colors.dart'; -import '../_mock_data.dart'; +import '../../../categories/application/categories_controller.dart'; +import '../../../categories/domain/entities/category.dart'; +import '../../../categories/presentation/widgets/category_icon.dart'; import '../month_summary.dart'; import '../state/selected_category_filter.dart'; class TransactionsSectionHeader extends ConsumerWidget { - const TransactionsSectionHeader({super.key}); + const TransactionsSectionHeader({super.key, required this.userId}); + + final String userId; @override Widget build(BuildContext context, WidgetRef ref) { final p = context.palette; final l10n = context.l10n; - final count = ref.watch(filteredTransactionsProvider).length; + final count = ref.watch(filteredTransactionsProvider(userId)).length; return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Row( @@ -39,19 +43,25 @@ class TransactionsSectionHeader extends ConsumerWidget { } class CategoryFilterPill extends ConsumerWidget { - const CategoryFilterPill({super.key}); + const CategoryFilterPill({super.key, required this.userId}); + + final String userId; @override Widget build(BuildContext context, WidgetRef ref) { final p = context.palette; final l10n = context.l10n; - final categories = ref.watch(mockCategoriesProvider); + final categories = + ref.watch(categoriesStreamProvider(userId)).value ?? + const []; final selectedId = ref.watch(selectedCategoryFilterProvider); - final activeCat = selectedId == null + final activeCat = selectedId == null || categories.isEmpty ? null - : categories.firstWhere((c) => c.id == selectedId, - orElse: () => categories.first); - final count = ref.watch(filteredTransactionsProvider).length; + : categories.firstWhere( + (c) => c.id == selectedId, + orElse: () => categories.first, + ); + final count = ref.watch(filteredTransactionsProvider(userId)).length; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -81,7 +91,7 @@ class CategoryFilterPill extends ConsumerWidget { width: 8, height: 8, decoration: BoxDecoration( - color: colorFor(activeCat), + color: colorForCategory(activeCat), borderRadius: BorderRadius.circular(2), ), ), diff --git a/lib/src/features/home/presentation/widgets/tx_row.dart b/lib/src/features/home/presentation/widgets/tx_row.dart index bc7c249..1f8d338 100644 --- a/lib/src/features/home/presentation/widgets/tx_row.dart +++ b/lib/src/features/home/presentation/widgets/tx_row.dart @@ -4,9 +4,8 @@ import 'package:intl/intl.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../core/database/converters/enum_converters.dart'; import '../../../categories/domain/entities/category.dart'; +import '../../../categories/presentation/widgets/category_icon.dart'; import '../../../transactions/domain/entities/transaction.dart'; -import '../_mock_data.dart'; -import '../month_summary.dart'; import 'money_text.dart'; class TxRow extends StatelessWidget { @@ -23,7 +22,7 @@ class TxRow extends StatelessWidget { Widget build(BuildContext context) { final p = context.palette; final cat = category; - final color = cat != null ? colorFor(cat) : p.ink2; + final color = cat != null ? colorForCategory(cat) : p.ink2; final icon = cat != null ? iconForCategory(cat) : Icons.more_horiz; final signedAmount = tx.type == TransactionType.income diff --git a/lib/src/features/settings/application/settings_controller.dart b/lib/src/features/settings/application/settings_controller.dart index f195038..35b3a84 100644 --- a/lib/src/features/settings/application/settings_controller.dart +++ b/lib/src/features/settings/application/settings_controller.dart @@ -19,7 +19,7 @@ part 'settings_controller.g.dart'; @riverpod class SettingsController extends _$SettingsController { @override - Future build(int userId) async { + Future build(String userId) async { // Подписываемся на стрим — при изменении в БД состояние обновится автоматически. final sub = ref.listen( settingsStreamProvider(userId), diff --git a/lib/src/features/settings/application/settings_providers.dart b/lib/src/features/settings/application/settings_providers.dart index 9539953..16b3e44 100644 --- a/lib/src/features/settings/application/settings_providers.dart +++ b/lib/src/features/settings/application/settings_providers.dart @@ -15,5 +15,5 @@ SettingsRepository settingsRepository(Ref ref) { /// Реактивный поток настроек для конкретного пользователя. @riverpod -Stream settingsStream(Ref ref, int userId) => +Stream settingsStream(Ref ref, String userId) => ref.watch(settingsRepositoryProvider).watchSettings(userId); diff --git a/lib/src/features/settings/data/repositories/settings_repository_impl.dart b/lib/src/features/settings/data/repositories/settings_repository_impl.dart index 3034a01..f14a89d 100644 --- a/lib/src/features/settings/data/repositories/settings_repository_impl.dart +++ b/lib/src/features/settings/data/repositories/settings_repository_impl.dart @@ -15,11 +15,11 @@ class SettingsRepositoryImpl implements SettingsRepository { // ── Чтение ──────────────────────────────────────────────────────────────── @override - Stream watchSettings(int userId) => + Stream watchSettings(String userId) => _dao.watchSettingsByUser(userId).map((row) => row?.toDomain()); @override - Future getSettings(int userId) async { + Future getSettings(String userId) async { final row = await _dao.getSettingsByUser(userId); return row?.toDomain(); } @@ -31,7 +31,7 @@ class SettingsRepositoryImpl implements SettingsRepository { _dao.upsertSettings(_toCompanion(settings)); @override - Future ensureDefaults(int userId) async { + Future ensureDefaults(String userId) async { final existing = await getSettings(userId); if (existing != null) return existing; @@ -50,25 +50,25 @@ class SettingsRepositoryImpl implements SettingsRepository { // ── Точечные обновления ──────────────────────────────────────────────────── @override - Future updateBaseCurrency(int userId, String currency) async { + Future updateBaseCurrency(String userId, String currency) async { final current = await _requireSettings(userId); await upsertSettings(current.copyWith(baseCurrency: currency)); } @override - Future updateThemeMode(int userId, AppThemeMode themeMode) async { + Future updateThemeMode(String userId, AppThemeMode themeMode) async { final current = await _requireSettings(userId); await upsertSettings(current.copyWith(themeMode: themeMode)); } @override - Future updateLocale(int userId, String locale) async { + Future updateLocale(String userId, String locale) async { final current = await _requireSettings(userId); await upsertSettings(current.copyWith(locale: locale)); } @override - Future updateFirstDayOfMonth(int userId, int day) async { + Future updateFirstDayOfMonth(String userId, int day) async { assert(day >= 1 && day <= 28, 'firstDayOfMonth must be between 1 and 28'); final current = await _requireSettings(userId); await upsertSettings(current.copyWith(firstDayOfMonth: day)); @@ -77,7 +77,7 @@ class SettingsRepositoryImpl implements SettingsRepository { // ── Helpers ─────────────────────────────────────────────────────────────── /// Возвращает текущие настройки, создавая дефолтные при отсутствии. - Future _requireSettings(int userId) => + Future _requireSettings(String userId) => ensureDefaults(userId); static SettingsTableCompanion _toCompanion(Settings s) => diff --git a/lib/src/features/settings/domain/entities/settings.dart b/lib/src/features/settings/domain/entities/settings.dart index 98698a8..cd87d0c 100644 --- a/lib/src/features/settings/domain/entities/settings.dart +++ b/lib/src/features/settings/domain/entities/settings.dart @@ -9,7 +9,7 @@ part 'settings.freezed.dart'; abstract class Settings with _$Settings { const factory Settings({ /// FK → User.id; одновременно является PK таблицы settings. - required int userId, + required String userId, /// ISO 4217-код валюты по умолчанию, напр. 'RUB', 'USD'. required String baseCurrency, diff --git a/lib/src/features/settings/domain/repositories/settings_repository.dart b/lib/src/features/settings/domain/repositories/settings_repository.dart index fa0b34d..dae9635 100644 --- a/lib/src/features/settings/domain/repositories/settings_repository.dart +++ b/lib/src/features/settings/domain/repositories/settings_repository.dart @@ -6,28 +6,28 @@ import '../entities/settings.dart'; abstract interface class SettingsRepository { /// Реактивный поток настроек пользователя. /// Испускает null, если запись ещё не создана. - Stream watchSettings(int userId); + Stream watchSettings(String userId); /// Однократное чтение настроек. null если не существует. - Future getSettings(int userId); + Future getSettings(String userId); /// Создать или обновить настройки (upsert по userId). Future upsertSettings(Settings settings); /// Создать запись с дефолтными значениями, если она ещё не существует. - Future ensureDefaults(int userId); + Future ensureDefaults(String userId); // ── Точечные обновления ──────────────────────────────────────────────────── /// Изменить валюту по умолчанию. - Future updateBaseCurrency(int userId, String currency); + Future updateBaseCurrency(String userId, String currency); /// Изменить тему оформления. - Future updateThemeMode(int userId, AppThemeMode themeMode); + Future updateThemeMode(String userId, AppThemeMode themeMode); /// Изменить локаль. - Future updateLocale(int userId, String locale); + Future updateLocale(String userId, String locale); /// Изменить первый день месяца/недели (1–28). - Future updateFirstDayOfMonth(int userId, int day); + Future updateFirstDayOfMonth(String userId, int day); } diff --git a/lib/src/features/transactions/application/transactions_controller.dart b/lib/src/features/transactions/application/transactions_controller.dart index 58035c3..28d9504 100644 --- a/lib/src/features/transactions/application/transactions_controller.dart +++ b/lib/src/features/transactions/application/transactions_controller.dart @@ -13,19 +13,12 @@ part 'transactions_controller.g.dart'; /// /// Все параметры фильтрации опциональны; при их отсутствии возвращаются все /// транзакции пользователя, отсортированные по дате (DESC). -/// -/// Пример использования в виджете: -/// ```dart -/// final txStream = ref.watch( -/// transactionsStreamProvider(userId, type: TransactionType.expense), -/// ); -/// ``` @riverpod Stream> transactionsStream( Ref ref, - int userId, { - int? accountId, - int? categoryId, + String userId, { + String? accountId, + String? categoryId, TransactionType? type, DateTime? from, DateTime? to, @@ -59,14 +52,14 @@ class TransactionsController extends _$TransactionsController { /// [amount] должен быть > 0 (минорные единицы). /// Для перевода ([TransactionType.transfer]) передайте [transferToAccountId]. Future createTransaction({ - required int userId, - required int accountId, - int? categoryId, + required String userId, + required String accountId, + String? categoryId, required TransactionType type, required int amount, required DateTime date, String? note, - int? transferToAccountId, + String? transferToAccountId, }) async { state = const AsyncLoading(); final result = await AsyncValue.guard( @@ -100,7 +93,7 @@ class TransactionsController extends _$TransactionsController { } /// Удаляет транзакцию по [id]. - Future deleteTransaction(int id) async { + Future deleteTransaction(String id) async { state = const AsyncLoading(); state = await AsyncValue.guard( () => ref.read(transactionRepositoryProvider).delete(id), diff --git a/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart b/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart index 7329745..e53fd99 100644 --- a/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart +++ b/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; import '../../../../core/database/app_database.dart'; import '../../../../core/database/daos/transactions_dao.dart'; import '../../../../core/database/converters/enum_converters.dart'; @@ -12,9 +13,9 @@ class TransactionRepositoryImpl implements TransactionRepository { @override Stream> watchTransactions({ - required int userId, - int? accountId, - int? categoryId, + required String userId, + String? accountId, + String? categoryId, TransactionType? type, DateTime? from, DateTime? to, @@ -33,24 +34,26 @@ class TransactionRepositoryImpl implements TransactionRepository { } @override - Future findById(int id) async { + Future findById(String id) async { final row = await _dao.findById(id); return row?.toDomain(); } @override Future create({ - required int userId, - required int accountId, - int? categoryId, + required String userId, + required String accountId, + String? categoryId, required TransactionType type, required int amount, required DateTime date, String? note, - int? transferToAccountId, + String? transferToAccountId, }) async { - final newId = await _dao.insertTransaction( + final id = const Uuid().v4(); + await _dao.insertTransaction( TransactionsTableCompanion.insert( + id: id, userId: userId, accountId: accountId, categoryId: Value(categoryId), @@ -61,7 +64,7 @@ class TransactionRepositoryImpl implements TransactionRepository { transferToAccountId: Value(transferToAccountId), ), ); - final row = await _dao.findById(newId); + final row = await _dao.findById(id); return row!.toDomain(); } @@ -86,7 +89,7 @@ class TransactionRepositoryImpl implements TransactionRepository { } @override - Future delete(int id) async { + Future delete(String id) async { await _dao.deleteTransaction(id); } } diff --git a/lib/src/features/transactions/domain/entities/transaction.dart b/lib/src/features/transactions/domain/entities/transaction.dart index 13fb784..6270807 100644 --- a/lib/src/features/transactions/domain/entities/transaction.dart +++ b/lib/src/features/transactions/domain/entities/transaction.dart @@ -14,10 +14,10 @@ part 'transaction.freezed.dart'; @freezed abstract class Transaction with _$Transaction { const factory Transaction({ - required int id, - required int userId, - required int accountId, - int? categoryId, + required String id, + required String userId, + required String accountId, + String? categoryId, required TransactionType type, /// Сумма в минорных единицах (всегда > 0). @@ -26,7 +26,7 @@ abstract class Transaction with _$Transaction { String? note, /// Целевой счёт для переводов ([TransactionType.transfer]). - int? transferToAccountId, + String? transferToAccountId, required DateTime createdAt, }) = _Transaction; } diff --git a/lib/src/features/transactions/domain/repositories/transaction_repository.dart b/lib/src/features/transactions/domain/repositories/transaction_repository.dart index f96a3cc..b5f0232 100644 --- a/lib/src/features/transactions/domain/repositories/transaction_repository.dart +++ b/lib/src/features/transactions/domain/repositories/transaction_repository.dart @@ -10,20 +10,20 @@ abstract interface class TransactionRepository { /// /// Результаты отсортированы по [Transaction.date] в убывающем порядке. Stream> watchTransactions({ - required int userId, - int? accountId, - int? categoryId, + required String userId, + String? accountId, + String? categoryId, TransactionType? type, DateTime? from, DateTime? to, }); - Future findById(int id); + Future findById(String id); Future create({ - required int userId, - required int accountId, - int? categoryId, + required String userId, + required String accountId, + String? categoryId, required TransactionType type, /// Сумма в минорных единицах (должна быть > 0). @@ -32,10 +32,10 @@ abstract interface class TransactionRepository { String? note, /// Целевой счёт для [TransactionType.transfer]. - int? transferToAccountId, + String? transferToAccountId, }); Future update(Transaction transaction); - Future delete(int id); + Future delete(String id); } diff --git a/lib/src/features/user/application/active_user_controller.dart b/lib/src/features/user/application/active_user_controller.dart index f419c85..e09a22d 100644 --- a/lib/src/features/user/application/active_user_controller.dart +++ b/lib/src/features/user/application/active_user_controller.dart @@ -13,16 +13,14 @@ class ActiveUserController extends _$ActiveUserController { @override Future build() async { final db = ref.watch(appDatabaseProvider); - final idStr = await db.settingsDao.getPreference(_kActiveUserKey); - if (idStr == null) return null; - final id = int.tryParse(idStr); + final id = await db.settingsDao.getPreference(_kActiveUserKey); if (id == null) return null; return ref.read(userRepositoryProvider).findById(id); } Future setActiveUser(User user) async { final db = ref.read(appDatabaseProvider); - await db.settingsDao.setPreference(_kActiveUserKey, user.id.toString()); + await db.settingsDao.setPreference(_kActiveUserKey, user.id); state = AsyncData(user); } diff --git a/lib/src/features/user/application/users_controller.dart b/lib/src/features/user/application/users_controller.dart index 9f89e39..b8cb67f 100644 --- a/lib/src/features/user/application/users_controller.dart +++ b/lib/src/features/user/application/users_controller.dart @@ -1,6 +1,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../domain/entities/user.dart'; import 'user_providers.dart'; +import 'user_seeder.dart'; part 'users_controller.g.dart'; @@ -17,21 +18,23 @@ class UsersController extends _$UsersController { Future createUser(String name) async { state = const AsyncLoading(); - final result = await AsyncValue.guard( - () => ref.read(userRepositoryProvider).create(name), - ); + final result = await AsyncValue.guard(() async { + final user = await ref.read(userRepositoryProvider).create(name); + await ref.read(userSeederProvider).seedForNewUser(user.id); + return user; + }); state = result.hasError ? AsyncError(result.error!, StackTrace.current) : const AsyncData(null); return result.value!; } - Future renameUser(int id, String newName) async { + Future renameUser(String id, String newName) async { state = const AsyncLoading(); state = await AsyncValue.guard( () => ref.read(userRepositoryProvider).rename(id, newName), ).then((_) => const AsyncData(null)); } - Future deleteUser(int id) async { + Future deleteUser(String id) async { state = const AsyncLoading(); state = await AsyncValue.guard( () => ref.read(userRepositoryProvider).delete(id), diff --git a/lib/src/features/user/data/repositories/user_repository_impl.dart b/lib/src/features/user/data/repositories/user_repository_impl.dart index 9d8a228..cc428c5 100644 --- a/lib/src/features/user/data/repositories/user_repository_impl.dart +++ b/lib/src/features/user/data/repositories/user_repository_impl.dart @@ -1,4 +1,5 @@ import 'package:drift/drift.dart'; +import 'package:uuid/uuid.dart'; import '../../../../core/database/app_database.dart'; import '../../../../core/database/daos/users_dao.dart'; import '../../domain/entities/user.dart'; @@ -16,22 +17,23 @@ class UserRepositoryImpl implements UserRepository { _dao.watchAll().map((rows) => rows.map((r) => r.toDomain()).toList()); @override - Future findById(int id) async { + Future findById(String id) async { final row = await _dao.findById(id); return row?.toDomain(); } @override Future create(String name) async { - final id = await _dao.insertUser( - UsersTableCompanion.insert(name: name), + final id = const Uuid().v4(); + await _dao.insertUser( + UsersTableCompanion.insert(id: id, name: name), ); final row = await _dao.findById(id); return row!.toDomain(); } @override - Future rename(int id, String newName) async { + Future rename(String id, String newName) async { await _dao.updateUser( UsersTableCompanion(id: Value(id), name: Value(newName)), ); @@ -40,5 +42,5 @@ class UserRepositoryImpl implements UserRepository { } @override - Future delete(int id) => _dao.deleteUser(id); + Future delete(String id) => _dao.deleteUser(id); } diff --git a/lib/src/features/user/domain/entities/user.dart b/lib/src/features/user/domain/entities/user.dart index b79a392..f758862 100644 --- a/lib/src/features/user/domain/entities/user.dart +++ b/lib/src/features/user/domain/entities/user.dart @@ -6,7 +6,7 @@ part 'user.freezed.dart'; @freezed abstract class User with _$User { const factory User({ - required int id, + required String id, required String name, required DateTime createdAt, }) = _User; diff --git a/lib/src/features/user/domain/repositories/user_repository.dart b/lib/src/features/user/domain/repositories/user_repository.dart index a50335b..103f95b 100644 --- a/lib/src/features/user/domain/repositories/user_repository.dart +++ b/lib/src/features/user/domain/repositories/user_repository.dart @@ -7,14 +7,14 @@ abstract interface class UserRepository { Stream> watchAll(); /// Найти пользователя по id. null если не найден. - Future findById(int id); + Future findById(String id); /// Создать новый профиль. Возвращает созданную сущность. Future create(String name); /// Переименовать профиль. - Future rename(int id, String newName); + Future rename(String id, String newName); /// Удалить профиль (каскадно удаляет все связанные данные). - Future delete(int id); + Future delete(String id); } diff --git a/pubspec.lock b/pubspec.lock index 29a2076..0362f08 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -899,7 +899,7 @@ packages: source: hosted version: "1.4.0" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" diff --git a/pubspec.yaml b/pubspec.yaml index 02fe264..f43ed86 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,9 @@ dependencies: # Formatting intl: ^0.20.2 + # UUID generation + uuid: ^4.5.1 + # Icons cupertino_icons: ^1.0.8