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/money_text.dart'; import '../portfolio/labels.dart' show formatPercent; import 'data/goals_api.dart'; import 'providers.dart'; /// What the projection is built on. `none` is not an error — it means there is no trend to /// extrapolate yet, which is different from "the goal is not reachable". const goalBasisLabels = { 'xirr': 'по доходности', 'contribution': 'по взносам', 'none': 'нет данных', }; const goalBasisDescriptions = { 'xirr': 'Прогноз построен на фактической доходности портфеля (XIRR).', 'contribution': 'Прогноз построен на регулярных взносах, без учёта доходности.', 'none': 'Данных для прогноза нет: ни доходности, ни истории взносов.', }; String goalBasisLabel(String basis) => goalBasisLabels[basis] ?? basis; /// One goal with its server-computed progress. /// /// The load-bearing line is the projected date: `projected_date == null` means **the goal /// is not reached at the current trend**, and it is written out in those words. Leaving the /// spot blank, or filling it with a far-off date, would both read as an answer. class GoalCard extends ConsumerWidget { const GoalCard({required this.goal, super.key, this.onEdit, this.onDelete}); final Goal goal; final VoidCallback? onEdit; final VoidCallback? onDelete; @override Widget build(BuildContext context, WidgetRef ref) { final progress = ref.watch(goalProgressProvider(goal.id)); final theme = Theme.of(context); return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(goal.name, style: theme.textTheme.titleMedium), Text( [ 'цель ${MoneyText.format(goal.targetAmount, goal.currency)}', if (goal.targetDate != null) 'к ${ruDate(goal.targetDate!)}', if (goal.monthlyContribution != null) 'взнос ${MoneyText.format(goal.monthlyContribution!, goal.currency)}/мес', if (goal.archived) 'в архиве', ].join(' · '), style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), ), ], ), ), if (onEdit != null) IconButton( tooltip: 'Изменить', icon: const Icon(Icons.edit_outlined), onPressed: onEdit, ), if (onDelete != null) IconButton( tooltip: 'Удалить', icon: const Icon(Icons.delete_outline), onPressed: onDelete, ), ], ), const SizedBox(height: 12), AsyncValueView( value: progress, onRetry: () => ref.invalidate(goalProgressProvider(goal.id)), data: (cached) => GoalProgressView(goal: goal, progress: cached.data), ), ], ), ), ); } } /// The progress body, split out of [GoalCard] so it can be rendered (and tested) without a /// provider container. class GoalProgressView extends StatelessWidget { const GoalProgressView({required this.goal, required this.progress, super.key}); final Goal goal; final GoalProgress progress; @override Widget build(BuildContext context) { final theme = Theme.of(context); final share = Decimal.tryParse(progress.progress ?? '') ?? Decimal.zero; final clamped = share.toDouble().clamp(0.0, 1.0); final unreachable = progress.projectedDate == null; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator(value: clamped, minHeight: 8), ), const SizedBox(height: 6), Row( children: [ Text( formatPercent(progress.progress, signed: false), style: theme.textTheme.titleSmall, ), const SizedBox(width: 8), Expanded( child: Text( '${progress.currentValueRub == null ? '—' : MoneyText.format(progress.currentValueRub!, 'RUB')}' ' из ' '${progress.targetAmountRub == null ? '—' : MoneyText.format(progress.targetAmountRub!, 'RUB')}', style: theme.textTheme.bodySmall, ), ), Tooltip( message: goalBasisDescriptions[progress.basis] ?? progress.basis, child: Chip( label: Text('Основание: ${goalBasisLabel(progress.basis)}'), visualDensity: VisualDensity.compact, ), ), ], ), const SizedBox(height: 10), // The whole point of the screen. Null is a statement, not an absence. Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( unreachable ? Icons.trending_flat : Icons.flag_outlined, size: 18, color: unreachable ? theme.colorScheme.error : theme.colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Text( unreachable ? (progress.basis == 'none' ? 'Прогноз недоступен: данных для оценки тренда пока нет' : 'При текущем тренде цель не достигается') : 'Прогнозная дата достижения: ${ruDate(progress.projectedDate!)}', style: theme.textTheme.bodyMedium?.copyWith( color: unreachable ? theme.colorScheme.error : null, fontWeight: FontWeight.w500, ), ), ), ], ), const SizedBox(height: 6), Wrap( spacing: 16, runSpacing: 4, children: [ Text( progress.monthlyNeededRub == null ? 'Нужный ежемесячный взнос: — (нет целевой даты)' : 'Нужно докладывать: ' '${MoneyText.format(progress.monthlyNeededRub!, 'RUB')}/мес', style: theme.textTheme.bodySmall, ), if (progress.assumedRate != null) Text( 'Заложенная доходность: ${formatPercent(progress.assumedRate, signed: false)}', style: theme.textTheme.bodySmall, ), Text( progress.onTrack ? 'Идёт по плану' : 'Отстаёт от плана', style: theme.textTheme.bodySmall?.copyWith( color: progress.onTrack ? null : theme.colorScheme.error, ), ), if (progress.asOf != null) Text('на ${ruDate(progress.asOf!)}', style: theme.textTheme.bodySmall), ], ), ], ); } }