import 'package:decimal/decimal.dart'; import 'package:fl_chart/fl_chart.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 '../portfolio/labels.dart' show formatPercent; import 'data/income_api.dart'; import 'labels.dart'; import 'providers.dart'; /// Прогноз: expected income for the next N months, **always split by basis**. /// /// The stacked bar and the table both carry the split rather than the total alone: a month /// built entirely out of `history` is an extrapolation, and a month built out of `announced` /// is nearly a fact. One number cannot say which. class IncomeForecastTab extends ConsumerWidget { const IncomeForecastTab({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final forecast = ref.watch(incomeForecastProvider); return RefreshIndicator( onRefresh: () async => ref.invalidate(incomeForecastProvider), child: AsyncValueView( value: forecast, onRetry: () => ref.invalidate(incomeForecastProvider), data: (cached) { final data = cached.data; final bases = data.bases; return ListView( padding: const EdgeInsets.all(16), children: [ const _HorizonChips(), const SizedBox(height: 12), Wrap( spacing: 12, runSpacing: 12, children: [ StatTile( label: 'Ожидается всего', value: MoneyText(data.totalRub, currency: 'RUB'), note: 'за ${data.months.length} мес, до налога', ), StatTile( label: 'Доходность к стоимости', // null means "нет оценки стоимости" and must not read as 0 % value: Text(formatPercent(data.annualYieldOnValue, signed: false)), note: data.annualYieldOnValue == null ? 'нет оценки текущей стоимости' : 'ожидаемый доход / стоимость портфеля', ), for (final b in bases) StatTile( label: 'Основание: ${basisLabel(b)}', value: MoneyText(_basisTotal(data, b).toString(), currency: 'RUB'), note: basisDescription(b), width: 220, ), ], ), if (data.warnings.isNotEmpty) ...[ const SizedBox(height: 16), _Warnings(warnings: data.warnings), ], const SizedBox(height: 16), if (data.months.isEmpty) const Padding( padding: EdgeInsets.only(top: 48), child: EmptyState( icon: Icons.insights_outlined, message: 'Прогнозировать нечего: ни объявленных выплат, ни истории.', ), ) else ...[ SectionCard( title: 'По месяцам', subtitle: 'цвет столбца — основание прогноза', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _BasisLegend(bases: bases), const SizedBox(height: 12), SizedBox( height: 220, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: SizedBox( width: (data.months.length * 52).toDouble().clamp(320, double.infinity), child: _ForecastChart(months: data.months, bases: bases), ), ), ), ], ), ), const SizedBox(height: 16), SectionCard( title: 'Разбивка по основанию', child: _ForecastTable(months: data.months, bases: bases), ), ], ], ); }, ), ); } } Decimal _basisTotal(IncomeForecast data, String basis) => data.months .map((m) => Decimal.tryParse(m.byBasis[basis] ?? '0') ?? Decimal.zero) .fold(Decimal.zero, (a, b) => a + b); class _HorizonChips extends ConsumerWidget { const _HorizonChips(); @override Widget build(BuildContext context, WidgetRef ref) { final months = ref.watch(forecastMonthsProvider); return Wrap( spacing: 8, children: [ for (final m in const [6, 12, 24, 36]) ChoiceChip( label: Text('$m мес'), selected: months == m, onSelected: (_) => ref.read(forecastMonthsProvider.notifier).state = m, ), ], ); } } class _Warnings extends StatelessWidget { const _Warnings({required this.warnings}); final List warnings; @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Card( color: scheme.tertiaryContainer, child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final w in warnings) Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.warning_amber_outlined, size: 16), const SizedBox(width: 8), Expanded(child: Text(w)), ], ), ), ], ), ), ); } } class _BasisLegend extends StatelessWidget { const _BasisLegend({required this.bases}); final List bases; @override Widget build(BuildContext context) { return Wrap( spacing: 16, runSpacing: 6, children: [ for (final b in bases) Tooltip( message: basisDescription(b), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 10, height: 10, decoration: BoxDecoration(color: basisColor(b), shape: BoxShape.circle), ), const SizedBox(width: 6), Text(basisLabel(b), style: Theme.of(context).textTheme.bodySmall), ], ), ), ], ); } } class _ForecastChart extends StatelessWidget { const _ForecastChart({required this.months, required this.bases}); final List months; final List bases; @override Widget build(BuildContext context) { final theme = Theme.of(context); final maxY = months.fold(0, (m, r) { final v = (Decimal.tryParse(r.amountRub) ?? Decimal.zero).toDouble(); return v > m ? v : m; }); return BarChart( BarChartData( maxY: maxY == 0 ? 1 : maxY * 1.15, gridData: const FlGridData(show: true, drawVerticalLine: false), borderData: FlBorderData(show: false), titlesData: FlTitlesData( topTitles: const AxisTitles(), rightTitles: const AxisTitles(), leftTitles: const AxisTitles( sideTitles: SideTitles(showTitles: true, reservedSize: 56), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 28, getTitlesWidget: (value, meta) { final i = value.round(); if (i < 0 || i >= months.length) return const SizedBox.shrink(); if (months.length > 14 && i.isOdd) return const SizedBox.shrink(); final m = months[i].month; return Text( m == null ? '—' : ruMonthYearShort(m), style: theme.textTheme.bodySmall, ); }, ), ), ), barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, _, rod, _) { final m = months[group.x]; final parts = [ for (final b in bases) if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) > Decimal.zero) '${basisLabel(b)}: ${MoneyText.format(m.byBasis[b]!, 'RUB')}', ]; return BarTooltipItem( '${m.month == null ? '—' : ruMonthYearShort(m.month!)}\n' '${MoneyText.format(m.amountRub, 'RUB')}' '${parts.isEmpty ? '' : '\n${parts.join('\n')}'}', theme.textTheme.bodySmall ?? const TextStyle(), ); }, ), ), barGroups: [ for (var i = 0; i < months.length; i++) BarChartGroupData(x: i, barRods: [_stackedRod(months[i])]), ], ), ); } /// One rod per month, stacked by basis: the height is the month's total, the segments are /// where that total came from. BarChartRodData _stackedRod(ForecastMonth m) { final stack = []; var from = 0.0; for (final b in bases) { final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero).toDouble(); if (v <= 0) continue; stack.add(BarChartRodStackItem(from, from + v, basisColor(b))); from += v; } final total = (Decimal.tryParse(m.amountRub) ?? Decimal.zero).toDouble(); return BarChartRodData( toY: from > 0 ? from : total, rodStackItems: stack, color: Colors.transparent, width: 16, borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), ); } } class _ForecastTable extends StatelessWidget { const _ForecastTable({required this.months, required this.bases}); final List months; final List bases; @override Widget build(BuildContext context) { final headerStyle = Theme.of(context).textTheme.labelMedium; return SingleChildScrollView( scrollDirection: Axis.horizontal, child: DataTable( columns: [ DataColumn(label: Text('Месяц', style: headerStyle)), for (final b in bases) DataColumn( label: Tooltip( message: basisDescription(b), child: Text(basisLabel(b), style: headerStyle), ), numeric: true, ), DataColumn(label: Text('Итого', style: headerStyle), numeric: true), ], rows: [ for (final m in months) DataRow( cells: [ DataCell(Text(m.month == null ? '—' : ruMonthYearShort(m.month!))), for (final b in bases) DataCell(MoneyText(m.byBasis[b] ?? '0', currency: 'RUB')), DataCell(MoneyText( m.amountRub, currency: 'RUB', style: Theme.of(context).textTheme.titleSmall, )), ], ), ], ), ); } }