feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков

Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
This commit is contained in:
Dmitry
2026-09-19 10:44:38 +03:00
parent 15f5812ea4
commit b69bb4a0c9
52 changed files with 7404 additions and 13 deletions
+273
View File
@@ -0,0 +1,273 @@
import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/money_text.dart';
import '../../core/widgets/section_card.dart';
import '../portfolio/labels.dart' show formatQty;
import 'data/income_api.dart';
import 'labels.dart';
import 'providers.dart';
/// Календарь: every expected payment, month by month, each row carrying its [BasisChip].
///
/// The total is deliberately paired with the by-basis split: adding «объявлено» and «по
/// истории» into one number turns a guess into a promise.
class IncomeCalendarTab extends ConsumerWidget {
const IncomeCalendarTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final calendar = ref.watch(incomeCalendarProvider);
return RefreshIndicator(
onRefresh: () async => ref.invalidate(incomeCalendarProvider),
child: AsyncValueView(
value: calendar,
onRetry: () => ref.invalidate(incomeCalendarProvider),
data: (data) => ListView(
padding: const EdgeInsets.all(16),
children: [
const _CalendarControls(),
const SizedBox(height: 12),
_Totals(data: data),
const SizedBox(height: 16),
if (data.entries.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyState(
icon: Icons.event_available_outlined,
message: 'Ожидаемых выплат в этом окне нет.\n'
'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.',
),
)
else
for (final group in _groupByMonth(data.entries))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _MonthCard(month: group.key, entries: group.value),
),
],
),
),
);
}
}
class _CalendarControls extends ConsumerWidget {
const _CalendarControls();
@override
Widget build(BuildContext context, WidgetRef ref) {
final months = ref.watch(calendarMonthsProvider);
final includePaid = ref.watch(calendarIncludePaidProvider);
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final m in const [3, 6, 12, 24])
ChoiceChip(
label: Text('$m мес'),
selected: months == m,
onSelected: (_) => ref.read(calendarMonthsProvider.notifier).state = m,
),
const SizedBox(width: 8),
FilterChip(
label: const Text('Показать выплаченные'),
selected: includePaid,
onSelected: (v) => ref.read(calendarIncludePaidProvider.notifier).state = v,
),
],
);
}
}
class _Totals extends StatelessWidget {
const _Totals({required this.data});
final IncomeCalendar data;
@override
Widget build(BuildContext context) {
final byBasis = data.byBasis;
final guess = Decimal.tryParse(byBasis['history'] ?? '0') ?? Decimal.zero;
return SectionCard(
title: 'Ожидается',
subtitle: data.asOf == null ? null : 'на ${ruDate(data.asOf!)}',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MoneyText(
data.totalExpectedRub,
currency: data.currency,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
if (byBasis.isEmpty)
Text(
'Сервер не прислал разбивку по основанию — сумму нельзя трактовать как прогноз.',
style: Theme.of(context).textTheme.bodySmall,
)
else
Wrap(
spacing: 12,
runSpacing: 8,
children: [
for (final e in _orderedBases(byBasis.keys))
_BasisTotal(basis: e, amount: byBasis[e] ?? '0'),
],
),
if (guess > Decimal.zero) ...[
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, size: 16, color: basisColor('history')),
const SizedBox(width: 6),
Expanded(
child: Text(
'Из них ${MoneyText.format(byBasis['history']!, data.currency)}'
'экстраполяция по истории выплат: этих выплат может не быть.',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
],
),
);
}
}
class _BasisTotal extends StatelessWidget {
const _BasisTotal({required this.basis, required this.amount});
final String basis;
final String amount;
@override
Widget build(BuildContext context) {
return Tooltip(
message: basisDescription(basis),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
BasisChip(basis: basis),
const SizedBox(height: 4),
MoneyText(amount, currency: 'RUB', style: Theme.of(context).textTheme.titleSmall),
],
),
);
}
}
class _MonthCard extends StatelessWidget {
const _MonthCard({required this.month, required this.entries});
final DateTime month;
final List<IncomeEntry> entries;
@override
Widget build(BuildContext context) {
final total = entries
.map((e) => Decimal.tryParse(e.amountRub ?? '') ?? Decimal.zero)
.fold(Decimal.zero, (a, b) => a + b);
final unconverted = entries.where((e) => e.amountRub == null).length;
return SectionCard(
// `_groupByMonth` parks dateless entries under a sentinel year rather than dropping
// them: money with an unknown date is still money.
title: month.year == 9999 ? 'Дата неизвестна' : ruMonthYear(month),
subtitle: unconverted == 0
? null
: 'у $unconverted выплат нет курса на дату — в сумму месяца не вошли',
trailing: MoneyText(
total.toString(),
currency: 'RUB',
style: Theme.of(context).textTheme.titleMedium,
),
child: Column(children: [for (final e in entries) _EntryRow(entry: e)]),
);
}
}
class _EntryRow extends StatelessWidget {
const _EntryRow({required this.entry});
final IncomeEntry entry;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final details = [
incomeKindLabel(entry.kind),
if (entry.qty != null && entry.perUnit != null)
'${formatQty(entry.qty!)} × ${MoneyText.format(entry.perUnit!, entry.currency)}',
if (entry.recordDate != null) 'отсечка ${ruDate(entry.recordDate!)}',
if (entry.taxWithheld != null)
'налог ${MoneyText.format(entry.taxWithheld!, entry.currency)}',
].join(' · ');
return ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
onTap: entry.instrumentId == null
? null
: () => context.push('/portfolio/instrument/${entry.instrumentId}'),
title: Row(
children: [
Flexible(child: Text(entry.title, overflow: TextOverflow.ellipsis)),
const SizedBox(width: 8),
BasisChip(basis: entry.basis),
],
),
subtitle: Text(
'${entry.expectedDate == null ? 'дата неизвестна' : ruDate(entry.expectedDate!)} · $details',
style: theme.textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (entry.amount != null)
MoneyText(entry.amount!, currency: entry.currency, style: theme.textTheme.titleSmall),
// no FX rate for the date ⇒ no rouble figure. An em dash, never 0 ₽.
if (entry.currency != 'RUB')
Text(
entry.amountRub == null
? '— ₽ (нет курса)'
: MoneyText.format(entry.amountRub!, 'RUB'),
style: theme.textTheme.bodySmall,
),
],
),
);
}
}
/// Groups entries by month, preserving the server's order inside each month. Entries with
/// no date go last, under their own heading — they are still expected money.
List<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(List<IncomeEntry> entries) {
final groups = <DateTime, List<IncomeEntry>>{};
for (final e in entries) {
final d = e.expectedDate;
final key = d == null ? DateTime.utc(9999) : DateTime.utc(d.year, d.month);
groups.putIfAbsent(key, () => []).add(e);
}
final keys = groups.keys.toList()..sort();
return [for (final k in keys) MapEntry(k, groups[k]!)];
}
/// Contract order first, anything unexpected after it.
List<String> _orderedBases(Iterable<String> bases) {
const order = ['schedule', 'announced', 'history', 'paid'];
final set = bases.toSet();
return [...order.where(set.contains), ...set.where((b) => !order.contains(b))];
}