feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user