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
+134 -54
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../accounts/account_create_dialog.dart';
import '../portfolio/labels.dart' show eventKindLabels, formatQty;
import 'data/imports_api.dart';
import 'labels.dart';
@@ -31,14 +32,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
ImportResult? _result;
void _snack(String message) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
Future<void> _commit(ImportPreview preview) async {
final accountId = preview.accountId ?? _accountChoice;
if (accountId == null) return;
setState(() => _busy = true);
try {
final result = await ref.read(importsApiProvider).commit(
final result = await ref
.read(importsApiProvider)
.commit(
preview.id,
accountId: preview.accountId == null ? accountId : null,
confirmDuplicates: _confirmDuplicates,
@@ -49,8 +53,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
invalidateLedgerDependents(ref);
ref.invalidate(importsListProvider);
ref.invalidate(importPreviewProvider(preview.id));
_snack('Импортировано: создано ${result.eventsCreated}, '
'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}');
_snack(
'Импортировано: создано ${result.eventsCreated}, '
'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}',
);
} catch (e) {
if (!mounted) return;
_snack(importErrorMessage(e));
@@ -64,13 +70,19 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
context: context,
builder: (context) => AlertDialog(
title: const Text('Удалить импорт?'),
content: Text('Файл «${preview.filename}» и разобранные строки будут удалены. '
'События в леджере не создавались, так что удалять нечего.'),
content: Text(
'Файл «${preview.filename}» и разобранные строки будут удалены. '
'События в леджере не создавались, так что удалять нечего.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')),
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true), child: const Text('Удалить')),
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Удалить'),
),
],
),
);
@@ -104,7 +116,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
IconButton(
tooltip: 'Обновить',
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(importPreviewProvider(widget.importId)),
onPressed: () =>
ref.invalidate(importPreviewProvider(widget.importId)),
),
],
),
@@ -131,7 +144,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
icon: Icons.copy_all_outlined,
color: theme.colorScheme.secondary,
title: 'Этот файл уже загружали',
body: 'Показан ранее созданный импорт №${p.duplicateOfId}. '
body:
'Показан ранее созданный импорт №${p.duplicateOfId}. '
'Повторная загрузка не создаёт новых событий.',
),
],
@@ -172,8 +186,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
children: [
Text('Строки отчёта', style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text('Первые ${p.sampleEvents.length} из ${p.counts.lines}',
style: theme.textTheme.bodySmall),
Text(
'Первые ${p.sampleEvents.length} из ${p.counts.lines}',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
SampleEventsTable(events: p.sampleEvents),
],
@@ -217,13 +233,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
_kv(
'Счёт',
p.accountName ??
(p.accountId != null ? '#${p.accountId}' : 'не определён по отчёту'),
(p.accountId != null
? '#${p.accountId}'
: 'не определён по отчёту'),
),
if (p.accountExternalId != null) _kv('Счёт в отчёте', p.accountExternalId!),
if (p.accountExternalId != null)
_kv('Счёт в отчёте', p.accountExternalId!),
if (p.parserName != null)
_kv('Парсер', '${p.parserName} v${p.parserVersion ?? '1'}'),
if (p.sizeBytes != null) _kv('Размер', formatBytes(p.sizeBytes)),
if (p.uploadedAt != null) _kv('Загружен', ruDate(p.uploadedAt!.toLocal())),
if (p.uploadedAt != null)
_kv('Загружен', ruDate(p.uploadedAt!.toLocal())),
if (p.committedAt != null)
_kv('Импортирован', ruDate(p.committedAt!.toLocal())),
],
@@ -232,6 +252,22 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
);
}
Future<void> _createAccountFromReport(ImportPreview p) async {
final created = await showAccountCreateDialog(
context,
broker: brokerFromImportKey(p.broker),
sourceId: p.accountExternalId,
name: p.accountExternalId == null
? null
: '${brokerLabel(p.broker)} ${p.accountExternalId}',
);
if (created == null || !mounted) return;
// refetch first: the dropdown only accepts a value that is among the server's suggestions
ref.invalidate(importPreviewProvider(widget.importId));
await ref.read(importPreviewProvider(widget.importId).future);
if (mounted) setState(() => _accountChoice = created.id);
}
Widget _accountPicker(ImportPreview p) {
final theme = Theme.of(context);
return Card(
@@ -243,12 +279,18 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
children: [
Row(
children: [
Icon(Icons.account_balance_outlined, color: theme.colorScheme.onErrorContainer),
Icon(
Icons.account_balance_outlined,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text('Счёт не определён',
style: theme.textTheme.titleMedium
?.copyWith(color: theme.colorScheme.onErrorContainer)),
child: Text(
'Счёт не определён',
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
@@ -256,10 +298,17 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
Text(
p.accountSuggestions.isEmpty
? 'В отчёте номер счёта ${p.accountExternalId ?? ''}, но подходящего '
'счёта в базе нет. Заведите счёт, затем вернитесь сюда.'
: 'Выберите счёт, в который писать события. Без него импорт недоступен.',
'счёта в базе нет. Создайте счёт по данным из отчёта.'
: 'Выберите счёт, в который писать события, или создайте новый. '
'Без счёта импорт недоступен.',
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () => _createAccountFromReport(p),
icon: const Icon(Icons.add),
label: const Text('Создать счёт из отчёта'),
),
if (p.accountSuggestions.isNotEmpty) ...[
const SizedBox(height: 12),
DropdownButtonFormField<int>(
@@ -274,11 +323,13 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
for (final s in p.accountSuggestions)
DropdownMenuItem(
value: s.id,
child: Text([
s.name,
if (s.broker != null) brokerLabel(s.broker),
if (s.sourceId != null) s.sourceId!,
].join(' · ')),
child: Text(
[
s.name,
if (s.broker != null) brokerLabel(s.broker),
if (s.sourceId != null) s.sourceId!,
].join(' · '),
),
),
],
onChanged: (v) => setState(() => _accountChoice = v),
@@ -308,14 +359,25 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
_stat('Строк', c.lines),
_stat('Событий', c.eventsTotal),
_stat('Новых', c.eventsNew, color: Colors.green),
_stat('Дубликатов', c.eventsDuplicate,
color: c.eventsDuplicate > 0 ? theme.colorScheme.secondary : null,
hint: 'Уже есть в леджере: будут обновлены, а не продублированы'),
_stat('Shadow', c.eventsShadow,
hint: 'Не первичный источник — в аналитику не идут'),
_stat('Ждут инструмента', c.eventsPending,
color: c.eventsPending > 0 ? theme.colorScheme.error : null,
hint: 'Инструмент не распознан, события останутся в статусе pending'),
_stat(
'Дубликатов',
c.eventsDuplicate,
color: c.eventsDuplicate > 0
? theme.colorScheme.secondary
: null,
hint: 'Уже есть в леджере: будут обновлены, а не продублированы',
),
_stat(
'Shadow',
c.eventsShadow,
hint: 'Не первичный источник — в аналитику не идут',
),
_stat(
'Ждут инструмента',
c.eventsPending,
color: c.eventsPending > 0 ? theme.colorScheme.error : null,
hint: 'Инструмент не распознан, события останутся в статусе pending',
),
],
),
if (c.byKind.isNotEmpty) ...[
@@ -329,7 +391,9 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
for (final e in c.byKind.entries)
Chip(
visualDensity: VisualDensity.compact,
label: Text('${eventKindLabels[e.key] ?? e.key}: ${e.value}'),
label: Text(
'${eventKindLabels[e.key] ?? e.key}: ${e.value}',
),
),
],
),
@@ -348,8 +412,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Нераспознанные инструменты (${p.pendingInstruments.length})',
style: theme.textTheme.titleMedium),
Text(
'Нераспознанные инструменты (${p.pendingInstruments.length})',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Сервер не угадывает инструмент. Пока вы не привяжете эти строки вручную, '
@@ -362,11 +428,14 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
contentPadding: EdgeInsets.zero,
dense: true,
title: Text(pi.title),
subtitle: Text([
if (pi.isin != null) 'ISIN ${pi.isin}',
'встречается ${pi.occurrences}',
if (pi.sampleQuantity != null) 'кол-во ${formatQty(pi.sampleQuantity!)}',
].join(' · ')),
subtitle: Text(
[
if (pi.isin != null) 'ISIN ${pi.isin}',
'встречается ${pi.occurrences}',
if (pi.sampleQuantity != null)
'кол-во ${formatQty(pi.sampleQuantity!)}',
].join(' · '),
),
),
Align(
alignment: Alignment.centerRight,
@@ -392,7 +461,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
children: [
Row(
children: [
Icon(Icons.warning_amber_outlined, color: theme.colorScheme.tertiary),
Icon(
Icons.warning_amber_outlined,
color: theme.colorScheme.tertiary,
),
const SizedBox(width: 8),
Text('Предупреждения', style: theme.textTheme.titleMedium),
],
@@ -434,7 +506,8 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
}
Widget _actions(ImportPreview p, int? accountId) {
final canCommit = !p.isCommitted && !p.isFailed && accountId != null && !_busy;
final canCommit =
!p.isCommitted && !p.isFailed && accountId != null && !_busy;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -444,8 +517,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
value: _confirmDuplicates,
onChanged: (v) => setState(() => _confirmDuplicates = v ?? false),
title: const Text('Обновлять дубликаты'),
subtitle: Text('Перезаписать ${p.counts.eventsDuplicate} событий, '
'которые уже есть в леджере'),
subtitle: Text(
'Перезаписать ${p.counts.eventsDuplicate} событий, '
'которые уже есть в леджере',
),
),
Wrap(
spacing: 12,
@@ -455,7 +530,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
onPressed: canCommit ? () => _commit(p) : null,
icon: _busy
? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.playlist_add_check),
label: Text(p.isCommitted ? 'Уже импортирован' : 'Импортировать'),
),
@@ -497,11 +575,11 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(color: color)),
Text(
title,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(color: color),
),
const SizedBox(height: 4),
Text(body),
],
@@ -525,8 +603,10 @@ class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodySmall),
Text('$value',
style: theme.textTheme.titleLarge?.copyWith(color: color)),
Text(
'$value',
style: theme.textTheme.titleLarge?.copyWith(color: color),
),
],
),
);