Files
BalanceMono/BalanceFlutter/lib/ui/editors.dart
T
wt 2c278044bb
Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
delete CloudKit and added BalanceFlutter
2026-10-28 15:37:32 +07:00

586 lines
19 KiB
Dart

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:uuid/uuid.dart';
import '../domain/finance.dart';
import 'design.dart';
const _uuid = Uuid();
Future<T?> editDialog<T>(BuildContext context, Widget child) => showDialog<T>(
context: context,
builder: (_) => Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: child,
),
),
);
class TransactionEditor extends StatefulWidget {
const TransactionEditor({super.key, this.existing, required this.categories});
final TransactionRecord? existing;
final List<CategoryRecord> categories;
@override
State<TransactionEditor> createState() => _TransactionEditorState();
}
class _TransactionEditorState extends State<TransactionEditor> {
final form = GlobalKey<FormState>();
late final TextEditingController amount, note;
late DateTime date;
late String kind;
late CategoryRecord selected;
List<CategoryRecord> get choices {
final list = [
...BuiltInCategories.forKind(kind),
...widget.categories.where((c) => c.kindRawValue == kind),
];
if (!list.any((c) => c.name == selected.name)) list.add(selected);
return {for (final c in list) c.name: c}.values.toList();
}
@override
void initState() {
super.initState();
final t = widget.existing;
amount = TextEditingController(
text: t == null ? '' : amountInput(t.amountMinor),
);
note = TextEditingController(text: t?.note ?? '');
date = t?.date.toLocal() ?? DateTime.now();
kind = t?.kindRawValue ?? 'expense';
selected = t == null
? BuiltInCategories.expense.first
: CategoryRecord(
id: 'archived',
name: t.categoryName,
icon: t.categoryIcon,
emoji: t.categoryEmoji,
colorName: t.categoryColorName,
kindRawValue: kind,
createdAt: date,
updatedAt: date,
);
}
@override
void dispose() {
amount.dispose();
note.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: form,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
widget.existing == null ? 'Новая операция' : 'Изменить операцию',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
SegmentedButton<String>(
segments: const [
ButtonSegment(
value: 'expense',
label: Text('Расход'),
icon: Icon(Icons.north_east),
),
ButtonSegment(
value: 'income',
label: Text('Доход'),
icon: Icon(Icons.south_west),
),
],
selected: {kind},
onSelectionChanged: widget.existing?.isBalanceAdjustment == true
? null
: (s) => setState(() {
kind = s.first;
selected = BuiltInCategories.forKind(kind).first;
}),
),
const SizedBox(height: 20),
TextFormField(
controller: amount,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Сумма',
border: OutlineInputBorder(),
),
validator: (v) {
final m = parseAmount(v ?? '');
return m == null || m <= 0
? 'Введите положительную сумму, до 2 знаков после запятой'
: null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: selected.name,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Категория',
border: OutlineInputBorder(),
),
items: choices
.map(
(c) => DropdownMenuItem(
value: c.name,
child: Row(
children: [
CategoryAvatar(
icon: c.icon,
emoji: c.emoji,
color: c.colorName,
size: 28,
),
const SizedBox(width: 12),
Flexible(
child: Text(c.name, overflow: TextOverflow.ellipsis),
),
],
),
),
)
.toList(),
onChanged: widget.existing?.isBalanceAdjustment == true
? null
: (v) => setState(
() => selected = choices.firstWhere((c) => c.name == v),
),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.calendar_today_outlined),
title: Text(DateFormat('d MMMM y, HH:mm', 'ru').format(date)),
trailing: const Icon(Icons.edit_outlined),
onTap: () async {
final d = await showDatePicker(
context: context,
initialDate: date,
firstDate: DateTime(1900),
lastDate: DateTime(2200),
);
if (d == null || !context.mounted) return;
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(date),
);
if (time != null && mounted) {
setState(
() => date = DateTime(
d.year,
d.month,
d.day,
time.hour,
time.minute,
),
);
}
},
),
const SizedBox(height: 12),
TextFormField(
controller: note,
maxLines: 3,
maxLength: 4000,
decoration: const InputDecoration(
labelText: 'Заметка',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () {
if (!form.currentState!.validate()) return;
final now = DateTime.now();
Navigator.pop(
context,
TransactionRecord(
id: widget.existing?.id ?? _uuid.v4(),
amountMinor: parseAmount(amount.text)!,
date: date,
note: note.text.trim(),
categoryName: selected.name,
categoryIcon: selected.icon,
categoryEmoji: selected.emoji,
categoryColorName: selected.colorName,
kindRawValue: kind,
isBalanceAdjustment:
widget.existing?.isBalanceAdjustment ?? false,
updatedAt: now,
),
);
},
child: const Text('Сохранить'),
),
],
),
],
),
),
);
}
class CategoryEditor extends StatefulWidget {
const CategoryEditor({super.key, this.existing, required this.categories});
final CategoryRecord? existing;
final List<CategoryRecord> categories;
@override
State<CategoryEditor> createState() => _CategoryEditorState();
}
class _CategoryEditorState extends State<CategoryEditor> {
final form = GlobalKey<FormState>();
late final TextEditingController name, emoji;
late String kind, icon, color;
@override
void initState() {
super.initState();
final c = widget.existing;
name = TextEditingController(text: c?.name ?? '');
emoji = TextEditingController(text: c?.emoji ?? '');
kind = c?.kindRawValue ?? 'expense';
icon = c?.icon ?? 'star.fill';
color = c?.colorName ?? 'indigo';
}
@override
void dispose() {
name.dispose();
emoji.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: form,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.existing == null ? 'Новая категория' : 'Изменить категорию',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 20),
TextFormField(
controller: name,
maxLength: 80,
decoration: const InputDecoration(
labelText: 'Название',
border: OutlineInputBorder(),
),
validator: (s) {
final n = (s ?? '').trim().toLowerCase();
if (n.isEmpty) return 'Введите название';
if ([
...BuiltInCategories.expense,
...BuiltInCategories.income,
...widget.categories,
].any(
(c) => c.id != widget.existing?.id && c.name.toLowerCase() == n,
)) {
return 'Категория с таким названием уже есть';
}
return null;
},
),
const SizedBox(height: 16),
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'expense', label: Text('Расход')),
ButtonSegment(value: 'income', label: Text('Доход')),
],
selected: {kind},
onSelectionChanged: widget.existing != null
? null
: (s) => setState(() => kind = s.first),
),
const SizedBox(height: 20),
Row(
children: [
CategoryAvatar(
icon: icon,
emoji: emoji.text,
color: color,
size: 60,
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: emoji,
decoration: InputDecoration(
labelText: 'Свой эмодзи',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: 'Убрать эмодзи',
icon: const Icon(Icons.close),
onPressed: () => setState(() => emoji.clear()),
),
),
onChanged: (_) => setState(() {}),
maxLength: 8,
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 4,
runSpacing: 4,
children: CategoryDesignOptions.emojis
.map(
(e) => IconButton(
onPressed: () => setState(() => emoji.text = e),
tooltip: e,
icon: Text(e, style: const TextStyle(fontSize: 23)),
),
)
.toList(),
),
const SizedBox(height: 16),
Text('Значок', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Wrap(
spacing: 4,
runSpacing: 4,
children: CategoryDesignOptions.icons
.map(
(i) => IconButton.filledTonal(
isSelected: i == icon && emoji.text.isEmpty,
onPressed: () => setState(() {
icon = i;
emoji.clear();
}),
tooltip: i,
icon: Icon(categoryIcons[i] ?? Icons.category_outlined),
),
)
.toList(),
),
const SizedBox(height: 20),
Text('Цвет', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: CategoryDesignOptions.colors
.map(
(c) => IconButton(
style: IconButton.styleFrom(
backgroundColor: categoryColors[c],
),
tooltip: c,
onPressed: () => setState(() => color = c),
icon: Icon(
c == color ? Icons.check : Icons.circle,
color: c == color ? Colors.white : Colors.transparent,
),
),
)
.toList(),
),
const SizedBox(height: 24),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () {
if (!form.currentState!.validate()) return;
final now = DateTime.now();
Navigator.pop(
context,
CategoryRecord(
id: widget.existing?.id ?? _uuid.v4(),
name: name.text.trim(),
icon: icon,
emoji: emoji.text.characters.take(1).toString(),
colorName: color,
kindRawValue: kind,
createdAt: widget.existing?.createdAt ?? now,
updatedAt: now,
),
);
},
child: const Text('Сохранить'),
),
],
),
],
),
),
);
}
class BudgetEditor extends StatefulWidget {
const BudgetEditor({
super.key,
this.existing,
required this.categories,
required this.month,
});
final BudgetRecord? existing;
final List<CategoryRecord> categories;
final DateTime month;
@override
State<BudgetEditor> createState() => _BudgetEditorState();
}
class _BudgetEditorState extends State<BudgetEditor> {
final form = GlobalKey<FormState>();
late final TextEditingController amount;
late CategoryRecord selected;
@override
void initState() {
super.initState();
amount = TextEditingController(
text: widget.existing == null
? ''
: amountInput(widget.existing!.limitMinor),
);
selected =
widget.categories
.where((c) => c.name == widget.existing?.categoryName)
.firstOrNull ??
widget.categories.first;
}
@override
void dispose() {
amount.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: form,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Лимит на ${DateFormat('LLLL y', 'ru').format(widget.month)}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 24),
DropdownButtonFormField<String>(
initialValue: selected.name,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Категория',
border: OutlineInputBorder(),
),
items: widget.categories
.map(
(c) => DropdownMenuItem(value: c.name, child: Text(c.name)),
)
.toList(),
onChanged: (n) => setState(
() => selected = widget.categories.firstWhere((c) => c.name == n),
),
),
const SizedBox(height: 16),
TextFormField(
controller: amount,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Месячный лимит',
border: OutlineInputBorder(),
),
validator: (v) =>
parseAmount(v ?? '') == null || parseAmount(v ?? '')! <= 0
? 'Введите положительный лимит'
: null,
),
const SizedBox(height: 24),
Wrap(
alignment: WrapAlignment.end,
spacing: 8,
runSpacing: 8,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
if (!form.currentState!.validate()) return;
Navigator.pop(
context,
BudgetRecord(
id: widget.existing?.id ?? _uuid.v4(),
categoryName: selected.name,
categoryIcon: selected.icon,
categoryEmoji: selected.emoji,
limitMinor: parseAmount(amount.text)!,
monthStart: widget.month,
updatedAt: DateTime.now(),
),
);
},
child: const Text('Сохранить'),
),
],
),
],
),
),
);
}
Future<bool> confirmDelete(
BuildContext context,
String title, {
String message =
'Это действие будет синхронизировано с другими устройствами.',
}) async =>
await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Удалить'),
),
],
),
) ??
false;