style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
@@ -5,17 +5,27 @@ import '../../core/api/api_client.dart';
|
||||
import '../../core/cache/cached.dart';
|
||||
|
||||
/// See `docs/ai/offline-cache.md`.
|
||||
final rulesListProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((ref) async {
|
||||
final rulesListProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((
|
||||
ref,
|
||||
) async {
|
||||
final r = await ref.watch(apiProvider).getRulesApi().rulesList();
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
/// Enabled rules that matched nothing in the latest refresh — «устарело» here is a business
|
||||
/// concept (the rule looks dead), unrelated to the offline-cache staleness this file also
|
||||
/// tracks; both happen to be named "stale" in their own domains.
|
||||
final rulesStaleProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((ref) async {
|
||||
final rulesStaleProvider = FutureProvider.autoDispose<Cached<List<RuleOut>>>((
|
||||
ref,
|
||||
) async {
|
||||
final r = await ref.watch(apiProvider).getRulesApi().rulesStale();
|
||||
return Cached(r.data ?? const [], fetchedAt: r.extra['fetchedAt'] as DateTime?);
|
||||
return Cached(
|
||||
r.data ?? const [],
|
||||
fetchedAt: r.extra['fetchedAt'] as DateTime?,
|
||||
);
|
||||
});
|
||||
|
||||
final staleRuleIdsProvider = Provider.autoDispose<Set<int>>((ref) {
|
||||
|
||||
@@ -13,24 +13,24 @@ 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 => 'Неизвестно',
|
||||
};
|
||||
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 => 'Неизвестно',
|
||||
};
|
||||
RuleMatchType.id => 'ID транзакции',
|
||||
RuleMatchType.payee => 'Плательщик',
|
||||
RuleMatchType.comment => 'Комментарий',
|
||||
RuleMatchType.category => 'Категория',
|
||||
RuleMatchType.mcc => 'MCC',
|
||||
RuleMatchType.account => 'Счёт',
|
||||
RuleMatchType.unknownDefaultOpenApi => 'Неизвестно',
|
||||
};
|
||||
|
||||
const _kinds = [
|
||||
RuleKind.savings,
|
||||
@@ -65,9 +65,13 @@ class RulesPage extends ConsumerWidget {
|
||||
try {
|
||||
final r = await ref.read(apiProvider).getRulesApi().rulesApply();
|
||||
final ok = r.data?.error == null;
|
||||
messenger.showSnackBar(SnackBar(
|
||||
content: Text(ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}'),
|
||||
));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
ok ? 'Правила применены' : 'Ошибка применения: ${r.data?.error}',
|
||||
),
|
||||
),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
@@ -113,11 +117,15 @@ class RulesPage extends ConsumerWidget {
|
||||
return ListView(
|
||||
children: [
|
||||
if (stale != null) StaleBanner(fetchedAt: stale),
|
||||
const EmptyState(icon: Icons.rule_folder_outlined, message: 'Правил ещё нет.'),
|
||||
const EmptyState(
|
||||
icon: Icons.rule_folder_outlined,
|
||||
message: 'Правил ещё нет.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final sorted = [...rows]..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
final sorted = [...rows]
|
||||
..sort((a, b) => a.priority.compareTo(b.priority));
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
@@ -138,7 +146,11 @@ class RulesPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _RuleTile extends ConsumerStatefulWidget {
|
||||
const _RuleTile({required this.rule, required this.stale, required this.onChanged});
|
||||
const _RuleTile({
|
||||
required this.rule,
|
||||
required this.stale,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final RuleOut rule;
|
||||
final bool stale;
|
||||
@@ -154,14 +166,18 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
|
||||
Future<void> _toggle(bool enabled) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await ref.read(apiProvider).getRulesApi().rulesPatch(
|
||||
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))));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
@@ -174,18 +190,28 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
|
||||
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('Удалить')),
|
||||
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);
|
||||
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))));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +222,8 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
|
||||
child: ListTile(
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
builder: (_) => _RuleDialog(existing: rule, onSaved: widget.onChanged),
|
||||
builder: (_) =>
|
||||
_RuleDialog(existing: rule, onSaved: widget.onChanged),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
@@ -222,7 +249,8 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
|
||||
[
|
||||
'${rule.pattern} → ${rule.value ?? '—'}',
|
||||
'совпадений: ${rule.matchCount}',
|
||||
if (rule.lastMatchedAt != null) 'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}',
|
||||
if (rule.lastMatchedAt != null)
|
||||
'последнее: ${ruDate(rule.lastMatchedAt!.toLocal())}',
|
||||
].join(' · '),
|
||||
),
|
||||
),
|
||||
@@ -230,7 +258,10 @@ class _RuleTileState extends ConsumerState<_RuleTile> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Switch(value: rule.enabled, onChanged: _busy ? null : _toggle),
|
||||
IconButton(icon: const Icon(Icons.delete_outline), onPressed: _delete),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: _delete,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -317,7 +348,8 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
|
||||
if (mounted) Navigator.pop(context);
|
||||
} on DioException catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(problemMessage(e))));
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
@@ -337,7 +369,10 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
|
||||
DropdownButtonFormField<RuleKind>(
|
||||
initialValue: _kind,
|
||||
decoration: const InputDecoration(labelText: 'Тип правила'),
|
||||
items: [for (final k in _kinds) DropdownMenuItem(value: k, child: Text(ruleKindLabel(k)))],
|
||||
items: [
|
||||
for (final k in _kinds)
|
||||
DropdownMenuItem(value: k, child: Text(ruleKindLabel(k))),
|
||||
],
|
||||
onChanged: (v) => setState(() => _kind = v!),
|
||||
),
|
||||
DropdownButtonFormField<RuleMatchType>(
|
||||
@@ -345,18 +380,27 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
|
||||
decoration: const InputDecoration(labelText: 'Совпадение по'),
|
||||
items: [
|
||||
for (final t in _matchTypes)
|
||||
DropdownMenuItem(value: t, child: Text(ruleMatchTypeLabel(t))),
|
||||
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,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Шаблон (pattern)',
|
||||
),
|
||||
validator: (v) => (v == null || v.trim().isEmpty)
|
||||
? 'Обязательное поле'
|
||||
: null,
|
||||
),
|
||||
TextFormField(
|
||||
controller: _value,
|
||||
decoration: const InputDecoration(labelText: 'Значение (value)'),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Значение (value)',
|
||||
),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _note,
|
||||
@@ -378,8 +422,14 @@ class _RuleDialogState extends ConsumerState<_RuleDialog> {
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Отмена')),
|
||||
FilledButton(onPressed: _saving ? null : _save, child: const Text('Сохранить')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user