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