This commit is contained in:
2026-05-27 17:01:13 +03:00
parent cbabfb0972
commit 9e4f7ae751
55 changed files with 496 additions and 950 deletions
+4 -1
View File
@@ -1,7 +1,10 @@
{
"permissions": {
"allow": [
"PowerShell(flutter *)"
"PowerShell(flutter *)",
"Bash(flutter analyze *)",
"Bash(flutter gen-l10n *)",
"Bash(dart run *)"
]
}
}
+30 -22
View File
@@ -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
-342
View File
@@ -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<AppPalette>``Theme.of(context).extension<AppPalette>()!`. Это идиоматичнее, чем
глобальные синглтоны, и автоматически переключается с темой.
`lib/src/app/theme/app_theme.dart``ThemeData light()` / `dark()`:
- `useMaterial3: true`
- `colorScheme` через `ColorScheme.fromSeed(seedColor: accent, brightness: ...)`, затем `copyWith`
для `surface`, `onSurface`, `outline` — чтобы материаловские виджеты (Material, Card, Divider)
на дефолте уже использовали наши токены
- `textTheme: GoogleFonts.dmSansTextTheme(...)`
- `extensions: [AppPalette.light / .dark]`
- семейство для числовых стилей — отдельный публичный helper `Text monoText(...)`, который
применяет `GoogleFonts.jetBrainsMono(fontFeatures: [tabular-nums])`. Использовать там, где в
дизайне `fontFamily: 'JetBrains Mono'` (балансы, суммы транзакций).
### 3. Контроллер темы
`lib/src/app/theme/theme_mode_controller.dart`:
```dart
@riverpod
class ThemeModeController extends _$ThemeModeController {
@override
ThemeMode build() => ThemeMode.dark; // дефолт совпадает с design/index.html
void set(ThemeMode mode) => state = mode;
void toggle() => state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
}
```
Простой `@riverpod` Notifier; персистентность не требуется в этой итерации (отмечено в
«Будущие шаги»). Использовать через `ref.watch(themeModeControllerProvider)` в `App`.
### 4. Навигация
`lib/src/app/router/app_routes.dart` — константы:
```dart
class AppRoutes {
static const home = '/home';
static const analytics = '/analytics';
static const accounts = '/accounts';
static const profile = '/profile';
}
```
`lib/src/app/router/app_router.dart``@riverpod` провайдер `GoRouter`:
- корневая `StatefulShellRoute.indexedStack` с 4 ветками (home, analytics, accounts, profile);
каждая ветка — обычный `GoRoute` без вложенности
- `initialLocation: AppRoutes.home`
- shell-builder возвращает `AppScaffold` (см. ниже) — он рисует bottom nav и индекс активной вкладки
### 5. Каркасный Scaffold с bottom nav
`lib/src/shared/widgets/app_scaffold.dart`:
- получает `StatefulNavigationShell`, рендерит `Scaffold(body: shell, bottomNavigationBar: AppBottomNav(...))`
- `AppBottomNav` — собственная реализация, не `NavigationBar`, чтобы повторить визуал из
`design/common.jsx:230` (иконка в пилюле с `--accent-soft` фоном для активной)
- 4 пункта: Главная (Icons.home_outlined), Аналитика (Icons.bar_chart_outlined),
Счета (Icons.account_balance_wallet_outlined), Профиль (Icons.person_outline) —
иконки берём из Material, иконки дизайна (`wallet`, `stats`...) близки к материаловским аналогам
### 6. Главный экран (V1)
`lib/src/features/home/presentation/screens/home_screen.dart``ConsumerWidget`, который
читает mock-провайдеры и собирает композицию. Структура `build`:
```
Scaffold(
body: Stack(
children: [
CustomScrollView(slivers: [
SliverToBoxAdapter(MonthHeader)
SliverToBoxAdapter(AccountTabs)
SliverToBoxAdapter(MonthKpiCard)
SliverToBoxAdapter(CategoryDonutCard)
SliverToBoxAdapter(TransactionsSectionHeader)
SliverToBoxAdapter(CategoryFilterPill)
SliverList(... DayHeader + TxRow ...)
SliverPadding(80px)
])
Positioned(FabAddTransaction, bottom: 16, right: 16)
]
)
)
```
Виджеты, по одному на файл, под `lib/src/features/home/presentation/widgets/`:
| Виджет | Соответствие в variants.jsx |
|-----------------------------|---------------------------------------|
| `MonthHeader` | строки 108-116 |
| `AccountTabs` | строки 119-133 |
| `MonthKpiCard` | строки 136-165 |
| `CategoryDonutCard` | строки 167-205 |
| `CategoryDonut` (CustomPainter) | `design/common.jsx:76``Donut` |
| `TransactionsSectionHeader` | строки 207-211 |
| `CategoryFilterPill` | строки 213-239 |
| `DayHeader` | `design/common.jsx:215``DayHeader` |
| `TxRow` | `design/common.jsx:179``TxRow` |
| `FabAddTransaction` | `design/common.jsx:262``FAB` |
Поведение фильтра (выбор сегмента donut фильтрует список транзакций) — состояние храним в
локальном `StateProvider`/`Notifier` внутри `home` фичи: `selected_category_filter.dart`
`@riverpod` `int? selectedCategoryFilter(ref)` либо просто `StateProvider<int?>`.
Группировка по дням — в `home_screen.dart` через `Map<DateTime, List<Transaction>>` (ключ — дата без
времени). Заголовок дня — «Сегодня», «Вчера» или `dd MMM` через `intl`.
### 7. Donut-диаграмма (через `fl_chart`)
`lib/src/features/home/presentation/widgets/category_donut.dart`:
- обёртка над `PieChart` из `fl_chart`:
```dart
PieChart(PieChartData(
sections: [
for (final s in spend) PieChartSectionData(
value: s.amount.toDouble(),
color: s.color,
radius: isActive(s) ? 26 : 22, // визуальный аналог rOff=4
showTitle: false,
),
],
centerSpaceRadius: 44, // совпадает с `thickness: 22` при size: 130
sectionsSpace: 0,
pieTouchData: PieTouchData(
touchCallback: (event, response) {
if (event is FlTapUpEvent) {
final idx = response?.touchedSection?.touchedSectionIndex;
onSegment?.call(idx);
}
},
),
))
```
- центральный текст («Расходы / 67 200 ₽») рисуем поверх через `Stack` — `PieChart` сам центр не
занимает (это и есть «дырка» donut'а)
- активный сегмент получает увеличенный `radius`; для не-активных, когда выбран другой, можно
понизить `opacity` через `color.withOpacity(0.4)` — равнозначно `opacity: 0.35` из референса
- получаем встроенный hit-test по сегментам — никакого ручного расчёта угла
### 8. Mock-данные
`lib/src/features/home/presentation/_mock_data.dart`:
- `mockUserId = 1`
- `mockAccounts: List<Account>` — 4 счёта из `design/common.jsx:157` (all / card / cash / pig),
с балансами в **минорных единицах** (умножить на 100): `184_320` → `18_432_000`. Поле `type`
мапим в `AccountType`; `iconCode`/`colorValue` — `null`, иконку выбираем в `AccountTabs` по
`type`. Виртуальный «Все счета» — отдельная константа `kAllAccountsId = 0` (т.к. это не реальный
счёт, а агрегат); в UI он рендерится первой пиллой и фильтрует список «все».
- `mockCategories: List<Category>` — 6 категорий (food/rent/transp/cafe/enter/other),
`type: CategoryType.expense`, `colorValue`/`iconCode` хранят hex и иконку из дизайна
- `mockTransactions: List<Transaction>` — 10 строк из `design/common.jsx:165-176`, амаунты в
минорных единицах со знаком, `date` — `DateTime.now()` сдвинутый на нужное кол-во дней назад
- `mockCategorySpend: Map<int, int>` — суммы по категории для donut'а (`SPEND` из `common.jsx:142`)
Простые провайдеры:
```dart
@riverpod
List<Account> mockAccounts(...) => _mockAccountsList;
@riverpod
List<Transaction> mockTransactions(...) => _mockTransactionsList;
// и т.д.
```
Так замена на реальные данные потом — это **переименование провайдеров** в виджетах с
`mockAccountsProvider` на `accountsStreamProvider(userId)` (уже существует в скелете) — UI не
меняется.
### 9. Палитра категорий и иконок
В дизайне категории имеют hex-цвета и кастомные SVG. В Flutter:
- цвет хранится прямо в mock-данных в `colorValue` (`int` уже есть в `Category`)
- иконку выбираем через статическую мапу `categoryIconFor(Category c)` в
`lib/src/features/categories/presentation/category_icon.dart` — ключ это `iconCode` или
имя категории; значение — `IconData` из `Icons` (cart → `shopping_cart_outlined`,
house → `home_outlined`, car → `directions_car_outlined`, food → `restaurant_outlined`,
film → `movie_outlined`, more → `more_horiz`). Дешевле SVG, ближе к материаловскому стилю.
### 10. Экраны-заглушки остальных вкладок
`lib/src/features/{analytics,accounts_screen,profile}/presentation/screens/*.dart` —
`AnalyticsScreen`, `AccountsScreen`, `ProfileScreen`. Каждый — `Scaffold` с заголовком в
стиле V1 хедера и `Center(child: Text('Скоро'))` либо `EmptyState` виджетом. `ProfileScreen`
содержит **рабочий** `SwitchListTile` темы (читает/пишет `themeModeControllerProvider`) —
это и есть единственный нон-плейсхолдер вне главного экрана.
`AnalyticsScreen` и `AccountsScreen` создаём в существующих фичах
(`features/categories` не подходит — добавим `features/analytics/presentation/` —
presentation-only). `AccountsScreen` логично положить в `features/accounts/presentation/screens/`.
### 11. Точка входа
`lib/main.dart` — переписать:
```dart
void main() {
runApp(const ProviderScope(child: NewBudgetApp()));
}
```
`lib/src/app/app.dart` — `NewBudgetApp` (`ConsumerWidget`):
- читает `themeModeControllerProvider` и `appRouterProvider`
- возвращает `MaterialApp.router(theme: AppTheme.light(), darkTheme: AppTheme.dark(),
themeMode: ..., routerConfig: ...)`
### 12. Удалить мусор
- `test/widget_test.dart` — ссылается на `MyApp` из counter-шаблона, удалить или заменить
smoke-тестом, который только инициализирует `NewBudgetApp` под `ProviderScope`.
## Файлы
**Создать (presentation/app):**
- `lib/src/app/app.dart`
- `lib/src/app/theme/app_colors.dart`
- `lib/src/app/theme/app_theme.dart`
- `lib/src/app/theme/theme_mode_controller.dart`
- `lib/src/app/router/app_routes.dart`
- `lib/src/app/router/app_router.dart`
- `lib/src/shared/widgets/app_scaffold.dart`
- `lib/src/shared/widgets/app_bottom_nav.dart`
- `lib/src/shared/text/mono_text.dart` (helper для JetBrains Mono)
**Создать (home feature):**
- `lib/src/features/home/presentation/screens/home_screen.dart`
- `lib/src/features/home/presentation/widgets/month_header.dart`
- `lib/src/features/home/presentation/widgets/account_tabs.dart`
- `lib/src/features/home/presentation/widgets/month_kpi_card.dart`
- `lib/src/features/home/presentation/widgets/category_donut_card.dart`
- `lib/src/features/home/presentation/widgets/category_donut.dart`
- `lib/src/features/home/presentation/widgets/transactions_section.dart` (header + filter pill)
- `lib/src/features/home/presentation/widgets/day_header.dart`
- `lib/src/features/home/presentation/widgets/tx_row.dart`
- `lib/src/features/home/presentation/widgets/fab_add_transaction.dart`
- `lib/src/features/home/presentation/state/selected_category_filter.dart`
- `lib/src/features/home/presentation/_mock_data.dart`
**Создать (другие вкладки):**
- `lib/src/features/analytics/presentation/screens/analytics_screen.dart`
- `lib/src/features/accounts/presentation/screens/accounts_screen.dart`
- `lib/src/features/profile/presentation/screens/profile_screen.dart`
- `lib/src/features/categories/presentation/category_icon.dart`
**Изменить:**
- `lib/main.dart` — runApp + ProviderScope
- `pubspec.yaml` — добавить `google_fonts`
**Удалить/заменить:**
- `test/widget_test.dart` — заменить на smoke-тест приложения
## Сборка и проверка
1. `flutter pub get`
2. `dart run build_runner build --delete-conflicting-outputs` — без ошибок
3. `flutter analyze` — без ошибок (исключения для `*.g.dart`/`*.freezed.dart` уже настроены в
`analysis_options.yaml`)
4. `flutter run` на Android-эмуляторе:
- открывается главный экран в тёмной теме
- визуально совпадает с V1 в `design/index.html` (структура, отступы, цвета, шрифты)
- таб-пиллы счетов переключаются (хайлайт активного)
- тап по сегменту donut'а / по pill-фильтру меняет список транзакций
- bottom-nav переключает между 4 вкладками; состояние главного экрана сохраняется
(благодаря `StatefulShellRoute`)
- в Профиле есть переключатель темы; нажатие действительно меняет палитру всего приложения
- FAB виден над списком, тап — пока no-op (TODO: открыть экран добавления транзакции)
## Будущие шаги (вне этой итерации)
- Заменить mock-провайдеры на стримы из `application/` (`accountsStreamProvider`,
`transactionsStreamProvider` и т.д.) — UI остаётся прежним.
- Персистентность `ThemeMode` через `SettingsRepository` (`SettingsEntity.themeMode` уже
существует).
- Точный hit-test сегментов donut'а (по углу относительно центра) для тапа.
- Экран добавления/редактирования транзакции (FAB-таргет).
- Реализация Аналитики и Счетов поверх существующих DAO.
+7 -1
View File
@@ -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"
}
+30
View File
@@ -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
+15
View File
@@ -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';
}
+15
View File
@@ -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 => 'Продолжить';
}
+7 -1
View File
@@ -63,5 +63,11 @@
"profileSubtitle": "Настройки",
"darkTheme": "Тёмная тема",
"profileHint": "Здесь появится профиль пользователя, валюта, локаль и другие настройки."
"profileHint": "Здесь появится профиль пользователя, валюта, локаль и другие настройки.",
"onboardingTitle": "Добро пожаловать",
"onboardingSubtitle": "Расскажите, как к вам обращаться.",
"onboardingNameLabel": "Ваше имя",
"onboardingNameHint": "Например, Алекс",
"onboardingContinue": "Продолжить"
}
+27
View File
@@ -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<int>(0);
ref.listen<AsyncValue<Object?>>(
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,
+1
View File
@@ -5,4 +5,5 @@ class AppRoutes {
static const analytics = '/analytics';
static const accounts = '/accounts';
static const profile = '/profile';
static const onboarding = '/onboarding';
}
+12 -2
View File
@@ -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();
}
},
);
+7 -6
View File
@@ -11,33 +11,34 @@ class AccountsDao extends DatabaseAccessor<AppDatabase>
AccountsDao(super.db);
/// Реактивный поток счетов пользователя (не архивных).
Stream<List<AccountsTableData>> watchAccountsByUser(int userId) =>
Stream<List<AccountsTableData>> watchAccountsByUser(String userId) =>
(select(accountsTable)
..where((t) => t.userId.equals(userId) & t.archived.equals(false))
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
.watch();
Future<List<AccountsTableData>> getAccountsByUser(int userId) =>
Future<List<AccountsTableData>> getAccountsByUser(String userId) =>
(select(accountsTable)
..where((t) => t.userId.equals(userId) & t.archived.equals(false)))
.get();
Future<AccountsTableData?> findById(int id) =>
Future<AccountsTableData?> findById(String id) =>
(select(accountsTable)..where((t) => t.id.equals(id))).getSingleOrNull();
Future<int> insertAccount(AccountsTableCompanion companion) =>
/// UUID передаётся в companion.
Future<void> insertAccount(AccountsTableCompanion companion) =>
into(accountsTable).insert(companion);
Future<bool> updateAccount(AccountsTableCompanion companion) =>
update(accountsTable).replace(companion);
Future<void> archiveAccount(int id) => (update(accountsTable)
Future<void> archiveAccount(String id) => (update(accountsTable)
..where((t) => t.id.equals(id)))
.write(const AccountsTableCompanion(archived: Value(true)));
/// Реактивный текущий баланс счёта (начальный + сумма транзакций).
/// TODO: добавить сложную SQL-агрегацию с учётом типа транзакции.
Stream<int> watchAccountBalance(int accountId) {
Stream<int> watchAccountBalance(String accountId) {
// Stub: возвращает только initialBalance пока не реализована агрегация.
return (select(accountsTable)..where((t) => t.id.equals(accountId)))
.watchSingleOrNull()
@@ -10,7 +10,7 @@ class CategoriesDao extends DatabaseAccessor<AppDatabase>
with _$CategoriesDaoMixin {
CategoriesDao(super.db);
Stream<List<CategoriesTableData>> watchCategoriesByUser(int userId) =>
Stream<List<CategoriesTableData>> watchCategoriesByUser(String userId) =>
(select(categoriesTable)
..where(
(t) => t.userId.equals(userId) & t.archived.equals(false))
@@ -18,7 +18,7 @@ class CategoriesDao extends DatabaseAccessor<AppDatabase>
.watch();
Stream<List<CategoriesTableData>> watchByType(
int userId,
String userId,
CategoryType type,
) =>
(select(categoriesTable)
@@ -28,23 +28,24 @@ class CategoriesDao extends DatabaseAccessor<AppDatabase>
t.archived.equals(false)))
.watch();
Future<List<CategoriesTableData>> getCategoriesByUser(int userId) =>
Future<List<CategoriesTableData>> getCategoriesByUser(String userId) =>
(select(categoriesTable)
..where(
(t) => t.userId.equals(userId) & t.archived.equals(false)))
.get();
Future<CategoriesTableData?> findById(int id) =>
Future<CategoriesTableData?> findById(String id) =>
(select(categoriesTable)..where((t) => t.id.equals(id)))
.getSingleOrNull();
Future<int> insertCategory(CategoriesTableCompanion companion) =>
/// UUID передаётся в companion.
Future<void> insertCategory(CategoriesTableCompanion companion) =>
into(categoriesTable).insert(companion);
Future<bool> updateCategory(CategoriesTableCompanion companion) =>
update(categoriesTable).replace(companion);
Future<void> archiveCategory(int id) =>
Future<void> archiveCategory(String id) =>
(update(categoriesTable)..where((t) => t.id.equals(id)))
.write(const CategoriesTableCompanion(archived: Value(true)));
}
+2 -2
View File
@@ -11,11 +11,11 @@ class SettingsDao extends DatabaseAccessor<AppDatabase>
// ── Settings per user ──────────────────────────────────────────────────────
Stream<SettingsTableData?> watchSettingsByUser(int userId) =>
Stream<SettingsTableData?> watchSettingsByUser(String userId) =>
(select(settingsTable)..where((t) => t.userId.equals(userId)))
.watchSingleOrNull();
Future<SettingsTableData?> getSettingsByUser(int userId) =>
Future<SettingsTableData?> getSettingsByUser(String userId) =>
(select(settingsTable)..where((t) => t.userId.equals(userId)))
.getSingleOrNull();
@@ -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<AppDatabase>
TransactionFilter filter) =>
watchTransactions(filter).first;
Future<TransactionsTableData?> findById(int id) =>
Future<TransactionsTableData?> findById(String id) =>
(select(transactionsTable)..where((t) => t.id.equals(id)))
.getSingleOrNull();
Future<int> insertTransaction(TransactionsTableCompanion companion) =>
/// UUID передаётся в companion.
Future<void> insertTransaction(TransactionsTableCompanion companion) =>
into(transactionsTable).insert(companion);
Future<bool> updateTransaction(TransactionsTableCompanion companion) =>
update(transactionsTable).replace(companion);
Future<int> deleteTransaction(int id) =>
Future<int> deleteTransaction(String id) =>
(delete(transactionsTable)..where((t) => t.id.equals(id))).go();
/// TODO: сложные SQL-агрегаты: суммы по категориям за период.
/// Stream<Map<int, int>> watchTotalsByCategory(int userId, DateTime from, DateTime to)
/// Stream<Map<String, int>> watchTotalsByCategory(String userId, DateTime from, DateTime to)
}
+4 -4
View File
@@ -15,11 +15,11 @@ class UsersDao extends DatabaseAccessor<AppDatabase> with _$UsersDaoMixin {
Future<List<UsersTableData>> getAll() => select(usersTable).get();
/// Найти пользователя по id.
Future<UsersTableData?> findById(int id) =>
Future<UsersTableData?> findById(String id) =>
(select(usersTable)..where((t) => t.id.equals(id))).getSingleOrNull();
/// Создать пользователя. Возвращает id.
Future<int> insertUser(UsersTableCompanion companion) =>
/// Создать пользователя. UUID передаётся в companion.
Future<void> insertUser(UsersTableCompanion companion) =>
into(usersTable).insert(companion);
/// Обновить пользователя.
@@ -27,6 +27,6 @@ class UsersDao extends DatabaseAccessor<AppDatabase> with _$UsersDaoMixin {
update(usersTable).replace(companion);
/// Удалить пользователя.
Future<int> deleteUser(int id) =>
Future<int> deleteUser(String id) =>
(delete(usersTable)..where((t) => t.id.equals(id))).go();
}
@@ -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<Column> get primaryKey => {id};
}
@@ -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<Column> get primaryKey => {id};
}
@@ -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'))();
@@ -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<Column> get primaryKey => {id};
}
@@ -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<Column> get primaryKey => {id};
}
@@ -7,12 +7,12 @@ part 'accounts_controller.g.dart';
/// Реактивный список счетов для активного пользователя.
@riverpod
Stream<List<Account>> accountsStream(Ref ref, int userId) =>
Stream<List<Account>> accountsStream(Ref ref, String userId) =>
ref.watch(accountRepositoryProvider).watchByUser(userId);
/// Текущий баланс счёта.
@riverpod
Stream<int> accountBalance(Ref ref, int accountId) =>
Stream<int> accountBalance(Ref ref, String accountId) =>
ref.watch(accountRepositoryProvider).watchBalance(accountId);
/// Контроллер CRUD-операций над счетами.
@@ -22,7 +22,7 @@ class AccountsController extends _$AccountsController {
AsyncValue<void> build() => const AsyncData(null);
Future<Account> 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<void> archiveAccount(int id) async {
Future<void> archiveAccount(String id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(accountRepositoryProvider).archive(id),
@@ -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<List<Account>> watchByUser(int userId) =>
Stream<List<Account>> watchByUser(String userId) =>
_dao.watchAccountsByUser(userId).map((rows) => rows.map((r) => r.toDomain()).toList());
@override
Future<Account?> findById(int id) async {
Future<Account?> findById(String id) async {
final row = await _dao.findById(id);
return row?.toDomain();
}
@override
Future<Account> 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<void> archive(int id) => _dao.archiveAccount(id);
Future<void> archive(String id) => _dao.archiveAccount(id);
@override
Stream<int> watchBalance(int accountId) => _dao.watchAccountBalance(accountId);
Stream<int> watchBalance(String accountId) => _dao.watchAccountBalance(accountId);
}
@@ -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,
@@ -2,10 +2,10 @@ import '../entities/account.dart';
import '../../../../core/database/converters/enum_converters.dart';
abstract interface class AccountRepository {
Stream<List<Account>> watchByUser(int userId);
Future<Account?> findById(int id);
Stream<List<Account>> watchByUser(String userId);
Future<Account?> findById(String id);
Future<Account> 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<Account> update(Account account);
Future<void> archive(int id);
Future<void> archive(String id);
/// Текущий баланс счёта (начальный + агрегат транзакций).
Stream<int> watchBalance(int accountId);
Stream<int> watchBalance(String accountId);
}
@@ -7,14 +7,14 @@ part 'categories_controller.g.dart';
/// Реактивный список категорий для активного пользователя.
@riverpod
Stream<List<Category>> categoriesStream(Ref ref, int userId) =>
Stream<List<Category>> categoriesStream(Ref ref, String userId) =>
ref.watch(categoryRepositoryProvider).watchByUser(userId);
/// Реактивный список категорий, фильтрованный по типу (income / expense).
@riverpod
Stream<List<Category>> categoriesByTypeStream(
Ref ref,
int userId,
String userId,
CategoryType type,
) =>
ref.watch(categoryRepositoryProvider).watchByType(userId, type);
@@ -26,12 +26,12 @@ class CategoriesController extends _$CategoriesController {
AsyncValue<void> build() => const AsyncData(null);
Future<Category> 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<void> archiveCategory(int id) async {
Future<void> archiveCategory(String id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(categoryRepositoryProvider).archive(id),
@@ -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<List<Category>> watchByUser(int userId) =>
Stream<List<Category>> watchByUser(String userId) =>
_dao.watchCategoriesByUser(userId).map((rows) => rows.map((r) => r.toDomain()).toList());
@override
Stream<List<Category>> watchByType(int userId, CategoryType type) =>
Stream<List<Category>> watchByType(String userId, CategoryType type) =>
_dao.watchByType(userId, type).map((rows) => rows.map((r) => r.toDomain()).toList());
@override
Future<Category?> findById(int id) async {
Future<Category?> findById(String id) async {
final row = await _dao.findById(id);
return row?.toDomain();
}
@override
Future<Category> 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<void> archive(int id) => _dao.archiveCategory(id);
Future<void> archive(String id) => _dao.archiveCategory(id);
}
@@ -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;
}
@@ -2,17 +2,17 @@ import '../../../../core/database/converters/enum_converters.dart';
import '../entities/category.dart';
abstract interface class CategoryRepository {
Stream<List<Category>> watchByUser(int userId);
Stream<List<Category>> watchByType(int userId, CategoryType type);
Future<Category?> findById(int id);
Stream<List<Category>> watchByUser(String userId);
Stream<List<Category>> watchByType(String userId, CategoryType type);
Future<Category?> findById(String id);
Future<Category> create({
required int userId,
required String userId,
required String name,
required CategoryType type,
int? iconCode,
int? colorValue,
int? parentId,
String? parentId,
});
Future<Category> update(Category category);
Future<void> archive(int id);
Future<void> archive(String id);
}
@@ -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<Category> _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<Account> _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<Transaction> _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<List<Account>>((ref) => _mockAccounts);
final mockCategoriesProvider =
Provider<List<Category>>((ref) => _mockCategories);
final mockTransactionsProvider =
Provider<List<Transaction>>((ref) => _mockTransactions);
@@ -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<int, int> spendByCategory; // categoryId -> sum in minor units
final Map<String, int> spendByCategory; // categoryId sum in minor units
final int transactionsCount;
int get spendTotalMinor =>
spendByCategory.values.fold(0, (sum, v) => sum + v);
}
/// Возвращает транзакции отфильтрованные по выбранному счёту и опционально
/// по выбранной категории.
final filteredTransactionsProvider = Provider<List<Transaction>>((ref) {
final all = ref.watch(mockTransactionsProvider);
/// Транзакции пользователя, отфильтрованные по выбранному счёту и (опционально)
/// по выбранной категории. Источник — `transactionsStream`. Во время первичной
/// загрузки возвращается пустой список.
@riverpod
List<Transaction> filteredTransactions(Ref ref, String userId) {
final all = ref.watch(transactionsStreamProvider(userId)).value ??
const <Transaction>[];
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<MonthSummary>((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 <Account>[];
final txs = ref.watch(transactionsStreamProvider(userId)).value ??
const <Transaction>[];
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 = <int, int>{};
final spendByCat = <String, int>{};
for (final t in scopedTxs) {
switch (t.type) {
case TransactionType.income:
@@ -70,8 +79,7 @@ final monthSummaryProvider = Provider<MonthSummary>((ref) {
}
}
// Баланс = сумма initialBalance по выбранным счетам + доход - расход.
final selectedAccounts = selectedAccount == 0
final selectedAccounts = selectedAccount.isEmpty
? accounts
: accounts.where((a) => a.id == selectedAccount);
final baseBalance = selectedAccounts.fold<int>(
@@ -86,11 +94,11 @@ final monthSummaryProvider = Provider<MonthSummary>((ref) {
spendByCategory: spendByCat,
transactionsCount: scopedTxs.length,
);
});
}
/// Возвращает категории, отсортированные по сумме трат (по убыванию).
/// Категории, отсортированные по сумме трат (по убыванию).
List<MapEntry<Category, int>> categoriesBySpend(
Map<int, int> spend,
Map<String, int> spend,
List<Category> categories,
) {
final result = <MapEntry<Category, int>>[];
@@ -101,5 +109,3 @@ List<MapEntry<Category, int>> categoriesBySpend(
result.sort((a, b) => b.value.compareTo(a.value));
return result;
}
Color colorFor(Category c) => Color(c.colorValue ?? 0xFFB8B5AC);
@@ -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 <Category>[];
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<int, Category> categoryById;
final Map<String, Category> 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<Transaction> 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<int>(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,
@@ -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;
}
@@ -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 <Account>[];
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),
@@ -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<DonutSlice> slices;
final double size;
final double thickness;
final int? activeId;
final ValueChanged<int?>? onSegmentTap;
final String? activeId;
final ValueChanged<String?>? onSegmentTap;
final Widget? centerChild;
@override
@@ -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 <Category>[];
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,
@@ -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),
@@ -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 <Category>[];
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),
),
),
@@ -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
@@ -19,7 +19,7 @@ part 'settings_controller.g.dart';
@riverpod
class SettingsController extends _$SettingsController {
@override
Future<Settings> build(int userId) async {
Future<Settings> build(String userId) async {
// Подписываемся на стрим — при изменении в БД состояние обновится автоматически.
final sub = ref.listen(
settingsStreamProvider(userId),
@@ -15,5 +15,5 @@ SettingsRepository settingsRepository(Ref ref) {
/// Реактивный поток настроек для конкретного пользователя.
@riverpod
Stream<Settings?> settingsStream(Ref ref, int userId) =>
Stream<Settings?> settingsStream(Ref ref, String userId) =>
ref.watch(settingsRepositoryProvider).watchSettings(userId);
@@ -15,11 +15,11 @@ class SettingsRepositoryImpl implements SettingsRepository {
// ── Чтение ────────────────────────────────────────────────────────────────
@override
Stream<Settings?> watchSettings(int userId) =>
Stream<Settings?> watchSettings(String userId) =>
_dao.watchSettingsByUser(userId).map((row) => row?.toDomain());
@override
Future<Settings?> getSettings(int userId) async {
Future<Settings?> 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<Settings> ensureDefaults(int userId) async {
Future<Settings> ensureDefaults(String userId) async {
final existing = await getSettings(userId);
if (existing != null) return existing;
@@ -50,25 +50,25 @@ class SettingsRepositoryImpl implements SettingsRepository {
// ── Точечные обновления ────────────────────────────────────────────────────
@override
Future<void> updateBaseCurrency(int userId, String currency) async {
Future<void> updateBaseCurrency(String userId, String currency) async {
final current = await _requireSettings(userId);
await upsertSettings(current.copyWith(baseCurrency: currency));
}
@override
Future<void> updateThemeMode(int userId, AppThemeMode themeMode) async {
Future<void> updateThemeMode(String userId, AppThemeMode themeMode) async {
final current = await _requireSettings(userId);
await upsertSettings(current.copyWith(themeMode: themeMode));
}
@override
Future<void> updateLocale(int userId, String locale) async {
Future<void> updateLocale(String userId, String locale) async {
final current = await _requireSettings(userId);
await upsertSettings(current.copyWith(locale: locale));
}
@override
Future<void> updateFirstDayOfMonth(int userId, int day) async {
Future<void> 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<Settings> _requireSettings(int userId) =>
Future<Settings> _requireSettings(String userId) =>
ensureDefaults(userId);
static SettingsTableCompanion _toCompanion(Settings s) =>
@@ -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,
@@ -6,28 +6,28 @@ import '../entities/settings.dart';
abstract interface class SettingsRepository {
/// Реактивный поток настроек пользователя.
/// Испускает null, если запись ещё не создана.
Stream<Settings?> watchSettings(int userId);
Stream<Settings?> watchSettings(String userId);
/// Однократное чтение настроек. null если не существует.
Future<Settings?> getSettings(int userId);
Future<Settings?> getSettings(String userId);
/// Создать или обновить настройки (upsert по userId).
Future<void> upsertSettings(Settings settings);
/// Создать запись с дефолтными значениями, если она ещё не существует.
Future<Settings> ensureDefaults(int userId);
Future<Settings> ensureDefaults(String userId);
// ── Точечные обновления ────────────────────────────────────────────────────
/// Изменить валюту по умолчанию.
Future<void> updateBaseCurrency(int userId, String currency);
Future<void> updateBaseCurrency(String userId, String currency);
/// Изменить тему оформления.
Future<void> updateThemeMode(int userId, AppThemeMode themeMode);
Future<void> updateThemeMode(String userId, AppThemeMode themeMode);
/// Изменить локаль.
Future<void> updateLocale(int userId, String locale);
Future<void> updateLocale(String userId, String locale);
/// Изменить первый день месяца/недели (1–28).
Future<void> updateFirstDayOfMonth(int userId, int day);
Future<void> updateFirstDayOfMonth(String userId, int day);
}
@@ -13,19 +13,12 @@ part 'transactions_controller.g.dart';
///
/// Все параметры фильтрации опциональны; при их отсутствии возвращаются все
/// транзакции пользователя, отсортированные по дате (DESC).
///
/// Пример использования в виджете:
/// ```dart
/// final txStream = ref.watch(
/// transactionsStreamProvider(userId, type: TransactionType.expense),
/// );
/// ```
@riverpod
Stream<List<Transaction>> 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<Transaction> 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<void> deleteTransaction(int id) async {
Future<void> deleteTransaction(String id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(transactionRepositoryProvider).delete(id),
@@ -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<List<Transaction>> 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<Transaction?> findById(int id) async {
Future<Transaction?> findById(String id) async {
final row = await _dao.findById(id);
return row?.toDomain();
}
@override
Future<Transaction> 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<void> delete(int id) async {
Future<void> delete(String id) async {
await _dao.deleteTransaction(id);
}
}
@@ -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;
}
@@ -10,20 +10,20 @@ abstract interface class TransactionRepository {
///
/// Результаты отсортированы по [Transaction.date] в убывающем порядке.
Stream<List<Transaction>> watchTransactions({
required int userId,
int? accountId,
int? categoryId,
required String userId,
String? accountId,
String? categoryId,
TransactionType? type,
DateTime? from,
DateTime? to,
});
Future<Transaction?> findById(int id);
Future<Transaction?> findById(String id);
Future<Transaction> 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<Transaction> update(Transaction transaction);
Future<void> delete(int id);
Future<void> delete(String id);
}
@@ -13,16 +13,14 @@ class ActiveUserController extends _$ActiveUserController {
@override
Future<User?> 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<void> 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);
}
@@ -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<User> 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<void> renameUser(int id, String newName) async {
Future<void> renameUser(String id, String newName) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(userRepositoryProvider).rename(id, newName),
).then((_) => const AsyncData(null));
}
Future<void> deleteUser(int id) async {
Future<void> deleteUser(String id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(userRepositoryProvider).delete(id),
@@ -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<User?> findById(int id) async {
Future<User?> findById(String id) async {
final row = await _dao.findById(id);
return row?.toDomain();
}
@override
Future<User> 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<User> rename(int id, String newName) async {
Future<User> 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<void> delete(int id) => _dao.deleteUser(id);
Future<void> delete(String id) => _dao.deleteUser(id);
}
@@ -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;
@@ -7,14 +7,14 @@ abstract interface class UserRepository {
Stream<List<User>> watchAll();
/// Найти пользователя по id. null если не найден.
Future<User?> findById(int id);
Future<User?> findById(String id);
/// Создать новый профиль. Возвращает созданную сущность.
Future<User> create(String name);
/// Переименовать профиль.
Future<User> rename(int id, String newName);
Future<User> rename(String id, String newName);
/// Удалить профиль (каскадно удаляет все связанные данные).
Future<void> delete(int id);
Future<void> delete(String id);
}
+1 -1
View File
@@ -899,7 +899,7 @@ packages:
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
dependency: "direct main"
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
+3
View File
@@ -30,6 +30,9 @@ dependencies:
# Formatting
intl: ^0.20.2
# UUID generation
uuid: ^4.5.1
# Icons
cupertino_icons: ^1.0.8