diff --git a/app/lib/core/utils/json.dart b/app/lib/core/utils/json.dart new file mode 100644 index 0000000..d891f94 --- /dev/null +++ b/app/lib/core/utils/json.dart @@ -0,0 +1,69 @@ +/// JSON coercion helpers shared by the hand-written phase-4 data layers +/// (`features/{income,rebalance,goals,tax}/data`, `features/portfolio/data`). +/// +/// **Temporary, like the layers that use them.** Once the phase-4 routes land in +/// `openapi/openapi.json` and `just gen-client` runs, the generated models take over the +/// parsing and this file loses most of its callers. +/// +/// Nothing here ever produces a `double`: money, quantities and rates arrive as strings and +/// stay strings until the point of display, where [Decimal] parses them. A numeric JSON +/// value (should the server ever send one) is kept in its lossless string form. +library; + +import 'package:decimal/decimal.dart'; +import 'package:dio/dio.dart'; + +import '../auth/auth_controller.dart' show problemMessage; + +String? asString(Object? v) => v == null ? null : (v is String ? v : v.toString()); + +int? asInt(Object? v) => switch (v) { + null => null, + final int i => i, + final String s => int.tryParse(s), + _ => null, + }; + +bool asBool(Object? v) => v == true; + +DateTime? asDate(Object? v) { + final s = asString(v); + if (s == null || s.isEmpty) return null; + return DateTime.tryParse(s); +} + +/// A list of JSON objects; anything else (including null) becomes an empty list. +List> asObjects(Object? v) => v is List + ? v.whereType().map((e) => Map.from(e)).toList() + : const []; + +Map? asObject(Object? v) => v is Map ? Map.from(v) : null; + +List asStrings(Object? v) => + v is List ? v.map((e) => e.toString()).toList() : const []; + +/// `{"schedule": "8100.00", ...}` — a string→decimal-string map such as `by_basis`. +Map asStringMap(Object? v) => v is Map + ? {for (final e in v.entries) e.key.toString(): e.value?.toString() ?? '0'} + : const {}; + +/// Parses a decimal string, returning null for null/empty/garbage rather than zero: +/// "no value" and "zero" are different answers and must not be merged. +Decimal? asDecimal(Object? v) { + final s = asString(v); + if (s == null || s.isEmpty) return null; + return Decimal.tryParse(s); +} + +/// Sum of decimal strings, exact — used for weight sums, where 0.1 + 0.2 in `double` +/// would make a valid set look invalid. +Decimal sumDecimals(Iterable values) => values.fold( + Decimal.zero, + (acc, v) => acc + (Decimal.tryParse(v) ?? Decimal.zero), + ); + +/// RFC 7807 `detail` first, then a readable fallback. Never surfaces a raw [DioException]. +String apiErrorMessage(Object error) { + if (error is! DioException) return error.toString(); + return problemMessage(error); +} diff --git a/app/lib/core/widgets/scope_selector.dart b/app/lib/core/widgets/scope_selector.dart new file mode 100644 index 0000000..e57e98d --- /dev/null +++ b/app/lib/core/widgets/scope_selector.dart @@ -0,0 +1,46 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../features/portfolio/providers.dart'; +import 'async_value_view.dart'; + +/// The `all | account: | portfolio:` selector, shared by every screen scoped the +/// same way (Портфель, Доходы, Налоги). Hidden while there is nothing to choose between — +/// a dropdown with one option is furniture, not a control. +/// +/// It drives the app-wide [scopeProvider] on purpose: two screens showing different scopes +/// at the same time is a trap, not a feature. +class ScopeSelector extends ConsumerWidget { + const ScopeSelector({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scopes = ref.watch(scopesProvider); + final current = ref.watch(scopeProvider); + + return AsyncValueView>( + value: scopes, + data: (rows) { + if (rows.length < 2) return const SizedBox.shrink(); + final known = rows.any((s) => s.scope == current) ? current : rows.first.scope; + return DropdownButtonHideUnderline( + child: DropdownButton( + value: known, + borderRadius: BorderRadius.circular(8), + items: [ + for (final s in rows) + DropdownMenuItem( + value: s.scope, + child: Text(s.name, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (value) { + if (value != null) ref.read(scopeProvider.notifier).state = value; + }, + ), + ); + }, + ); + } +} diff --git a/app/lib/core/widgets/section_card.dart b/app/lib/core/widgets/section_card.dart new file mode 100644 index 0000000..dd08c77 --- /dev/null +++ b/app/lib/core/widgets/section_card.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +/// A titled card section, the layout unit the phase-4 screens are built from (the same +/// shape Портфель already uses inline). +class SectionCard extends StatelessWidget { + const SectionCard({required this.title, required this.child, this.subtitle, this.trailing, super.key}); + + final String title; + final String? subtitle; + final Widget? trailing; + final Widget child; + + @override + Widget build(BuildContext context) { + 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(title, style: theme.textTheme.titleMedium), + if (subtitle != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + ), + ), + ], + ), + ), + ?trailing, + ], + ), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } +} + +/// A compact labelled number, used for the summary rows of the phase-4 screens. +class StatTile extends StatelessWidget { + const StatTile({required this.label, required this.value, this.note, this.width = 184, super.key}); + + final String label; + final Widget value; + final String? note; + final double width; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: width, + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodySmall), + const SizedBox(height: 4), + DefaultTextStyle(style: theme.textTheme.titleMedium!, child: value), + if (note != null) ...[ + const SizedBox(height: 2), + Text( + note!, + style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + maxLines: 3, + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/goals/data/goals_api.dart b/app/lib/features/goals/data/goals_api.dart new file mode 100644 index 0000000..208347b --- /dev/null +++ b/app/lib/features/goals/data/goals_api.dart @@ -0,0 +1,151 @@ +/// Hand-written client for `/api/v1/goals`. +/// +/// **Temporary.** The goal routes are not in `openapi/openapi.json` yet, so +/// `app/packages/api_client` has no generated models or methods for them. Everything here +/// follows `docs/ai/phase4-contract.md` §4 literally and is meant to be **replaced by the +/// generated client** once the routes land in the spec and `just gen-client` runs. +/// +/// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, bearer header and the one-shot +/// refresh on 401 are already wired there. +library; + +import 'package:dio/dio.dart'; + +import '../../../core/utils/json.dart'; + +class Goal { + const Goal({ + required this.id, + required this.name, + required this.scope, + required this.targetAmount, + required this.currency, + this.targetDate, + this.monthlyContribution, + this.note, + this.archived = false, + }); + + final int id; + final String name; + final String scope; + final String targetAmount; + final String currency; + final DateTime? targetDate; + final String? monthlyContribution; + final String? note; + final bool archived; + + /// The create/patch body. `id` is never sent; `null` fields are omitted so a PATCH that + /// touches one field does not blank the rest. + Map toJson() => { + 'name': name, + 'scope': scope, + 'target_amount': targetAmount, + 'currency': currency, + 'target_date': ?_isoDate(targetDate), + 'monthly_contribution': ?monthlyContribution, + 'note': ?note, + 'archived': archived, + }; + + static Goal fromJson(Map json) => Goal( + id: asInt(json['id']) ?? 0, + name: asString(json['name']) ?? 'Без названия', + scope: asString(json['scope']) ?? 'all', + targetAmount: asString(json['target_amount']) ?? '0', + currency: asString(json['currency']) ?? 'RUB', + targetDate: asDate(json['target_date']), + monthlyContribution: asString(json['monthly_contribution']), + note: asString(json['note']), + archived: asBool(json['archived']), + ); + + static String? _isoDate(DateTime? d) => d == null + ? null + : '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; +} + +class GoalProgress { + const GoalProgress({ + required this.goalId, + required this.basis, + required this.onTrack, + this.asOf, + this.currentValueRub, + this.targetAmountRub, + this.progress, + this.projectedDate, + this.assumedRate, + this.monthlyNeededRub, + }); + + final int goalId; + final DateTime? asOf; + final String? currentValueRub; + final String? targetAmountRub; + + /// A share: `"0.4128"` is 41,28 %. + final String? progress; + + /// **Null means the goal is not reached at the current trend** — an honest answer, and the + /// contract forbids substituting a far-off date for it. The UI must say so in words. + final DateTime? projectedDate; + + /// `xirr | contribution | none` — what the projection is built on. + final String basis; + final String? assumedRate; + final String? monthlyNeededRub; + final bool onTrack; + + /// The projection exists only when a date came back. `basis == 'none'` means there was + /// nothing to project from in the first place. + bool get isUnreachable => projectedDate == null && basis != 'none'; + + static GoalProgress fromJson(Map json) => GoalProgress( + goalId: asInt(json['goal_id']) ?? 0, + asOf: asDate(json['as_of']), + currentValueRub: asString(json['current_value_rub']), + targetAmountRub: asString(json['target_amount_rub']), + progress: asString(json['progress']), + projectedDate: asDate(json['projected_date']), + basis: asString(json['basis']) ?? 'none', + assumedRate: asString(json['assumed_rate']), + monthlyNeededRub: asString(json['monthly_needed_rub']), + onTrack: asBool(json['on_track']), + ); +} + +class GoalsApi { + const GoalsApi(this._dio); + + final Dio _dio; + + static const _base = '/api/v1/goals'; + + Future> list() async { + final r = await _dio.get>(_base); + return (r.data ?? const []) + .map((e) => Goal.fromJson(Map.from(e as Map))) + .toList(); + } + + Future create(Goal goal) async { + final r = await _dio.post>(_base, data: goal.toJson()); + return Goal.fromJson(r.data ?? const {}); + } + + Future patch(int id, Map changes) async { + final r = await _dio.patch>('$_base/$id', data: changes); + return Goal.fromJson(r.data ?? const {}); + } + + Future delete(int id) => _dio.delete('$_base/$id'); + + Future progress(int id) async { + final r = await _dio.get>('$_base/$id/progress'); + return GoalProgress.fromJson(r.data ?? const {}); + } +} diff --git a/app/lib/features/goals/goal_card.dart b/app/lib/features/goals/goal_card.dart new file mode 100644 index 0000000..a925f4a --- /dev/null +++ b/app/lib/features/goals/goal_card.dart @@ -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), + ], + ), + ], + ); + } +} diff --git a/app/lib/features/goals/goal_edit_dialog.dart b/app/lib/features/goals/goal_edit_dialog.dart new file mode 100644 index 0000000..717bd11 --- /dev/null +++ b/app/lib/features/goals/goal_edit_dialog.dart @@ -0,0 +1,171 @@ +import 'package:decimal/decimal.dart'; +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/utils/ru_date.dart'; +import '../portfolio/providers.dart' show scopesProvider; +import 'data/goals_api.dart'; + +/// Create/edit form for a goal. Returns the goal to save, or null when cancelled. +class GoalEditDialog extends ConsumerStatefulWidget { + const GoalEditDialog({super.key, this.initial}); + + final Goal? initial; + + @override + ConsumerState createState() => _GoalEditDialogState(); +} + +class _GoalEditDialogState extends ConsumerState { + final _formKey = GlobalKey(); + late final _name = TextEditingController(text: widget.initial?.name ?? ''); + late final _amount = TextEditingController(text: widget.initial?.targetAmount ?? ''); + late final _contribution = + TextEditingController(text: widget.initial?.monthlyContribution ?? ''); + late final _note = TextEditingController(text: widget.initial?.note ?? ''); + late String _scope = widget.initial?.scope ?? 'all'; + late DateTime? _targetDate = widget.initial?.targetDate; + late bool _archived = widget.initial?.archived ?? false; + + @override + void dispose() { + _name.dispose(); + _amount.dispose(); + _contribution.dispose(); + _note.dispose(); + super.dispose(); + } + + String? _decimalValidator(String? v, {bool required = true}) { + final text = (v ?? '').trim().replaceAll(',', '.'); + if (text.isEmpty) return required ? 'Обязательное поле' : null; + return Decimal.tryParse(text) == null ? 'Нужно число' : null; + } + + static String? _decimal(String text) { + final normalized = text.trim().replaceAll(',', '.').replaceAll(' ', ''); + if (normalized.isEmpty) return null; + return Decimal.tryParse(normalized)?.toString(); + } + + @override + Widget build(BuildContext context) { + final scopes = ref.watch(scopesProvider).valueOrNull ?? const []; + + return AlertDialog( + title: Text(widget.initial == null ? 'Новая цель' : 'Цель'), + content: SizedBox( + width: 420, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _name, + decoration: const InputDecoration(labelText: 'Название'), + validator: (v) => + (v ?? '').trim().isEmpty ? 'Обязательное поле' : null, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: scopes.any((s) => s.scope == _scope) || _scope == 'all' + ? _scope + : 'all', + decoration: const InputDecoration(labelText: 'Что считаем'), + items: [ + if (!scopes.any((s) => s.scope == 'all')) + const DropdownMenuItem(value: 'all', child: Text('Всё')), + for (final s in scopes) + DropdownMenuItem(value: s.scope, child: Text(s.name)), + ], + onChanged: (v) => setState(() => _scope = v ?? 'all'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _amount, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Целевая сумма, ₽'), + validator: _decimalValidator, + ), + const SizedBox(height: 12), + TextFormField( + controller: _contribution, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Ежемесячный взнос, ₽', + helperText: 'необязательно', + ), + validator: (v) => _decimalValidator(v, required: false), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text(_targetDate == null + ? 'Целевая дата не задана' + : 'Целевая дата: ${ruDate(_targetDate!)}'), + ), + TextButton( + onPressed: () async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: _targetDate ?? DateTime(now.year + 3, now.month, now.day), + firstDate: DateTime(now.year - 1), + lastDate: DateTime(now.year + 50), + ); + if (picked != null) setState(() => _targetDate = picked); + }, + child: const Text('Выбрать'), + ), + if (_targetDate != null) + IconButton( + tooltip: 'Убрать дату', + icon: const Icon(Icons.clear), + onPressed: () => setState(() => _targetDate = null), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: _note, + decoration: const InputDecoration(labelText: 'Заметка'), + ), + if (widget.initial != null) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('В архиве'), + value: _archived, + onChanged: (v) => setState(() => _archived = v), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Отмена')), + FilledButton( + onPressed: () { + if (!(_formKey.currentState?.validate() ?? false)) return; + Navigator.of(context).pop(Goal( + id: widget.initial?.id ?? 0, + name: _name.text.trim(), + scope: _scope, + targetAmount: _decimal(_amount.text) ?? '0', + currency: widget.initial?.currency ?? 'RUB', + targetDate: _targetDate, + monthlyContribution: _decimal(_contribution.text), + note: _note.text.trim().isEmpty ? null : _note.text.trim(), + archived: _archived, + )); + }, + child: const Text('Сохранить'), + ), + ], + ); + } +} diff --git a/app/lib/features/goals/goals_page.dart b/app/lib/features/goals/goals_page.dart new file mode 100644 index 0000000..5448d3f --- /dev/null +++ b/app/lib/features/goals/goals_page.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/utils/json.dart'; +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import 'data/goals_api.dart'; +import 'goal_card.dart'; +import 'goal_edit_dialog.dart'; +import 'providers.dart'; + +/// Цели: the goal list, each card carrying its server-computed progress. +class GoalsPage extends ConsumerStatefulWidget { + const GoalsPage({super.key}); + + @override + ConsumerState createState() => _GoalsPageState(); +} + +class _GoalsPageState extends ConsumerState { + bool _showArchived = false; + + void _snack(String message) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + + Future _create() async { + final goal = await showDialog( + context: context, + builder: (_) => const GoalEditDialog(), + ); + if (goal == null) return; + try { + await ref.read(goalsApiProvider).create(goal); + if (!mounted) return; + invalidateGoals(ref); + } catch (e) { + if (mounted) _snack(apiErrorMessage(e)); + } + } + + Future _edit(Goal goal) async { + final updated = await showDialog( + context: context, + builder: (_) => GoalEditDialog(initial: goal), + ); + if (updated == null) return; + try { + await ref.read(goalsApiProvider).patch(goal.id, updated.toJson()); + if (!mounted) return; + invalidateGoals(ref); + } catch (e) { + if (mounted) _snack(apiErrorMessage(e)); + } + } + + Future _delete(Goal goal) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Удалить цель?'), + content: Text('«${goal.name}» будет удалена безвозвратно.'), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Отмена')), + FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Удалить')), + ], + ), + ); + if (confirmed != true) return; + try { + await ref.read(goalsApiProvider).delete(goal.id); + if (!mounted) return; + invalidateGoals(ref); + } catch (e) { + if (mounted) _snack(apiErrorMessage(e)); + } + } + + @override + Widget build(BuildContext context) { + final goals = ref.watch(goalsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Цели'), + actions: [ + IconButton( + tooltip: _showArchived ? 'Скрыть архив' : 'Показать архив', + icon: Icon(_showArchived ? Icons.inventory_2 : Icons.inventory_2_outlined), + onPressed: () => setState(() => _showArchived = !_showArchived), + ), + IconButton( + tooltip: 'Обновить', + icon: const Icon(Icons.refresh), + onPressed: () => invalidateGoals(ref), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _create, + icon: const Icon(Icons.add), + label: const Text('Новая цель'), + ), + body: RefreshIndicator( + onRefresh: () async => invalidateGoals(ref), + child: AsyncValueView( + value: goals, + onRetry: () => ref.invalidate(goalsProvider), + data: (all) { + final rows = _showArchived ? all : all.where((g) => !g.archived).toList(); + if (rows.isEmpty) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const SizedBox(height: 48), + EmptyState( + icon: Icons.flag_outlined, + message: all.isEmpty + ? 'Целей пока нет.\nЦель — это сумма и (необязательно) дата; ' + 'прогноз считает сервер по доходности или по взносам.' + : 'Все цели в архиве — включите показ архива.', + ), + ], + ); + } + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), + children: [ + for (final g in rows) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: GoalCard( + goal: g, + onEdit: () => _edit(g), + onDelete: () => _delete(g), + ), + ), + ], + ); + }, + ), + ), + ); + } +} diff --git a/app/lib/features/goals/providers.dart b/app/lib/features/goals/providers.dart new file mode 100644 index 0000000..878bd6f --- /dev/null +++ b/app/lib/features/goals/providers.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import 'data/goals_api.dart'; + +final goalsApiProvider = Provider((ref) => GoalsApi(ref.watch(apiProvider).dio)); + +final goalsProvider = + FutureProvider.autoDispose>((ref) => ref.watch(goalsApiProvider).list()); + +/// Progress is computed server-side and refetched per goal — the client never projects +/// anything itself. +final goalProgressProvider = + FutureProvider.autoDispose.family((ref, id) async { + return ref.watch(goalsApiProvider).progress(id); +}); + +/// After any create/patch/delete the list and every progress card are refetched: the +/// numbers are recomputed on the server, not patched in the app. +void invalidateGoals(WidgetRef ref) { + ref.invalidate(goalsProvider); + ref.invalidate(goalProgressProvider); +} diff --git a/app/lib/features/imports/data/imports_api.dart b/app/lib/features/imports/data/imports_api.dart new file mode 100644 index 0000000..3c76000 --- /dev/null +++ b/app/lib/features/imports/data/imports_api.dart @@ -0,0 +1,441 @@ +/// Hand-written client for `/api/v1/imports`. +/// +/// **Temporary.** The import routes are not in `openapi/openapi.json` yet, so the generated +/// package `app/packages/api_client` knows nothing about them. Everything here — models and +/// calls — follows `docs/ai/import-contract.md` literally and is meant to be **replaced by +/// the generated client** as soon as the routes land in the spec and `just gen-client` runs. +/// Until then this is the only place in the app that talks to those endpoints. +/// +/// Raw Dio comes from `ref.read(apiProvider).dio`, which already carries the base URL, the +/// bearer header and the single transparent refresh on 401. +library; + +import 'package:dio/dio.dart'; + +import '../../../core/auth/auth_controller.dart' show problemMessage; +import '../../pending/data/pending_api.dart'; + +/// A candidate account for an import whose `account_id` the server could not resolve. +class AccountSuggestion { + const AccountSuggestion({required this.id, required this.name, this.broker, this.sourceId}); + + final int id; + final String name; + final String? broker; + final String? sourceId; + + static AccountSuggestion fromJson(Map json) => AccountSuggestion( + id: asInt(json['id'])!, + name: asString(json['name']) ?? '#${json['id']}', + broker: asString(json['broker']), + sourceId: asString(json['source_id']), + ); +} + +class ImportCounts { + const ImportCounts({ + this.lines = 0, + this.eventsTotal = 0, + this.eventsNew = 0, + this.eventsDuplicate = 0, + this.eventsShadow = 0, + this.eventsPending = 0, + this.byKind = const {}, + }); + + final int lines; + final int eventsTotal; + final int eventsNew; + final int eventsDuplicate; + final int eventsShadow; + final int eventsPending; + final Map byKind; + + static ImportCounts fromJson(Map? json) { + if (json == null) return const ImportCounts(); + final byKind = json['by_kind']; + return ImportCounts( + lines: asInt(json['lines']) ?? 0, + eventsTotal: asInt(json['events_total']) ?? 0, + eventsNew: asInt(json['events_new']) ?? 0, + eventsDuplicate: asInt(json['events_duplicate']) ?? 0, + eventsShadow: asInt(json['events_shadow']) ?? 0, + eventsPending: asInt(json['events_pending']) ?? 0, + byKind: byKind is Map + ? { + for (final e in byKind.entries) + e.key.toString(): asInt(e.value) ?? 0, + } + : const {}, + ); + } +} + +/// One position of the report checked against the ledger. Quantities stay strings here and +/// are parsed into `Decimal` only where they are shown or compared. +class ReconPosition { + const ReconPosition({ + required this.matches, + this.instrumentId, + this.instrumentName, + this.ticker, + this.isin, + this.qtyReport, + this.qtyDerived, + this.qtyDelta, + }); + + final bool matches; + final int? instrumentId; + final String? instrumentName; + final String? ticker; + final String? isin; + final String? qtyReport; + final String? qtyDerived; + final String? qtyDelta; + + String get title => instrumentName ?? ticker ?? isin ?? '—'; + + static ReconPosition fromJson(Map json) => ReconPosition( + matches: json['matches'] == true, + instrumentId: asInt(json['instrument_id']), + instrumentName: asString(json['instrument_name']), + ticker: asString(json['ticker']), + isin: asString(json['isin']), + qtyReport: asString(json['qty_report']), + qtyDerived: asString(json['qty_derived']), + qtyDelta: asString(json['qty_delta']), + ); +} + +class ReconCash { + const ReconCash({ + required this.currency, + required this.matches, + this.balanceReport, + this.balanceDerived, + this.delta, + }); + + final String currency; + final bool matches; + final String? balanceReport; + final String? balanceDerived; + final String? delta; + + static ReconCash fromJson(Map json) => ReconCash( + currency: asString(json['currency']) ?? 'RUB', + matches: json['matches'] == true, + balanceReport: asString(json['balance_report']), + balanceDerived: asString(json['balance_derived']), + delta: asString(json['delta']), + ); +} + +class Reconciliation { + const Reconciliation({ + required this.matches, + this.asOf, + this.positions = const [], + this.cash = const [], + }); + + final bool matches; + final DateTime? asOf; + final List positions; + final List cash; + + bool get isEmpty => positions.isEmpty && cash.isEmpty; + + static Reconciliation? fromJson(Map? json) { + if (json == null) return null; + return Reconciliation( + matches: json['matches'] == true, + asOf: asDate(json['as_of']), + positions: asList(json['positions']).map(ReconPosition.fromJson).toList(), + cash: asList(json['cash']).map(ReconCash.fromJson).toList(), + ); + } +} + +/// A parsed report line as the user should see it before committing anything. +class SampleEvent { + const SampleEvent({ + required this.lineNo, + required this.kind, + required this.isDuplicate, + this.tradeDate, + this.settleDate, + this.instrumentKey, + this.instrumentName, + this.instrumentId, + this.quantity, + this.price, + this.amount, + this.currency, + this.fee, + this.tradeNo, + this.dedupeKey, + this.description, + }); + + final int lineNo; + final String kind; + final bool isDuplicate; + final DateTime? tradeDate; + final DateTime? settleDate; + final String? instrumentKey; + final String? instrumentName; + final int? instrumentId; + final String? quantity; + final String? price; + final String? amount; + final String? currency; + final String? fee; + final String? tradeNo; + final String? dedupeKey; + final String? description; + + static SampleEvent fromJson(Map json) => SampleEvent( + lineNo: asInt(json['line_no']) ?? 0, + kind: asString(json['kind']) ?? 'other', + isDuplicate: json['is_duplicate'] == true, + tradeDate: asDate(json['trade_date']), + settleDate: asDate(json['settle_date']), + instrumentKey: asString(json['instrument_key']), + instrumentName: asString(json['instrument_name']), + instrumentId: asInt(json['instrument_id']), + quantity: asString(json['quantity']), + price: asString(json['price']), + amount: asString(json['amount']), + currency: asString(json['currency']), + fee: asString(json['fee']), + tradeNo: asString(json['trade_no']), + dedupeKey: asString(json['dedupe_key']), + description: asString(json['description']), + ); +} + +/// `ImportPreview` and `ImportSummary` in one class: the summary is the same object without +/// `sample_events`, `pending_instruments` and `reconciliation`, so the list screen simply +/// gets empty collections and a null reconciliation. +class ImportPreview { + const ImportPreview({ + required this.id, + required this.filename, + required this.parseStatus, + this.broker, + this.sha256, + this.sizeBytes, + this.parserName, + this.parserVersion, + this.error, + this.duplicateOfId, + this.accountExternalId, + this.accountId, + this.accountName, + this.accountSuggestions = const [], + this.periodFrom, + this.periodTo, + this.uploadedAt, + this.committedAt, + this.counts = const ImportCounts(), + this.pendingInstruments = const [], + this.reconciliation, + this.warnings = const [], + this.sampleEvents = const [], + }); + + final int id; + final String filename; + + /// `uploaded | parsed | committed | failed` — a plain string, like every other stable key. + final String parseStatus; + final String? broker; + final String? sha256; + final int? sizeBytes; + final String? parserName; + final String? parserVersion; + final String? error; + final int? duplicateOfId; + final String? accountExternalId; + final int? accountId; + final String? accountName; + final List accountSuggestions; + final DateTime? periodFrom; + final DateTime? periodTo; + final DateTime? uploadedAt; + final DateTime? committedAt; + final ImportCounts counts; + final List pendingInstruments; + final Reconciliation? reconciliation; + final List warnings; + final List sampleEvents; + + bool get isCommitted => parseStatus == 'committed'; + bool get isFailed => parseStatus == 'failed'; + + /// Commit is allowed only once an account is known and the file actually parsed. + bool get canCommit => accountId != null && !isCommitted && !isFailed; + + bool get canDelete => !isCommitted; + + static ImportPreview fromJson(Map json) => ImportPreview( + id: asInt(json['id'])!, + filename: asString(json['filename']) ?? 'без имени', + parseStatus: asString(json['parse_status']) ?? 'uploaded', + broker: asString(json['broker']), + sha256: asString(json['sha256']), + sizeBytes: asInt(json['size_bytes']), + parserName: asString(json['parser_name']), + parserVersion: asString(json['parser_version']), + error: asString(json['error']), + duplicateOfId: asInt(json['duplicate_of_id']), + accountExternalId: asString(json['account_external_id']), + accountId: asInt(json['account_id']), + accountName: asString(json['account_name']), + accountSuggestions: + asList(json['account_suggestions']).map(AccountSuggestion.fromJson).toList(), + periodFrom: asDate(json['period_from']), + periodTo: asDate(json['period_to']), + uploadedAt: asDate(json['uploaded_at']), + committedAt: asDate(json['committed_at']), + counts: ImportCounts.fromJson(asMap(json['counts'])), + pendingInstruments: + asList(json['pending_instruments']).map(PendingInstrument.fromJson).toList(), + reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])), + warnings: json['warnings'] is List + ? (json['warnings'] as List).map((e) => e.toString()).toList() + : const [], + sampleEvents: asList(json['sample_events']).map(SampleEvent.fromJson).toList(), + ); +} + +/// What `POST /imports/{id}/commit` reports back. +class ImportResult { + const ImportResult({ + required this.importId, + required this.committed, + this.eventsCreated = 0, + this.eventsUpdated = 0, + this.eventsSkipped = 0, + this.eventsShadow = 0, + this.pendingInstruments = 0, + this.reconciliation, + this.metricsRefreshed = false, + }); + + final int importId; + final bool committed; + final int eventsCreated; + final int eventsUpdated; + final int eventsSkipped; + final int eventsShadow; + final int pendingInstruments; + final Reconciliation? reconciliation; + final bool metricsRefreshed; + + static ImportResult fromJson(Map json) => ImportResult( + importId: asInt(json['import_id']) ?? 0, + committed: json['committed'] == true, + eventsCreated: asInt(json['events_created']) ?? 0, + eventsUpdated: asInt(json['events_updated']) ?? 0, + eventsSkipped: asInt(json['events_skipped']) ?? 0, + eventsShadow: asInt(json['events_shadow']) ?? 0, + pendingInstruments: asInt(json['pending_instruments']) ?? 0, + reconciliation: Reconciliation.fromJson(asMap(json['reconciliation'])), + metricsRefreshed: json['metrics_refreshed'] == true, + ); +} + +/// A report chosen by the user. On web `file_picker` can only hand over [bytes]; on desktop +/// it hands over a [path] and reading the file is left to Dio. Both are supported so the +/// web build keeps working. +class PickedReport { + const PickedReport({required this.name, this.bytes, this.path}); + + final String name; + final List? bytes; + final String? path; +} + +class ImportsApi { + const ImportsApi(this._dio); + + final Dio _dio; + + static const _base = '/api/v1/imports'; + + Future> list({int limit = 50, int offset = 0, String? status}) async { + final r = await _dio.get>( + _base, + queryParameters: { + 'limit': limit, + 'offset': offset, + 'status': ?status, + }, + ); + return (r.data ?? const []) + .map((e) => ImportPreview.fromJson(Map.from(e as Map))) + .toList(); + } + + Future get(int id) async { + final r = await _dio.get>('$_base/$id'); + return ImportPreview.fromJson(r.data!); + } + + Future upload(PickedReport report, {int? accountId, String? parser}) async { + final bytes = report.bytes; + final form = FormData.fromMap({ + 'file': bytes != null + ? MultipartFile.fromBytes(bytes, filename: report.name) + : await MultipartFile.fromFile(report.path!, filename: report.name), + 'account_id': ?accountId, + 'parser': ?parser, + }); + final r = await _dio.post>(_base, data: form); + return ImportPreview.fromJson(r.data!); + } + + Future commit( + int id, { + int? accountId, + bool confirmDuplicates = false, + bool dryRun = false, + }) async { + final r = await _dio.post>('$_base/$id/commit', data: { + 'account_id': ?accountId, + 'confirm_duplicates': confirmDuplicates, + 'dry_run': dryRun, + }); + return ImportResult.fromJson(r.data ?? const {}); + } + + Future delete(int id) => _dio.delete('$_base/$id'); +} + +/// RFC 7807 `detail` first; a readable Russian fallback for the statuses the contract names +/// when the body carries no detail. Never surfaces a raw `DioException`. +String importErrorMessage(Object error) { + if (error is! DioException) return error.toString(); + final data = error.response?.data; + if (data is Map) { + final detail = data['detail'] ?? data['title']; + if (detail is String && detail.isNotEmpty) return detail; + } + return switch (error.response?.statusCode) { + 413 => 'Файл больше 16 МБ', + 415 => 'Формат файла не распознан', + 422 => 'Файл не удалось разобрать', + 409 => 'Импорт уже закоммичен', + 404 => 'Импорт не найден', + _ => problemMessage(error), + }; +} + +List> asList(Object? v) => v is List + ? v.whereType().map((e) => Map.from(e)).toList() + : const []; + +Map? asMap(Object? v) => v is Map ? Map.from(v) : null; diff --git a/app/lib/features/imports/data/report_picker.dart b/app/lib/features/imports/data/report_picker.dart new file mode 100644 index 0000000..6ccd446 --- /dev/null +++ b/app/lib/features/imports/data/report_picker.dart @@ -0,0 +1,37 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'imports_api.dart'; + +/// Picking a report is the one part of the import screen that touches the platform, so it +/// sits behind this one-method interface: widget tests (and a future drag-and-drop variant) +/// override [reportPickerProvider] instead of mocking a plugin. +abstract class ReportPicker { + Future pick(); +} + +/// The extensions the report parsers accept. +const reportExtensions = ['html', 'htm', 'xlsx', 'csv']; + +class FilePickerReportPicker implements ReportPicker { + const FilePickerReportPicker(); + + @override + Future pick() async { + // file_picker 11 exposes `pickFiles` as a static on [FilePicker]; the old + // `FilePicker.platform` singleton is gone. + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: reportExtensions, + // On web there is no path at all, only bytes; asking for bytes everywhere would read + // a 16 MB report into memory for nothing on desktop. + withData: kIsWeb, + ); + if (result == null || result.files.isEmpty) return null; + final file = result.files.first; + return PickedReport(name: file.name, bytes: file.bytes, path: file.path); + } +} + +final reportPickerProvider = Provider((ref) => const FilePickerReportPicker()); diff --git a/app/lib/features/imports/import_preview_page.dart b/app/lib/features/imports/import_preview_page.dart new file mode 100644 index 0000000..8f53225 --- /dev/null +++ b/app/lib/features/imports/import_preview_page.dart @@ -0,0 +1,551 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/utils/ru_date.dart'; +import '../../core/widgets/async_value_view.dart'; +import '../portfolio/labels.dart' show eventKindLabels, formatQty; +import 'data/imports_api.dart'; +import 'labels.dart'; +import 'providers.dart'; +import 'widgets/reconciliation_card.dart'; +import 'widgets/sample_events_table.dart'; + +/// The preview of one uploaded report: what the parser found, how it lines up with the +/// ledger, and the one button that actually writes events. Nothing on this screen has +/// touched `event` yet — upload only parses. +class ImportPreviewPage extends ConsumerStatefulWidget { + const ImportPreviewPage({required this.importId, super.key}); + + final int importId; + + @override + ConsumerState createState() => _ImportPreviewPageState(); +} + +class _ImportPreviewPageState extends ConsumerState { + /// Chosen by the user when the server could not resolve `account_id` itself. + int? _accountChoice; + bool _confirmDuplicates = false; + bool _busy = false; + ImportResult? _result; + + void _snack(String message) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + + Future _commit(ImportPreview preview) async { + final accountId = preview.accountId ?? _accountChoice; + if (accountId == null) return; + setState(() => _busy = true); + try { + final result = await ref.read(importsApiProvider).commit( + preview.id, + accountId: preview.accountId == null ? accountId : null, + confirmDuplicates: _confirmDuplicates, + ); + if (!mounted) return; + setState(() => _result = result); + // The ledger changed: every screen that counts events or values positions is stale. + invalidateLedgerDependents(ref); + ref.invalidate(importsListProvider); + ref.invalidate(importPreviewProvider(preview.id)); + _snack('Импортировано: создано ${result.eventsCreated}, ' + 'обновлено ${result.eventsUpdated}, пропущено ${result.eventsSkipped}'); + } catch (e) { + if (!mounted) return; + _snack(importErrorMessage(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _delete(ImportPreview preview) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Удалить импорт?'), + content: Text('Файл «${preview.filename}» и разобранные строки будут удалены. ' + 'События в леджере не создавались, так что удалять нечего.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), child: const Text('Удалить')), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _busy = true); + try { + await ref.read(importsApiProvider).delete(preview.id); + if (!mounted) return; + ref.invalidate(importsListProvider); + context.go('/imports'); + } catch (e) { + if (!mounted) return; + _snack(importErrorMessage(e)); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final preview = ref.watch(importPreviewProvider(widget.importId)); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/imports'), + ), + title: const Text('Импорт отчёта'), + actions: [ + IconButton( + tooltip: 'Обновить', + icon: const Icon(Icons.refresh), + onPressed: () => ref.invalidate(importPreviewProvider(widget.importId)), + ), + ], + ), + body: AsyncValueView( + value: preview, + onRetry: () => ref.invalidate(importPreviewProvider(widget.importId)), + data: _buildBody, + ), + ); + } + + Widget _buildBody(ImportPreview p) { + final theme = Theme.of(context); + final accountId = p.accountId ?? _accountChoice; + final recon = p.reconciliation; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _header(p), + if (p.duplicateOfId != null) ...[ + const SizedBox(height: 12), + _banner( + icon: Icons.copy_all_outlined, + color: theme.colorScheme.secondary, + title: 'Этот файл уже загружали', + body: 'Показан ранее созданный импорт №${p.duplicateOfId}. ' + 'Повторная загрузка не создаёт новых событий.', + ), + ], + if (p.isFailed) ...[ + const SizedBox(height: 12), + _banner( + icon: Icons.error_outline, + color: theme.colorScheme.error, + title: 'Файл не разобрался', + body: p.error ?? 'Парсер не смог прочитать отчёт.', + ), + ], + if (p.accountId == null && !p.isFailed) ...[ + const SizedBox(height: 12), + _accountPicker(p), + ], + const SizedBox(height: 12), + _countsCard(p), + if (recon != null) ...[ + const SizedBox(height: 12), + ReconciliationCard(reconciliation: recon), + ], + if (p.pendingInstruments.isNotEmpty) ...[ + const SizedBox(height: 12), + _pendingCard(p), + ], + if (p.warnings.isNotEmpty) ...[ + const SizedBox(height: 12), + _warningsCard(p), + ], + if (p.sampleEvents.isNotEmpty) ...[ + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Строки отчёта', style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text('Первые ${p.sampleEvents.length} из ${p.counts.lines}', + style: theme.textTheme.bodySmall), + const SizedBox(height: 8), + SampleEventsTable(events: p.sampleEvents), + ], + ), + ), + ), + ], + if (_result != null) ...[ + const SizedBox(height: 12), + _resultCard(_result!), + ], + const SizedBox(height: 16), + _actions(p, accountId), + const SizedBox(height: 32), + ], + ); + } + + Widget _header(ImportPreview p) { + final theme = Theme.of(context); + final period = p.periodFrom != null && p.periodTo != null + ? '${ruDate(p.periodFrom!)} – ${ruDate(p.periodTo!)}' + : 'период не определён'; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(p.filename, style: theme.textTheme.titleMedium), + ), + parseStatusChip(context, p.parseStatus), + ], + ), + const SizedBox(height: 8), + _kv('Брокер', brokerLabel(p.broker)), + _kv('Период', period), + _kv( + 'Счёт', + p.accountName ?? + (p.accountId != null ? '#${p.accountId}' : 'не определён по отчёту'), + ), + if (p.accountExternalId != null) _kv('Счёт в отчёте', p.accountExternalId!), + if (p.parserName != null) + _kv('Парсер', '${p.parserName} v${p.parserVersion ?? '1'}'), + if (p.sizeBytes != null) _kv('Размер', formatBytes(p.sizeBytes)), + if (p.uploadedAt != null) _kv('Загружен', ruDate(p.uploadedAt!.toLocal())), + if (p.committedAt != null) + _kv('Импортирован', ruDate(p.committedAt!.toLocal())), + ], + ), + ), + ); + } + + Widget _accountPicker(ImportPreview p) { + final theme = Theme.of(context); + return Card( + color: theme.colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.account_balance_outlined, color: theme.colorScheme.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text('Счёт не определён', + style: theme.textTheme.titleMedium + ?.copyWith(color: theme.colorScheme.onErrorContainer)), + ), + ], + ), + const SizedBox(height: 4), + Text( + p.accountSuggestions.isEmpty + ? 'В отчёте номер счёта ${p.accountExternalId ?? '—'}, но подходящего ' + 'счёта в базе нет. Заведите счёт, затем вернитесь сюда.' + : 'Выберите счёт, в который писать события. Без него импорт недоступен.', + style: TextStyle(color: theme.colorScheme.onErrorContainer), + ), + if (p.accountSuggestions.isNotEmpty) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _accountChoice, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Счёт', + filled: true, + border: OutlineInputBorder(), + ), + items: [ + for (final s in p.accountSuggestions) + DropdownMenuItem( + value: s.id, + child: Text([ + s.name, + if (s.broker != null) brokerLabel(s.broker), + if (s.sourceId != null) s.sourceId!, + ].join(' · ')), + ), + ], + onChanged: (v) => setState(() => _accountChoice = v), + ), + ], + ], + ), + ), + ); + } + + Widget _countsCard(ImportPreview p) { + final theme = Theme.of(context); + final c = p.counts; + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Что нашлось', style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _stat('Строк', c.lines), + _stat('Событий', c.eventsTotal), + _stat('Новых', c.eventsNew, color: Colors.green), + _stat('Дубликатов', c.eventsDuplicate, + color: c.eventsDuplicate > 0 ? theme.colorScheme.secondary : null, + hint: 'Уже есть в леджере: будут обновлены, а не продублированы'), + _stat('Shadow', c.eventsShadow, + hint: 'Не первичный источник — в аналитику не идут'), + _stat('Ждут инструмента', c.eventsPending, + color: c.eventsPending > 0 ? theme.colorScheme.error : null, + hint: 'Инструмент не распознан, события останутся в статусе pending'), + ], + ), + if (c.byKind.isNotEmpty) ...[ + const SizedBox(height: 16), + Text('По типам', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final e in c.byKind.entries) + Chip( + visualDensity: VisualDensity.compact, + label: Text('${eventKindLabels[e.key] ?? e.key}: ${e.value}'), + ), + ], + ), + ], + ], + ), + ), + ); + } + + Widget _pendingCard(ImportPreview p) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Нераспознанные инструменты (${p.pendingInstruments.length})', + style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + 'Сервер не угадывает инструмент. Пока вы не привяжете эти строки вручную, ' + 'их события останутся в статусе pending и не попадут в аналитику.', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 8), + for (final pi in p.pendingInstruments) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(pi.title), + subtitle: Text([ + if (pi.isin != null) 'ISIN ${pi.isin}', + 'встречается ${pi.occurrences}', + if (pi.sampleQuantity != null) 'кол-во ${formatQty(pi.sampleQuantity!)}', + ].join(' · ')), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => context.go('/instruments/pending'), + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('К резолву инструментов'), + ), + ), + ], + ), + ), + ); + } + + Widget _warningsCard(ImportPreview p) { + final theme = Theme.of(context); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.warning_amber_outlined, color: theme.colorScheme.tertiary), + const SizedBox(width: 8), + Text('Предупреждения', style: theme.textTheme.titleMedium), + ], + ), + const SizedBox(height: 8), + for (final w in p.warnings) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text('• $w'), + ), + ], + ), + ), + ); + } + + Widget _resultCard(ImportResult r) { + final theme = Theme.of(context); + return Card( + color: theme.colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Результат импорта', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + _kv('Создано событий', '${r.eventsCreated}'), + _kv('Обновлено', '${r.eventsUpdated}'), + _kv('Пропущено', '${r.eventsSkipped}'), + if (r.eventsShadow > 0) _kv('Shadow', '${r.eventsShadow}'), + if (r.pendingInstruments > 0) + _kv('Ждут инструмента', '${r.pendingInstruments}'), + _kv('Метрики пересчитаны', r.metricsRefreshed ? 'да' : 'нет'), + ], + ), + ), + ); + } + + Widget _actions(ImportPreview p, int? accountId) { + final canCommit = !p.isCommitted && !p.isFailed && accountId != null && !_busy; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (p.counts.eventsDuplicate > 0 && !p.isCommitted) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: _confirmDuplicates, + onChanged: (v) => setState(() => _confirmDuplicates = v ?? false), + title: const Text('Обновлять дубликаты'), + subtitle: Text('Перезаписать ${p.counts.eventsDuplicate} событий, ' + 'которые уже есть в леджере'), + ), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + onPressed: canCommit ? () => _commit(p) : null, + icon: _busy + ? const SizedBox( + width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.playlist_add_check), + label: Text(p.isCommitted ? 'Уже импортирован' : 'Импортировать'), + ), + if (p.canDelete) + OutlinedButton.icon( + onPressed: _busy ? null : () => _delete(p), + icon: const Icon(Icons.delete_outline), + label: const Text('Удалить'), + ), + ], + ), + if (accountId == null && !p.isFailed) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Импорт недоступен, пока не выбран счёт.', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ); + } + + Widget _banner({ + required IconData icon, + required Color color, + required String title, + required String body, + }) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(color: color)), + const SizedBox(height: 4), + Text(body), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _stat(String label, int value, {Color? color, String? hint}) { + final theme = Theme.of(context); + final tile = Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodySmall), + Text('$value', + style: theme.textTheme.titleLarge?.copyWith(color: color)), + ], + ), + ); + return hint == null ? tile : Tooltip(message: hint, child: tile); + } + + Widget _kv(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text(label, style: Theme.of(context).textTheme.bodySmall), + ), + Expanded(child: Text(value)), + ], + ), + ); + } +} diff --git a/app/lib/features/imports/imports_page.dart b/app/lib/features/imports/imports_page.dart new file mode 100644 index 0000000..54fd498 --- /dev/null +++ b/app/lib/features/imports/imports_page.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/utils/ru_date.dart'; +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import '../pending/providers.dart'; +import 'data/imports_api.dart'; +import 'data/report_picker.dart'; +import 'labels.dart'; +import 'providers.dart'; + +/// Импорт: the list of uploaded broker reports plus the upload button. +/// +/// Uploading never writes to the ledger — it parses the file and opens the preview, where +/// the numbers are checked against the ledger before anything is committed. +class ImportsPage extends ConsumerStatefulWidget { + const ImportsPage({super.key}); + + @override + ConsumerState createState() => _ImportsPageState(); +} + +class _ImportsPageState extends ConsumerState { + bool _uploading = false; + + Future _upload() async { + final picked = await ref.read(reportPickerProvider).pick(); + if (picked == null || !mounted) return; + + setState(() => _uploading = true); + try { + final preview = await ref.read(importsApiProvider).upload(picked); + if (!mounted) return; + ref.invalidate(importsListProvider); + if (preview.duplicateOfId != null) { + _snack('Этот файл уже загружали — открыт существующий импорт №${preview.id}'); + } + context.go('/imports/${preview.id}'); + } catch (e) { + if (!mounted) return; + _snack(importErrorMessage(e)); + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + void _snack(String message) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + + @override + Widget build(BuildContext context) { + final imports = ref.watch(importsListProvider); + final pendingCount = ref.watch(pendingCountProvider).valueOrNull ?? 0; + + return Scaffold( + appBar: AppBar( + title: const Text('Импорт отчётов'), + actions: [ + IconButton( + tooltip: 'Обновить', + icon: const Icon(Icons.refresh), + onPressed: () { + ref.invalidate(importsListProvider); + ref.invalidate(pendingCountProvider); + }, + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _uploading ? null : _upload, + icon: _uploading + ? const SizedBox( + width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.upload_file), + label: Text(_uploading ? 'Загрузка…' : 'Загрузить отчёт'), + ), + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(importsListProvider); + ref.invalidate(pendingCountProvider); + }, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), + children: [ + if (pendingCount > 0) + Card( + color: Theme.of(context).colorScheme.tertiaryContainer, + child: ListTile( + leading: const Icon(Icons.help_outline), + title: Text('Нераспознанных инструментов: $pendingCount'), + subtitle: const Text( + 'События по ним ждут в статусе pending и не попадают в аналитику.'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go('/instruments/pending'), + ), + ), + const _StatusFilter(), + const SizedBox(height: 8), + AsyncValueView( + value: imports, + onRetry: () => ref.invalidate(importsListProvider), + data: (rows) => rows.isEmpty + ? const Padding( + padding: EdgeInsets.only(top: 48), + child: EmptyState( + icon: Icons.upload_file_outlined, + message: 'Отчёты ещё не загружались.\n' + 'Поддерживаются выгрузки Сбера и ВТБ (HTML, XLSX) и CSV.', + ), + ) + : Column(children: [for (final row in rows) _ImportCard(item: row)]), + ), + ], + ), + ), + ); + } +} + +class _StatusFilter extends ConsumerWidget { + const _StatusFilter(); + + static const _options = { + null: 'Все', + 'uploaded': 'Загружены', + 'parsed': 'Разобраны', + 'committed': 'Импортированы', + 'failed': 'С ошибкой', + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final current = ref.watch(importsStatusFilterProvider); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Wrap( + spacing: 8, + children: [ + for (final e in _options.entries) + ChoiceChip( + label: Text(e.value), + selected: current == e.key, + onSelected: (_) => + ref.read(importsStatusFilterProvider.notifier).state = e.key, + ), + ], + ), + ); + } +} + +class _ImportCard extends StatelessWidget { + const _ImportCard({required this.item}); + + final ImportPreview item; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final period = item.periodFrom != null && item.periodTo != null + ? '${ruDate(item.periodFrom!)} – ${ruDate(item.periodTo!)}' + : 'период не определён'; + final subtitle = [ + period, + item.accountName ?? (item.accountId != null ? 'счёт #${item.accountId}' : 'счёт не найден'), + if (item.uploadedAt != null) 'загружен ${ruDate(item.uploadedAt!.toLocal())}', + if (item.sizeBytes != null) formatBytes(item.sizeBytes), + ].join(' · '); + + return Card( + child: ListTile( + onTap: () => context.go('/imports/${item.id}'), + title: Row( + children: [ + Flexible(child: Text(item.filename, overflow: TextOverflow.ellipsis)), + const SizedBox(width: 8), + parseStatusChip(context, item.parseStatus), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${brokerLabel(item.broker)} · $subtitle'), + if (item.counts.eventsTotal > 0) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'событий ${item.counts.eventsTotal}' + ' · новых ${item.counts.eventsNew}' + ' · дубликатов ${item.counts.eventsDuplicate}', + style: theme.textTheme.bodySmall, + ), + ), + if (item.isFailed && item.error != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(item.error!, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.error)), + ), + ], + ), + ), + trailing: const Icon(Icons.chevron_right), + ), + ); + } +} diff --git a/app/lib/features/imports/labels.dart b/app/lib/features/imports/labels.dart new file mode 100644 index 0000000..fe03859 --- /dev/null +++ b/app/lib/features/imports/labels.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +/// Russian labels for the stable string keys the import contract sends. +const brokerLabels = { + 'sber': 'Сбер', + 'vtb': 'ВТБ', + 'tinvest': 'Т-Инвестиции', + 'csv': 'CSV', +}; + +String brokerLabel(String? broker) => brokerLabels[broker] ?? broker ?? '—'; + +const parseStatusLabels = { + 'uploaded': 'загружен', + 'parsed': 'разобран', + 'committed': 'импортирован', + 'failed': 'ошибка', +}; + +String parseStatusLabel(String status) => parseStatusLabels[status] ?? status; + +/// The colour of a `parse_status` chip: only `failed` is an error, and only `committed` +/// means the events are actually in the ledger. +Color parseStatusColor(BuildContext context, String status) { + final scheme = Theme.of(context).colorScheme; + return switch (status) { + 'committed' => Colors.green, + 'failed' => scheme.error, + 'parsed' => scheme.primary, + _ => scheme.outline, + }; +} + +Widget parseStatusChip(BuildContext context, String status) { + final color = parseStatusColor(context, status); + return Chip( + visualDensity: VisualDensity.compact, + label: Text(parseStatusLabel(status)), + backgroundColor: color.withValues(alpha: 0.15), + labelStyle: TextStyle(color: color), + side: BorderSide(color: color.withValues(alpha: 0.4)), + ); +} + +String formatBytes(int? bytes) { + if (bytes == null) return ''; + if (bytes < 1024) return '$bytes Б'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).round()} КБ'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1).replaceAll('.', ',')} МБ'; +} diff --git a/app/lib/features/imports/providers.dart b/app/lib/features/imports/providers.dart new file mode 100644 index 0000000..be695dc --- /dev/null +++ b/app/lib/features/imports/providers.dart @@ -0,0 +1,35 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import '../events/providers.dart'; +import '../home/providers.dart'; +import '../portfolio/providers.dart'; +import 'data/imports_api.dart'; + +final importsApiProvider = Provider((ref) => ImportsApi(ref.watch(apiProvider).dio)); + +/// The list on `/imports`. Not auto-disposed by status: the filter is a separate provider so +/// changing it refetches without rebuilding the page state. +final importsStatusFilterProvider = StateProvider((ref) => null); + +final importsListProvider = FutureProvider.autoDispose>((ref) async { + final status = ref.watch(importsStatusFilterProvider); + return ref.watch(importsApiProvider).list(status: status); +}); + +/// A single import's preview. For an uncommitted import the server recomputes counts and +/// reconciliation on every read, so this is deliberately re-fetched rather than cached from +/// the list response. +final importPreviewProvider = + FutureProvider.autoDispose.family((ref, id) async { + return ref.watch(importsApiProvider).get(id); +}); + +/// Everything whose numbers change once events land in (or move inside) the ledger: +/// portfolio, holdings, allocation, the value series, the dashboard and the event list. +/// Called after a successful commit and after every pending-instrument resolve. +void invalidateLedgerDependents(WidgetRef ref) { + invalidatePortfolioProviders(ref); + invalidateHomeProviders(ref); + ref.invalidate(eventsControllerProvider); +} diff --git a/app/lib/features/imports/widgets/reconciliation_card.dart b/app/lib/features/imports/widgets/reconciliation_card.dart new file mode 100644 index 0000000..8b01f96 --- /dev/null +++ b/app/lib/features/imports/widgets/reconciliation_card.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + +import '../../../core/utils/ru_date.dart'; +import '../../../core/widgets/money_text.dart'; +import '../../portfolio/labels.dart' show formatQty; +import '../data/imports_api.dart'; + +/// Сверка: the report's own positions and cash balances against what the ledger derives. +/// +/// When everything matches this collapses to a single green line — a full table of zeroes +/// is noise. A mismatch is the whole point of the screen, so it stays expanded and red. +class ReconciliationCard extends StatelessWidget { + const ReconciliationCard({required this.reconciliation, super.key}); + + final Reconciliation reconciliation; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final asOf = reconciliation.asOf; + final title = asOf == null ? 'Сверка' : 'Сверка на ${ruDate(asOf)}'; + + if (reconciliation.isEmpty) { + return Card( + child: ListTile( + leading: Icon(Icons.remove_circle_outline, color: theme.colorScheme.outline), + title: Text(title), + subtitle: const Text('В отчёте нет остатков для сверки'), + ), + ); + } + + if (reconciliation.matches) { + return Card( + child: ListTile( + leading: const Icon(Icons.check_circle_outline, color: Colors.green), + title: Text(title), + subtitle: Text( + 'Всё сошлось: позиций ${reconciliation.positions.length}, ' + 'остатков ${reconciliation.cash.length}', + style: const TextStyle(color: Colors.green), + ), + ), + ); + } + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.error_outline, color: theme.colorScheme.error), + const SizedBox(width: 8), + Text(title, style: theme.textTheme.titleMedium), + ], + ), + const SizedBox(height: 4), + Text( + 'Отчёт и леджер разошлись. Импортировать можно, но расхождение стоит объяснить.', + style: theme.textTheme.bodySmall, + ), + if (reconciliation.positions.isNotEmpty) ...[ + const SizedBox(height: 12), + Text('Позиции', style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + _ScrollableTable( + columns: const ['Позиция', 'Из отчёта', 'Из леджера', 'Расхождение'], + rows: [ + for (final p in reconciliation.positions) + _Row( + cells: [ + p.title, + _qty(p.qtyReport), + _qty(p.qtyDerived), + _qty(p.qtyDelta), + ], + highlight: !p.matches, + ), + ], + ), + ], + if (reconciliation.cash.isNotEmpty) ...[ + const SizedBox(height: 12), + Text('Денежные остатки', style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + _ScrollableTable( + columns: const ['Валюта', 'Из отчёта', 'Из леджера', 'Расхождение'], + rows: [ + for (final c in reconciliation.cash) + _Row( + cells: [ + c.currency, + _money(c.balanceReport, c.currency), + _money(c.balanceDerived, c.currency), + _money(c.delta, c.currency), + ], + highlight: !c.matches, + ), + ], + ), + ], + ], + ), + ), + ); + } + + /// A quantity the ledger could not produce is an em dash, not a zero: an unknown position + /// and an empty one are different findings. + static String _qty(String? value) => + value == null || value.isEmpty ? '—' : formatQty(value); + + static String _money(String? value, String currency) => + value == null || value.isEmpty ? '—' : MoneyText.format(value, currency); +} + +class _Row { + const _Row({required this.cells, required this.highlight}); + final List cells; + final bool highlight; +} + +/// A narrow table that scrolls sideways rather than overflowing on a phone. +class _ScrollableTable extends StatelessWidget { + const _ScrollableTable({required this.columns, required this.rows}); + + final List columns; + final List<_Row> rows; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columnSpacing: 24, + headingRowHeight: 36, + dataRowMinHeight: 36, + dataRowMaxHeight: 48, + columns: [for (final c in columns) DataColumn(label: Text(c))], + rows: [ + for (final r in rows) + DataRow( + color: r.highlight + ? WidgetStatePropertyAll(theme.colorScheme.errorContainer.withValues(alpha: 0.4)) + : null, + cells: [ + for (final cell in r.cells) + DataCell(Text( + cell, + style: r.highlight + ? TextStyle(color: theme.colorScheme.error) + : null, + )), + ], + ), + ], + ), + ); + } +} diff --git a/app/lib/features/imports/widgets/sample_events_table.dart b/app/lib/features/imports/widgets/sample_events_table.dart new file mode 100644 index 0000000..2c8197f --- /dev/null +++ b/app/lib/features/imports/widgets/sample_events_table.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +import '../../../core/utils/ru_date.dart'; +import '../../../core/widgets/money_text.dart'; +import '../../portfolio/labels.dart' show eventKindLabels, formatQty; +import '../data/imports_api.dart'; + +/// The first lines of the report as the parser read them. A row already present in the +/// ledger (same `dedupe_key`) is marked: committing will update it, not add a second one. +class SampleEventsTable extends StatelessWidget { + const SampleEventsTable({required this.events, super.key}); + + final List events; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columnSpacing: 20, + headingRowHeight: 36, + dataRowMinHeight: 36, + dataRowMaxHeight: 52, + columns: const [ + DataColumn(label: Text('№')), + DataColumn(label: Text('Дата')), + DataColumn(label: Text('Тип')), + DataColumn(label: Text('Инструмент')), + DataColumn(label: Text('Кол-во')), + DataColumn(label: Text('Цена')), + DataColumn(label: Text('Сумма')), + DataColumn(label: Text('')), + ], + rows: [ + for (final e in events) + DataRow( + color: e.isDuplicate + ? WidgetStatePropertyAll( + theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.6)) + : null, + cells: [ + DataCell(Text('${e.lineNo}')), + DataCell(Text(e.tradeDate == null ? '—' : ruDate(e.tradeDate!))), + DataCell(Text(eventKindLabels[e.kind] ?? e.kind)), + DataCell( + Tooltip( + message: e.instrumentKey ?? '', + child: Text(e.instrumentName ?? e.instrumentKey ?? '—'), + ), + ), + DataCell(Text(e.quantity == null ? '—' : formatQty(e.quantity!))), + DataCell(Text(_money(e.price, e.currency))), + DataCell(Text(_money(e.amount, e.currency))), + DataCell(e.isDuplicate + ? Tooltip( + message: 'Такое событие уже есть в леджере', + child: Chip( + visualDensity: VisualDensity.compact, + label: const Text('дубль'), + backgroundColor: + theme.colorScheme.secondaryContainer.withValues(alpha: 0.8), + ), + ) + : const SizedBox.shrink()), + ], + ), + ], + ), + ); + } + + static String _money(String? value, String? currency) => + value == null || value.isEmpty ? '—' : MoneyText.format(value, currency ?? 'RUB'); +} diff --git a/app/lib/features/income/calendar_tab.dart b/app/lib/features/income/calendar_tab.dart new file mode 100644 index 0000000..c9ced33 --- /dev/null +++ b/app/lib/features/income/calendar_tab.dart @@ -0,0 +1,273 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/utils/ru_date.dart'; +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/money_text.dart'; +import '../../core/widgets/section_card.dart'; +import '../portfolio/labels.dart' show formatQty; +import 'data/income_api.dart'; +import 'labels.dart'; +import 'providers.dart'; + +/// Календарь: every expected payment, month by month, each row carrying its [BasisChip]. +/// +/// The total is deliberately paired with the by-basis split: adding «объявлено» and «по +/// истории» into one number turns a guess into a promise. +class IncomeCalendarTab extends ConsumerWidget { + const IncomeCalendarTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final calendar = ref.watch(incomeCalendarProvider); + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(incomeCalendarProvider), + child: AsyncValueView( + value: calendar, + onRetry: () => ref.invalidate(incomeCalendarProvider), + data: (data) => ListView( + padding: const EdgeInsets.all(16), + children: [ + const _CalendarControls(), + const SizedBox(height: 12), + _Totals(data: data), + const SizedBox(height: 16), + if (data.entries.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 48), + child: EmptyState( + icon: Icons.event_available_outlined, + message: 'Ожидаемых выплат в этом окне нет.\n' + 'Либо по бумагам нет объявленных выплат и истории, либо портфель пуст.', + ), + ) + else + for (final group in _groupByMonth(data.entries)) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _MonthCard(month: group.key, entries: group.value), + ), + ], + ), + ), + ); + } +} + +class _CalendarControls extends ConsumerWidget { + const _CalendarControls(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final months = ref.watch(calendarMonthsProvider); + final includePaid = ref.watch(calendarIncludePaidProvider); + return Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final m in const [3, 6, 12, 24]) + ChoiceChip( + label: Text('$m мес'), + selected: months == m, + onSelected: (_) => ref.read(calendarMonthsProvider.notifier).state = m, + ), + const SizedBox(width: 8), + FilterChip( + label: const Text('Показать выплаченные'), + selected: includePaid, + onSelected: (v) => ref.read(calendarIncludePaidProvider.notifier).state = v, + ), + ], + ); + } +} + +class _Totals extends StatelessWidget { + const _Totals({required this.data}); + + final IncomeCalendar data; + + @override + Widget build(BuildContext context) { + final byBasis = data.byBasis; + final guess = Decimal.tryParse(byBasis['history'] ?? '0') ?? Decimal.zero; + return SectionCard( + title: 'Ожидается', + subtitle: data.asOf == null ? null : 'на ${ruDate(data.asOf!)}', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MoneyText( + data.totalExpectedRub, + currency: data.currency, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 12), + if (byBasis.isEmpty) + Text( + 'Сервер не прислал разбивку по основанию — сумму нельзя трактовать как прогноз.', + style: Theme.of(context).textTheme.bodySmall, + ) + else + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + for (final e in _orderedBases(byBasis.keys)) + _BasisTotal(basis: e, amount: byBasis[e] ?? '0'), + ], + ), + if (guess > Decimal.zero) ...[ + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 16, color: basisColor('history')), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Из них ${MoneyText.format(byBasis['history']!, data.currency)} — ' + 'экстраполяция по истории выплат: этих выплат может не быть.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _BasisTotal extends StatelessWidget { + const _BasisTotal({required this.basis, required this.amount}); + + final String basis; + final String amount; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: basisDescription(basis), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + BasisChip(basis: basis), + const SizedBox(height: 4), + MoneyText(amount, currency: 'RUB', style: Theme.of(context).textTheme.titleSmall), + ], + ), + ); + } +} + +class _MonthCard extends StatelessWidget { + const _MonthCard({required this.month, required this.entries}); + + final DateTime month; + final List entries; + + @override + Widget build(BuildContext context) { + final total = entries + .map((e) => Decimal.tryParse(e.amountRub ?? '') ?? Decimal.zero) + .fold(Decimal.zero, (a, b) => a + b); + final unconverted = entries.where((e) => e.amountRub == null).length; + + return SectionCard( + // `_groupByMonth` parks dateless entries under a sentinel year rather than dropping + // them: money with an unknown date is still money. + title: month.year == 9999 ? 'Дата неизвестна' : ruMonthYear(month), + subtitle: unconverted == 0 + ? null + : 'у $unconverted выплат нет курса на дату — в сумму месяца не вошли', + trailing: MoneyText( + total.toString(), + currency: 'RUB', + style: Theme.of(context).textTheme.titleMedium, + ), + child: Column(children: [for (final e in entries) _EntryRow(entry: e)]), + ); + } +} + +class _EntryRow extends StatelessWidget { + const _EntryRow({required this.entry}); + + final IncomeEntry entry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final details = [ + incomeKindLabel(entry.kind), + if (entry.qty != null && entry.perUnit != null) + '${formatQty(entry.qty!)} × ${MoneyText.format(entry.perUnit!, entry.currency)}', + if (entry.recordDate != null) 'отсечка ${ruDate(entry.recordDate!)}', + if (entry.taxWithheld != null) + 'налог ${MoneyText.format(entry.taxWithheld!, entry.currency)}', + ].join(' · '); + + return ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + onTap: entry.instrumentId == null + ? null + : () => context.push('/portfolio/instrument/${entry.instrumentId}'), + title: Row( + children: [ + Flexible(child: Text(entry.title, overflow: TextOverflow.ellipsis)), + const SizedBox(width: 8), + BasisChip(basis: entry.basis), + ], + ), + subtitle: Text( + '${entry.expectedDate == null ? 'дата неизвестна' : ruDate(entry.expectedDate!)} · $details', + style: theme.textTheme.bodySmall, + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (entry.amount != null) + MoneyText(entry.amount!, currency: entry.currency, style: theme.textTheme.titleSmall), + // no FX rate for the date ⇒ no rouble figure. An em dash, never 0 ₽. + if (entry.currency != 'RUB') + Text( + entry.amountRub == null + ? '— ₽ (нет курса)' + : MoneyText.format(entry.amountRub!, 'RUB'), + style: theme.textTheme.bodySmall, + ), + ], + ), + ); + } +} + +/// Groups entries by month, preserving the server's order inside each month. Entries with +/// no date go last, under their own heading — they are still expected money. +List>> _groupByMonth(List entries) { + final groups = >{}; + for (final e in entries) { + final d = e.expectedDate; + final key = d == null ? DateTime.utc(9999) : DateTime.utc(d.year, d.month); + groups.putIfAbsent(key, () => []).add(e); + } + final keys = groups.keys.toList()..sort(); + return [for (final k in keys) MapEntry(k, groups[k]!)]; +} + +/// Contract order first, anything unexpected after it. +List _orderedBases(Iterable bases) { + const order = ['schedule', 'announced', 'history', 'paid']; + final set = bases.toSet(); + return [...order.where(set.contains), ...set.where((b) => !order.contains(b))]; +} diff --git a/app/lib/features/income/data/income_api.dart b/app/lib/features/income/data/income_api.dart new file mode 100644 index 0000000..1a976db --- /dev/null +++ b/app/lib/features/income/data/income_api.dart @@ -0,0 +1,255 @@ +/// Hand-written client for `/api/v1/income`. +/// +/// **Temporary.** The income routes are not in `openapi/openapi.json` yet, so the generated +/// package `app/packages/api_client` knows nothing about them. Models and calls here follow +/// `docs/ai/phase4-contract.md` §1 literally and are meant to be **replaced by the +/// generated client** as soon as the routes land in the spec and `just gen-client` runs. +/// +/// Raw Dio comes from `ref.read(apiProvider).dio`, which already carries the base URL, the +/// bearer header and the single transparent refresh on 401. +library; + +import 'package:dio/dio.dart'; + +import '../../../core/utils/json.dart'; + +/// One expected (or already paid) payment. +/// +/// [basis] is not decoration: `schedule` is arithmetic over a published schedule, +/// `announced` is a fact the issuer declared, `history` is an extrapolation that can be +/// wrong by any amount, and `paid` already happened. The screen must show it on every row. +class IncomeEntry { + const IncomeEntry({ + required this.kind, + required this.basis, + required this.currency, + this.instrumentId, + this.ticker, + this.name, + this.expectedDate, + this.recordDate, + this.qty, + this.perUnit, + this.amount, + this.amountRub, + this.taxWithheld, + }); + + final int? instrumentId; + final String? ticker; + final String? name; + + /// `dividend | coupon | amortization | repayment` — a plain string, like every stable key. + final String kind; + final DateTime? expectedDate; + final DateTime? recordDate; + final String? qty; + final String? perUnit; + final String? amount; + final String currency; + + /// Null when there is no FX rate for the date — not zero. + final String? amountRub; + + /// `schedule | announced | history | paid`. + final String basis; + final String? taxWithheld; + + String get title => ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId'); + + static IncomeEntry fromJson(Map json) => IncomeEntry( + instrumentId: asInt(json['instrument_id']), + ticker: asString(json['ticker']), + name: asString(json['name']), + kind: asString(json['kind']) ?? 'dividend', + expectedDate: asDate(json['expected_date']), + recordDate: asDate(json['record_date']), + qty: asString(json['qty']), + perUnit: asString(json['per_unit']), + amount: asString(json['amount']), + currency: asString(json['currency']) ?? 'RUB', + amountRub: asString(json['amount_rub']), + basis: asString(json['basis']) ?? 'history', + taxWithheld: asString(json['tax_withheld']), + ); +} + +class IncomeCalendar { + const IncomeCalendar({ + required this.totalExpectedRub, + required this.currency, + this.asOf, + this.entries = const [], + this.byBasis = const {}, + }); + + final DateTime? asOf; + final String currency; + final String totalExpectedRub; + final List entries; + final Map byBasis; + + static IncomeCalendar fromJson(Map json) => IncomeCalendar( + asOf: asDate(json['as_of']), + currency: asString(json['currency']) ?? 'RUB', + totalExpectedRub: asString(json['total_expected_rub']) ?? '0', + entries: asObjects(json['entries']).map(IncomeEntry.fromJson).toList(), + byBasis: asStringMap(json['by_basis']), + ); +} + +class IncomeHistoryRow { + const IncomeHistoryRow({ + required this.kind, + required this.currency, + required this.amount, + this.month, + this.amountRub, + this.taxWithheld, + this.paymentCount = 0, + }); + + final DateTime? month; + final String kind; + final String currency; + final String amount; + final String? amountRub; + final String? taxWithheld; + final int paymentCount; + + static IncomeHistoryRow fromJson(Map json) => IncomeHistoryRow( + month: asDate(json['month']), + kind: asString(json['kind']) ?? 'other', + currency: asString(json['currency']) ?? 'RUB', + amount: asString(json['amount']) ?? '0', + amountRub: asString(json['amount_rub']), + taxWithheld: asString(json['tax_withheld']), + paymentCount: asInt(json['payment_count']) ?? 0, + ); +} + +class IncomeHistory { + const IncomeHistory({ + this.rows = const [], + this.totalRub = '0', + this.taxWithheldRub = '0', + }); + + final List rows; + final String totalRub; + final String taxWithheldRub; + + static IncomeHistory fromJson(Map json) { + final totals = asObject(json['totals']) ?? const {}; + return IncomeHistory( + rows: asObjects(json['rows']).map(IncomeHistoryRow.fromJson).toList(), + totalRub: asString(totals['amount_rub']) ?? '0', + taxWithheldRub: asString(totals['tax_withheld_rub']) ?? '0', + ); + } +} + +class ForecastMonth { + const ForecastMonth({required this.amountRub, this.month, this.byBasis = const {}}); + + final DateTime? month; + final String amountRub; + + /// The split the total must never hide: a month made of `history` alone is a guess. + final Map byBasis; + + static ForecastMonth fromJson(Map json) => ForecastMonth( + month: asDate(json['month']), + amountRub: asString(json['amount_rub']) ?? '0', + byBasis: asStringMap(json['by_basis']), + ); +} + +class IncomeForecast { + const IncomeForecast({ + required this.totalRub, + this.months = const [], + this.annualYieldOnValue, + this.warnings = const [], + }); + + final List months; + final String totalRub; + + /// Null when the current value is unknown — shown as an em dash, never as 0 %. + final String? annualYieldOnValue; + final List warnings; + + /// Every basis present anywhere in the forecast, in contract order. + List get bases { + const order = ['schedule', 'announced', 'history', 'paid']; + final seen = {for (final m in months) ...m.byBasis.keys}; + return [ + ...order.where(seen.contains), + ...seen.where((b) => !order.contains(b)), + ]; + } + + static IncomeForecast fromJson(Map json) => IncomeForecast( + months: asObjects(json['months']).map(ForecastMonth.fromJson).toList(), + totalRub: asString(json['total_rub']) ?? '0', + annualYieldOnValue: asString(json['annual_yield_on_value']), + warnings: asStrings(json['warnings']), + ); +} + +class IncomeApi { + const IncomeApi(this._dio); + + final Dio _dio; + + static const _base = '/api/v1/income'; + + Future calendar({ + String scope = 'all', + DateTime? dateFrom, + DateTime? dateTo, + bool includePaid = false, + }) async { + final r = await _dio.get>('$_base/calendar', queryParameters: { + 'scope': scope, + 'date_from': ?_isoDate(dateFrom), + 'date_to': ?_isoDate(dateTo), + 'include_paid': includePaid, + }); + return IncomeCalendar.fromJson(r.data ?? const {}); + } + + Future history({ + String scope = 'all', + String group = 'month', + DateTime? dateFrom, + DateTime? dateTo, + String? kind, + }) async { + final r = await _dio.get>('$_base/history', queryParameters: { + 'scope': scope, + 'group': group, + 'date_from': ?_isoDate(dateFrom), + 'date_to': ?_isoDate(dateTo), + 'kind': ?kind, + }); + return IncomeHistory.fromJson(r.data ?? const {}); + } + + Future forecast({String scope = 'all', int months = 12}) async { + final r = await _dio.get>('$_base/forecast', queryParameters: { + 'scope': scope, + 'months': months, + }); + return IncomeForecast.fromJson(r.data ?? const {}); + } + + /// `format: date` on the wire. `DateQueryInterceptor` does this for the generated client; + /// this layer builds its query maps itself, so it truncates here. + static String? _isoDate(DateTime? d) => d == null + ? null + : '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; +} diff --git a/app/lib/features/income/forecast_tab.dart b/app/lib/features/income/forecast_tab.dart new file mode 100644 index 0000000..0839304 --- /dev/null +++ b/app/lib/features/income/forecast_tab.dart @@ -0,0 +1,337 @@ +import 'package:decimal/decimal.dart'; +import 'package:fl_chart/fl_chart.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/empty_state.dart'; +import '../../core/widgets/money_text.dart'; +import '../../core/widgets/section_card.dart'; +import '../portfolio/labels.dart' show formatPercent; +import 'data/income_api.dart'; +import 'labels.dart'; +import 'providers.dart'; + +/// Прогноз: expected income for the next N months, **always split by basis**. +/// +/// The stacked bar and the table both carry the split rather than the total alone: a month +/// built entirely out of `history` is an extrapolation, and a month built out of `announced` +/// is nearly a fact. One number cannot say which. +class IncomeForecastTab extends ConsumerWidget { + const IncomeForecastTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final forecast = ref.watch(incomeForecastProvider); + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(incomeForecastProvider), + child: AsyncValueView( + value: forecast, + onRetry: () => ref.invalidate(incomeForecastProvider), + data: (data) { + final bases = data.bases; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const _HorizonChips(), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + StatTile( + label: 'Ожидается всего', + value: MoneyText(data.totalRub, currency: 'RUB'), + note: 'за ${data.months.length} мес, до налога', + ), + StatTile( + label: 'Доходность к стоимости', + // null means "нет оценки стоимости" and must not read as 0 % + value: Text(formatPercent(data.annualYieldOnValue, signed: false)), + note: data.annualYieldOnValue == null + ? 'нет оценки текущей стоимости' + : 'ожидаемый доход / стоимость портфеля', + ), + for (final b in bases) + StatTile( + label: 'Основание: ${basisLabel(b)}', + value: MoneyText(_basisTotal(data, b).toString(), currency: 'RUB'), + note: basisDescription(b), + width: 220, + ), + ], + ), + if (data.warnings.isNotEmpty) ...[ + const SizedBox(height: 16), + _Warnings(warnings: data.warnings), + ], + const SizedBox(height: 16), + if (data.months.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 48), + child: EmptyState( + icon: Icons.insights_outlined, + message: 'Прогнозировать нечего: ни объявленных выплат, ни истории.', + ), + ) + else ...[ + SectionCard( + title: 'По месяцам', + subtitle: 'цвет столбца — основание прогноза', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _BasisLegend(bases: bases), + const SizedBox(height: 12), + SizedBox( + height: 220, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: (data.months.length * 52).toDouble().clamp(320, double.infinity), + child: _ForecastChart(months: data.months, bases: bases), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + SectionCard( + title: 'Разбивка по основанию', + child: _ForecastTable(months: data.months, bases: bases), + ), + ], + ], + ); + }, + ), + ); + } +} + +Decimal _basisTotal(IncomeForecast data, String basis) => data.months + .map((m) => Decimal.tryParse(m.byBasis[basis] ?? '0') ?? Decimal.zero) + .fold(Decimal.zero, (a, b) => a + b); + +class _HorizonChips extends ConsumerWidget { + const _HorizonChips(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final months = ref.watch(forecastMonthsProvider); + return Wrap( + spacing: 8, + children: [ + for (final m in const [6, 12, 24, 36]) + ChoiceChip( + label: Text('$m мес'), + selected: months == m, + onSelected: (_) => ref.read(forecastMonthsProvider.notifier).state = m, + ), + ], + ); + } +} + +class _Warnings extends StatelessWidget { + const _Warnings({required this.warnings}); + + final List warnings; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Card( + color: scheme.tertiaryContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final w in warnings) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_outlined, size: 16), + const SizedBox(width: 8), + Expanded(child: Text(w)), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _BasisLegend extends StatelessWidget { + const _BasisLegend({required this.bases}); + + final List bases; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 16, + runSpacing: 6, + children: [ + for (final b in bases) + Tooltip( + message: basisDescription(b), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: basisColor(b), shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text(basisLabel(b), style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ], + ); + } +} + +class _ForecastChart extends StatelessWidget { + const _ForecastChart({required this.months, required this.bases}); + + final List months; + final List bases; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final maxY = months.fold(0, (m, r) { + final v = (Decimal.tryParse(r.amountRub) ?? Decimal.zero).toDouble(); + return v > m ? v : m; + }); + + return BarChart( + BarChartData( + maxY: maxY == 0 ? 1 : maxY * 1.15, + gridData: const FlGridData(show: true, drawVerticalLine: false), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: true, reservedSize: 56), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (value, meta) { + final i = value.round(); + if (i < 0 || i >= months.length) return const SizedBox.shrink(); + if (months.length > 14 && i.isOdd) return const SizedBox.shrink(); + final m = months[i].month; + return Text( + m == null ? '—' : ruMonthYearShort(m), + style: theme.textTheme.bodySmall, + ); + }, + ), + ), + ), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, _, rod, _) { + final m = months[group.x]; + final parts = [ + for (final b in bases) + if ((Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero) > Decimal.zero) + '${basisLabel(b)}: ${MoneyText.format(m.byBasis[b]!, 'RUB')}', + ]; + return BarTooltipItem( + '${m.month == null ? '—' : ruMonthYearShort(m.month!)}\n' + '${MoneyText.format(m.amountRub, 'RUB')}' + '${parts.isEmpty ? '' : '\n${parts.join('\n')}'}', + theme.textTheme.bodySmall ?? const TextStyle(), + ); + }, + ), + ), + barGroups: [ + for (var i = 0; i < months.length; i++) + BarChartGroupData(x: i, barRods: [_stackedRod(months[i])]), + ], + ), + ); + } + + /// One rod per month, stacked by basis: the height is the month's total, the segments are + /// where that total came from. + BarChartRodData _stackedRod(ForecastMonth m) { + final stack = []; + var from = 0.0; + for (final b in bases) { + final v = (Decimal.tryParse(m.byBasis[b] ?? '0') ?? Decimal.zero).toDouble(); + if (v <= 0) continue; + stack.add(BarChartRodStackItem(from, from + v, basisColor(b))); + from += v; + } + final total = (Decimal.tryParse(m.amountRub) ?? Decimal.zero).toDouble(); + return BarChartRodData( + toY: from > 0 ? from : total, + rodStackItems: stack, + color: Colors.transparent, + width: 16, + borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), + ); + } +} + +class _ForecastTable extends StatelessWidget { + const _ForecastTable({required this.months, required this.bases}); + + final List months; + final List bases; + + @override + Widget build(BuildContext context) { + final headerStyle = Theme.of(context).textTheme.labelMedium; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columns: [ + DataColumn(label: Text('Месяц', style: headerStyle)), + for (final b in bases) + DataColumn( + label: Tooltip( + message: basisDescription(b), + child: Text(basisLabel(b), style: headerStyle), + ), + numeric: true, + ), + DataColumn(label: Text('Итого', style: headerStyle), numeric: true), + ], + rows: [ + for (final m in months) + DataRow( + cells: [ + DataCell(Text(m.month == null ? '—' : ruMonthYearShort(m.month!))), + for (final b in bases) + DataCell(MoneyText(m.byBasis[b] ?? '0', currency: 'RUB')), + DataCell(MoneyText( + m.amountRub, + currency: 'RUB', + style: Theme.of(context).textTheme.titleSmall, + )), + ], + ), + ], + ), + ); + } +} diff --git a/app/lib/features/income/history_tab.dart b/app/lib/features/income/history_tab.dart new file mode 100644 index 0000000..3cf32e4 --- /dev/null +++ b/app/lib/features/income/history_tab.dart @@ -0,0 +1,243 @@ +import 'package:decimal/decimal.dart'; +import 'package:fl_chart/fl_chart.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/empty_state.dart'; +import '../../core/widgets/money_text.dart'; +import '../../core/widgets/section_card.dart'; +import 'data/income_api.dart'; +import 'labels.dart'; +import 'providers.dart'; + +/// История: what was actually paid, by month and kind. Facts only — no basis column here, +/// because every row is `paid` by construction. +class IncomeHistoryTab extends ConsumerWidget { + const IncomeHistoryTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final history = ref.watch(incomeHistoryProvider); + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(incomeHistoryProvider), + child: AsyncValueView( + value: history, + onRetry: () => ref.invalidate(incomeHistoryProvider), + data: (data) { + if (data.rows.isEmpty) { + return ListView( + padding: const EdgeInsets.all(16), + children: const [ + _PeriodChips(), + SizedBox(height: 48), + EmptyState( + icon: Icons.history, + message: 'Выплат за период не было.', + ), + ], + ); + } + final months = _byMonth(data.rows); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const _PeriodChips(), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + StatTile( + label: 'Получено', + value: MoneyText(data.totalRub, currency: 'RUB'), + note: 'за выбранный период, до налога', + ), + StatTile( + label: 'Удержано налога', + value: MoneyText(data.taxWithheldRub, currency: 'RUB'), + note: 'по данным брокера', + ), + StatTile( + label: 'Месяцев с выплатами', + value: Text('${months.length}'), + note: 'строк в таблице: ${data.rows.length}', + ), + ], + ), + const SizedBox(height: 16), + SectionCard( + title: 'Выплаты по месяцам', + child: SizedBox( + height: 220, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + child: SizedBox( + width: (months.length * 44).toDouble().clamp(320, double.infinity), + child: _HistoryChart(months: months), + ), + ), + ), + ), + const SizedBox(height: 16), + SectionCard( + title: 'Помесячно', + child: _HistoryTable(rows: data.rows), + ), + ], + ); + }, + ), + ); + } +} + +class _PeriodChips extends ConsumerWidget { + const _PeriodChips(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final months = ref.watch(historyMonthsProvider); + return Wrap( + spacing: 8, + children: [ + for (final m in const [12, 24, 36, 120]) + ChoiceChip( + label: Text(m >= 120 ? 'Всё время' : '$m мес'), + selected: months == m, + onSelected: (_) => ref.read(historyMonthsProvider.notifier).state = m, + ), + ], + ); + } +} + +/// One bar per month, in rubles. Rows of several kinds inside a month are summed for the +/// chart; the breakdown by kind stays in the table below. +class _MonthTotal { + _MonthTotal(this.month, this.amountRub); + final DateTime month; + final Decimal amountRub; +} + +List<_MonthTotal> _byMonth(List rows) { + final sums = {}; + for (final r in rows) { + final m = r.month; + if (m == null) continue; + final key = DateTime.utc(m.year, m.month); + sums[key] = (sums[key] ?? Decimal.zero) + (Decimal.tryParse(r.amountRub ?? '') ?? Decimal.zero); + } + final keys = sums.keys.toList()..sort(); + return [for (final k in keys) _MonthTotal(k, sums[k]!)]; +} + +class _HistoryChart extends StatelessWidget { + const _HistoryChart({required this.months}); + + final List<_MonthTotal> months; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final maxY = months.fold(0, (m, r) { + final v = r.amountRub.toDouble(); + return v > m ? v : m; + }); + return BarChart( + BarChartData( + maxY: maxY == 0 ? 1 : maxY * 1.15, + gridData: const FlGridData(show: true, drawVerticalLine: false), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: true, reservedSize: 56), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (value, meta) { + final i = value.round(); + if (i < 0 || i >= months.length) return const SizedBox.shrink(); + if (months.length > 14 && i % 3 != 0) return const SizedBox.shrink(); + return Text(ruMonthYearShort(months[i].month), style: theme.textTheme.bodySmall); + }, + ), + ), + ), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, _, rod, _) => BarTooltipItem( + '${ruMonthYearShort(months[group.x].month)}\n' + '${MoneyText.format(months[group.x].amountRub.toString(), 'RUB')}', + theme.textTheme.bodySmall ?? const TextStyle(), + ), + ), + ), + barGroups: [ + for (var i = 0; i < months.length; i++) + BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: months[i].amountRub.toDouble(), + color: basisColor('paid'), + width: 12, + borderRadius: const BorderRadius.vertical(top: Radius.circular(2)), + ), + ], + ), + ], + ), + ); + } +} + +class _HistoryTable extends StatelessWidget { + const _HistoryTable({required this.rows}); + + final List rows; + + @override + Widget build(BuildContext context) { + final headerStyle = Theme.of(context).textTheme.labelMedium; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columns: [ + DataColumn(label: Text('Месяц', style: headerStyle)), + DataColumn(label: Text('Тип', style: headerStyle)), + DataColumn(label: Text('Валюта', style: headerStyle)), + DataColumn(label: Text('Сумма', style: headerStyle), numeric: true), + DataColumn(label: Text('В рублях', style: headerStyle), numeric: true), + DataColumn(label: Text('Налог', style: headerStyle), numeric: true), + DataColumn(label: Text('Выплат', style: headerStyle), numeric: true), + ], + rows: [ + for (final r in rows) + DataRow( + cells: [ + DataCell(Text(r.month == null ? '—' : ruMonthYearShort(r.month!))), + DataCell(Text(incomeKindLabel(r.kind))), + DataCell(Text(r.currency)), + DataCell(MoneyText(r.amount, currency: r.currency)), + DataCell(r.amountRub == null + ? const Text('—') + : MoneyText(r.amountRub!, currency: 'RUB')), + DataCell(r.taxWithheld == null + ? const Text('—') + : MoneyText(r.taxWithheld!, currency: r.currency)), + DataCell(Text('${r.paymentCount}')), + ], + ), + ], + ), + ); + } +} diff --git a/app/lib/features/income/income_page.dart b/app/lib/features/income/income_page.dart new file mode 100644 index 0000000..2485441 --- /dev/null +++ b/app/lib/features/income/income_page.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/widgets/scope_selector.dart'; +import 'calendar_tab.dart'; +import 'forecast_tab.dart'; +import 'history_tab.dart'; +import 'providers.dart'; + +/// Доходы: the dividend/coupon calendar, the paid history and the forecast — three views of +/// one question, so three tabs of one screen rather than three navigation destinations. +class IncomePage extends ConsumerWidget { + const IncomePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('Доходы'), + actions: [ + const ScopeSelector(), + IconButton( + tooltip: 'Обновить', + icon: const Icon(Icons.refresh), + onPressed: () => invalidateIncomeProviders(ref), + ), + ], + bottom: const TabBar( + tabs: [Tab(text: 'Календарь'), Tab(text: 'История'), Tab(text: 'Прогноз')], + ), + ), + body: const TabBarView( + children: [IncomeCalendarTab(), IncomeHistoryTab(), IncomeForecastTab()], + ), + ), + ); + } +} diff --git a/app/lib/features/income/labels.dart b/app/lib/features/income/labels.dart new file mode 100644 index 0000000..1c387c4 --- /dev/null +++ b/app/lib/features/income/labels.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/chart_colors.dart'; + +/// Russian labels for the stable keys the income routes send. + +const incomeKindLabels = { + 'dividend': 'Дивиденд', + 'coupon': 'Купон', + 'amortization': 'Амортизация', + 'repayment': 'Погашение', +}; + +String incomeKindLabel(String kind) => incomeKindLabels[kind] ?? kind; + +const basisLabels = { + 'schedule': 'по графику', + 'announced': 'объявлено', + 'history': 'по истории', + 'paid': 'выплачено', +}; + +/// What each basis actually promises. Shown as a tooltip and spelled out in the legend, +/// because the difference between «объявлено» and «по истории» is the difference between a +/// fact and a guess. +const basisDescriptions = { + 'schedule': 'Арифметика по опубликованному графику выплат эмитента.', + 'announced': 'Объявленный эмитентом факт: размер и дата известны.', + 'history': 'Экстраполяция по выплатам за последние 24 мес — может ошибаться ' + 'на любую величину, в том числе выплаты может не быть вовсе.', + 'paid': 'Уже получено.', +}; + +String basisLabel(String basis) => basisLabels[basis] ?? basis; + +String basisDescription(String basis) => basisDescriptions[basis] ?? basis; + +/// Fixed slot per basis — never cycled, so the same basis is the same colour on the +/// calendar, in the forecast legend and in the stacked bars. +Color basisColor(String basis) => switch (basis) { + 'schedule' => ChartColors.slot1Blue, + 'announced' => ChartColors.slot3Aqua, + 'history' => ChartColors.slot4Yellow, + 'paid' => ChartColors.slot5Magenta, + _ => ChartColors.slot2Orange, + }; + +/// The basis chip that has to sit on every calendar and forecast row. +class BasisChip extends StatelessWidget { + const BasisChip({required this.basis, super.key, this.dense = true}); + + final String basis; + final bool dense; + + @override + Widget build(BuildContext context) { + final color = basisColor(basis); + return Tooltip( + message: basisDescription(basis), + child: Container( + padding: EdgeInsets.symmetric(horizontal: dense ? 6 : 10, vertical: dense ? 1 : 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + border: Border.all(color: color.withValues(alpha: 0.5)), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + basisLabel(basis), + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color), + ), + ), + ); + } +} diff --git a/app/lib/features/income/providers.dart b/app/lib/features/income/providers.dart new file mode 100644 index 0000000..6cdafa7 --- /dev/null +++ b/app/lib/features/income/providers.dart @@ -0,0 +1,56 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import '../portfolio/providers.dart' show scopeProvider; +import 'data/income_api.dart'; + +final incomeApiProvider = Provider((ref) => IncomeApi(ref.watch(apiProvider).dio)); + +/// How far the calendar looks ahead, in months. 12 is the contract default. +final calendarMonthsProvider = StateProvider((ref) => 12); + +final calendarIncludePaidProvider = StateProvider((ref) => false); + +/// Доходы shares the portfolio-wide [scopeProvider]: switching the scope on Портфель must +/// not leave the income calendar showing a different portfolio. +final incomeCalendarProvider = FutureProvider.autoDispose((ref) async { + final scope = ref.watch(scopeProvider); + final months = ref.watch(calendarMonthsProvider); + final includePaid = ref.watch(calendarIncludePaidProvider); + final now = DateTime.now(); + return ref.watch(incomeApiProvider).calendar( + scope: scope, + dateFrom: DateTime(now.year, now.month, now.day), + dateTo: DateTime(now.year, now.month + months, now.day), + includePaid: includePaid, + ); +}); + +/// How far the history goes back, in months. +final historyMonthsProvider = StateProvider((ref) => 24); + +final incomeHistoryProvider = FutureProvider.autoDispose((ref) async { + final scope = ref.watch(scopeProvider); + final months = ref.watch(historyMonthsProvider); + final now = DateTime.now(); + return ref.watch(incomeApiProvider).history( + scope: scope, + dateFrom: DateTime(now.year, now.month - months + 1, 1), + dateTo: DateTime(now.year, now.month + 1, 0), + ); +}); + +final forecastMonthsProvider = StateProvider((ref) => 12); + +final incomeForecastProvider = FutureProvider.autoDispose((ref) async { + final scope = ref.watch(scopeProvider); + return ref + .watch(incomeApiProvider) + .forecast(scope: scope, months: ref.watch(forecastMonthsProvider)); +}); + +void invalidateIncomeProviders(WidgetRef ref) { + ref.invalidate(incomeCalendarProvider); + ref.invalidate(incomeHistoryProvider); + ref.invalidate(incomeForecastProvider); +} diff --git a/app/lib/features/pending/data/pending_api.dart b/app/lib/features/pending/data/pending_api.dart new file mode 100644 index 0000000..107e7ef --- /dev/null +++ b/app/lib/features/pending/data/pending_api.dart @@ -0,0 +1,212 @@ +/// Hand-written client for `/api/v1/instruments/pending`. +/// +/// **Temporary.** These routes do not exist in `openapi/openapi.json` yet, so +/// `app/packages/api_client` has no generated methods or models for them. The models and +/// calls here follow `docs/ai/import-contract.md` literally and are meant to be **deleted** +/// once the routes land in the spec and `just gen-client` regenerates the real client — +/// at that point the providers should switch to `getInstrumentsApi()` and these classes +/// should give way to the generated ones. +/// +/// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, the bearer header and the +/// one-shot refresh on 401 are already wired there, so this layer only shapes URLs and JSON. +library; + +import 'package:dio/dio.dart'; + +/// A report line whose instrument the server refused to guess. Mirrors +/// `PendingInstrumentOut`; the extra fields (`firstSeenFileId`, `createdAt`) are absent +/// from the copy embedded in `ImportPreview.pending_instruments`, hence nullable. +class PendingInstrument { + const PendingInstrument({ + required this.id, + required this.source, + required this.sourceKey, + required this.status, + this.isin, + this.ticker, + this.board, + this.name, + this.currency, + this.assetClassHint, + this.occurrences = 0, + this.sampleQuantity, + this.samplePrice, + this.instrumentId, + this.firstSeenFileId, + this.createdAt, + }); + + final int id; + final String source; + final String sourceKey; + final String status; + final String? isin; + final String? ticker; + final String? board; + final String? name; + final String? currency; + final String? assetClassHint; + final int occurrences; + final String? sampleQuantity; + final String? samplePrice; + final int? instrumentId; + final int? firstSeenFileId; + final DateTime? createdAt; + + /// The best human label available, never a guess about which instrument this is. + String get title { + final parts = [?ticker, ?name]; + if (parts.isNotEmpty) return parts.join(' · '); + return isin ?? sourceKey; + } + + static PendingInstrument fromJson(Map json) => PendingInstrument( + id: asInt(json['id'])!, + source: asString(json['source']) ?? '', + sourceKey: asString(json['source_key']) ?? '', + status: asString(json['status']) ?? 'pending', + isin: asString(json['isin']), + ticker: asString(json['ticker']), + board: asString(json['board']), + name: asString(json['name']), + currency: asString(json['currency']), + assetClassHint: asString(json['asset_class_hint']), + occurrences: asInt(json['occurrences']) ?? 0, + sampleQuantity: asString(json['sample_quantity']), + samplePrice: asString(json['sample_price']), + instrumentId: asInt(json['instrument_id']), + firstSeenFileId: asInt(json['first_seen_file_id']), + createdAt: asDate(json['created_at']), + ); +} + +/// What `POST /instruments/pending/{id}/resolve` reports back. +class PendingResolveResult { + const PendingResolveResult({ + required this.id, + required this.status, + this.instrumentId, + this.eventsBound = 0, + this.aliasCreated = false, + this.metricsRefreshed = false, + }); + + final int id; + final String status; + final int? instrumentId; + final int eventsBound; + final bool aliasCreated; + final bool metricsRefreshed; + + static PendingResolveResult fromJson(Map json) => PendingResolveResult( + id: asInt(json['id']) ?? 0, + status: asString(json['status']) ?? 'resolved', + instrumentId: asInt(json['instrument_id']), + eventsBound: asInt(json['events_bound']) ?? 0, + aliasCreated: json['alias_created'] == true, + metricsRefreshed: json['metrics_refreshed'] == true, + ); +} + +/// The body of `action: "create"` — the user's own answer, typed in by hand. +class NewInstrument { + const NewInstrument({ + required this.assetClass, + required this.name, + required this.currency, + this.isin, + this.ticker, + this.board, + this.lot, + }); + + /// A plain string on the wire: `AssetClass` is deliberately not exposed by the API + /// (`index` cannot be a Dart enum member — it collides with `Enum.index`). + final String assetClass; + final String name; + final String currency; + final String? isin; + final String? ticker; + final String? board; + final int? lot; + + Map toJson() => { + 'asset_class': assetClass, + 'name': name, + 'currency': currency, + if (isin != null && isin!.isNotEmpty) 'isin': isin, + if (ticker != null && ticker!.isNotEmpty) 'ticker': ticker, + if (board != null && board!.isNotEmpty) 'board': board, + if (lot != null) 'lot': lot, + }; +} + +/// The asset classes the contract allows, as wire strings. +const assetClassKeys = [ + 'share', + 'bond', + 'etf', + 'fund', + 'currency', + 'index', + 'deposit', + 'real_estate', + 'crypto', + 'custom', +]; + +class PendingApi { + const PendingApi(this._dio); + + final Dio _dio; + + static const _base = '/api/v1/instruments/pending'; + + Future> list({ + String status = 'pending', + int limit = 100, + int offset = 0, + }) async { + final r = await _dio.get>( + _base, + queryParameters: {'status': status, 'limit': limit, 'offset': offset}, + ); + return (r.data ?? const []) + .map((e) => PendingInstrument.fromJson(Map.from(e as Map))) + .toList(); + } + + Future link(int id, int instrumentId) => + _resolve(id, {'action': 'link', 'instrument_id': instrumentId}); + + Future create(int id, NewInstrument instrument) => + _resolve(id, {'action': 'create', 'instrument': instrument.toJson()}); + + Future ignore(int id) => _resolve(id, {'action': 'ignore'}); + + Future _resolve(int id, Map body) async { + final r = await _dio.post>('$_base/$id/resolve', data: body); + return PendingResolveResult.fromJson(r.data ?? const {}); + } +} + +// --- JSON coercion helpers, shared with the imports layer ------------------------------- +// +// The server sends money and quantities as strings and never as numbers; these helpers do +// not convert to `double` anywhere — a numeric JSON value (should one ever appear) is kept +// as its lossless string form and parsed into `Decimal` at the point of display. + +String? asString(Object? v) => v == null ? null : (v is String ? v : v.toString()); + +int? asInt(Object? v) => switch (v) { + null => null, + final int i => i, + final String s => int.tryParse(s), + _ => null, + }; + +DateTime? asDate(Object? v) { + final s = asString(v); + if (s == null || s.isEmpty) return null; + return DateTime.tryParse(s); +} diff --git a/app/lib/features/pending/pending_page.dart b/app/lib/features/pending/pending_page.dart new file mode 100644 index 0000000..65f8ccf --- /dev/null +++ b/app/lib/features/pending/pending_page.dart @@ -0,0 +1,277 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/money_text.dart'; +import '../imports/data/imports_api.dart' show importErrorMessage; +import '../imports/providers.dart' show invalidateLedgerDependents; +import '../portfolio/labels.dart' show assetClassLabel, formatQty; +import 'data/pending_api.dart'; +import 'providers.dart'; +import 'widgets/create_instrument_dialog.dart'; +import 'widgets/link_instrument_dialog.dart'; + +/// Нераспознанные инструменты: the queue of report lines whose instrument the server +/// refused to guess. Every row waits for an explicit decision — link, create or ignore. +class PendingInstrumentsPage extends ConsumerStatefulWidget { + const PendingInstrumentsPage({super.key}); + + @override + ConsumerState createState() => _PendingInstrumentsPageState(); +} + +class _PendingInstrumentsPageState extends ConsumerState { + int? _busyId; + + void _snack(String message) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + + Future _run(int id, Future Function() action) async { + setState(() => _busyId = id); + try { + final result = await action(); + if (!mounted) return; + ref.invalidate(pendingInstrumentsProvider); + ref.invalidate(pendingCountProvider); + // Events moved out of `pending`, so holdings, allocation and the event list changed. + invalidateLedgerDependents(ref); + _snack(result.status == 'ignored' + ? 'Строка помечена как «не инструмент»' + : 'Привязано событий: ${result.eventsBound}' + '${result.aliasCreated ? ', добавлен алиас' : ''}'); + } catch (e) { + if (!mounted) return; + _snack(importErrorMessage(e)); + } finally { + if (mounted) setState(() => _busyId = null); + } + } + + Future _link(PendingInstrument p) async { + final instrumentId = await showDialog( + context: context, + builder: (_) => LinkInstrumentDialog( + initialQuery: p.isin ?? p.ticker ?? p.name ?? '', + ), + ); + if (instrumentId == null || !mounted) return; + await _run(p.id, () => ref.read(pendingApiProvider).link(p.id, instrumentId)); + } + + Future _create(PendingInstrument p) async { + final instrument = await showDialog( + context: context, + builder: (_) => CreateInstrumentDialog(pending: p), + ); + if (instrument == null || !mounted) return; + await _run(p.id, () => ref.read(pendingApiProvider).create(p.id, instrument)); + } + + Future _ignore(PendingInstrument p) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Игнорировать строку?'), + content: Text('«${p.title}» больше не будет предлагаться к резолву. ' + 'Её события останутся без инструмента.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), child: const Text('Отмена')), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Игнорировать')), + ], + ), + ); + if (confirmed != true || !mounted) return; + await _run(p.id, () => ref.read(pendingApiProvider).ignore(p.id)); + } + + @override + Widget build(BuildContext context) { + final rows = ref.watch(pendingInstrumentsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Нераспознанные инструменты'), + actions: [ + IconButton( + tooltip: 'Обновить', + icon: const Icon(Icons.refresh), + onPressed: () { + ref.invalidate(pendingInstrumentsProvider); + ref.invalidate(pendingCountProvider); + }, + ), + ], + ), + body: RefreshIndicator( + onRefresh: () async => ref.invalidate(pendingInstrumentsProvider), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const _StatusFilter(), + const SizedBox(height: 8), + AsyncValueView( + value: rows, + onRetry: () => ref.invalidate(pendingInstrumentsProvider), + data: (items) => items.isEmpty + ? const Padding( + padding: EdgeInsets.only(top: 48), + child: EmptyState( + icon: Icons.check_circle_outline, + message: 'Нераспознанных инструментов нет.', + ), + ) + : Column( + children: [ + for (final p in items) + _PendingCard( + pending: p, + busy: _busyId == p.id, + onLink: () => _link(p), + onCreate: () => _create(p), + onIgnore: () => _ignore(p), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _StatusFilter extends ConsumerWidget { + const _StatusFilter(); + + static const _options = { + 'pending': 'Ждут решения', + 'resolved': 'Привязаны', + 'ignored': 'Игнорируются', + 'all': 'Все', + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final current = ref.watch(pendingStatusFilterProvider); + return Wrap( + spacing: 8, + children: [ + for (final e in _options.entries) + ChoiceChip( + label: Text(e.value), + selected: current == e.key, + onSelected: (_) => ref.read(pendingStatusFilterProvider.notifier).state = e.key, + ), + ], + ); + } +} + +class _PendingCard extends StatelessWidget { + const _PendingCard({ + required this.pending, + required this.busy, + required this.onLink, + required this.onCreate, + required this.onIgnore, + }); + + final PendingInstrument pending; + final bool busy; + final VoidCallback onLink; + final VoidCallback onCreate; + final VoidCallback onIgnore; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final p = pending; + final open = p.status == 'pending'; + + final facts = [ + if (p.isin != null) 'ISIN ${p.isin}', + if (p.ticker != null) 'тикер ${p.ticker}', + if (p.board != null) 'доска ${p.board}', + if (p.currency != null) p.currency!, + if (p.assetClassHint != null) 'в отчёте: ${assetClassLabel(p.assetClassHint)}', + ]; + final sample = [ + 'встречается ${p.occurrences} раз', + if (p.sampleQuantity != null) 'кол-во ${formatQty(p.sampleQuantity!)}', + if (p.samplePrice != null) + 'цена ${MoneyText.format(p.samplePrice!, p.currency ?? 'RUB')}', + ]; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text(p.title, style: theme.textTheme.titleMedium)), + if (!open) + Chip( + visualDensity: VisualDensity.compact, + label: Text(p.status == 'ignored' ? 'игнорируется' : 'привязан'), + ), + ], + ), + if (facts.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(facts.join(' · '), style: theme.textTheme.bodySmall), + ), + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(sample.join(' · '), style: theme.textTheme.bodySmall), + ), + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + 'источник ${p.source} · ключ ${p.sourceKey}' + '${p.firstSeenFileId != null ? ' · файл №${p.firstSeenFileId}' : ''}', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), + ), + ), + if (open) ...[ + const SizedBox(height: 12), + if (busy) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator(), + ) + else + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + FilledButton.tonalIcon( + onPressed: onLink, + icon: const Icon(Icons.link, size: 18), + label: const Text('Привязать к существующему'), + ), + OutlinedButton.icon( + onPressed: onCreate, + icon: const Icon(Icons.add, size: 18), + label: const Text('Создать новый'), + ), + TextButton.icon( + onPressed: onIgnore, + icon: const Icon(Icons.block, size: 18), + label: const Text('Игнорировать'), + ), + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/app/lib/features/pending/providers.dart b/app/lib/features/pending/providers.dart new file mode 100644 index 0000000..f83db87 --- /dev/null +++ b/app/lib/features/pending/providers.dart @@ -0,0 +1,34 @@ +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import 'data/pending_api.dart'; + +final pendingApiProvider = Provider((ref) => PendingApi(ref.watch(apiProvider).dio)); + +/// `pending | resolved | ignored | all` — the filter of the resolve screen. +final pendingStatusFilterProvider = StateProvider((ref) => 'pending'); + +final pendingInstrumentsProvider = + FutureProvider.autoDispose>((ref) async { + final status = ref.watch(pendingStatusFilterProvider); + return ref.watch(pendingApiProvider).list(status: status); +}); + +/// How many rows still await a decision — shown as a badge next to the import screen's link. +final pendingCountProvider = FutureProvider.autoDispose((ref) async { + final rows = await ref.watch(pendingApiProvider).list(status: 'pending'); + return rows.length; +}); + +/// Instrument search for the "link to an existing instrument" dialog. This one endpoint is +/// already in the generated client, so it goes through it rather than raw Dio. +final instrumentSearchProvider = + FutureProvider.autoDispose.family, String>((ref, query) async { + if (query.trim().length < 2) return const []; + final r = await ref + .watch(apiProvider) + .getInstrumentsApi() + .instrumentsList(q: query.trim(), limit: 25); + return r.data ?? const []; +}); diff --git a/app/lib/features/pending/widgets/create_instrument_dialog.dart b/app/lib/features/pending/widgets/create_instrument_dialog.dart new file mode 100644 index 0000000..853cf44 --- /dev/null +++ b/app/lib/features/pending/widgets/create_instrument_dialog.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; + +import '../../portfolio/labels.dart' show assetClassLabel; +import '../data/pending_api.dart'; + +/// The form behind `action: "create"`. Fields are prefilled from what the report literally +/// said about this line (ISIN, ticker, name, currency) — that is transcription, not a guess +/// about which instrument it is; the user still confirms every field before submitting. +class CreateInstrumentDialog extends StatefulWidget { + const CreateInstrumentDialog({required this.pending, super.key}); + + final PendingInstrument pending; + + @override + State createState() => _CreateInstrumentDialogState(); +} + +class _CreateInstrumentDialogState extends State { + final _formKey = GlobalKey(); + late final _isin = TextEditingController(text: widget.pending.isin ?? ''); + late final _ticker = TextEditingController(text: widget.pending.ticker ?? ''); + late final _board = TextEditingController(text: widget.pending.board ?? ''); + late final _name = TextEditingController(text: widget.pending.name ?? ''); + late final _currency = TextEditingController(text: widget.pending.currency ?? 'RUB'); + late final _lot = TextEditingController(text: '1'); + + /// `asset_class_hint` is what the report's own section said (e.g. the «Фонды» table), so + /// it is offered as the initial value of a control the user must still look at. + late String _assetClass = assetClassKeys.contains(widget.pending.assetClassHint) + ? widget.pending.assetClassHint! + : 'share'; + + @override + void dispose() { + _isin.dispose(); + _ticker.dispose(); + _board.dispose(); + _name.dispose(); + _currency.dispose(); + _lot.dispose(); + super.dispose(); + } + + void _submit() { + if (!_formKey.currentState!.validate()) return; + Navigator.of(context).pop(NewInstrument( + assetClass: _assetClass, + name: _name.text.trim(), + currency: _currency.text.trim().toUpperCase(), + isin: _isin.text.trim(), + ticker: _ticker.text.trim(), + board: _board.text.trim(), + lot: int.tryParse(_lot.text.trim()), + )); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Создать инструмент'), + content: SizedBox( + width: 480, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: _assetClass, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Класс актива'), + items: [ + for (final key in assetClassKeys) + DropdownMenuItem(value: key, child: Text('${assetClassLabel(key)} ($key)')), + ], + onChanged: (v) => setState(() => _assetClass = v ?? _assetClass), + ), + _field(_name, 'Название', required: true), + _field(_isin, 'ISIN'), + _field(_ticker, 'Тикер'), + _field(_board, 'Доска (TQBR, TQTF…)'), + _field(_currency, 'Валюта', required: true), + _field(_lot, 'Лот', keyboard: TextInputType.number, validator: (v) { + if (v == null || v.trim().isEmpty) return null; + return int.tryParse(v.trim()) == null ? 'Целое число' : null; + }), + ], + ), + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Отмена')), + FilledButton(onPressed: _submit, child: const Text('Создать и привязать')), + ], + ); + } + + Widget _field( + TextEditingController controller, + String label, { + bool required = false, + TextInputType? keyboard, + String? Function(String?)? validator, + }) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: TextFormField( + controller: controller, + keyboardType: keyboard, + decoration: InputDecoration(labelText: label, isDense: true), + validator: validator ?? + (required + ? (v) => (v == null || v.trim().isEmpty) ? 'Обязательное поле' : null + : null), + ), + ); + } +} diff --git a/app/lib/features/pending/widgets/link_instrument_dialog.dart b/app/lib/features/pending/widgets/link_instrument_dialog.dart new file mode 100644 index 0000000..0d200a5 --- /dev/null +++ b/app/lib/features/pending/widgets/link_instrument_dialog.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:fintracker_api/fintracker_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../portfolio/labels.dart' show assetClassLabel; +import '../providers.dart'; + +/// Search-and-pick over `GET /instruments?q=`. Deliberately dumb: it shows what the search +/// returned and nothing else — no "probably this one" pre-selection, no highlighted best +/// guess. The binding is the user's statement, not the app's inference. +class LinkInstrumentDialog extends ConsumerStatefulWidget { + const LinkInstrumentDialog({required this.initialQuery, super.key}); + + /// Prefilled search text (the ISIN or ticker from the report). It only fills the search + /// box — nothing is selected until the user taps a row. + final String initialQuery; + + @override + ConsumerState createState() => _LinkInstrumentDialogState(); +} + +class _LinkInstrumentDialogState extends ConsumerState { + late final TextEditingController _controller = + TextEditingController(text: widget.initialQuery); + String _query = ''; + Timer? _debounce; + + @override + void initState() { + super.initState(); + _query = widget.initialQuery; + } + + @override + void dispose() { + _debounce?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _onChanged(String value) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (mounted) setState(() => _query = value); + }); + } + + @override + Widget build(BuildContext context) { + final results = ref.watch(instrumentSearchProvider(_query)); + return AlertDialog( + title: const Text('Привязать к инструменту'), + content: SizedBox( + width: 520, + height: 420, + child: Column( + children: [ + TextField( + controller: _controller, + autofocus: true, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: 'Тикер, ISIN или название', + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: _onChanged, + ), + const SizedBox(height: 12), + Expanded( + child: results.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('$e')), + data: (rows) { + if (_query.trim().length < 2) { + return const Center(child: Text('Введите минимум 2 символа')); + } + if (rows.isEmpty) { + return const Center(child: Text('Ничего не найдено')); + } + return ListView.builder( + itemCount: rows.length, + itemBuilder: (context, i) => _row(rows[i]), + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + ], + ); + } + + Widget _row(InstrumentOut instrument) { + final subtitle = [ + assetClassLabel(instrument.assetClass), + if (instrument.isin != null) instrument.isin!, + if (instrument.board != null) instrument.board!, + instrument.currency, + ].join(' · '); + return ListTile( + dense: true, + title: Text([ + if (instrument.ticker != null) instrument.ticker!, + instrument.name, + ].join(' · ')), + subtitle: Text(subtitle), + onTap: () => Navigator.of(context).pop(instrument.id), + ); + } +} diff --git a/app/lib/features/portfolio/benchmarks_card.dart b/app/lib/features/portfolio/benchmarks_card.dart new file mode 100644 index 0000000..b99560d --- /dev/null +++ b/app/lib/features/portfolio/benchmarks_card.dart @@ -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((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>((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), + ], + ), + ); + } +} diff --git a/app/lib/features/portfolio/data/benchmarks_api.dart b/app/lib/features/portfolio/data/benchmarks_api.dart new file mode 100644 index 0000000..f82a066 --- /dev/null +++ b/app/lib/features/portfolio/data/benchmarks_api.dart @@ -0,0 +1,106 @@ +/// Hand-written client for `GET /api/v1/analytics/benchmarks`. +/// +/// **Temporary.** The benchmark routes are not in `openapi/openapi.json` yet, so +/// `app/packages/api_client` has no generated models or methods for them. Everything here +/// follows `docs/ai/phase4-contract.md` §3 literally and is meant to be **replaced by the +/// generated client** once the routes land in the spec and `just gen-client` runs. +/// +/// Raw Dio comes from `ref.read(apiProvider).dio`. +library; + +import 'package:dio/dio.dart'; + +import '../../../core/utils/json.dart'; + +/// One benchmark inside one period row. +class BenchmarkResult { + const BenchmarkResult({ + required this.benchmarkId, + required this.code, + required this.kind, + this.twr, + this.twrAnnualized, + this.daysSkipped = 0, + this.excess, + }); + + final int benchmarkId; + final String code; + + /// `total_return | price`. A **price** index does not include dividends and therefore + /// systematically understates what a holder earned — comparing against it without saying + /// so is misleading, so the UI marks it. + final String kind; + final String? twr; + final String? twrAnnualized; + final int daysSkipped; + + /// `portfolio_twr - twr`, as the server computed it. + final String? excess; + + bool get isPriceIndex => kind == 'price'; + + static BenchmarkResult fromJson(Map json) => BenchmarkResult( + benchmarkId: asInt(json['benchmark_id']) ?? 0, + code: asString(json['code']) ?? '—', + kind: asString(json['kind']) ?? 'total_return', + twr: asString(json['twr']), + twrAnnualized: asString(json['twr_annualized']), + daysSkipped: asInt(json['days_skipped']) ?? 0, + excess: asString(json['excess']), + ); +} + +class BenchmarkRow { + const BenchmarkRow({ + required this.period, + this.dateFrom, + this.dateTo, + this.portfolioTwr, + this.portfolioTwrAnnualized, + this.portfolioDaysSkipped = 0, + this.benchmarks = const [], + }); + + final String period; + final DateTime? dateFrom; + final DateTime? dateTo; + final String? portfolioTwr; + final String? portfolioTwrAnnualized; + + /// Days the portfolio series had to skip. Non-zero on either side means the two returns + /// were not computed over the same set of days — not a like-for-like comparison. + final int portfolioDaysSkipped; + final List benchmarks; + + bool get hasSkippedDays => + portfolioDaysSkipped > 0 || benchmarks.any((b) => b.daysSkipped > 0); + + static BenchmarkRow fromJson(Map json) => BenchmarkRow( + period: asString(json['period']) ?? 'all', + dateFrom: asDate(json['date_from']), + dateTo: asDate(json['date_to']), + portfolioTwr: asString(json['portfolio_twr']), + portfolioTwrAnnualized: asString(json['portfolio_twr_annualized']), + portfolioDaysSkipped: asInt(json['portfolio_days_skipped']) ?? 0, + benchmarks: asObjects(json['benchmarks']).map(BenchmarkResult.fromJson).toList(), + ); +} + +class BenchmarksApi { + const BenchmarksApi(this._dio); + + final Dio _dio; + + Future> compare({ + String scope = 'all', + List periods = const ['1m', 'ytd', '1y', 'all'], + }) async { + final r = await _dio.get>( + '/api/v1/analytics/benchmarks', + // `period` is repeatable; Dio serialises a list as repeated query parameters. + queryParameters: {'scope': scope, 'period': periods}, + ); + return asObjects((r.data ?? const {})['rows']).map(BenchmarkRow.fromJson).toList(); + } +} diff --git a/app/lib/features/portfolio/holdings_tab.dart b/app/lib/features/portfolio/holdings_tab.dart index 171c4fc..6ca1bf0 100644 --- a/app/lib/features/portfolio/holdings_tab.dart +++ b/app/lib/features/portfolio/holdings_tab.dart @@ -10,6 +10,7 @@ import '../../core/utils/ru_date.dart'; import '../../core/widgets/async_value_view.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/money_text.dart'; +import 'benchmarks_card.dart'; import 'labels.dart'; import 'providers.dart'; @@ -59,6 +60,11 @@ class HoldingsTab extends ConsumerWidget { ), ), const SizedBox(height: 16), + const _Card( + title: 'Сравнение с бенчмарками', + child: BenchmarksCard(), + ), + const SizedBox(height: 16), _Card( title: 'Позиции', child: AsyncValueView( diff --git a/app/lib/features/portfolio/providers.dart b/app/lib/features/portfolio/providers.dart index 14cd272..d3eeade 100644 --- a/app/lib/features/portfolio/providers.dart +++ b/app/lib/features/portfolio/providers.dart @@ -2,6 +2,7 @@ import 'package:fintracker_api/fintracker_api.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api/api_client.dart'; +import 'benchmarks_card.dart' show benchmarkRowsProvider; /// The reporting unit every portfolio screen is scoped to: `all`, `account:` or /// `portfolio:`. Held in one place so switching it on Позиции also switches Аллокация @@ -67,4 +68,6 @@ void invalidatePortfolioProviders(WidgetRef ref) { ref.invalidate(allocationProvider); ref.invalidate(valueSeriesProvider); ref.invalidate(instrumentProvider); + // the benchmark block lives on Позиции and is scoped the same way + ref.invalidate(benchmarkRowsProvider); } diff --git a/app/lib/features/rebalance/data/rebalance_api.dart b/app/lib/features/rebalance/data/rebalance_api.dart new file mode 100644 index 0000000..a605c38 --- /dev/null +++ b/app/lib/features/rebalance/data/rebalance_api.dart @@ -0,0 +1,245 @@ +/// Hand-written client for `/api/v1/portfolios/{id}/targets` and `/rebalance`. +/// +/// **Temporary.** These routes are not in `openapi/openapi.json` yet, so +/// `app/packages/api_client` has no generated methods or models for them. Everything here +/// follows `docs/ai/phase4-contract.md` §2 literally and is meant to be **replaced by the +/// generated client** once the routes land in the spec and `just gen-client` runs. +/// +/// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, bearer header and the one-shot +/// refresh on 401 are already wired there. +library; + +import 'package:decimal/decimal.dart'; +import 'package:dio/dio.dart'; + +import '../../../core/utils/json.dart'; + +/// The dimensions targets can be set along, as wire strings (`AssetClass` is never exposed +/// as an enum — `index` cannot be a Dart enum member). +const targetDimensions = ['asset_class', 'sector', 'country', 'currency']; + +class TargetWeight { + const TargetWeight({ + required this.bucket, + required this.targetWeight, + this.band, + this.note, + }); + + final String bucket; + + /// A share, not a percent: `"0.60"` is 60 %. + final String targetWeight; + final String? band; + final String? note; + + TargetWeight copyWith({String? bucket, String? targetWeight, String? band, String? note}) => + TargetWeight( + bucket: bucket ?? this.bucket, + targetWeight: targetWeight ?? this.targetWeight, + band: band ?? this.band, + note: note ?? this.note, + ); + + Map toJson() => { + 'bucket': bucket, + 'target_weight': targetWeight, + 'band': ?band, + 'note': ?note, + }; + + static TargetWeight fromJson(Map json) => TargetWeight( + bucket: asString(json['bucket']) ?? '', + targetWeight: asString(json['target_weight']) ?? '0', + band: asString(json['band']), + note: asString(json['note']), + ); +} + +class TargetSet { + const TargetSet({required this.dimension, this.targets = const [], this.weightsSum}); + + final String dimension; + final List targets; + + /// What the server computed. The client computes its own sum too — the user must see the + /// problem before pressing Save, not after the 422 comes back. + final String? weightsSum; + + /// Exact sum of the weights as typed. `Decimal`, never `double`: 0.1 + 0.2 in binary + /// floating point would make a perfectly valid set look broken. + Decimal get localSum => sumDecimals(targets.map((t) => t.targetWeight)); + + /// The contract's tolerance: the sum must be 1 within 0.0001. + bool get sumIsValid => (localSum - Decimal.one).abs() <= Decimal.parse('0.0001'); + + Map toJson() => { + 'dimension': dimension, + 'targets': [for (final t in targets) t.toJson()], + }; + + static TargetSet fromJson(Map json) => TargetSet( + dimension: asString(json['dimension']) ?? 'asset_class', + targets: asObjects(json['targets']).map(TargetWeight.fromJson).toList(), + weightsSum: asString(json['weights_sum']), + ); +} + +class RebalanceTrade { + const RebalanceTrade({ + required this.action, + required this.blockedByCash, + this.instrumentId, + this.ticker, + this.name, + this.suggestedQty, + this.lot, + this.price, + this.priceCurrency, + this.amountRub, + }); + + final int? instrumentId; + final String? ticker; + final String? name; + + /// `buy | sell`. + final String action; + + /// Whole lots, always inside the available cash. **Null means there is no price** — the + /// screen shows an em dash and the reason, never 0. + final String? suggestedQty; + final int? lot; + final String? price; + final String? priceCurrency; + final String? amountRub; + + /// The quantity was cut down because the cash ran out. Without this flag a user cannot + /// tell an underweight recommendation from a wrong one. + final bool blockedByCash; + + String get title => ticker ?? name ?? (instrumentId == null ? '—' : '#$instrumentId'); + + static RebalanceTrade fromJson(Map json) => RebalanceTrade( + instrumentId: asInt(json['instrument_id']), + ticker: asString(json['ticker']), + name: asString(json['name']), + action: asString(json['action']) ?? 'buy', + suggestedQty: asString(json['suggested_qty']), + lot: asInt(json['lot']), + price: asString(json['price']), + priceCurrency: asString(json['price_currency']), + amountRub: asString(json['amount_rub']), + blockedByCash: asBool(json['blocked_by_cash']), + ); +} + +class RebalanceBucket { + const RebalanceBucket({ + required this.bucket, + required this.withinBand, + this.currentValueRub, + this.currentWeight, + this.targetWeight, + this.drift, + this.deltaValueRub, + this.trades = const [], + }); + + final String bucket; + final String? currentValueRub; + final String? currentWeight; + final String? targetWeight; + + /// `current - target`, in shares. + final String? drift; + + /// `|drift| <= band` — no action needed, and saying so is the point. + final bool withinBand; + final String? deltaValueRub; + final List trades; + + static RebalanceBucket fromJson(Map json) => RebalanceBucket( + bucket: asString(json['bucket']) ?? '', + currentValueRub: asString(json['current_value_rub']), + currentWeight: asString(json['current_weight']), + targetWeight: asString(json['target_weight']), + drift: asString(json['drift']), + withinBand: asBool(json['within_band']), + deltaValueRub: asString(json['delta_value_rub']), + trades: asObjects(json['trades']).map(RebalanceTrade.fromJson).toList(), + ); +} + +class RebalancePlan { + const RebalancePlan({ + required this.portfolioId, + required this.dimension, + this.asOf, + this.totalValueRub, + this.cashAvailableRub, + this.buckets = const [], + this.warnings = const [], + }); + + final int portfolioId; + final String dimension; + final DateTime? asOf; + final String? totalValueRub; + final String? cashAvailableRub; + final List buckets; + final List warnings; + + bool get everythingWithinBand => buckets.isNotEmpty && buckets.every((b) => b.withinBand); + + static RebalancePlan fromJson(Map json) => RebalancePlan( + portfolioId: asInt(json['portfolio_id']) ?? 0, + dimension: asString(json['dimension']) ?? 'asset_class', + asOf: asDate(json['as_of']), + totalValueRub: asString(json['total_value_rub']), + cashAvailableRub: asString(json['cash_available_rub']), + buckets: asObjects(json['buckets']).map(RebalanceBucket.fromJson).toList(), + warnings: asStrings(json['warnings']), + ); +} + +class RebalanceApi { + const RebalanceApi(this._dio); + + final Dio _dio; + + static const _base = '/api/v1/portfolios'; + + /// The contract does not spell out a query parameter for `GET .../targets`, but `PUT` + /// is per-dimension, so the set has to be addressable per-dimension as well. Sending + /// `dimension` is harmless for a server that ignores it and necessary for one that does + /// not — revisit once the route is in the spec. + Future getTargets(int portfolioId, {String dimension = 'asset_class'}) async { + final r = await _dio.get>( + '$_base/$portfolioId/targets', + queryParameters: {'dimension': dimension}, + ); + return TargetSet.fromJson(r.data ?? const {}); + } + + /// Full replacement of one dimension — partial updates are not supported by the contract. + Future putTargets(int portfolioId, TargetSet set) async { + final r = await _dio.put>( + '$_base/$portfolioId/targets', + data: set.toJson(), + ); + return TargetSet.fromJson(r.data ?? const {}); + } + + Future plan( + int portfolioId, { + String dimension = 'asset_class', + String? cashAvailable, + }) async { + final r = await _dio.get>( + '$_base/$portfolioId/rebalance', + queryParameters: {'dimension': dimension, 'cash_available': ?cashAvailable}, + ); + return RebalancePlan.fromJson(r.data ?? const {}); + } +} diff --git a/app/lib/features/rebalance/labels.dart b/app/lib/features/rebalance/labels.dart new file mode 100644 index 0000000..b155062 --- /dev/null +++ b/app/lib/features/rebalance/labels.dart @@ -0,0 +1,14 @@ +import '../portfolio/labels.dart' show assetClassLabels; + +/// Bucket labels keyed by the **wire** dimension string. +/// +/// `features/portfolio/labels.dart` already has `bucketLabel`, but it takes the generated +/// `AllocationDimension` enum, and the rebalance routes are not in the spec yet — this +/// layer only has the plain string. Once `just gen-client` produces the enum, this helper +/// should give way to the existing one. +String bucketLabelForKey(String dimension, String bucket) { + if (bucket == 'cash') return 'Денежные средства'; + if (bucket == 'unknown') return 'Не указано'; + if (bucket.isEmpty) return '—'; + return dimension == 'asset_class' ? (assetClassLabels[bucket] ?? bucket) : bucket; +} diff --git a/app/lib/features/rebalance/plan_tab.dart b/app/lib/features/rebalance/plan_tab.dart new file mode 100644 index 0000000..edf4086 --- /dev/null +++ b/app/lib/features/rebalance/plan_tab.dart @@ -0,0 +1,343 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/utils/ru_date.dart'; +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/money_text.dart'; +import '../../core/widgets/section_card.dart'; +import '../portfolio/labels.dart' show formatQty, signColor; +import 'data/rebalance_api.dart'; +import 'labels.dart'; +import 'providers.dart'; +import 'weights.dart'; + +/// Рекомендации: what to buy and sell to get back to the target weights. +/// +/// Three things are marked explicitly, because without them the numbers mislead: +/// `within_band` (no action needed — the drift is inside the corridor the user set), +/// `blocked_by_cash` (the quantity was cut because the cash ran out), and a null +/// `suggested_qty` (there is no price, so no quantity can be computed — an em dash, not 0). +class RebalancePlanTab extends ConsumerWidget { + const RebalancePlanTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final plan = ref.watch(rebalancePlanProvider); + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(rebalancePlanProvider), + child: AsyncValueView( + value: plan, + onRetry: () => ref.invalidate(rebalancePlanProvider), + data: (data) { + if (data == null) { + return const EmptyState( + icon: Icons.balance, + message: 'Портфель не выбран.', + ); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _Header(plan: data), + if (data.warnings.isNotEmpty) ...[ + const SizedBox(height: 12), + _Warnings(warnings: data.warnings), + ], + const SizedBox(height: 16), + if (data.buckets.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 32), + child: EmptyState( + icon: Icons.balance, + message: 'Нечего показать: целевые веса по этому измерению не заданы.', + ), + ) + else if (data.everythingWithinBand) + Card( + color: Theme.of(context).colorScheme.secondaryContainer, + child: const ListTile( + leading: Icon(Icons.check_circle_outline), + title: Text('Все группы внутри коридора'), + subtitle: Text('Действий не требуется — отклонения меньше заданного допуска.'), + ), + ), + const SizedBox(height: 8), + for (final b in data.buckets) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _BucketCard(dimension: data.dimension, bucket: b), + ), + ], + ); + }, + ), + ); + } +} + +class _Header extends ConsumerWidget { + const _Header({required this.plan}); + + final RebalancePlan plan; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final whatIf = ref.watch(whatIfCashProvider); + return SectionCard( + title: 'Портфель', + subtitle: plan.asOf == null ? null : 'на ${ruDate(plan.asOf!)}', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 24, + runSpacing: 8, + children: [ + _Figure( + label: 'Стоимость', + child: plan.totalValueRub == null + ? const Text('—') + : MoneyText(plan.totalValueRub!, currency: 'RUB'), + ), + _Figure( + label: whatIf == null ? 'Доступно денег' : 'Доступно денег (what-if)', + child: plan.cashAvailableRub == null + ? const Text('—') + : MoneyText(plan.cashAvailableRub!, currency: 'RUB'), + ), + ], + ), + const SizedBox(height: 12), + _WhatIfCashField(current: whatIf), + ], + ), + ); + } +} + +class _Figure extends StatelessWidget { + const _Figure({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: theme.textTheme.bodySmall), + DefaultTextStyle(style: theme.textTheme.titleMedium!, child: child), + ], + ); + } +} + +class _WhatIfCashField extends ConsumerStatefulWidget { + const _WhatIfCashField({required this.current}); + + final String? current; + + @override + ConsumerState<_WhatIfCashField> createState() => _WhatIfCashFieldState(); +} + +class _WhatIfCashFieldState extends ConsumerState<_WhatIfCashField> { + late final TextEditingController _controller = + TextEditingController(text: widget.current ?? ''); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _apply() { + final text = _controller.text.trim().replaceAll(',', '.').replaceAll(' ', ''); + ref.read(whatIfCashProvider.notifier).state = text.isEmpty ? null : text; + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: 220, + child: TextField( + controller: _controller, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Считать по другой сумме денег, ₽', + isDense: true, + helperText: 'пусто — реальный остаток на счетах', + ), + onSubmitted: (_) => _apply(), + ), + ), + const SizedBox(width: 12), + TextButton(onPressed: _apply, child: const Text('Пересчитать')), + ], + ); + } +} + +class _Warnings extends StatelessWidget { + const _Warnings({required this.warnings}); + + final List warnings; + + @override + Widget build(BuildContext context) { + return Card( + color: Theme.of(context).colorScheme.tertiaryContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final w in warnings) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_outlined, size: 16), + const SizedBox(width: 8), + Expanded(child: Text(w)), + ], + ), + ], + ), + ), + ); + } +} + +class _BucketCard extends StatelessWidget { + const _BucketCard({required this.dimension, required this.bucket}); + + final String dimension; + final RebalanceBucket bucket; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SectionCard( + title: bucketLabelForKey(dimension, bucket.bucket), + subtitle: 'сейчас ${formatShareAsPercent(bucket.currentWeight)} · ' + 'цель ${formatShareAsPercent(bucket.targetWeight)} · ' + 'отклонение ${formatShareAsPercent(bucket.drift, signed: true)}', + trailing: bucket.withinBand + ? const _WithinBandChip() + : (bucket.deltaValueRub == null + ? const Text('—') + : MoneyText( + bucket.deltaValueRub!, + currency: 'RUB', + style: TextStyle(color: signColor(context, bucket.deltaValueRub)), + )), + child: bucket.withinBand + ? Text( + 'Внутри коридора — сделок не требуется.', + style: theme.textTheme.bodySmall, + ) + : bucket.trades.isEmpty + ? Text( + 'Сервер не предложил сделок по этой группе: ' + 'вероятно, у подходящих бумаг нет цены — см. предупреждения выше.', + style: theme.textTheme.bodySmall, + ) + : Column(children: [for (final t in bucket.trades) TradeRow(trade: t)]), + ); + } +} + +class _WithinBandChip extends StatelessWidget { + const _WithinBandChip(); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Tooltip( + message: 'Отклонение не выходит за допуск (band) — действий не требуется', + child: Chip( + avatar: const Icon(Icons.check, size: 16), + label: const Text('в коридоре'), + backgroundColor: scheme.secondaryContainer, + visualDensity: VisualDensity.compact, + ), + ); + } +} + +/// One suggested trade. Public (unlike its neighbours) so the widget test can render the +/// `suggested_qty == null` case without standing up a provider container. +class TradeRow extends StatelessWidget { + const TradeRow({required this.trade, super.key}); + + final RebalanceTrade trade; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isBuy = trade.action == 'buy'; + final noQty = trade.suggestedQty == null; + + final details = [ + if (trade.lot != null) 'лот ${trade.lot}', + if (trade.price != null) + 'цена ${MoneyText.format(trade.price!, trade.priceCurrency ?? 'RUB')}', + ].join(' · '); + + return ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + onTap: trade.instrumentId == null + ? null + : () => context.push('/portfolio/instrument/${trade.instrumentId}'), + leading: Icon( + isBuy ? Icons.add_circle_outline : Icons.remove_circle_outline, + color: isBuy ? Theme.of(context).colorScheme.primary : theme.colorScheme.error, + ), + title: Row( + children: [ + Flexible(child: Text(trade.title, overflow: TextOverflow.ellipsis)), + const SizedBox(width: 8), + if (trade.blockedByCash) const _BlockedByCashChip(), + ], + ), + subtitle: Text( + // a missing price is a reason, not a zero quantity + noQty + ? 'Количество не рассчитано: нет цены инструмента' + : '${isBuy ? 'Купить' : 'Продать'} ${formatQty(trade.suggestedQty!)} шт.' + '${details.isEmpty ? '' : ' · $details'}', + style: theme.textTheme.bodySmall, + ), + trailing: noQty || trade.amountRub == null + ? const Text('—') + : MoneyText(trade.amountRub!, currency: 'RUB', style: theme.textTheme.titleSmall), + ); + } +} + +class _BlockedByCashChip extends StatelessWidget { + const _BlockedByCashChip(); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Tooltip( + message: 'Количество урезано: на счетах не хватает денег на полный объём', + child: Chip( + avatar: const Icon(Icons.account_balance_wallet_outlined, size: 16), + label: const Text('не хватает денег'), + backgroundColor: scheme.errorContainer, + visualDensity: VisualDensity.compact, + ), + ); + } +} diff --git a/app/lib/features/rebalance/providers.dart b/app/lib/features/rebalance/providers.dart new file mode 100644 index 0000000..bec6ba7 --- /dev/null +++ b/app/lib/features/rebalance/providers.dart @@ -0,0 +1,61 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import '../portfolio/providers.dart' show scopeProvider, scopesProvider; +import 'data/rebalance_api.dart'; + +final rebalanceApiProvider = + Provider((ref) => RebalanceApi(ref.watch(apiProvider).dio)); + +/// A portfolio the user can rebalance, derived from the scope list the analytics API +/// already publishes (`portfolio:`): there is no separate portfolios endpoint, and +/// inventing one would be a contract of its own. +class PortfolioRef { + const PortfolioRef(this.id, this.name); + final int id; + final String name; +} + +final portfoliosProvider = FutureProvider.autoDispose>((ref) async { + final scopes = await ref.watch(scopesProvider.future); + return [ + for (final s in scopes) + if (s.scope.startsWith('portfolio:')) + PortfolioRef(int.parse(s.scope.substring('portfolio:'.length)), s.name), + ]; +}); + +/// The portfolio the rebalance screen is working on. Null until the list loads; seeded from +/// the app-wide scope when that scope already names a portfolio. +final selectedPortfolioProvider = StateProvider((ref) { + final scope = ref.watch(scopeProvider); + if (!scope.startsWith('portfolio:')) return null; + return int.tryParse(scope.substring('portfolio:'.length)); +}); + +final targetDimensionProvider = StateProvider((ref) => 'asset_class'); + +/// What-if cash for the recommendations, as a decimal string. Null = use the real balance. +final whatIfCashProvider = StateProvider((ref) => null); + +final targetsProvider = FutureProvider.autoDispose((ref) async { + final id = ref.watch(selectedPortfolioProvider); + final dimension = ref.watch(targetDimensionProvider); + if (id == null) return TargetSet(dimension: dimension); + return ref.watch(rebalanceApiProvider).getTargets(id, dimension: dimension); +}); + +final rebalancePlanProvider = FutureProvider.autoDispose((ref) async { + final id = ref.watch(selectedPortfolioProvider); + final dimension = ref.watch(targetDimensionProvider); + final cash = ref.watch(whatIfCashProvider); + if (id == null) return null; + return ref.watch(rebalanceApiProvider).plan(id, dimension: dimension, cashAvailable: cash); +}); + +/// After a successful PUT the numbers are recomputed on the server, so both sides of the +/// screen are refetched rather than patched in place. +void invalidateRebalanceProviders(WidgetRef ref) { + ref.invalidate(targetsProvider); + ref.invalidate(rebalancePlanProvider); +} diff --git a/app/lib/features/rebalance/rebalance_page.dart b/app/lib/features/rebalance/rebalance_page.dart new file mode 100644 index 0000000..d51980b --- /dev/null +++ b/app/lib/features/rebalance/rebalance_page.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import '../portfolio/labels.dart' show dimensionLabels; +import 'data/rebalance_api.dart'; +import 'plan_tab.dart'; +import 'providers.dart'; +import 'targets_tab.dart'; + +/// Ребалансировка: the target weights on one tab, the resulting recommendations on the +/// other. Both are per portfolio and per dimension, so the pickers live in the app bar and +/// drive both tabs at once. +class RebalancePage extends ConsumerWidget { + const RebalancePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final portfolios = ref.watch(portfoliosProvider); + + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Ребалансировка'), + actions: [ + const _PortfolioSelector(), + const _DimensionSelector(), + IconButton( + tooltip: 'Обновить', + icon: const Icon(Icons.refresh), + onPressed: () => invalidateRebalanceProviders(ref), + ), + ], + bottom: const TabBar( + tabs: [Tab(text: 'Целевые веса'), Tab(text: 'Рекомендации')], + ), + ), + body: AsyncValueView( + value: portfolios, + onRetry: () => ref.invalidate(portfoliosProvider), + data: (list) { + if (list.isEmpty) { + return const EmptyState( + icon: Icons.pie_chart_outline, + message: 'Портфелей пока нет.\n' + 'Ребалансировка считается по портфелю: заведите его в настройках счетов.', + ); + } + final selected = ref.watch(selectedPortfolioProvider); + if (selected == null || !list.any((p) => p.id == selected)) { + // pick the first portfolio once, after the list is known + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(selectedPortfolioProvider.notifier).state = list.first.id; + }); + return const Center(child: CircularProgressIndicator()); + } + return const TabBarView(children: [TargetsTab(), RebalancePlanTab()]); + }, + ), + ), + ); + } +} + +class _PortfolioSelector extends ConsumerWidget { + const _PortfolioSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final portfolios = ref.watch(portfoliosProvider).valueOrNull ?? const []; + final selected = ref.watch(selectedPortfolioProvider); + if (portfolios.length < 2 || selected == null) return const SizedBox.shrink(); + return DropdownButtonHideUnderline( + child: DropdownButton( + value: portfolios.any((p) => p.id == selected) ? selected : portfolios.first.id, + borderRadius: BorderRadius.circular(8), + items: [ + for (final p in portfolios) + DropdownMenuItem(value: p.id, child: Text(p.name, overflow: TextOverflow.ellipsis)), + ], + onChanged: (v) { + if (v != null) ref.read(selectedPortfolioProvider.notifier).state = v; + }, + ), + ); + } +} + +class _DimensionSelector extends ConsumerWidget { + const _DimensionSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final current = ref.watch(targetDimensionProvider); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: current, + borderRadius: BorderRadius.circular(8), + items: [ + for (final d in targetDimensions) + DropdownMenuItem(value: d, child: Text(dimensionLabels[d] ?? d)), + ], + onChanged: (v) { + if (v != null) ref.read(targetDimensionProvider.notifier).state = v; + }, + ), + ), + ); + } +} diff --git a/app/lib/features/rebalance/targets_tab.dart b/app/lib/features/rebalance/targets_tab.dart new file mode 100644 index 0000000..f21bdc7 --- /dev/null +++ b/app/lib/features/rebalance/targets_tab.dart @@ -0,0 +1,308 @@ +import 'package:decimal/decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/utils/json.dart'; +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/section_card.dart'; +import '../portfolio/labels.dart' show assetClassLabels; +import 'data/rebalance_api.dart'; +import 'labels.dart'; +import 'providers.dart'; +import 'weights.dart'; + +/// Целевые веса: the editable target set for one dimension. +/// +/// The sum of the weights is shown permanently and Save is blocked whenever it differs from +/// 100 % by more than 0,01 pp. The server answers 422 in that case — but a user should see +/// the problem while typing, not after a round trip. +class TargetsTab extends ConsumerStatefulWidget { + const TargetsTab({super.key}); + + @override + ConsumerState createState() => _TargetsTabState(); +} + +class _TargetsTabState extends ConsumerState { + /// Local draft. Seeded from the server set and reseeded whenever the portfolio or the + /// dimension changes — editing one dimension must never leak into another. + List? _draft; + String? _seededFor; + bool _saving = false; + bool _dirty = false; + + void _seed(TargetSet set, String key) { + if (_seededFor == key) return; + _seededFor = key; + _draft = [...set.targets]; + _dirty = false; + } + + Decimal get _sum => sumDecimals((_draft ?? const []).map((t) => t.targetWeight)); + + bool get _sumIsValid => (_sum - Decimal.one).abs() <= Decimal.parse('0.0001'); + + Future _save() async { + final portfolioId = ref.read(selectedPortfolioProvider); + final dimension = ref.read(targetDimensionProvider); + final draft = _draft; + if (portfolioId == null || draft == null) return; + + setState(() => _saving = true); + try { + await ref + .read(rebalanceApiProvider) + .putTargets(portfolioId, TargetSet(dimension: dimension, targets: draft)); + if (!mounted) return; + _dirty = false; + _seededFor = null; // refetch reseeds the draft from the saved set + invalidateRebalanceProviders(ref); + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Целевые веса сохранены'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(apiErrorMessage(e)))); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final portfolioId = ref.watch(selectedPortfolioProvider); + final dimension = ref.watch(targetDimensionProvider); + final targets = ref.watch(targetsProvider); + + return AsyncValueView( + value: targets, + onRetry: () => ref.invalidate(targetsProvider), + data: (set) { + _seed(set, '$portfolioId/$dimension'); + final draft = _draft ?? const []; + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 88), + children: [ + _SumBanner(sum: _sum, valid: _sumIsValid, serverSum: set.weightsSum), + const SizedBox(height: 12), + SectionCard( + title: 'Целевые веса', + subtitle: 'Вес — доля портфеля; допуск (band) — ширина коридора, ' + 'внутри которого сделки не предлагаются.', + child: Column( + children: [ + for (var i = 0; i < draft.length; i++) + _TargetRow( + key: ValueKey('${_seededFor}_$i'), + dimension: dimension, + target: draft[i], + onChanged: (t) => setState(() { + _draft![i] = t; + _dirty = true; + }), + onRemove: () => setState(() { + _draft!.removeAt(i); + _dirty = true; + }), + ), + if (draft.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Text('Целевые веса ещё не заданы.'), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: () => setState(() { + _draft = [ + ...draft, + const TargetWeight(bucket: '', targetWeight: '0', band: '0.05'), + ]; + _dirty = true; + }), + icon: const Icon(Icons.add), + label: const Text('Добавить группу'), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Row( + children: [ + FilledButton.icon( + onPressed: _saving || !_sumIsValid || draft.any((t) => t.bucket.isEmpty) + ? null + : _save, + icon: _saving + ? const SizedBox( + width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.save_outlined), + label: const Text('Сохранить'), + ), + const SizedBox(width: 12), + if (_dirty) + TextButton( + onPressed: () => setState(() { + _seededFor = null; + _seed(set, '$portfolioId/$dimension'); + }), + child: const Text('Отменить изменения'), + ), + ], + ), + const SizedBox(height: 8), + if (!_sumIsValid) + Text( + 'Сохранение заблокировано: сумма весов должна быть ровно 100 %. ' + 'Сервер не нормализует веса — сумма 90 % означает, что 10 % портфеля ' + 'не отнесены ни к одной группе, а не что доли надо растянуть.', + style: Theme.of(context).textTheme.bodySmall, + ) + else if (draft.any((t) => t.bucket.isEmpty)) + Text( + 'У одной из групп пустое имя — сохранить нельзя.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + }, + ); + } +} + +class _SumBanner extends StatelessWidget { + const _SumBanner({required this.sum, required this.valid, this.serverSum}); + + final Decimal sum; + final bool valid; + final String? serverSum; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Card( + color: valid ? scheme.secondaryContainer : scheme.errorContainer, + child: ListTile( + leading: Icon(valid ? Icons.check_circle_outline : Icons.error_outline), + title: Text('Сумма весов: ${formatShareAsPercent(sum.toString())}'), + subtitle: Text( + valid + ? 'Набор можно сохранить.' + : 'Нужно ровно 100 %. Разница: ' + '${formatShareAsPercent((sum - Decimal.one).toString(), signed: true)}', + ), + trailing: serverSum == null + ? null + : Tooltip( + message: 'Последняя сумма, сохранённая на сервере', + child: Text(formatShareAsPercent(serverSum)), + ), + ), + ); + } +} + +class _TargetRow extends StatefulWidget { + const _TargetRow({ + required this.dimension, + required this.target, + required this.onChanged, + required this.onRemove, + super.key, + }); + + final String dimension; + final TargetWeight target; + final ValueChanged onChanged; + final VoidCallback onRemove; + + @override + State<_TargetRow> createState() => _TargetRowState(); +} + +class _TargetRowState extends State<_TargetRow> { + late final TextEditingController _weight = + TextEditingController(text: shareToPercentText(widget.target.targetWeight)); + late final TextEditingController _band = + TextEditingController(text: shareToPercentText(widget.target.band)); + late final TextEditingController _bucket = + TextEditingController(text: widget.target.bucket); + + @override + void dispose() { + _weight.dispose(); + _band.dispose(); + _bucket.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // The known asset classes are offered as a list; every other dimension (sector, country, + // currency) has an open set of buckets that only the data knows, so those stay free text. + final knownBuckets = widget.dimension == 'asset_class' + ? [...assetClassLabels.keys, 'cash'] + : const []; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: knownBuckets.isEmpty + ? TextField( + controller: _bucket, + decoration: const InputDecoration(labelText: 'Группа', isDense: true), + onChanged: (v) => widget.onChanged(widget.target.copyWith(bucket: v)), + ) + : DropdownButtonFormField( + initialValue: + knownBuckets.contains(widget.target.bucket) ? widget.target.bucket : null, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Группа', isDense: true), + items: [ + for (final b in knownBuckets) + DropdownMenuItem(value: b, child: Text(bucketLabelForKey('asset_class', b))), + ], + onChanged: (v) => + widget.onChanged(widget.target.copyWith(bucket: v ?? '')), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: TextField( + controller: _weight, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Вес, %', isDense: true), + onChanged: (v) { + final share = percentTextToShare(v); + widget.onChanged(widget.target.copyWith(targetWeight: share ?? '0')); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: TextField( + controller: _band, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration(labelText: 'Допуск, %', isDense: true), + onChanged: (v) => + widget.onChanged(widget.target.copyWith(band: percentTextToShare(v))), + ), + ), + IconButton( + tooltip: 'Удалить группу', + onPressed: widget.onRemove, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/rebalance/weights.dart b/app/lib/features/rebalance/weights.dart new file mode 100644 index 0000000..74fa703 --- /dev/null +++ b/app/lib/features/rebalance/weights.dart @@ -0,0 +1,35 @@ +import 'package:decimal/decimal.dart'; + +/// Weights travel as shares (`"0.60"` = 60 %) and are edited as percents. The conversion is +/// exact on both sides: `Decimal`, never `double`, so 33,33 % round-trips unchanged and a +/// set that sums to exactly 1 does not fail validation because of binary floating point. + +final _hundred = Decimal.fromInt(100); + +/// `"0.605"` → `"60,5"`; empty for anything unparseable. +String shareToPercentText(String? share) { + final d = share == null ? null : Decimal.tryParse(share); + if (d == null) return ''; + final p = d * _hundred; + final text = p == p.truncate() ? p.truncate().toString() : p.toString(); + return text.replaceAll('.', ','); +} + +/// `"60,5"` → `"0.605"`; null when the text is not a number. +String? percentTextToShare(String text) { + final normalized = text.trim().replaceAll(',', '.').replaceAll(' ', ''); + if (normalized.isEmpty) return null; + final d = Decimal.tryParse(normalized); + if (d == null) return null; + return (d / _hundred).toDecimal(scaleOnInfinitePrecision: 10).toString(); +} + +/// `"0.032"` → `"3,20 %"`, signed. Kept local to the rebalance screen because drift is a +/// share, not a return, and the portfolio helper would sign it the same way by accident. +String formatShareAsPercent(String? share, {bool signed = false, int digits = 2}) { + final d = share == null ? null : Decimal.tryParse(share); + if (d == null) return '—'; + final p = (d * _hundred).toDouble(); + final sign = signed && p > 0 ? '+' : ''; + return '$sign${p.toStringAsFixed(digits).replaceAll('.', ',')} %'; +} diff --git a/app/lib/features/shell/analytics_page.dart b/app/lib/features/shell/analytics_page.dart new file mode 100644 index 0000000..3db3f5e --- /dev/null +++ b/app/lib/features/shell/analytics_page.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Аналитика: a hub for the phase-4 screens. +/// +/// Why a hub instead of four more destinations: the shell already carried eleven, which on +/// a phone leaves ~36 px per label in the bottom bar. Доходы, Ребалансировка, Цели and +/// Налоги are all "what do I do with the portfolio next" questions, so they live behind one +/// entry that highlights for all four (see `alsoMatches` in `app_shell.dart`), while each +/// keeps its own top-level route from the contract (`/income`, `/rebalance`, `/goals`, +/// `/tax`) and is deep-linkable. +class AnalyticsHubPage extends StatelessWidget { + const AnalyticsHubPage({super.key}); + + static const _entries = [ + ( + path: '/income', + icon: Icons.payments_outlined, + title: 'Доходы', + subtitle: 'Календарь дивидендов и купонов, история выплат, прогноз на 12 месяцев', + ), + ( + path: '/rebalance', + icon: Icons.balance, + title: 'Ребалансировка', + subtitle: 'Целевые веса портфеля и рекомендации, что докупить или продать', + ), + ( + path: '/goals', + icon: Icons.flag_outlined, + title: 'Цели', + subtitle: 'Накопительные цели и прогноз их достижения по текущему тренду', + ), + ( + path: '/tax', + icon: Icons.receipt_long_outlined, + title: 'Налоги', + subtitle: 'Оценка налога за год и лоты с датой ЛДВ — для сверки со справкой брокера', + ), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Аналитика')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final e in _entries) + Card( + child: ListTile( + leading: Icon(e.icon), + title: Text(e.title), + subtitle: Text(e.subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go(e.path), + ), + ), + const SizedBox(height: 12), + Text( + 'Сравнение с бенчмарками живёт на экране «Портфель»: обгон индекса — ' + 'свойство портфеля, а не отдельная тема.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} diff --git a/app/lib/features/shell/app_shell.dart b/app/lib/features/shell/app_shell.dart index dd79079..326bd17 100644 --- a/app/lib/features/shell/app_shell.dart +++ b/app/lib/features/shell/app_shell.dart @@ -2,18 +2,46 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class _Destination { - const _Destination(this.path, this.icon, this.selectedIcon, this.label); + const _Destination(this.path, this.icon, this.selectedIcon, this.label, + {this.alsoMatches = const [], this.primary = false}); final String path; final IconData icon; final IconData selectedIcon; final String label; + + /// Extra route prefixes that belong to this destination but do not start with [path] — + /// `/instruments/pending` is reached from Импорт and must keep it highlighted, and the + /// four phase-4 screens are reached from Аналитика the same way. + final List alsoMatches; + + /// Shown directly in the bottom bar on a phone. Everything else moves behind «Ещё». + final bool primary; + + /// The longest prefix of [location] this destination claims, or -1 for no match. + int matchLength(String location) { + var best = -1; + for (final p in [path, ...alsoMatches]) { + final hit = p == '/' ? location == '/' : location.startsWith(p); + if (hit && p.length > best) best = p.length; + } + return best; + } } const _destinations = [ - _Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'), - _Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'), - _Destination('/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель'), + _Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор', primary: true), + _Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета', + primary: true), + _Destination('/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель', + primary: true), + // One entry for the four phase-4 screens. They keep their own top-level routes from the + // contract and stay deep-linkable; the hub at `/analytics` is what the bottom bar and the + // rail point at, and `alsoMatches` keeps it highlighted while you are inside any of them. + _Destination('/analytics', Icons.insights_outlined, Icons.insights, 'Аналитика', + alsoMatches: ['/income', '/rebalance', '/goals', '/tax'], primary: true), _Destination('/events', Icons.event_note_outlined, Icons.event_note, 'События'), + _Destination('/imports', Icons.upload_file_outlined, Icons.upload_file, 'Импорт', + alsoMatches: ['/instruments/pending']), _Destination('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки'), _Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'), _Destination( @@ -31,6 +59,10 @@ const _wideBreakpoint = 1200.0; /// Adaptive navigation shell around the current route: a bottom /// [NavigationBar] on narrow surfaces, a [NavigationRail] (collapsed or /// extended) otherwise. +/// +/// The rail shows every destination. The bottom bar shows the four primary ones plus +/// «Ещё», which opens the rest in a sheet: twelve destinations in a phone-width bar would +/// leave about 30 px per label, which is not navigation but decoration. class AppShell extends StatelessWidget { const AppShell({required this.location, required this.child, super.key}); @@ -40,12 +72,15 @@ class AppShell extends StatelessWidget { int get _selectedIndex { // longest prefix wins, so /portfolio/instrument/311 keeps Портфель selected var best = -1; + var bestLength = -1; for (var i = 0; i < _destinations.length; i++) { - final path = _destinations[i].path; - final matches = path == '/' ? location == '/' : location.startsWith(path); - if (matches && (best == -1 || path.length > _destinations[best].path.length)) best = i; + final length = _destinations[i].matchLength(location); + if (length > bestLength) { + best = i; + bestLength = length; + } } - return best == -1 ? 0 : best; + return best == -1 || bestLength < 0 ? 0 : best; } void _onSelect(BuildContext context, int index) { @@ -57,18 +92,36 @@ class AppShell extends StatelessWidget { final width = MediaQuery.sizeOf(context).width; if (width < _narrowBreakpoint) { + final primary = _destinations.where((d) => d.primary).toList(); + final selected = _destinations[_selectedIndex]; + final primaryIndex = primary.indexOf(selected); + return Scaffold( body: SafeArea(child: child), bottomNavigationBar: NavigationBar( - selectedIndex: _selectedIndex, - onDestinationSelected: (i) => _onSelect(context, i), + // nothing primary matches ⇒ we are inside a screen that lives behind «Ещё», + // and «Ещё» is what should look active + selectedIndex: primaryIndex >= 0 ? primaryIndex : primary.length, + onDestinationSelected: (i) { + if (i >= primary.length) { + _showMore(context, selected); + } else { + final target = _destinations.indexOf(primary[i]); + _onSelect(context, target); + } + }, destinations: [ - for (final d in _destinations) + for (final d in primary) NavigationDestination( icon: Icon(d.icon), selectedIcon: Icon(d.selectedIcon), label: d.label, ), + NavigationDestination( + icon: const Icon(Icons.more_horiz), + selectedIcon: const Icon(Icons.more_horiz), + label: primaryIndex >= 0 ? 'Ещё' : selected.label, + ), ], ), ); @@ -99,4 +152,31 @@ class AppShell extends StatelessWidget { ), ); } + + /// The non-primary destinations, as a sheet. The currently open one is ticked, so «Ещё» + /// still answers "where am I". + void _showMore(BuildContext context, _Destination current) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (final d in _destinations.where((d) => !d.primary)) + ListTile( + leading: Icon(d == current ? d.selectedIcon : d.icon), + title: Text(d.label), + selected: d == current, + trailing: d == current ? const Icon(Icons.check) : null, + onTap: () { + Navigator.of(sheetContext).pop(); + if (d != current) context.go(d.path); + }, + ), + ], + ), + ), + ); + } } diff --git a/app/lib/features/tax/data/tax_api.dart b/app/lib/features/tax/data/tax_api.dart new file mode 100644 index 0000000..e3254a2 --- /dev/null +++ b/app/lib/features/tax/data/tax_api.dart @@ -0,0 +1,171 @@ +/// Hand-written client for `/api/v1/tax`. +/// +/// **Temporary.** The tax routes are not in `openapi/openapi.json` yet, so +/// `app/packages/api_client` has no generated models or methods for them. Everything here +/// follows `docs/ai/phase4-contract.md` §5 literally and is meant to be **replaced by the +/// generated client** once the routes land in the spec and `just gen-client` runs. +/// +/// Raw Dio comes from `ref.read(apiProvider).dio`: base URL, bearer header and the one-shot +/// refresh on 401 are already wired there. +library; + +import 'package:dio/dio.dart'; + +import '../../../core/utils/json.dart'; + +/// Per-account (or total) tax figures for a year. Every number is an **estimate**: the tax +/// agent is the broker, and these exist so that the broker's statement can be checked. +class TaxRow { + const TaxRow({ + this.accountId, + this.accountName, + this.dividendsGrossRub, + this.couponsGrossRub, + this.taxWithheldRub, + this.realizedGainRub, + this.realizedLossRub, + this.ldvExemptRub, + this.taxableBaseRub, + this.estimatedTaxRub, + }); + + final int? accountId; + final String? accountName; + final String? dividendsGrossRub; + final String? couponsGrossRub; + final String? taxWithheldRub; + final String? realizedGainRub; + final String? realizedLossRub; + final String? ldvExemptRub; + final String? taxableBaseRub; + final String? estimatedTaxRub; + + String get title => accountName ?? (accountId == null ? 'Итого' : 'Счёт #$accountId'); + + static TaxRow fromJson(Map json) => TaxRow( + accountId: asInt(json['account_id']), + accountName: asString(json['account_name']), + dividendsGrossRub: asString(json['dividends_gross_rub']), + couponsGrossRub: asString(json['coupons_gross_rub']), + taxWithheldRub: asString(json['tax_withheld_rub']), + realizedGainRub: asString(json['realized_gain_rub']), + realizedLossRub: asString(json['realized_loss_rub']), + ldvExemptRub: asString(json['ldv_exempt_rub']), + taxableBaseRub: asString(json['taxable_base_rub']), + estimatedTaxRub: asString(json['estimated_tax_rub']), + ); +} + +class TaxSummary { + const TaxSummary({ + required this.year, + required this.estimated, + this.taxRate, + this.accounts = const [], + this.totals, + this.disclaimer, + }); + + final int year; + + /// Always true per the contract — and shown on screen, not hidden in a tooltip. + final bool estimated; + final String? taxRate; + final List accounts; + final TaxRow? totals; + final String? disclaimer; + + static const defaultDisclaimer = + 'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.'; + + static TaxSummary fromJson(Map json) { + final totals = asObject(json['totals']); + return TaxSummary( + year: asInt(json['year']) ?? DateTime.now().year, + estimated: json.containsKey('estimated') ? asBool(json['estimated']) : true, + taxRate: asString(json['tax_rate']), + accounts: asObjects(json['accounts']).map(TaxRow.fromJson).toList(), + totals: totals == null ? null : TaxRow.fromJson(totals), + disclaimer: asString(json['disclaimer']), + ); + } +} + +/// An open lot with the date after which a sale falls under ЛДВ (the three-year exemption). +class TaxLot { + const TaxLot({ + required this.lotId, + required this.ldvEligible, + this.instrumentId, + this.ticker, + this.accountId, + this.openDate, + this.qtyRemaining, + this.costRub, + this.marketValueRub, + this.unrealizedGainRub, + this.ldvDate, + this.daysToLdv, + this.taxIfSoldNowRub, + }); + + final int lotId; + final int? instrumentId; + final String? ticker; + final int? accountId; + final DateTime? openDate; + final String? qtyRemaining; + final String? costRub; + final String? marketValueRub; + final String? unrealizedGainRub; + final bool ldvEligible; + final DateTime? ldvDate; + final int? daysToLdv; + final String? taxIfSoldNowRub; + + /// Close enough to ЛДВ that selling now is an expensive mistake. Six months is the + /// horizon at which a person can still decide to wait. + bool get nearLdv => !ldvEligible && daysToLdv != null && daysToLdv! <= 183; + + String get title => ticker ?? (instrumentId == null ? '#$lotId' : '#$instrumentId'); + + static TaxLot fromJson(Map json) => TaxLot( + lotId: asInt(json['lot_id']) ?? 0, + instrumentId: asInt(json['instrument_id']), + ticker: asString(json['ticker']), + accountId: asInt(json['account_id']), + openDate: asDate(json['open_date']), + qtyRemaining: asString(json['qty_remaining']), + costRub: asString(json['cost_rub']), + marketValueRub: asString(json['market_value_rub']), + unrealizedGainRub: asString(json['unrealized_gain_rub']), + ldvEligible: asBool(json['ldv_eligible']), + ldvDate: asDate(json['ldv_date']), + daysToLdv: asInt(json['days_to_ldv']), + taxIfSoldNowRub: asString(json['tax_if_sold_now_rub']), + ); +} + +class TaxApi { + const TaxApi(this._dio); + + final Dio _dio; + + static const _base = '/api/v1/tax'; + + Future summary({required int year, int? accountId}) async { + final r = await _dio.get>( + _base, + queryParameters: {'year': year, 'account_id': ?accountId}, + ); + return TaxSummary.fromJson(r.data ?? const {}); + } + + Future> lots({required int year, int? accountId}) async { + final r = await _dio.get>( + '$_base/lots', + queryParameters: {'year': year, 'account_id': ?accountId}, + ); + return asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList(); + } +} diff --git a/app/lib/features/tax/lots_tab.dart b/app/lib/features/tax/lots_tab.dart new file mode 100644 index 0000000..995be73 --- /dev/null +++ b/app/lib/features/tax/lots_tab.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/utils/ru_date.dart'; +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/money_text.dart'; +import '../../core/widgets/section_card.dart'; +import '../portfolio/labels.dart' show formatQty, signColor; +import 'data/tax_api.dart'; +import 'providers.dart'; + +/// Лоты и ЛДВ: the practical screen of the phase — what a sale costs **today** versus what +/// it costs after the three-year mark. +/// +/// Lots close to the ЛДВ date are called out, because selling a lot 20 days early is the +/// one mistake this screen exists to prevent. +class TaxLotsTab extends ConsumerStatefulWidget { + const TaxLotsTab({super.key}); + + @override + ConsumerState createState() => _TaxLotsTabState(); +} + +class _TaxLotsTabState extends ConsumerState { + bool _onlyNearLdv = false; + + @override + Widget build(BuildContext context) { + final lots = ref.watch(taxLotsProvider); + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(taxLotsProvider), + child: AsyncValueView( + value: lots, + onRetry: () => ref.invalidate(taxLotsProvider), + data: (all) { + final near = all.where((l) => l.nearLdv).toList(); + // a copy: the provider's list must not be reordered under other watchers + final rows = [...(_onlyNearLdv ? near : all)]; + // soonest ЛДВ first among the lots that do not have it yet, eligible ones last + rows.sort((a, b) { + if (a.ldvEligible != b.ldvEligible) return a.ldvEligible ? 1 : -1; + return (a.daysToLdv ?? 1 << 30).compareTo(b.daysToLdv ?? 1 << 30); + }); + + if (all.isEmpty) { + return ListView( + padding: const EdgeInsets.all(16), + children: const [ + SizedBox(height: 48), + EmptyState( + icon: Icons.inventory_2_outlined, + message: 'Открытых лотов нет.', + ), + ], + ); + } + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (near.isNotEmpty) + Card( + color: Theme.of(context).colorScheme.tertiaryContainer, + child: ListTile( + leading: const Icon(Icons.schedule), + title: Text('До ЛДВ меньше полугода: ${near.length} лот(ов)'), + subtitle: const Text( + 'Продажа до этой даты облагается налогом на весь прирост.'), + trailing: FilterChip( + label: const Text('только они'), + selected: _onlyNearLdv, + onSelected: (v) => setState(() => _onlyNearLdv = v), + ), + ), + ), + const SizedBox(height: 12), + SectionCard( + title: 'Открытые лоты', + subtitle: 'налог при продаже сегодня — оценка по ставке из сводки', + child: _LotsTable(rows: rows), + ), + ], + ); + }, + ), + ); + } +} + +class _LotsTable extends StatelessWidget { + const _LotsTable({required this.rows}); + + final List rows; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final headerStyle = theme.textTheme.labelMedium; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columns: [ + DataColumn(label: Text('Бумага', style: headerStyle)), + DataColumn(label: Text('Куплен', style: headerStyle)), + DataColumn(label: Text('Кол-во', style: headerStyle), numeric: true), + DataColumn(label: Text('Стоимость', style: headerStyle), numeric: true), + DataColumn(label: Text('Рынок', style: headerStyle), numeric: true), + DataColumn(label: Text('Нереализ.', style: headerStyle), numeric: true), + DataColumn(label: Text('Дата ЛДВ', style: headerStyle)), + DataColumn(label: Text('Дней до ЛДВ', style: headerStyle), numeric: true), + DataColumn(label: Text('Налог при продаже', style: headerStyle), numeric: true), + ], + rows: [ + for (final l in rows) + DataRow( + color: l.nearLdv + ? WidgetStatePropertyAll( + theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5)) + : null, + onSelectChanged: l.instrumentId == null + ? null + : (_) => context.push('/portfolio/instrument/${l.instrumentId}'), + cells: [ + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l.title), + if (l.nearLdv) ...[ + const SizedBox(width: 6), + Tooltip( + message: 'До ЛДВ осталось ${l.daysToLdv} дн. — ' + 'продажа сейчас облагается налогом полностью', + child: Icon(Icons.schedule, size: 16, color: theme.colorScheme.error), + ), + ], + ], + )), + DataCell(Text(l.openDate == null ? '—' : ruDate(l.openDate!))), + DataCell(Text(l.qtyRemaining == null ? '—' : formatQty(l.qtyRemaining!))), + DataCell(l.costRub == null + ? const Text('—') + : MoneyText(l.costRub!, currency: 'RUB')), + DataCell(l.marketValueRub == null + ? const Text('—') + : MoneyText(l.marketValueRub!, currency: 'RUB')), + DataCell(l.unrealizedGainRub == null + ? const Text('—') + : MoneyText( + l.unrealizedGainRub!, + currency: 'RUB', + style: TextStyle(color: signColor(context, l.unrealizedGainRub)), + )), + DataCell(l.ldvEligible + ? const Text('уже действует') + : Text(l.ldvDate == null ? '—' : ruDate(l.ldvDate!))), + DataCell(l.ldvEligible + ? const Text('—') + : Text( + l.daysToLdv == null ? '—' : '${l.daysToLdv}', + style: l.nearLdv + ? TextStyle( + color: theme.colorScheme.error, fontWeight: FontWeight.w600) + : null, + )), + DataCell(l.taxIfSoldNowRub == null + ? const Text('—') + : MoneyText(l.taxIfSoldNowRub!, currency: 'RUB')), + ], + ), + ], + ), + ); + } +} diff --git a/app/lib/features/tax/providers.dart b/app/lib/features/tax/providers.dart new file mode 100644 index 0000000..6ab881b --- /dev/null +++ b/app/lib/features/tax/providers.dart @@ -0,0 +1,30 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api/api_client.dart'; +import 'data/tax_api.dart'; + +final taxApiProvider = Provider((ref) => TaxApi(ref.watch(apiProvider).dio)); + +final taxYearProvider = StateProvider((ref) => DateTime.now().year); + +/// Optional account filter; null = all accounts. +final taxAccountProvider = StateProvider((ref) => null); + +final taxSummaryProvider = FutureProvider.autoDispose((ref) async { + return ref.watch(taxApiProvider).summary( + year: ref.watch(taxYearProvider), + accountId: ref.watch(taxAccountProvider), + ); +}); + +final taxLotsProvider = FutureProvider.autoDispose>((ref) async { + return ref.watch(taxApiProvider).lots( + year: ref.watch(taxYearProvider), + accountId: ref.watch(taxAccountProvider), + ); +}); + +void invalidateTaxProviders(WidgetRef ref) { + ref.invalidate(taxSummaryProvider); + ref.invalidate(taxLotsProvider); +} diff --git a/app/lib/features/tax/summary_tab.dart b/app/lib/features/tax/summary_tab.dart new file mode 100644 index 0000000..aac1833 --- /dev/null +++ b/app/lib/features/tax/summary_tab.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/widgets/async_value_view.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/money_text.dart'; +import '../../core/widgets/section_card.dart'; +import '../portfolio/labels.dart' show formatPercent, signColor; +import 'data/tax_api.dart'; +import 'providers.dart'; + +/// Сводка: the estimated tax picture for the year, per account and in total. +class TaxSummaryTab extends ConsumerWidget { + const TaxSummaryTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final summary = ref.watch(taxSummaryProvider); + + return RefreshIndicator( + onRefresh: () async => ref.invalidate(taxSummaryProvider), + child: AsyncValueView( + value: summary, + onRetry: () => ref.invalidate(taxSummaryProvider), + data: (data) { + final totals = data.totals; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Text('${data.year} год', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(width: 12), + if (data.estimated) + const Chip( + avatar: Icon(Icons.calculate_outlined, size: 16), + label: Text('оценка'), + visualDensity: VisualDensity.compact, + ), + const Spacer(), + if (data.taxRate != null) + Text('ставка ${formatPercent(data.taxRate, signed: false)}'), + ], + ), + const SizedBox(height: 12), + if (totals != null) ...[ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + StatTile( + label: 'Оценка налога', + value: _money(totals.estimatedTaxRub), + note: 'по всем счетам за год', + ), + StatTile( + label: 'Удержано брокером', + value: _money(totals.taxWithheldRub), + note: 'по данным операций', + ), + StatTile( + label: 'Налоговая база', + value: _money(totals.taxableBaseRub), + note: 'после вычета ЛДВ', + ), + StatTile( + label: 'Освобождено по ЛДВ', + value: _money(totals.ldvExemptRub), + note: 'оценка по ст. 219.1', + ), + StatTile( + label: 'Дивиденды и купоны', + value: _money(totals.dividendsGrossRub), + note: 'купоны ${_moneyText(totals.couponsGrossRub)}', + ), + StatTile( + label: 'Реализовано', + value: _money(totals.realizedGainRub), + note: 'убыток ${_moneyText(totals.realizedLossRub)}', + ), + ], + ), + const SizedBox(height: 16), + ], + if (data.accounts.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 32), + child: EmptyState( + icon: Icons.receipt_long_outlined, + message: 'За этот год нет ни сделок, ни выплат.', + ), + ) + else + SectionCard( + title: 'По счетам', + subtitle: 'все суммы — оценка; авторитет — справка брокера', + child: _AccountsTable(rows: data.accounts, totals: totals), + ), + ], + ); + }, + ), + ); + } + + static Widget _money(String? value) => + value == null ? const Text('—') : MoneyText(value, currency: 'RUB'); + + static String _moneyText(String? value) => + value == null ? '—' : MoneyText.format(value, 'RUB'); +} + +class _AccountsTable extends StatelessWidget { + const _AccountsTable({required this.rows, this.totals}); + + final List rows; + final TaxRow? totals; + + @override + Widget build(BuildContext context) { + final headerStyle = Theme.of(context).textTheme.labelMedium; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columns: [ + DataColumn(label: Text('Счёт', style: headerStyle)), + DataColumn(label: Text('Дивиденды', style: headerStyle), numeric: true), + DataColumn(label: Text('Купоны', style: headerStyle), numeric: true), + DataColumn(label: Text('Удержано', style: headerStyle), numeric: true), + DataColumn(label: Text('Прибыль', style: headerStyle), numeric: true), + DataColumn(label: Text('Убыток', style: headerStyle), numeric: true), + DataColumn(label: Text('ЛДВ', style: headerStyle), numeric: true), + DataColumn(label: Text('База', style: headerStyle), numeric: true), + DataColumn(label: Text('Налог (оценка)', style: headerStyle), numeric: true), + ], + rows: [ + for (final r in rows) _row(context, r, bold: false), + if (totals != null) _row(context, totals!, bold: true), + ], + ), + ); + } + + DataRow _row(BuildContext context, TaxRow r, {required bool bold}) { + final style = bold ? Theme.of(context).textTheme.titleSmall : null; + Widget cell(String? v, {bool signed = false}) => v == null + ? Text('—', style: style) + : MoneyText( + v, + currency: 'RUB', + style: signed ? (style ?? const TextStyle()).copyWith(color: signColor(context, v)) : style, + ); + + return DataRow( + cells: [ + DataCell(Text(bold ? 'Итого' : r.title, style: style)), + DataCell(cell(r.dividendsGrossRub)), + DataCell(cell(r.couponsGrossRub)), + DataCell(cell(r.taxWithheldRub)), + DataCell(cell(r.realizedGainRub, signed: true)), + DataCell(cell(r.realizedLossRub, signed: true)), + DataCell(cell(r.ldvExemptRub)), + DataCell(cell(r.taxableBaseRub)), + DataCell(cell(r.estimatedTaxRub)), + ], + ); + } +} diff --git a/app/lib/features/tax/tax_page.dart b/app/lib/features/tax/tax_page.dart new file mode 100644 index 0000000..5ee3a78 --- /dev/null +++ b/app/lib/features/tax/tax_page.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'data/tax_api.dart'; +import 'lots_tab.dart'; +import 'providers.dart'; +import 'summary_tab.dart'; + +/// Налоги: the year summary and the open-lot list with ЛДВ dates. +/// +/// The word «оценка» is on the screen itself, not in a tooltip: the tax agent is the +/// broker, and these numbers exist so that the broker's statement can be checked against +/// something — not to replace it. +class TaxPage extends ConsumerWidget { + const TaxPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Налоги (оценка)'), + actions: [ + const _YearSelector(), + IconButton( + tooltip: 'Обновить', + icon: const Icon(Icons.refresh), + onPressed: () => invalidateTaxProviders(ref), + ), + ], + bottom: const TabBar( + tabs: [Tab(text: 'Сводка за год'), Tab(text: 'Лоты и ЛДВ')], + ), + ), + body: const Column( + children: [ + EstimateBanner(), + Expanded(child: TabBarView(children: [TaxSummaryTab(), TaxLotsTab()])), + ], + ), + ), + ); + } +} + +/// The disclaimer, always visible above both tabs. +class EstimateBanner extends ConsumerWidget { + const EstimateBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final summary = ref.watch(taxSummaryProvider).valueOrNull; + final scheme = Theme.of(context).colorScheme; + final text = summary?.disclaimer ?? TaxSummary.defaultDisclaimer; + + return Material( + color: scheme.tertiaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + const Icon(Icons.info_outline, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ); + } +} + +class _YearSelector extends ConsumerWidget { + const _YearSelector(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final year = ref.watch(taxYearProvider); + final now = DateTime.now().year; + return DropdownButtonHideUnderline( + child: DropdownButton( + value: year, + borderRadius: BorderRadius.circular(8), + items: [ + for (var y = now; y >= now - 6; y--) + DropdownMenuItem(value: y, child: Text('$y')), + ], + onChanged: (v) { + if (v != null) ref.read(taxYearProvider.notifier).state = v; + }, + ), + ); + } +} diff --git a/app/lib/router.dart b/app/lib/router.dart index 9f2c4bc..f07acf8 100644 --- a/app/lib/router.dart +++ b/app/lib/router.dart @@ -7,14 +7,22 @@ import 'features/accounts/accounts_page.dart'; import 'features/cashflow/cashflow_page.dart'; import 'features/categories/categories_page.dart'; import 'features/events/events_page.dart'; +import 'features/goals/goals_page.dart'; import 'features/home/home_page.dart'; +import 'features/income/income_page.dart'; +import 'features/imports/import_preview_page.dart'; +import 'features/imports/imports_page.dart'; import 'features/login/login_page.dart'; +import 'features/pending/pending_page.dart'; import 'features/portfolio/instrument_page.dart'; import 'features/portfolio/portfolio_page.dart'; +import 'features/rebalance/rebalance_page.dart'; import 'features/rules/rules_page.dart'; import 'features/settings/settings_page.dart'; +import 'features/shell/analytics_page.dart'; import 'features/shell/app_shell.dart'; import 'features/sync/sync_page.dart'; +import 'features/tax/tax_page.dart'; import 'features/transactions/transactions_page.dart'; final routerProvider = Provider((ref) { @@ -48,7 +56,25 @@ final routerProvider = Provider((ref) { builder: (_, state) => InstrumentPage(instrumentId: int.parse(state.pathParameters['id']!)), ), + // Аналитика: one navigation destination, four contract routes. The hub is what + // the shell points at; each screen below keeps its own top-level path and stays + // deep-linkable (see `alsoMatches` in `app_shell.dart`). + GoRoute(path: '/analytics', builder: (_, _) => const AnalyticsHubPage()), + GoRoute(path: '/income', builder: (_, _) => const IncomePage()), + GoRoute(path: '/rebalance', builder: (_, _) => const RebalancePage()), + GoRoute(path: '/goals', builder: (_, _) => const GoalsPage()), + GoRoute(path: '/tax', builder: (_, _) => const TaxPage()), GoRoute(path: '/events', builder: (_, _) => const EventsPage()), + GoRoute(path: '/imports', builder: (_, _) => const ImportsPage()), + GoRoute( + path: '/imports/:id', + builder: (_, state) => + ImportPreviewPage(importId: int.parse(state.pathParameters['id']!)), + ), + GoRoute( + path: '/instruments/pending', + builder: (_, _) => const PendingInstrumentsPage(), + ), GoRoute(path: '/cashflow', builder: (_, _) => const CashflowPage()), GoRoute( path: '/categories', diff --git a/app/pubspec.lock b/app/pubspec.lock index 561022b..3870532 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.1.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -73,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" decimal: dependency: "direct main" description: @@ -129,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" + url: "https://pub.dev" + source: hosted + version: "11.0.3" fintracker_api: dependency: "direct main" description: @@ -162,6 +186,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: @@ -444,6 +476,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -657,6 +697,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8930815..afe1cfc 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: intl: ^0.20.2 fl_chart: ^0.69.0 shared_preferences: ^2.3.4 + file_picker: ^11.0.3 dev_dependencies: flutter_test: diff --git a/app/test/app_shell_test.dart b/app/test/app_shell_test.dart index 9e66308..b69222e 100644 --- a/app/test/app_shell_test.dart +++ b/app/test/app_shell_test.dart @@ -3,11 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - Widget wrap(double width) { + Widget wrap(double width, {String location = '/'}) { return MediaQuery( data: MediaQueryData(size: Size(width, 800)), child: MaterialApp( - home: AppShell(location: '/', child: Container()), + home: AppShell(location: location, child: Container()), ), ); } @@ -23,4 +23,22 @@ void main() { expect(find.byType(NavigationBar), findsOneWidget); expect(find.byType(NavigationRail), findsNothing); }); + + testWidgets('the resolve screen keeps Импорт selected', (tester) async { + await tester.pumpWidget(wrap(1280, location: '/instruments/pending')); + final rail = tester.widget(find.byType(NavigationRail)); + expect( + (rail.destinations[rail.selectedIndex!].label as Text).data, + 'Импорт', + ); + }); + + testWidgets('a nested route keeps its own section selected', (tester) async { + await tester.pumpWidget(wrap(1280, location: '/portfolio/instrument/311')); + final rail = tester.widget(find.byType(NavigationRail)); + expect( + (rail.destinations[rail.selectedIndex!].label as Text).data, + 'Портфель', + ); + }); } diff --git a/app/test/imports_api_test.dart b/app/test/imports_api_test.dart new file mode 100644 index 0000000..83680c1 --- /dev/null +++ b/app/test/imports_api_test.dart @@ -0,0 +1,180 @@ +import 'package:fintracker_app/features/imports/data/imports_api.dart'; +import 'package:fintracker_app/features/pending/data/pending_api.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The exact payload from `docs/ai/import-contract.md`. If the backend renames a field this +/// test is the first thing that notices. +const _previewJson = { + 'id': 12, + 'broker': 'sber', + 'filename': 'S930W42_11022026_17092026.html', + 'sha256': '9f2c', + 'size_bytes': 87333, + 'parser_name': 'report_sber', + 'parser_version': '1', + 'parse_status': 'parsed', + 'error': null, + 'duplicate_of_id': null, + 'account_external_id': 'S930W42', + 'account_id': 57, + 'account_name': 'Сбер ИИС', + 'account_suggestions': [ + {'id': 57, 'name': 'Сбер ИИС', 'broker': 'sber', 'source_id': 'S930W42'}, + ], + 'period_from': '2026-02-11', + 'period_to': '2026-09-17', + 'uploaded_at': '2026-09-18T12:30:00+03:00', + 'committed_at': null, + 'counts': { + 'lines': 61, + 'events_total': 47, + 'events_new': 47, + 'events_duplicate': 0, + 'events_shadow': 0, + 'events_pending': 3, + 'by_kind': {'buy': 22, 'sell': 2, 'deposit': 8, 'commission': 15}, + }, + 'pending_instruments': [ + { + 'id': 4, + 'source': 'report_sber', + 'source_key': 'ISIN:RU000A1035S8', + 'isin': 'RU000A1035S8', + 'ticker': 'STME', + 'board': null, + 'name': 'Первая-ВечныйПортф БПИФ', + 'currency': 'RUB', + 'asset_class_hint': 'fund', + 'occurrences': 3, + 'sample_quantity': '439', + 'sample_price': '4.48', + 'status': 'pending', + 'instrument_id': null, + }, + ], + 'reconciliation': { + 'as_of': '2026-09-17', + 'positions': [ + { + 'instrument_id': 88, + 'instrument_name': 'Аэрофлот', + 'ticker': 'AFLT', + 'isin': 'RU0009062285', + 'qty_report': '130', + 'qty_derived': '130', + 'qty_delta': '0', + 'matches': true, + }, + ], + 'cash': [ + { + 'currency': 'RUB', + 'balance_report': '3171.34', + 'balance_derived': '3171.34', + 'delta': '0', + 'matches': true, + }, + ], + 'matches': true, + }, + 'warnings': ['Раздел «Купонный доход» отсутствует в файле'], + 'sample_events': [ + { + 'line_no': 3, + 'kind': 'buy', + 'trade_date': '2026-02-24', + 'settle_date': '2026-02-25', + 'instrument_key': 'ISIN:RU000A1035S8', + 'instrument_name': 'Первая-ВечныйПортф БПИФ', + 'instrument_id': null, + 'quantity': '439', + 'price': '4.48', + 'amount': '-1966.72', + 'currency': 'RUB', + 'fee': '0.39', + 'trade_no': '15678045077', + 'dedupe_key': 'a1b2', + 'is_duplicate': false, + 'description': 'Покупка', + }, + ], +}; + +void main() { + test('parses the contract ImportPreview payload', () { + final p = ImportPreview.fromJson(_previewJson); + + expect(p.id, 12); + expect(p.broker, 'sber'); + expect(p.parseStatus, 'parsed'); + expect(p.accountId, 57); + expect(p.periodFrom, DateTime.parse('2026-02-11')); + expect(p.counts.eventsPending, 3); + expect(p.counts.byKind['buy'], 22); + expect(p.accountSuggestions.single.sourceId, 'S930W42'); + expect(p.warnings, hasLength(1)); + expect(p.canCommit, isTrue); + + // Money and quantities stay strings end to end — never parsed into a double here. + final sample = p.sampleEvents.single; + expect(sample.amount, '-1966.72'); + expect(sample.price, '4.48'); + expect(sample.isDuplicate, isFalse); + + final recon = p.reconciliation!; + expect(recon.matches, isTrue); + expect(recon.positions.single.qtyDelta, '0'); + expect(recon.cash.single.balanceReport, '3171.34'); + + expect(p.pendingInstruments.single.ticker, 'STME'); + }); + + test('an ImportSummary without the preview-only sections still parses', () { + final json = Map.from(_previewJson) + ..remove('sample_events') + ..remove('pending_instruments') + ..remove('reconciliation'); + final p = ImportPreview.fromJson(json); + + expect(p.sampleEvents, isEmpty); + expect(p.pendingInstruments, isEmpty); + expect(p.reconciliation, isNull); + }); + + test('commit is blocked until an account is known', () { + final json = Map.from(_previewJson)..['account_id'] = null; + expect(ImportPreview.fromJson(json).canCommit, isFalse); + + final failed = Map.from(_previewJson)..['parse_status'] = 'failed'; + expect(ImportPreview.fromJson(failed).canCommit, isFalse); + expect(ImportPreview.fromJson(failed).canDelete, isTrue); + + final committed = Map.from(_previewJson)..['parse_status'] = 'committed'; + expect(ImportPreview.fromJson(committed).canDelete, isFalse); + }); + + test('parses PendingResolveResult and builds the create body as plain strings', () { + final r = PendingResolveResult.fromJson(const { + 'id': 4, + 'status': 'resolved', + 'instrument_id': 88, + 'events_bound': 3, + 'alias_created': true, + 'metrics_refreshed': true, + }); + expect(r.eventsBound, 3); + expect(r.instrumentId, 88); + + final body = const NewInstrument( + assetClass: 'index', + name: 'Индекс МосБиржи', + currency: 'RUB', + isin: '', + ticker: 'IMOEX', + lot: 1, + ).toJson(); + expect(body['asset_class'], 'index'); + expect(body.containsKey('isin'), isFalse, reason: 'empty optionals are omitted'); + expect(body['ticker'], 'IMOEX'); + }); +} diff --git a/app/test/phase4_models_test.dart b/app/test/phase4_models_test.dart new file mode 100644 index 0000000..7324a66 --- /dev/null +++ b/app/test/phase4_models_test.dart @@ -0,0 +1,407 @@ +import 'package:decimal/decimal.dart'; +import 'package:fintracker_app/features/goals/data/goals_api.dart'; +import 'package:fintracker_app/features/income/data/income_api.dart'; +import 'package:fintracker_app/features/portfolio/data/benchmarks_api.dart'; +import 'package:fintracker_app/features/rebalance/data/rebalance_api.dart'; +import 'package:fintracker_app/features/rebalance/weights.dart'; +import 'package:fintracker_app/features/tax/data/tax_api.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The exact payloads from `docs/ai/phase4-contract.md`. If the backend renames a field or +/// changes a shape, these tests are the first thing that notices — the hand-written layer +/// has no generated code to fail the build for it. +void main() { + group('income', () { + const calendarJson = { + 'as_of': '2026-09-18', + 'currency': 'RUB', + 'total_expected_rub': '14230.50', + 'entries': [ + { + 'instrument_id': 88, + 'ticker': 'SBER', + 'name': 'Сбербанк России', + 'kind': 'dividend', + 'expected_date': '2026-10-12', + 'record_date': '2026-10-09', + 'qty': '20', + 'per_unit': '34.84', + 'amount': '696.80', + 'currency': 'RUB', + 'amount_rub': '696.80', + 'basis': 'announced', + 'tax_withheld': null, + }, + ], + 'by_basis': {'schedule': '8100.00', 'announced': '4130.50', 'history': '2000.00'}, + }; + + test('parses the calendar, keeping every amount as a string', () { + final c = IncomeCalendar.fromJson(calendarJson); + expect(c.asOf, DateTime.utc(2026, 9, 18)); + expect(c.totalExpectedRub, '14230.50'); + expect(c.byBasis['history'], '2000.00'); + expect(c.entries.single.basis, 'announced'); + expect(c.entries.single.ticker, 'SBER'); + expect(c.entries.single.taxWithheld, isNull); + expect(c.entries.single.perUnit, '34.84', reason: 'never parsed through double'); + }); + + test('a null amount_rub stays null — no FX rate is not zero', () { + final json = Map.from(calendarJson); + json['entries'] = [ + {...(calendarJson['entries'] as List).first as Map, 'amount_rub': null}, + ]; + expect(IncomeCalendar.fromJson(json).entries.single.amountRub, isNull); + }); + + test('parses the history rows and totals', () { + final h = IncomeHistory.fromJson(const { + 'rows': [ + { + 'month': '2026-08-01', + 'kind': 'coupon', + 'currency': 'RUB', + 'amount': '1204.11', + 'amount_rub': '1204.11', + 'tax_withheld': '156.00', + 'payment_count': 3, + }, + ], + 'totals': {'amount_rub': '24518.30', 'tax_withheld_rub': '3187.00'}, + }); + expect(h.rows.single.month, DateTime.utc(2026, 8)); + expect(h.rows.single.paymentCount, 3); + expect(h.totalRub, '24518.30'); + expect(h.taxWithheldRub, '3187.00'); + }); + + test('parses the forecast with its by_basis split and null yield', () { + final f = IncomeForecast.fromJson(const { + 'months': [ + { + 'month': '2026-10-01', + 'amount_rub': '1830.20', + 'by_basis': {'schedule': '1133.40', 'announced': '696.80', 'history': '0'}, + }, + ], + 'total_rub': '21960.00', + 'annual_yield_on_value': '0.081', + 'warnings': ['у 3 инструментов нет истории выплат — в прогноз не вошли'], + }); + expect(f.months.single.byBasis['schedule'], '1133.40'); + expect(f.totalRub, '21960.00'); + expect(f.annualYieldOnValue, '0.081'); + expect(f.warnings, hasLength(1)); + expect(f.bases, ['schedule', 'announced', 'history'], reason: 'contract order'); + + final noYield = IncomeForecast.fromJson(const { + 'months': [], + 'total_rub': '0', + 'annual_yield_on_value': null, + }); + expect(noYield.annualYieldOnValue, isNull); + }); + }); + + group('rebalance', () { + test('parses a target set and validates the sum exactly', () { + final set = TargetSet.fromJson(const { + 'dimension': 'asset_class', + 'weights_sum': '1.00', + 'targets': [ + {'bucket': 'share', 'target_weight': '0.60', 'band': '0.05', 'note': null}, + {'bucket': 'bond', 'target_weight': '0.30', 'band': '0.05'}, + {'bucket': 'cash', 'target_weight': '0.10', 'band': '0.02'}, + ], + }); + expect(set.targets, hasLength(3)); + expect(set.localSum, Decimal.one); + expect(set.sumIsValid, isTrue); + expect(set.targets.first.band, '0.05'); + + // 0.1 + 0.2 + 0.7 is exactly 1 in Decimal and would not be in double + final tenths = TargetSet(dimension: 'asset_class', targets: const [ + TargetWeight(bucket: 'a', targetWeight: '0.1'), + TargetWeight(bucket: 'b', targetWeight: '0.2'), + TargetWeight(bucket: 'c', targetWeight: '0.7'), + ]); + expect(tenths.sumIsValid, isTrue); + + final short = TargetSet(dimension: 'asset_class', targets: const [ + TargetWeight(bucket: 'a', targetWeight: '0.9'), + ]); + expect(short.sumIsValid, isFalse); + }); + + test('the PUT body omits empty optionals and keeps shares as strings', () { + final body = const TargetWeight(bucket: 'bond', targetWeight: '0.30', band: '0.05') + .toJson(); + expect(body['target_weight'], '0.30'); + expect(body.containsKey('note'), isFalse); + }); + + test('percent input round-trips through shares without float error', () { + expect(percentTextToShare('60'), '0.6'); + expect(percentTextToShare('33,33'), '0.3333'); + expect(shareToPercentText('0.3333'), '33,33'); + expect(shareToPercentText('0.60'), '60'); + expect(percentTextToShare('abc'), isNull); + expect(formatShareAsPercent('0.032', signed: true), '+3,20 %'); + expect(formatShareAsPercent(null), '—'); + }); + + test('parses the rebalance plan, including within_band and blocked_by_cash', () { + final plan = RebalancePlan.fromJson(const { + 'portfolio_id': 1, + 'dimension': 'asset_class', + 'as_of': '2026-09-18', + 'total_value_rub': '1284300.00', + 'cash_available_rub': '48120.87', + 'buckets': [ + { + 'bucket': 'share', + 'current_value_rub': '812000.00', + 'current_weight': '0.632', + 'target_weight': '0.60', + 'drift': '0.032', + 'within_band': true, + 'delta_value_rub': '-41420.00', + 'trades': [ + { + 'instrument_id': 88, + 'ticker': 'SBER', + 'name': 'Сбербанк России', + 'action': 'sell', + 'suggested_qty': '150', + 'lot': 10, + 'price': '275.89', + 'price_currency': 'RUB', + 'amount_rub': '41383.50', + 'blocked_by_cash': false, + }, + ], + }, + ], + 'warnings': ['у 2 инструментов нет цены — в рекомендации не вошли'], + }); + + expect(plan.portfolioId, 1); + expect(plan.buckets.single.withinBand, isTrue); + expect(plan.everythingWithinBand, isTrue); + final trade = plan.buckets.single.trades.single; + expect(trade.action, 'sell'); + expect(trade.suggestedQty, '150'); + expect(trade.lot, 10); + expect(trade.blockedByCash, isFalse); + expect(plan.warnings, hasLength(1)); + }); + + test('suggested_qty stays null when there is no price', () { + final trade = RebalanceTrade.fromJson(const { + 'instrument_id': 91, + 'ticker': 'SIBN6P4', + 'action': 'buy', + 'suggested_qty': null, + 'lot': 1, + 'price': null, + 'amount_rub': null, + 'blocked_by_cash': false, + }); + expect(trade.suggestedQty, isNull, reason: 'null is «нет цены», never 0'); + expect(trade.price, isNull); + }); + }); + + group('benchmarks', () { + test('parses a period row with excess, kind and days_skipped on both sides', () { + final rows = [ + for (final r in const [ + { + 'period': '1y', + 'date_from': '2025-09-18', + 'date_to': '2026-09-18', + 'portfolio_twr': '0.184', + 'portfolio_twr_annualized': '0.184', + 'portfolio_days_skipped': 3, + 'benchmarks': [ + { + 'benchmark_id': 1, + 'code': 'MCFTR', + 'kind': 'total_return', + 'twr': '0.121', + 'twr_annualized': '0.121', + 'days_skipped': 0, + 'excess': '0.063', + }, + { + 'benchmark_id': 2, + 'code': 'IMOEX', + 'kind': 'price', + 'twr': '0.084', + 'days_skipped': 2, + 'excess': '0.100', + }, + ], + }, + ]) + BenchmarkRow.fromJson(r), + ]; + + final row = rows.single; + expect(row.period, '1y'); + expect(row.portfolioDaysSkipped, 3); + expect(row.hasSkippedDays, isTrue); + expect(row.benchmarks.first.isPriceIndex, isFalse); + expect(row.benchmarks.last.isPriceIndex, isTrue, + reason: 'a price index must be markable'); + expect(row.benchmarks.first.excess, '0.063'); + }); + }); + + group('goals', () { + test('parses a goal and builds a body without the id', () { + final goal = Goal.fromJson(const { + 'id': 3, + 'name': 'Подушка', + 'scope': 'account:12', + 'target_amount': '1000000', + 'currency': 'RUB', + 'target_date': '2028-01-01', + 'monthly_contribution': '30000', + 'note': null, + 'archived': false, + }); + expect(goal.id, 3); + expect(goal.targetDate, DateTime.utc(2028)); + final body = goal.toJson(); + expect(body.containsKey('id'), isFalse); + expect(body['target_date'], '2028-01-01'); + expect(body.containsKey('note'), isFalse); + }); + + test('parses progress with a projected date', () { + final p = GoalProgress.fromJson(const { + 'goal_id': 3, + 'as_of': '2026-09-18', + 'current_value_rub': '412800.00', + 'target_amount_rub': '1000000.00', + 'progress': '0.4128', + 'projected_date': '2027-11-14', + 'basis': 'xirr', + 'assumed_rate': '0.142', + 'monthly_needed_rub': '42300.00', + 'on_track': false, + }); + expect(p.projectedDate, DateTime.utc(2027, 11, 14)); + expect(p.isUnreachable, isFalse); + expect(p.basis, 'xirr'); + expect(p.onTrack, isFalse); + }); + + test('a null projected_date means «не достигается», not «неизвестно»', () { + final p = GoalProgress.fromJson(const { + 'goal_id': 3, + 'progress': '0.05', + 'projected_date': null, + 'basis': 'contribution', + 'monthly_needed_rub': '99000.00', + 'on_track': false, + }); + expect(p.projectedDate, isNull); + expect(p.isUnreachable, isTrue); + + // basis "none" is the other case: nothing to project from at all + final noBasis = GoalProgress.fromJson(const { + 'goal_id': 4, + 'projected_date': null, + 'basis': 'none', + 'on_track': false, + }); + expect(noBasis.isUnreachable, isFalse); + }); + }); + + group('tax', () { + test('parses the year summary, keeping estimated и disclaimer', () { + final s = TaxSummary.fromJson(const { + 'year': 2026, + 'estimated': true, + 'tax_rate': '0.13', + 'accounts': [ + { + 'account_id': 12, + 'account_name': 'ИИС Сбер', + 'dividends_gross_rub': '12400.00', + 'coupons_gross_rub': '8100.00', + 'tax_withheld_rub': '2665.00', + 'realized_gain_rub': '31200.00', + 'realized_loss_rub': '-4100.00', + 'ldv_exempt_rub': '12000.00', + 'taxable_base_rub': '15100.00', + 'estimated_tax_rub': '1963.00', + }, + ], + 'totals': { + 'dividends_gross_rub': '12400.00', + 'estimated_tax_rub': '1963.00', + }, + 'disclaimer': 'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.', + }); + + expect(s.year, 2026); + expect(s.estimated, isTrue); + expect(s.taxRate, '0.13'); + expect(s.accounts.single.accountName, 'ИИС Сбер'); + expect(s.accounts.single.realizedLossRub, '-4100.00'); + expect(s.totals?.estimatedTaxRub, '1963.00'); + expect(s.disclaimer, contains('Оценка')); + }); + + test('estimated defaults to true when the field is missing', () { + expect(TaxSummary.fromJson(const {'year': 2026}).estimated, isTrue); + }); + + test('parses lots and flags the ones close to ЛДВ', () { + final lots = [ + for (final l in const [ + { + 'lot_id': 812, + 'instrument_id': 88, + 'ticker': 'SBER', + 'account_id': 12, + 'open_date': '2024-03-14', + 'qty_remaining': '20', + 'cost_rub': '4800.00', + 'market_value_rub': '5517.80', + 'unrealized_gain_rub': '717.80', + 'ldv_eligible': false, + 'ldv_date': '2027-03-14', + 'days_to_ldv': 177, + 'tax_if_sold_now_rub': '93.31', + }, + { + 'lot_id': 813, + 'ticker': 'LKOH', + 'ldv_eligible': false, + 'days_to_ldv': 400, + }, + { + 'lot_id': 814, + 'ticker': 'GAZP', + 'ldv_eligible': true, + 'days_to_ldv': 0, + }, + ]) + TaxLot.fromJson(l), + ]; + + expect(lots[0].ldvDate, DateTime.utc(2027, 3, 14)); + expect(lots[0].daysToLdv, 177); + expect(lots[0].nearLdv, isTrue, reason: '177 дн. — меньше полугода'); + expect(lots[1].nearLdv, isFalse); + expect(lots[2].nearLdv, isFalse, reason: 'ЛДВ уже действует'); + expect(lots[0].taxIfSoldNowRub, '93.31'); + }); + }); +}