style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
@@ -22,7 +22,12 @@ class TransactionsFilter {
|
||||
final FlowType? flowType;
|
||||
|
||||
bool get isEmpty =>
|
||||
q == null && from == null && to == null && accountId == null && categoryId == null && flowType == null;
|
||||
q == null &&
|
||||
from == null &&
|
||||
to == null &&
|
||||
accountId == null &&
|
||||
categoryId == null &&
|
||||
flowType == null;
|
||||
|
||||
TransactionsFilter copyWith({
|
||||
String? Function()? q,
|
||||
@@ -98,7 +103,9 @@ class TransactionsController extends Notifier<TransactionsState> {
|
||||
return const TransactionsState(loading: true);
|
||||
}
|
||||
|
||||
Future<void> setFilter(TransactionsFilter Function(TransactionsFilter) update) {
|
||||
Future<void> setFilter(
|
||||
TransactionsFilter Function(TransactionsFilter) update,
|
||||
) {
|
||||
filter = update(filter);
|
||||
return refresh();
|
||||
}
|
||||
@@ -106,7 +113,10 @@ class TransactionsController extends Notifier<TransactionsState> {
|
||||
Future<void> refresh() async {
|
||||
state = state.copyWith(loading: true, clearError: true);
|
||||
try {
|
||||
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
|
||||
final r = await ref
|
||||
.read(apiProvider)
|
||||
.getTransactionsApi()
|
||||
.transactionsList(
|
||||
from: filter.from,
|
||||
to: filter.to,
|
||||
accountId: filter.accountId,
|
||||
@@ -133,7 +143,10 @@ class TransactionsController extends Notifier<TransactionsState> {
|
||||
state = state.copyWith(loadingMore: true, clearError: true);
|
||||
try {
|
||||
final next = state.page + 1;
|
||||
final r = await ref.read(apiProvider).getTransactionsApi().transactionsList(
|
||||
final r = await ref
|
||||
.read(apiProvider)
|
||||
.getTransactionsApi()
|
||||
.transactionsList(
|
||||
from: filter.from,
|
||||
to: filter.to,
|
||||
accountId: filter.accountId,
|
||||
@@ -157,4 +170,6 @@ class TransactionsController extends Notifier<TransactionsState> {
|
||||
}
|
||||
|
||||
final transactionsControllerProvider =
|
||||
NotifierProvider<TransactionsController, TransactionsState>(TransactionsController.new);
|
||||
NotifierProvider<TransactionsController, TransactionsState>(
|
||||
TransactionsController.new,
|
||||
);
|
||||
|
||||
@@ -7,12 +7,22 @@ import '../../core/widgets/money_text.dart';
|
||||
/// The amount/currency/RUB-equivalent a transaction is shown with, chosen by
|
||||
/// its [FlowType]: the outgoing leg for expenses and transfers out, the
|
||||
/// incoming leg for income.
|
||||
({String amount, String currency, String? rub}) primaryAmount(TransactionOut t) {
|
||||
({String amount, String currency, String? rub}) primaryAmount(
|
||||
TransactionOut t,
|
||||
) {
|
||||
switch (t.flowType) {
|
||||
case FlowType.income:
|
||||
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub);
|
||||
return (
|
||||
amount: t.income,
|
||||
currency: t.incomeCurrency ?? 'RUB',
|
||||
rub: t.incomeRub,
|
||||
);
|
||||
case FlowType.expense:
|
||||
return (amount: t.outcome, currency: t.outcomeCurrency ?? 'RUB', rub: t.outcomeRub);
|
||||
return (
|
||||
amount: t.outcome,
|
||||
currency: t.outcomeCurrency ?? 'RUB',
|
||||
rub: t.outcomeRub,
|
||||
);
|
||||
case FlowType.internalTransfer:
|
||||
case FlowType.savingsTransfer:
|
||||
case FlowType.brokerExternalFlow:
|
||||
@@ -20,22 +30,33 @@ import '../../core/widgets/money_text.dart';
|
||||
case FlowType.deleted:
|
||||
case FlowType.unknownDefaultOpenApi:
|
||||
if (t.outcome != '0' && t.outcomeCurrency != null) {
|
||||
return (amount: t.outcome, currency: t.outcomeCurrency!, rub: t.outcomeRub);
|
||||
return (
|
||||
amount: t.outcome,
|
||||
currency: t.outcomeCurrency!,
|
||||
rub: t.outcomeRub,
|
||||
);
|
||||
}
|
||||
return (amount: t.income, currency: t.incomeCurrency ?? 'RUB', rub: t.incomeRub);
|
||||
return (
|
||||
amount: t.income,
|
||||
currency: t.incomeCurrency ?? 'RUB',
|
||||
rub: t.incomeRub,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) => switch (type) {
|
||||
(IconData, Color) flowTypeIcon(FlowType type, ColorScheme scheme) =>
|
||||
switch (type) {
|
||||
FlowType.income => (Icons.arrow_circle_down_outlined, Colors.green),
|
||||
FlowType.expense => (Icons.arrow_circle_up_outlined, scheme.error),
|
||||
FlowType.internalTransfer => (Icons.swap_horiz, scheme.primary),
|
||||
FlowType.savingsTransfer => (Icons.savings_outlined, Colors.amber.shade800),
|
||||
FlowType.savingsTransfer => (
|
||||
Icons.savings_outlined,
|
||||
Colors.amber.shade800,
|
||||
),
|
||||
FlowType.brokerExternalFlow => (Icons.trending_up, Colors.deepPurple),
|
||||
FlowType.other || FlowType.deleted || FlowType.unknownDefaultOpenApi => (
|
||||
Icons.help_outline,
|
||||
scheme.outline,
|
||||
),
|
||||
FlowType.other ||
|
||||
FlowType.deleted ||
|
||||
FlowType.unknownDefaultOpenApi => (Icons.help_outline, scheme.outline),
|
||||
};
|
||||
|
||||
/// One row of the Операции list: date, payee, category, native amount with
|
||||
@@ -59,7 +80,9 @@ class TransactionRow extends StatelessWidget {
|
||||
final (icon, color) = flowTypeIcon(t.flowType, scheme);
|
||||
final amount = primaryAmount(t);
|
||||
final showRub = amount.currency != 'RUB' && amount.rub != null;
|
||||
final payee = t.payeeCanonical?.isNotEmpty == true ? t.payeeCanonical! : (t.payee ?? '—');
|
||||
final payee = t.payeeCanonical?.isNotEmpty == true
|
||||
? t.payeeCanonical!
|
||||
: (t.payee ?? '—');
|
||||
|
||||
return ListTile(
|
||||
onTap: onTap,
|
||||
@@ -74,7 +97,8 @@ class TransactionRow extends StatelessWidget {
|
||||
if (showRub)
|
||||
Text(
|
||||
MoneyText.format(amount.rub!, 'RUB'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: scheme.outline),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: scheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user