This commit is contained in:
2026-05-28 17:27:06 +03:00
parent f8f14270bd
commit ad6d0f44bf
22 changed files with 881 additions and 52 deletions
+34 -1
View File
@@ -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,33 @@
"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.",
"accountSetDefaultError": "Account saved, but couldn't mark it as default."
}
+192
View File
@@ -254,6 +254,24 @@ abstract class AppLocalizations {
/// **'Тёмная тема'**
String get darkTheme;
/// No description provided for @profileLanguageTile.
///
/// In ru, this message translates to:
/// **'Язык'**
String get profileLanguageTile;
/// No description provided for @langEnglish.
///
/// In ru, this message translates to:
/// **'English'**
String get langEnglish;
/// No description provided for @langRussian.
///
/// In ru, this message translates to:
/// **'Русский'**
String get langRussian;
/// No description provided for @profileHint.
///
/// In ru, this message translates to:
@@ -620,6 +638,18 @@ abstract class AppLocalizations {
/// **'Выберите цвет'**
String get colorPickerTitle;
/// No description provided for @currencyPickerTitle.
///
/// In ru, this message translates to:
/// **'Выберите валюту'**
String get currencyPickerTitle;
/// No description provided for @currencyPickerSearchHint.
///
/// In ru, this message translates to:
/// **'Поиск…'**
String get currencyPickerSearchHint;
/// No description provided for @colorPickerAllUsed.
///
/// In ru, this message translates to:
@@ -691,6 +721,168 @@ abstract class AppLocalizations {
/// In ru, this message translates to:
/// **'Категории'**
String get profileCategoriesTile;
/// No description provided for @profileAccountsTile.
///
/// In ru, this message translates to:
/// **'Счета'**
String get profileAccountsTile;
/// No description provided for @accountsEmpty.
///
/// In ru, this message translates to:
/// **'Счетов нет. Нажмите +, чтобы добавить.'**
String get accountsEmpty;
/// No description provided for @accountNewTitle.
///
/// In ru, this message translates to:
/// **'Новый счёт'**
String get accountNewTitle;
/// No description provided for @accountEditTitle.
///
/// In ru, this message translates to:
/// **'Редактировать счёт'**
String get accountEditTitle;
/// No description provided for @accountNameLabel.
///
/// In ru, this message translates to:
/// **'Название'**
String get accountNameLabel;
/// No description provided for @accountNameHint.
///
/// In ru, this message translates to:
/// **'напр. Кошелёк'**
String get accountNameHint;
/// No description provided for @accountTypeLabel.
///
/// In ru, this message translates to:
/// **'Тип'**
String get accountTypeLabel;
/// No description provided for @accountTypeCash.
///
/// In ru, this message translates to:
/// **'Наличные'**
String get accountTypeCash;
/// No description provided for @accountTypeCard.
///
/// In ru, this message translates to:
/// **'Карта'**
String get accountTypeCard;
/// No description provided for @accountTypeBank.
///
/// In ru, this message translates to:
/// **'Банк'**
String get accountTypeBank;
/// No description provided for @accountTypeSavings.
///
/// In ru, this message translates to:
/// **'Накопления'**
String get accountTypeSavings;
/// No description provided for @accountCurrencyLabel.
///
/// In ru, this message translates to:
/// **'Валюта'**
String get accountCurrencyLabel;
/// No description provided for @accountInitialBalanceLabel.
///
/// In ru, this message translates to:
/// **'Начальный баланс'**
String get accountInitialBalanceLabel;
/// No description provided for @accountIconLabel.
///
/// In ru, this message translates to:
/// **'Иконка'**
String get accountIconLabel;
/// No description provided for @accountColorLabel.
///
/// In ru, this message translates to:
/// **'Цвет'**
String get accountColorLabel;
/// No description provided for @accountIsDefaultLabel.
///
/// In ru, this message translates to:
/// **'Счёт по умолчанию'**
String get accountIsDefaultLabel;
/// No description provided for @accountIsDefaultHint.
///
/// In ru, this message translates to:
/// **'Подставляется при создании транзакций'**
String get accountIsDefaultHint;
/// No description provided for @accountSaveButton.
///
/// In ru, this message translates to:
/// **'Создать счёт'**
String get accountSaveButton;
/// No description provided for @accountSaveEditButton.
///
/// In ru, this message translates to:
/// **'Сохранить'**
String get accountSaveEditButton;
/// No description provided for @accountArchiveButton.
///
/// In ru, this message translates to:
/// **'Архивировать'**
String get accountArchiveButton;
/// No description provided for @accountArchiveConfirmTitle.
///
/// In ru, this message translates to:
/// **'Архивировать счёт?'**
String get accountArchiveConfirmTitle;
/// No description provided for @accountArchiveConfirmMessage.
///
/// In ru, this message translates to:
/// **'Архивные счета исчезают из списков и пикеров. Существующие транзакции сохранят ссылку.'**
String get accountArchiveConfirmMessage;
/// No description provided for @accountValidationNameRequired.
///
/// In ru, this message translates to:
/// **'Введите название'**
String get accountValidationNameRequired;
/// No description provided for @accountValidationNameTooLong.
///
/// In ru, this message translates to:
/// **'Слишком длинное название (макс. 50 символов)'**
String get accountValidationNameTooLong;
/// No description provided for @accountSaveError.
///
/// In ru, this message translates to:
/// **'Не удалось сохранить счёт. Попробуйте ещё раз.'**
String get accountSaveError;
/// No description provided for @accountArchiveError.
///
/// In ru, this message translates to:
/// **'Не удалось архивировать счёт. Попробуйте ещё раз.'**
String get accountArchiveError;
/// No description provided for @accountSetDefaultError.
///
/// In ru, this message translates to:
/// **'Счёт сохранён, но не удалось сделать его умолчательным.'**
String get accountSetDefaultError;
}
class _AppLocalizationsDelegate
+100
View File
@@ -114,6 +114,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get darkTheme => 'Dark theme';
@override
String get profileLanguageTile => 'Language';
@override
String get langEnglish => 'English';
@override
String get langRussian => 'Russian';
@override
String get profileHint =>
'User profile, currency, locale and other settings will appear here.';
@@ -319,6 +328,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get colorPickerTitle => 'Pick a color';
@override
String get currencyPickerTitle => 'Pick a currency';
@override
String get currencyPickerSearchHint => 'Search…';
@override
String get colorPickerAllUsed =>
'All palette colors are already in use by other categories.';
@@ -355,4 +370,89 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get profileCategoriesTile => 'Categories';
@override
String get profileAccountsTile => 'Accounts';
@override
String get accountsEmpty => 'No accounts yet. Tap + to add one.';
@override
String get accountNewTitle => 'New account';
@override
String get accountEditTitle => 'Edit account';
@override
String get accountNameLabel => 'Name';
@override
String get accountNameHint => 'e.g. Cash wallet';
@override
String get accountTypeLabel => 'Type';
@override
String get accountTypeCash => 'Cash';
@override
String get accountTypeCard => 'Card';
@override
String get accountTypeBank => 'Bank';
@override
String get accountTypeSavings => 'Savings';
@override
String get accountCurrencyLabel => 'Currency';
@override
String get accountInitialBalanceLabel => 'Initial balance';
@override
String get accountIconLabel => 'Icon';
@override
String get accountColorLabel => 'Color';
@override
String get accountIsDefaultLabel => 'Default account';
@override
String get accountIsDefaultHint => 'Used when creating new transactions';
@override
String get accountSaveButton => 'Create account';
@override
String get accountSaveEditButton => 'Save changes';
@override
String get accountArchiveButton => 'Archive';
@override
String get accountArchiveConfirmTitle => 'Archive account?';
@override
String get accountArchiveConfirmMessage =>
'Archived accounts disappear from pickers and lists. Existing transactions keep their reference.';
@override
String get accountValidationNameRequired => 'Enter a name';
@override
String get accountValidationNameTooLong => 'Name is too long (max 50 chars)';
@override
String get accountSaveError =>
'Failed to save the account. Please try again.';
@override
String get accountArchiveError =>
'Failed to archive the account. Please try again.';
@override
String get accountSetDefaultError =>
'Account saved, but couldn\'t mark it as default.';
}
+101
View File
@@ -120,6 +120,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get darkTheme => 'Тёмная тема';
@override
String get profileLanguageTile => 'Язык';
@override
String get langEnglish => 'English';
@override
String get langRussian => 'Русский';
@override
String get profileHint =>
'Здесь появится профиль пользователя, валюта, локаль и другие настройки.';
@@ -330,6 +339,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get colorPickerTitle => 'Выберите цвет';
@override
String get currencyPickerTitle => 'Выберите валюту';
@override
String get currencyPickerSearchHint => 'Поиск…';
@override
String get colorPickerAllUsed =>
'Все цвета палитры уже заняты другими категориями.';
@@ -366,4 +381,90 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get profileCategoriesTile => 'Категории';
@override
String get profileAccountsTile => 'Счета';
@override
String get accountsEmpty => 'Счетов нет. Нажмите +, чтобы добавить.';
@override
String get accountNewTitle => 'Новый счёт';
@override
String get accountEditTitle => 'Редактировать счёт';
@override
String get accountNameLabel => 'Название';
@override
String get accountNameHint => 'напр. Кошелёк';
@override
String get accountTypeLabel => 'Тип';
@override
String get accountTypeCash => 'Наличные';
@override
String get accountTypeCard => 'Карта';
@override
String get accountTypeBank => 'Банк';
@override
String get accountTypeSavings => 'Накопления';
@override
String get accountCurrencyLabel => 'Валюта';
@override
String get accountInitialBalanceLabel => 'Начальный баланс';
@override
String get accountIconLabel => 'Иконка';
@override
String get accountColorLabel => 'Цвет';
@override
String get accountIsDefaultLabel => 'Счёт по умолчанию';
@override
String get accountIsDefaultHint => 'Подставляется при создании транзакций';
@override
String get accountSaveButton => 'Создать счёт';
@override
String get accountSaveEditButton => 'Сохранить';
@override
String get accountArchiveButton => 'Архивировать';
@override
String get accountArchiveConfirmTitle => 'Архивировать счёт?';
@override
String get accountArchiveConfirmMessage =>
'Архивные счета исчезают из списков и пикеров. Существующие транзакции сохранят ссылку.';
@override
String get accountValidationNameRequired => 'Введите название';
@override
String get accountValidationNameTooLong =>
'Слишком длинное название (макс. 50 символов)';
@override
String get accountSaveError =>
'Не удалось сохранить счёт. Попробуйте ещё раз.';
@override
String get accountArchiveError =>
'Не удалось архивировать счёт. Попробуйте ещё раз.';
@override
String get accountSetDefaultError =>
'Счёт сохранён, но не удалось сделать его умолчательным.';
}
+34 -1
View File
@@ -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,33 @@
"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": "Не удалось архивировать счёт. Попробуйте ещё раз.",
"accountSetDefaultError": "Счёт сохранён, но не удалось сделать его умолчательным."
}
+3
View File
@@ -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,
+15
View File
@@ -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,
+4
View File
@@ -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';
}
+5 -1
View File
@@ -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);
}
},
);
+36 -5
View File
@@ -29,12 +29,43 @@ 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)));
/// Архивирует счёт и одновременно снимает с него флаг "по умолчанию",
/// чтобы он не попадал в [watchDefaultAccount] и не подставлялся в новые
/// транзакции.
Future<void> archiveAccount(String id) =>
(update(accountsTable)..where((t) => t.id.equals(id))).write(
const AccountsTableCompanion(
archived: Value(true),
isDefault: Value(false),
),
);
/// Реактивный умолчательный счёт пользователя (или null).
/// Архивные счета исключены — даже если флаг isDefault остался от прошлого.
Stream<AccountsTableData?> watchDefaultAccount(String userId) =>
(select(accountsTable)
..where((t) =>
t.userId.equals(userId) &
t.isDefault.equals(true) &
t.archived.equals(false))
..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)));
}
});
/// Реактивный текущий баланс счёта.
///
@@ -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
@@ -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,29 +45,38 @@ 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(
() => ref.read(accountRepositoryProvider).archive(id),
).then((_) => const AsyncData(null));
try {
await ref.read(accountRepositoryProvider).archive(id);
state = const AsyncData(null);
} catch (e, st) {
state = AsyncError(e, st);
rethrow;
}
}
}
@@ -12,6 +12,7 @@ extension AccountMapper on AccountsTableData {
iconCode: iconCode,
colorValue: colorValue,
archived: archived,
isDefault: isDefault,
createdAt: createdAt,
);
}
@@ -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);
}
@@ -15,6 +15,7 @@ abstract class Account with _$Account {
int? iconCode,
int? colorValue,
required bool archived,
@Default(false) bool isDefault,
required DateTime createdAt,
}) = _Account;
}
@@ -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);
}
@@ -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),
],
),
),
),
),
);
}
}
@@ -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;
@@ -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(
@@ -1,8 +1,8 @@
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/locale/app_locale_controller.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/theme_mode_controller.dart';
@@ -17,6 +17,7 @@ class ProfileScreen extends ConsumerWidget {
final l10n = context.l10n;
final mode = ref.watch(themeModeControllerProvider);
final controller = ref.read(themeModeControllerProvider.notifier);
final locale = ref.watch(appLocaleControllerProvider);
return PlaceholderScreen(
subtitle: l10n.profileSubtitle,
@@ -42,6 +43,19 @@ class ProfileScreen extends ConsumerWidget {
),
),
Container(height: 1, color: p.line),
_NavRow(
icon: Icons.language_outlined,
title: l10n.profileLanguageTile,
subtitle: _localeName(locale?.languageCode, l10n),
onTap: () => _showLanguagePicker(context, ref, locale, l10n),
),
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,
@@ -95,10 +109,12 @@ class _NavRow extends StatelessWidget {
required this.icon,
required this.title,
required this.onTap,
this.subtitle,
});
final IconData icon;
final String title;
final String? subtitle;
final VoidCallback onTap;
@override
@@ -118,6 +134,10 @@ class _NavRow extends StatelessWidget {
style: TextStyle(fontSize: 14, color: p.ink),
),
),
if (subtitle != null) ...[
Text(subtitle!, style: TextStyle(fontSize: 13, color: p.ink2)),
const SizedBox(width: 4),
],
Icon(Icons.chevron_right, size: 18, color: p.ink2),
],
),
@@ -125,3 +145,70 @@ class _NavRow extends StatelessWidget {
);
}
}
String _localeName(String? code, AppLocalizations l10n) => switch (code) {
'ru' => l10n.langRussian,
_ => l10n.langEnglish,
};
void _showLanguagePicker(
BuildContext context,
WidgetRef ref,
Locale? current,
AppLocalizations l10n,
) {
final p = context.palette;
final currentCode = current?.languageCode ?? 'en';
showModalBottomSheet<void>(
context: context,
backgroundColor: p.paper,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) {
final langs = [
('en', l10n.langEnglish),
('ru', l10n.langRussian),
];
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(width: 40, height: 4,
decoration: BoxDecoration(
color: p.line,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
...langs.map((entry) {
final (code, name) = entry;
final selected = code == currentCode;
return ListTile(
title: Text(name,
style: TextStyle(
fontSize: 15,
color: selected ? p.accent : p.ink,
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
),
),
trailing: selected
? Icon(Icons.check, color: p.accent)
: null,
onTap: () {
ref
.read(appLocaleControllerProvider.notifier)
.setLocale(code);
Navigator.of(context).pop();
},
);
}),
const SizedBox(height: 8),
],
),
);
},
);
}
@@ -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};
@@ -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);
}
},
);
}