Add main screen
This commit is contained in:
+342
@@ -0,0 +1,342 @@
|
||||
# Реализация 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
-118
@@ -1,122 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
import 'src/app/app.dart';
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: ThemeData(
|
||||
// This is the theme of your application.
|
||||
//
|
||||
// TRY THIS: Try running your application with "flutter run". You'll see
|
||||
// the application has a purple toolbar. Then, without quitting the app,
|
||||
// try changing the seedColor in the colorScheme below to Colors.green
|
||||
// and then invoke "hot reload" (save your changes or press the "hot
|
||||
// reload" button in a Flutter-supported IDE, or press "r" if you used
|
||||
// the command line to start the app).
|
||||
//
|
||||
// Notice that the counter didn't reset back to zero; the application
|
||||
// state is not lost during the reload. To reset the state, use hot
|
||||
// restart instead.
|
||||
//
|
||||
// This works for code too, not just values: Most code changes can be
|
||||
// tested with just a hot reload.
|
||||
colorScheme: .fromSeed(seedColor: Colors.deepPurple),
|
||||
),
|
||||
home: const MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key, required this.title});
|
||||
|
||||
// This widget is the home page of your application. It is stateful, meaning
|
||||
// that it has a State object (defined below) that contains fields that affect
|
||||
// how it looks.
|
||||
|
||||
// This class is the configuration for the state. It holds the values (in this
|
||||
// case the title) provided by the parent (in this case the App widget) and
|
||||
// used by the build method of the State. Fields in a Widget subclass are
|
||||
// always marked "final".
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int _counter = 0;
|
||||
|
||||
void _incrementCounter() {
|
||||
setState(() {
|
||||
// This call to setState tells the Flutter framework that something has
|
||||
// changed in this State, which causes it to rerun the build method below
|
||||
// so that the display can reflect the updated values. If we changed
|
||||
// _counter without calling setState(), then the build method would not be
|
||||
// called again, and so nothing would appear to happen.
|
||||
_counter++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// This method is rerun every time setState is called, for instance as done
|
||||
// by the _incrementCounter method above.
|
||||
//
|
||||
// The Flutter framework has been optimized to make rerunning build methods
|
||||
// fast, so that you can just rebuild anything that needs updating rather
|
||||
// than having to individually change instances of widgets.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// TRY THIS: Try changing the color here to a specific color (to
|
||||
// Colors.amber, perhaps?) and trigger a hot reload to see the AppBar
|
||||
// change color while the other colors stay the same.
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
// Here we take the value from the MyHomePage object that was created by
|
||||
// the App.build method, and use it to set our appbar title.
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
// Center is a layout widget. It takes a single child and positions it
|
||||
// in the middle of the parent.
|
||||
child: Column(
|
||||
// Column is also a layout widget. It takes a list of children and
|
||||
// arranges them vertically. By default, it sizes itself to fit its
|
||||
// children horizontally, and tries to be as tall as its parent.
|
||||
//
|
||||
// Column has various properties to control how it sizes itself and
|
||||
// how it positions its children. Here we use mainAxisAlignment to
|
||||
// center the children vertically; the main axis here is the vertical
|
||||
// axis because Columns are vertical (the cross axis would be
|
||||
// horizontal).
|
||||
//
|
||||
// TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint"
|
||||
// action in the IDE, or press "p" in the console), to see the
|
||||
// wireframe for each widget.
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
const Text('You have pushed the button this many times:'),
|
||||
Text(
|
||||
'$_counter',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _incrementCounter,
|
||||
tooltip: 'Increment',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await initializeDateFormatting('ru');
|
||||
runApp(const ProviderScope(child: NewBudgetApp()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'router/app_router.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'theme/theme_mode_controller.dart';
|
||||
|
||||
class NewBudgetApp extends ConsumerWidget {
|
||||
const NewBudgetApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(appRouterProvider);
|
||||
final themeMode = ref.watch(themeModeControllerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'NewBudget',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: themeMode,
|
||||
routerConfig: router,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru'), Locale('en')],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
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 '../../shared/widgets/app_scaffold.dart';
|
||||
import 'app_routes.dart';
|
||||
|
||||
part 'app_router.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
GoRouter appRouter(Ref ref) {
|
||||
return GoRouter(
|
||||
initialLocation: AppRoutes.home,
|
||||
routes: [
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) => AppScaffold(
|
||||
navigationShell: navigationShell,
|
||||
),
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.home,
|
||||
builder: (context, state) => const HomeScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.analytics,
|
||||
builder: (context, state) => const AnalyticsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.accounts,
|
||||
builder: (context, state) => const AccountsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.profile,
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
class AppRoutes {
|
||||
AppRoutes._();
|
||||
|
||||
static const home = '/home';
|
||||
static const analytics = '/analytics';
|
||||
static const accounts = '/accounts';
|
||||
static const profile = '/profile';
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
class AppPalette extends ThemeExtension<AppPalette> {
|
||||
const AppPalette({
|
||||
required this.paper,
|
||||
required this.paper2,
|
||||
required this.cardSoft,
|
||||
required this.ink,
|
||||
required this.ink2,
|
||||
required this.line,
|
||||
required this.line2,
|
||||
required this.accent,
|
||||
required this.accentSoft,
|
||||
required this.positive,
|
||||
required this.negative,
|
||||
});
|
||||
|
||||
final Color paper;
|
||||
final Color paper2;
|
||||
final Color cardSoft;
|
||||
final Color ink;
|
||||
final Color ink2;
|
||||
final Color line;
|
||||
final Color line2;
|
||||
final Color accent;
|
||||
final Color accentSoft;
|
||||
final Color positive;
|
||||
final Color negative;
|
||||
|
||||
static const light = AppPalette(
|
||||
paper: Color(0xFFF6F4EF),
|
||||
paper2: Color(0xFFEFECE5),
|
||||
cardSoft: Color(0xFFEDEAE3),
|
||||
ink: Color(0xFF1C1C1A),
|
||||
ink2: Color(0xFF6B6B66),
|
||||
line: Color(0xFFD8D5CC),
|
||||
line2: Color(0xFFB8B5AC),
|
||||
accent: Color(0xFF4A8A82),
|
||||
accentSoft: Color(0xFFDDE9E6),
|
||||
positive: Color(0xFF6F8C69),
|
||||
negative: Color(0xFFB3675A),
|
||||
);
|
||||
|
||||
static const dark = AppPalette(
|
||||
paper: Color(0xFF19191A),
|
||||
paper2: Color(0xFF232325),
|
||||
cardSoft: Color(0xFF232325),
|
||||
ink: Color(0xFFECE9E2),
|
||||
ink2: Color(0xFF8D8A83),
|
||||
line: Color(0xFF2E2D2A),
|
||||
line2: Color(0xFF4A4845),
|
||||
accent: Color(0xFF76B3A9),
|
||||
accentSoft: Color(0xFF23332F),
|
||||
positive: Color(0xFF92B58A),
|
||||
negative: Color(0xFFD18D7E),
|
||||
);
|
||||
|
||||
@override
|
||||
AppPalette copyWith({
|
||||
Color? paper,
|
||||
Color? paper2,
|
||||
Color? cardSoft,
|
||||
Color? ink,
|
||||
Color? ink2,
|
||||
Color? line,
|
||||
Color? line2,
|
||||
Color? accent,
|
||||
Color? accentSoft,
|
||||
Color? positive,
|
||||
Color? negative,
|
||||
}) {
|
||||
return AppPalette(
|
||||
paper: paper ?? this.paper,
|
||||
paper2: paper2 ?? this.paper2,
|
||||
cardSoft: cardSoft ?? this.cardSoft,
|
||||
ink: ink ?? this.ink,
|
||||
ink2: ink2 ?? this.ink2,
|
||||
line: line ?? this.line,
|
||||
line2: line2 ?? this.line2,
|
||||
accent: accent ?? this.accent,
|
||||
accentSoft: accentSoft ?? this.accentSoft,
|
||||
positive: positive ?? this.positive,
|
||||
negative: negative ?? this.negative,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AppPalette lerp(ThemeExtension<AppPalette>? other, double t) {
|
||||
if (other is! AppPalette) return this;
|
||||
return AppPalette(
|
||||
paper: Color.lerp(paper, other.paper, t)!,
|
||||
paper2: Color.lerp(paper2, other.paper2, t)!,
|
||||
cardSoft: Color.lerp(cardSoft, other.cardSoft, t)!,
|
||||
ink: Color.lerp(ink, other.ink, t)!,
|
||||
ink2: Color.lerp(ink2, other.ink2, t)!,
|
||||
line: Color.lerp(line, other.line, t)!,
|
||||
line2: Color.lerp(line2, other.line2, t)!,
|
||||
accent: Color.lerp(accent, other.accent, t)!,
|
||||
accentSoft: Color.lerp(accentSoft, other.accentSoft, t)!,
|
||||
positive: Color.lerp(positive, other.positive, t)!,
|
||||
negative: Color.lerp(negative, other.negative, t)!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension AppPaletteX on BuildContext {
|
||||
AppPalette get palette => Theme.of(this).extension<AppPalette>()!;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTheme {
|
||||
const AppTheme._();
|
||||
|
||||
static ThemeData light() => _build(AppPalette.light, Brightness.light);
|
||||
static ThemeData dark() => _build(AppPalette.dark, Brightness.dark);
|
||||
|
||||
static ThemeData _build(AppPalette p, Brightness brightness) {
|
||||
final base = ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: brightness,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: p.accent,
|
||||
brightness: brightness,
|
||||
).copyWith(
|
||||
surface: p.paper,
|
||||
onSurface: p.ink,
|
||||
surfaceContainerHighest: p.paper2,
|
||||
outline: p.line,
|
||||
outlineVariant: p.line,
|
||||
),
|
||||
scaffoldBackgroundColor: p.paper,
|
||||
dividerColor: p.line,
|
||||
extensions: [p],
|
||||
);
|
||||
|
||||
return base.copyWith(
|
||||
textTheme: GoogleFonts.dmSansTextTheme(base.textTheme).apply(
|
||||
bodyColor: p.ink,
|
||||
displayColor: p.ink,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper for tabular-numbers monospace text (balances, amounts).
|
||||
TextStyle monoStyle({
|
||||
required Color color,
|
||||
double fontSize = 14,
|
||||
FontWeight fontWeight = FontWeight.w500,
|
||||
double letterSpacing = 0,
|
||||
}) {
|
||||
return GoogleFonts.jetBrainsMono(
|
||||
color: color,
|
||||
fontSize: fontSize,
|
||||
fontWeight: fontWeight,
|
||||
letterSpacing: letterSpacing,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'theme_mode_controller.g.dart';
|
||||
|
||||
/// Активный [ThemeMode] приложения. Дефолт — `dark`, чтобы совпадать с
|
||||
/// `design/index.html` (TWEAK_DEFAULTS.theme = 'dark').
|
||||
@Riverpod(keepAlive: true)
|
||||
class ThemeModeController extends _$ThemeModeController {
|
||||
@override
|
||||
ThemeMode build() => ThemeMode.dark;
|
||||
|
||||
void set(ThemeMode mode) => state = mode;
|
||||
|
||||
void toggle() {
|
||||
state = state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../../../core/database/converters/enum_converters.dart';
|
||||
import '../../../core/database/converters/enum_converters.dart';
|
||||
import '../domain/entities/account.dart';
|
||||
import 'account_providers.dart';
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/widgets/placeholder_screen.dart';
|
||||
|
||||
class AccountsScreen extends StatelessWidget {
|
||||
const AccountsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const PlaceholderScreen(
|
||||
subtitle: 'Управление',
|
||||
title: 'Счета',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/widgets/placeholder_screen.dart';
|
||||
|
||||
class AnalyticsScreen extends StatelessWidget {
|
||||
const AnalyticsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const PlaceholderScreen(
|
||||
subtitle: 'Аналитика',
|
||||
title: 'Отчёты',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../../../core/database/converters/enum_converters.dart';
|
||||
import '../../../core/database/converters/enum_converters.dart';
|
||||
import '../domain/entities/category.dart';
|
||||
import 'category_providers.dart';
|
||||
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
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);
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../accounts/domain/entities/account.dart';
|
||||
import '../../categories/domain/entities/category.dart';
|
||||
import '../../../core/database/converters/enum_converters.dart';
|
||||
import '../../transactions/domain/entities/transaction.dart';
|
||||
import '_mock_data.dart';
|
||||
import 'state/selected_category_filter.dart';
|
||||
|
||||
/// Сводка по месяцу — баланс, доходы, расходы для текущего выбранного счёта.
|
||||
class MonthSummary {
|
||||
const MonthSummary({
|
||||
required this.balanceMinor,
|
||||
required this.incomeMinor,
|
||||
required this.expensesMinor,
|
||||
required this.spendByCategory,
|
||||
required this.transactionsCount,
|
||||
});
|
||||
|
||||
final int balanceMinor;
|
||||
final int incomeMinor;
|
||||
final int expensesMinor;
|
||||
final Map<int, 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);
|
||||
final accountId = ref.watch(selectedAccountProvider);
|
||||
final categoryId = ref.watch(selectedCategoryFilterProvider);
|
||||
|
||||
return all.where((t) {
|
||||
if (accountId != 0 && 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);
|
||||
final selectedAccount = ref.watch(selectedAccountProvider);
|
||||
|
||||
// Балансы считаем не учитывая фильтр категории (баланс счёта от неё не зависит).
|
||||
final scopedTxs = selectedAccount == 0
|
||||
? txs
|
||||
: txs.where((t) => t.accountId == selectedAccount).toList();
|
||||
|
||||
var income = 0;
|
||||
var expense = 0;
|
||||
final spendByCat = <int, int>{};
|
||||
for (final t in scopedTxs) {
|
||||
switch (t.type) {
|
||||
case TransactionType.income:
|
||||
income += t.amount;
|
||||
case TransactionType.expense:
|
||||
expense += t.amount;
|
||||
final cid = t.categoryId;
|
||||
if (cid != null) {
|
||||
spendByCat[cid] = (spendByCat[cid] ?? 0) + t.amount;
|
||||
}
|
||||
case TransactionType.transfer:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Баланс = сумма initialBalance по выбранным счетам + доход - расход.
|
||||
final selectedAccounts = selectedAccount == 0
|
||||
? accounts
|
||||
: accounts.where((a) => a.id == selectedAccount);
|
||||
final baseBalance = selectedAccounts.fold<int>(
|
||||
0,
|
||||
(s, Account a) => s + a.initialBalance,
|
||||
);
|
||||
|
||||
return MonthSummary(
|
||||
balanceMinor: baseBalance + income - expense,
|
||||
incomeMinor: income,
|
||||
expensesMinor: expense,
|
||||
spendByCategory: spendByCat,
|
||||
transactionsCount: scopedTxs.length,
|
||||
);
|
||||
});
|
||||
|
||||
/// Возвращает категории, отсортированные по сумме трат (по убыванию).
|
||||
List<MapEntry<Category, int>> categoriesBySpend(
|
||||
Map<int, int> spend,
|
||||
List<Category> categories,
|
||||
) {
|
||||
final result = <MapEntry<Category, int>>[];
|
||||
for (final c in categories) {
|
||||
final v = spend[c.id];
|
||||
if (v != null && v > 0) result.add(MapEntry(c, v));
|
||||
}
|
||||
result.sort((a, b) => b.value.compareTo(a.value));
|
||||
return result;
|
||||
}
|
||||
|
||||
Color colorFor(Category c) => Color(c.colorValue ?? 0xFFB8B5AC);
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import '../../../categories/domain/entities/category.dart';
|
||||
import '../../../transactions/domain/entities/transaction.dart';
|
||||
import '../_mock_data.dart';
|
||||
import '../month_summary.dart';
|
||||
import '../widgets/account_tabs.dart';
|
||||
import '../widgets/category_donut_card.dart';
|
||||
import '../widgets/day_header.dart';
|
||||
import '../widgets/fab_add_transaction.dart';
|
||||
import '../widgets/month_header.dart';
|
||||
import '../widgets/month_kpi_card.dart';
|
||||
import '../widgets/transactions_section.dart';
|
||||
import '../widgets/tx_row.dart';
|
||||
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final categories = ref.watch(mockCategoriesProvider);
|
||||
final categoryById = {for (final c in categories) c.id: c};
|
||||
final txs = ref.watch(filteredTransactionsProvider);
|
||||
final groups = _groupByDay(txs);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: p.paper,
|
||||
body: Stack(
|
||||
children: [
|
||||
SafeArea(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
const SliverToBoxAdapter(child: MonthHeader()),
|
||||
const SliverToBoxAdapter(child: AccountTabs()),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 0)),
|
||||
const SliverToBoxAdapter(child: MonthKpiCard()),
|
||||
const SliverToBoxAdapter(child: CategoryDonutCard()),
|
||||
const SliverToBoxAdapter(child: TransactionsSectionHeader()),
|
||||
const SliverToBoxAdapter(child: CategoryFilterPill()),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 4)),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) => _DayGroupBlock(
|
||||
group: groups[i],
|
||||
categoryById: categoryById,
|
||||
),
|
||||
childCount: groups.length,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 96)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FabAddTransaction(onPressed: () {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DayGroupBlock extends StatelessWidget {
|
||||
const _DayGroupBlock({required this.group, required this.categoryById});
|
||||
|
||||
final _DayGroup group;
|
||||
final Map<int, Category> categoryById;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
DayHeader(label: group.label, totalMinor: group.totalSpentMinor),
|
||||
for (final t in group.items)
|
||||
TxRow(tx: t, category: categoryById[t.categoryId]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DayGroup {
|
||||
_DayGroup({required this.label, required this.items, required this.totalSpentMinor});
|
||||
final String label;
|
||||
final List<Transaction> items;
|
||||
final int totalSpentMinor;
|
||||
}
|
||||
|
||||
List<_DayGroup> _groupByDay(List<Transaction> txs) {
|
||||
final sorted = [...txs]..sort((a, b) => b.date.compareTo(a.date));
|
||||
final map = <DateTime, List<Transaction>>{};
|
||||
for (final t in sorted) {
|
||||
final key = DateTime(t.date.year, t.date.month, t.date.day);
|
||||
map.putIfAbsent(key, () => []).add(t);
|
||||
}
|
||||
final keys = map.keys.toList()..sort((a, b) => b.compareTo(a));
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final fmt = DateFormat('d MMMM', 'ru');
|
||||
|
||||
return [
|
||||
for (final k in keys)
|
||||
_DayGroup(
|
||||
label: _dayLabel(k, today, fmt),
|
||||
items: map[k]!,
|
||||
totalSpentMinor: map[k]!
|
||||
.where((t) => t.amount > 0 && t.note != null)
|
||||
.where((t) => _isExpense(t))
|
||||
.fold<int>(0, (s, t) => s + t.amount),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
bool _isExpense(Transaction t) {
|
||||
// Помечаем расходы — для day-итога. (Type-based filter.)
|
||||
return t.amount > 0 && t.categoryId != null && _isExpenseType(t);
|
||||
}
|
||||
|
||||
bool _isExpenseType(Transaction t) {
|
||||
// Доступ к энам — без импорта в этой утилите.
|
||||
return t.type.name == 'expense';
|
||||
}
|
||||
|
||||
String _dayLabel(DateTime day, DateTime today, DateFormat fmt) {
|
||||
final diff = today.difference(day).inDays;
|
||||
final base = fmt.format(day);
|
||||
if (diff == 0) return 'Сегодня · $base';
|
||||
if (diff == 1) return 'Вчера · $base';
|
||||
return base;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// id выбранной категории для фильтра списка транзакций.
|
||||
/// `null` — фильтр снят, отображаются все категории.
|
||||
final selectedCategoryFilterProvider = StateProvider<int?>((ref) => null);
|
||||
|
||||
/// id выбранного счёта в табах. `kAllAccountsId` (0) — «Все счета».
|
||||
final selectedAccountProvider = StateProvider<int>((ref) => 0);
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import '../../../accounts/domain/entities/account.dart';
|
||||
import '../_mock_data.dart';
|
||||
import '../state/selected_category_filter.dart';
|
||||
|
||||
class AccountTabs extends ConsumerWidget {
|
||||
const AccountTabs({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final accounts = ref.watch(mockAccountsProvider);
|
||||
final selected = ref.watch(selectedAccountProvider);
|
||||
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
_Pill(
|
||||
icon: Icons.account_balance_wallet_outlined,
|
||||
label: 'Все',
|
||||
active: selected == kAllAccountsId,
|
||||
onTap: () => ref.read(selectedAccountProvider.notifier).state =
|
||||
kAllAccountsId,
|
||||
),
|
||||
for (final a in accounts) ...[
|
||||
const SizedBox(width: 8),
|
||||
_AccountPill(
|
||||
account: a,
|
||||
active: selected == a.id,
|
||||
onTap: () =>
|
||||
ref.read(selectedAccountProvider.notifier).state = a.id,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AccountPill extends StatelessWidget {
|
||||
const _AccountPill({
|
||||
required this.account,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Account account;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Pill(
|
||||
icon: iconForAccount(account),
|
||||
label: shortAccountLabel(account),
|
||||
active: active,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Pill extends StatelessWidget {
|
||||
const _Pill({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final fg = active ? p.paper : p.ink;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? p.ink : p.paper,
|
||||
border: Border.all(color: active ? p.ink : p.line),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: fg.withValues(alpha: active ? 1 : 0.7)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DonutSlice {
|
||||
const DonutSlice({
|
||||
required this.id,
|
||||
required this.value,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final double value;
|
||||
final Color color;
|
||||
}
|
||||
|
||||
class CategoryDonut extends StatelessWidget {
|
||||
const CategoryDonut({
|
||||
super.key,
|
||||
required this.slices,
|
||||
required this.size,
|
||||
required this.thickness,
|
||||
this.activeId,
|
||||
this.onSegmentTap,
|
||||
this.centerChild,
|
||||
});
|
||||
|
||||
final List<DonutSlice> slices;
|
||||
final double size;
|
||||
final double thickness;
|
||||
final int? activeId;
|
||||
final ValueChanged<int?>? onSegmentTap;
|
||||
final Widget? centerChild;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final radius = thickness;
|
||||
final activeRadius = thickness + 4;
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
PieChart(
|
||||
PieChartData(
|
||||
sectionsSpace: 0,
|
||||
centerSpaceRadius: (size / 2) - thickness,
|
||||
startDegreeOffset: -90,
|
||||
sections: [
|
||||
for (final s in slices)
|
||||
PieChartSectionData(
|
||||
value: s.value,
|
||||
color: activeId == null || activeId == s.id
|
||||
? s.color
|
||||
: s.color.withValues(alpha: 0.35),
|
||||
radius: activeId == s.id ? activeRadius : radius,
|
||||
showTitle: false,
|
||||
),
|
||||
],
|
||||
pieTouchData: PieTouchData(
|
||||
enabled: onSegmentTap != null,
|
||||
touchCallback: (event, response) {
|
||||
if (event is FlTapUpEvent) {
|
||||
final i = response?.touchedSection?.touchedSectionIndex;
|
||||
if (i == null || i < 0 || i >= slices.length) {
|
||||
onSegmentTap?.call(null);
|
||||
} else {
|
||||
onSegmentTap?.call(slices[i].id);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (centerChild != null)
|
||||
IgnorePointer(child: centerChild!),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import '../../../categories/domain/entities/category.dart';
|
||||
import '../_mock_data.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});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final summary = ref.watch(monthSummaryProvider);
|
||||
final categories = ref.watch(mockCategoriesProvider);
|
||||
final selectedCat = ref.watch(selectedCategoryFilterProvider);
|
||||
final sorted = categoriesBySpend(summary.spendByCategory, categories);
|
||||
|
||||
final slices = [
|
||||
for (final entry in sorted)
|
||||
DonutSlice(
|
||||
id: entry.key.id,
|
||||
value: entry.value.toDouble(),
|
||||
color: colorFor(entry.key),
|
||||
),
|
||||
];
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: p.line),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CategoryDonut(
|
||||
slices: slices,
|
||||
size: 130,
|
||||
thickness: 22,
|
||||
activeId: selectedCat,
|
||||
onSegmentTap: (id) {
|
||||
final notifier =
|
||||
ref.read(selectedCategoryFilterProvider.notifier);
|
||||
notifier.state = id == notifier.state ? null : id;
|
||||
},
|
||||
centerChild: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'РАСХОДЫ',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: p.ink2,
|
||||
letterSpacing: 0.6,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
MoneyText(
|
||||
-summary.spendTotalMinor,
|
||||
color: p.ink,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: _Legend(entries: sorted, totalMinor: summary.spendTotalMinor)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Legend extends StatelessWidget {
|
||||
const _Legend({required this.entries, required this.totalMinor});
|
||||
|
||||
final List<MapEntry<Category, int>> entries;
|
||||
final int totalMinor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final top = entries.take(4).toList();
|
||||
final restCount = entries.length - top.length;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final e in top)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colorFor(e.key),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
e.key.name,
|
||||
style: TextStyle(fontSize: 12, color: p.ink),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
MoneyText(
|
||||
totalMinor == 0
|
||||
? 0
|
||||
: (e.value * 100 ~/ totalMinor),
|
||||
color: p.ink2,
|
||||
fontSize: 12,
|
||||
withCurrency: false,
|
||||
),
|
||||
Text(
|
||||
'%',
|
||||
style: TextStyle(fontSize: 12, color: p.ink2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (restCount > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'+ ещё $restCount ${_word(restCount)} →',
|
||||
style: TextStyle(fontSize: 10, color: p.ink2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _word(int n) {
|
||||
final mod10 = n % 10;
|
||||
final mod100 = n % 100;
|
||||
if (mod10 == 1 && mod100 != 11) return 'категория';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
|
||||
return 'категории';
|
||||
}
|
||||
return 'категорий';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import 'money_text.dart';
|
||||
|
||||
class DayHeader extends StatelessWidget {
|
||||
const DayHeader({super.key, required this.label, required this.totalMinor});
|
||||
|
||||
final String label;
|
||||
final int totalMinor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: p.ink2,
|
||||
letterSpacing: 0.6,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
MoneyText(
|
||||
-totalMinor,
|
||||
color: p.ink2,
|
||||
fontSize: 11,
|
||||
withSign: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
|
||||
class FabAddTransaction extends StatelessWidget {
|
||||
const FabAddTransaction({super.key, this.onPressed});
|
||||
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
return Material(
|
||||
color: p.accent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
elevation: 6,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.25),
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
width: 52,
|
||||
height: 52,
|
||||
child: Icon(Icons.add, color: p.paper, size: 24),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../app/theme/app_theme.dart';
|
||||
|
||||
String formatMinor(int minor, {bool withSign = false, bool withCurrency = true}) {
|
||||
final value = minor.abs() ~/ 100;
|
||||
final formatter = NumberFormat.decimalPattern('ru');
|
||||
final body = formatter.format(value).replaceAll(',', ' ');
|
||||
final sign = !withSign
|
||||
? ''
|
||||
: minor > 0
|
||||
? '+'
|
||||
: minor < 0
|
||||
? '−' // U+2212 minus
|
||||
: '';
|
||||
final suffix = withCurrency ? ' ₽' : '';
|
||||
return '$sign$body$suffix';
|
||||
}
|
||||
|
||||
/// Денежный текст моно-шрифтом с табулярными цифрами.
|
||||
class MoneyText extends StatelessWidget {
|
||||
const MoneyText(
|
||||
this.minor, {
|
||||
super.key,
|
||||
required this.color,
|
||||
this.fontSize = 14,
|
||||
this.fontWeight = FontWeight.w500,
|
||||
this.withSign = false,
|
||||
this.withCurrency = true,
|
||||
this.letterSpacing = 0,
|
||||
});
|
||||
|
||||
final int minor;
|
||||
final Color color;
|
||||
final double fontSize;
|
||||
final FontWeight fontWeight;
|
||||
final bool withSign;
|
||||
final bool withCurrency;
|
||||
final double letterSpacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
formatMinor(minor, withSign: withSign, withCurrency: withCurrency),
|
||||
style: monoStyle(
|
||||
color: color,
|
||||
fontSize: fontSize,
|
||||
fontWeight: fontWeight,
|
||||
letterSpacing: letterSpacing,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
|
||||
class MonthHeader extends StatelessWidget {
|
||||
const MonthHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final now = DateTime.now();
|
||||
final monthName = DateFormat.MMMM('ru').format(now);
|
||||
final monthCap = '${monthName[0].toUpperCase()}${monthName.substring(1)}';
|
||||
final title = '$monthCap ${now.year}';
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'БЮДЖЕТ',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: p.ink2,
|
||||
letterSpacing: 0.6,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
icon: Icon(Icons.search, size: 20, color: p.ink2),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: () {},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
icon: Icon(Icons.notifications_outlined, size: 20, color: p.ink2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import '../month_summary.dart';
|
||||
import 'money_text.dart';
|
||||
|
||||
class MonthKpiCard extends ConsumerWidget {
|
||||
const MonthKpiCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final s = ref.watch(monthSummaryProvider);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 12, 16, 14),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: p.paper,
|
||||
border: Border.all(color: p.line),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'БАЛАНС',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: p.ink2,
|
||||
letterSpacing: 0.6,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
MoneyText(
|
||||
s.balanceMinor,
|
||||
color: p.ink,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SubKpi(
|
||||
label: 'ДОХОДЫ',
|
||||
value: s.incomeMinor,
|
||||
color: p.positive,
|
||||
withSign: true,
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 32, color: p.line),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SubKpi(
|
||||
label: 'РАСХОДЫ',
|
||||
value: -s.expensesMinor,
|
||||
color: p.negative,
|
||||
withSign: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubKpi extends StatelessWidget {
|
||||
const _SubKpi({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
this.withSign = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int value;
|
||||
final Color color;
|
||||
final bool withSign;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: p.ink2,
|
||||
letterSpacing: 0.6,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
MoneyText(
|
||||
value,
|
||||
color: color,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.3,
|
||||
withSign: withSign,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import '../_mock_data.dart';
|
||||
import '../month_summary.dart';
|
||||
import '../state/selected_category_filter.dart';
|
||||
|
||||
class TransactionsSectionHeader extends ConsumerWidget {
|
||||
const TransactionsSectionHeader({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final count = ref.watch(filteredTransactionsProvider).length;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Транзакции',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.ink,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'$count ${_opsWord(count)}',
|
||||
style: TextStyle(fontSize: 11, color: p.ink2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _opsWord(int n) {
|
||||
final mod10 = n % 10;
|
||||
final mod100 = n % 100;
|
||||
if (mod10 == 1 && mod100 != 11) return 'операция';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
|
||||
return 'операции';
|
||||
}
|
||||
return 'операций';
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryFilterPill extends ConsumerWidget {
|
||||
const CategoryFilterPill({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final categories = ref.watch(mockCategoriesProvider);
|
||||
final selectedId = ref.watch(selectedCategoryFilterProvider);
|
||||
final activeCat = selectedId == null
|
||||
? null
|
||||
: categories.firstWhere((c) => c.id == selectedId,
|
||||
orElse: () => categories.first);
|
||||
final count = ref.watch(filteredTransactionsProvider).length;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
ref.read(selectedCategoryFilterProvider.notifier).state = null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: p.line),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.tune, size: 18, color: p.ink2),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: activeCat == null
|
||||
? Text(
|
||||
'Все категории · все типы',
|
||||
style: TextStyle(fontSize: 13, color: p.ink),
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colorFor(activeCat),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
activeCat.name,
|
||||
style: TextStyle(fontSize: 13, color: p.ink),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: p.accentSoft,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(Icons.keyboard_arrow_down, size: 18, color: p.ink2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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 '../../../transactions/domain/entities/transaction.dart';
|
||||
import '../_mock_data.dart';
|
||||
import '../month_summary.dart';
|
||||
import 'money_text.dart';
|
||||
|
||||
class TxRow extends StatelessWidget {
|
||||
const TxRow({
|
||||
super.key,
|
||||
required this.tx,
|
||||
required this.category,
|
||||
});
|
||||
|
||||
final Transaction tx;
|
||||
final Category? category;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final cat = category;
|
||||
final color = cat != null ? colorFor(cat) : p.ink2;
|
||||
final icon = cat != null ? iconForCategory(cat) : Icons.more_horiz;
|
||||
|
||||
final signedAmount = tx.type == TransactionType.income
|
||||
? tx.amount
|
||||
: tx.type == TransactionType.expense
|
||||
? -tx.amount
|
||||
: 0;
|
||||
final amountColor =
|
||||
tx.type == TransactionType.income ? p.positive : p.ink;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: p.line)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: color),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tx.note ?? '—',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: p.ink,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${cat?.name ?? ''} · ${_subtitleTime(tx.date)}',
|
||||
style: TextStyle(fontSize: 11, color: p.ink2),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
MoneyText(
|
||||
signedAmount,
|
||||
color: amountColor,
|
||||
fontSize: 14,
|
||||
withSign: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _subtitleTime(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final dt = DateTime(date.year, date.month, date.day);
|
||||
final diff = today.difference(dt).inDays;
|
||||
final hm = DateFormat('HH:mm').format(date);
|
||||
if (diff == 0) return 'Сегодня, $hm';
|
||||
if (diff == 1) return 'Вчера, $hm';
|
||||
return DateFormat('d MMM', 'ru').format(date);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../app/theme/app_colors.dart';
|
||||
import '../../../../app/theme/theme_mode_controller.dart';
|
||||
import '../../../../shared/widgets/placeholder_screen.dart';
|
||||
|
||||
class ProfileScreen extends ConsumerWidget {
|
||||
const ProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final p = context.palette;
|
||||
final mode = ref.watch(themeModeControllerProvider);
|
||||
final controller = ref.read(themeModeControllerProvider.notifier);
|
||||
|
||||
return PlaceholderScreen(
|
||||
subtitle: 'Настройки',
|
||||
title: 'Профиль',
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: p.line),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_Row(
|
||||
icon: Icons.dark_mode_outlined,
|
||||
title: 'Тёмная тема',
|
||||
trailing: Switch(
|
||||
value: mode == ThemeMode.dark,
|
||||
activeThumbColor: p.accent,
|
||||
onChanged: (v) =>
|
||||
controller.set(v ? ThemeMode.dark : ThemeMode.light),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Здесь появится профиль пользователя, валюта, локаль и другие настройки.',
|
||||
style: TextStyle(fontSize: 12, color: p.ink2),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row({required this.icon, required this.title, required this.trailing});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final Widget trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: p.ink2),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 14, color: p.ink),
|
||||
),
|
||||
),
|
||||
trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../../../core/database/converters/enum_converters.dart';
|
||||
import '../../../core/database/converters/enum_converters.dart';
|
||||
import '../domain/entities/transaction.dart';
|
||||
import 'transaction_providers.dart';
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/theme/app_colors.dart';
|
||||
|
||||
class AppBottomNav extends StatelessWidget {
|
||||
const AppBottomNav({
|
||||
super.key,
|
||||
required this.activeIndex,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final int activeIndex;
|
||||
final ValueChanged<int> onTap;
|
||||
|
||||
static const _items = <_NavItem>[
|
||||
_NavItem(icon: Icons.home_outlined, label: 'Главная'),
|
||||
_NavItem(icon: Icons.bar_chart_outlined, label: 'Аналитика'),
|
||||
_NavItem(icon: Icons.account_balance_wallet_outlined, label: 'Счета'),
|
||||
_NavItem(icon: Icons.person_outline, label: 'Профиль'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: p.paper,
|
||||
border: Border(top: BorderSide(color: p.line)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: 60,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < _items.length; i++)
|
||||
Expanded(
|
||||
child: _NavTab(
|
||||
item: _items[i],
|
||||
active: i == activeIndex,
|
||||
onTap: () => onTap(i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem {
|
||||
const _NavItem({required this.icon, required this.label});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class _NavTab extends StatelessWidget {
|
||||
const _NavTab({
|
||||
required this.item,
|
||||
required this.active,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final _NavItem item;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
final color = active ? p.accent : p.ink2;
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 2,
|
||||
horizontal: active ? 14 : 0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? p.accentSoft : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(item.icon, size: 22, color: color),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
item.label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: active ? FontWeight.w600 : FontWeight.w400,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'app_bottom_nav.dart';
|
||||
|
||||
class AppScaffold extends StatelessWidget {
|
||||
const AppScaffold({super.key, required this.navigationShell});
|
||||
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: navigationShell,
|
||||
bottomNavigationBar: AppBottomNav(
|
||||
activeIndex: navigationShell.currentIndex,
|
||||
onTap: (i) => navigationShell.goBranch(
|
||||
i,
|
||||
initialLocation: i == navigationShell.currentIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/theme/app_colors.dart';
|
||||
|
||||
class PlaceholderScreen extends StatelessWidget {
|
||||
const PlaceholderScreen({
|
||||
super.key,
|
||||
required this.subtitle,
|
||||
required this.title,
|
||||
this.body,
|
||||
});
|
||||
|
||||
final String subtitle;
|
||||
final String title;
|
||||
final Widget? body;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final p = context.palette;
|
||||
return Scaffold(
|
||||
backgroundColor: p.paper,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
subtitle.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: p.ink2,
|
||||
letterSpacing: 0.6,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: p.ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: body ??
|
||||
Center(
|
||||
child: Text(
|
||||
'Скоро',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: p.ink2,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+767
-5
@@ -1,6 +1,38 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "85.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.6.0"
|
||||
analyzer_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_plugin
|
||||
sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.4"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -17,6 +49,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build
|
||||
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
build_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
build_daemon:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_resolvers:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
build_runner:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
build_runner_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.2"
|
||||
built_collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.6"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -25,6 +121,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
ci:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ci
|
||||
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -33,6 +161,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: dad6bf6b9f4f378b0a69edbf42584d336efd1a9ce15deb1ba591cbb1b5ff440f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
code_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -41,6 +185,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -49,6 +209,78 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
custom_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: custom_lint
|
||||
sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
custom_lint_builder:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_builder
|
||||
sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
custom_lint_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_core
|
||||
sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.5"
|
||||
custom_lint_visitor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: custom_lint_visitor
|
||||
sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0+7.7.0"
|
||||
dart_style:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: "540cf382a3bfa99b76e51514db5b0ebcd81ce3679b7c1c9cb9478ff3735e47a1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.28.2"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: "68c138e884527d2bd61df2ade276c3a144df84d1adeb0ab8f3196b5afe021bd4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.28.0"
|
||||
drift_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift_flutter
|
||||
sha256: b7534bf320aac5213259aac120670ba67b63a1fd010505babc436ff86083818f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.7"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -57,6 +289,38 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.69.2"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@@ -66,15 +330,185 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
version: "5.0.0"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
freezed:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: freezed
|
||||
sha256: "2d399f823b8849663744d2a9ddcce01c49268fb4170d0442a655bf6a2f47be22"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
freezed_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
hotreloader:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hotreloader
|
||||
sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.4.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
json_serializable:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.9.5"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -103,10 +537,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
version: "5.1.1"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -131,6 +573,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -139,11 +605,195 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
recase:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: recase
|
||||
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
riverpod_analyzer_utils:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod_analyzer_utils
|
||||
sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.10"
|
||||
riverpod_annotation:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: riverpod_annotation
|
||||
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
riverpod_generator:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: riverpod_generator
|
||||
sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.5"
|
||||
riverpod_lint:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: riverpod_lint
|
||||
sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.5"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_gen:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
source_helper:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.7"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -152,6 +802,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.42"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "57090342af1ce32bb499aa641f4ecdd2d6231b9403cea537ac059e803cc20d67"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.41.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -160,6 +834,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
state_notifier:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: state_notifier
|
||||
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -168,6 +850,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -192,6 +882,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -208,6 +922,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.0 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
+10
-2
@@ -9,6 +9,8 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
|
||||
# State management
|
||||
flutter_riverpod: ^2.6.1
|
||||
@@ -22,7 +24,7 @@ dependencies:
|
||||
go_router: ^14.8.1
|
||||
|
||||
# Immutable entities
|
||||
freezed_annotation: ^2.4.4
|
||||
freezed_annotation: ^3.0.0
|
||||
json_annotation: ^4.9.0
|
||||
|
||||
# Formatting
|
||||
@@ -31,6 +33,12 @@ dependencies:
|
||||
# Icons
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
# Fonts
|
||||
google_fonts: ^6.2.1
|
||||
|
||||
# Charts
|
||||
fl_chart: ^0.69.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -44,7 +52,7 @@ dev_dependencies:
|
||||
riverpod_lint: ^2.6.5
|
||||
custom_lint: ^0.7.5
|
||||
drift_dev: ^2.26.1
|
||||
freezed: ^2.5.7
|
||||
freezed: ^3.0.0
|
||||
json_serializable: ^6.9.5
|
||||
|
||||
flutter:
|
||||
|
||||
+9
-23
@@ -1,30 +1,16 @@
|
||||
// This is a basic Flutter widget test.
|
||||
//
|
||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||
// tree, read text, and verify that the values of widget properties are correct.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:new_budget/main.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:new_budget/src/app/app.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
testWidgets('App boots without crashes', (tester) async {
|
||||
await initializeDateFormatting('ru');
|
||||
await tester.pumpWidget(
|
||||
const ProviderScope(child: NewBudgetApp()),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.byType(MaterialApp), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user