This commit is contained in:
2026-05-28 17:27:11 +03:00
parent ad6d0f44bf
commit 7bad68b311
4 changed files with 1949 additions and 0 deletions
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../features/settings/application/settings_controller.dart';
import '../../features/settings/application/settings_providers.dart';
import '../../features/user/application/active_user_controller.dart';
part 'app_locale_controller.g.dart';
@Riverpod(keepAlive: true)
class AppLocaleController extends _$AppLocaleController {
@override
Locale? build() {
final userId = ref.watch(activeUserControllerProvider).asData?.value?.id;
if (userId == null) return null;
final locale = ref
.watch(settingsStreamProvider(userId))
.asData
?.value
?.locale;
if (locale == null || locale.isEmpty) return null;
return Locale(locale);
}
Future<void> setLocale(String localeCode) async {
final userId = ref.read(activeUserControllerProvider).asData?.value?.id;
if (userId == null) return;
await ref
.read(settingsControllerProvider(userId).notifier)
.setLocale(localeCode);
}
}
@@ -0,0 +1,913 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../../../core/logging/app_logger.dart';
import '../../../categories/presentation/widgets/category_color_palette.dart';
import '../../../categories/presentation/widgets/category_icon.dart';
import '../../../categories/presentation/widgets/color_picker_sheet.dart';
import '../../../categories/presentation/widgets/icon_picker_sheet.dart';
import '../widgets/currency_picker_sheet.dart';
import '../../../user/application/active_user_controller.dart';
import '../../application/account_providers.dart';
import '../../application/accounts_controller.dart';
import '../../domain/entities/account.dart';
/// Форма создания (`accountId == null`) или редактирования счёта.
/// Возвращает `id` созданного/обновлённого счёта при `pop`.
class AccountFormScreen extends ConsumerWidget {
const AccountFormScreen({super.key, this.accountId});
final String? accountId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final activeUser = ref.watch(activeUserControllerProvider);
return Scaffold(
backgroundColor: p.paper,
body: activeUser.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (user) {
if (user == null) return const SizedBox.shrink();
if (accountId == null) {
return _FormBody(userId: user.id, initial: null);
}
return _EditLoader(userId: user.id, accountId: accountId!);
},
),
);
}
}
class _EditLoader extends ConsumerStatefulWidget {
const _EditLoader({required this.userId, required this.accountId});
final String userId;
final String accountId;
@override
ConsumerState<_EditLoader> createState() => _EditLoaderState();
}
class _EditLoaderState extends ConsumerState<_EditLoader> {
late Future<Account?> _future;
@override
void initState() {
super.initState();
_future = ref.read(accountRepositoryProvider).findById(widget.accountId);
}
@override
void didUpdateWidget(_EditLoader oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.accountId != widget.accountId) {
_future =
ref.read(accountRepositoryProvider).findById(widget.accountId);
}
}
@override
Widget build(BuildContext context) {
final p = context.palette;
return FutureBuilder<Account?>(
future: _future,
builder: (context, snap) {
if (!snap.hasData && snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final account = snap.data;
if (account == null) {
return Center(child: Text('', style: TextStyle(color: p.ink2)));
}
return _FormBody(userId: widget.userId, initial: account);
},
);
}
}
class _FormBody extends ConsumerStatefulWidget {
const _FormBody({required this.userId, required this.initial});
final String userId;
final Account? initial;
@override
ConsumerState<_FormBody> createState() => _FormBodyState();
}
class _FormBodyState extends ConsumerState<_FormBody> {
late final TextEditingController _nameCtrl;
late final TextEditingController _balanceCtrl;
late String _currency;
late AccountType _type;
late int? _iconCode;
late int _colorValue;
late bool _isDefault;
bool _submitting = false;
String? _nameError;
@override
void initState() {
super.initState();
final a = widget.initial;
_nameCtrl = TextEditingController(text: a?.name ?? '');
_currency = a?.currency ?? 'RUB';
_balanceCtrl = TextEditingController(
text: a != null ? (a.initialBalance / 100).toStringAsFixed(2) : '',
);
_type = a?.type ?? AccountType.cash;
_iconCode = a?.iconCode;
_colorValue = a?.colorValue ?? randomCategoryColor();
_isDefault = a?.isDefault ?? false;
}
@override
void dispose() {
_nameCtrl.dispose();
_balanceCtrl.dispose();
super.dispose();
}
bool get _isEdit => widget.initial != null;
IconData get _resolvedIcon {
if (_iconCode != null) return iconForCode(_iconCode!);
switch (_type) {
case AccountType.cash:
return Icons.payments_outlined;
case AccountType.card:
return Icons.credit_card_outlined;
case AccountType.bank:
return Icons.account_balance_outlined;
case AccountType.savings:
return Icons.savings_outlined;
}
}
int _parseBalance() {
final text = _balanceCtrl.text.trim().replaceAll(',', '.');
final val = double.tryParse(text) ?? 0.0;
return (val * 100).round();
}
Future<void> _save() async {
final l10n = context.l10n;
final name = _nameCtrl.text.trim();
if (name.isEmpty) {
setState(() => _nameError = l10n.accountValidationNameRequired);
return;
}
if (name.length > 50) {
setState(() => _nameError = l10n.accountValidationNameTooLong);
return;
}
setState(() {
_submitting = true;
_nameError = null;
});
final accountsCtrl = ref.read(accountsControllerProvider.notifier);
String resultId;
try {
if (_isEdit) {
final updated = await accountsCtrl.updateAccount(
widget.initial!.copyWith(
name: name,
type: _type,
currency: _currency,
initialBalance: _parseBalance(),
iconCode: _iconCode,
colorValue: _colorValue,
),
);
resultId = updated.id;
} else {
final created = await accountsCtrl.createAccount(
userId: widget.userId,
name: name,
type: _type,
currency: _currency,
initialBalance: _parseBalance(),
iconCode: _iconCode,
colorValue: _colorValue,
);
resultId = created.id;
}
} catch (e, st) {
AppLogger.error(
_isEdit
? 'Failed to update account (id=${widget.initial!.id})'
: 'Failed to create account',
error: e,
stackTrace: st,
tag: 'account_form',
);
if (mounted) {
setState(() => _submitting = false);
_snack(context.l10n.accountSaveError);
}
return;
}
// Установить/снять умолчательный счёт атомарно.
// При создании нового счёта: трогаем дефолт только если пользователь
// явно включил переключатель. При редактировании: снимаем дефолт если
// переключатель был выключен у ранее дефолтного счёта.
// Ошибка этого шага НЕ должна выглядеть как ошибка сохранения —
// счёт уже создан, повторное нажатие создаст дубликат.
var defaultOk = true;
try {
if (_isDefault) {
await accountsCtrl.setDefaultAccount(resultId, widget.userId);
} else if (_isEdit && (widget.initial?.isDefault ?? false)) {
await accountsCtrl.setDefaultAccount(null, widget.userId);
}
} catch (e, st) {
defaultOk = false;
AppLogger.error(
'Failed to update default flag (id=$resultId)',
error: e,
stackTrace: st,
tag: 'account_form',
);
}
if (!mounted) return;
if (!defaultOk) {
_snack(context.l10n.accountSetDefaultError);
}
Navigator.of(context).pop(resultId);
}
Future<void> _archive() async {
final l10n = context.l10n;
final id = widget.initial?.id;
if (id == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.accountArchiveConfirmTitle),
content: Text(l10n.accountArchiveConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l10n.txDeleteConfirmNo),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l10n.accountArchiveButton),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _submitting = true);
try {
await ref
.read(accountsControllerProvider.notifier)
.archiveAccount(id);
if (!mounted) return;
Navigator.of(context).pop();
} catch (e, st) {
AppLogger.error(
'Failed to archive account (id=$id)',
error: e,
stackTrace: st,
tag: 'account_form',
);
if (!mounted) return;
_snack(context.l10n.accountArchiveError);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _snack(String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final color = Color(_colorValue);
final icon = _resolvedIcon;
return SafeArea(
child: Column(
children: [
_Header(
title: _isEdit ? l10n.accountEditTitle : l10n.accountNewTitle,
onClose: () => Navigator.of(context).pop(),
trailing: _isEdit
? IconButton(
onPressed: _submitting ? null : _archive,
icon: Icon(Icons.archive_outlined, color: p.negative),
tooltip: l10n.accountArchiveButton,
)
: null,
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PreviewHeader(name: _nameCtrl.text.trim().isEmpty
? l10n.accountNameHint
: _nameCtrl.text.trim(),
icon: icon,
color: color,
),
const SizedBox(height: 16),
// Card: Name
_Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.accountNameLabel,
style: TextStyle(fontSize: 12, color: p.ink2)),
TextField(
controller: _nameCtrl,
onChanged: (_) {
if (_nameError != null) {
setState(() => _nameError = null);
} else {
setState(() {});
}
},
maxLength: 50,
textCapitalization: TextCapitalization.sentences,
style: TextStyle(fontSize: 16, color: p.ink),
decoration: InputDecoration(
isCollapsed: true,
contentPadding:
const EdgeInsets.symmetric(vertical: 8),
border: InputBorder.none,
hintText: l10n.accountNameHint,
hintStyle:
TextStyle(fontSize: 16, color: p.ink2),
counterText: '',
),
),
if (_nameError != null) ...[
const SizedBox(height: 4),
Text(_nameError!,
style: TextStyle(
fontSize: 12, color: p.negative)),
],
],
),
),
),
const SizedBox(height: 12),
// Card: Type
_AccountTypeSegmented(
value: _type,
onChanged: (t) => setState(() => _type = t),
),
const SizedBox(height: 12),
// Card: Currency + Initial balance
_Card(
child: Column(
children: [
_PickerRow(
label: l10n.accountCurrencyLabel,
leading: _CurrencyChip(currency: _currency),
onTap: () async {
final picked = await showCurrencyPicker(
context,
current: _currency,
);
if (picked != null) {
setState(() => _currency = picked);
}
},
),
_Divider(),
Padding(
padding:
const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.accountInitialBalanceLabel,
style: TextStyle(
fontSize: 12, color: p.ink2)),
TextField(
controller: _balanceCtrl,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[\d.,]')),
],
style:
TextStyle(fontSize: 16, color: p.ink),
decoration: InputDecoration(
isCollapsed: true,
contentPadding:
const EdgeInsets.symmetric(vertical: 8),
border: InputBorder.none,
hintText: '0.00',
hintStyle: TextStyle(
fontSize: 16, color: p.ink2),
),
),
],
),
),
],
),
),
const SizedBox(height: 12),
// Card: Icon + Color
_Card(
child: Column(
children: [
_PickerRow(
label: l10n.accountIconLabel,
leading: _IconChip(icon: icon, color: color),
onTap: () async {
final code = await showIconPicker(
context,
currentCode: _iconCode ?? icon.codePoint,
tintColor: color,
);
if (code != null) {
setState(() => _iconCode = code);
}
},
),
_Divider(),
_PickerRow(
label: l10n.accountColorLabel,
leading: _ColorChip(color: color),
onTap: () async {
final argb = await showColorPicker(
context,
currentValue: _colorValue,
usedValues: const {},
);
if (argb != null) {
setState(() => _colorValue = argb);
}
},
),
],
),
),
const SizedBox(height: 12),
// Card: Default account toggle
_Card(
child: Padding(
padding:
const EdgeInsets.fromLTRB(16, 4, 8, 4),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.accountIsDefaultLabel,
style: TextStyle(
fontSize: 14, color: p.ink),
),
const SizedBox(height: 2),
Text(
l10n.accountIsDefaultHint,
style: TextStyle(
fontSize: 12, color: p.ink2),
),
],
),
),
Switch(
value: _isDefault,
activeThumbColor: p.accent,
onChanged: (v) =>
setState(() => _isDefault = v),
),
],
),
),
),
const SizedBox(height: 24),
_SubmitButton(
label: _isEdit
? l10n.accountSaveEditButton
: l10n.accountSaveButton,
loading: _submitting,
onPressed: _submitting ? null : _save,
),
],
),
),
),
],
),
);
}
}
// ─── Shared UI components ────────────────────────────────────────────────────
class _Header extends StatelessWidget {
const _Header({required this.title, required this.onClose, this.trailing});
final String title;
final VoidCallback onClose;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
IconButton(
onPressed: onClose,
icon: Icon(Icons.close, color: p.ink),
),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: p.ink,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(width: 48, child: trailing),
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Container(
decoration: BoxDecoration(
color: p.paper2,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: p.line),
),
child: child,
);
}
}
class _Divider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 16),
color: context.palette.line,
);
}
}
class _PickerRow extends StatelessWidget {
const _PickerRow({
required this.label,
required this.leading,
required this.onTap,
});
final String label;
final Widget leading;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: 12, color: p.ink2)),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: leading),
Icon(Icons.chevron_right, size: 18, color: p.ink2),
],
),
],
),
),
);
}
}
class _SubmitButton extends StatelessWidget {
const _SubmitButton({
required this.label,
required this.loading,
required this.onPressed,
});
final String label;
final bool loading;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final p = context.palette;
return SizedBox(
width: double.infinity,
height: 52,
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: p.accent,
foregroundColor: p.paper,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: onPressed,
child: loading
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: p.paper,
),
)
: Text(
label,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class _AccountTypeSegmented extends StatelessWidget {
const _AccountTypeSegmented({required this.value, required this.onChanged});
final AccountType value;
final ValueChanged<AccountType> onChanged;
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final items = <(AccountType, String, IconData)>[
(AccountType.cash, l10n.accountTypeCash, Icons.payments_outlined),
(AccountType.card, l10n.accountTypeCard, Icons.credit_card_outlined),
(AccountType.bank, l10n.accountTypeBank, Icons.account_balance_outlined),
(AccountType.savings, l10n.accountTypeSavings, Icons.savings_outlined),
];
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: p.paper2,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: p.line),
),
child: Column(
children: [
Row(
children: [
for (final (type, label, icon) in items.take(2))
Expanded(
child: _TypeSegment(
label: label,
icon: icon,
selected: type == value,
onTap: () => onChanged(type),
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
for (final (type, label, icon) in items.skip(2))
Expanded(
child: _TypeSegment(
label: label,
icon: icon,
selected: type == value,
onTap: () => onChanged(type),
),
),
],
),
],
),
);
}
}
class _TypeSegment extends StatelessWidget {
const _TypeSegment({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
final String label;
final IconData icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Material(
color: selected ? p.paper : Colors.transparent,
borderRadius: BorderRadius.circular(10),
elevation: selected ? 1 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: SizedBox(
height: 44,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 16,
color: selected ? p.accent : p.ink2,
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight:
selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? p.ink : p.ink2,
),
),
],
),
),
),
);
}
}
class _PreviewHeader extends StatelessWidget {
const _PreviewHeader({
required this.name,
required this.icon,
required this.color,
});
final String name;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Icon(icon, size: 36, color: color),
),
const SizedBox(height: 10),
Text(
name,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: p.ink,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
class _IconChip extends StatelessWidget {
const _IconChip({required this.icon, required this.color});
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: color),
),
],
);
}
}
class _CurrencyChip extends StatelessWidget {
const _CurrencyChip({required this.currency});
final String currency;
@override
Widget build(BuildContext context) {
final p = context.palette;
final symbol = symbolForCurrency(currency);
return Row(
children: [
Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: p.paper,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: p.line),
),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
symbol,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: p.ink,
),
),
const SizedBox(width: 6),
Text(
currency,
style: TextStyle(fontSize: 13, color: p.ink2),
),
],
),
),
],
);
}
}
class _ColorChip extends StatelessWidget {
const _ColorChip({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: p.line),
),
),
],
);
}
}
@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
class _Currency {
const _Currency(this.code, this.symbol, this.nameEn, this.nameRu);
final String code;
final String symbol;
final String nameEn;
final String nameRu;
}
const _kCurrencies = [
_Currency('RUB', '', 'Russian Ruble', 'Российский рубль'),
_Currency('USD', '\$', 'US Dollar', 'Доллар США'),
_Currency('EUR', '', 'Euro', 'Евро'),
_Currency('GBP', '£', 'British Pound', 'Британский фунт'),
_Currency('CNY', '¥', 'Chinese Yuan', 'Китайский юань'),
_Currency('JPY', '¥', 'Japanese Yen', 'Японская иена'),
_Currency('CHF', 'Fr', 'Swiss Franc', 'Швейцарский франк'),
_Currency('TRY', '', 'Turkish Lira', 'Турецкая лира'),
_Currency('KZT', '', 'Kazakhstani Tenge', 'Казахстанский тенге'),
_Currency('BYN', 'Br', 'Belarusian Ruble', 'Белорусский рубль'),
_Currency('UAH', '', 'Ukrainian Hryvnia', 'Украинская гривна'),
_Currency('GEL', '', 'Georgian Lari', 'Грузинский лари'),
_Currency('AMD', '֏', 'Armenian Dram', 'Армянский драм'),
_Currency('AZN', '', 'Azerbaijani Manat', 'Азербайджанский манат'),
_Currency('UZS', 'сўм', 'Uzbekistani Som', 'Узбекский сум'),
_Currency('KGS', 'с', 'Kyrgyzstani Som', 'Киргизский сом'),
_Currency('TJS', 'SM', 'Tajikistani Somoni', 'Таджикский сомони'),
_Currency('TMT', 'T', 'Turkmenistani Manat', 'Туркменский манат'),
_Currency('MDL', 'L', 'Moldovan Leu', 'Молдавский лей'),
_Currency('AED', 'د.إ', 'UAE Dirham', 'Дирхам ОАЭ'),
_Currency('AUD', 'A\$', 'Australian Dollar', 'Австралийский доллар'),
_Currency('CAD', 'C\$', 'Canadian Dollar', 'Канадский доллар'),
_Currency('HKD', 'HK\$', 'Hong Kong Dollar', 'Гонконгский доллар'),
_Currency('SGD', 'S\$', 'Singapore Dollar', 'Сингапурский доллар'),
_Currency('SEK', 'kr', 'Swedish Krona', 'Шведская крона'),
_Currency('NOK', 'kr', 'Norwegian Krone', 'Норвежская крона'),
_Currency('DKK', 'kr', 'Danish Krone', 'Датская крона'),
_Currency('PLN', '', 'Polish Zloty', 'Польский злотый'),
_Currency('CZK', '', 'Czech Koruna', 'Чешская крона'),
_Currency('HUF', 'Ft', 'Hungarian Forint', 'Венгерский форинт'),
_Currency('RON', 'lei', 'Romanian Leu', 'Румынский лей'),
_Currency('INR', '', 'Indian Rupee', 'Индийская рупия'),
_Currency('KRW', '', 'South Korean Won', 'Южнокорейская вона'),
_Currency('BRL', 'R\$', 'Brazilian Real', 'Бразильский реал'),
_Currency('MXN', '\$', 'Mexican Peso', 'Мексиканское песо'),
_Currency('ZAR', 'R', 'South African Rand', 'Южноафриканский рэнд'),
_Currency('THB', '฿', 'Thai Baht', 'Тайский бат'),
_Currency('IDR', 'Rp', 'Indonesian Rupiah', 'Индонезийская рупия'),
_Currency('MYR', 'RM', 'Malaysian Ringgit', 'Малайзийский ринггит'),
_Currency('PHP', '', 'Philippine Peso', 'Филиппинское песо'),
_Currency('VND', '', 'Vietnamese Dong', 'Вьетнамский донг'),
];
/// Returns the symbol for [code], or the code itself if unknown.
String symbolForCurrency(String code) {
for (final c in _kCurrencies) {
if (c.code == code) return c.symbol;
}
return code;
}
Future<String?> showCurrencyPicker(
BuildContext context, {
required String current,
}) {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => _CurrencyPickerSheet(current: current),
);
}
class _CurrencyPickerSheet extends StatefulWidget {
const _CurrencyPickerSheet({required this.current});
final String current;
@override
State<_CurrencyPickerSheet> createState() => _CurrencyPickerSheetState();
}
class _CurrencyPickerSheetState extends State<_CurrencyPickerSheet> {
final _searchCtrl = TextEditingController();
List<_Currency> _filtered = _kCurrencies;
@override
void initState() {
super.initState();
_searchCtrl.addListener(_onSearch);
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
void _onSearch() {
final q = _searchCtrl.text.toLowerCase();
setState(() {
_filtered = q.isEmpty
? _kCurrencies
: _kCurrencies
.where((c) =>
c.code.toLowerCase().contains(q) ||
c.nameEn.toLowerCase().contains(q) ||
c.nameRu.toLowerCase().contains(q) ||
c.symbol.toLowerCase().contains(q))
.toList();
});
}
bool get _isRu =>
Localizations.localeOf(context).languageCode == 'ru';
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
return DraggableScrollableSheet(
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Container(
decoration: BoxDecoration(
color: p.paper,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: p.line2,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.currencyPickerTitle,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: p.ink,
),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
height: 40,
decoration: BoxDecoration(
color: p.paper2,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: p.line),
),
child: TextField(
controller: _searchCtrl,
style: TextStyle(fontSize: 14, color: p.ink),
decoration: InputDecoration(
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 10,
horizontal: 12,
),
border: InputBorder.none,
hintText: l10n.currencyPickerSearchHint,
hintStyle: TextStyle(fontSize: 14, color: p.ink2),
prefixIcon: Icon(Icons.search, size: 18, color: p.ink2),
prefixIconConstraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
),
),
),
),
const SizedBox(height: 8),
Expanded(
child: _filtered.isEmpty
? Center(
child: Text(
'',
style: TextStyle(color: p.ink2),
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(12, 4, 12, 24),
itemCount: _filtered.length,
itemBuilder: (context, i) {
final c = _filtered[i];
final selected = c.code == widget.current;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => Navigator.of(context).pop(c.code),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [
Container(
width: 44,
height: 36,
decoration: BoxDecoration(
color: p.paper2,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: p.line),
),
alignment: Alignment.center,
child: Text(
c.symbol,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: p.ink,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
c.code,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: p.ink,
),
),
Text(
_isRu ? c.nameRu : c.nameEn,
style: TextStyle(
fontSize: 12,
color: p.ink2,
),
),
],
),
),
if (selected)
Icon(
Icons.check_circle_rounded,
size: 20,
color: p.accent,
),
],
),
),
),
);
},
),
),
],
),
),
);
}
}
+722
View File
@@ -0,0 +1,722 @@
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 8650b06..d59bdea 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -74,6 +74,9 @@
"profileSubtitle": "Settings",
"darkTheme": "Dark theme",
+ "profileLanguageTile": "Language",
+ "langEnglish": "English",
+ "langRussian": "Russian",
"profileHint": "User profile, currency, locale and other settings will appear here.",
"onboardingTitle": "Welcome",
@@ -154,6 +157,8 @@
"iconPickerTitle": "Pick an icon",
"colorPickerTitle": "Pick a color",
+ "currencyPickerTitle": "Pick a currency",
+ "currencyPickerSearchHint": "Search…",
"colorPickerAllUsed": "All palette colors are already in use by other categories.",
"iconGroupFood": "Food",
"iconGroupTransport": "Transport",
@@ -166,5 +171,32 @@
"iconGroupFinance": "Finance",
"iconGroupOther": "Other",
- "profileCategoriesTile": "Categories"
+ "profileCategoriesTile": "Categories",
+ "profileAccountsTile": "Accounts",
+
+ "accountsEmpty": "No accounts yet. Tap + to add one.",
+ "accountNewTitle": "New account",
+ "accountEditTitle": "Edit account",
+ "accountNameLabel": "Name",
+ "accountNameHint": "e.g. Cash wallet",
+ "accountTypeLabel": "Type",
+ "accountTypeCash": "Cash",
+ "accountTypeCard": "Card",
+ "accountTypeBank": "Bank",
+ "accountTypeSavings": "Savings",
+ "accountCurrencyLabel": "Currency",
+ "accountInitialBalanceLabel": "Initial balance",
+ "accountIconLabel": "Icon",
+ "accountColorLabel": "Color",
+ "accountIsDefaultLabel": "Default account",
+ "accountIsDefaultHint": "Used when creating new transactions",
+ "accountSaveButton": "Create account",
+ "accountSaveEditButton": "Save changes",
+ "accountArchiveButton": "Archive",
+ "accountArchiveConfirmTitle": "Archive account?",
+ "accountArchiveConfirmMessage": "Archived accounts disappear from pickers and lists. Existing transactions keep their reference.",
+ "accountValidationNameRequired": "Enter a name",
+ "accountValidationNameTooLong": "Name is too long (max 50 chars)",
+ "accountSaveError": "Failed to save the account. Please try again.",
+ "accountArchiveError": "Failed to archive the account. Please try again."
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 01eed48..e4fb291 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -74,6 +74,9 @@
"profileSubtitle": "Настройки",
"darkTheme": "Тёмная тема",
+ "profileLanguageTile": "Язык",
+ "langEnglish": "English",
+ "langRussian": "Русский",
"profileHint": "Здесь появится профиль пользователя, валюта, локаль и другие настройки.",
"onboardingTitle": "Добро пожаловать",
@@ -154,6 +157,8 @@
"iconPickerTitle": "Выберите иконку",
"colorPickerTitle": "Выберите цвет",
+ "currencyPickerTitle": "Выберите валюту",
+ "currencyPickerSearchHint": "Поиск…",
"colorPickerAllUsed": "Все цвета палитры уже заняты другими категориями.",
"iconGroupFood": "Еда",
"iconGroupTransport": "Транспорт",
@@ -166,5 +171,32 @@
"iconGroupFinance": "Финансы",
"iconGroupOther": "Прочее",
- "profileCategoriesTile": "Категории"
+ "profileCategoriesTile": "Категории",
+ "profileAccountsTile": "Счета",
+
+ "accountsEmpty": "Счетов нет. Нажмите +, чтобы добавить.",
+ "accountNewTitle": "Новый счёт",
+ "accountEditTitle": "Редактировать счёт",
+ "accountNameLabel": "Название",
+ "accountNameHint": "напр. Кошелёк",
+ "accountTypeLabel": "Тип",
+ "accountTypeCash": "Наличные",
+ "accountTypeCard": "Карта",
+ "accountTypeBank": "Банк",
+ "accountTypeSavings": "Накопления",
+ "accountCurrencyLabel": "Валюта",
+ "accountInitialBalanceLabel": "Начальный баланс",
+ "accountIconLabel": "Иконка",
+ "accountColorLabel": "Цвет",
+ "accountIsDefaultLabel": "Счёт по умолчанию",
+ "accountIsDefaultHint": "Подставляется при создании транзакций",
+ "accountSaveButton": "Создать счёт",
+ "accountSaveEditButton": "Сохранить",
+ "accountArchiveButton": "Архивировать",
+ "accountArchiveConfirmTitle": "Архивировать счёт?",
+ "accountArchiveConfirmMessage": "Архивные счета исчезают из списков и пикеров. Существующие транзакции сохранят ссылку.",
+ "accountValidationNameRequired": "Введите название",
+ "accountValidationNameTooLong": "Слишком длинное название (макс. 50 символов)",
+ "accountSaveError": "Не удалось сохранить счёт. Попробуйте ещё раз.",
+ "accountArchiveError": "Не удалось архивировать счёт. Попробуйте ещё раз."
}
diff --git a/lib/src/app/app.dart b/lib/src/app/app.dart
index cf2147b..a36c315 100644
--- a/lib/src/app/app.dart
+++ b/lib/src/app/app.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:new_budget/l10n/app_localizations.dart';
+import 'locale/app_locale_controller.dart';
import 'router/app_router.dart';
import 'theme/app_theme.dart';
import 'theme/theme_mode_controller.dart';
@@ -13,6 +14,7 @@ class NewBudgetApp extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
final themeMode = ref.watch(themeModeControllerProvider);
+ final locale = ref.watch(appLocaleControllerProvider);
return MaterialApp.router(
title: 'NewBudget',
@@ -20,6 +22,7 @@ class NewBudgetApp extends ConsumerWidget {
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: themeMode,
+ locale: locale,
routerConfig: router,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
diff --git a/lib/src/app/router/app_router.dart b/lib/src/app/router/app_router.dart
index a6b821e..a5af90d 100644
--- a/lib/src/app/router/app_router.dart
+++ b/lib/src/app/router/app_router.dart
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../core/database/converters/enum_converters.dart';
+import '../../features/accounts/presentation/screens/account_form_screen.dart';
import '../../features/accounts/presentation/screens/accounts_screen.dart';
import '../../features/analytics/presentation/screens/analytics_screen.dart';
import '../../features/categories/presentation/screens/categories_list_screen.dart';
@@ -81,6 +82,20 @@ GoRouter appRouter(Ref ref) {
CategoryFormScreen(categoryId: state.pathParameters['id']),
),
),
+ GoRoute(
+ path: AppRoutes.accountNew,
+ pageBuilder: (context, state) => _slideUpPage<String?>(
+ state,
+ const AccountFormScreen(),
+ ),
+ ),
+ GoRoute(
+ path: AppRoutes.accountEditPattern,
+ pageBuilder: (context, state) => _slideUpPage<String?>(
+ state,
+ AccountFormScreen(accountId: state.pathParameters['id']),
+ ),
+ ),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => AppScaffold(
navigationShell: navigationShell,
diff --git a/lib/src/app/router/app_routes.dart b/lib/src/app/router/app_routes.dart
index 0c43dfc..623cd39 100644
--- a/lib/src/app/router/app_routes.dart
+++ b/lib/src/app/router/app_routes.dart
@@ -15,4 +15,8 @@ class AppRoutes {
static const categoryNew = '/categories/new';
static const categoryEditPattern = '/categories/edit/:id';
static String categoryEdit(String id) => '/categories/edit/$id';
+
+ static const accountNew = '/accounts/new';
+ static const accountEditPattern = '/accounts/edit/:id';
+ static String accountEdit(String id) => '/accounts/edit/$id';
}
diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart
index 2848272..296e06a 100644
--- a/lib/src/core/database/app_database.dart
+++ b/lib/src/core/database/app_database.dart
@@ -39,7 +39,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
- int get schemaVersion => 3;
+ int get schemaVersion => 4;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -60,6 +60,10 @@ class AppDatabase extends _$AppDatabase {
// v2 → v3: добавлено поле extra_info в transactions.
await m.addColumn(transactionsTable, transactionsTable.extraInfo);
}
+ if (from < 4) {
+ // v3 → v4: добавлено поле is_default в accounts.
+ await m.addColumn(accountsTable, accountsTable.isDefault);
+ }
},
);
diff --git a/lib/src/core/database/daos/accounts_dao.dart b/lib/src/core/database/daos/accounts_dao.dart
index 241000c..1407788 100644
--- a/lib/src/core/database/daos/accounts_dao.dart
+++ b/lib/src/core/database/daos/accounts_dao.dart
@@ -29,13 +29,33 @@ class AccountsDao extends DatabaseAccessor<AppDatabase>
Future<void> insertAccount(AccountsTableCompanion companion) =>
into(accountsTable).insert(companion);
- Future<bool> updateAccount(AccountsTableCompanion companion) =>
- update(accountsTable).replace(companion);
+ Future<void> updateAccount(AccountsTableCompanion companion) =>
+ (update(accountsTable)..where((t) => t.id.equals(companion.id.value)))
+ .write(companion);
Future<void> archiveAccount(String id) => (update(accountsTable)
..where((t) => t.id.equals(id)))
.write(const AccountsTableCompanion(archived: Value(true)));
+ /// Реактивный умолчательный счёт пользователя (или null).
+ Stream<AccountsTableData?> watchDefaultAccount(String userId) =>
+ (select(accountsTable)
+ ..where((t) => t.userId.equals(userId) & t.isDefault.equals(true))
+ ..limit(1))
+ .watchSingleOrNull();
+
+ /// Атомарная смена умолчательного счёта.
+ /// id == null — снять дефолт со всех счетов пользователя.
+ Future<void> setDefaultAccount(String? id, String userId) =>
+ transaction(() async {
+ await (update(accountsTable)..where((t) => t.userId.equals(userId)))
+ .write(const AccountsTableCompanion(isDefault: Value(false)));
+ if (id != null) {
+ await (update(accountsTable)..where((t) => t.id.equals(id)))
+ .write(const AccountsTableCompanion(isDefault: Value(true)));
+ }
+ });
+
/// Реактивный текущий баланс счёта.
///
/// Формула: `initialBalance + Σincome Σexpense + Σtransfer_in Σtransfer_out`,
diff --git a/lib/src/core/database/tables/accounts_table.dart b/lib/src/core/database/tables/accounts_table.dart
index 61c4486..0532112 100644
--- a/lib/src/core/database/tables/accounts_table.dart
+++ b/lib/src/core/database/tables/accounts_table.dart
@@ -26,6 +26,7 @@ class AccountsTable extends Table {
IntColumn get colorValue => integer().nullable()();
BoolColumn get archived => boolean().withDefault(const Constant(false))();
+ BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
diff --git a/lib/src/features/accounts/application/accounts_controller.dart b/lib/src/features/accounts/application/accounts_controller.dart
index 62cefbd..c88d2a5 100644
--- a/lib/src/features/accounts/application/accounts_controller.dart
+++ b/lib/src/features/accounts/application/accounts_controller.dart
@@ -15,6 +15,11 @@ Stream<List<Account>> accountsStream(Ref ref, String userId) =>
Stream<int> accountBalance(Ref ref, String accountId) =>
ref.watch(accountRepositoryProvider).watchBalance(accountId);
+/// Умолчательный счёт пользователя (или null если не задан).
+@riverpod
+Stream<Account?> defaultAccount(Ref ref, String userId) =>
+ ref.watch(accountRepositoryProvider).watchDefault(userId);
+
/// Контроллер CRUD-операций над счетами.
@Riverpod(keepAlive: true)
class AccountsController extends _$AccountsController {
@@ -31,8 +36,8 @@ class AccountsController extends _$AccountsController {
int? colorValue,
}) async {
state = const AsyncLoading();
- final result = await AsyncValue.guard(
- () => ref.read(accountRepositoryProvider).create(
+ try {
+ final account = await ref.read(accountRepositoryProvider).create(
userId: userId,
name: name,
type: type,
@@ -40,25 +45,30 @@ class AccountsController extends _$AccountsController {
initialBalance: initialBalance,
iconCode: iconCode,
colorValue: colorValue,
- ),
- );
- state = result.hasError
- ? AsyncError(result.error!, StackTrace.current)
- : const AsyncData(null);
- return result.value!;
+ );
+ state = const AsyncData(null);
+ return account;
+ } catch (e, st) {
+ state = AsyncError(e, st);
+ rethrow;
+ }
}
Future<Account> updateAccount(Account account) async {
state = const AsyncLoading();
- final result = await AsyncValue.guard(
- () => ref.read(accountRepositoryProvider).update(account),
- );
- state = result.hasError
- ? AsyncError(result.error!, StackTrace.current)
- : const AsyncData(null);
- return result.value!;
+ try {
+ final updated = await ref.read(accountRepositoryProvider).update(account);
+ state = const AsyncData(null);
+ return updated;
+ } catch (e, st) {
+ state = AsyncError(e, st);
+ rethrow;
+ }
}
+ Future<void> setDefaultAccount(String? id, String userId) =>
+ ref.read(accountRepositoryProvider).setDefault(id, userId);
+
Future<void> archiveAccount(String id) async {
state = const AsyncLoading();
state = await AsyncValue.guard(
diff --git a/lib/src/features/accounts/data/mappers/account_mapper.dart b/lib/src/features/accounts/data/mappers/account_mapper.dart
index 1b9a339..32a0bcd 100644
--- a/lib/src/features/accounts/data/mappers/account_mapper.dart
+++ b/lib/src/features/accounts/data/mappers/account_mapper.dart
@@ -12,6 +12,7 @@ extension AccountMapper on AccountsTableData {
iconCode: iconCode,
colorValue: colorValue,
archived: archived,
+ isDefault: isDefault,
createdAt: createdAt,
);
}
diff --git a/lib/src/features/accounts/data/repositories/account_repository_impl.dart b/lib/src/features/accounts/data/repositories/account_repository_impl.dart
index 7e53d27..6773eaa 100644
--- a/lib/src/features/accounts/data/repositories/account_repository_impl.dart
+++ b/lib/src/features/accounts/data/repositories/account_repository_impl.dart
@@ -65,6 +65,14 @@ class AccountRepositoryImpl implements AccountRepository {
@override
Future<void> archive(String id) => _dao.archiveAccount(id);
+ @override
+ Stream<Account?> watchDefault(String userId) =>
+ _dao.watchDefaultAccount(userId).map((row) => row?.toDomain());
+
+ @override
+ Future<void> setDefault(String? id, String userId) =>
+ _dao.setDefaultAccount(id, userId);
+
@override
Stream<int> watchBalance(String accountId) => _dao.watchAccountBalance(accountId);
}
diff --git a/lib/src/features/accounts/domain/entities/account.dart b/lib/src/features/accounts/domain/entities/account.dart
index 6bf4271..0e83fc9 100644
--- a/lib/src/features/accounts/domain/entities/account.dart
+++ b/lib/src/features/accounts/domain/entities/account.dart
@@ -15,6 +15,7 @@ abstract class Account with _$Account {
int? iconCode,
int? colorValue,
required bool archived,
+ @Default(false) bool isDefault,
required DateTime createdAt,
}) = _Account;
}
diff --git a/lib/src/features/accounts/domain/repositories/account_repository.dart b/lib/src/features/accounts/domain/repositories/account_repository.dart
index 12442da..6100a21 100644
--- a/lib/src/features/accounts/domain/repositories/account_repository.dart
+++ b/lib/src/features/accounts/domain/repositories/account_repository.dart
@@ -16,6 +16,12 @@ abstract interface class AccountRepository {
Future<Account> update(Account account);
Future<void> archive(String id);
+ /// Реактивный умолчательный счёт пользователя.
+ Stream<Account?> watchDefault(String userId);
+
+ /// Установить (или снять, если id == null) умолчательный счёт.
+ Future<void> setDefault(String? id, String userId);
+
/// Текущий баланс счёта (начальный + агрегат транзакций).
Stream<int> watchBalance(String accountId);
}
diff --git a/lib/src/features/accounts/presentation/screens/accounts_screen.dart b/lib/src/features/accounts/presentation/screens/accounts_screen.dart
index edb896c..52596be 100644
--- a/lib/src/features/accounts/presentation/screens/accounts_screen.dart
+++ b/lib/src/features/accounts/presentation/screens/accounts_screen.dart
@@ -1,17 +1,205 @@
import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
import '../../../../app/l10n/l10n.dart';
-import '../../../../shared/widgets/placeholder_screen.dart';
+import '../../../../app/router/app_routes.dart';
+import '../../../../app/theme/app_colors.dart';
+import '../../../home/presentation/widgets/money_text.dart';
+import '../../../user/application/active_user_controller.dart';
+import '../../application/accounts_controller.dart';
+import '../../domain/entities/account.dart';
+import '../widgets/account_icon.dart';
-class AccountsScreen extends StatelessWidget {
+class AccountsScreen extends ConsumerWidget {
const AccountsScreen({super.key});
@override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, WidgetRef ref) {
+ final p = context.palette;
+ final activeUser = ref.watch(activeUserControllerProvider);
+
+ return Scaffold(
+ backgroundColor: p.paper,
+ body: SafeArea(
+ child: activeUser.when(
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (e, _) => Center(child: Text('$e')),
+ data: (user) {
+ if (user == null) return const SizedBox.shrink();
+ return _AccountsList(userId: user.id);
+ },
+ ),
+ ),
+ floatingActionButton: FloatingActionButton(
+ backgroundColor: p.accent,
+ foregroundColor: p.paper,
+ onPressed: () => context.push(AppRoutes.accountNew),
+ child: const Icon(Icons.add),
+ ),
+ );
+ }
+}
+
+class _AccountsList extends ConsumerWidget {
+ const _AccountsList({required this.userId});
+ final String userId;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final p = context.palette;
final l10n = context.l10n;
- return PlaceholderScreen(
- subtitle: l10n.accountsSubtitle,
- title: l10n.navAccounts,
+ final accountsAsync = ref.watch(accountsStreamProvider(userId));
+ final defaultAsync = ref.watch(defaultAccountProvider(userId));
+ final defaultId = defaultAsync.value?.id;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ l10n.accountsSubtitle.toUpperCase(),
+ style: TextStyle(
+ fontSize: 11,
+ color: p.ink2,
+ letterSpacing: 0.6,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ l10n.navAccounts,
+ style: TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w600,
+ color: p.ink,
+ ),
+ ),
+ ],
+ ),
+ ),
+ Expanded(
+ child: accountsAsync.when(
+ loading: () => const Center(child: CircularProgressIndicator()),
+ error: (e, _) => Center(child: Text('$e')),
+ data: (accounts) {
+ if (accounts.isEmpty) {
+ return Center(
+ child: Text(
+ l10n.accountsEmpty,
+ style: TextStyle(fontSize: 14, color: p.ink2),
+ textAlign: TextAlign.center,
+ ),
+ );
+ }
+ return ListView.builder(
+ padding: const EdgeInsets.fromLTRB(12, 4, 12, 80),
+ itemCount: accounts.length,
+ itemBuilder: (context, i) => _AccountTile(
+ account: accounts[i],
+ isDefault: accounts[i].id == defaultId,
+ onTap: () => context.push(
+ AppRoutes.accountEdit(accounts[i].id),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _AccountTile extends ConsumerWidget {
+ const _AccountTile({
+ required this.account,
+ required this.isDefault,
+ required this.onTap,
+ });
+
+ final Account account;
+ final bool isDefault;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final p = context.palette;
+ final balance = ref.watch(accountBalanceProvider(account.id)).value ?? 0;
+ final color = Color(account.colorValue ?? 0xFFB8B5AC);
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
+ child: Material(
+ color: p.paper2,
+ borderRadius: BorderRadius.circular(14),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(14),
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
+ child: Row(
+ children: [
+ Container(
+ width: 44,
+ height: 44,
+ decoration: BoxDecoration(
+ color: color.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(iconForAccount(account), size: 22, color: color),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ Flexible(
+ child: Text(
+ account.name,
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w600,
+ color: p.ink,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ if (isDefault) ...[
+ const SizedBox(width: 6),
+ Icon(Icons.star_rounded,
+ size: 14, color: p.accent),
+ ],
+ ],
+ ),
+ const SizedBox(height: 2),
+ Text(
+ shortAccountLabel(account),
+ style: TextStyle(fontSize: 12, color: p.ink2),
+ ),
+ ],
+ ),
+ ),
+ MoneyText(
+ balance,
+ color: p.ink,
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ ),
+ const SizedBox(width: 4),
+ Icon(Icons.chevron_right, size: 16, color: p.ink2),
+ ],
+ ),
+ ),
+ ),
+ ),
);
}
}
diff --git a/lib/src/features/accounts/presentation/widgets/account_icon.dart b/lib/src/features/accounts/presentation/widgets/account_icon.dart
index 00b6e87..e12bfdc 100644
--- a/lib/src/features/accounts/presentation/widgets/account_icon.dart
+++ b/lib/src/features/accounts/presentation/widgets/account_icon.dart
@@ -16,15 +16,4 @@ IconData iconForAccount(Account a) {
}
}
-String shortAccountLabel(Account a) {
- switch (a.type) {
- case AccountType.cash:
- return 'Кэш';
- case AccountType.card:
- return 'Карта';
- case AccountType.bank:
- return 'Банк';
- case AccountType.savings:
- return 'Копилка';
- }
-}
+String shortAccountLabel(Account a) => a.name;
diff --git a/lib/src/features/home/presentation/widgets/account_tabs.dart b/lib/src/features/home/presentation/widgets/account_tabs.dart
index e282c34..a23102c 100644
--- a/lib/src/features/home/presentation/widgets/account_tabs.dart
+++ b/lib/src/features/home/presentation/widgets/account_tabs.dart
@@ -15,9 +15,9 @@ class AccountTabs extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final accounts =
- ref.watch(accountsStreamProvider(userId)).value ??
- const <Account>[];
+ final accounts = [
+ ...ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[],
+ ]..sort((a, b) => (b.isDefault ? 1 : 0) - (a.isDefault ? 1 : 0));
final selected = ref.watch(selectedAccountProvider);
return SizedBox(
diff --git a/lib/src/features/profile/presentation/screens/profile_screen.dart b/lib/src/features/profile/presentation/screens/profile_screen.dart
index 71c6a57..15d6aa5 100644
--- a/lib/src/features/profile/presentation/screens/profile_screen.dart
+++ b/lib/src/features/profile/presentation/screens/profile_screen.dart
@@ -42,6 +42,12 @@ class ProfileScreen extends ConsumerWidget {
),
),
Container(height: 1, color: p.line),
+ _NavRow(
+ icon: Icons.account_balance_wallet_outlined,
+ title: l10n.profileAccountsTile,
+ onTap: () => context.go(AppRoutes.accounts),
+ ),
+ Container(height: 1, color: p.line),
_NavRow(
icon: Icons.label_outline,
title: l10n.profileCategoriesTile,
diff --git a/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart b/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart
index ee24203..245fced 100644
--- a/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart
+++ b/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart
@@ -297,6 +297,21 @@ class _FormBodyState extends ConsumerState<_FormBody> {
ref.read(transactionDraftControllerProvider(widget.txId).notifier);
_syncControllersFromDraft(draft);
+ // Подставить умолчательный счёт при открытии формы создания.
+ if (widget.txId == null) {
+ final defaultAccount = ref.watch(defaultAccountProvider(widget.userId));
+ if (draft.accountId == null && defaultAccount.value != null) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted) return;
+ final current = ref.read(
+ transactionDraftControllerProvider(widget.txId));
+ if (current.accountId == null) {
+ draftCtrl.setAccount(defaultAccount.value!.id);
+ }
+ });
+ }
+ }
+
final accounts = ref.watch(accountsStreamProvider(widget.userId)).value ??
const <Account>[];
final accountById = {for (final a in accounts) a.id: a};
diff --git a/lib/src/features/transactions/presentation/widgets/account_picker_sheet.dart b/lib/src/features/transactions/presentation/widgets/account_picker_sheet.dart
index 5991cc3..79e0874 100644
--- a/lib/src/features/transactions/presentation/widgets/account_picker_sheet.dart
+++ b/lib/src/features/transactions/presentation/widgets/account_picker_sheet.dart
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
import '../../../../app/l10n/l10n.dart';
+import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../accounts/domain/entities/account.dart';
@@ -99,10 +101,11 @@ class _AccountPickerSheet extends ConsumerWidget {
if (i == accounts.length) {
return _AddAccountTile(
label: l10n.pickerAddAccount,
- onTap: () {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.comingSoonShort)),
- );
+ onTap: () async {
+ final id = await context.push<String?>(AppRoutes.accountNew);
+ if (id != null && context.mounted) {
+ Navigator.of(context).pop(id);
+ }
},
);
}