feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков
Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income, /rebalance, /tax, аналитика-хаб с benchmarks_card) — по docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для загрузки отчёта.
This commit is contained in:
@@ -0,0 +1,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<Map<String, dynamic>> asObjects(Object? v) => v is List
|
||||||
|
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
|
||||||
|
: const [];
|
||||||
|
|
||||||
|
Map<String, dynamic>? asObject(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null;
|
||||||
|
|
||||||
|
List<String> asStrings(Object? v) =>
|
||||||
|
v is List ? v.map((e) => e.toString()).toList() : const <String>[];
|
||||||
|
|
||||||
|
/// `{"schedule": "8100.00", ...}` — a string→decimal-string map such as `by_basis`.
|
||||||
|
Map<String, String> 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<String> 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);
|
||||||
|
}
|
||||||
@@ -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:<id> | portfolio:<id>` 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<List<ScopeOut>>(
|
||||||
|
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<String>(
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<Goal>> list() async {
|
||||||
|
final r = await _dio.get<List<dynamic>>(_base);
|
||||||
|
return (r.data ?? const [])
|
||||||
|
.map((e) => Goal.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Goal> create(Goal goal) async {
|
||||||
|
final r = await _dio.post<Map<String, dynamic>>(_base, data: goal.toJson());
|
||||||
|
return Goal.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Goal> patch(int id, Map<String, dynamic> changes) async {
|
||||||
|
final r = await _dio.patch<Map<String, dynamic>>('$_base/$id', data: changes);
|
||||||
|
return Goal.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) => _dio.delete<void>('$_base/$id');
|
||||||
|
|
||||||
|
Future<GoalProgress> progress(int id) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('$_base/$id/progress');
|
||||||
|
return GoalProgress.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GoalEditDialog> createState() => _GoalEditDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GoalEditDialogState extends ConsumerState<GoalEditDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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 <ScopeOut>[];
|
||||||
|
|
||||||
|
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<String>(
|
||||||
|
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('Сохранить'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GoalsPage> createState() => _GoalsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GoalsPageState extends ConsumerState<GoalsPage> {
|
||||||
|
bool _showArchived = false;
|
||||||
|
|
||||||
|
void _snack(String message) =>
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
|
||||||
|
Future<void> _create() async {
|
||||||
|
final goal = await showDialog<Goal>(
|
||||||
|
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<void> _edit(Goal goal) async {
|
||||||
|
final updated = await showDialog<Goal>(
|
||||||
|
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<void> _delete(Goal goal) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GoalsApi>((ref) => GoalsApi(ref.watch(apiProvider).dio));
|
||||||
|
|
||||||
|
final goalsProvider =
|
||||||
|
FutureProvider.autoDispose<List<Goal>>((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<GoalProgress, int>((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);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, int> byKind;
|
||||||
|
|
||||||
|
static ImportCounts fromJson(Map<String, dynamic>? 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<String, dynamic> 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<String, dynamic> 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<ReconPosition> positions;
|
||||||
|
final List<ReconCash> cash;
|
||||||
|
|
||||||
|
bool get isEmpty => positions.isEmpty && cash.isEmpty;
|
||||||
|
|
||||||
|
static Reconciliation? fromJson(Map<String, dynamic>? 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<String, dynamic> 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<AccountSuggestion> accountSuggestions;
|
||||||
|
final DateTime? periodFrom;
|
||||||
|
final DateTime? periodTo;
|
||||||
|
final DateTime? uploadedAt;
|
||||||
|
final DateTime? committedAt;
|
||||||
|
final ImportCounts counts;
|
||||||
|
final List<PendingInstrument> pendingInstruments;
|
||||||
|
final Reconciliation? reconciliation;
|
||||||
|
final List<String> warnings;
|
||||||
|
final List<SampleEvent> 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<String, dynamic> 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<String, dynamic> 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<int>? bytes;
|
||||||
|
final String? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportsApi {
|
||||||
|
const ImportsApi(this._dio);
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
static const _base = '/api/v1/imports';
|
||||||
|
|
||||||
|
Future<List<ImportPreview>> list({int limit = 50, int offset = 0, String? status}) async {
|
||||||
|
final r = await _dio.get<List<dynamic>>(
|
||||||
|
_base,
|
||||||
|
queryParameters: {
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
'status': ?status,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return (r.data ?? const [])
|
||||||
|
.map((e) => ImportPreview.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ImportPreview> get(int id) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('$_base/$id');
|
||||||
|
return ImportPreview.fromJson(r.data!);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ImportPreview> 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<Map<String, dynamic>>(_base, data: form);
|
||||||
|
return ImportPreview.fromJson(r.data!);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ImportResult> commit(
|
||||||
|
int id, {
|
||||||
|
int? accountId,
|
||||||
|
bool confirmDuplicates = false,
|
||||||
|
bool dryRun = false,
|
||||||
|
}) async {
|
||||||
|
final r = await _dio.post<Map<String, dynamic>>('$_base/$id/commit', data: {
|
||||||
|
'account_id': ?accountId,
|
||||||
|
'confirm_duplicates': confirmDuplicates,
|
||||||
|
'dry_run': dryRun,
|
||||||
|
});
|
||||||
|
return ImportResult.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(int id) => _dio.delete<void>('$_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<Map<String, dynamic>> asList(Object? v) => v is List
|
||||||
|
? v.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList()
|
||||||
|
: const [];
|
||||||
|
|
||||||
|
Map<String, dynamic>? asMap(Object? v) => v is Map ? Map<String, dynamic>.from(v) : null;
|
||||||
@@ -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<PickedReport?> pick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The extensions the report parsers accept.
|
||||||
|
const reportExtensions = ['html', 'htm', 'xlsx', 'csv'];
|
||||||
|
|
||||||
|
class FilePickerReportPicker implements ReportPicker {
|
||||||
|
const FilePickerReportPicker();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PickedReport?> 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<ReportPicker>((ref) => const FilePickerReportPicker());
|
||||||
@@ -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<ImportPreviewPage> createState() => _ImportPreviewPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportPreviewPageState extends ConsumerState<ImportPreviewPage> {
|
||||||
|
/// 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<void> _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<void> _delete(ImportPreview preview) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<int>(
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ImportsPage> createState() => _ImportsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportsPageState extends ConsumerState<ImportsPage> {
|
||||||
|
bool _uploading = false;
|
||||||
|
|
||||||
|
Future<void> _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 = <String?, String>{
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('.', ',')} МБ';
|
||||||
|
}
|
||||||
@@ -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<ImportsApi>((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<String?>((ref) => null);
|
||||||
|
|
||||||
|
final importsListProvider = FutureProvider.autoDispose<List<ImportPreview>>((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<ImportPreview, int>((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);
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> 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,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SampleEvent> 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');
|
||||||
|
}
|
||||||
@@ -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<IncomeEntry> 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<MapEntry<DateTime, List<IncomeEntry>>> _groupByMonth(List<IncomeEntry> entries) {
|
||||||
|
final groups = <DateTime, List<IncomeEntry>>{};
|
||||||
|
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<String> _orderedBases(Iterable<String> bases) {
|
||||||
|
const order = ['schedule', 'announced', 'history', 'paid'];
|
||||||
|
final set = bases.toSet();
|
||||||
|
return [...order.where(set.contains), ...set.where((b) => !order.contains(b))];
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<IncomeEntry> entries;
|
||||||
|
final Map<String, String> byBasis;
|
||||||
|
|
||||||
|
static IncomeCalendar fromJson(Map<String, dynamic> 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<String, dynamic> 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<IncomeHistoryRow> rows;
|
||||||
|
final String totalRub;
|
||||||
|
final String taxWithheldRub;
|
||||||
|
|
||||||
|
static IncomeHistory fromJson(Map<String, dynamic> 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<String, String> byBasis;
|
||||||
|
|
||||||
|
static ForecastMonth fromJson(Map<String, dynamic> 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<ForecastMonth> months;
|
||||||
|
final String totalRub;
|
||||||
|
|
||||||
|
/// Null when the current value is unknown — shown as an em dash, never as 0 %.
|
||||||
|
final String? annualYieldOnValue;
|
||||||
|
final List<String> warnings;
|
||||||
|
|
||||||
|
/// Every basis present anywhere in the forecast, in contract order.
|
||||||
|
List<String> 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<String, dynamic> 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<IncomeCalendar> calendar({
|
||||||
|
String scope = 'all',
|
||||||
|
DateTime? dateFrom,
|
||||||
|
DateTime? dateTo,
|
||||||
|
bool includePaid = false,
|
||||||
|
}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('$_base/calendar', queryParameters: {
|
||||||
|
'scope': scope,
|
||||||
|
'date_from': ?_isoDate(dateFrom),
|
||||||
|
'date_to': ?_isoDate(dateTo),
|
||||||
|
'include_paid': includePaid,
|
||||||
|
});
|
||||||
|
return IncomeCalendar.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<IncomeHistory> history({
|
||||||
|
String scope = 'all',
|
||||||
|
String group = 'month',
|
||||||
|
DateTime? dateFrom,
|
||||||
|
DateTime? dateTo,
|
||||||
|
String? kind,
|
||||||
|
}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('$_base/history', queryParameters: {
|
||||||
|
'scope': scope,
|
||||||
|
'group': group,
|
||||||
|
'date_from': ?_isoDate(dateFrom),
|
||||||
|
'date_to': ?_isoDate(dateTo),
|
||||||
|
'kind': ?kind,
|
||||||
|
});
|
||||||
|
return IncomeHistory.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<IncomeForecast> forecast({String scope = 'all', int months = 12}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>('$_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')}';
|
||||||
|
}
|
||||||
@@ -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<String> 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<String> 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<ForecastMonth> months;
|
||||||
|
final List<String> bases;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final maxY = months.fold<double>(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 = <BarChartRodStackItem>[];
|
||||||
|
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<ForecastMonth> months;
|
||||||
|
final List<String> 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,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IncomeHistoryRow> rows) {
|
||||||
|
final sums = <DateTime, Decimal>{};
|
||||||
|
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<double>(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<IncomeHistoryRow> 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}')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IncomeApi>((ref) => IncomeApi(ref.watch(apiProvider).dio));
|
||||||
|
|
||||||
|
/// How far the calendar looks ahead, in months. 12 is the contract default.
|
||||||
|
final calendarMonthsProvider = StateProvider<int>((ref) => 12);
|
||||||
|
|
||||||
|
final calendarIncludePaidProvider = StateProvider<bool>((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<IncomeCalendar>((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<int>((ref) => 24);
|
||||||
|
|
||||||
|
final incomeHistoryProvider = FutureProvider.autoDispose<IncomeHistory>((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<int>((ref) => 12);
|
||||||
|
|
||||||
|
final incomeForecastProvider = FutureProvider.autoDispose<IncomeForecast>((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);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<PendingInstrument>> list({
|
||||||
|
String status = 'pending',
|
||||||
|
int limit = 100,
|
||||||
|
int offset = 0,
|
||||||
|
}) async {
|
||||||
|
final r = await _dio.get<List<dynamic>>(
|
||||||
|
_base,
|
||||||
|
queryParameters: {'status': status, 'limit': limit, 'offset': offset},
|
||||||
|
);
|
||||||
|
return (r.data ?? const [])
|
||||||
|
.map((e) => PendingInstrument.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PendingResolveResult> link(int id, int instrumentId) =>
|
||||||
|
_resolve(id, {'action': 'link', 'instrument_id': instrumentId});
|
||||||
|
|
||||||
|
Future<PendingResolveResult> create(int id, NewInstrument instrument) =>
|
||||||
|
_resolve(id, {'action': 'create', 'instrument': instrument.toJson()});
|
||||||
|
|
||||||
|
Future<PendingResolveResult> ignore(int id) => _resolve(id, {'action': 'ignore'});
|
||||||
|
|
||||||
|
Future<PendingResolveResult> _resolve(int id, Map<String, dynamic> body) async {
|
||||||
|
final r = await _dio.post<Map<String, dynamic>>('$_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);
|
||||||
|
}
|
||||||
@@ -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<PendingInstrumentsPage> createState() => _PendingInstrumentsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PendingInstrumentsPageState extends ConsumerState<PendingInstrumentsPage> {
|
||||||
|
int? _busyId;
|
||||||
|
|
||||||
|
void _snack(String message) =>
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
|
||||||
|
|
||||||
|
Future<void> _run(int id, Future<PendingResolveResult> 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<void> _link(PendingInstrument p) async {
|
||||||
|
final instrumentId = await showDialog<int>(
|
||||||
|
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<void> _create(PendingInstrument p) async {
|
||||||
|
final instrument = await showDialog<NewInstrument>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => CreateInstrumentDialog(pending: p),
|
||||||
|
);
|
||||||
|
if (instrument == null || !mounted) return;
|
||||||
|
await _run(p.id, () => ref.read(pendingApiProvider).create(p.id, instrument));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ignore(PendingInstrument p) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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 = <String>[
|
||||||
|
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 = <String>[
|
||||||
|
'встречается ${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('Игнорировать'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PendingApi>((ref) => PendingApi(ref.watch(apiProvider).dio));
|
||||||
|
|
||||||
|
/// `pending | resolved | ignored | all` — the filter of the resolve screen.
|
||||||
|
final pendingStatusFilterProvider = StateProvider<String>((ref) => 'pending');
|
||||||
|
|
||||||
|
final pendingInstrumentsProvider =
|
||||||
|
FutureProvider.autoDispose<List<PendingInstrument>>((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<int>((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<List<InstrumentOut>, 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 [];
|
||||||
|
});
|
||||||
@@ -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<CreateInstrumentDialog> createState() => _CreateInstrumentDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateInstrumentDialogState extends State<CreateInstrumentDialog> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<String>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LinkInstrumentDialog> createState() => _LinkInstrumentDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LinkInstrumentDialogState extends ConsumerState<LinkInstrumentDialog> {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/api/api_client.dart';
|
||||||
|
import '../../core/utils/ru_date.dart';
|
||||||
|
import '../../core/widgets/async_value_view.dart';
|
||||||
|
import '../../core/widgets/empty_state.dart';
|
||||||
|
import 'data/benchmarks_api.dart';
|
||||||
|
import 'labels.dart';
|
||||||
|
import 'providers.dart';
|
||||||
|
|
||||||
|
final benchmarksApiProvider =
|
||||||
|
Provider<BenchmarksApi>((ref) => BenchmarksApi(ref.watch(apiProvider).dio));
|
||||||
|
|
||||||
|
/// Benchmark comparison for the current scope. Part of Портфель, not a screen of its own:
|
||||||
|
/// «на сколько я обогнал индекс» is a property of the portfolio, not a separate subject.
|
||||||
|
final benchmarkRowsProvider = FutureProvider.autoDispose<List<BenchmarkRow>>((ref) async {
|
||||||
|
final scope = ref.watch(scopeProvider);
|
||||||
|
return ref.watch(benchmarksApiProvider).compare(scope: scope);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The comparison block on Портфель.
|
||||||
|
///
|
||||||
|
/// Two things are always marked: a **price** index (no dividends — it understates the
|
||||||
|
/// holder's result by construction) and any non-zero `days_skipped` on either side (the two
|
||||||
|
/// returns then do not cover the same days, so the difference is not strictly like-for-like).
|
||||||
|
class BenchmarksCard extends ConsumerWidget {
|
||||||
|
const BenchmarksCard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final rows = ref.watch(benchmarkRowsProvider);
|
||||||
|
|
||||||
|
return AsyncValueView(
|
||||||
|
value: rows,
|
||||||
|
onRetry: () => ref.invalidate(benchmarkRowsProvider),
|
||||||
|
data: (data) => data.isEmpty
|
||||||
|
? const EmptyState(
|
||||||
|
icon: Icons.compare_arrows,
|
||||||
|
message: 'Бенчмарки не настроены — сравнивать не с чем.',
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (final row in data)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _PeriodBlock(row: row),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PeriodBlock extends StatelessWidget {
|
||||||
|
const _PeriodBlock({required this.row});
|
||||||
|
|
||||||
|
final BenchmarkRow row;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(periodLabel(row.period), style: theme.textTheme.titleSmall),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (row.dateFrom != null && row.dateTo != null)
|
||||||
|
Text(
|
||||||
|
'${ruDate(row.dateFrom!)} – ${ruDate(row.dateTo!)}',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'портфель ${formatPercent(row.portfolioTwr)}',
|
||||||
|
style: theme.textTheme.titleSmall
|
||||||
|
?.copyWith(color: signColor(context, row.portfolioTwr)),
|
||||||
|
),
|
||||||
|
if (row.portfolioDaysSkipped > 0) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_SkippedChip(days: row.portfolioDaysSkipped, side: 'портфеля'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
for (final b in row.benchmarks) _BenchmarkRowView(result: b),
|
||||||
|
if (row.hasSkippedDays)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
'Сетка дат не полностью совпадает: часть дней пропущена, '
|
||||||
|
'сравнение не строго like-for-like.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 20),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BenchmarkRowView extends StatelessWidget {
|
||||||
|
const _BenchmarkRowView({required this.result});
|
||||||
|
|
||||||
|
final BenchmarkResult result;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(result.code, style: theme.textTheme.bodyMedium),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
if (result.isPriceIndex) const _PriceIndexChip(),
|
||||||
|
if (result.daysSkipped > 0) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_SkippedChip(days: result.daysSkipped, side: 'бенчмарка'),
|
||||||
|
],
|
||||||
|
const Spacer(),
|
||||||
|
Text(formatPercent(result.twr), style: theme.textTheme.bodyMedium),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: 92,
|
||||||
|
child: Text(
|
||||||
|
formatPercent(result.excess),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: theme.textTheme.bodyMedium
|
||||||
|
?.copyWith(color: signColor(context, result.excess)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PriceIndexChip extends StatelessWidget {
|
||||||
|
const _PriceIndexChip();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Ценовой индекс: не учитывает дивиденды и систематически занижает '
|
||||||
|
'результат держателя. Сравнение с ним — нижняя граница, а не эталон.',
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text('ценовой индекс', style: Theme.of(context).textTheme.labelSmall),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SkippedChip extends StatelessWidget {
|
||||||
|
const _SkippedChip({required this.days, required this.side});
|
||||||
|
|
||||||
|
final int days;
|
||||||
|
final String side;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: 'В расчёте $side пропущено $days дн. — в эти дни не было цены',
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.info_outline, size: 14),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text('$days дн.', style: Theme.of(context).textTheme.labelSmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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<BenchmarkResult> benchmarks;
|
||||||
|
|
||||||
|
bool get hasSkippedDays =>
|
||||||
|
portfolioDaysSkipped > 0 || benchmarks.any((b) => b.daysSkipped > 0);
|
||||||
|
|
||||||
|
static BenchmarkRow fromJson(Map<String, dynamic> 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<List<BenchmarkRow>> compare({
|
||||||
|
String scope = 'all',
|
||||||
|
List<String> periods = const ['1m', 'ytd', '1y', 'all'],
|
||||||
|
}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import '../../core/utils/ru_date.dart';
|
|||||||
import '../../core/widgets/async_value_view.dart';
|
import '../../core/widgets/async_value_view.dart';
|
||||||
import '../../core/widgets/empty_state.dart';
|
import '../../core/widgets/empty_state.dart';
|
||||||
import '../../core/widgets/money_text.dart';
|
import '../../core/widgets/money_text.dart';
|
||||||
|
import 'benchmarks_card.dart';
|
||||||
import 'labels.dart';
|
import 'labels.dart';
|
||||||
import 'providers.dart';
|
import 'providers.dart';
|
||||||
|
|
||||||
@@ -59,6 +60,11 @@ class HoldingsTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
const _Card(
|
||||||
|
title: 'Сравнение с бенчмарками',
|
||||||
|
child: BenchmarksCard(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
_Card(
|
_Card(
|
||||||
title: 'Позиции',
|
title: 'Позиции',
|
||||||
child: AsyncValueView(
|
child: AsyncValueView(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:fintracker_api/fintracker_api.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/api/api_client.dart';
|
import '../../core/api/api_client.dart';
|
||||||
|
import 'benchmarks_card.dart' show benchmarkRowsProvider;
|
||||||
|
|
||||||
/// The reporting unit every portfolio screen is scoped to: `all`, `account:<id>` or
|
/// The reporting unit every portfolio screen is scoped to: `all`, `account:<id>` or
|
||||||
/// `portfolio:<id>`. Held in one place so switching it on Позиции also switches Аллокация
|
/// `portfolio:<id>`. Held in one place so switching it on Позиции also switches Аллокация
|
||||||
@@ -67,4 +68,6 @@ void invalidatePortfolioProviders(WidgetRef ref) {
|
|||||||
ref.invalidate(allocationProvider);
|
ref.invalidate(allocationProvider);
|
||||||
ref.invalidate(valueSeriesProvider);
|
ref.invalidate(valueSeriesProvider);
|
||||||
ref.invalidate(instrumentProvider);
|
ref.invalidate(instrumentProvider);
|
||||||
|
// the benchmark block lives on Позиции and is scoped the same way
|
||||||
|
ref.invalidate(benchmarkRowsProvider);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, dynamic> toJson() => {
|
||||||
|
'bucket': bucket,
|
||||||
|
'target_weight': targetWeight,
|
||||||
|
'band': ?band,
|
||||||
|
'note': ?note,
|
||||||
|
};
|
||||||
|
|
||||||
|
static TargetWeight fromJson(Map<String, dynamic> 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<TargetWeight> 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<String, dynamic> toJson() => {
|
||||||
|
'dimension': dimension,
|
||||||
|
'targets': [for (final t in targets) t.toJson()],
|
||||||
|
};
|
||||||
|
|
||||||
|
static TargetSet fromJson(Map<String, dynamic> 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<String, dynamic> 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<RebalanceTrade> trades;
|
||||||
|
|
||||||
|
static RebalanceBucket fromJson(Map<String, dynamic> 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<RebalanceBucket> buckets;
|
||||||
|
final List<String> warnings;
|
||||||
|
|
||||||
|
bool get everythingWithinBand => buckets.isNotEmpty && buckets.every((b) => b.withinBand);
|
||||||
|
|
||||||
|
static RebalancePlan fromJson(Map<String, dynamic> 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<TargetSet> getTargets(int portfolioId, {String dimension = 'asset_class'}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'$_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<TargetSet> putTargets(int portfolioId, TargetSet set) async {
|
||||||
|
final r = await _dio.put<Map<String, dynamic>>(
|
||||||
|
'$_base/$portfolioId/targets',
|
||||||
|
data: set.toJson(),
|
||||||
|
);
|
||||||
|
return TargetSet.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<RebalancePlan> plan(
|
||||||
|
int portfolioId, {
|
||||||
|
String dimension = 'asset_class',
|
||||||
|
String? cashAvailable,
|
||||||
|
}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'$_base/$portfolioId/rebalance',
|
||||||
|
queryParameters: {'dimension': dimension, 'cash_available': ?cashAvailable},
|
||||||
|
);
|
||||||
|
return RebalancePlan.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<String> 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RebalanceApi>((ref) => RebalanceApi(ref.watch(apiProvider).dio));
|
||||||
|
|
||||||
|
/// A portfolio the user can rebalance, derived from the scope list the analytics API
|
||||||
|
/// already publishes (`portfolio:<id>`): 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<List<PortfolioRef>>((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<int?>((ref) {
|
||||||
|
final scope = ref.watch(scopeProvider);
|
||||||
|
if (!scope.startsWith('portfolio:')) return null;
|
||||||
|
return int.tryParse(scope.substring('portfolio:'.length));
|
||||||
|
});
|
||||||
|
|
||||||
|
final targetDimensionProvider = StateProvider<String>((ref) => 'asset_class');
|
||||||
|
|
||||||
|
/// What-if cash for the recommendations, as a decimal string. Null = use the real balance.
|
||||||
|
final whatIfCashProvider = StateProvider<String?>((ref) => null);
|
||||||
|
|
||||||
|
final targetsProvider = FutureProvider.autoDispose<TargetSet>((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<RebalancePlan?>((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);
|
||||||
|
}
|
||||||
@@ -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 <PortfolioRef>[];
|
||||||
|
final selected = ref.watch(selectedPortfolioProvider);
|
||||||
|
if (portfolios.length < 2 || selected == null) return const SizedBox.shrink();
|
||||||
|
return DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<int>(
|
||||||
|
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<String>(
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TargetsTab> createState() => _TargetsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TargetsTabState extends ConsumerState<TargetsTab> {
|
||||||
|
/// 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<TargetWeight>? _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<void> _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 <TargetWeight>[];
|
||||||
|
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<TargetWeight> 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 <String>[];
|
||||||
|
|
||||||
|
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<String>(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('.', ',')} %';
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,18 +2,46 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class _Destination {
|
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 String path;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final IconData selectedIcon;
|
final IconData selectedIcon;
|
||||||
final String label;
|
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<String> 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 = [
|
const _destinations = [
|
||||||
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор'),
|
_Destination('/', Icons.dashboard_outlined, Icons.dashboard, 'Обзор', primary: true),
|
||||||
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета'),
|
_Destination('/accounts', Icons.account_balance_outlined, Icons.account_balance, 'Счета',
|
||||||
_Destination('/portfolio', Icons.pie_chart_outline, Icons.pie_chart, 'Портфель'),
|
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('/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('/cashflow', Icons.swap_horiz_outlined, Icons.swap_horiz, 'Потоки'),
|
||||||
_Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'),
|
_Destination('/categories', Icons.category_outlined, Icons.category, 'Категории'),
|
||||||
_Destination(
|
_Destination(
|
||||||
@@ -31,6 +59,10 @@ const _wideBreakpoint = 1200.0;
|
|||||||
/// Adaptive navigation shell around the current route: a bottom
|
/// Adaptive navigation shell around the current route: a bottom
|
||||||
/// [NavigationBar] on narrow surfaces, a [NavigationRail] (collapsed or
|
/// [NavigationBar] on narrow surfaces, a [NavigationRail] (collapsed or
|
||||||
/// extended) otherwise.
|
/// 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 {
|
class AppShell extends StatelessWidget {
|
||||||
const AppShell({required this.location, required this.child, super.key});
|
const AppShell({required this.location, required this.child, super.key});
|
||||||
|
|
||||||
@@ -40,12 +72,15 @@ class AppShell extends StatelessWidget {
|
|||||||
int get _selectedIndex {
|
int get _selectedIndex {
|
||||||
// longest prefix wins, so /portfolio/instrument/311 keeps Портфель selected
|
// longest prefix wins, so /portfolio/instrument/311 keeps Портфель selected
|
||||||
var best = -1;
|
var best = -1;
|
||||||
|
var bestLength = -1;
|
||||||
for (var i = 0; i < _destinations.length; i++) {
|
for (var i = 0; i < _destinations.length; i++) {
|
||||||
final path = _destinations[i].path;
|
final length = _destinations[i].matchLength(location);
|
||||||
final matches = path == '/' ? location == '/' : location.startsWith(path);
|
if (length > bestLength) {
|
||||||
if (matches && (best == -1 || path.length > _destinations[best].path.length)) best = i;
|
best = i;
|
||||||
|
bestLength = length;
|
||||||
}
|
}
|
||||||
return best == -1 ? 0 : best;
|
}
|
||||||
|
return best == -1 || bestLength < 0 ? 0 : best;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSelect(BuildContext context, int index) {
|
void _onSelect(BuildContext context, int index) {
|
||||||
@@ -57,18 +92,36 @@ class AppShell extends StatelessWidget {
|
|||||||
final width = MediaQuery.sizeOf(context).width;
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
|
||||||
if (width < _narrowBreakpoint) {
|
if (width < _narrowBreakpoint) {
|
||||||
|
final primary = _destinations.where((d) => d.primary).toList();
|
||||||
|
final selected = _destinations[_selectedIndex];
|
||||||
|
final primaryIndex = primary.indexOf(selected);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(child: child),
|
body: SafeArea(child: child),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: _selectedIndex,
|
// nothing primary matches ⇒ we are inside a screen that lives behind «Ещё»,
|
||||||
onDestinationSelected: (i) => _onSelect(context, i),
|
// 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: [
|
destinations: [
|
||||||
for (final d in _destinations)
|
for (final d in primary)
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(d.icon),
|
icon: Icon(d.icon),
|
||||||
selectedIcon: Icon(d.selectedIcon),
|
selectedIcon: Icon(d.selectedIcon),
|
||||||
label: d.label,
|
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<void>(
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, dynamic> 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<TaxRow> accounts;
|
||||||
|
final TaxRow? totals;
|
||||||
|
final String? disclaimer;
|
||||||
|
|
||||||
|
static const defaultDisclaimer =
|
||||||
|
'Оценка. Налоговый агент — брокер; сверяйтесь с его справкой.';
|
||||||
|
|
||||||
|
static TaxSummary fromJson(Map<String, dynamic> 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<String, dynamic> 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<TaxSummary> summary({required int year, int? accountId}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
_base,
|
||||||
|
queryParameters: {'year': year, 'account_id': ?accountId},
|
||||||
|
);
|
||||||
|
return TaxSummary.fromJson(r.data ?? const {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<TaxLot>> lots({required int year, int? accountId}) async {
|
||||||
|
final r = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'$_base/lots',
|
||||||
|
queryParameters: {'year': year, 'account_id': ?accountId},
|
||||||
|
);
|
||||||
|
return asObjects((r.data ?? const {})['lots']).map(TaxLot.fromJson).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TaxLotsTab> createState() => _TaxLotsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TaxLotsTabState extends ConsumerState<TaxLotsTab> {
|
||||||
|
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<TaxLot> 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TaxApi>((ref) => TaxApi(ref.watch(apiProvider).dio));
|
||||||
|
|
||||||
|
final taxYearProvider = StateProvider<int>((ref) => DateTime.now().year);
|
||||||
|
|
||||||
|
/// Optional account filter; null = all accounts.
|
||||||
|
final taxAccountProvider = StateProvider<int?>((ref) => null);
|
||||||
|
|
||||||
|
final taxSummaryProvider = FutureProvider.autoDispose<TaxSummary>((ref) async {
|
||||||
|
return ref.watch(taxApiProvider).summary(
|
||||||
|
year: ref.watch(taxYearProvider),
|
||||||
|
accountId: ref.watch(taxAccountProvider),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final taxLotsProvider = FutureProvider.autoDispose<List<TaxLot>>((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);
|
||||||
|
}
|
||||||
@@ -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<TaxRow> 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)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<int>(
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,14 +7,22 @@ import 'features/accounts/accounts_page.dart';
|
|||||||
import 'features/cashflow/cashflow_page.dart';
|
import 'features/cashflow/cashflow_page.dart';
|
||||||
import 'features/categories/categories_page.dart';
|
import 'features/categories/categories_page.dart';
|
||||||
import 'features/events/events_page.dart';
|
import 'features/events/events_page.dart';
|
||||||
|
import 'features/goals/goals_page.dart';
|
||||||
import 'features/home/home_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/login/login_page.dart';
|
||||||
|
import 'features/pending/pending_page.dart';
|
||||||
import 'features/portfolio/instrument_page.dart';
|
import 'features/portfolio/instrument_page.dart';
|
||||||
import 'features/portfolio/portfolio_page.dart';
|
import 'features/portfolio/portfolio_page.dart';
|
||||||
|
import 'features/rebalance/rebalance_page.dart';
|
||||||
import 'features/rules/rules_page.dart';
|
import 'features/rules/rules_page.dart';
|
||||||
import 'features/settings/settings_page.dart';
|
import 'features/settings/settings_page.dart';
|
||||||
|
import 'features/shell/analytics_page.dart';
|
||||||
import 'features/shell/app_shell.dart';
|
import 'features/shell/app_shell.dart';
|
||||||
import 'features/sync/sync_page.dart';
|
import 'features/sync/sync_page.dart';
|
||||||
|
import 'features/tax/tax_page.dart';
|
||||||
import 'features/transactions/transactions_page.dart';
|
import 'features/transactions/transactions_page.dart';
|
||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
@@ -48,7 +56,25 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
builder: (_, state) =>
|
builder: (_, state) =>
|
||||||
InstrumentPage(instrumentId: int.parse(state.pathParameters['id']!)),
|
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: '/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: '/cashflow', builder: (_, _) => const CashflowPage()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/categories',
|
path: '/categories',
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.1.0"
|
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:
|
crypto:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -73,6 +81,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.15"
|
||||||
decimal:
|
decimal:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -129,6 +145,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
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:
|
fintracker_api:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -162,6 +186,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_riverpod:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -444,6 +476,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -657,6 +697,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ dependencies:
|
|||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
fl_chart: ^0.69.0
|
fl_chart: ^0.69.0
|
||||||
shared_preferences: ^2.3.4
|
shared_preferences: ^2.3.4
|
||||||
|
file_picker: ^11.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
Widget wrap(double width) {
|
Widget wrap(double width, {String location = '/'}) {
|
||||||
return MediaQuery(
|
return MediaQuery(
|
||||||
data: MediaQueryData(size: Size(width, 800)),
|
data: MediaQueryData(size: Size(width, 800)),
|
||||||
child: MaterialApp(
|
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(NavigationBar), findsOneWidget);
|
||||||
expect(find.byType(NavigationRail), findsNothing);
|
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<NavigationRail>(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<NavigationRail>(find.byType(NavigationRail));
|
||||||
|
expect(
|
||||||
|
(rail.destinations[rail.selectedIndex!].label as Text).data,
|
||||||
|
'Портфель',
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = <String, dynamic>{
|
||||||
|
'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<String, dynamic>.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<String, dynamic>.from(_previewJson)..['account_id'] = null;
|
||||||
|
expect(ImportPreview.fromJson(json).canCommit, isFalse);
|
||||||
|
|
||||||
|
final failed = Map<String, dynamic>.from(_previewJson)..['parse_status'] = 'failed';
|
||||||
|
expect(ImportPreview.fromJson(failed).canCommit, isFalse);
|
||||||
|
expect(ImportPreview.fromJson(failed).canDelete, isTrue);
|
||||||
|
|
||||||
|
final committed = Map<String, dynamic>.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');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 = <String, dynamic>{
|
||||||
|
'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<String, dynamic>.from(calendarJson);
|
||||||
|
json['entries'] = [
|
||||||
|
{...(calendarJson['entries'] as List).first as Map<String, dynamic>, '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': <dynamic>[],
|
||||||
|
'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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user