feat(app): экраны импорта отчётов, целей, доходов, ребалансировки, налогов и бенчмарков

Импорт (/imports, /instruments/pending) и фаза 4 (/goals, /income,
/rebalance, /tax, аналитика-хаб с benchmarks_card) — по
docs/ai/import-contract.md и docs/ai/phase4-contract.md. file_picker для
загрузки отчёта.
This commit is contained in:
Dmitry
2026-09-19 10:44:38 +03:00
parent 15f5812ea4
commit b69bb4a0c9
52 changed files with 7404 additions and 13 deletions
+69
View File
@@ -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);
}
+46
View File
@@ -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;
},
),
);
},
);
}
}
+89
View File
@@ -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,
),
],
],
),
),
),
);
}
}