import 'package:decimal/decimal.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/utils/compact_number.dart'; import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/money_text.dart'; import '../../core/widgets/section_card.dart'; import 'data/income_api.dart'; import 'entry_row.dart'; import 'labels.dart'; import 'providers.dart'; const _weekdays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; /// Kinds in the order their glyphs are laid out inside a day cell. const _kindOrder = ['dividend', 'coupon', 'amortization', 'repayment']; /// When several payments of one kind share a day, the cell shows the least certain basis: /// a guess must not be hidden behind a fact that happens to fall on the same day. const _basisOrder = ['history', 'schedule', 'announced', 'paid']; /// The month grid: every dividend, coupon, amortisation and redemption on the day it falls, /// paid ones beside the expected ones. A day opens its payments beside or below the grid. /// /// The screen itself does not scroll: the grid takes the height it is given, and only the /// list of payments — the one part that can be any length — scrolls inside its own panel. class IncomeMonthView extends ConsumerStatefulWidget { const IncomeMonthView({super.key}); @override ConsumerState createState() => _IncomeMonthViewState(); } class _IncomeMonthViewState extends ConsumerState { int? _selectedDay; void _setMonth(DateTime month) { ref.read(calendarMonthProvider.notifier).state = month; setState(() => _selectedDay = null); } @override Widget build(BuildContext context) { final month = ref.watch(calendarMonthProvider); final data = ref.watch(incomeMonthProvider(month)); final now = DateTime.now(); final isCurrent = month.year == now.year && month.month == now.month; const dense = VisualDensity.compact; return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ IconButton( visualDensity: dense, tooltip: 'Предыдущий месяц', icon: const Icon(Icons.chevron_left), onPressed: () => _setMonth(DateTime(month.year, month.month - 1)), ), Expanded( child: Text( ruMonthYear(month), textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium, ), ), IconButton( visualDensity: dense, tooltip: 'Следующий месяц', icon: const Icon(Icons.chevron_right), onPressed: () => _setMonth(DateTime(month.year, month.month + 1)), ), TextButton( style: TextButton.styleFrom(visualDensity: dense), onPressed: isCurrent ? null : () => _setMonth(DateTime(now.year, now.month)), child: const Text('Сегодня'), ), ], ), Expanded( child: AsyncValueView( value: data, onRetry: () => ref.invalidate(incomeMonthProvider(month)), data: (cached) => _MonthBody( month: month, entries: cached.data.entries, selectedDay: _selectedDay, onSelect: (day) => setState( () => _selectedDay = day == _selectedDay ? null : day, ), onRefresh: () async => ref.invalidate(incomeMonthProvider(month)), ), ), ), ], ), ); } } /// Width from which the payments sit beside the grid instead of under it. const _wideFrom = 840.0; /// Room the parts around the grid take, for sizing its rows from the height that is left. const _weekdayRow = 24.0; const _legendRow = 32.0; /// On a narrow screen the legend wraps onto several lines. const _legendRowNarrow = 64.0; const _detailsMin = 150.0; class _MonthBody extends StatelessWidget { const _MonthBody({ required this.month, required this.entries, required this.selectedDay, required this.onSelect, required this.onRefresh, }); final DateTime month; final List entries; final int? selectedDay; final ValueChanged onSelect; final Future Function() onRefresh; @override Widget build(BuildContext context) { final inMonth = [ for (final e in entries) if (e.expectedDate != null && e.expectedDate!.year == month.year && e.expectedDate!.month == month.month) e, ]; final byDay = >{}; for (final e in inMonth) { byDay.putIfAbsent(e.expectedDate!.day, () => []).add(e); } final shown = selectedDay == null ? inMonth : byDay[selectedDay] ?? const []; final lead = DateTime(month.year, month.month).weekday - 1; final days = DateTime(month.year, month.month + 1, 0).day; final weeks = ((lead + days) / 7).ceil(); Widget grid(double cellHeight) => _Grid( month: month, byDay: byDay, selectedDay: selectedDay, onSelect: onSelect, lead: lead, days: days, weeks: weeks, cellHeight: cellHeight, ); final legend = _Legend(entries: inMonth); final details = _Details( title: selectedDay == null ? 'Выплаты за месяц' : ruDate(DateTime(month.year, month.month, selectedDay!)), entries: shown, onRefresh: onRefresh, ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _Summary(entries: inMonth), const SizedBox(height: 8), Expanded( child: LayoutBuilder( builder: (context, c) { if (c.maxWidth >= _wideFrom) { // grid on the left fills the height; payments on the right final cell = ((c.maxHeight - _weekdayRow - _legendRow) / weeks) .clamp(48.0, 104.0); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 3, child: Column( children: [ grid(cell), const SizedBox(height: 8), legend, ], ), ), const SizedBox(width: 16), Expanded(flex: 2, child: details), ], ); } // narrow: rows shrink to leave the payments room underneath final cell = ((c.maxHeight - _weekdayRow - _legendRowNarrow - _detailsMin) / weeks) .clamp(40.0, 60.0); return Column( children: [ grid(cell), const SizedBox(height: 8), legend, const SizedBox(height: 8), Expanded(child: details), ], ); }, ), ), ], ); } } /// The payments of the chosen day (or of the month), scrolling inside their own panel. class _Details extends StatelessWidget { const _Details({ required this.title, required this.entries, required this.onRefresh, }); final String title; final List entries; final Future Function() onRefresh; @override Widget build(BuildContext context) { final theme = Theme.of(context); return RefreshIndicator( onRefresh: onRefresh, child: ListView( padding: EdgeInsets.zero, children: [ SectionCard( title: title, child: entries.isEmpty ? Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( 'Выплат нет.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.hintColor, ), ), ) : Column( children: [ for (final e in entries) IncomeEntryRow(entry: e), ], ), ), ], ), ); } } class _Sum { const _Sum(this.total, this.unconverted); final Decimal total; /// Payments with no rouble figure (no FX rate for the day): left out of [total], not zeroed. final int unconverted; } _Sum _sum(Iterable entries) { var total = Decimal.zero; var unconverted = 0; for (final e in entries) { final v = Decimal.tryParse(e.amountRub ?? ''); if (v == null) { unconverted++; } else { total += v; } } return _Sum(total, unconverted); } /// One line: what came in, what is still expected. What qualifies the numbers — a share that /// is only an extrapolation, payments with no rouble figure — sits behind the info icon. class _Summary extends StatelessWidget { const _Summary({required this.entries}); final List entries; @override Widget build(BuildContext context) { final theme = Theme.of(context); final paid = _sum(entries.where((e) => e.basis == 'paid')); final expected = _sum(entries.where((e) => e.basis != 'paid')); final guess = _sum(entries.where((e) => e.basis == 'history')).total; final unconverted = paid.unconverted + expected.unconverted; Widget stat(String label, Color color, Decimal value) => Row( mainAxisSize: MainAxisSize.min, children: [ Text( '$label ', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), ), MoneyText( value.toString(), currency: 'RUB', style: theme.textTheme.titleSmall?.copyWith(color: color), ), ], ); final notes = [ if (guess > Decimal.zero) 'Из ожидаемого ${MoneyText.format(guess.toString(), 'RUB')} — ' 'экстраполяция по истории: этих выплат может не быть.', if (unconverted > 0) 'У $unconverted выплат нет курса на дату — в суммы они не вошли.', ]; return Wrap( spacing: 20, runSpacing: 2, crossAxisAlignment: WrapCrossAlignment.center, children: [ stat('Получено', basisColor('paid'), paid.total), stat('Ожидается', basisColor('schedule'), expected.total), if (notes.isNotEmpty) Tooltip( message: notes.join('\n'), triggerMode: TooltipTriggerMode.tap, showDuration: const Duration(seconds: 6), child: Icon( Icons.info_outline, size: 18, color: basisColor('history'), ), ), ], ); } } class _Grid extends StatelessWidget { const _Grid({ required this.month, required this.byDay, required this.selectedDay, required this.onSelect, required this.lead, required this.days, required this.weeks, required this.cellHeight, }); final DateTime month; final Map> byDay; final int? selectedDay; final ValueChanged onSelect; final int lead; final int days; final int weeks; final double cellHeight; @override Widget build(BuildContext context) { final theme = Theme.of(context); final now = DateTime.now(); return Column( children: [ SizedBox( height: _weekdayRow, child: Row( children: [ for (final w in _weekdays) Expanded( child: Center( child: Text( w, style: theme.textTheme.labelMedium?.copyWith( color: theme.hintColor, ), ), ), ), ], ), ), for (var w = 0; w < weeks; w++) Row( children: [ for (var c = 0; c < 7; c++) Expanded( child: Builder( builder: (_) { final day = w * 7 + c - lead + 1; if (day < 1 || day > days) { return SizedBox(height: cellHeight); } return _DayCell( day: day, height: cellHeight, entries: byDay[day] ?? const [], isToday: month.year == now.year && month.month == now.month && day == now.day, selected: day == selectedDay, onTap: () => onSelect(day), ); }, ), ), ], ), ], ); } } class _DayCell extends StatelessWidget { const _DayCell({ required this.day, required this.height, required this.entries, required this.isToday, required this.selected, required this.onTap, }); final int day; final double height; final List entries; final bool isToday; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final primary = theme.colorScheme.primary; final kinds = [ for (final k in _kindOrder) if (entries.any((e) => e.kind == k)) k, // a kind the server invents later still gets a glyph rather than vanishing for (final k in {for (final e in entries) e.kind}) if (!_kindOrder.contains(k)) k, ]; final sum = _sum(entries); final hasAmount = entries.isNotEmpty && sum.unconverted < entries.length; return LayoutBuilder( builder: (context, c) { final narrow = c.maxWidth < 72; // a wide but short cell keeps the day and the glyphs on one line final oneLine = !narrow && height < 64; final showAmount = !narrow && height >= 64 && hasAmount; final iconSize = narrow || height < 64 ? 12.0 : 15.0; final dayText = Text( '$day', style: theme.textTheme.bodySmall?.copyWith( fontWeight: isToday ? FontWeight.w700 : null, color: isToday ? primary : null, ), ); final glyphs = Wrap( spacing: 2, runSpacing: 2, alignment: oneLine ? WrapAlignment.end : WrapAlignment.start, children: [ for (final k in kinds) Icon( incomeKindIcon(k), size: iconSize, color: basisColor( _leastCertain(entries.where((e) => e.kind == k)), ), ), ], ); return Padding( padding: const EdgeInsets.all(1.5), child: Material( color: selected ? primary.withValues(alpha: 0.16) : entries.isEmpty ? Colors.transparent : theme.colorScheme.surfaceContainerHighest.withValues( alpha: 0.5, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), side: isToday ? BorderSide(color: primary) : selected ? BorderSide(color: primary.withValues(alpha: 0.5)) : BorderSide( color: theme.dividerColor.withValues(alpha: 0.4), ), ), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: onTap, child: SizedBox( height: height - 3, child: Padding( padding: EdgeInsets.all(narrow ? 4 : 6), child: oneLine ? Row( crossAxisAlignment: CrossAxisAlignment.start, children: [dayText, const Spacer(), glyphs], ) : Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ dayText, glyphs, if (showAmount) Text( '${sum.unconverted > 0 ? '≥ ' : ''}${compactNumber(sum.total.toDouble())}', maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall, ), ], ), ), ), ), ), ); }, ); } } String _leastCertain(Iterable entries) { final bases = entries.map((e) => e.basis).toSet(); for (final b in _basisOrder) { if (bases.contains(b)) return b; } return bases.first; } /// What the glyphs and colours mean, and only for what is on this month's grid. class _Legend extends StatelessWidget { const _Legend({required this.entries}); final List entries; @override Widget build(BuildContext context) { final theme = Theme.of(context); final bases = {for (final e in entries) e.basis}; return Wrap( spacing: 16, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final k in _kindOrder) Row( mainAxisSize: MainAxisSize.min, children: [ Icon(incomeKindIcon(k), size: 15, color: theme.hintColor), const SizedBox(width: 4), Text(incomeKindLabel(k), style: theme.textTheme.bodySmall), ], ), for (final b in _basisOrder.reversed) if (bases.contains(b)) BasisChip(basis: b), ], ); } }