Files
Dmitry 322c60a359 style(app): остальные экраны под новый визуальный язык и форматирование
Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
2026-09-19 22:14:27 +03:00

182 lines
6.0 KiB
Dart

import 'package:flutter/material.dart';
import '../../../core/utils/ru_date.dart';
import '../../../core/widgets/money_text.dart';
import '../../portfolio/labels.dart' show formatQty;
import '../data/imports_api.dart';
/// Сверка: the report's own positions and cash balances against what the ledger derives.
///
/// When everything matches this collapses to a single green line — a full table of zeroes
/// is noise. A mismatch is the whole point of the screen, so it stays expanded and red.
class ReconciliationCard extends StatelessWidget {
const ReconciliationCard({required this.reconciliation, super.key});
final Reconciliation reconciliation;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final asOf = reconciliation.asOf;
final title = asOf == null ? 'Сверка' : 'Сверка на ${ruDate(asOf)}';
if (reconciliation.isEmpty) {
return Card(
child: ListTile(
leading: Icon(
Icons.remove_circle_outline,
color: theme.colorScheme.outline,
),
title: Text(title),
subtitle: const Text('В отчёте нет остатков для сверки'),
),
);
}
if (reconciliation.matches) {
return Card(
child: ListTile(
leading: const Icon(Icons.check_circle_outline, color: Colors.green),
title: Text(title),
subtitle: Text(
'Всё сошлось: позиций ${reconciliation.positions.length}, '
'остатков ${reconciliation.cash.length}',
style: const TextStyle(color: Colors.green),
),
),
);
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.error_outline, color: theme.colorScheme.error),
const SizedBox(width: 8),
Text(title, style: theme.textTheme.titleMedium),
],
),
const SizedBox(height: 4),
Text(
'Отчёт и леджер разошлись. Импортировать можно, но расхождение стоит объяснить.',
style: theme.textTheme.bodySmall,
),
if (reconciliation.positions.isNotEmpty) ...[
const SizedBox(height: 12),
Text('Позиции', style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
_ScrollableTable(
columns: const [
'Позиция',
'Из отчёта',
'Из леджера',
'Расхождение',
],
rows: [
for (final p in reconciliation.positions)
_Row(
cells: [
p.title,
_qty(p.qtyReport),
_qty(p.qtyDerived),
_qty(p.qtyDelta),
],
highlight: !p.matches,
),
],
),
],
if (reconciliation.cash.isNotEmpty) ...[
const SizedBox(height: 12),
Text('Денежные остатки', style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
_ScrollableTable(
columns: const [
'Валюта',
'Из отчёта',
'Из леджера',
'Расхождение',
],
rows: [
for (final c in reconciliation.cash)
_Row(
cells: [
c.currency,
_money(c.balanceReport, c.currency),
_money(c.balanceDerived, c.currency),
_money(c.delta, c.currency),
],
highlight: !c.matches,
),
],
),
],
],
),
),
);
}
/// A quantity the ledger could not produce is an em dash, not a zero: an unknown position
/// and an empty one are different findings.
static String _qty(String? value) =>
value == null || value.isEmpty ? '—' : formatQty(value);
static String _money(String? value, String currency) =>
value == null || value.isEmpty ? '—' : MoneyText.format(value, currency);
}
class _Row {
const _Row({required this.cells, required this.highlight});
final List<String> cells;
final bool highlight;
}
/// A narrow table that scrolls sideways rather than overflowing on a phone.
class _ScrollableTable extends StatelessWidget {
const _ScrollableTable({required this.columns, required this.rows});
final List<String> columns;
final List<_Row> rows;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
columnSpacing: 24,
headingRowHeight: 36,
dataRowMinHeight: 36,
dataRowMaxHeight: 48,
columns: [for (final c in columns) DataColumn(label: Text(c))],
rows: [
for (final r in rows)
DataRow(
color: r.highlight
? 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,
),
),
],
),
],
),
);
}
}