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: (cached) { final data = cached.data; return 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 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>> _groupByMonth( List entries, ) { final groups = >{}; 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 _orderedBases(Iterable bases) { const order = ['schedule', 'announced', 'history', 'paid']; final set = bases.toSet(); return [ ...order.where(set.contains), ...set.where((b) => !order.contains(b)), ]; }