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:
Dmitry
2026-09-19 10:44:38 +03:00
parent 15f5812ea4
commit b69bb4a0c9
52 changed files with 7404 additions and 13 deletions
+201
View File
@@ -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),
],
),
],
);
}
}