Files
BalanceMono/BalanceFlutter/lib/ui/app.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

1492 lines
52 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart';
import 'package:file_selector/file_selector.dart';
import 'package:share_plus/share_plus.dart';
import '../domain/finance.dart';
import '../services/app_store.dart';
import 'design.dart';
import 'editors.dart';
class BalanceApp extends StatelessWidget {
const BalanceApp({super.key, required this.store});
final AppStore store;
@override
Widget build(BuildContext context) => ListenableBuilder(
listenable: store,
builder: (context, _) => MaterialApp(
title: 'Баланс',
debugShowCheckedModeBanner: false,
locale: const Locale('ru'),
supportedLocales: const [Locale('ru'), Locale('en')],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: makeTheme(Brightness.light),
darkTheme: makeTheme(Brightness.dark),
themeMode: store.theme == 'dark'
? ThemeMode.dark
: store.theme == 'light'
? ThemeMode.light
: ThemeMode.system,
home: store.ready
? BalanceShell(store: store)
: const Scaffold(body: Center(child: CircularProgressIndicator())),
),
);
ThemeData makeTheme(Brightness brightness) {
final scheme = ColorScheme.fromSeed(
seedColor: const Color(0xff356859),
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
fontFamily: 'BalanceRoboto',
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
cardTheme: CardThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
centerTitle: false,
),
);
}
}
class BalanceShell extends StatefulWidget {
const BalanceShell({super.key, required this.store});
final AppStore store;
@override
State<BalanceShell> createState() => _BalanceShellState();
}
class _BalanceShellState extends State<BalanceShell> {
AppStore get store => widget.store;
int page = 0;
int months = 1;
String query = '', filter = 'all';
DateTime budgetMonth = DateTime(DateTime.now().year, DateTime.now().month);
static const titles = [
'Обзор',
'Операции',
'Аналитика',
'Бюджеты',
'Настройки',
];
static const icons = [
Icons.space_dashboard_outlined,
Icons.receipt_long_outlined,
Icons.insights_outlined,
Icons.savings_outlined,
Icons.settings_outlined,
];
Future<void> run(Future<void> Function() action, {String? success}) async {
try {
await action();
if (success != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(success)));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
e.toString().replaceFirst('Invalid argument(s): ', ''),
),
duration: const Duration(seconds: 6),
),
);
}
}
}
Future<void> editTransaction([TransactionRecord? value]) async {
final result = await editDialog<TransactionRecord>(
context,
TransactionEditor(existing: value, categories: store.categories),
);
if (result != null) await run(() => store.saveTransaction(result));
}
Future<void> editCategory([CategoryRecord? value]) async {
final result = await editDialog<CategoryRecord>(
context,
CategoryEditor(existing: value, categories: store.categories),
);
if (result != null) await run(() => store.saveCategory(result));
}
Future<void> editBudget([BudgetRecord? value]) async {
final choices = store.categoriesForKind('expense');
if (value != null && !choices.any((c) => c.name == value.categoryName)) {
choices.add(
CategoryRecord(
id: 'archive',
colorName: 'indigo',
name: value.categoryName,
icon: value.categoryIcon,
emoji: value.categoryEmoji,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
}
final result = await editDialog<BudgetRecord>(
context,
BudgetEditor(existing: value, categories: choices, month: budgetMonth),
);
if (result != null) await run(() => store.saveBudget(result));
}
Future<void> adjust() async {
final amount = TextEditingController(
text: amountInput(FinanceMath.totalBalance(store.transactions)),
);
final note = TextEditingController();
final form = GlobalKey<FormState>();
final target = await showDialog<int>(
context: context,
builder: (context) => AlertDialog(
scrollable: true,
title: const Text('Скорректировать баланс'),
content: SizedBox(
width: 400,
child: Form(
key: form,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Укажите фактический остаток. Разница сохранится отдельной операцией и не повлияет на аналитику.',
),
const SizedBox(height: 20),
TextFormField(
controller: amount,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
decoration: InputDecoration(
labelText: 'Фактический баланс, ${store.currency}',
),
validator: (s) => parseAmount(s ?? '', signed: true) == null
? 'Введите сумму'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: note,
decoration: const InputDecoration(labelText: 'Заметка'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
if (form.currentState!.validate()) {
Navigator.pop(context, parseAmount(amount.text, signed: true));
}
},
child: const Text('Сохранить'),
),
],
),
);
if (target != null) {
await run(() => store.adjustBalance(target, note: note.text.trim()));
}
// Dialog route may still be animating; controllers are no longer attached after frame.
await Future<void>.delayed(const Duration(milliseconds: 300));
amount.dispose();
note.dispose();
}
@override
Widget build(BuildContext context) {
final wide = MediaQuery.sizeOf(context).width >= 900;
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: BorderRadius.circular(11),
),
child: Icon(
Icons.account_balance_wallet_outlined,
color: Theme.of(context).colorScheme.onPrimary,
size: 21,
),
),
const SizedBox(width: 12),
const Text('Баланс', style: TextStyle(fontWeight: FontWeight.w700)),
],
),
actions: [
if (store.syncing)
const Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
IconButton(
tooltip: store.session == null
? 'Настроить синхронизацию'
: 'Синхронизировать',
onPressed: store.session == null
? () => setState(() => page = 4)
: () => run(store.sync),
icon: Icon(
store.session == null ? Icons.cloud_off_outlined : Icons.sync,
),
),
const SizedBox(width: 12),
],
),
body: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (wide)
NavigationRail(
selectedIndex: page,
onDestinationSelected: (i) => setState(() => page = i),
labelType: NavigationRailLabelType.all,
minWidth: 100,
leading: Padding(
padding: const EdgeInsets.only(bottom: 28, top: 16),
child: FloatingActionButton(
tooltip: 'Добавить операцию',
onPressed: editTransaction,
child: const Icon(Icons.add),
),
),
destinations: List.generate(
5,
(i) => NavigationRailDestination(
icon: Icon(icons[i]),
label: Text(titles[i]),
),
),
),
if (wide) const VerticalDivider(width: 1),
Expanded(
child: Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1280),
child: ListView(
padding: EdgeInsets.fromLTRB(
wide ? 36 : 16,
16,
wide ? 36 : 16,
100,
),
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titles[page],
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 5),
Text(
page == 0
? DateFormat(
'LLLL y',
'ru',
).format(DateTime.now())
: [
'',
'Вся история ваших финансов',
'Понимайте, куда уходят деньги',
'Планируйте расходы по категориям',
'Ваши предпочтения и данные',
][page],
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
if (wide && page == 0)
FilledButton.icon(
onPressed: editTransaction,
icon: const Icon(Icons.add),
label: const Text('Новая операция'),
),
],
),
const SizedBox(height: 28),
if (store.error != null) ...[
MaterialBanner(
content: Text(store.error!),
leading: const Icon(Icons.cloud_off_outlined),
actions: [
TextButton(
onPressed: store.clearError,
child: const Text('Закрыть'),
),
],
),
const SizedBox(height: 20),
],
...switch (page) {
0 => overview(),
1 => transactions(),
2 => analytics(),
3 => budgets(),
_ => settings(),
},
],
),
),
),
),
],
),
bottomNavigationBar: wide
? null
: NavigationBar(
selectedIndex: page,
onDestinationSelected: (i) => setState(() => page = i),
destinations: List.generate(
5,
(i) => NavigationDestination(
icon: Icon(icons[i]),
label: titles[i],
),
),
),
floatingActionButton: !wide && (page == 0 || page == 1)
? FloatingActionButton.extended(
onPressed: editTransaction,
icon: const Icon(Icons.add),
label: const Text('Операция'),
)
: null,
);
}
Widget sectionTitle(String title, {Widget? action}) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600),
),
),
?action,
],
),
);
Widget metricRow(List<Widget> metrics) => LayoutBuilder(
builder: (context, c) => c.maxWidth < 550
? Column(
children: [
for (final m in metrics)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SizedBox(width: double.infinity, child: m),
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < metrics.length; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(child: metrics[i]),
],
],
),
);
List<Widget> overview() {
final now = DateTime.now();
final start = DateTime(now.year, now.month);
final end = DateTime(now.year, now.month + 1);
final summary = FinanceMath.summary(store.transactions, start, end);
return [
Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(28),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(child: Text('Общий баланс')),
TextButton.icon(
onPressed: adjust,
icon: const Icon(Icons.tune, size: 18),
label: const Text('Изменить'),
),
],
),
const SizedBox(height: 8),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
money(
FinanceMath.totalBalance(store.transactions),
store.currency,
),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 16),
Text(
store.session == null
? 'Данные хранятся на этом устройстве'
: '${store.email} · ${store.syncMessage ?? 'Готово к синхронизации'}',
),
],
),
),
const SizedBox(height: 20),
metricRow([
Metric(
label: 'Доходы за месяц',
value: money(summary.incomeMinor, store.currency),
icon: Icons.south_west,
color: Colors.teal,
),
Metric(
label: 'Расходы за месяц',
value: money(summary.expensesMinor, store.currency),
icon: Icons.north_east,
color: Colors.deepOrange,
),
]),
const SizedBox(height: 28),
sectionTitle('Расходы по категориям'),
spending(start, end, limit: 5),
const SizedBox(height: 28),
sectionTitle(
'Последние операции',
action: TextButton(
onPressed: () => setState(() => page = 1),
child: const Text('Все операции'),
),
),
if (store.transactions.isEmpty)
EmptyView(
title: 'Начните с первой операции',
subtitle: 'Запишите доход или расход, чтобы увидеть свой баланс.',
action: FilledButton.icon(
onPressed: editTransaction,
icon: const Icon(Icons.add),
label: const Text('Добавить'),
),
)
else
Panel(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: store.transactions.take(5).map(transactionTile).toList(),
),
),
];
}
Widget transactionTile(TransactionRecord t) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
leading: CategoryAvatar(
icon: t.isBalanceAdjustment ? 'tune' : t.categoryIcon,
emoji: t.categoryEmoji,
color: t.categoryColorName,
),
title: Text(
t.isBalanceAdjustment ? 'Корректировка баланса' : t.categoryName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${DateFormat('d MMM, HH:mm', 'ru').format(t.date.toLocal())}${t.note.isEmpty ? '' : ' · ${t.note}'}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${t.kindRawValue == 'income' ? '+' : '−'}${money(t.amountMinor, store.currency)}',
style: TextStyle(
fontWeight: FontWeight.w600,
color: t.kindRawValue == 'income' ? Colors.teal : null,
),
),
PopupMenuButton<String>(
tooltip: 'Действия с операцией',
onSelected: (v) async {
if (v == 'edit') {
await editTransaction(t);
} else if (await confirmDelete(context, 'Удалить операцию?')) {
await run(() => store.deleteTransaction(t.id));
}
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'edit', child: Text('Изменить')),
PopupMenuItem(value: 'delete', child: Text('Удалить')),
],
),
],
),
onTap: () => editTransaction(t),
);
List<Widget> transactions() {
final items = store.transactions
.where(
(t) =>
(filter == 'all' || t.kindRawValue == filter) &&
(query.isEmpty ||
'${t.categoryName} ${t.note}'.toLowerCase().contains(
query.toLowerCase(),
)),
)
.toList();
return [
TextField(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Поиск по категории или заметке',
),
onChanged: (s) => setState(() => query = s),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: [
for (final e in {
'all': 'Все',
'expense': 'Расходы',
'income': 'Доходы',
}.entries)
ChoiceChip(
label: Text(e.value),
selected: filter == e.key,
onSelected: (_) => setState(() => filter = e.key),
),
],
),
const SizedBox(height: 20),
if (items.isEmpty)
const EmptyView(
title: 'Операций пока нет',
subtitle: 'Добавьте операцию или измените условия поиска.',
)
else
Panel(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
for (int i = 0; i < items.length; i++) ...[
if (i == 0 ||
!DateUtils.isSameDay(
items[i - 1].date.toLocal(),
items[i].date.toLocal(),
))
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
DateFormat(
'd MMMM y',
'ru',
).format(items[i].date.toLocal()),
style: Theme.of(context).textTheme.labelLarge,
),
),
),
transactionTile(items[i]),
],
],
),
),
];
}
Widget spending(DateTime start, DateTime end, {int? limit}) {
var groups = FinanceMath.spending(store.transactions, start, end);
if (limit != null) groups = groups.take(limit).toList();
final total = FinanceMath.summary(
store.transactions,
start,
end,
).expensesMinor;
if (groups.isEmpty) {
return const EmptyView(
title: 'Расходов за период нет',
subtitle: 'Здесь появится структура ваших расходов.',
icon: Icons.donut_large,
);
}
return Panel(
child: Column(
children: [
for (final g in groups)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
CategoryAvatar(
icon: g.icon,
emoji: g.emoji,
color: g.colorName,
size: 36,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
g.name,
overflow: TextOverflow.ellipsis,
),
),
Text(
money(g.amountMinor, store.currency),
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: total == 0 ? 0 : g.amountMinor / total,
borderRadius: BorderRadius.circular(8),
minHeight: 5,
color: categoryColors[g.colorName],
),
const SizedBox(height: 4),
Text(
'${total == 0 ? 0 : (g.amountMinor / total * 100).round()}% расходов',
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
),
),
],
),
);
}
List<Widget> analytics() {
final now = DateTime.now();
final start = DateTime(now.year, now.month - months + 1);
final end = DateTime(now.year, now.month + 1);
final sum = FinanceMath.summary(store.transactions, start, end);
final grouped = FinanceMath.spending(store.transactions, start, end);
final saving = sum.incomeMinor > 0
? sum.balanceMinor / sum.incomeMinor * 100
: 0.0;
final days =
(DateTime.utc(now.year, now.month, now.day)
.difference(
DateTime.utc(start.year, start.month, start.day),
)
.inDays +
1)
.clamp(1, 10000);
return [
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 1, label: Text('Месяц')),
ButtonSegment(value: 3, label: Text('3 месяца')),
ButtonSegment(value: 12, label: Text('12 месяцев')),
],
selected: {months},
onSelectionChanged: (s) => setState(() => months = s.first),
),
const SizedBox(height: 20),
metricRow([
Metric(
label: 'Доходы',
value: money(sum.incomeMinor, store.currency),
icon: Icons.south_west,
color: Colors.teal,
),
Metric(
label: 'Расходы',
value: money(sum.expensesMinor, store.currency),
icon: Icons.north_east,
color: Colors.deepOrange,
),
]),
const SizedBox(height: 16),
metricRow([
Metric(
label: 'Сбережения',
value: '${saving.toStringAsFixed(1)}%',
icon: Icons.savings_outlined,
),
Metric(
label: 'Средний расход в день',
value: money((sum.expensesMinor / days).round(), store.currency),
icon: Icons.calendar_today_outlined,
),
]),
const SizedBox(height: 16),
metricRow([
Metric(
label: 'Результат периода',
value: money(sum.balanceMinor, store.currency),
icon: Icons.account_balance_wallet_outlined,
),
Metric(
label: 'Крупнейшая категория',
value: grouped.isEmpty ? '—' : grouped.first.name,
icon: Icons.category_outlined,
),
]),
const SizedBox(height: 28),
sectionTitle('Динамика доходов и расходов'),
trend(start, end),
const SizedBox(height: 28),
sectionTitle('Структура расходов'),
if (grouped.isNotEmpty) ...[
Panel(
child: SpendingRing(
segments: grouped
.map(
(g) => (
g.amountMinor,
categoryColors[g.colorName] ??
Theme.of(context).colorScheme.primary,
),
)
.toList(),
total: money(sum.expensesMinor, store.currency),
),
),
const SizedBox(height: 16),
],
spending(start, end),
];
}
Widget trend(DateTime start, DateTime end) {
final buckets = <(DateTime, DateTime, String)>[];
if (months == 1) {
for (
var d = start;
d.isBefore(end);
d = DateTime(d.year, d.month, d.day + 1)
) {
buckets.add((d, DateTime(d.year, d.month, d.day + 1), '${d.day}'));
}
} else if (months == 3) {
for (
var d = DateTime(
start.year,
start.month,
start.day - start.weekday + 1,
);
d.isBefore(end);
d = DateTime(d.year, d.month, d.day + 7)
) {
final e = DateTime(d.year, d.month, d.day + 7);
buckets.add((
d.isBefore(start) ? start : d,
e.isAfter(end) ? end : e,
DateFormat('d.MM').format(d),
));
}
} else {
for (var d = start; d.isBefore(end); d = DateTime(d.year, d.month + 1)) {
buckets.add((
d,
DateTime(d.year, d.month + 1),
DateFormat('MMM', 'ru').format(d),
));
}
}
final summaries = buckets
.map((b) => FinanceMath.summary(store.transactions, b.$1, b.$2))
.toList();
var max = 1;
for (final s in summaries) {
if (s.incomeMinor > max) max = s.incomeMinor;
if (s.expensesMinor > max) max = s.expensesMinor;
}
return Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Wrap(
spacing: 20,
children: [
Text('● Доходы', style: TextStyle(color: Colors.teal)),
Text('● Расходы', style: TextStyle(color: Colors.deepOrange)),
],
),
const SizedBox(height: 24),
SizedBox(
height: 210,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (int i = 0; i < buckets.length; i++)
Expanded(
child: Tooltip(
message:
'${DateFormat('d MMM', 'ru').format(buckets[i].$1)}\nДоходы: ${money(summaries[i].incomeMinor, store.currency)}\nРасходы: ${money(summaries[i].expensesMinor, store.currency)}',
child: Semantics(
label:
'${buckets[i].$3}, доходы ${money(summaries[i].incomeMinor, store.currency)}, расходы ${money(summaries[i].expensesMinor, store.currency)}',
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Spacer(),
Expanded(
flex: 3,
child: Container(
height:
(summaries[i].incomeMinor / max * 174)
.clamp(2, 174)
.toDouble(),
decoration: BoxDecoration(
color: Colors.teal,
borderRadius: BorderRadius.circular(4),
),
),
),
const SizedBox(width: 2),
Expanded(
flex: 3,
child: Container(
height:
(summaries[i].expensesMinor /
max *
174)
.clamp(2, 174)
.toDouble(),
decoration: BoxDecoration(
color: Colors.deepOrange,
borderRadius: BorderRadius.circular(4),
),
),
),
const Spacer(),
],
),
),
const SizedBox(height: 8),
SizedBox(
height: 24,
child: i % ((buckets.length / 8).ceil()) == 0
? Text(
buckets[i].$3,
style: const TextStyle(fontSize: 10),
maxLines: 1,
)
: null,
),
],
),
),
),
),
],
),
),
],
),
);
}
List<Widget> budgets() {
final entries = store.budgets
.where(
(b) =>
b.monthStart.toLocal().year == budgetMonth.year &&
b.monthStart.toLocal().month == budgetMonth.month,
)
.toList();
return [
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 16,
runSpacing: 12,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Предыдущий месяц',
onPressed: () => setState(
() => budgetMonth = DateTime(
budgetMonth.year,
budgetMonth.month - 1,
),
),
icon: const Icon(Icons.chevron_left),
),
Text(DateFormat('LLLL y', 'ru').format(budgetMonth)),
IconButton(
tooltip: 'Следующий месяц',
onPressed: () => setState(
() => budgetMonth = DateTime(
budgetMonth.year,
budgetMonth.month + 1,
),
),
icon: const Icon(Icons.chevron_right),
),
],
),
FilledButton.icon(
onPressed: editBudget,
icon: const Icon(Icons.add),
label: const Text('Добавить лимит'),
),
],
),
const SizedBox(height: 24),
if (entries.isEmpty)
const EmptyView(
title: 'Бюджет ещё не запланирован',
subtitle: 'Задайте месячные лимиты для категорий расходов.',
icon: Icons.savings_outlined,
),
for (final b in entries)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: budgetCard(b),
),
];
}
Widget budgetCard(BudgetRecord b) {
final used = FinanceMath.budgetUsed(b, store.transactions);
final ratio = b.limitMinor > 0 ? used / b.limitMinor : 0.0;
final color = b.limitMinor <= 0 || ratio >= 1
? Theme.of(context).colorScheme.error
: ratio >= .8
? Colors.orange
: Theme.of(context).colorScheme.primary;
return Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CategoryAvatar(icon: b.categoryIcon, emoji: b.categoryEmoji),
const SizedBox(width: 12),
Expanded(
child: Text(
b.categoryName,
style: Theme.of(context).textTheme.titleMedium,
),
),
IconButton(
tooltip: 'Изменить лимит',
onPressed: () => editBudget(b),
icon: const Icon(Icons.edit_outlined),
),
IconButton(
tooltip: 'Удалить лимит',
onPressed: () async {
if (await confirmDelete(context, 'Удалить лимит?')) {
await run(() => store.deleteBudget(b.id));
}
},
icon: const Icon(Icons.delete_outline),
),
],
),
const SizedBox(height: 18),
Wrap(
spacing: 6,
children: [
Text(
money(used, store.currency),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
Text('из ${money(b.limitMinor, store.currency)}'),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: ratio.clamp(0, 1),
color: color,
minHeight: 8,
borderRadius: BorderRadius.circular(8),
),
const SizedBox(height: 12),
Text(
b.limitMinor <= 0
? 'Некорректный лимит. Измените бюджет.'
: used > b.limitMinor
? 'Превышение: ${money(used - b.limitMinor, store.currency)}'
: 'Осталось: ${money(b.limitMinor - used, store.currency)}',
style: TextStyle(color: color),
),
],
),
);
}
List<Widget> settings() => [
Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle('Оформление'),
DropdownButtonFormField<String>(
initialValue: store.currency,
decoration: const InputDecoration(labelText: 'Валюта'),
items: const [
'RUB',
'EUR',
'USD',
'SEK',
].map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(),
onChanged: (v) {
if (v != null) run(() => store.setCurrency(v));
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: store.theme,
decoration: const InputDecoration(labelText: 'Тема'),
items: const [
DropdownMenuItem(value: 'system', child: Text('Системная')),
DropdownMenuItem(value: 'light', child: Text('Светлая')),
DropdownMenuItem(value: 'dark', child: Text('Тёмная')),
],
onChanged: (v) {
if (v != null) run(() => store.setTheme(v));
},
),
const SizedBox(height: 12),
Text(
'Валюта задаёт формат сумм. Конвертация валют не выполняется.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
const SizedBox(height: 20),
Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle('Сервер и синхронизация'),
Text(
store.serverUrl.isEmpty ? 'Сервер не подключён' : store.serverUrl,
),
const SizedBox(height: 12),
Text(
store.session == null
? 'Можно работать локально или подключить свой BalanceServer.'
: store.email,
),
if (store.lastSyncAt != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'Последняя синхронизация: ${DateFormat('d.MM.y HH:mm').format(store.lastSyncAt!)}',
),
),
if (store.syncMessage != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(store.syncMessage!),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: serverDialog,
icon: const Icon(Icons.dns_outlined),
label: const Text('Адрес сервера'),
),
if (store.session == null) ...[
FilledButton(
onPressed: store.serverUrl.isEmpty
? null
: () => authDialog(false),
child: const Text('Войти'),
),
TextButton(
onPressed: store.serverUrl.isEmpty
? null
: () => authDialog(true),
child: const Text('Регистрация'),
),
] else ...[
FilledButton.icon(
onPressed: store.syncing ? null : () => run(store.sync),
icon: const Icon(Icons.sync),
label: const Text('Синхронизировать'),
),
OutlinedButton(
onPressed: store.syncing
? null
: () => run(store.resetAndSync),
child: const Text('Полная синхронизация'),
),
TextButton(
onPressed: store.syncing ? null : () => run(store.signOut),
child: const Text('Выйти'),
),
],
],
),
if (kIsWeb)
const Padding(
padding: EdgeInsets.only(top: 12),
child: Text(
'Web: используйте HTTPS и разрешите адрес приложения в CORS-настройках сервера.',
),
),
],
),
),
const SizedBox(height: 20),
Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle(
'Категории',
action: FilledButton.tonalIcon(
onPressed: editCategory,
icon: const Icon(Icons.add),
label: const Text('Добавить'),
),
),
if (store.categories.isEmpty)
const Text('Создайте свои категории или используйте встроенные.'),
for (final c in store.categories)
ListTile(
contentPadding: EdgeInsets.zero,
leading: CategoryAvatar(
icon: c.icon,
emoji: c.emoji,
color: c.colorName,
),
title: Text(c.name),
subtitle: Text(c.kindRawValue == 'income' ? 'Доход' : 'Расход'),
onTap: () => editCategory(c),
trailing: IconButton(
tooltip: 'Удалить категорию',
onPressed: () async {
if (await confirmDelete(
context,
'Удалить «${c.name}»?',
message:
'Операции сохранят категорию. Связанные бюджеты будут удалены.',
)) {
await run(() => store.deleteCategory(c.id));
}
},
icon: const Icon(Icons.delete_outline),
),
),
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: const Text('Встроенные категории'),
children: [
for (final c in [
...BuiltInCategories.expense,
...BuiltInCategories.income,
])
ListTile(
leading: CategoryAvatar(
icon: c.icon,
emoji: c.emoji,
color: c.colorName,
size: 32,
),
title: Text(c.name),
subtitle: Text(
c.kindRawValue == 'income' ? 'Доход' : 'Расход',
),
),
],
),
],
),
),
const SizedBox(height: 20),
Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
sectionTitle('Данные'),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
OutlinedButton.icon(
onPressed: export,
icon: const Icon(Icons.download_outlined),
label: const Text('Экспорт истории CSV'),
),
TextButton(
onPressed: () => showAboutDialog(
context: context,
applicationName: 'Баланс',
applicationVersion: '4.0.0',
applicationIcon: const Icon(
Icons.account_balance_wallet_outlined,
size: 40,
),
children: const [
Text(
'Личный бюджет · Flutter · Material 3\nСинхронизация с BalanceServer',
),
],
),
child: const Text('О приложении'),
),
],
),
],
),
),
];
Future<void> serverDialog() async {
final controller = TextEditingController(text: store.serverUrl);
final form = GlobalKey<FormState>();
final result = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
scrollable: true,
title: const Text('Адрес сервера'),
content: SizedBox(
width: 460,
child: Form(
key: form,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'При смене сервера потребуется новый вход. Данные аккаунтов хранятся раздельно.',
),
const SizedBox(height: 20),
TextFormField(
controller: controller,
keyboardType: TextInputType.url,
decoration: const InputDecoration(
labelText: 'https://balance.example.com',
),
validator: (s) {
final uri = Uri.tryParse((s ?? '').trim());
if (uri == null ||
!uri.hasAuthority ||
!['https', 'http'].contains(uri.scheme)) {
return 'Введите полный адрес сервера';
}
return null;
},
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
if (form.currentState!.validate()) {
Navigator.pop(context, controller.text);
}
},
child: const Text('Сохранить'),
),
],
),
);
if (result != null) {
await run(
() => store.setServerUrl(result),
success: 'Адрес сервера сохранён',
);
}
await Future<void>.delayed(const Duration(milliseconds: 300));
controller.dispose();
}
Future<void> authDialog(bool register) async {
final email = TextEditingController();
final password = TextEditingController();
final form = GlobalKey<FormState>();
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
scrollable: true,
title: Text(register ? 'Создать аккаунт' : 'Войти в аккаунт'),
content: SizedBox(
width: 420,
child: Form(
key: form,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: email,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'Электронная почта',
),
validator: (s) =>
(s ?? '').contains('@') ? null : 'Введите email',
),
const SizedBox(height: 16),
TextFormField(
controller: password,
obscureText: true,
autofillHints: [
register
? AutofillHints.newPassword
: AutofillHints.password,
],
decoration: const InputDecoration(labelText: 'Пароль'),
validator: (s) => (s ?? '').isEmpty
? 'Введите пароль'
: register && s!.length < 8
? 'Не менее 8 символов'
: null,
),
const SizedBox(height: 12),
const Text(
'При первом подключении нового аккаунта локальные записи переносятся в него. Аккаунты хранятся раздельно.',
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Отмена'),
),
FilledButton(
onPressed: () {
if (form.currentState!.validate()) Navigator.pop(context, true);
},
child: Text(register ? 'Зарегистрироваться' : 'Войти'),
),
],
),
);
if (result == true) {
await run(
() => register
? store.register(email.text.trim(), password.text)
: store.signIn(email.text.trim(), password.text),
);
}
await Future<void>.delayed(const Duration(milliseconds: 300));
email.dispose();
password.dispose();
}
Future<void> export() async {
await run(() async {
final csv = FinanceMath.exportCsv(store.transactions);
final bytes = Uint8List.fromList(utf8.encode(csv));
if (!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS)) {
final box = context.findRenderObject() as RenderBox?;
await SharePlus.instance.share(
ShareParams(
files: [
XFile.fromData(
bytes,
mimeType: 'text/csv',
name: 'balance-transactions.csv',
),
],
fileNameOverrides: ['balance-transactions.csv'],
sharePositionOrigin: box == null
? null
: box.localToGlobal(Offset.zero) & box.size,
),
);
} else {
final location = await getSaveLocation(
suggestedName: 'balance-transactions.csv',
acceptedTypeGroups: const [
XTypeGroup(label: 'CSV', extensions: ['csv']),
],
);
if (location != null) {
await XFile.fromData(
bytes,
mimeType: 'text/csv',
name: 'balance-transactions.csv',
).saveTo(location.path);
}
}
});
}
}