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,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('Сохранить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user