import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 'calendar_grid.dart'; import 'data/income_api.dart'; import 'entry_row.dart'; import 'labels.dart'; import 'providers.dart'; /// Календарь: the payments as a month grid (default) or as a list running forward. class IncomeCalendarTab extends ConsumerWidget { const IncomeCalendarTab({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final view = ref.watch(calendarViewProvider); return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: SegmentedButton( showSelectedIcon: false, style: const ButtonStyle(visualDensity: VisualDensity.compact), segments: const [ ButtonSegment( value: CalendarView.month, icon: Icon(Icons.calendar_month_outlined), label: Text('Календарь'), ), ButtonSegment( value: CalendarView.list, icon: Icon(Icons.view_list_outlined), label: Text('Список'), ), ], selected: {view}, onSelectionChanged: (s) => ref.read(calendarViewProvider.notifier).state = s.first, ), ), Expanded( child: view == CalendarView.month ? const IncomeMonthView() : const _IncomeList(), ), ], ); } } /// 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 _IncomeList extends ConsumerWidget { const _IncomeList(); @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) IncomeEntryRow(entry: e)], ), ); } } /// 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)), ]; }