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
+103 -85
View File
@@ -17,14 +17,20 @@ import '../../pending/data/pending_api.dart';
/// A candidate account for an import whose `account_id` the server could not resolve.
class AccountSuggestion {
const AccountSuggestion({required this.id, required this.name, this.broker, this.sourceId});
const AccountSuggestion({
required this.id,
required this.name,
this.broker,
this.sourceId,
});
final int id;
final String name;
final String? broker;
final String? sourceId;
static AccountSuggestion fromJson(Map<String, dynamic> json) => AccountSuggestion(
static AccountSuggestion fromJson(Map<String, dynamic> json) =>
AccountSuggestion(
id: asInt(json['id'])!,
name: asString(json['name']) ?? '#${json['id']}',
broker: asString(json['broker']),
@@ -97,15 +103,15 @@ class ReconPosition {
String get title => instrumentName ?? ticker ?? isin ?? '';
static ReconPosition fromJson(Map<String, dynamic> json) => ReconPosition(
matches: json['matches'] == true,
instrumentId: asInt(json['instrument_id']),
instrumentName: asString(json['instrument_name']),
ticker: asString(json['ticker']),
isin: asString(json['isin']),
qtyReport: asString(json['qty_report']),
qtyDerived: asString(json['qty_derived']),
qtyDelta: asString(json['qty_delta']),
);
matches: json['matches'] == true,
instrumentId: asInt(json['instrument_id']),
instrumentName: asString(json['instrument_name']),
ticker: asString(json['ticker']),
isin: asString(json['isin']),
qtyReport: asString(json['qty_report']),
qtyDerived: asString(json['qty_derived']),
qtyDelta: asString(json['qty_delta']),
);
}
class ReconCash {
@@ -124,12 +130,12 @@ class ReconCash {
final String? delta;
static ReconCash fromJson(Map<String, dynamic> json) => ReconCash(
currency: asString(json['currency']) ?? 'RUB',
matches: json['matches'] == true,
balanceReport: asString(json['balance_report']),
balanceDerived: asString(json['balance_derived']),
delta: asString(json['delta']),
);
currency: asString(json['currency']) ?? 'RUB',
matches: json['matches'] == true,
balanceReport: asString(json['balance_report']),
balanceDerived: asString(json['balance_derived']),
delta: asString(json['delta']),
);
}
class Reconciliation {
@@ -197,23 +203,23 @@ class SampleEvent {
final String? description;
static SampleEvent fromJson(Map<String, dynamic> json) => SampleEvent(
lineNo: asInt(json['line_no']) ?? 0,
kind: asString(json['kind']) ?? 'other',
isDuplicate: json['is_duplicate'] == true,
tradeDate: asDate(json['trade_date']),
settleDate: asDate(json['settle_date']),
instrumentKey: asString(json['instrument_key']),
instrumentName: asString(json['instrument_name']),
instrumentId: asInt(json['instrument_id']),
quantity: asString(json['quantity']),
price: asString(json['price']),
amount: asString(json['amount']),
currency: asString(json['currency']),
fee: asString(json['fee']),
tradeNo: asString(json['trade_no']),
dedupeKey: asString(json['dedupe_key']),
description: asString(json['description']),
);
lineNo: asInt(json['line_no']) ?? 0,
kind: asString(json['kind']) ?? 'other',
isDuplicate: json['is_duplicate'] == true,
tradeDate: asDate(json['trade_date']),
settleDate: asDate(json['settle_date']),
instrumentKey: asString(json['instrument_key']),
instrumentName: asString(json['instrument_name']),
instrumentId: asInt(json['instrument_id']),
quantity: asString(json['quantity']),
price: asString(json['price']),
amount: asString(json['amount']),
currency: asString(json['currency']),
fee: asString(json['fee']),
tradeNo: asString(json['trade_no']),
dedupeKey: asString(json['dedupe_key']),
description: asString(json['description']),
);
}
/// `ImportPreview` and `ImportSummary` in one class: the summary is the same object without
@@ -281,34 +287,38 @@ class ImportPreview {
bool get canDelete => !isCommitted;
static ImportPreview fromJson(Map<String, dynamic> json) => ImportPreview(
id: asInt(json['id'])!,
filename: asString(json['filename']) ?? 'без имени',
parseStatus: asString(json['parse_status']) ?? 'uploaded',
broker: asString(json['broker']),
sha256: asString(json['sha256']),
sizeBytes: asInt(json['size_bytes']),
parserName: asString(json['parser_name']),
parserVersion: asString(json['parser_version']),
error: asString(json['error']),
duplicateOfId: asInt(json['duplicate_of_id']),
accountExternalId: asString(json['account_external_id']),
accountId: asInt(json['account_id']),
accountName: asString(json['account_name']),
accountSuggestions:
asList(json['account_suggestions']).map(AccountSuggestion.fromJson).toList(),
periodFrom: asDate(json['period_from']),
periodTo: asDate(json['period_to']),
uploadedAt: asDate(json['uploaded_at']),
committedAt: asDate(json['committed_at']),
counts: ImportCounts.fromJson(asMap(json['counts'])),
pendingInstruments:
asList(json['pending_instruments']).map(PendingInstrument.fromJson).toList(),
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
warnings: json['warnings'] is List
? (json['warnings'] as List).map((e) => e.toString()).toList()
: const [],
sampleEvents: asList(json['sample_events']).map(SampleEvent.fromJson).toList(),
);
id: asInt(json['id'])!,
filename: asString(json['filename']) ?? 'без имени',
parseStatus: asString(json['parse_status']) ?? 'uploaded',
broker: asString(json['broker']),
sha256: asString(json['sha256']),
sizeBytes: asInt(json['size_bytes']),
parserName: asString(json['parser_name']),
parserVersion: asString(json['parser_version']),
error: asString(json['error']),
duplicateOfId: asInt(json['duplicate_of_id']),
accountExternalId: asString(json['account_external_id']),
accountId: asInt(json['account_id']),
accountName: asString(json['account_name']),
accountSuggestions: asList(json['account_suggestions'])
.map(AccountSuggestion.fromJson)
.toList(),
periodFrom: asDate(json['period_from']),
periodTo: asDate(json['period_to']),
uploadedAt: asDate(json['uploaded_at']),
committedAt: asDate(json['committed_at']),
counts: ImportCounts.fromJson(asMap(json['counts'])),
pendingInstruments: asList(json['pending_instruments'])
.map(PendingInstrument.fromJson)
.toList(),
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
warnings: json['warnings'] is List
? (json['warnings'] as List).map((e) => e.toString()).toList()
: const [],
sampleEvents: asList(json['sample_events'])
.map(SampleEvent.fromJson)
.toList(),
);
}
/// What `POST /imports/{id}/commit` reports back.
@@ -336,16 +346,16 @@ class ImportResult {
final bool metricsRefreshed;
static ImportResult fromJson(Map<String, dynamic> json) => ImportResult(
importId: asInt(json['import_id']) ?? 0,
committed: json['committed'] == true,
eventsCreated: asInt(json['events_created']) ?? 0,
eventsUpdated: asInt(json['events_updated']) ?? 0,
eventsSkipped: asInt(json['events_skipped']) ?? 0,
eventsShadow: asInt(json['events_shadow']) ?? 0,
pendingInstruments: asInt(json['pending_instruments']) ?? 0,
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
metricsRefreshed: json['metrics_refreshed'] == true,
);
importId: asInt(json['import_id']) ?? 0,
committed: json['committed'] == true,
eventsCreated: asInt(json['events_created']) ?? 0,
eventsUpdated: asInt(json['events_updated']) ?? 0,
eventsSkipped: asInt(json['events_skipped']) ?? 0,
eventsShadow: asInt(json['events_shadow']) ?? 0,
pendingInstruments: asInt(json['pending_instruments']) ?? 0,
reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])),
metricsRefreshed: json['metrics_refreshed'] == true,
);
}
/// A report chosen by the user. On web `file_picker` can only hand over [bytes]; on desktop
@@ -366,14 +376,14 @@ class ImportsApi {
static const _base = '/api/v1/imports';
Future<List<ImportPreview>> list({int limit = 50, int offset = 0, String? status}) async {
Future<List<ImportPreview>> list({
int limit = 50,
int offset = 0,
String? status,
}) async {
final r = await _dio.get<List<dynamic>>(
_base,
queryParameters: {
'limit': limit,
'offset': offset,
'status': ?status,
},
queryParameters: {'limit': limit, 'offset': offset, 'status': ?status},
);
return (r.data ?? const [])
.map((e) => ImportPreview.fromJson(Map<String, dynamic>.from(e as Map)))
@@ -385,7 +395,11 @@ class ImportsApi {
return ImportPreview.fromJson(r.data!);
}
Future<ImportPreview> upload(PickedReport report, {int? accountId, String? parser}) async {
Future<ImportPreview> upload(
PickedReport report, {
int? accountId,
String? parser,
}) async {
final bytes = report.bytes;
final form = FormData.fromMap({
'file': bytes != null
@@ -404,11 +418,14 @@ class ImportsApi {
bool confirmDuplicates = false,
bool dryRun = false,
}) async {
final r = await _dio.post<Map<String, dynamic>>('$_base/$id/commit', data: {
'account_id': ?accountId,
'confirm_duplicates': confirmDuplicates,
'dry_run': dryRun,
});
final r = await _dio.post<Map<String, dynamic>>(
'$_base/$id/commit',
data: {
'account_id': ?accountId,
'confirm_duplicates': confirmDuplicates,
'dry_run': dryRun,
},
);
return ImportResult.fromJson(r.data ?? const {});
}
@@ -438,4 +455,5 @@ List<Map<String, dynamic>> asList(Object? v) => v is List
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
: const [];
Map<String, dynamic>? asMap(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null;
Map<String, dynamic>? asMap(Object? v) =>
v is Map ? Map<String, dynamic>.from(v) : null;
@@ -34,4 +34,6 @@ class FilePickerReportPicker implements ReportPicker {
}
}
final reportPickerProvider = Provider<ReportPicker>((ref) => const FilePickerReportPicker());
final reportPickerProvider = Provider<ReportPicker>(
(ref) => const FilePickerReportPicker(),
);
+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),
),
],
),
);
+33 -12
View File
@@ -35,7 +35,9 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
if (!mounted) return;
ref.invalidate(importsListProvider);
if (preview.duplicateOfId != null) {
_snack('Этот файл уже загружали — открыт существующий импорт №${preview.id}');
_snack(
'Этот файл уже загружали — открыт существующий импорт №${preview.id}',
);
}
context.go('/imports/${preview.id}');
} catch (e) {
@@ -47,7 +49,8 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
}
void _snack(String message) =>
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
@override
Widget build(BuildContext context) {
@@ -72,7 +75,10 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
onPressed: _uploading ? null : _upload,
icon: _uploading
? const SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.upload_file),
label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'),
),
@@ -91,7 +97,8 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
leading: const Icon(Icons.help_outline),
title: Text('Нераспознанных инструментов: $pendingCount'),
subtitle: const Text(
'События по ним ждут в статусе pending и не попадают в аналитику.'),
'События по ним ждут в статусе pending и не попадают в аналитику.',
),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go('/instruments/pending'),
),
@@ -106,11 +113,16 @@ class _ImportsPageState extends ConsumerState<ImportsPage> {
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.upload_file_outlined,
message: 'Отчёты ещё не загружались.\n'
message:
'Отчёты ещё не загружались.\n'
'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.',
),
)
: Column(children: [for (final row in rows) _ImportCard(item: row)]),
: Column(
children: [
for (final row in rows) _ImportCard(item: row),
],
),
),
],
),
@@ -164,8 +176,12 @@ class _ImportCard extends StatelessWidget {
: 'период не определён';
final subtitle = [
period,
item.accountName ?? (item.accountId != null ? 'счёт #${item.accountId}' : 'счёт не найден'),
if (item.uploadedAt != null) 'загружен ${ruDate(item.uploadedAt!.toLocal())}',
item.accountName ??
(item.accountId != null
? 'счёт #${item.accountId}'
: 'счёт не найден'),
if (item.uploadedAt != null)
'загружен ${ruDate(item.uploadedAt!.toLocal())}',
if (item.sizeBytes != null) formatBytes(item.sizeBytes),
].join(' · ');
@@ -174,7 +190,9 @@ class _ImportCard extends StatelessWidget {
onTap: () => context.go('/imports/${item.id}'),
title: Row(
children: [
Flexible(child: Text(item.filename, overflow: TextOverflow.ellipsis)),
Flexible(
child: Text(item.filename, overflow: TextOverflow.ellipsis),
),
const SizedBox(width: 8),
parseStatusChip(context, item.parseStatus),
],
@@ -198,9 +216,12 @@ class _ImportCard extends StatelessWidget {
if (item.isFailed && item.error != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(item.error!,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error)),
child: Text(
item.error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
+10 -6
View File
@@ -6,13 +6,17 @@ import '../home/providers.dart';
import '../portfolio/providers.dart';
import 'data/imports_api.dart';
final importsApiProvider = Provider<ImportsApi>((ref) => ImportsApi(ref.watch(apiProvider).dio));
final importsApiProvider = Provider<ImportsApi>(
(ref) => ImportsApi(ref.watch(apiProvider).dio),
);
/// The list on `/imports`. Not auto-disposed by status: the filter is a separate provider so
/// changing it refetches without rebuilding the page state.
final importsStatusFilterProvider = StateProvider<String?>((ref) => null);
final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((ref) async {
final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((
ref,
) async {
final status = ref.watch(importsStatusFilterProvider);
return ref.watch(importsApiProvider).list(status: status);
});
@@ -20,10 +24,10 @@ final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((ref
/// A single import's preview. For an uncommitted import the server recomputes counts and
/// reconciliation on every read, so this is deliberately re-fetched rather than cached from
/// the list response.
final importPreviewProvider =
FutureProvider.autoDispose.family<ImportPreview, int>((ref, id) async {
return ref.watch(importsApiProvider).get(id);
});
final importPreviewProvider = FutureProvider.autoDispose
.family<ImportPreview, int>((ref, id) async {
return ref.watch(importsApiProvider).get(id);
});
/// Everything whose numbers change once events land in (or move inside) the ledger:
/// portfolio, holdings, allocation, the value series, the dashboard and the event list.
@@ -23,7 +23,10 @@ class ReconciliationCard extends StatelessWidget {
if (reconciliation.isEmpty) {
return Card(
child: ListTile(
leading: Icon(Icons.remove_circle_outline, color: theme.colorScheme.outline),
leading: Icon(
Icons.remove_circle_outline,
color: theme.colorScheme.outline,
),
title: Text(title),
subtitle: const Text('В отчёте нет остатков для сверки'),
),
@@ -67,7 +70,12 @@ class ReconciliationCard extends StatelessWidget {
Text('Позиции', style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
_ScrollableTable(
columns: const ['Позиция', 'Из отчёта', 'Из леджера', 'Расхождение'],
columns: const [
'Позиция',
'Из отчёта',
'Из леджера',
'Расхождение',
],
rows: [
for (final p in reconciliation.positions)
_Row(
@@ -87,7 +95,12 @@ class ReconciliationCard extends StatelessWidget {
Text('Денежные остатки', style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
_ScrollableTable(
columns: const ['Валюта', 'Из отчёта', 'Из леджера', 'Расхождение'],
columns: const [
'Валюта',
'Из отчёта',
'Из леджера',
'Расхождение',
],
rows: [
for (final c in reconciliation.cash)
_Row(
@@ -145,16 +158,20 @@ class _ScrollableTable extends StatelessWidget {
for (final r in rows)
DataRow(
color: r.highlight
? WidgetStatePropertyAll(theme.colorScheme.errorContainer.withValues(alpha: 0.4))
? WidgetStatePropertyAll(
theme.colorScheme.errorContainer.withValues(alpha: 0.4),
)
: null,
cells: [
for (final cell in r.cells)
DataCell(Text(
cell,
style: r.highlight
? TextStyle(color: theme.colorScheme.error)
: null,
)),
DataCell(
Text(
cell,
style: r.highlight
? TextStyle(color: theme.colorScheme.error)
: null,
),
),
],
),
],
@@ -37,11 +37,16 @@ class SampleEventsTable extends StatelessWidget {
DataRow(
color: e.isDuplicate
? WidgetStatePropertyAll(
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6))
theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.6,
),
)
: null,
cells: [
DataCell(Text('${e.lineNo}')),
DataCell(Text(e.tradeDate == null ? '' : ruDate(e.tradeDate!))),
DataCell(
Text(e.tradeDate == null ? '' : ruDate(e.tradeDate!)),
),
DataCell(Text(eventKindLabels[e.kind] ?? e.kind)),
DataCell(
Tooltip(
@@ -49,20 +54,26 @@ class SampleEventsTable extends StatelessWidget {
child: Text(e.instrumentName ?? e.instrumentKey ?? ''),
),
),
DataCell(Text(e.quantity == null ? '' : formatQty(e.quantity!))),
DataCell(
Text(e.quantity == null ? '' : formatQty(e.quantity!)),
),
DataCell(Text(_money(e.price, e.currency))),
DataCell(Text(_money(e.amount, e.currency))),
DataCell(e.isDuplicate
? Tooltip(
message: 'Такое событие уже есть в леджере',
child: Chip(
visualDensity: VisualDensity.compact,
label: const Text('дубль'),
backgroundColor:
theme.colorScheme.secondaryContainer.withValues(alpha: 0.8),
),
)
: const SizedBox.shrink()),
DataCell(
e.isDuplicate
? Tooltip(
message: 'Такое событие уже есть в леджере',
child: Chip(
visualDensity: VisualDensity.compact,
label: const Text('дубль'),
backgroundColor: theme
.colorScheme
.secondaryContainer
.withValues(alpha: 0.8),
),
)
: const SizedBox.shrink(),
),
],
),
],
@@ -71,5 +82,7 @@ class SampleEventsTable extends StatelessWidget {
}
static String _money(String? value, String? currency) =>
value == null || value.isEmpty ? '' : MoneyText.format(value, currency ?? 'RUB');
value == null || value.isEmpty
? ''
: MoneyText.format(value, currency ?? 'RUB');
}