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
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/utils/ru_date.dart';
import '../../core/widgets/async_value_view.dart';
import '../../core/widgets/empty_state.dart';
import 'data/benchmarks_api.dart';
import 'labels.dart';
import 'providers.dart';
final benchmarksApiProvider =
Provider<BenchmarksApi>((ref) => BenchmarksApi(ref.watch(apiProvider).dio));
/// Benchmark comparison for the current scope. Part of Портфель, not a screen of its own:
/// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject.
final benchmarkRowsProvider = FutureProvider.autoDispose<List<BenchmarkRow>>((ref) async {
final scope = ref.watch(scopeProvider);
return ref.watch(benchmarksApiProvider).compare(scope: scope);
});
/// The comparison block on Портфель.
///
/// Two things are always marked: a **price** index (no dividends — it understates the
/// holder's result by construction) and any non-zero `days_skipped` on either side (the two
/// returns then do not cover the same days, so the difference is not strictly like-for-like).
class BenchmarksCard extends ConsumerWidget {
const BenchmarksCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final rows = ref.watch(benchmarkRowsProvider);
return AsyncValueView(
value: rows,
onRetry: () => ref.invalidate(benchmarkRowsProvider),
data: (data) => data.isEmpty
? const EmptyState(
icon: Icons.compare_arrows,
message: 'Бенчмарки не настроены — сравнивать не с чем.',
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final row in data)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _PeriodBlock(row: row),
),
],
),
);
}
}
class _PeriodBlock extends StatelessWidget {
const _PeriodBlock({required this.row});
final BenchmarkRow row;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(periodLabel(row.period), style: theme.textTheme.titleSmall),
const SizedBox(width: 8),
if (row.dateFrom != null && row.dateTo != null)
Text(
'${ruDate(row.dateFrom!)} ${ruDate(row.dateTo!)}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
),
const Spacer(),
Text(
'портфель ${formatPercent(row.portfolioTwr)}',
style: theme.textTheme.titleSmall
?.copyWith(color: signColor(context, row.portfolioTwr)),
),
if (row.portfolioDaysSkipped > 0) ...[
const SizedBox(width: 6),
_SkippedChip(days: row.portfolioDaysSkipped, side: 'портфеля'),
],
],
),
const SizedBox(height: 6),
for (final b in row.benchmarks) _BenchmarkRowView(result: b),
if (row.hasSkippedDays)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'Сетка дат не полностью совпадает: часть дней пропущена, '
'сравнение не строго like-for-like.',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
),
),
const Divider(height: 20),
],
);
}
}
class _BenchmarkRowView extends StatelessWidget {
const _BenchmarkRowView({required this.result});
final BenchmarkResult result;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Text(result.code, style: theme.textTheme.bodyMedium),
const SizedBox(width: 6),
if (result.isPriceIndex) const _PriceIndexChip(),
if (result.daysSkipped > 0) ...[
const SizedBox(width: 6),
_SkippedChip(days: result.daysSkipped, side: 'бенчмарка'),
],
const Spacer(),
Text(formatPercent(result.twr), style: theme.textTheme.bodyMedium),
const SizedBox(width: 16),
SizedBox(
width: 92,
child: Text(
formatPercent(result.excess),
textAlign: TextAlign.right,
style: theme.textTheme.bodyMedium
?.copyWith(color: signColor(context, result.excess)),
),
),
],
),
);
}
}
class _PriceIndexChip extends StatelessWidget {
const _PriceIndexChip();
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Tooltip(
message: 'Ценовой индекс: не учитывает дивиденды и систематически занижает '
'результат держателя. Сравнение с ним — нижняя граница, а не эталон.',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text('ценовой индекс', style: Theme.of(context).textTheme.labelSmall),
),
);
}
}
class _SkippedChip extends StatelessWidget {
const _SkippedChip({required this.days, required this.side});
final int days;
final String side;
@override
Widget build(BuildContext context) {
return Tooltip(
message: 'В расчёте $side пропущено $days дн. — в эти дни не было цены',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline, size: 14),
const SizedBox(width: 2),
Text('$days дн.', style: Theme.of(context).textTheme.labelSmall),
],
),
);
}
}