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:
@@ -0,0 +1,377 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:fintracker_api/fintracker_api.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/api/api_client.dart';
|
||||
import '../../core/auth/auth_controller.dart';
|
||||
import '../../core/utils/ru_date.dart';
|
||||
import '../../core/widgets/async_value_view.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import 'providers.dart';
|
||||
|
||||
String ruleKindLabel(RuleKind k) => switch (k) {
|
||||
RuleKind.savings => 'Сбережения',
|
||||
RuleKind.oneOff => 'Разовая трата',
|
||||
RuleKind.category => 'Категория',
|
||||
RuleKind.payee => 'Плательщик',
|
||||
RuleKind.brokerTarget => 'Целевой брокер',
|
||||
RuleKind.ignore => 'Игнорировать',
|
||||
RuleKind.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
String ruleMatchTypeLabel(RuleMatchType t) => switch (t) {
|
||||
RuleMatchType.id => 'ID транзакции',
|
||||
RuleMatchType.payee => 'Плательщик',
|
||||
RuleMatchType.comment => 'Комментарий',
|
||||
RuleMatchType.category => 'Категория',
|
||||
RuleMatchType.mcc => 'MCC',
|
||||
RuleMatchType.account => 'Счёт',
|
||||
RuleMatchType.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
const _kinds = [
|
||||
RuleKind.savings,
|
||||
RuleKind.oneOff,
|
||||
RuleKind.category,
|
||||
RuleKind.payee,
|
||||
RuleKind.brokerTarget,
|
||||
RuleKind.ignore,
|
||||
];
|
||||
|
||||
const _matchTypes = [
|
||||
RuleMatchType.id,
|
||||
RuleMatchType.payee,
|
||||
RuleMatchType.comment,
|
||||
RuleMatchType.category,
|
||||
RuleMatchType.mcc,
|
||||
RuleMatchType.account,
|
||||
];
|
||||
|
||||
void _invalidateAll(WidgetRef ref) {
|
||||
ref.invalidate(rulesListProvider);
|
||||
ref.invalidate(rulesStaleProvider);
|
||||
}
|
||||
|
||||
/// Правила: list of categorisation rules, an add/edit dialog, and a manual
|
||||
/// "apply" trigger that re-runs the whole metric refresh.
|
||||
class RulesPage extends ConsumerWidget {
|
||||
const RulesPage({super.key});
|
||||
|
||||
Future<void> _apply(BuildContext context, WidgetRef ref) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final r = await ref.read(apiProvider).getRulesApi().rulesApply();
|
||||
final ok = r.data?.error == null;
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}'),
|
||||
));
|
||||
} on DioException catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
_invalidateAll(ref);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final rules = ref.watch(rulesListProvider);
|
||||
final staleIds = ref.watch(staleRuleIdsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Правила'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Применить правила',
|
||||
icon: const Icon(Icons.play_circle_outline),
|
||||
onPressed: () => _apply(context, ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => _RuleDialog(onSaved: () => _invalidateAll(ref)),
|
||||
),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => _invalidateAll(ref),
|
||||
child: AsyncValueView(
|
||||
value: rules,
|
||||
onRetry: () => ref.invalidate(rulesListProvider),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return ListView(
|
||||
children: const [
|
||||
EmptyState(icon: Icons.rule_folder_outlined, message: 'Правил ещё нет.'),
|
||||
],
|
||||
);
|
||||
}
|
||||
final sorted = [...rows]..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
for (final rule in sorted)
|
||||
_RuleTile(
|
||||
rule: rule,
|
||||
stale: staleIds.contains(rule.id),
|
||||
onChanged: () => _invalidateAll(ref),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RuleTile extends ConsumerStatefulWidget {
|
||||
const _RuleTile({required this.rule, required this.stale, required this.onChanged});
|
||||
|
||||
final RuleOut rule;
|
||||
final bool stale;
|
||||
final VoidCallback onChanged;
|
||||
|
||||
@override
|
||||
ConsumerState<_RuleTile> createState() => _RuleTileState();
|
||||
}
|
||||
|
||||
class _RuleTileState extends ConsumerState<_RuleTile> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _toggle(bool enabled) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await ref.read(apiProvider).getRulesApi().rulesPatch(
|
||||
ruleId: widget.rule.id,
|
||||
rulePatch: RulePatch(enabled: enabled),
|
||||
);
|
||||
widget.onChanged();
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Удалить правило?'),
|
||||
content: Text('«${widget.rule.pattern}» — действие необратимо.'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Отмена')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Удалить')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await ref.read(apiProvider).getRulesApi().rulesDelete(ruleId: widget.rule.id);
|
||||
widget.onChanged();
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rule = widget.rule;
|
||||
return Card(
|
||||
child: ListTile(
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => _RuleDialog(existing: rule, onSaved: widget.onChanged),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Text(ruleKindLabel(rule.kind)),
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(ruleMatchTypeLabel(rule.matchType)),
|
||||
),
|
||||
if (widget.stale) ...[
|
||||
const SizedBox(width: 8),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: const Text('устарело'),
|
||||
backgroundColor: Colors.amber.withValues(alpha: 0.2),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
[
|
||||
'${rule.pattern} → ${rule.value ?? '—'}',
|
||||
'совпадений: ${rule.matchCount}',
|
||||
if (rule.lastMatchedAt != null) 'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}',
|
||||
].join(' · '),
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Switch(value: rule.enabled, onChanged: _busy ? null : _toggle),
|
||||
IconButton(icon: const Icon(Icons.delete_outline), onPressed: _delete),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RuleDialog extends ConsumerStatefulWidget {
|
||||
const _RuleDialog({this.existing, required this.onSaved});
|
||||
|
||||
final RuleOut? existing;
|
||||
final VoidCallback onSaved;
|
||||
|
||||
@override
|
||||
ConsumerState<_RuleDialog> createState() => _RuleDialogState();
|
||||
}
|
||||
|
||||
class _RuleDialogState extends ConsumerState<_RuleDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late RuleKind _kind;
|
||||
late RuleMatchType _matchType;
|
||||
late final TextEditingController _pattern;
|
||||
late final TextEditingController _value;
|
||||
late final TextEditingController _note;
|
||||
late final TextEditingController _priority;
|
||||
late bool _enabled;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final e = widget.existing;
|
||||
_kind = e?.kind ?? RuleKind.category;
|
||||
_matchType = e?.matchType ?? RuleMatchType.payee;
|
||||
_pattern = TextEditingController(text: e?.pattern ?? '');
|
||||
_value = TextEditingController(text: e?.value ?? '');
|
||||
_note = TextEditingController(text: e?.note ?? '');
|
||||
_priority = TextEditingController(text: (e?.priority ?? 100).toString());
|
||||
_enabled = e?.enabled ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pattern.dispose();
|
||||
_value.dispose();
|
||||
_note.dispose();
|
||||
_priority.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
setState(() => _saving = true);
|
||||
final priority = int.tryParse(_priority.text.trim()) ?? 100;
|
||||
try {
|
||||
final api = ref.read(apiProvider).getRulesApi();
|
||||
if (widget.existing == null) {
|
||||
await api.rulesCreate(
|
||||
ruleCreate: RuleCreate(
|
||||
kind: _kind,
|
||||
matchType: _matchType,
|
||||
pattern: _pattern.text.trim(),
|
||||
value: _value.text.trim().isEmpty ? null : _value.text.trim(),
|
||||
note: _note.text.trim().isEmpty ? null : _note.text.trim(),
|
||||
priority: priority,
|
||||
enabled: _enabled,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await api.rulesPatch(
|
||||
ruleId: widget.existing!.id,
|
||||
rulePatch: RulePatch(
|
||||
kind: _kind,
|
||||
matchType: _matchType,
|
||||
pattern: _pattern.text.trim(),
|
||||
value: _value.text.trim().isEmpty ? null : _value.text.trim(),
|
||||
note: _note.text.trim().isEmpty ? null : _note.text.trim(),
|
||||
priority: priority,
|
||||
enabled: _enabled,
|
||||
),
|
||||
);
|
||||
}
|
||||
widget.onSaved();
|
||||
if (mounted) Navigator.pop(context);
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.existing == null ? 'Новое правило' : 'Правило'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<RuleKind>(
|
||||
initialValue: _kind,
|
||||
decoration: const InputDecoration(labelText: 'Тип правила'),
|
||||
items: [for (final k in _kinds) DropdownMenuItem(value: k, child: Text(ruleKindLabel(k)))],
|
||||
onChanged: (v) => setState(() => _kind = v!),
|
||||
),
|
||||
DropdownButtonFormField<RuleMatchType>(
|
||||
initialValue: _matchType,
|
||||
decoration: const InputDecoration(labelText: 'Совпадение по'),
|
||||
items: [
|
||||
for (final t in _matchTypes)
|
||||
DropdownMenuItem(value: t, child: Text(ruleMatchTypeLabel(t))),
|
||||
],
|
||||
onChanged: (v) => setState(() => _matchType = v!),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _pattern,
|
||||
decoration: const InputDecoration(labelText: 'Шаблон (pattern)'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null,
|
||||
),
|
||||
TextFormField(
|
||||
controller: _value,
|
||||
decoration: const InputDecoration(labelText: 'Значение (value)'),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _note,
|
||||
decoration: const InputDecoration(labelText: 'Заметка'),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _priority,
|
||||
decoration: const InputDecoration(labelText: 'Приоритет'),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Включено'),
|
||||
value: _enabled,
|
||||
onChanged: (v) => setState(() => _enabled = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Отмена')),
|
||||
FilledButton(onPressed: _saving ? null : _save, child: const Text('Сохранить')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user