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

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