Files
fin-tracker/app/lib/features/rules/rules_page.dart
T
Dmitry 7b419f4188 feat(app): offline-кэш на остальных экранах — фаза 5
accounts, cashflow, categories, goals, income, portfolio (+instrument),
rebalance, tax, rules переведены на Cached<T> по контракту
docs/ai/offline-cache.md. Общие/второстепенные селекторы (scopesProvider,
categoriesListProvider и т.п.) оставлены как есть — не основной контент
экрана. sync_page.dart и settings_page.dart не тронуты (первый — заменён
health-page отдельно, второй — чистые действия без списка для баннера).
2026-09-19 14:00:13 +03:00

387 lines
13 KiB
Dart

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/cache/cached.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/stale_banner.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);
final stale = oldestFetch([
rules.valueOrNull?.fetchedAt,
ref.watch(rulesStaleProvider).valueOrNull?.fetchedAt,
]);
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: (cached) {
final rows = cached.data;
if (rows.isEmpty) {
return ListView(
children: [
if (stale != null) StaleBanner(fetchedAt: stale),
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: [
if (stale != null) StaleBanner(fetchedAt: stale),
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('Сохранить')),
],
);
}
}