Доходы, ребалансировка, налоги, импорт, цели, здоровье данных, правила, транзакции, категории, cashflow, pending: новые виджеты и токены темы, dart format. Тесты подстроены под новые модели.
192 lines
7.3 KiB
Dart
192 lines
7.3 KiB
Dart
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('Сохранить'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|