Add accs
This commit is contained in:
@@ -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', 'zł', 'Polish Zloty', 'Польский злотый'),
|
||||||
|
_Currency('CZK', 'Kč', '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
@@ -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);
|
||||||
|
+ }
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user