Add main screen

This commit is contained in:
2026-05-27 10:10:38 +03:00
parent 04d1ad6629
commit 456986e79e
34 changed files with 3174 additions and 151 deletions
+7 -118
View File
@@ -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()));
}
+32
View File
@@ -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')],
);
}
}
+60
View File
@@ -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(),
),
],
),
],
),
],
);
}
+8
View File
@@ -0,0 +1,8 @@
class AppRoutes {
AppRoutes._();
static const home = '/home';
static const analytics = '/analytics';
static const accounts = '/accounts';
static const profile = '/profile';
}
+109
View File
@@ -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>()!;
}
+54
View File
@@ -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';
+106
View File
@@ -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,
),
),
],
),
),
);
}
}
+24
View File
@@ -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,
),
),
),
),
],
),
),
);
}
}