Add main screen
This commit is contained in:
+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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user