style(app): остальные экраны под новый визуальный язык и форматирование

Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
Dmitry
2026-09-19 22:14:27 +03:00
parent 62d36aa3e8
commit 322c60a359
55 changed files with 2158 additions and 1080 deletions
@@ -20,15 +20,15 @@ const _flowTypes = [
];
String _flowTypeLabel(FlowType t) => switch (t) {
FlowType.income => 'Доход',
FlowType.expense => 'Расход',
FlowType.internalTransfer => 'Перевод',
FlowType.savingsTransfer => 'В сбережения',
FlowType.brokerExternalFlow => 'Брокер',
FlowType.other => 'Прочее',
FlowType.deleted => 'Удалено',
FlowType.unknownDefaultOpenApi => 'Неизвестно',
};
FlowType.income => 'Доход',
FlowType.expense => 'Расход',
FlowType.internalTransfer => 'Перевод',
FlowType.savingsTransfer => 'В сбережения',
FlowType.brokerExternalFlow => 'Брокер',
FlowType.other => 'Прочее',
FlowType.deleted => 'Удалено',
FlowType.unknownDefaultOpenApi => 'Неизвестно',
};
/// Операции: filtered, paginated transaction list with infinite scroll and a
/// detail bottom sheet per row.
@@ -60,7 +60,8 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
}
void _onScroll() {
if (_scrollController.position.pixels > _scrollController.position.maxScrollExtent - 200) {
if (_scrollController.position.pixels >
_scrollController.position.maxScrollExtent - 200) {
ref.read(transactionsControllerProvider.notifier).loadMore();
}
}
@@ -68,8 +69,11 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 400), () {
ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
ref
.read(transactionsControllerProvider.notifier)
.setFilter(
(f) =>
f.copyWith(q: () => value.trim().isEmpty ? null : value.trim()),
);
});
}
@@ -87,16 +91,18 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
helpText: 'Период',
);
if (picked != null) {
ref.read(transactionsControllerProvider.notifier).setFilter(
ref
.read(transactionsControllerProvider.notifier)
.setFilter(
(f) => f.copyWith(from: () => picked.start, to: () => picked.end),
);
}
}
void _clearDateRange() {
ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(from: () => null, to: () => null),
);
ref
.read(transactionsControllerProvider.notifier)
.setFilter((f) => f.copyWith(from: () => null, to: () => null));
}
void _showDetail(TransactionOut t) {
@@ -116,10 +122,14 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
@override
Widget build(BuildContext context) {
final state = ref.watch(transactionsControllerProvider);
final controllerFilter = ref.watch(transactionsControllerProvider.notifier).filter;
final controllerFilter = ref
.watch(transactionsControllerProvider.notifier)
.filter;
final categoryNames = ref.watch(categoryNamesProvider);
final accounts = ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
final categories = ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[];
final accounts =
ref.watch(accountsProvider).valueOrNull?.data ?? const <AccountOut>[];
final categories =
ref.watch(categoriesListProvider).valueOrNull ?? const <CategoryOut>[];
return Scaffold(
appBar: AppBar(title: const Text('Операции')),
@@ -149,26 +159,36 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
InputChip(
avatar: const Icon(Icons.date_range, size: 18),
label: Text(
controllerFilter.from != null && controllerFilter.to != null
controllerFilter.from != null &&
controllerFilter.to != null
? '${ruDate(controllerFilter.from!)} ${ruDate(controllerFilter.to!)}'
: 'Период',
),
onPressed: _pickDateRange,
onDeleted: controllerFilter.from != null ? _clearDateRange : null,
onDeleted: controllerFilter.from != null
? _clearDateRange
: null,
),
SizedBox(
width: 180,
child: DropdownButtonFormField<int?>(
initialValue: controllerFilter.accountId,
isDense: true,
decoration: const InputDecoration(labelText: 'Счёт', isDense: true),
decoration: const InputDecoration(
labelText: 'Счёт',
isDense: true,
),
items: [
const DropdownMenuItem(value: null, child: Text('Все счета')),
for (final a in accounts) DropdownMenuItem(value: a.id, child: Text(a.name)),
const DropdownMenuItem(
value: null,
child: Text('Все счета'),
),
for (final a in accounts)
DropdownMenuItem(value: a.id, child: Text(a.name)),
],
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(accountId: () => v),
),
onChanged: (v) => ref
.read(transactionsControllerProvider.notifier)
.setFilter((f) => f.copyWith(accountId: () => v)),
),
),
SizedBox(
@@ -176,14 +196,21 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
child: DropdownButtonFormField<int?>(
initialValue: controllerFilter.categoryId,
isDense: true,
decoration: const InputDecoration(labelText: 'Категория', isDense: true),
decoration: const InputDecoration(
labelText: 'Категория',
isDense: true,
),
items: [
const DropdownMenuItem(value: null, child: Text('Все категории')),
for (final c in categories) DropdownMenuItem(value: c.id, child: Text(c.name)),
const DropdownMenuItem(
value: null,
child: Text('Все категории'),
),
for (final c in categories)
DropdownMenuItem(value: c.id, child: Text(c.name)),
],
onChanged: (v) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(categoryId: () => v),
),
onChanged: (v) => ref
.read(transactionsControllerProvider.notifier)
.setFilter((f) => f.copyWith(categoryId: () => v)),
),
),
],
@@ -195,17 +222,17 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
ChoiceChip(
label: const Text('Все типы'),
selected: controllerFilter.flowType == null,
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(flowType: () => null),
),
onSelected: (_) => ref
.read(transactionsControllerProvider.notifier)
.setFilter((f) => f.copyWith(flowType: () => null)),
),
for (final ft in _flowTypes)
ChoiceChip(
label: Text(_flowTypeLabel(ft)),
selected: controllerFilter.flowType == ft,
onSelected: (_) => ref.read(transactionsControllerProvider.notifier).setFilter(
(f) => f.copyWith(flowType: () => ft),
),
onSelected: (_) => ref
.read(transactionsControllerProvider.notifier)
.setFilter((f) => f.copyWith(flowType: () => ft)),
),
],
),
@@ -230,12 +257,17 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error, size: 32),
Icon(
Icons.error_outline,
color: Theme.of(context).colorScheme.error,
size: 32,
),
const SizedBox(height: 8),
Text('${state.error}', textAlign: TextAlign.center),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: () => ref.read(transactionsControllerProvider.notifier).refresh(),
onPressed: () =>
ref.read(transactionsControllerProvider.notifier).refresh(),
child: const Text('Повторить'),
),
],
@@ -250,7 +282,8 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
);
}
return RefreshIndicator(
onRefresh: () => ref.read(transactionsControllerProvider.notifier).refresh(),
onRefresh: () =>
ref.read(transactionsControllerProvider.notifier).refresh(),
child: ListView.builder(
controller: _scrollController,
itemCount: state.items.length + 1,
@@ -263,7 +296,9 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
child: state.loadingMore
? const CircularProgressIndicator()
: TextButton(
onPressed: () => ref.read(transactionsControllerProvider.notifier).loadMore(),
onPressed: () => ref
.read(transactionsControllerProvider.notifier)
.loadMore(),
child: const Text('Ещё'),
),
),
@@ -272,7 +307,9 @@ class _TransactionsPageState extends ConsumerState<TransactionsPage> {
final t = state.items[index];
return TransactionRow(
transaction: t,
categoryName: t.categoryId != null ? categoryNames[t.categoryId] : null,
categoryName: t.categoryId != null
? categoryNames[t.categoryId]
: null,
onTap: () => _showDetail(t),
);
},
@@ -298,24 +335,43 @@ class _TransactionDetailSheet extends StatelessWidget {
final rows = <(String, String)>[
('Дата', ruDate(t.date)),
('Плательщик', t.payee ?? ''),
if (t.payeeCanonical != null && t.payeeCanonical != t.payee) ('Канонический', t.payeeCanonical!),
if (t.payeeCanonical != null && t.payeeCanonical != t.payee)
('Канонический', t.payeeCanonical!),
('Комментарий', t.comment ?? ''),
('Категория', t.categoryId != null ? (categoryNames[t.categoryId] ?? '#${t.categoryId}') : 'Без категории'),
(
'Категория',
t.categoryId != null
? (categoryNames[t.categoryId] ?? '#${t.categoryId}')
: 'Без категории',
),
('Тип', _flowTypeLabel(t.flowType)),
if (t.outcome != '0')
('Списание', '${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}'
'${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}'),
(
'Списание',
'${MoneyText.format(t.outcome, t.outcomeCurrency ?? 'RUB')}'
'${t.outcomeRub != null ? ' (${MoneyText.format(t.outcomeRub!, 'RUB')})' : ''}',
),
if (t.income != '0')
('Зачисление', '${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}'
'${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}'),
(
'Зачисление',
'${MoneyText.format(t.income, t.incomeCurrency ?? 'RUB')}'
'${t.incomeRub != null ? ' (${MoneyText.format(t.incomeRub!, 'RUB')})' : ''}',
),
if (t.outcomeAccountId != null)
('Счёт списания', accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}'),
(
'Счёт списания',
accountNames[t.outcomeAccountId] ?? '#${t.outcomeAccountId}',
),
if (t.incomeAccountId != null)
('Счёт зачисления', accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}'),
(
'Счёт зачисления',
accountNames[t.incomeAccountId] ?? '#${t.incomeAccountId}',
),
if (t.mcc != null) ('MCC', '${t.mcc}'),
('Удержание (hold)', t.hold ? 'да' : 'нет'),
('Разовая трата', t.isOneOff ? 'да' : 'нет'),
if (t.tags.isNotEmpty) ('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')),
if (t.tags.isNotEmpty)
('Теги', t.tags.map((id) => categoryNames[id] ?? '#$id').join(', ')),
if (t.tripId != null) ('Поездка', '#${t.tripId}'),
('Источник (id)', t.sourceId),
];
@@ -334,7 +390,13 @@ class _TransactionDetailSheet extends StatelessWidget {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 140, child: Text(label, style: Theme.of(context).textTheme.bodySmall)),
SizedBox(
width: 140,
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
),
Expanded(child: Text(value)),
],
),