style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user