feat(app): Flutter-клиент — логин, дашборд, потоки, категории, транзакции, правила

Riverpod + go_router с auth-guard, NavigationRail на широком экране и bottom bar
на узком. Токены в flutter_secure_storage, на web access живёт в памяти.
Интерцептор подставляет токен и делает ровно один refresh на 401.

Деньги приходят строками и форматируются через Decimal: парсить их в double
значило бы терять копейки ровно там, где бэкенд их бережёт.
This commit is contained in:
Dmitry
2026-09-18 13:44:09 +03:00
parent b9c12fa1a1
commit ce0966fca2
99 changed files with 6284 additions and 0 deletions
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/common_providers.dart';
import '../../core/app_info.dart';
import '../../core/auth/auth_controller.dart';
import '../../core/config.dart';
import '../../core/theme/theme_controller.dart';
import '../../core/widgets/async_value_view.dart';
/// Настройки: API endpoint, signed-in account, theme and logout.
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final me = ref.watch(meProvider);
final themeMode = ref.watch(themeModeProvider);
return Scaffold(
appBar: AppBar(title: const Text('Настройки')),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
const ListTile(
leading: Icon(Icons.dns_outlined),
title: Text('Адрес API'),
),
ListTile(
contentPadding: const EdgeInsets.only(left: 56, right: 16),
title: SelectableText(apiBaseUrl()),
),
const Divider(),
ListTile(
leading: const Icon(Icons.account_circle_outlined),
title: const Text('Аккаунт'),
subtitle: AsyncValueView(
value: me,
data: (u) => Text(u.email),
onRetry: () => ref.invalidate(meProvider),
),
),
const Divider(),
const ListTile(
leading: Icon(Icons.palette_outlined),
title: Text('Тема'),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
label: Text('Системная'),
icon: Icon(Icons.brightness_auto_outlined),
),
ButtonSegment(
value: ThemeMode.light,
label: Text('Светлая'),
icon: Icon(Icons.light_mode_outlined),
),
ButtonSegment(
value: ThemeMode.dark,
label: Text('Тёмная'),
icon: Icon(Icons.dark_mode_outlined),
),
],
selected: {themeMode},
onSelectionChanged: (selection) =>
ref.read(themeModeProvider.notifier).setMode(selection.first),
),
),
const Divider(),
const ListTile(
leading: Icon(Icons.info_outline),
title: Text('Версия приложения'),
subtitle: Text(appVersion),
),
const Divider(),
ListTile(
leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
title: Text('Выйти', style: TextStyle(color: Theme.of(context).colorScheme.error)),
onTap: () => ref.read(authControllerProvider.notifier).logout(),
),
],
),
);
}
}