fixes
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"PowerShell(flutter *)"
|
||||
"PowerShell(flutter *)",
|
||||
"Bash(flutter analyze *)",
|
||||
"Bash(flutter gen-l10n *)",
|
||||
"Bash(dart run *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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
@@ -63,5 +63,11 @@
|
||||
|
||||
"profileSubtitle": "Настройки",
|
||||
"darkTheme": "Тёмная тема",
|
||||
"profileHint": "Здесь появится профиль пользователя, валюта, локаль и другие настройки."
|
||||
"profileHint": "Здесь появится профиль пользователя, валюта, локаль и другие настройки.",
|
||||
|
||||
"onboardingTitle": "Добро пожаловать",
|
||||
"onboardingSubtitle": "Расскажите, как к вам обращаться.",
|
||||
"onboardingNameLabel": "Ваше имя",
|
||||
"onboardingNameHint": "Например, Алекс",
|
||||
"onboardingContinue": "Продолжить"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,4 +5,5 @@ class AppRoutes {
|
||||
static const analytics = '/analytics';
|
||||
static const accounts = '/accounts';
|
||||
static const profile = '/profile';
|
||||
static const onboarding = '/onboarding';
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,27 +25,55 @@ 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(
|
||||
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()),
|
||||
const SliverToBoxAdapter(child: AccountTabs()),
|
||||
SliverToBoxAdapter(child: AccountTabs(userId: userId)),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 0)),
|
||||
const SliverToBoxAdapter(child: MonthKpiCard()),
|
||||
const SliverToBoxAdapter(child: CategoryDonutCard()),
|
||||
const SliverToBoxAdapter(child: TransactionsSectionHeader()),
|
||||
const SliverToBoxAdapter(child: CategoryFilterPill()),
|
||||
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(
|
||||
@@ -64,7 +94,6 @@ class HomeScreen extends ConsumerWidget {
|
||||
child: FabAddTransaction(onPressed: () {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +102,7 @@ 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
@@ -899,7 +899,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
|
||||
@@ -30,6 +30,9 @@ dependencies:
|
||||
# Formatting
|
||||
intl: ^0.20.2
|
||||
|
||||
# UUID generation
|
||||
uuid: ^4.5.1
|
||||
|
||||
# Icons
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
|
||||
Reference in New Issue
Block a user