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 отдельно, второй — чистые действия без списка для баннера).
387 lines
13 KiB
Dart
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('Сохранить')),
|
|
],
|
|
);
|
|
}
|
|
}
|