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 _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 _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 _delete() async { final confirmed = await showDialog( 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(); 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 _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( 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( 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('Сохранить')), ], ); } }