Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
202 lines
7.8 KiB
Dart
202 lines
7.8 KiB
Dart
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: (p) => GoalProgressView(goal: goal, progress: p),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|