delete CloudKit and added BalanceFlutter
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
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
This commit is contained in:
1 parent
a715eca947
commit
2c278044bb
214 files changed
+13411
-2290
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
String money(int minor, String currency) => NumberFormat.currency(
|
||||
locale: 'ru_RU',
|
||||
name: currency,
|
||||
symbol:
|
||||
{'RUB': '₽', 'USD': r'$', 'EUR': '€', 'SEK': 'kr'}[currency] ?? currency,
|
||||
).format(minor / 100);
|
||||
int? parseAmount(String value, {bool signed = false}) {
|
||||
final clean = value
|
||||
.replaceAll(RegExp(r'[\s\u00a0\u202f]'), '')
|
||||
.replaceAll(',', '.');
|
||||
if (!RegExp(
|
||||
signed ? r'^-?\d{1,12}(\.\d{1,2})?$' : r'^\d{1,12}(\.\d{1,2})?$',
|
||||
).hasMatch(clean)) {
|
||||
return null;
|
||||
}
|
||||
final parts = clean.replaceFirst('-', '').split('.');
|
||||
final result =
|
||||
int.parse(parts[0]) * 100 +
|
||||
(parts.length > 1 ? int.parse(parts[1].padRight(2, '0')) : 0);
|
||||
return clean.startsWith('-') ? -result : result;
|
||||
}
|
||||
|
||||
String amountInput(int minor) => (minor / 100).toStringAsFixed(2);
|
||||
|
||||
const categoryColors = <String, Color>{
|
||||
'red': Colors.red,
|
||||
'orange': Colors.orange,
|
||||
'yellow': Colors.amber,
|
||||
'green': Colors.green,
|
||||
'mint': Color(0xff39b997),
|
||||
'teal': Colors.teal,
|
||||
'cyan': Colors.cyan,
|
||||
'blue': Colors.blue,
|
||||
'indigo': Colors.indigo,
|
||||
'purple': Colors.purple,
|
||||
'pink': Colors.pink,
|
||||
'gray': Colors.blueGrey,
|
||||
'coral': Color(0xffee7063),
|
||||
'peach': Color(0xffe9a077),
|
||||
'gold': Color(0xffbf930c),
|
||||
'lime': Color(0xff91ad21),
|
||||
'forest': Color(0xff28734a),
|
||||
'turquoise': Color(0xff27a9a0),
|
||||
'sky': Color(0xff459bc8),
|
||||
'navy': Color(0xff364d94),
|
||||
'lavender': Color(0xff9b82ce),
|
||||
'magenta': Color(0xffbd439d),
|
||||
'brown': Colors.brown,
|
||||
'slate': Color(0xff627389),
|
||||
};
|
||||
const categoryIcons = <String, IconData>{
|
||||
'star.fill': Icons.star_rounded,
|
||||
'heart.fill': Icons.favorite_rounded,
|
||||
'sparkles': Icons.auto_awesome,
|
||||
'bolt.fill': Icons.bolt,
|
||||
'flame.fill': Icons.local_fire_department,
|
||||
'drop.fill': Icons.water_drop,
|
||||
'cart.fill': Icons.shopping_cart_outlined,
|
||||
'bag.fill': Icons.shopping_bag_outlined,
|
||||
'creditcard.fill': Icons.credit_card,
|
||||
'banknote.fill': Icons.payments_outlined,
|
||||
'gift.fill': Icons.card_giftcard,
|
||||
'tag.fill': Icons.sell_outlined,
|
||||
'cup.and.saucer.fill': Icons.coffee,
|
||||
'fork.knife': Icons.restaurant,
|
||||
'takeoutbag.and.cup.and.straw.fill': Icons.takeout_dining,
|
||||
'birthday.cake.fill': Icons.cake_outlined,
|
||||
'car.fill': Icons.directions_car_outlined,
|
||||
'bus.fill': Icons.directions_bus,
|
||||
'tram.fill': Icons.tram,
|
||||
'bicycle': Icons.pedal_bike,
|
||||
'airplane': Icons.flight,
|
||||
'fuelpump.fill': Icons.local_gas_station,
|
||||
'house.fill': Icons.home_outlined,
|
||||
'bed.double.fill': Icons.bed_outlined,
|
||||
'lightbulb.fill': Icons.lightbulb_outline,
|
||||
'wifi': Icons.wifi,
|
||||
'phone.fill': Icons.phone_outlined,
|
||||
'printer.fill': Icons.print_outlined,
|
||||
'pawprint.fill': Icons.pets,
|
||||
'leaf.fill': Icons.eco_outlined,
|
||||
'figure.walk': Icons.directions_walk,
|
||||
'figure.run': Icons.directions_run,
|
||||
'dumbbell.fill': Icons.fitness_center,
|
||||
'cross.case.fill': Icons.medical_services_outlined,
|
||||
'pills.fill': Icons.medication_outlined,
|
||||
'stethoscope': Icons.monitor_heart_outlined,
|
||||
'book.fill': Icons.menu_book,
|
||||
'graduationcap.fill': Icons.school_outlined,
|
||||
'laptopcomputer': Icons.laptop,
|
||||
'briefcase.fill': Icons.work_outline,
|
||||
'gamecontroller.fill': Icons.sports_esports_outlined,
|
||||
'music.note': Icons.music_note,
|
||||
'film.fill': Icons.movie_outlined,
|
||||
'camera.fill': Icons.photo_camera_outlined,
|
||||
'paintpalette.fill': Icons.palette_outlined,
|
||||
'tshirt.fill': Icons.checkroom,
|
||||
'hammer.fill': Icons.gavel,
|
||||
'wrench.and.screwdriver.fill': Icons.handyman_outlined,
|
||||
'shippingbox.fill': Icons.inventory_2_outlined,
|
||||
'globe.europe.africa.fill': Icons.public,
|
||||
'repeat': Icons.repeat,
|
||||
'chart.line.uptrend.xyaxis': Icons.trending_up,
|
||||
'ellipsis.circle.fill': Icons.more_horiz,
|
||||
'plus.circle.fill': Icons.add_circle_outline,
|
||||
'slider.horizontal.3': Icons.tune,
|
||||
'tune': Icons.tune,
|
||||
};
|
||||
|
||||
class CategoryAvatar extends StatelessWidget {
|
||||
const CategoryAvatar({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.emoji = '',
|
||||
this.color = 'indigo',
|
||||
this.size = 44,
|
||||
});
|
||||
final String icon, emoji, color;
|
||||
final double size;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = categoryColors[color] ?? Theme.of(context).colorScheme.primary;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: c.withValues(alpha: .13),
|
||||
borderRadius: BorderRadius.circular(size * .32),
|
||||
),
|
||||
child: emoji.isNotEmpty
|
||||
? Text(emoji, style: TextStyle(fontSize: size * .48))
|
||||
: Icon(
|
||||
categoryIcons[icon] ?? Icons.category_outlined,
|
||||
color: c,
|
||||
size: size * .52,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Panel extends StatelessWidget {
|
||||
const Panel({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(24),
|
||||
});
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
child: Padding(padding: padding, child: child),
|
||||
);
|
||||
}
|
||||
|
||||
class EmptyView extends StatelessWidget {
|
||||
const EmptyView({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.action,
|
||||
this.icon = Icons.receipt_long_outlined,
|
||||
});
|
||||
final String title, subtitle;
|
||||
final Widget? action;
|
||||
final IconData icon;
|
||||
@override
|
||||
Widget build(BuildContext context) => Panel(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(subtitle, textAlign: TextAlign.center),
|
||||
if (action != null) ...[const SizedBox(height: 20), action!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class Metric extends StatelessWidget {
|
||||
const Metric({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
this.color,
|
||||
});
|
||||
final String label, value;
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
@override
|
||||
Widget build(BuildContext context) => Panel(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color ?? Theme.of(context).colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(label, style: Theme.of(context).textTheme.labelLarge),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class SpendingRing extends StatelessWidget {
|
||||
const SpendingRing({super.key, required this.segments, required this.total});
|
||||
final List<(int, Color)> segments;
|
||||
final String total;
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
label: 'Всего расходов: $total. Структура по категориям приведена ниже.',
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 190,
|
||||
child: CustomPaint(
|
||||
painter: _RingPainter(segments),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(30),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Расходы',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
FittedBox(
|
||||
child: Text(
|
||||
total,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RingPainter extends CustomPainter {
|
||||
_RingPainter(this.segments);
|
||||
final List<(int, Color)> segments;
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final total = segments.fold<int>(0, (sum, s) => sum + s.$1);
|
||||
if (total <= 0) return;
|
||||
final rect = Offset.zero & size;
|
||||
var angle = -1.5707963267948966;
|
||||
for (final segment in segments) {
|
||||
final sweep = segment.$1 / total * 6.283185307179586;
|
||||
canvas.drawArc(
|
||||
rect.deflate(13),
|
||||
angle,
|
||||
sweep,
|
||||
false,
|
||||
Paint()
|
||||
..color = segment.$2
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 20,
|
||||
);
|
||||
angle += sweep;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _RingPainter oldDelegate) =>
|
||||
oldDelegate.segments != segments;
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
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;
|
||||
Reference in new issue
Block a user