From b2c8189cb28f99da6c4cf3c8352d2c543bdd2b4a Mon Sep 17 00:00:00 2001 From: Sanders Date: Fri, 27 Jun 2025 16:42:12 +0300 Subject: [PATCH 01/35] Add bloc --- lib/injection_container.dart | 16 +++++ lib/logic/auth/.placeholder | 0 lib/logic/auth/auth_bloc.dart | 35 ++++++++++ lib/logic/auth/auth_event.dart | 24 +++++++ lib/logic/auth/auth_state.dart | 24 +++++++ lib/logic/transaction/.placeholder | 0 lib/logic/transaction/transaction_bloc.dart | 67 ++++++++++++++++++++ lib/logic/transaction/transaction_event.dart | 44 +++++++++++++ lib/logic/transaction/transaction_state.dart | 30 +++++++++ lib/main.dart | 40 +++++++++--- pubspec.lock | 40 ++++++++++++ pubspec.yaml | 3 + 12 files changed, 314 insertions(+), 9 deletions(-) create mode 100644 lib/logic/auth/.placeholder create mode 100644 lib/logic/auth/auth_bloc.dart create mode 100644 lib/logic/auth/auth_event.dart create mode 100644 lib/logic/auth/auth_state.dart create mode 100644 lib/logic/transaction/.placeholder create mode 100644 lib/logic/transaction/transaction_bloc.dart create mode 100644 lib/logic/transaction/transaction_event.dart create mode 100644 lib/logic/transaction/transaction_state.dart diff --git a/lib/injection_container.dart b/lib/injection_container.dart index c85ac5a..1b0fcd1 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -6,7 +6,12 @@ import 'data/repositories/hive_transaction_repository.dart'; import 'data/repositories/interfaces/icategory_repository.dart'; import 'data/repositories/interfaces/itag_repository.dart'; import 'data/repositories/interfaces/itransaction_repository.dart'; +import 'data/repositories/hive_user_repository.dart'; +import 'data/repositories/interfaces/iuser_repository.dart'; +import 'logic/auth/auth_bloc.dart'; +import 'logic/transaction/transaction_bloc.dart'; import 'services/settings_service.dart'; +import 'services/user_service.dart'; final getIt = GetIt.instance; @@ -29,4 +34,15 @@ Future initDependencies() async { getIt.registerSingleton( HiveTransactionRepository(HiveService.transactions), ); + + getIt.registerSingleton( + HiveUserRepository(HiveService.users), + ); + + // Services + getIt.registerSingleton(UserService(getIt())); + + // Blocs + getIt.registerFactory(() => AuthBloc(userService: getIt())); + getIt.registerFactory(() => TransactionBloc(transactionRepository: getIt())); } diff --git a/lib/logic/auth/.placeholder b/lib/logic/auth/.placeholder new file mode 100644 index 0000000..e69de29 diff --git a/lib/logic/auth/auth_bloc.dart b/lib/logic/auth/auth_bloc.dart new file mode 100644 index 0000000..bc5ecd1 --- /dev/null +++ b/lib/logic/auth/auth_bloc.dart @@ -0,0 +1,35 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:budget_app/models/user.dart'; +import 'package:budget_app/services/user_service.dart'; + +part 'auth_event.dart'; +part 'auth_state.dart'; + +class AuthBloc extends Bloc { + final UserService _userService; + + AuthBloc({required UserService userService}) : _userService = userService, super(AuthInitial()) { + on(_onAuthStarted); + on(_onAuthLoggedIn); + on(_onAuthLoggedOut); + } + + void _onAuthStarted(AuthStarted event, Emitter emit) { + final user = _userService.currentUser; + if (user != null) { + emit(AuthAuthenticated(user: user)); + } else { + emit(AuthUnauthenticated()); + } + } + + void _onAuthLoggedIn(AuthLoggedIn event, Emitter emit) { + emit(AuthAuthenticated(user: event.user)); + } + + void _onAuthLoggedOut(AuthLoggedOut event, Emitter emit) { + _userService.logout(); + emit(AuthUnauthenticated()); + } +} diff --git a/lib/logic/auth/auth_event.dart b/lib/logic/auth/auth_event.dart new file mode 100644 index 0000000..2f75eba --- /dev/null +++ b/lib/logic/auth/auth_event.dart @@ -0,0 +1,24 @@ +part of 'auth_bloc.dart'; + +abstract class AuthEvent extends Equatable { + const AuthEvent(); + + @override + List get props => []; +} + +// Событие, которое будет вызываться при инициализации BLoC +class AuthStarted extends AuthEvent {} + +// Событие, которое будет вызываться при входе пользователя +class AuthLoggedIn extends AuthEvent { + final User user; + + const AuthLoggedIn({required this.user}); + + @override + List get props => [user]; +} + +// Событие, которое будет вызываться при выходе пользователя +class AuthLoggedOut extends AuthEvent {} diff --git a/lib/logic/auth/auth_state.dart b/lib/logic/auth/auth_state.dart new file mode 100644 index 0000000..dca2f08 --- /dev/null +++ b/lib/logic/auth/auth_state.dart @@ -0,0 +1,24 @@ +part of 'auth_bloc.dart'; + +abstract class AuthState extends Equatable { + const AuthState(); + + @override + List get props => []; +} + +// Начальное состояние, пока мы не знаем, аутентифицирован ли пользователь +class AuthInitial extends AuthState {} + +// Состояние, когда пользователь аутентифицирован +class AuthAuthenticated extends AuthState { + final User user; + + const AuthAuthenticated({required this.user}); + + @override + List get props => [user]; +} + +// Состояние, когда пользователь не аутентифицирован +class AuthUnauthenticated extends AuthState {} diff --git a/lib/logic/transaction/.placeholder b/lib/logic/transaction/.placeholder new file mode 100644 index 0000000..e69de29 diff --git a/lib/logic/transaction/transaction_bloc.dart b/lib/logic/transaction/transaction_bloc.dart new file mode 100644 index 0000000..46701e9 --- /dev/null +++ b/lib/logic/transaction/transaction_bloc.dart @@ -0,0 +1,67 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:budget_app/data/repositories/interfaces/itransaction_repository.dart'; +import 'package:budget_app/models/transaction_record.dart'; + +part 'transaction_event.dart'; +part 'transaction_state.dart'; + +class TransactionBloc extends Bloc { + final ITransactionRepository _transactionRepository; + + TransactionBloc({required ITransactionRepository transactionRepository}) + : _transactionRepository = transactionRepository, + super(TransactionInitial()) { + on(_onLoadTransactions); + on(_onAddTransaction); + on(_onUpdateTransaction); + on(_onDeleteTransaction); + } + + void _onLoadTransactions(LoadTransactions event, Emitter emit) async { + emit(TransactionLoading()); + try { + final transactions = await _transactionRepository.getAllByUser(event.userId); + emit(TransactionLoaded(transactions: transactions)); + } catch (e) { + emit(TransactionError(message: e.toString())); + } + } + + void _onAddTransaction(AddTransaction event, Emitter emit) async { + try { + await _transactionRepository.add(event.transaction); + final transactions = await _transactionRepository.getAllByUser(event.transaction.userId); + emit(TransactionLoaded(transactions: transactions)); + } catch (e) { + emit(TransactionError(message: e.toString())); + } + } + + void _onUpdateTransaction(UpdateTransaction event, Emitter emit) async { + try { + await _transactionRepository.update(event.transaction); + final transactions = await _transactionRepository.getAllByUser(event.transaction.userId); + emit(TransactionLoaded(transactions: transactions)); + } catch (e) { + emit(TransactionError(message: e.toString())); + } + } + + void _onDeleteTransaction(DeleteTransaction event, Emitter emit) async { + try { + // Для удаления нам нужен userId, который мы можем получить из текущего состояния + if (state is TransactionLoaded) { + final loadedState = state as TransactionLoaded; + if (loadedState.transactions.isNotEmpty) { + final userId = loadedState.transactions.first.userId; + await _transactionRepository.delete(event.transactionId); + final transactions = await _transactionRepository.getAllByUser(userId); + emit(TransactionLoaded(transactions: transactions)); + } + } + } catch (e) { + emit(TransactionError(message: e.toString())); + } + } +} diff --git a/lib/logic/transaction/transaction_event.dart b/lib/logic/transaction/transaction_event.dart new file mode 100644 index 0000000..e60917d --- /dev/null +++ b/lib/logic/transaction/transaction_event.dart @@ -0,0 +1,44 @@ +part of 'transaction_bloc.dart'; + +abstract class TransactionEvent extends Equatable { + const TransactionEvent(); + + @override + List get props => []; +} + +class LoadTransactions extends TransactionEvent { + final String userId; + + const LoadTransactions({required this.userId}); + + @override + List get props => [userId]; +} + +class AddTransaction extends TransactionEvent { + final TransactionRecord transaction; + + const AddTransaction({required this.transaction}); + + @override + List get props => [transaction]; +} + +class UpdateTransaction extends TransactionEvent { + final TransactionRecord transaction; + + const UpdateTransaction({required this.transaction}); + + @override + List get props => [transaction]; +} + +class DeleteTransaction extends TransactionEvent { + final String transactionId; + + const DeleteTransaction({required this.transactionId}); + + @override + List get props => [transactionId]; +} diff --git a/lib/logic/transaction/transaction_state.dart b/lib/logic/transaction/transaction_state.dart new file mode 100644 index 0000000..5a5e8c2 --- /dev/null +++ b/lib/logic/transaction/transaction_state.dart @@ -0,0 +1,30 @@ +part of 'transaction_bloc.dart'; + +abstract class TransactionState extends Equatable { + const TransactionState(); + + @override + List get props => []; +} + +class TransactionInitial extends TransactionState {} + +class TransactionLoading extends TransactionState {} + +class TransactionLoaded extends TransactionState { + final List transactions; + + const TransactionLoaded({this.transactions = const []}); + + @override + List get props => [transactions]; +} + +class TransactionError extends TransactionState { + final String message; + + const TransactionError({required this.message}); + + @override + List get props => [message]; +} diff --git a/lib/main.dart b/lib/main.dart index 4e0ab91..6b5f233 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'logic/auth/auth_bloc.dart'; import 'package:budget_app/pages/home_page.dart'; import 'theme/app_theme.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; @@ -12,19 +14,26 @@ void main() async { runApp(const MyApp()); } -/// Главный виджет приложения -/// -/// Управляет: -/// - Состоянием темы (темная/светлая) -/// - Конфигурацией MaterialApp -class MyApp extends StatefulWidget { +class MyApp extends StatelessWidget { const MyApp({super.key}); @override - State createState() => _MyAppState(); + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => GetIt.instance()..add(AuthStarted()), + child: const AppView(), + ); + } } -class _MyAppState extends State { +class AppView extends StatefulWidget { + const AppView({super.key}); + + @override + State createState() => _AppViewState(); +} + +class _AppViewState extends State { late final SettingsService _settingsService; @override @@ -51,7 +60,20 @@ class _MyAppState extends State { theme: AppTheme.lightTheme(), darkTheme: AppTheme.darkTheme(), themeMode: _settingsService.isDarkMode ? ThemeMode.dark : ThemeMode.light, - home: const HomePage(), + home: BlocBuilder( + builder: (context, state) { + if (state is AuthAuthenticated) { + return const HomePage(); + } else { + // Здесь будет страница входа, пока просто заглушка + return const Scaffold( + body: Center( + child: Text('Требуется аутентификация'), + ), + ); + } + }, + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index e07ff53..33da270 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + bloc: + dependency: transitive + description: + name: bloc + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" + url: "https://pub.dev" + source: hosted + version: "9.0.0" boolean_selector: dependency: transitive description: @@ -177,6 +185,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + equatable: + dependency: "direct main" + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -214,6 +230,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + name: flutter_bloc + sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 + url: "https://pub.dev" + source: hosted + version: "9.1.1" flutter_lints: dependency: "direct dev" description: @@ -419,6 +443,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" package_config: dependency: transitive description: @@ -507,6 +539,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + provider: + dependency: transitive + description: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 95eac71..8d8e2bd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,9 @@ dependencies: path_provider: ^2.0.15 uuid: ^3.0.7 logger: ^2.5.0 + flutter_bloc: ^9.1.1 + equatable: ^2.0.7 + dev_dependencies: hive_ce_generator: ^1.9.2 From 5856b59b54c643a4cb931edcde0eb4ace7f33870 Mon Sep 17 00:00:00 2001 From: Sanders Date: Fri, 27 Jun 2025 18:42:02 +0300 Subject: [PATCH 02/35] Language --- l10n.yaml | 3 + .../hive_category_repository.dart | 8 + .../repositories/hive_tag_repository.dart | 8 + .../hive_transaction_repository.dart | 8 + .../interfaces/icategory_repository.dart | 3 + .../interfaces/itag_repository.dart | 3 + .../interfaces/itransaction_repository.dart | 3 + lib/injection_container.dart | 2 +- lib/l10n/app_en.arb | 24 ++ lib/l10n/app_localizations.dart | 260 ++++++++++++++++++ lib/l10n/app_localizations_en.dart | 75 +++++ lib/l10n/app_localizations_ru.dart | 75 +++++ lib/l10n/app_ru.arb | 24 ++ lib/main.dart | 72 ++--- lib/models/user.dart | 6 + lib/models/user.g.dart | 7 +- lib/pages/home_page.dart | 211 +++++++++++--- lib/pages/login/.placeholder | 0 lib/pages/login/login_page.dart | 77 ++++++ lib/pages/reports_page.dart | 20 ++ lib/pages/settings_page.dart | 29 +- lib/services/settings_service.dart | 12 + lib/services/user_service.dart | 21 +- pubspec.lock | 47 ++-- pubspec.yaml | 5 + 25 files changed, 903 insertions(+), 100 deletions(-) create mode 100644 l10n.yaml create mode 100644 lib/l10n/app_en.arb create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_en.dart create mode 100644 lib/l10n/app_localizations_ru.dart create mode 100644 lib/l10n/app_ru.arb create mode 100644 lib/pages/login/.placeholder create mode 100644 lib/pages/login/login_page.dart create mode 100644 lib/pages/reports_page.dart diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000..15338f2 --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_en.arb +output-localization-file: app_localizations.dart diff --git a/lib/data/repositories/hive_category_repository.dart b/lib/data/repositories/hive_category_repository.dart index dc4d876..e9277e4 100644 --- a/lib/data/repositories/hive_category_repository.dart +++ b/lib/data/repositories/hive_category_repository.dart @@ -65,4 +65,12 @@ class HiveCategoryRepository implements ICategoryRepository { .where((c) => c.userId == userId && !c.isIncome) .toList(); } + + @override + Future addAll(List categories) async { + final Map categoryMap = { + for (var cat in categories) cat.id: cat + }; + await _box.putAll(categoryMap); + } } diff --git a/lib/data/repositories/hive_tag_repository.dart b/lib/data/repositories/hive_tag_repository.dart index 7dd5ffc..7b4ff47 100644 --- a/lib/data/repositories/hive_tag_repository.dart +++ b/lib/data/repositories/hive_tag_repository.dart @@ -40,4 +40,12 @@ class HiveTagRepository implements ITagRepository { // у которых поле `userId` совпадает с идентификатором, переданным в метод. return _box.values.where((tag) => tag.userId == userId).toList(); } + + @override + Future addAll(List tags) async { + final Map tagMap = { + for (var tag in tags) tag.id: tag + }; + await _box.putAll(tagMap); + } } diff --git a/lib/data/repositories/hive_transaction_repository.dart b/lib/data/repositories/hive_transaction_repository.dart index 593883c..36b0457 100644 --- a/lib/data/repositories/hive_transaction_repository.dart +++ b/lib/data/repositories/hive_transaction_repository.dart @@ -93,4 +93,12 @@ class HiveTransactionRepository implements ITransactionRepository { .where((t) => t.userId == userId && t.tag?.id == tagId) .toList(); } + + @override + Future addAll(List transactions) async { + final Map transactionMap = { + for (var tr in transactions) tr.id: tr + }; + await _box.putAll(transactionMap); + } } diff --git a/lib/data/repositories/interfaces/icategory_repository.dart b/lib/data/repositories/interfaces/icategory_repository.dart index ec41521..a2eb91c 100644 --- a/lib/data/repositories/interfaces/icategory_repository.dart +++ b/lib/data/repositories/interfaces/icategory_repository.dart @@ -19,4 +19,7 @@ abstract class ICategoryRepository { /// Получить категории расходов конкретного пользователя Future> getExpenseCategoriesByUser(String userId); + + /// Добавить список категорий + Future addAll(List categories); } diff --git a/lib/data/repositories/interfaces/itag_repository.dart b/lib/data/repositories/interfaces/itag_repository.dart index cf5c73d..266e9a3 100644 --- a/lib/data/repositories/interfaces/itag_repository.dart +++ b/lib/data/repositories/interfaces/itag_repository.dart @@ -12,4 +12,7 @@ abstract class ITagRepository { // реализацию этого метода. Это гарантирует, что наш репозиторий // сможет получать теги для конкретного пользователя. Future> getAllByUser(String userId); + + /// Добавить список тегов + Future addAll(List tags); } diff --git a/lib/data/repositories/interfaces/itransaction_repository.dart b/lib/data/repositories/interfaces/itransaction_repository.dart index 1e36afd..876652d 100644 --- a/lib/data/repositories/interfaces/itransaction_repository.dart +++ b/lib/data/repositories/interfaces/itransaction_repository.dart @@ -17,4 +17,7 @@ abstract class ITransactionRepository { Future> getByUserAndDateRange(String userId, DateTime from, DateTime to); Future> getByUserAndCategory(String userId, String categoryId); Future> getByUserAndTag(String userId, String tagId); + + /// Добавить список транзакций + Future addAll(List transactions); } diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 1b0fcd1..e297650 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -40,7 +40,7 @@ Future initDependencies() async { ); // Services - getIt.registerSingleton(UserService(getIt())); + getIt.registerSingleton(UserService(getIt(), getIt(), getIt(), getIt())); // Blocs getIt.registerFactory(() => AuthBloc(userService: getIt())); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..c671fd5 --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,24 @@ +{ + "@@locale": "en", + "appTitle": "Budget App", + "homePageTitle": "Home", + "reportsPageTitle": "Reports", + "settingsPageTitle": "Settings", + "addTransactionButton": "Add new transaction", + "noTransactionsText": "No transactions yet", + "loadingTransactionsText": "Loading transactions...", + "transactionErrorText": "Error: {message}", + "loginPageTitle": "Login", + "nameFieldLabel": "Name", + "emailFieldLabel": "Email", + "nameFieldEmptyError": "Please enter your name", + "emailFieldEmptyError": "Please enter your email", + "loginButtonText": "Login / Register", + "darkModeSetting": "Dark Theme", + "darkModeDescription": "Toggle between light and dark theme", + "languageSetting": "Language", + "languageDescription": "Change application language", + "russianLanguage": "Russian", + "englishLanguage": "English", + "defaultUser": "Default User" +} \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..60e25fb --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,260 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_ru.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('ru'), + ]; + + /// No description provided for @appTitle. + /// + /// In en, this message translates to: + /// **'Budget App'** + String get appTitle; + + /// No description provided for @homePageTitle. + /// + /// In en, this message translates to: + /// **'Home'** + String get homePageTitle; + + /// No description provided for @reportsPageTitle. + /// + /// In en, this message translates to: + /// **'Reports'** + String get reportsPageTitle; + + /// No description provided for @settingsPageTitle. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settingsPageTitle; + + /// No description provided for @addTransactionButton. + /// + /// In en, this message translates to: + /// **'Add new transaction'** + String get addTransactionButton; + + /// No description provided for @noTransactionsText. + /// + /// In en, this message translates to: + /// **'No transactions yet'** + String get noTransactionsText; + + /// No description provided for @loadingTransactionsText. + /// + /// In en, this message translates to: + /// **'Loading transactions...'** + String get loadingTransactionsText; + + /// No description provided for @transactionErrorText. + /// + /// In en, this message translates to: + /// **'Error: {message}'** + String transactionErrorText(Object message); + + /// No description provided for @loginPageTitle. + /// + /// In en, this message translates to: + /// **'Login'** + String get loginPageTitle; + + /// No description provided for @nameFieldLabel. + /// + /// In en, this message translates to: + /// **'Name'** + String get nameFieldLabel; + + /// No description provided for @emailFieldLabel. + /// + /// In en, this message translates to: + /// **'Email'** + String get emailFieldLabel; + + /// No description provided for @nameFieldEmptyError. + /// + /// In en, this message translates to: + /// **'Please enter your name'** + String get nameFieldEmptyError; + + /// No description provided for @emailFieldEmptyError. + /// + /// In en, this message translates to: + /// **'Please enter your email'** + String get emailFieldEmptyError; + + /// No description provided for @loginButtonText. + /// + /// In en, this message translates to: + /// **'Login / Register'** + String get loginButtonText; + + /// No description provided for @darkModeSetting. + /// + /// In en, this message translates to: + /// **'Dark Theme'** + String get darkModeSetting; + + /// No description provided for @darkModeDescription. + /// + /// In en, this message translates to: + /// **'Toggle between light and dark theme'** + String get darkModeDescription; + + /// No description provided for @languageSetting. + /// + /// In en, this message translates to: + /// **'Language'** + String get languageSetting; + + /// No description provided for @languageDescription. + /// + /// In en, this message translates to: + /// **'Change application language'** + String get languageDescription; + + /// No description provided for @russianLanguage. + /// + /// In en, this message translates to: + /// **'Russian'** + String get russianLanguage; + + /// No description provided for @englishLanguage. + /// + /// In en, this message translates to: + /// **'English'** + String get englishLanguage; + + /// No description provided for @defaultUser. + /// + /// In en, this message translates to: + /// **'Default User'** + String get defaultUser; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'ru'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'ru': + return AppLocalizationsRu(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..9301836 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,75 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'Budget App'; + + @override + String get homePageTitle => 'Home'; + + @override + String get reportsPageTitle => 'Reports'; + + @override + String get settingsPageTitle => 'Settings'; + + @override + String get addTransactionButton => 'Add new transaction'; + + @override + String get noTransactionsText => 'No transactions yet'; + + @override + String get loadingTransactionsText => 'Loading transactions...'; + + @override + String transactionErrorText(Object message) { + return 'Error: $message'; + } + + @override + String get loginPageTitle => 'Login'; + + @override + String get nameFieldLabel => 'Name'; + + @override + String get emailFieldLabel => 'Email'; + + @override + String get nameFieldEmptyError => 'Please enter your name'; + + @override + String get emailFieldEmptyError => 'Please enter your email'; + + @override + String get loginButtonText => 'Login / Register'; + + @override + String get darkModeSetting => 'Dark Theme'; + + @override + String get darkModeDescription => 'Toggle between light and dark theme'; + + @override + String get languageSetting => 'Language'; + + @override + String get languageDescription => 'Change application language'; + + @override + String get russianLanguage => 'Russian'; + + @override + String get englishLanguage => 'English'; + + @override + String get defaultUser => 'Default User'; +} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart new file mode 100644 index 0000000..e99b099 --- /dev/null +++ b/lib/l10n/app_localizations_ru.dart @@ -0,0 +1,75 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get appTitle => 'Бюджетное приложение'; + + @override + String get homePageTitle => 'Главная'; + + @override + String get reportsPageTitle => 'Отчеты'; + + @override + String get settingsPageTitle => 'Настройки'; + + @override + String get addTransactionButton => 'Добавить новую транзакцию'; + + @override + String get noTransactionsText => 'Нет транзакций'; + + @override + String get loadingTransactionsText => 'Загрузка транзакций...'; + + @override + String transactionErrorText(Object message) { + return 'Ошибка: $message'; + } + + @override + String get loginPageTitle => 'Вход'; + + @override + String get nameFieldLabel => 'Имя'; + + @override + String get emailFieldLabel => 'Email'; + + @override + String get nameFieldEmptyError => 'Пожалуйста, введите имя'; + + @override + String get emailFieldEmptyError => 'Пожалуйста, введите email'; + + @override + String get loginButtonText => 'Войти / Зарегистрироваться'; + + @override + String get darkModeSetting => 'Темная тема'; + + @override + String get darkModeDescription => 'Переключить между светлой и темной темой'; + + @override + String get languageSetting => 'Язык'; + + @override + String get languageDescription => 'Изменить язык приложения'; + + @override + String get russianLanguage => 'Русский'; + + @override + String get englishLanguage => 'Английский'; + + @override + String get defaultUser => 'Пользователь по умолчанию'; +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 0000000..4e1e496 --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,24 @@ +{ + "@@locale": "ru", + "appTitle": "Бюджетное приложение", + "homePageTitle": "Главная", + "reportsPageTitle": "Отчеты", + "settingsPageTitle": "Настройки", + "addTransactionButton": "Добавить новую транзакцию", + "noTransactionsText": "Нет транзакций", + "loadingTransactionsText": "Загрузка транзакций...", + "transactionErrorText": "Ошибка: {message}", + "loginPageTitle": "Вход", + "nameFieldLabel": "Имя", + "emailFieldLabel": "Email", + "nameFieldEmptyError": "Пожалуйста, введите имя", + "emailFieldEmptyError": "Пожалуйста, введите email", + "loginButtonText": "Войти / Зарегистрироваться", + "darkModeSetting": "Темная тема", + "darkModeDescription": "Переключить между светлой и темной темой", + "languageSetting": "Язык", + "languageDescription": "Изменить язык приложения", + "russianLanguage": "Русский", + "englishLanguage": "Английский", + "defaultUser": "Пользователь по умолчанию" +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 6b5f233..9b3f1ee 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; // Добавляем импортсса +import 'l10n/app_localizations.dart'; import 'logic/auth/auth_bloc.dart'; +import 'pages/login/login_page.dart'; import 'package:budget_app/pages/home_page.dart'; import 'theme/app_theme.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; @@ -14,66 +18,62 @@ void main() async { runApp(const MyApp()); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => GetIt.instance()..add(AuthStarted()), - child: const AppView(), - ); - } + State createState() => _MyAppState(); } -class AppView extends StatefulWidget { - const AppView({super.key}); - - @override - State createState() => _AppViewState(); -} - -class _AppViewState extends State { +class _MyAppState extends State { late final SettingsService _settingsService; @override void initState() { super.initState(); _settingsService = GetIt.instance(); - _settingsService.addListener(_onThemeChanged); + _settingsService.addListener(_onSettingsChanged); } @override void dispose() { - _settingsService.removeListener(_onThemeChanged); + _settingsService.removeListener(_onSettingsChanged); super.dispose(); } - void _onThemeChanged() { + void _onSettingsChanged() { setState(() {}); } @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Budget App', - theme: AppTheme.lightTheme(), - darkTheme: AppTheme.darkTheme(), - themeMode: _settingsService.isDarkMode ? ThemeMode.dark : ThemeMode.light, - home: BlocBuilder( - builder: (context, state) { - if (state is AuthAuthenticated) { - return const HomePage(); - } else { - // Здесь будет страница входа, пока просто заглушка - return const Scaffold( - body: Center( - child: Text('Требуется аутентификация'), - ), - ); - } - }, + return BlocProvider( + create: (context) => GetIt.instance()..add(AuthStarted()), + child: MaterialApp( + title: AppLocalizations.of(context)?.appTitle ?? 'Budget App', // Используем локализованный заголовок + theme: AppTheme.lightTheme(), + darkTheme: AppTheme.darkTheme(), + themeMode: _settingsService.isDarkMode ? ThemeMode.dark : ThemeMode.light, + // Добавляем локализацию + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: Locale(_settingsService.languageCode), // Устанавливаем текущий язык из настроек + home: BlocBuilder( + builder: (context, state) { + if (state is AuthAuthenticated) { + return const HomePage(); + } else { + return const LoginPage(); + } + }, + ), ), ); } } + diff --git a/lib/models/user.dart b/lib/models/user.dart index 6337552..e424c03 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -21,12 +21,16 @@ class User { @HiveField(2) // Поле 2 в Hive - третье поле модели final String email; + @HiveField(3) // Поле 3 в Hive - язык пользователя + final String language; + /// Конструктор пользователя /// id генерируется автоматически, если не передан User({ String? id, // Опциональный параметр - если null, сгенерируется автоматически required this.name, // Обязательный параметр required this.email, // Обязательный параметр + this.language = 'ru', // Язык по умолчанию - русский }) : id = id ?? IdGenerator.generateId(); // Если id не передан, генерируем новый /// Преобразование объекта в Map для сохранения в JSON или передачи по сети @@ -35,6 +39,7 @@ class User { 'id': id, 'name': name, 'email': email, + 'language': language, }; } @@ -44,6 +49,7 @@ class User { id: map['id'], name: map['name'], email: map['email'], + language: map['language'] ?? 'ru', // Устанавливаем русский по умолчанию, если язык не указан ); } diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index cea03e5..9ff78d3 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -20,19 +20,22 @@ class UserAdapter extends TypeAdapter { id: fields[0] as String?, name: fields[1] as String, email: fields[2] as String, + language: fields[3] == null ? 'ru' : fields[3] as String, ); } @override void write(BinaryWriter writer, User obj) { writer - ..writeByte(3) + ..writeByte(4) ..writeByte(0) ..write(obj.id) ..writeByte(1) ..write(obj.name) ..writeByte(2) - ..write(obj.email); + ..write(obj.email) + ..writeByte(3) + ..write(obj.language); } @override diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index ea14564..aefcd6b 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,24 +1,148 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import '/l10n/app_localizations.dart'; +import '../logic/auth/auth_bloc.dart'; +import '../logic/transaction/transaction_bloc.dart'; +import '../models/transaction_record.dart'; import 'settings_page.dart'; +import 'reports_page.dart'; // Импортируем новую страницу отчетов -/** - * Главная страница приложения для управления бюджетом. - * - * Этот виджет является основным экраном приложения и содержит: - * - AppBar с заголовком и кнопкой настроек - * - Основной контент с приветственным сообщением - * - * Особенности реализации: - * 1. Наследуется от StatelessWidget, так как не содержит собственного состояния - * 2. Использует Material Design через Scaffold - * 3. Управление темой вынесено в SettingsService - */ -class HomePage extends StatelessWidget { - /// Конструктор виджета - /// - /// @param key - опциональный ключ для идентификации виджета +class HomePage extends StatefulWidget { const HomePage({super.key}); + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + int _selectedIndex = 0; // Индекс выбранной вкладки + late String _currentUserId; // ID текущего пользователя + + // Список виджетов для каждой вкладки нижней навигации + static final List _widgetOptions = [ + const TransactionsPage(), // Главная страница с транзакциями + const ReportsPage(), // Страница отчетов + const SettingsPage(), // Страница настроек + ]; + + @override + void initState() { + super.initState(); + // Получаем ID текущего пользователя из AuthBloc + final authState = context.read().state; + if (authState is AuthAuthenticated) { + _currentUserId = authState.user.id; + } else { + // Если пользователь не аутентифицирован, можно перенаправить на страницу входа + // или использовать ID по умолчанию, если это применимо. + // В данном случае, для простоты, мы предполагаем, что пользователь всегда аутентифицирован. + _currentUserId = 'default_user'; // Заглушка, если что-то пошло не так + } + } + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + + return BlocProvider( + create: (context) { + return GetIt.instance()..add(LoadTransactions(userId: _currentUserId)); + }, + child: Scaffold( + appBar: AppBar( + title: Text(localizations.appTitle), // Локализованный заголовок + // Кнопки действий в AppBar теперь не нужны, так как настройки перенесены в BottomNavigationBar + ), + body: Center( + child: _widgetOptions[_selectedIndex], // Отображаем выбранный виджет + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + // TODO: Добавить логику для добавления новой транзакции + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(localizations.addTransactionButton)), // Локализованный текст + ); + }, + child: const Icon(Icons.add), + ), + bottomNavigationBar: BottomNavigationBar( + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.home), + label: localizations.homePageTitle, // Локализованный текст + ), + BottomNavigationBarItem( + icon: const Icon(Icons.bar_chart), + label: localizations.reportsPageTitle, // Локализованный текст + ), + BottomNavigationBarItem( + icon: const Icon(Icons.settings), + label: localizations.settingsPageTitle, // Локализованный текст + ), + ], + currentIndex: _selectedIndex, + selectedItemColor: Theme.of(context).colorScheme.primary, + onTap: _onItemTapped, + ), + ), + ); + } +} + +// Отдельный виджет для страницы транзакций, чтобы HomeView был чище +class TransactionsPage extends StatelessWidget { + const TransactionsPage({super.key}); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + + return BlocBuilder( + builder: (context, state) { + if (state is TransactionLoading) { + return Center(child: CircularProgressIndicator()); + } else if (state is TransactionLoaded) { + if (state.transactions.isEmpty) { + return Center(child: Text(localizations.noTransactionsText)); // Локализованный текст + } + return ListView.builder( + itemCount: state.transactions.length, + itemBuilder: (context, index) { + final transaction = state.transactions[index]; + return ListTile( + leading: Icon(transaction.category.icon, color: transaction.category.color), + title: Text(transaction.vendor), + subtitle: Text(transaction.category.name), + trailing: Text( + '${transaction.amount.toStringAsFixed(2)} ${transaction.currency}', + style: TextStyle( + color: transaction.isIncome ? Colors.green : Colors.red, + ), + ), + ); + }, + ); + } else if (state is TransactionError) { + return Center(child: Text(localizations.transactionErrorText(state.message))); // Локализованный текст + } else { + return Center(child: Text(localizations.loadingTransactionsText)); // Локализованный текст + } + }, + ); + } +} + +class HomeView extends StatelessWidget { + const HomeView({super.key}); + + /** * Основной метод построения интерфейса виджета. * @@ -29,6 +153,8 @@ class HomePage extends StatelessWidget { */ @override Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + return Scaffold( // Верхняя панель приложения appBar: AppBar( @@ -51,28 +177,37 @@ class HomePage extends StatelessWidget { ], ), - // Основное содержимое страницы - body: const Center( - // Вертикальное расположение элементов - child: Column( - // Выравнивание по центру по вертикали - mainAxisAlignment: MainAxisAlignment.center, - - // Дочерние виджеты колонки - children: [ - // Иконка кошелька - Icon(Icons.account_balance_wallet, size: 64), - - // Отступ между элементами - SizedBox(height: 16), - - // Приветственный текст - Text( - 'Добро пожаловать в Budget App!', - style: TextStyle(fontSize: 24), - ), - ], - ), + body: BlocBuilder( + builder: (context, state) { + if (state is TransactionLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is TransactionLoaded) { + if (state.transactions.isEmpty) { + return const Center(child: Text('Нет транзакций')); + } + return ListView.builder( + itemCount: state.transactions.length, + itemBuilder: (context, index) { + final transaction = state.transactions[index]; + return ListTile( + leading: Icon(transaction.category.icon, color: transaction.category.color), + title: Text(transaction.vendor), + subtitle: Text(transaction.category.name), + trailing: Text( + '${transaction.amount.toStringAsFixed(2)} ${transaction.currency}', + style: TextStyle( + color: transaction.isIncome ? Colors.green : Colors.red, + ), + ), + ); + }, + ); + } else if (state is TransactionError) { + return Center(child: Text(localizations.transactionErrorText(state.message))); // Локализованный текст + } else { + return Center(child: Text(localizations.loadingTransactionsText)); // Локализованный текст + } + }, ), ); } diff --git a/lib/pages/login/.placeholder b/lib/pages/login/.placeholder new file mode 100644 index 0000000..e69de29 diff --git a/lib/pages/login/login_page.dart b/lib/pages/login/login_page.dart new file mode 100644 index 0000000..d42642d --- /dev/null +++ b/lib/pages/login/login_page.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; + +import '/l10n/app_localizations.dart'; +import '../../logic/auth/auth_bloc.dart'; +import '../../services/user_service.dart'; + +class LoginPage extends StatefulWidget { + const LoginPage({super.key}); + + @override + State createState() => _LoginPageState(); +} + +class _LoginPageState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _nameController = TextEditingController(); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + + return Scaffold( + appBar: AppBar(title: Text(localizations.loginPageTitle)), // Локализованный заголовок + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: _nameController, + decoration: InputDecoration(labelText: localizations.nameFieldLabel), // Локализованный текст + validator: (value) { + if (value == null || value.isEmpty) { + return localizations.nameFieldEmptyError; // Локализованный текст + } + return null; + }, + ), + TextFormField( + controller: _emailController, + decoration: InputDecoration(labelText: localizations.emailFieldLabel), // Локализованный текст + validator: (value) { + if (value == null || value.isEmpty) { + return localizations.emailFieldEmptyError; // Локализованный текст + } + return null; + }, + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + final userService = GetIt.instance(); + userService.createAndSetUser( + _nameController.text, + _emailController.text, + ).then((_) { + final user = userService.currentUser; + if (user != null) { + context.read().add(AuthLoggedIn(user: user)); + } + }); + } + }, + child: Text(localizations.loginButtonText), // Локализованный текст + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/reports_page.dart b/lib/pages/reports_page.dart new file mode 100644 index 0000000..b1ab5d3 --- /dev/null +++ b/lib/pages/reports_page.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import '/l10n/app_localizations.dart'; + +class ReportsPage extends StatelessWidget { + const ReportsPage({super.key}); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + + return Scaffold( + appBar: AppBar( + title: Text(localizations.reportsPageTitle), // Локализованный заголовок + ), + body: Center( + child: Text(localizations.reportsPageTitle), // Локализованный текст + ), + ); + } +} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 75cda47..b5815cc 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import '/l10n/app_localizations.dart'; import '../services/settings_service.dart'; class SettingsPage extends StatefulWidget { @@ -31,9 +32,11 @@ class _SettingsPageState extends State { @override Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + return Scaffold( appBar: AppBar( - title: const Text('Настройки'), + title: Text(localizations.settingsPageTitle), // Локализованный заголовок ), body: Padding( padding: const EdgeInsets.all(16.0), @@ -41,14 +44,34 @@ class _SettingsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( - title: const Text('Темная тема'), - subtitle: const Text('Переключить между светлой и темной темой'), + title: Text(localizations.darkModeSetting), // Локализованный текст + subtitle: Text(localizations.darkModeDescription), // Локализованный текст value: _settingsService.isDarkMode, onChanged: (value) async { await _settingsService.setDarkMode(value); }, ), const Divider(), + ListTile( + title: Text(localizations.languageSetting), // Локализованный текст + subtitle: Text(localizations.languageDescription), // Локализованный текст + trailing: DropdownButton( + value: _settingsService.languageCode, // Текущий выбранный язык + onChanged: (String? newValue) async { + if (newValue != null) { + await _settingsService.setLanguageCode(newValue); + } + }, + items: ['en', 'ru'] // Доступные языки + .map>((String value) { + return DropdownMenuItem( + value: value, + child: Text(value == 'en' ? localizations.englishLanguage : localizations.russianLanguage), // Локализованные названия языков + ); + }).toList(), + ), + ), + const Divider(), // Здесь можно добавить другие настройки ], ), diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 67ca0ed..9fff468 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -3,6 +3,7 @@ import 'package:hive_ce/hive.dart'; class SettingsService extends ChangeNotifier { static const String _darkModeKey = 'darkMode'; + static const String _languageCodeKey = 'languageCode'; // Новый ключ для языка late final Box _settingsBox; SettingsService() { @@ -19,4 +20,15 @@ class SettingsService extends ChangeNotifier { Future toggleTheme() async { await setDarkMode(!isDarkMode); } + + // Новый геттер для получения кода языка + String get languageCode => _settingsBox.get(_languageCodeKey, defaultValue: 'ru'); + + // Новый метод для установки кода языка + Future setLanguageCode(String code) async { + await _settingsBox.put(_languageCodeKey, code); + notifyListeners(); + } } + + diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart index 8b9d8f8..bb4691c 100644 --- a/lib/services/user_service.dart +++ b/lib/services/user_service.dart @@ -1,3 +1,9 @@ +import 'package:budget_app/data/repositories/interfaces/icategory_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/itag_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/itransaction_repository.dart'; +import 'package:budget_app/utils/category_utils.dart'; +import 'package:budget_app/utils/tag_utils.dart'; +import 'package:budget_app/utils/transaction_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:hive_ce/hive.dart'; import '/models/user.dart'; @@ -10,7 +16,10 @@ class UserService extends ChangeNotifier { static const String _currentUserKey = 'currentUserId'; late final Box _settingsBox; // Box для хранения настроек - final IUserRepository _userRepository; // Репозиторий для работы с пользователями + final IUserRepository _userRepository; + final ICategoryRepository _categoryRepository; + final ITagRepository _tagRepository; + final ITransactionRepository _transactionRepository; User? _currentUser; // Текущий активный пользователь @@ -21,7 +30,7 @@ class UserService extends ChangeNotifier { bool get hasCurrentUser => _currentUser != null; /// Конструктор сервиса - UserService(this._userRepository) { + UserService(this._userRepository, this._categoryRepository, this._tagRepository, this._transactionRepository) { _settingsBox = Hive.box('settings'); _loadCurrentUser(); // Загружаем сохраненного пользователя при запуске } @@ -52,8 +61,14 @@ class UserService extends ChangeNotifier { /// Создание нового пользователя и установка его как текущего Future createAndSetUser(String name, String email) async { - final user = User(name: name, email: email); + final user = User(name: name, email: email, language: 'ru'); // Устанавливаем язык по умолчанию await _userRepository.add(user); + + // Добавляем начальные категории, теги и транзакции для нового пользователя + await _categoryRepository.addAll(CategoryUtils.getDefaultCategories(user.id)); + await _tagRepository.addAll(TagUtils.getDefaultTags(user.id)); + await _transactionRepository.addAll(TransactionUtils.getSampleTransactions(user.id)); + await setCurrentUser(user); } diff --git a/pubspec.lock b/pubspec.lock index 33da270..fb48554 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f url: "https://pub.dev" source: hosted - version: "82.0.0" + version: "85.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "904ae5bb474d32c38fb9482e2d925d5454cda04ddd0e55d2e6826bc72f6ba8c0" + sha256: f6154230675c44a191f2e20d16eeceb4aa18b30ca732db4efaf94c6a7d43cfa6 url: "https://pub.dev" source: hosted - version: "7.4.5" + version: "7.5.2" args: dependency: transitive description: @@ -34,7 +34,7 @@ packages: source: hosted version: "2.13.0" bloc: - dependency: transitive + dependency: "direct main" description: name: bloc sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: build - sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.4" build_config: dependency: transitive description: @@ -77,26 +77,26 @@ packages: dependency: transitive description: name: build_resolvers - sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.5.4" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99" + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.5.4" build_runner_core: dependency: transitive description: name: build_runner_core - sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" url: "https://pub.dev" source: hosted - version: "8.0.0" + version: "9.1.2" built_collection: dependency: transitive description: @@ -246,6 +246,11 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -331,6 +336,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: @@ -399,10 +412,10 @@ packages: dependency: "direct main" description: name: logger - sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" logging: dependency: transitive description: @@ -700,10 +713,10 @@ packages: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8d8e2bd..fa544d3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,10 @@ dependencies: logger: ^2.5.0 flutter_bloc: ^9.1.1 equatable: ^2.0.7 + flutter_localizations: + sdk: flutter + intl: ^0.20.2 + bloc: dev_dependencies: @@ -57,6 +61,7 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + generate: true # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in From 0d64d4ca0eeb59c149c07255c2586aea8913843f Mon Sep 17 00:00:00 2001 From: Sanders Date: Fri, 27 Jun 2025 22:28:12 +0300 Subject: [PATCH 03/35] Add cubit settings --- GEMINI.md | 23 ++++ lib/hive/hive_adapters.dart | 5 - lib/injection_container.dart | 23 ++-- lib/l10n/app_en.arb | 4 +- lib/l10n/app_localizations.dart | 12 ++ lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_ru.dart | 7 ++ lib/l10n/app_ru.arb | 4 +- lib/logic/settings/settings_cubit.dart | 45 +++++++ lib/logic/settings/settings_state.dart | 32 +++++ lib/main.dart | 85 ++++++------- lib/models/category.dart | 30 ++++- lib/models/category.g.dart | 7 +- lib/models/tag.dart | 26 +++- lib/models/tag.g.dart | 7 +- lib/models/transaction_record.dart | 41 ++++++- lib/models/transaction_record.g.dart | 7 +- lib/models/user.dart | 26 +++- lib/models/user.g.dart | 7 +- lib/pages/home_page.dart | 74 ++++++++---- lib/pages/settings_page.dart | 158 ++++++++++++++----------- lib/utils/category_utils.dart | 2 +- lib/utils/tag_utils.dart | 1 + lib/utils/transaction_utils.dart | 2 - 24 files changed, 459 insertions(+), 175 deletions(-) create mode 100644 GEMINI.md create mode 100644 lib/logic/settings/settings_cubit.dart create mode 100644 lib/logic/settings/settings_state.dart diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..8a52b28 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,23 @@ +# Project: Budget App + +## General Instructions: + +- Это проект на Flutter используй только его +- Комментируй в коде каждое изменение, которое ты делаешь, что бы мне было понятно и я учился на этом. +- Комментарии и твои ответы должны быть на русском языке +- When generating new Flutter code, please follow the existing coding style. + +## Coding Style: + +- Interface names should be prefixed with `I` (e.g., `IUserService`). +- Private class members should be prefixed with an underscore (`_`). +- Учитывай, что в проекте используется bloc cubit архитектура + +## Role +- You are a Flutter assistant that helps users write more efficient and optimizable Flutter code. +- You specialize in identifying patterns that enable Flutter Compiler to automatically apply optimizations, reducing unnecessary re-renders and improving application performance. + +## Follow these guidelines in all code you produce and suggest +- Prefer composition and small components: Break down UI into small, reusable components rather than writing large monolithic components. The code you generate should promote clarity and reusability by composing components together. +- Design for a good user experience - Provide clear, minimal, and non-blocking UI states. When data is loading, show lightweight placeholders (e.g., skeleton screens) rather than intrusive spinners everywhere. Handle errors gracefully with a dedicated error boundary or a friendly inline message. Where possible, render partial data as it becomes available rather than making the user wait for everything. Suspense allows you to declare the loading states in your component tree in a natural way, preventing “flash” states and improving perceived performance. +- \ No newline at end of file diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index 0b50f24..8b6c07c 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -1,12 +1,7 @@ -import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:hive_ce/hive.dart'; -import 'package:budget_app/models/category.dart'; -import 'package:budget_app/models/tag.dart'; -import 'package:budget_app/models/transaction_record.dart'; -import '../models/user.dart'; @GenerateAdapters([ AdapterSpec(), diff --git a/lib/injection_container.dart b/lib/injection_container.dart index e297650..df37dd1 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -1,16 +1,17 @@ import 'package:get_it/get_it.dart'; + import 'data/database/hive_service.dart'; import 'data/repositories/hive_category_repository.dart'; import 'data/repositories/hive_tag_repository.dart'; import 'data/repositories/hive_transaction_repository.dart'; +import 'data/repositories/hive_user_repository.dart'; import 'data/repositories/interfaces/icategory_repository.dart'; import 'data/repositories/interfaces/itag_repository.dart'; import 'data/repositories/interfaces/itransaction_repository.dart'; -import 'data/repositories/hive_user_repository.dart'; import 'data/repositories/interfaces/iuser_repository.dart'; import 'logic/auth/auth_bloc.dart'; +import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit import 'logic/transaction/transaction_bloc.dart'; -import 'services/settings_service.dart'; import 'services/user_service.dart'; final getIt = GetIt.instance; @@ -20,17 +21,15 @@ Future initDependencies() async { await HiveService.init(); // Регистрация сервисов - getIt.registerSingleton(SettingsService()); + getIt.registerSingleton(SettingsCubit()); // Регистрируем Cubit // Регистрация репозиториев getIt.registerSingleton( HiveCategoryRepository(HiveService.categories), ); - - getIt.registerSingleton( - HiveTagRepository(HiveService.tags), - ); - + + getIt.registerSingleton(HiveTagRepository(HiveService.tags)); + getIt.registerSingleton( HiveTransactionRepository(HiveService.transactions), ); @@ -40,9 +39,13 @@ Future initDependencies() async { ); // Services - getIt.registerSingleton(UserService(getIt(), getIt(), getIt(), getIt())); + getIt.registerSingleton( + UserService(getIt(), getIt(), getIt(), getIt()), + ); // Blocs getIt.registerFactory(() => AuthBloc(userService: getIt())); - getIt.registerFactory(() => TransactionBloc(transactionRepository: getIt())); + getIt.registerFactory( + () => TransactionBloc(transactionRepository: getIt()), + ); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c671fd5..7e1d83a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -20,5 +20,7 @@ "languageDescription": "Change application language", "russianLanguage": "Russian", "englishLanguage": "English", - "defaultUser": "Default User" + "defaultUser": "Default User", + "currencySetting": "Default Currency", + "currencyDescription": "Set the default currency for transactions" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 60e25fb..8a657e1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -223,6 +223,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Default User'** String get defaultUser; + + /// No description provided for @currencySetting. + /// + /// In en, this message translates to: + /// **'Default Currency'** + String get currencySetting; + + /// No description provided for @currencyDescription. + /// + /// In en, this message translates to: + /// **'Set the default currency for transactions'** + String get currencyDescription; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9301836..6c2f947 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -72,4 +72,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get defaultUser => 'Default User'; + + @override + String get currencySetting => 'Default Currency'; + + @override + String get currencyDescription => 'Set the default currency for transactions'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e99b099..919f29e 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -72,4 +72,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get defaultUser => 'Пользователь по умолчанию'; + + @override + String get currencySetting => 'Валюта по умолчанию'; + + @override + String get currencyDescription => + 'Установить валюту по умолчанию для транзакций'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4e1e496..70f682b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -20,5 +20,7 @@ "languageDescription": "Изменить язык приложения", "russianLanguage": "Русский", "englishLanguage": "Английский", - "defaultUser": "Пользователь по умолчанию" + "defaultUser": "Пользователь по умолчанию", + "currencySetting": "Валюта по умолчанию", + "currencyDescription": "Установить валюту по умолчанию для транзакций" } \ No newline at end of file diff --git a/lib/logic/settings/settings_cubit.dart b/lib/logic/settings/settings_cubit.dart new file mode 100644 index 0000000..6304d70 --- /dev/null +++ b/lib/logic/settings/settings_cubit.dart @@ -0,0 +1,45 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:hive_ce/hive.dart'; + +part 'settings_state.dart'; + +class SettingsCubit extends Cubit { + static const String _darkModeKey = 'darkMode'; + static const String _languageCodeKey = 'languageCode'; + static const String _defaultCurrencyKey = 'defaultCurrency'; // Комментарий: Новый ключ для хранения валюты по умолчанию + late final Box _settingsBox; + + SettingsCubit() : super(const SettingsInitial()) { + _settingsBox = Hive.box('settings'); + _loadSettings(); + } + + void _loadSettings() { + final isDarkMode = _settingsBox.get(_darkModeKey, defaultValue: false); + final languageCode = _settingsBox.get(_languageCodeKey, defaultValue: 'ru'); + // Комментарий: Загружаем валюту по умолчанию из Hive. Если ее нет, используем 'RUB'. + final defaultCurrency = _settingsBox.get(_defaultCurrencyKey, defaultValue: 'RUB'); + emit(state.copyWith(isDarkMode: isDarkMode, languageCode: languageCode, defaultCurrency: defaultCurrency)); + } + + Future setDarkMode(bool value) async { + await _settingsBox.put(_darkModeKey, value); + emit(state.copyWith(isDarkMode: value)); + } + + Future setLanguageCode(String code) async { + await _settingsBox.put(_languageCodeKey, code); + emit(state.copyWith(languageCode: code)); + } + + // Комментарий: Новый метод для установки валюты по умолчанию. + Future setDefaultCurrency(String currencyCode) async { + await _settingsBox.put(_defaultCurrencyKey, currencyCode); + emit(state.copyWith(defaultCurrency: currencyCode)); + } + + void toggleTheme() { + setDarkMode(!state.isDarkMode); + } +} diff --git a/lib/logic/settings/settings_state.dart b/lib/logic/settings/settings_state.dart new file mode 100644 index 0000000..16f98ff --- /dev/null +++ b/lib/logic/settings/settings_state.dart @@ -0,0 +1,32 @@ +part of 'settings_cubit.dart'; + +class SettingsState extends Equatable { + final bool isDarkMode; + final String languageCode; + final String defaultCurrency; // Новое поле для валюты по умолчанию + + const SettingsState({ + required this.isDarkMode, + required this.languageCode, + required this.defaultCurrency, // Теперь обязательный параметр + }); + + @override + List get props => [isDarkMode, languageCode, defaultCurrency]; + + SettingsState copyWith({ + bool? isDarkMode, + String? languageCode, + String? defaultCurrency, // Добавляем в copyWith + }) { + return SettingsState( + isDarkMode: isDarkMode ?? this.isDarkMode, + languageCode: languageCode ?? this.languageCode, + defaultCurrency: defaultCurrency ?? this.defaultCurrency, // Обновляем значение + ); + } +} + +class SettingsInitial extends SettingsState { + const SettingsInitial() : super(isDarkMode: false, languageCode: 'ru', defaultCurrency: 'RUB'); // Инициализируем валюту по умолчанию +} diff --git a/lib/main.dart b/lib/main.dart index 9b3f1ee..287ca66 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; // Добавляем импортсса -import 'l10n/app_localizations.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; // Добавляем импорт +import '/l10n/app_localizations.dart'; import 'logic/auth/auth_bloc.dart'; import 'pages/login/login_page.dart'; import 'package:budget_app/pages/home_page.dart'; import 'theme/app_theme.dart'; -import 'package:hive_ce_flutter/hive_flutter.dart'; import 'injection_container.dart' as di; -import 'services/settings_service.dart'; -import 'package:get_it/get_it.dart'; +import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -26,54 +24,43 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - late final SettingsService _settingsService; - - @override - void initState() { - super.initState(); - _settingsService = GetIt.instance(); - _settingsService.addListener(_onSettingsChanged); - } - - @override - void dispose() { - _settingsService.removeListener(_onSettingsChanged); - super.dispose(); - } - - void _onSettingsChanged() { - setState(() {}); - } - @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => GetIt.instance()..add(AuthStarted()), - child: MaterialApp( - title: AppLocalizations.of(context)?.appTitle ?? 'Budget App', // Используем локализованный заголовок - theme: AppTheme.lightTheme(), - darkTheme: AppTheme.darkTheme(), - themeMode: _settingsService.isDarkMode ? ThemeMode.dark : ThemeMode.light, - // Добавляем локализацию - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: AppLocalizations.supportedLocales, - locale: Locale(_settingsService.languageCode), // Устанавливаем текущий язык из настроек - home: BlocBuilder( - builder: (context, state) { - if (state is AuthAuthenticated) { - return const HomePage(); - } else { - return const LoginPage(); - } - }, - ), + return MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => GetIt.instance()..add(AuthStarted())), + BlocProvider(create: (context) => GetIt.instance()), + ], + child: BlocBuilder( + builder: (context, settingsState) { + return MaterialApp( + title: AppLocalizations.of(context)?.appTitle ?? 'Budget App', // Используем локализованный заголовок + theme: AppTheme.lightTheme(), + darkTheme: AppTheme.darkTheme(), + themeMode: settingsState.isDarkMode ? ThemeMode.dark : ThemeMode.light, + // Добавляем локализацию + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: Locale(settingsState.languageCode), // Устанавливаем текущий язык из настроек + home: BlocBuilder( + builder: (context, authState) { + if (authState is AuthAuthenticated) { + return const HomePage(); + } else { + return const LoginPage(); + } + }, + ), + ); + }, ), ); } } + diff --git a/lib/models/category.dart b/lib/models/category.dart index b308c88..8fa89bc 100644 --- a/lib/models/category.dart +++ b/lib/models/category.dart @@ -36,6 +36,10 @@ class Category { /// Это позволяет разделять категории между разными пользователями final String userId; + @HiveField(6) + /// Дата и время последнего обновления объекта + final DateTime updatedAt; + /// Конструктор с обязательными параметрами Category({ String? id, @@ -44,7 +48,9 @@ class Category { required this.icon, required this.isIncome, required this.userId, // Теперь userId обязательный параметр - }) : id = id ?? IdGenerator.generateId(); + DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное + }) : id = id ?? IdGenerator.generateId(), + updatedAt = updatedAt ?? DateTime.now(); // Устанавливаем текущее время по умолчанию /// Метод для преобразования объекта в Map (полезно для работы с БД) Map toMap() { @@ -55,6 +61,7 @@ class Category { 'icon': icon.codePoint, 'isIncome': isIncome, 'userId': userId, // Добавляем userId в Map + 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map }; } @@ -67,6 +74,27 @@ class Category { icon: IconData(map['icon'], fontFamily: 'MaterialIcons'), isIncome: map['isIncome'], userId: map['userId'], // Добавляем userId при создании из Map + updatedAt: DateTime.parse(map['updatedAt']), // Добавлено updatedAt при создании из Map + ); + } + + /// Метод для создания копии объекта с возможностью изменения полей + Category copyWith({ + String? id, + String? name, + Color? color, + IconData? icon, + bool? isIncome, + String? userId, + }) { + return Category( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + icon: icon ?? this.icon, + isIncome: isIncome ?? this.isIncome, + userId: userId ?? this.userId, + updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании ); } } diff --git a/lib/models/category.g.dart b/lib/models/category.g.dart index 6e7f75e..64cd3f3 100644 --- a/lib/models/category.g.dart +++ b/lib/models/category.g.dart @@ -23,13 +23,14 @@ class CategoryAdapter extends TypeAdapter { icon: fields[3] as IconData, isIncome: fields[4] as bool, userId: fields[5] as String, + updatedAt: fields[6] as DateTime?, ); } @override void write(BinaryWriter writer, Category obj) { writer - ..writeByte(6) + ..writeByte(7) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -41,7 +42,9 @@ class CategoryAdapter extends TypeAdapter { ..writeByte(4) ..write(obj.isIncome) ..writeByte(5) - ..write(obj.userId); + ..write(obj.userId) + ..writeByte(6) + ..write(obj.updatedAt); } @override diff --git a/lib/models/tag.dart b/lib/models/tag.dart index bb0dfc8..51f743d 100644 --- a/lib/models/tag.dart +++ b/lib/models/tag.dart @@ -19,6 +19,10 @@ class Tag { @HiveField(2) final String userId; + @HiveField(3) + /// Дата и время последнего обновления объекта + final DateTime updatedAt; + /// Конструктор с обязательными параметрами Tag({ String? id, @@ -26,7 +30,9 @@ class Tag { // Комментарий: Добавляем userId в конструктор как обязательный параметр. // Теперь при создании тега необходимо будет указать, какому пользователю он принадлежит. required this.userId, - }) : id = id ?? IdGenerator.generateId(); + DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное + }) : id = id ?? IdGenerator.generateId(), + updatedAt = updatedAt ?? DateTime.now(); // Устанавливаем текущее время по умолчанию /// Преобразование объекта в Map Map toMap() { @@ -36,6 +42,7 @@ class Tag { // Комментарий: Добавляем userId в Map. Это нужно для сохранения // данных в форматах, которые не работают напрямую с объектами Dart (например, при отправке на сервер). 'userId': userId, + 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map }; } @@ -45,8 +52,23 @@ class Tag { id: map['id'], name: map['name'], // Комментарий: Извлекаем userId из Map при создании объекта. - // Это позволяет восстановить полный объект Tag из данных, например, из базы данных. + // Это позволит восстановить полный объект Tag из данных, например, из базы данных. userId: map['userId'], + updatedAt: DateTime.parse(map['updatedAt']), // Добавлено updatedAt при создании из Map + ); + } + + /// Метод для создания копии объекта с возможностью изменения полей + Tag copyWith({ + String? id, + String? name, + String? userId, + }) { + return Tag( + id: id ?? this.id, + name: name ?? this.name, + userId: userId ?? this.userId, + updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании ); } } diff --git a/lib/models/tag.g.dart b/lib/models/tag.g.dart index f2d5e60..30b33c6 100644 --- a/lib/models/tag.g.dart +++ b/lib/models/tag.g.dart @@ -20,19 +20,22 @@ class TagAdapter extends TypeAdapter { id: fields[0] as String?, name: fields[1] as String, userId: fields[2] as String, + updatedAt: fields[3] as DateTime?, ); } @override void write(BinaryWriter writer, Tag obj) { writer - ..writeByte(3) + ..writeByte(4) ..writeByte(0) ..write(obj.id) ..writeByte(1) ..write(obj.name) ..writeByte(2) - ..write(obj.userId); + ..write(obj.userId) + ..writeByte(3) + ..write(obj.updatedAt); } @override diff --git a/lib/models/transaction_record.dart b/lib/models/transaction_record.dart index 5463c5c..afcf3a1 100644 --- a/lib/models/transaction_record.dart +++ b/lib/models/transaction_record.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:hive_ce/hive.dart'; + import '../../utils/id_generator.dart'; import 'category.dart'; import 'tag.dart'; @@ -10,7 +10,6 @@ part 'transaction_record.g.dart'; /// Модель записи о транзакции - основной элемент учета бюджета /// Содержит все детали финансовой операции class TransactionRecord { - /// Уникальный идентификатор транзакции @HiveField(0) final String id; @@ -43,6 +42,10 @@ class TransactionRecord { @HiveField(7) // Используем следующий доступный номер поля Hive final String userId; + @HiveField(8) + /// Дата и время последнего обновления объекта + final DateTime updatedAt; + /// Конструктор с обязательными параметрами TransactionRecord({ String? id, @@ -53,7 +56,11 @@ class TransactionRecord { required this.vendor, required this.currency, required this.userId, // Добавляем userId в конструктор - }) : id = id ?? IdGenerator.generateId(); + DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное + }) : id = id ?? IdGenerator.generateId(), + updatedAt = + updatedAt ?? + DateTime.now(); // Устанавливаем текущее время по умолчанию /// Преобразование объекта в Map Map toMap() { @@ -66,6 +73,7 @@ class TransactionRecord { 'vendor': vendor, 'currency': currency, 'userId': userId, // Добавляем userId в Map + 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map }; } @@ -80,10 +88,37 @@ class TransactionRecord { vendor: map['vendor'], currency: map['currency'], userId: map['userId'], // Извлекаем userId из Map + updatedAt: DateTime.parse( + map['updatedAt'], + ), // Добавлено updatedAt при создании из Map ); } /// Вспомогательный геттер для определения типа операции /// (доход/расход) на основе категории bool get isIncome => category.isIncome; + + /// Метод для создания копии объекта с возможностью изменения полей + TransactionRecord copyWith({ + String? id, + Category? category, + Tag? tag, + double? amount, + DateTime? dateTime, + String? vendor, + String? currency, + String? userId, + }) { + return TransactionRecord( + id: id ?? this.id, + category: category ?? this.category, + tag: tag ?? this.tag, + amount: amount ?? this.amount, + dateTime: dateTime ?? this.dateTime, + vendor: vendor ?? this.vendor, + currency: currency ?? this.currency, + userId: userId ?? this.userId, + updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании + ); + } } diff --git a/lib/models/transaction_record.g.dart b/lib/models/transaction_record.g.dart index c4c70e1..2a90916 100644 --- a/lib/models/transaction_record.g.dart +++ b/lib/models/transaction_record.g.dart @@ -25,13 +25,14 @@ class TransactionRecordAdapter extends TypeAdapter { vendor: fields[5] as String, currency: fields[6] as String, userId: fields[7] as String, + updatedAt: fields[8] as DateTime?, ); } @override void write(BinaryWriter writer, TransactionRecord obj) { writer - ..writeByte(8) + ..writeByte(9) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -47,7 +48,9 @@ class TransactionRecordAdapter extends TypeAdapter { ..writeByte(6) ..write(obj.currency) ..writeByte(7) - ..write(obj.userId); + ..write(obj.userId) + ..writeByte(8) + ..write(obj.updatedAt); } @override diff --git a/lib/models/user.dart b/lib/models/user.dart index e424c03..1fbd545 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -24,6 +24,10 @@ class User { @HiveField(3) // Поле 3 в Hive - язык пользователя final String language; + @HiveField(4) + /// Дата и время последнего обновления объекта + final DateTime updatedAt; + /// Конструктор пользователя /// id генерируется автоматически, если не передан User({ @@ -31,7 +35,9 @@ class User { required this.name, // Обязательный параметр required this.email, // Обязательный параметр this.language = 'ru', // Язык по умолчанию - русский - }) : id = id ?? IdGenerator.generateId(); // Если id не передан, генерируем новый + DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное + }) : id = id ?? IdGenerator.generateId(), // Если id не передан, генерируем новый + updatedAt = updatedAt ?? DateTime.now(); // Устанавливаем текущее время по умолчанию /// Преобразование объекта в Map для сохранения в JSON или передачи по сети Map toMap() { @@ -40,6 +46,7 @@ class User { 'name': name, 'email': email, 'language': language, + 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map }; } @@ -50,6 +57,7 @@ class User { name: map['name'], email: map['email'], language: map['language'] ?? 'ru', // Устанавливаем русский по умолчанию, если язык не указан + updatedAt: DateTime.parse(map['updatedAt']), // Добавлено updatedAt при создании из Map ); } @@ -58,4 +66,20 @@ class User { String toString() { return 'User(id: $id, name: $name, email: $email)'; } + + /// Метод для создания копии объекта с возможностью изменения полей + User copyWith({ + String? id, + String? name, + String? email, + String? language, + }) { + return User( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + language: language ?? this.language, + updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании + ); + } } diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index 9ff78d3..4697b89 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -21,13 +21,14 @@ class UserAdapter extends TypeAdapter { name: fields[1] as String, email: fields[2] as String, language: fields[3] == null ? 'ru' : fields[3] as String, + updatedAt: fields[4] as DateTime?, ); } @override void write(BinaryWriter writer, User obj) { writer - ..writeByte(4) + ..writeByte(5) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -35,7 +36,9 @@ class UserAdapter extends TypeAdapter { ..writeByte(2) ..write(obj.email) ..writeByte(3) - ..write(obj.language); + ..write(obj.language) + ..writeByte(4) + ..write(obj.updatedAt); } @override diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index aefcd6b..b8e4406 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; + import '/l10n/app_localizations.dart'; import '../logic/auth/auth_bloc.dart'; import '../logic/transaction/transaction_bloc.dart'; -import '../models/transaction_record.dart'; -import 'settings_page.dart'; import 'reports_page.dart'; // Импортируем новую страницу отчетов +import 'settings_page.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -49,11 +49,14 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + final localizations = AppLocalizations.of( + context, + )!; // Получаем экземпляр локализации return BlocProvider( create: (context) { - return GetIt.instance()..add(LoadTransactions(userId: _currentUserId)); + return GetIt.instance() + ..add(LoadTransactions(userId: _currentUserId)); }, child: Scaffold( appBar: AppBar( @@ -67,7 +70,9 @@ class _HomePageState extends State { onPressed: () { // TODO: Добавить логику для добавления новой транзакции ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(localizations.addTransactionButton)), // Локализованный текст + SnackBar( + content: Text(localizations.addTransactionButton), + ), // Локализованный текст ); }, child: const Icon(Icons.add), @@ -102,7 +107,9 @@ class TransactionsPage extends StatelessWidget { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + final localizations = AppLocalizations.of( + context, + )!; // Получаем экземпляр локализации return BlocBuilder( builder: (context, state) { @@ -110,14 +117,19 @@ class TransactionsPage extends StatelessWidget { return Center(child: CircularProgressIndicator()); } else if (state is TransactionLoaded) { if (state.transactions.isEmpty) { - return Center(child: Text(localizations.noTransactionsText)); // Локализованный текст + return Center( + child: Text(localizations.noTransactionsText), + ); // Локализованный текст } return ListView.builder( itemCount: state.transactions.length, itemBuilder: (context, index) { final transaction = state.transactions[index]; return ListTile( - leading: Icon(transaction.category.icon, color: transaction.category.color), + leading: Icon( + transaction.category.icon, + color: transaction.category.color, + ), title: Text(transaction.vendor), subtitle: Text(transaction.category.name), trailing: Text( @@ -130,9 +142,13 @@ class TransactionsPage extends StatelessWidget { }, ); } else if (state is TransactionError) { - return Center(child: Text(localizations.transactionErrorText(state.message))); // Локализованный текст + return Center( + child: Text(localizations.transactionErrorText(state.message)), + ); // Локализованный текст } else { - return Center(child: Text(localizations.loadingTransactionsText)); // Локализованный текст + return Center( + child: Text(localizations.loadingTransactionsText), + ); // Локализованный текст } }, ); @@ -142,25 +158,26 @@ class TransactionsPage extends StatelessWidget { class HomeView extends StatelessWidget { const HomeView({super.key}); - - /** - * Основной метод построения интерфейса виджета. - * - * Возвращает Scaffold - базовую структуру страницы Material Design, - * которая включает: - * 1. AppBar (верхнюю панель) - * 2. Body (основное содержимое) - */ + // // + // Основной метод построения интерфейса виджета. + // + // Возвращает Scaffold - базовую структуру страницы Material Design, + // которая включает: + // 1. AppBar (верхнюю панель) + // 2. Body (основное содержимое) + // // @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + final localizations = AppLocalizations.of( + context, + )!; // Получаем экземпляр локализации return Scaffold( // Верхняя панель приложения appBar: AppBar( // Заголовок приложения title: const Text('Budget App'), - + // Кнопки в правой части AppBar actions: [ // Кнопка настроек @@ -176,7 +193,7 @@ class HomeView extends StatelessWidget { ), ], ), - + body: BlocBuilder( builder: (context, state) { if (state is TransactionLoading) { @@ -190,7 +207,10 @@ class HomeView extends StatelessWidget { itemBuilder: (context, index) { final transaction = state.transactions[index]; return ListTile( - leading: Icon(transaction.category.icon, color: transaction.category.color), + leading: Icon( + transaction.category.icon, + color: transaction.category.color, + ), title: Text(transaction.vendor), subtitle: Text(transaction.category.name), trailing: Text( @@ -203,9 +223,13 @@ class HomeView extends StatelessWidget { }, ); } else if (state is TransactionError) { - return Center(child: Text(localizations.transactionErrorText(state.message))); // Локализованный текст + return Center( + child: Text(localizations.transactionErrorText(state.message)), + ); // Локализованный текст } else { - return Center(child: Text(localizations.loadingTransactionsText)); // Локализованный текст + return Center( + child: Text(localizations.loadingTransactionsText), + ); // Локализованный текст } }, ), diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index b5815cc..0875888 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,81 +1,107 @@ import 'package:flutter/material.dart'; -import 'package:get_it/get_it.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import '/l10n/app_localizations.dart'; -import '../services/settings_service.dart'; +import '../logic/settings/settings_cubit.dart'; -class SettingsPage extends StatefulWidget { +// Комментарий: Мы преобразуем SettingsPage из StatefulWidget в StatelessWidget. +// Это возможно, потому что теперь состояние управляется SettingsCubit, +// и виджету не нужно хранить собственное состояние, что делает код проще и эффективнее. +class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); - @override - State createState() => _SettingsPageState(); -} - -class _SettingsPageState extends State { - late final SettingsService _settingsService; - - @override - void initState() { - super.initState(); - _settingsService = GetIt.instance(); - _settingsService.addListener(_onSettingsChanged); - } - - @override - void dispose() { - _settingsService.removeListener(_onSettingsChanged); - super.dispose(); - } - - void _onSettingsChanged() { - setState(() {}); - } - @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + // Комментарий: Получаем экземпляр локализации для использования в тексте. + // Это позволяет нам отображать текст на языке, выбранном пользователем. + final localizations = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( - title: Text(localizations.settingsPageTitle), // Локализованный заголовок + // Комментарий: Используем локализованную строку для заголовка страницы. + title: Text(localizations.settingsPageTitle), ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SwitchListTile( - title: Text(localizations.darkModeSetting), // Локализованный текст - subtitle: Text(localizations.darkModeDescription), // Локализованный текст - value: _settingsService.isDarkMode, - onChanged: (value) async { - await _settingsService.setDarkMode(value); - }, + // Комментарий: Используем BlocBuilder для перестройки UI при изменении состояния SettingsCubit. + // Он будет "слушать" изменения в SettingsCubit и автоматически перестраивать дочерние виджеты + // с новым состоянием (state). + body: BlocBuilder( + builder: (context, state) { + // Комментарий: `state` - это текущее состояние настроек (тема и язык). + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Комментарий: SwitchListTile для переключения темной/светлой темы. + // Это удобный виджет, который объединяет переключатель с текстом. + SwitchListTile( + title: Text(localizations.darkModeSetting), + subtitle: Text(localizations.darkModeDescription), + // Комментарий: Значение переключателя (включен/выключен) берется из `state.isDarkMode`. + value: state.isDarkMode, + // Комментарий: При изменении положения переключателя (onChanged) мы вызываем метод `setDarkMode` у Cubit. + // `context.read()` используется для доступа к Cubit без подписки на его изменения. + // Это хорошо для вызова методов. + onChanged: (value) { + context.read().setDarkMode(value); + }, + ), + const Divider(), + // Комментарий: ListTile для смены языка. + ListTile( + title: Text(localizations.languageSetting), + subtitle: Text(localizations.languageDescription), + trailing: DropdownButton( + // Комментарий: Текущее значение языка для выпадающего списка берется из `state.languageCode`. + value: state.languageCode, + // Комментарий: При выборе нового языка вызываем метод `setLanguageCode` у Cubit. + onChanged: (String? newValue) { + if (newValue != null) { + context.read().setLanguageCode(newValue); + } + }, + // Комментарий: Формируем список доступных языков. + items: ['en', 'ru'] + .map>((String value) { + return DropdownMenuItem( + value: value, + // Комментарий: Отображаем локализованное название языка. + child: Text(value == 'en' + ? localizations.englishLanguage + : localizations.russianLanguage), + ); + }).toList(), + ), + ), + const Divider(), + // Комментарий: ListTile для выбора валюты по умолчанию. + ListTile( + title: Text(localizations.currencySetting), + subtitle: Text(localizations.currencyDescription), + trailing: DropdownButton( + // Комментарий: Текущее значение валюты берется из `state.defaultCurrency`. + value: state.defaultCurrency, + // Комментарий: При выборе новой валюты вызываем метод `setDefaultCurrency` у Cubit. + onChanged: (String? newValue) { + if (newValue != null) { + context.read().setDefaultCurrency(newValue); + } + }, + // Комментарий: Формируем список доступных валют. Можно расширить этот список. + items: ['RUB', 'USD', 'EUR'] + .map>((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + ), + ), + const Divider(), + ], ), - const Divider(), - ListTile( - title: Text(localizations.languageSetting), // Локализованный текст - subtitle: Text(localizations.languageDescription), // Локализованный текст - trailing: DropdownButton( - value: _settingsService.languageCode, // Текущий выбранный язык - onChanged: (String? newValue) async { - if (newValue != null) { - await _settingsService.setLanguageCode(newValue); - } - }, - items: ['en', 'ru'] // Доступные языки - .map>((String value) { - return DropdownMenuItem( - value: value, - child: Text(value == 'en' ? localizations.englishLanguage : localizations.russianLanguage), // Локализованные названия языков - ); - }).toList(), - ), - ), - const Divider(), - // Здесь можно добавить другие настройки - ], - ), + ); + }, ), ); } -} +} \ No newline at end of file diff --git a/lib/utils/category_utils.dart b/lib/utils/category_utils.dart index 5725479..91a183d 100644 --- a/lib/utils/category_utils.dart +++ b/lib/utils/category_utils.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; + import '../models/category.dart'; -import '../services/user_service.dart'; /// Утилиты для работы с категориями class CategoryUtils { diff --git a/lib/utils/tag_utils.dart b/lib/utils/tag_utils.dart index 43b7af7..b556950 100644 --- a/lib/utils/tag_utils.dart +++ b/lib/utils/tag_utils.dart @@ -16,6 +16,7 @@ class TagUtils { ), Tag( id: 'tag_work', + name: 'Работа', userId: userId, ), diff --git a/lib/utils/transaction_utils.dart b/lib/utils/transaction_utils.dart index b5c8872..20d2e5e 100644 --- a/lib/utils/transaction_utils.dart +++ b/lib/utils/transaction_utils.dart @@ -1,6 +1,4 @@ import '../models/transaction_record.dart'; -import '../models/category.dart'; -import '../models/tag.dart'; import 'category_utils.dart'; import 'tag_utils.dart'; From 65fe73b396acd9be3f401ac4099287c5ac2153d3 Mon Sep 17 00:00:00 2001 From: Sanders Date: Fri, 27 Jun 2025 23:31:16 +0300 Subject: [PATCH 04/35] Transfer settings to user --- lib/injection_container.dart | 2 +- lib/logic/settings/settings_cubit.dart | 63 ++++++++++++++++++-------- lib/models/user.dart | 30 +++++++++--- lib/models/user.g.dart | 14 ++++-- lib/services/settings_service.dart | 34 -------------- lib/services/user_service.dart | 28 +++++++++--- 6 files changed, 98 insertions(+), 73 deletions(-) delete mode 100644 lib/services/settings_service.dart diff --git a/lib/injection_container.dart b/lib/injection_container.dart index df37dd1..49b8f82 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -21,7 +21,7 @@ Future initDependencies() async { await HiveService.init(); // Регистрация сервисов - getIt.registerSingleton(SettingsCubit()); // Регистрируем Cubit + getIt.registerSingleton(SettingsCubit(getIt())); // Регистрируем Cubit и передаем IUserRepository // Регистрация репозиториев getIt.registerSingleton( diff --git a/lib/logic/settings/settings_cubit.dart b/lib/logic/settings/settings_cubit.dart index 6304d70..ef516eb 100644 --- a/lib/logic/settings/settings_cubit.dart +++ b/lib/logic/settings/settings_cubit.dart @@ -1,42 +1,65 @@ import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:hive_ce/hive.dart'; +import 'package:budget_app/data/repositories/interfaces/iuser_repository.dart'; // Комментарий: Импортируем интерфейс репозитория пользователя +import 'package:budget_app/models/user.dart'; // Комментарий: Импортируем модель пользователя part 'settings_state.dart'; class SettingsCubit extends Cubit { - static const String _darkModeKey = 'darkMode'; - static const String _languageCodeKey = 'languageCode'; - static const String _defaultCurrencyKey = 'defaultCurrency'; // Комментарий: Новый ключ для хранения валюты по умолчанию - late final Box _settingsBox; + final IUserRepository _userRepository; // Комментарий: Зависимость от репозитория пользователя - SettingsCubit() : super(const SettingsInitial()) { - _settingsBox = Hive.box('settings'); + SettingsCubit(this._userRepository) : super(const SettingsInitial()) { // Комментарий: Принимаем репозиторий в конструкторе _loadSettings(); } - void _loadSettings() { - final isDarkMode = _settingsBox.get(_darkModeKey, defaultValue: false); - final languageCode = _settingsBox.get(_languageCodeKey, defaultValue: 'ru'); - // Комментарий: Загружаем валюту по умолчанию из Hive. Если ее нет, используем 'RUB'. - final defaultCurrency = _settingsBox.get(_defaultCurrencyKey, defaultValue: 'RUB'); - emit(state.copyWith(isDarkMode: isDarkMode, languageCode: languageCode, defaultCurrency: defaultCurrency)); + void _loadSettings() async { + // Комментарий: Получаем всех пользователей. В реальном приложении здесь будет логика получения текущего залогиненного пользователя. + final users = await _userRepository.getAll(); + User currentUser; + if (users.isEmpty) { + // Комментарий: Если пользователей нет, создаем нового с дефолтными настройками. + currentUser = User(name: 'Default User', email: 'default@example.com'); + await _userRepository.add(currentUser); + } else { + // Комментарий: Используем первого пользователя как текущего. + currentUser = users.first; + } + + emit(state.copyWith( + isDarkMode: currentUser.isDarkMode, + languageCode: currentUser.languageCode, + defaultCurrency: currentUser.defaultCurrency, + )); } Future setDarkMode(bool value) async { - await _settingsBox.put(_darkModeKey, value); - emit(state.copyWith(isDarkMode: value)); + final users = await _userRepository.getAll(); + if (users.isNotEmpty) { + final currentUser = users.first; + final updatedUser = currentUser.copyWith(isDarkMode: value); + await _userRepository.update(updatedUser); + emit(state.copyWith(isDarkMode: value)); + } } Future setLanguageCode(String code) async { - await _settingsBox.put(_languageCodeKey, code); - emit(state.copyWith(languageCode: code)); + final users = await _userRepository.getAll(); + if (users.isNotEmpty) { + final currentUser = users.first; + final updatedUser = currentUser.copyWith(languageCode: code); + await _userRepository.update(updatedUser); + emit(state.copyWith(languageCode: code)); + } } - // Комментарий: Новый метод для установки валюты по умолчанию. Future setDefaultCurrency(String currencyCode) async { - await _settingsBox.put(_defaultCurrencyKey, currencyCode); - emit(state.copyWith(defaultCurrency: currencyCode)); + final users = await _userRepository.getAll(); + if (users.isNotEmpty) { + final currentUser = users.first; + final updatedUser = currentUser.copyWith(defaultCurrency: currencyCode); + await _userRepository.update(updatedUser); + emit(state.copyWith(defaultCurrency: currencyCode)); + } } void toggleTheme() { diff --git a/lib/models/user.dart b/lib/models/user.dart index 1fbd545..ebcc600 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -22,20 +22,28 @@ class User { final String email; @HiveField(3) // Поле 3 в Hive - язык пользователя - final String language; + final String languageCode; // Переименовано с 'language' на 'languageCode' @HiveField(4) /// Дата и время последнего обновления объекта final DateTime updatedAt; + @HiveField(5) // Новое поле для режима темы (светлая/темная) + final bool isDarkMode; + + @HiveField(6) // Новое поле для валюты по умолчанию + final String defaultCurrency; + /// Конструктор пользователя /// id генерируется автоматически, если не передан User({ String? id, // Опциональный параметр - если null, сгенерируется автоматически required this.name, // Обязательный параметр required this.email, // Обязательный параметр - this.language = 'ru', // Язык по умолчанию - русский + this.languageCode = 'ru', // Язык по умолчанию - русский DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное + this.isDarkMode = false, // По умолчанию светлая тема + this.defaultCurrency = 'RUB', // Валюта по умолчанию - RUB }) : id = id ?? IdGenerator.generateId(), // Если id не передан, генерируем новый updatedAt = updatedAt ?? DateTime.now(); // Устанавливаем текущее время по умолчанию @@ -45,8 +53,10 @@ class User { 'id': id, 'name': name, 'email': email, - 'language': language, + 'languageCode': languageCode, // Обновлено имя поля 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map + 'isDarkMode': isDarkMode, + 'defaultCurrency': defaultCurrency, }; } @@ -56,15 +66,17 @@ class User { id: map['id'], name: map['name'], email: map['email'], - language: map['language'] ?? 'ru', // Устанавливаем русский по умолчанию, если язык не указан + languageCode: map['languageCode'] ?? 'ru', // Обновлено имя поля updatedAt: DateTime.parse(map['updatedAt']), // Добавлено updatedAt при создании из Map + isDarkMode: map['isDarkMode'] ?? false, + defaultCurrency: map['defaultCurrency'] ?? 'RUB', ); } /// Переопределяем toString для удобного отображения в логах @override String toString() { - return 'User(id: $id, name: $name, email: $email)'; + return 'User(id: $id, name: $name, email: $email, languageCode: $languageCode, isDarkMode: $isDarkMode, defaultCurrency: $defaultCurrency)'; } /// Метод для создания копии объекта с возможностью изменения полей @@ -72,14 +84,18 @@ class User { String? id, String? name, String? email, - String? language, + String? languageCode, // Обновлено имя поля + bool? isDarkMode, + String? defaultCurrency, }) { return User( id: id ?? this.id, name: name ?? this.name, email: email ?? this.email, - language: language ?? this.language, + languageCode: languageCode ?? this.languageCode, // Обновлено имя поля updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании + isDarkMode: isDarkMode ?? this.isDarkMode, + defaultCurrency: defaultCurrency ?? this.defaultCurrency, ); } } diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index 4697b89..e1a5fe6 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -20,15 +20,17 @@ class UserAdapter extends TypeAdapter { id: fields[0] as String?, name: fields[1] as String, email: fields[2] as String, - language: fields[3] == null ? 'ru' : fields[3] as String, + languageCode: fields[3] == null ? 'ru' : fields[3] as String, updatedAt: fields[4] as DateTime?, + isDarkMode: fields[5] == null ? false : fields[5] as bool, + defaultCurrency: fields[6] == null ? 'RUB' : fields[6] as String, ); } @override void write(BinaryWriter writer, User obj) { writer - ..writeByte(5) + ..writeByte(7) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -36,9 +38,13 @@ class UserAdapter extends TypeAdapter { ..writeByte(2) ..write(obj.email) ..writeByte(3) - ..write(obj.language) + ..write(obj.languageCode) ..writeByte(4) - ..write(obj.updatedAt); + ..write(obj.updatedAt) + ..writeByte(5) + ..write(obj.isDarkMode) + ..writeByte(6) + ..write(obj.defaultCurrency); } @override diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart deleted file mode 100644 index 9fff468..0000000 --- a/lib/services/settings_service.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hive_ce/hive.dart'; - -class SettingsService extends ChangeNotifier { - static const String _darkModeKey = 'darkMode'; - static const String _languageCodeKey = 'languageCode'; // Новый ключ для языка - late final Box _settingsBox; - - SettingsService() { - _settingsBox = Hive.box('settings'); - } - - bool get isDarkMode => _settingsBox.get(_darkModeKey, defaultValue: false); - - Future setDarkMode(bool value) async { - await _settingsBox.put(_darkModeKey, value); - notifyListeners(); - } - - Future toggleTheme() async { - await setDarkMode(!isDarkMode); - } - - // Новый геттер для получения кода языка - String get languageCode => _settingsBox.get(_languageCodeKey, defaultValue: 'ru'); - - // Новый метод для установки кода языка - Future setLanguageCode(String code) async { - await _settingsBox.put(_languageCodeKey, code); - notifyListeners(); - } -} - - diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart index bb4691c..d06f725 100644 --- a/lib/services/user_service.dart +++ b/lib/services/user_service.dart @@ -6,21 +6,22 @@ import 'package:budget_app/utils/tag_utils.dart'; import 'package:budget_app/utils/transaction_utils.dart'; import 'package:flutter/foundation.dart'; import 'package:hive_ce/hive.dart'; -import '/models/user.dart'; + import '/data/repositories/interfaces/iuser_repository.dart'; +import '/models/user.dart'; /// Сервис для управления текущим пользователем приложения /// Использует ChangeNotifier для уведомления UI об изменениях class UserService extends ChangeNotifier { // Ключ для сохранения ID текущего пользователя в настройках static const String _currentUserKey = 'currentUserId'; - + late final Box _settingsBox; // Box для хранения настроек final IUserRepository _userRepository; final ICategoryRepository _categoryRepository; final ITagRepository _tagRepository; final ITransactionRepository _transactionRepository; - + User? _currentUser; // Текущий активный пользователь /// Геттер для получения текущего пользователя @@ -30,7 +31,12 @@ class UserService extends ChangeNotifier { bool get hasCurrentUser => _currentUser != null; /// Конструктор сервиса - UserService(this._userRepository, this._categoryRepository, this._tagRepository, this._transactionRepository) { + UserService( + this._userRepository, + this._categoryRepository, + this._tagRepository, + this._transactionRepository, + ) { _settingsBox = Hive.box('settings'); _loadCurrentUser(); // Загружаем сохраненного пользователя при запуске } @@ -61,13 +67,21 @@ class UserService extends ChangeNotifier { /// Создание нового пользователя и установка его как текущего Future createAndSetUser(String name, String email) async { - final user = User(name: name, email: email, language: 'ru'); // Устанавливаем язык по умолчанию + final user = User( + name: name, + email: email, + languageCode: 'ru', + ); // Устанавливаем язык по умолчанию await _userRepository.add(user); // Добавляем начальные категории, теги и транзакции для нового пользователя - await _categoryRepository.addAll(CategoryUtils.getDefaultCategories(user.id)); + await _categoryRepository.addAll( + CategoryUtils.getDefaultCategories(user.id), + ); await _tagRepository.addAll(TagUtils.getDefaultTags(user.id)); - await _transactionRepository.addAll(TransactionUtils.getSampleTransactions(user.id)); + await _transactionRepository.addAll( + TransactionUtils.getSampleTransactions(user.id), + ); await setCurrentUser(user); } From 14a69200d3dbc564de99d28a38ae114c4d1dad6c Mon Sep 17 00:00:00 2001 From: Sanders Date: Sat, 28 Jun 2025 00:02:41 +0300 Subject: [PATCH 05/35] Small fixes --- GEMINI.md | 2 ++ lib/models/category.dart | 2 +- lib/pages/login/login_page.dart | 17 +++++++++-------- lib/theme/app_theme.dart | 4 ++-- pubspec.lock | 12 ++++++++++-- pubspec.yaml | 12 ++++++------ 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 8a52b28..d6f8464 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -6,6 +6,8 @@ - Комментируй в коде каждое изменение, которое ты делаешь, что бы мне было понятно и я учился на этом. - Комментарии и твои ответы должны быть на русском языке - When generating new Flutter code, please follow the existing coding style. +- Цветовая палитра черно-белая +- Все настройки цветов выноси в тему ## Coding Style: diff --git a/lib/models/category.dart b/lib/models/category.dart index 8fa89bc..8c0045c 100644 --- a/lib/models/category.dart +++ b/lib/models/category.dart @@ -57,7 +57,7 @@ class Category { return { 'id': id, 'name': name, - 'color': color.value, // Сохраняем только значение цвета + 'color': color.toARGB32(), // Сохраняем только значение цвета 'icon': icon.codePoint, 'isIncome': isIncome, 'userId': userId, // Добавляем userId в Map diff --git a/lib/pages/login/login_page.dart b/lib/pages/login/login_page.dart index d42642d..7dcb879 100644 --- a/lib/pages/login/login_page.dart +++ b/lib/pages/login/login_page.dart @@ -52,18 +52,19 @@ class _LoginPageState extends State { ), const SizedBox(height: 20), ElevatedButton( - onPressed: () { + onPressed: () async { // Комментарий: Делаем onPressed асинхронным if (_formKey.currentState!.validate()) { final userService = GetIt.instance(); - userService.createAndSetUser( + final authBloc = context.read(); // Комментарий: Получаем AuthBloc до асинхронной операции + await userService.createAndSetUser( // Комментарий: Используем await _nameController.text, _emailController.text, - ).then((_) { - final user = userService.currentUser; - if (user != null) { - context.read().add(AuthLoggedIn(user: user)); - } - }); + ); + if (!mounted) return; // Комментарий: Проверяем, что виджет все еще в дереве виджетов после await + final user = userService.currentUser; + if (user != null) { + authBloc.add(AuthLoggedIn(user: user)); // Комментарий: Используем полученный AuthBloc + } } }, child: Text(localizations.loginButtonText), // Локализованный текст diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 087b776..dedba84 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -11,7 +11,7 @@ class AppTheme { primary: Colors.black, // Основной цвет - черный secondary: Colors.grey[800]!, // Вторичный цвет - темно-серый surface: Colors.white, // Фон поверхностей - background: Colors.grey[50]!, // Общий фон - очень светлый серый + ), appBarTheme: const AppBarTheme( backgroundColor: Colors.white, // Белый фон AppBar @@ -49,7 +49,7 @@ class AppTheme { primary: Colors.white, // Основной цвет - белый secondary: Colors.grey[300]!, // Вторичный цвет - светлый серый surface: Colors.grey[900]!, // Фон поверхностей - background: Colors.black, // Общий фон - черный + ), appBarTheme: AppBarTheme( backgroundColor: Colors.grey[900]!, // Темно-серый фон AppBar diff --git a/pubspec.lock b/pubspec.lock index fb48554..d497d8d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -621,6 +621,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" stack_trace: dependency: transitive description: @@ -689,10 +697,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.5.1" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fa544d3..b530b54 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,11 +34,11 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - hive_ce: ^2.11.3 - hive_ce_flutter: ^2.3.1 - get_it: ^7.2.0 + hive_ce: ^2.11.3 # Обновлено до последней версии + hive_ce_flutter: ^2.3.1 # Обновлено до последней версии + get_it: ^7.7.0 # Обновлено до последней версии path_provider: ^2.0.15 - uuid: ^3.0.7 + uuid: ^4.0.0 # Обновлено до последней версии logger: ^2.5.0 flutter_bloc: ^9.1.1 equatable: ^2.0.7 @@ -49,11 +49,11 @@ dependencies: dev_dependencies: - hive_ce_generator: ^1.9.2 + hive_ce_generator: ^1.9.2 # Возвращено к совместимой версии build_runner: ^2.4.0 flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^5.0.0 # Возвращено к совместимой версии # For information on the generic Dart part of this file, see the From 145e57f4103024d00cae9b6cef3ee412627e9806 Mon Sep 17 00:00:00 2001 From: Sanders Date: Sat, 28 Jun 2025 00:49:21 +0300 Subject: [PATCH 06/35] Small fixes --- GEMINI.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/GEMINI.md b/GEMINI.md index d6f8464..79cf02a 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -8,6 +8,8 @@ - When generating new Flutter code, please follow the existing coding style. - Цветовая палитра черно-белая - Все настройки цветов выноси в тему +- Все элементы должны разбиваться на мелкие и иметь логичную структуру по попкам +- Весь текст должен иметь локализацию ## Coding Style: From f2f9f7871f549ca4cb295e53f3cf386402bb257c Mon Sep 17 00:00:00 2001 From: Sanders Date: Sat, 28 Jun 2025 13:47:47 +0300 Subject: [PATCH 07/35] Add summary --- lib/injection_container.dart | 8 +- lib/l10n/app_en.arb | 3 +- lib/l10n/app_localizations.dart | 6 ++ lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_ru.dart | 3 + lib/l10n/app_ru.arb | 3 +- lib/main.dart | 2 +- lib/pages/{ => home}/home_page.dart | 75 +++++++++++------- lib/pages/home/widgets/summary_widget.dart | 88 ++++++++++++++++++++++ 9 files changed, 158 insertions(+), 33 deletions(-) rename lib/pages/{ => home}/home_page.dart (77%) create mode 100644 lib/pages/home/widgets/summary_widget.dart diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 49b8f82..1b2991f 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -20,9 +20,6 @@ Future initDependencies() async { // Инициализация Hive await HiveService.init(); - // Регистрация сервисов - getIt.registerSingleton(SettingsCubit(getIt())); // Регистрируем Cubit и передаем IUserRepository - // Регистрация репозиториев getIt.registerSingleton( HiveCategoryRepository(HiveService.categories), @@ -43,6 +40,11 @@ Future initDependencies() async { UserService(getIt(), getIt(), getIt(), getIt()), ); + // Регистрация сервисов + getIt.registerSingleton( + SettingsCubit(getIt()), + ); // Регистрируем Cubit и передаем IUserRepository + // Blocs getIt.registerFactory(() => AuthBloc(userService: getIt())); getIt.registerFactory( diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7e1d83a..073f262 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -22,5 +22,6 @@ "englishLanguage": "English", "defaultUser": "Default User", "currencySetting": "Default Currency", - "currencyDescription": "Set the default currency for transactions" + "currencyDescription": "Set the default currency for transactions", + "transactionsHistoryTitle": "Transactions History" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8a657e1..62d3ef0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -235,6 +235,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Set the default currency for transactions'** String get currencyDescription; + + /// No description provided for @transactionsHistoryTitle. + /// + /// In en, this message translates to: + /// **'Transactions History'** + String get transactionsHistoryTitle; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6c2f947..13ba00e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -78,4 +78,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get currencyDescription => 'Set the default currency for transactions'; + + @override + String get transactionsHistoryTitle => 'Transactions History'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 919f29e..3e4613c 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -79,4 +79,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get currencyDescription => 'Установить валюту по умолчанию для транзакций'; + + @override + String get transactionsHistoryTitle => 'История транзакций'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 70f682b..5384134 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -22,5 +22,6 @@ "englishLanguage": "Английский", "defaultUser": "Пользователь по умолчанию", "currencySetting": "Валюта по умолчанию", - "currencyDescription": "Установить валюту по умолчанию для транзакций" + "currencyDescription": "Установить валюту по умолчанию для транзакций", + "transactionsHistoryTitle": "История транзакций" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 287ca66..cf00e19 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,7 +5,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; // Добав import '/l10n/app_localizations.dart'; import 'logic/auth/auth_bloc.dart'; import 'pages/login/login_page.dart'; -import 'package:budget_app/pages/home_page.dart'; +import 'package:budget_app/pages/home/home_page.dart'; import 'theme/app_theme.dart'; import 'injection_container.dart' as di; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit diff --git a/lib/pages/home_page.dart b/lib/pages/home/home_page.dart similarity index 77% rename from lib/pages/home_page.dart rename to lib/pages/home/home_page.dart index b8e4406..30d20e4 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -3,10 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import '/l10n/app_localizations.dart'; -import '../logic/auth/auth_bloc.dart'; -import '../logic/transaction/transaction_bloc.dart'; -import 'reports_page.dart'; // Импортируем новую страницу отчетов -import 'settings_page.dart'; +import '../../logic/auth/auth_bloc.dart'; +import '../../logic/transaction/transaction_bloc.dart'; +import '../home/widgets/summary_widget.dart'; // Импортируем новый виджет сводки +import '../reports_page.dart'; // Импортируем новую страницу отчетов +import '../settings_page.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -114,32 +115,52 @@ class TransactionsPage extends StatelessWidget { return BlocBuilder( builder: (context, state) { if (state is TransactionLoading) { - return Center(child: CircularProgressIndicator()); + return const Center(child: CircularProgressIndicator()); } else if (state is TransactionLoaded) { - if (state.transactions.isEmpty) { - return Center( - child: Text(localizations.noTransactionsText), - ); // Локализованный текст - } - return ListView.builder( - itemCount: state.transactions.length, - itemBuilder: (context, index) { - final transaction = state.transactions[index]; - return ListTile( - leading: Icon( - transaction.category.icon, - color: transaction.category.color, + // Используем ListView для вертикальной прокрутки + return ListView( + children: [ + // Виджет сводки + const SummaryWidget(), + // Заголовок для списка транзакций + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + localizations.transactionsHistoryTitle, + style: Theme.of(context).textTheme.titleLarge, ), - title: Text(transaction.vendor), - subtitle: Text(transaction.category.name), - trailing: Text( - '${transaction.amount.toStringAsFixed(2)} ${transaction.currency}', - style: TextStyle( - color: transaction.isIncome ? Colors.green : Colors.red, - ), + ), + // Список транзакций + if (state.transactions.isEmpty) + Center( + child: Text(localizations.noTransactionsText), + ) // Локализованный текст + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: state.transactions.length, + itemBuilder: (context, index) { + final transaction = state.transactions[index]; + return ListTile( + leading: Icon( + transaction.category.icon, + color: transaction.category.color, + ), + title: Text(transaction.vendor), + subtitle: Text(transaction.category.name), + trailing: Text( + '${transaction.amount.toStringAsFixed(2)} ${transaction.currency}', + style: TextStyle( + color: transaction.isIncome + ? Colors.green + : Colors.red, + ), + ), + ); + }, ), - ); - }, + ], ); } else if (state is TransactionError) { return Center( diff --git a/lib/pages/home/widgets/summary_widget.dart b/lib/pages/home/widgets/summary_widget.dart new file mode 100644 index 0000000..d74ea36 --- /dev/null +++ b/lib/pages/home/widgets/summary_widget.dart @@ -0,0 +1,88 @@ + +import 'package:flutter/material.dart'; + +// Виджет для отображения сводки по доходам и расходам. +class SummaryWidget extends StatelessWidget { + const SummaryWidget({super.key}); + + @override + Widget build(BuildContext context) { + // Используем Card для придания виджету тени и скругленных углов. + return Card( + elevation: 4.0, + margin: const EdgeInsets.all(16.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + // Используем цвет из темы приложения. + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Заголовок секции. + Text( + 'Общие траты', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8.0), + // Отображение общей суммы. + Text( + '12345.67 ₽', // TODO: Заменить на реальные данные + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16.0), + // Разделение на доходы и расходы. + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Виджет для отображения доходов. + _buildIncomeExpense( + context, + 'Доходы', + '23456.78 ₽', // TODO: Заменить на реальные данные + Colors.green, + ), + // Виджет для отображения расходов. + _buildIncomeExpense( + context, + 'Расходы', + '11111.11 ₽', // TODO: Заменить на реальные данные + Colors.red, + ), + ], + ), + ], + ), + ), + ); + } + + // Вспомогательный метод для создания виджета дохода/расхода. + Widget _buildIncomeExpense( + BuildContext context, String title, String amount, Color color) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4.0), + Text( + amount, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } +} From e0cb2d1e2c7db5691d0bcf47260505049638b1f2 Mon Sep 17 00:00:00 2001 From: Sanders Date: Sun, 29 Jun 2025 13:07:39 +0300 Subject: [PATCH 08/35] Fix summary --- GEMINI.md | 2 +- lib/l10n/app_en.arb | 5 +- lib/l10n/app_localizations.dart | 18 +++ lib/l10n/app_localizations_en.dart | 9 ++ lib/l10n/app_localizations_ru.dart | 9 ++ lib/l10n/app_ru.arb | 5 +- lib/pages/home/home_page.dart | 27 ++++- lib/pages/home/widgets/summary_widget.dart | 130 +++++++++++++++------ lib/theme/app_theme.dart | 17 ++- pubspec.lock | 8 ++ pubspec.yaml | 1 + 11 files changed, 189 insertions(+), 42 deletions(-) diff --git a/GEMINI.md b/GEMINI.md index 79cf02a..d8f8e9f 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -9,7 +9,7 @@ - Цветовая палитра черно-белая - Все настройки цветов выноси в тему - Все элементы должны разбиваться на мелкие и иметь логичную структуру по попкам -- Весь текст должен иметь локализацию +- Весь текст должен иметь локализацию чрезе flutter_localizations. Смотри папку l10n ## Coding Style: diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 073f262..4ccbd10 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -23,5 +23,8 @@ "defaultUser": "Default User", "currencySetting": "Default Currency", "currencyDescription": "Set the default currency for transactions", - "transactionsHistoryTitle": "Transactions History" + "transactionsHistoryTitle": "Transactions History", + "balance": "Balance", + "income": "Income", + "expense": "Expense" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 62d3ef0..c3bf6eb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -241,6 +241,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Transactions History'** String get transactionsHistoryTitle; + + /// No description provided for @balance. + /// + /// In en, this message translates to: + /// **'Balance'** + String get balance; + + /// No description provided for @income. + /// + /// In en, this message translates to: + /// **'Income'** + String get income; + + /// No description provided for @expense. + /// + /// In en, this message translates to: + /// **'Expense'** + String get expense; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 13ba00e..46a21c7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -81,4 +81,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get transactionsHistoryTitle => 'Transactions History'; + + @override + String get balance => 'Balance'; + + @override + String get income => 'Income'; + + @override + String get expense => 'Expense'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 3e4613c..aee81cf 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -82,4 +82,13 @@ class AppLocalizationsRu extends AppLocalizations { @override String get transactionsHistoryTitle => 'История транзакций'; + + @override + String get balance => 'Баланс'; + + @override + String get income => 'Доходы'; + + @override + String get expense => 'Расходы'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5384134..710a626 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -23,5 +23,8 @@ "defaultUser": "Пользователь по умолчанию", "currencySetting": "Валюта по умолчанию", "currencyDescription": "Установить валюту по умолчанию для транзакций", - "transactionsHistoryTitle": "История транзакций" + "transactionsHistoryTitle": "История транзакций", + "balance": "Баланс", + "income": "Доходы", + "expense": "Расходы" } \ No newline at end of file diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 30d20e4..d7dd992 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -8,6 +8,7 @@ import '../../logic/transaction/transaction_bloc.dart'; import '../home/widgets/summary_widget.dart'; // Импортируем новый виджет сводки import '../reports_page.dart'; // Импортируем новую страницу отчетов import '../settings_page.dart'; +import '../../services/user_service.dart'; // Добавлен импорт для UserService class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -121,7 +122,31 @@ class TransactionsPage extends StatelessWidget { return ListView( children: [ // Виджет сводки - const SummaryWidget(), + // Рассчитываем доходы, расходы и баланс из транзакций + Builder( + builder: (context) { + double income = 0.0; + double expense = 0.0; + for (var transaction in state.transactions) { + if (transaction.isIncome) { + income += transaction.amount; + } else { + expense += transaction.amount; + } + } + final balance = income - expense; + // Получаем текущего пользователя для определения валюты + final userService = GetIt.instance(); + final currencySymbol = userService.currentUser?.defaultCurrency ?? '₽'; // Валюта по умолчанию, если не найдена + + return SummaryWidget( + income: income, + expense: expense, + balance: balance, + currencySymbol: currencySymbol, // Передаем символ валюты + ); // Передаем реальные данные в SummaryWidget + }, + ), // Заголовок для списка транзакций Padding( padding: const EdgeInsets.all(16.0), diff --git a/lib/pages/home/widgets/summary_widget.dart b/lib/pages/home/widgets/summary_widget.dart index d74ea36..9d24f5b 100644 --- a/lib/pages/home/widgets/summary_widget.dart +++ b/lib/pages/home/widgets/summary_widget.dart @@ -1,60 +1,105 @@ +import 'package:animated_digit/animated_digit.dart'; +import 'package:budget_app/l10n/app_localizations.dart'; +import 'package:budget_app/theme/custom_colors.dart'; import 'package:flutter/material.dart'; // Виджет для отображения сводки по доходам и расходам. class SummaryWidget extends StatelessWidget { - const SummaryWidget({super.key}); + // Добавлены параметры для отображения реальных данных + final double income; + final double expense; + final double balance; + final String currencySymbol; // Добавлен параметр для символа валюты + + const SummaryWidget({ + super.key, + required this.income, + required this.expense, + required this.balance, + required this.currencySymbol, // Обязательный параметр + }); @override Widget build(BuildContext context) { + // Получаем локализацию и тему. + final localizations = AppLocalizations.of(context)!; + final customColors = Theme.of(context).extension()!; + final theme = Theme.of(context); + + // Определяем цвет для градиента в зависимости от темы. + final isDarkMode = theme.brightness == Brightness.dark; + final gradientColor = isDarkMode ? Colors.grey[800]! : Colors.grey[200]!; + // Используем Card для придания виджету тени и скругленных углов. return Card( elevation: 4.0, margin: const EdgeInsets.all(16.0), + clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), child: Container( padding: const EdgeInsets.all(16.0), decoration: BoxDecoration( - // Используем цвет из темы приложения. - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(12.0), + // Добавляем градиентный фон для более современного вида. + gradient: LinearGradient( + colors: [ + theme.colorScheme.surface, + gradientColor, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Заголовок секции. - Text( - 'Общие траты', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8.0), - // Отображение общей суммы. - Text( - '12345.67 ₽', // TODO: Заменить на реальные данные - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, + // Центральный блок с балансом. + Center( + child: Column( + children: [ + // Заголовок секции. + Text( + localizations.balance, + style: theme.textTheme.titleLarge, ), + const SizedBox(height: 8.0), + // Анимированное отображение общей суммы. + AnimatedDigitWidget( + value: balance, + fractionDigits: 2, + textStyle: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + suffix: ' $currencySymbol', + ), + ], + ), ), const SizedBox(height: 16.0), + + // Добавляем разделитель для лучшей структуры. + const Divider(height: 24.0), + // Разделение на доходы и расходы. Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ // Виджет для отображения доходов. _buildIncomeExpense( context, - 'Доходы', - '23456.78 ₽', // TODO: Заменить на реальные данные - Colors.green, + localizations.income, + income, + customColors.income!, // Используем цвет дохода из темы + Icons.arrow_upward, ), // Виджет для отображения расходов. _buildIncomeExpense( context, - 'Расходы', - '11111.11 ₽', // TODO: Заменить на реальные данные - Colors.red, + localizations.expense, + expense, + customColors.expense!, // Используем цвет расхода из темы + Icons.arrow_downward, ), ], ), @@ -66,21 +111,34 @@ class SummaryWidget extends StatelessWidget { // Вспомогательный метод для создания виджета дохода/расхода. Widget _buildIncomeExpense( - BuildContext context, String title, String amount, Color color) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + BuildContext context, String title, double amount, Color color, IconData icon) { + return Row( children: [ - Text( - title, - style: Theme.of(context).textTheme.titleMedium, + // Иконка для наглядности. + Icon( + icon, + color: color, + size: 28.0, ), - const SizedBox(height: 4.0), - Text( - amount, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: color, - fontWeight: FontWeight.bold, - ), + const SizedBox(width: 8.0), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4.0), + // Анимированное отображение суммы. + AnimatedDigitWidget( + value: amount, + fractionDigits: 2, + textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + ], ), ], ); diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index dedba84..fc8f2dd 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,4 +1,6 @@ + import 'package:flutter/material.dart'; +import 'custom_colors.dart'; class AppTheme { // Светлая тема в черно-белой гамме @@ -11,7 +13,6 @@ class AppTheme { primary: Colors.black, // Основной цвет - черный secondary: Colors.grey[800]!, // Вторичный цвет - темно-серый surface: Colors.white, // Фон поверхностей - ), appBarTheme: const AppBarTheme( backgroundColor: Colors.white, // Белый фон AppBar @@ -36,6 +37,12 @@ class AppTheme { ), ), ), + extensions: const >[ + CustomColors( + income: Colors.green, + expense: Colors.red, + ), + ], ); } @@ -49,7 +56,6 @@ class AppTheme { primary: Colors.white, // Основной цвет - белый secondary: Colors.grey[300]!, // Вторичный цвет - светлый серый surface: Colors.grey[900]!, // Фон поверхностей - ), appBarTheme: AppBarTheme( backgroundColor: Colors.grey[900]!, // Темно-серый фон AppBar @@ -75,6 +81,13 @@ class AppTheme { ), ), ), + extensions: const >[ + CustomColors( + income: Colors.greenAccent, + expense: Colors.redAccent, + ), + ], ); } } + diff --git a/pubspec.lock b/pubspec.lock index d497d8d..96fb53d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.5.2" + animated_digit: + dependency: "direct main" + description: + name: animated_digit + sha256: "22300a550b83e08ac4a0ef9c6fc7e800bbebc34978d997e6346da03d69fbb7a8" + url: "https://pub.dev" + source: hosted + version: "3.2.3" args: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b530b54..15a4100 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ environment: dependencies: flutter: sdk: flutter + animated_digit: ^3.2.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. From 42f8e20bf041ba6ddae7c1e2c9c31f4e3fe377e0 Mon Sep 17 00:00:00 2001 From: Sanders Date: Sun, 29 Jun 2025 23:03:17 +0300 Subject: [PATCH 09/35] Improves: Internationalization and data models - Adds new localization keys for transaction details. - Deletes obsolete localization files, switching to a single ARB file per language. - Adds `Equatable` to data models (`Category`, `Tag`, `TransactionRecord`, `User`) for improved state management and change detection. - Introduces the transaction creation dialog. - Adds development note to GEMINI.md Improves internationalization and data models - Adds new localization keys for transaction details, supporting enhanced user experience. - Migrates to single ARB file for each language, streamlining the localization process. - Implements Equatable in data models (Category, Tag, TransactionRecord, User) for improved state management and simplified change detection. - Introduces transaction creation dialog, simplifying the transaction creation process. - Adds development note to GEMINI.md --- GEMINI.md | 1 + devtools_options.yaml | 3 + lib/l10n/app_en.arb | 10 +- lib/l10n/app_localizations.dart | 296 ------------------ lib/l10n/app_localizations_en.dart | 93 ------ lib/l10n/app_localizations_ru.dart | 94 ------ lib/l10n/app_ru.arb | 10 +- lib/models/category.dart | 8 +- lib/models/tag.dart | 8 +- lib/models/transaction_record.dart | 8 +- lib/models/user.dart | 8 +- lib/pages/home/home_page.dart | 17 +- .../home/widgets/add_transaction_dialog.dart | 200 ++++++++++++ lib/theme/custom_colors.dart | 33 ++ 14 files changed, 293 insertions(+), 496 deletions(-) create mode 100644 devtools_options.yaml delete mode 100644 lib/l10n/app_localizations.dart delete mode 100644 lib/l10n/app_localizations_en.dart delete mode 100644 lib/l10n/app_localizations_ru.dart create mode 100644 lib/pages/home/widgets/add_transaction_dialog.dart create mode 100644 lib/theme/custom_colors.dart diff --git a/GEMINI.md b/GEMINI.md index d8f8e9f..93a84bf 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -10,6 +10,7 @@ - Все настройки цветов выноси в тему - Все элементы должны разбиваться на мелкие и иметь логичную структуру по попкам - Весь текст должен иметь локализацию чрезе flutter_localizations. Смотри папку l10n +- Разработка ведется под windows, ты можешь исопльзовать его консольные команды ## Coding Style: diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ccbd10..8e365e4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -26,5 +26,13 @@ "transactionsHistoryTitle": "Transactions History", "balance": "Balance", "income": "Income", - "expense": "Expense" + "expense": "Expense", + "amount": "Amount", + "vendor": "Vendor", + "category": "Category", + "date": "Date", + "requiredField": "Required field", + "invalidNumber": "Invalid number", + "cancel": "Cancel", + "save": "Save" } \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart deleted file mode 100644 index c3bf6eb..0000000 --- a/lib/l10n/app_localizations.dart +++ /dev/null @@ -1,296 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:intl/intl.dart' as intl; - -import 'app_localizations_en.dart'; -import 'app_localizations_ru.dart'; - -// ignore_for_file: type=lint - -/// Callers can lookup localized strings with an instance of AppLocalizations -/// returned by `AppLocalizations.of(context)`. -/// -/// Applications need to include `AppLocalizations.delegate()` in their app's -/// `localizationDelegates` list, and the locales they support in the app's -/// `supportedLocales` list. For example: -/// -/// ```dart -/// import 'l10n/app_localizations.dart'; -/// -/// return MaterialApp( -/// localizationsDelegates: AppLocalizations.localizationsDelegates, -/// supportedLocales: AppLocalizations.supportedLocales, -/// home: MyApplicationHome(), -/// ); -/// ``` -/// -/// ## Update pubspec.yaml -/// -/// Please make sure to update your pubspec.yaml to include the following -/// packages: -/// -/// ```yaml -/// dependencies: -/// # Internationalization support. -/// flutter_localizations: -/// sdk: flutter -/// intl: any # Use the pinned version from flutter_localizations -/// -/// # Rest of dependencies -/// ``` -/// -/// ## iOS Applications -/// -/// iOS applications define key application metadata, including supported -/// locales, in an Info.plist file that is built into the application bundle. -/// To configure the locales supported by your app, you’ll need to edit this -/// file. -/// -/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. -/// Then, in the Project Navigator, open the Info.plist file under the Runner -/// project’s Runner folder. -/// -/// Next, select the Information Property List item, select Add Item from the -/// Editor menu, then select Localizations from the pop-up menu. -/// -/// Select and expand the newly-created Localizations item then, for each -/// locale your application supports, add a new item and select the locale -/// you wish to add from the pop-up menu in the Value field. This list should -/// be consistent with the languages listed in the AppLocalizations.supportedLocales -/// property. -abstract class AppLocalizations { - AppLocalizations(String locale) - : localeName = intl.Intl.canonicalizedLocale(locale.toString()); - - final String localeName; - - static AppLocalizations? of(BuildContext context) { - return Localizations.of(context, AppLocalizations); - } - - static const LocalizationsDelegate delegate = - _AppLocalizationsDelegate(); - - /// A list of this localizations delegate along with the default localizations - /// delegates. - /// - /// Returns a list of localizations delegates containing this delegate along with - /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, - /// and GlobalWidgetsLocalizations.delegate. - /// - /// Additional delegates can be added by appending to this list in - /// MaterialApp. This list does not have to be used at all if a custom list - /// of delegates is preferred or required. - static const List> localizationsDelegates = - >[ - delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ]; - - /// A list of this localizations delegate's supported locales. - static const List supportedLocales = [ - Locale('en'), - Locale('ru'), - ]; - - /// No description provided for @appTitle. - /// - /// In en, this message translates to: - /// **'Budget App'** - String get appTitle; - - /// No description provided for @homePageTitle. - /// - /// In en, this message translates to: - /// **'Home'** - String get homePageTitle; - - /// No description provided for @reportsPageTitle. - /// - /// In en, this message translates to: - /// **'Reports'** - String get reportsPageTitle; - - /// No description provided for @settingsPageTitle. - /// - /// In en, this message translates to: - /// **'Settings'** - String get settingsPageTitle; - - /// No description provided for @addTransactionButton. - /// - /// In en, this message translates to: - /// **'Add new transaction'** - String get addTransactionButton; - - /// No description provided for @noTransactionsText. - /// - /// In en, this message translates to: - /// **'No transactions yet'** - String get noTransactionsText; - - /// No description provided for @loadingTransactionsText. - /// - /// In en, this message translates to: - /// **'Loading transactions...'** - String get loadingTransactionsText; - - /// No description provided for @transactionErrorText. - /// - /// In en, this message translates to: - /// **'Error: {message}'** - String transactionErrorText(Object message); - - /// No description provided for @loginPageTitle. - /// - /// In en, this message translates to: - /// **'Login'** - String get loginPageTitle; - - /// No description provided for @nameFieldLabel. - /// - /// In en, this message translates to: - /// **'Name'** - String get nameFieldLabel; - - /// No description provided for @emailFieldLabel. - /// - /// In en, this message translates to: - /// **'Email'** - String get emailFieldLabel; - - /// No description provided for @nameFieldEmptyError. - /// - /// In en, this message translates to: - /// **'Please enter your name'** - String get nameFieldEmptyError; - - /// No description provided for @emailFieldEmptyError. - /// - /// In en, this message translates to: - /// **'Please enter your email'** - String get emailFieldEmptyError; - - /// No description provided for @loginButtonText. - /// - /// In en, this message translates to: - /// **'Login / Register'** - String get loginButtonText; - - /// No description provided for @darkModeSetting. - /// - /// In en, this message translates to: - /// **'Dark Theme'** - String get darkModeSetting; - - /// No description provided for @darkModeDescription. - /// - /// In en, this message translates to: - /// **'Toggle between light and dark theme'** - String get darkModeDescription; - - /// No description provided for @languageSetting. - /// - /// In en, this message translates to: - /// **'Language'** - String get languageSetting; - - /// No description provided for @languageDescription. - /// - /// In en, this message translates to: - /// **'Change application language'** - String get languageDescription; - - /// No description provided for @russianLanguage. - /// - /// In en, this message translates to: - /// **'Russian'** - String get russianLanguage; - - /// No description provided for @englishLanguage. - /// - /// In en, this message translates to: - /// **'English'** - String get englishLanguage; - - /// No description provided for @defaultUser. - /// - /// In en, this message translates to: - /// **'Default User'** - String get defaultUser; - - /// No description provided for @currencySetting. - /// - /// In en, this message translates to: - /// **'Default Currency'** - String get currencySetting; - - /// No description provided for @currencyDescription. - /// - /// In en, this message translates to: - /// **'Set the default currency for transactions'** - String get currencyDescription; - - /// No description provided for @transactionsHistoryTitle. - /// - /// In en, this message translates to: - /// **'Transactions History'** - String get transactionsHistoryTitle; - - /// No description provided for @balance. - /// - /// In en, this message translates to: - /// **'Balance'** - String get balance; - - /// No description provided for @income. - /// - /// In en, this message translates to: - /// **'Income'** - String get income; - - /// No description provided for @expense. - /// - /// In en, this message translates to: - /// **'Expense'** - String get expense; -} - -class _AppLocalizationsDelegate - extends LocalizationsDelegate { - const _AppLocalizationsDelegate(); - - @override - Future load(Locale locale) { - return SynchronousFuture(lookupAppLocalizations(locale)); - } - - @override - bool isSupported(Locale locale) => - ['en', 'ru'].contains(locale.languageCode); - - @override - bool shouldReload(_AppLocalizationsDelegate old) => false; -} - -AppLocalizations lookupAppLocalizations(Locale locale) { - // Lookup logic when only language code is specified. - switch (locale.languageCode) { - case 'en': - return AppLocalizationsEn(); - case 'ru': - return AppLocalizationsRu(); - } - - throw FlutterError( - 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' - 'an issue with the localizations generation tool. Please file an issue ' - 'on GitHub with a reproducible sample app and the gen-l10n configuration ' - 'that was used.', - ); -} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart deleted file mode 100644 index 46a21c7..0000000 --- a/lib/l10n/app_localizations_en.dart +++ /dev/null @@ -1,93 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for English (`en`). -class AppLocalizationsEn extends AppLocalizations { - AppLocalizationsEn([String locale = 'en']) : super(locale); - - @override - String get appTitle => 'Budget App'; - - @override - String get homePageTitle => 'Home'; - - @override - String get reportsPageTitle => 'Reports'; - - @override - String get settingsPageTitle => 'Settings'; - - @override - String get addTransactionButton => 'Add new transaction'; - - @override - String get noTransactionsText => 'No transactions yet'; - - @override - String get loadingTransactionsText => 'Loading transactions...'; - - @override - String transactionErrorText(Object message) { - return 'Error: $message'; - } - - @override - String get loginPageTitle => 'Login'; - - @override - String get nameFieldLabel => 'Name'; - - @override - String get emailFieldLabel => 'Email'; - - @override - String get nameFieldEmptyError => 'Please enter your name'; - - @override - String get emailFieldEmptyError => 'Please enter your email'; - - @override - String get loginButtonText => 'Login / Register'; - - @override - String get darkModeSetting => 'Dark Theme'; - - @override - String get darkModeDescription => 'Toggle between light and dark theme'; - - @override - String get languageSetting => 'Language'; - - @override - String get languageDescription => 'Change application language'; - - @override - String get russianLanguage => 'Russian'; - - @override - String get englishLanguage => 'English'; - - @override - String get defaultUser => 'Default User'; - - @override - String get currencySetting => 'Default Currency'; - - @override - String get currencyDescription => 'Set the default currency for transactions'; - - @override - String get transactionsHistoryTitle => 'Transactions History'; - - @override - String get balance => 'Balance'; - - @override - String get income => 'Income'; - - @override - String get expense => 'Expense'; -} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart deleted file mode 100644 index aee81cf..0000000 --- a/lib/l10n/app_localizations_ru.dart +++ /dev/null @@ -1,94 +0,0 @@ -// ignore: unused_import -import 'package:intl/intl.dart' as intl; -import 'app_localizations.dart'; - -// ignore_for_file: type=lint - -/// The translations for Russian (`ru`). -class AppLocalizationsRu extends AppLocalizations { - AppLocalizationsRu([String locale = 'ru']) : super(locale); - - @override - String get appTitle => 'Бюджетное приложение'; - - @override - String get homePageTitle => 'Главная'; - - @override - String get reportsPageTitle => 'Отчеты'; - - @override - String get settingsPageTitle => 'Настройки'; - - @override - String get addTransactionButton => 'Добавить новую транзакцию'; - - @override - String get noTransactionsText => 'Нет транзакций'; - - @override - String get loadingTransactionsText => 'Загрузка транзакций...'; - - @override - String transactionErrorText(Object message) { - return 'Ошибка: $message'; - } - - @override - String get loginPageTitle => 'Вход'; - - @override - String get nameFieldLabel => 'Имя'; - - @override - String get emailFieldLabel => 'Email'; - - @override - String get nameFieldEmptyError => 'Пожалуйста, введите имя'; - - @override - String get emailFieldEmptyError => 'Пожалуйста, введите email'; - - @override - String get loginButtonText => 'Войти / Зарегистрироваться'; - - @override - String get darkModeSetting => 'Темная тема'; - - @override - String get darkModeDescription => 'Переключить между светлой и темной темой'; - - @override - String get languageSetting => 'Язык'; - - @override - String get languageDescription => 'Изменить язык приложения'; - - @override - String get russianLanguage => 'Русский'; - - @override - String get englishLanguage => 'Английский'; - - @override - String get defaultUser => 'Пользователь по умолчанию'; - - @override - String get currencySetting => 'Валюта по умолчанию'; - - @override - String get currencyDescription => - 'Установить валюту по умолчанию для транзакций'; - - @override - String get transactionsHistoryTitle => 'История транзакций'; - - @override - String get balance => 'Баланс'; - - @override - String get income => 'Доходы'; - - @override - String get expense => 'Расходы'; -} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 710a626..5edc8c2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -26,5 +26,13 @@ "transactionsHistoryTitle": "История транзакций", "balance": "Баланс", "income": "Доходы", - "expense": "Расходы" + "expense": "Расходы", + "amount": "Сумма", + "vendor": "Название", + "category": "Категория", + "date": "Дата", + "requiredField": "Обязательное поле", + "invalidNumber": "Неверный формат числа", + "cancel": "Отмена", + "save": "Сохранить" } \ No newline at end of file diff --git a/lib/models/category.dart b/lib/models/category.dart index 8c0045c..99c2817 100644 --- a/lib/models/category.dart +++ b/lib/models/category.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:hive_ce/hive.dart'; import '../utils/id_generator.dart'; @@ -8,7 +9,7 @@ part 'category.g.dart'; /// Модель категории для группировки транзакций /// Содержит основные параметры для визуализации и классификации -class Category { +class Category extends Equatable { /// Уникальный идентификатор категории @HiveField(0) final String id; @@ -97,4 +98,9 @@ class Category { updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании ); } + + // Используем Equatable для сравнения объектов по их свойствам. + // В данном случае, мы считаем категории уникальными по их 'id'. + @override + List get props => [id]; } diff --git a/lib/models/tag.dart b/lib/models/tag.dart index 51f743d..07eb45b 100644 --- a/lib/models/tag.dart +++ b/lib/models/tag.dart @@ -1,10 +1,11 @@ +import 'package:equatable/equatable.dart'; import 'package:hive_ce/hive.dart'; import '../../utils/id_generator.dart'; part 'tag.g.dart'; @HiveType(typeId: 1001) -class Tag { +class Tag extends Equatable { @HiveField(0) /// Уникальный идентификатор тега final String id; @@ -71,4 +72,9 @@ class Tag { updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании ); } + + // Используем Equatable для сравнения объектов по их свойствам. + // В данном случае, мы считаем теги уникальными по их 'id'. + @override + List get props => [id]; } diff --git a/lib/models/transaction_record.dart b/lib/models/transaction_record.dart index afcf3a1..53f670e 100644 --- a/lib/models/transaction_record.dart +++ b/lib/models/transaction_record.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:hive_ce/hive.dart'; import '../../utils/id_generator.dart'; @@ -9,7 +10,7 @@ part 'transaction_record.g.dart'; @HiveType(typeId: 1002) /// Модель записи о транзакции - основной элемент учета бюджета /// Содержит все детали финансовой операции -class TransactionRecord { +class TransactionRecord extends Equatable { /// Уникальный идентификатор транзакции @HiveField(0) final String id; @@ -121,4 +122,9 @@ class TransactionRecord { updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании ); } + + // Используем Equatable для сравнения объектов по их свойствам. + // В данном случае, мы считаем записи транзакций уникальными по их 'id'. + @override + List get props => [id]; } diff --git a/lib/models/user.dart b/lib/models/user.dart index ebcc600..b21bae0 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:hive_ce/hive.dart'; import '../utils/id_generator.dart'; @@ -8,7 +9,7 @@ part 'user.g.dart'; @HiveType(typeId: 1003) /// Модель пользователя приложения /// Содержит основную информацию для идентификации пользователя -class User { +class User extends Equatable { /// Уникальный идентификатор пользователя @HiveField(0) // Поле 0 в Hive - первое поле модели final String id; @@ -98,4 +99,9 @@ class User { defaultCurrency: defaultCurrency ?? this.defaultCurrency, ); } + + // Используем Equatable для сравнения объектов по их свойствам. + // В данном случае, мы считаем пользователей уникальными по их 'id'. + @override + List get props => [id]; } diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index d7dd992..29e7d5c 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -5,10 +5,11 @@ import 'package:get_it/get_it.dart'; import '/l10n/app_localizations.dart'; import '../../logic/auth/auth_bloc.dart'; import '../../logic/transaction/transaction_bloc.dart'; +import '../../services/user_service.dart'; import '../home/widgets/summary_widget.dart'; // Импортируем новый виджет сводки import '../reports_page.dart'; // Импортируем новую страницу отчетов import '../settings_page.dart'; -import '../../services/user_service.dart'; // Добавлен импорт для UserService +import 'widgets/add_transaction_dialog.dart'; // Добавлен импорт для UserService class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -70,11 +71,11 @@ class _HomePageState extends State { ), floatingActionButton: FloatingActionButton( onPressed: () { - // TODO: Добавить логику для добавления новой транзакции - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(localizations.addTransactionButton), - ), // Локализованный текст + showDialog( + context: context, + builder: (BuildContext context) { + return const AddTransactionDialog(); + }, ); }, child: const Icon(Icons.add), @@ -137,7 +138,9 @@ class TransactionsPage extends StatelessWidget { final balance = income - expense; // Получаем текущего пользователя для определения валюты final userService = GetIt.instance(); - final currencySymbol = userService.currentUser?.defaultCurrency ?? '₽'; // Валюта по умолчанию, если не найдена + final currencySymbol = + userService.currentUser?.defaultCurrency ?? + '₽'; // Валюта по умолчанию, если не найдена return SummaryWidget( income: income, diff --git a/lib/pages/home/widgets/add_transaction_dialog.dart b/lib/pages/home/widgets/add_transaction_dialog.dart new file mode 100644 index 0000000..7eb7dd4 --- /dev/null +++ b/lib/pages/home/widgets/add_transaction_dialog.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../../../logic/auth/auth_bloc.dart'; +import '../../../logic/transaction/transaction_bloc.dart'; +import '../../../models/category.dart'; +import '../../../models/transaction_record.dart'; +import '../../../services/user_service.dart'; +import '../../../utils/category_utils.dart'; + +class AddTransactionDialog extends StatefulWidget { + const AddTransactionDialog({super.key}); + + @override + State createState() => _AddTransactionDialogState(); +} + +class _AddTransactionDialogState extends State { + final _formKey = GlobalKey(); + final _amountController = TextEditingController(); + final _vendorController = TextEditingController(); + final _dateController = TextEditingController(); + + bool _isIncome = false; + Category? _selectedCategory; + DateTime _selectedDate = DateTime.now(); + + @override + void initState() { + super.initState(); + _dateController.text = DateFormat.yMd().format(_selectedDate); + } + + @override + void dispose() { + _amountController.dispose(); + _vendorController.dispose(); + _dateController.dispose(); + super.dispose(); + } + + Future _selectDate(BuildContext context) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2000), + lastDate: DateTime(2101), + ); + if (picked != null && picked != _selectedDate) { + setState(() { + _selectedDate = picked; + _dateController.text = DateFormat.yMd().format(_selectedDate); + }); + } + } + + void _submitForm() { + if (_formKey.currentState!.validate()) { + final amount = double.tryParse(_amountController.text); + if (amount == null || _selectedCategory == null) { + // Показать ошибку, если сумма некорректна или категория не выбрана + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.transactionErrorText('Invalid data'), + ), + ), + ); + return; + } + + final authState = context.read().state; + if (authState is! AuthAuthenticated) { + // Показать ошибку, если пользователь не аутентифицирован + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of( + context, + )!.transactionErrorText('User not authenticated'), + ), + ), + ); + return; + } + + final newTransaction = TransactionRecord( + amount: amount, + vendor: _vendorController.text, + category: _selectedCategory!, + dateTime: _selectedDate, + currency: + GetIt.instance().currentUser?.defaultCurrency ?? 'USD', + userId: authState.user.id, + ); + + context.read().add( + AddTransaction(transaction: newTransaction), + ); + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + final categories = CategoryUtils.getDefaultCategories( + (context.read().state as AuthAuthenticated).user.id, + ).where((c) => c.isIncome == _isIncome).toList(); + + return AlertDialog( + title: Text(localizations.addTransactionButton), + content: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SwitchListTile( + title: Text(localizations.income), + value: _isIncome, + onChanged: (bool value) { + setState(() { + _isIncome = value; + _selectedCategory = + null; // Сбрасываем категорию при смене типа + }); + }, + ), + TextFormField( + controller: _amountController, + decoration: InputDecoration(labelText: localizations.amount), + keyboardType: TextInputType.number, + validator: (value) { + if (value == null || value.isEmpty) { + return localizations.requiredField; + } + if (double.tryParse(value) == null) { + return localizations.invalidNumber; + } + return null; + }, + ), + TextFormField( + controller: _vendorController, + decoration: InputDecoration(labelText: localizations.vendor), + validator: (value) { + if (value == null || value.isEmpty) { + return localizations.requiredField; + } + return null; + }, + ), + DropdownButtonFormField( + value: _selectedCategory, + decoration: InputDecoration(labelText: localizations.category), + items: categories.map((Category category) { + return DropdownMenuItem( + value: category, + child: Text(category.name), + ); + }).toList(), + onChanged: (Category? newValue) { + setState(() { + _selectedCategory = newValue; + }); + }, + validator: (value) => + value == null ? localizations.requiredField : null, + ), + TextFormField( + controller: _dateController, + decoration: InputDecoration( + labelText: localizations.date, + suffixIcon: IconButton( + icon: const Icon(Icons.calendar_today), + onPressed: () => _selectDate(context), + ), + ), + readOnly: true, + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(localizations.cancel), + ), + ElevatedButton(onPressed: _submitForm, child: Text(localizations.save)), + ], + ); + } +} diff --git a/lib/theme/custom_colors.dart b/lib/theme/custom_colors.dart new file mode 100644 index 0000000..43d6bdd --- /dev/null +++ b/lib/theme/custom_colors.dart @@ -0,0 +1,33 @@ + +import 'package:flutter/material.dart'; + +// Расширение темы для добавления пользовательских цветов. +@immutable +class CustomColors extends ThemeExtension { + const CustomColors({ + required this.income, + required this.expense, + }); + + final Color? income; + final Color? expense; + + @override + CustomColors copyWith({Color? income, Color? expense}) { + return CustomColors( + income: income ?? this.income, + expense: expense ?? this.expense, + ); + } + + @override + CustomColors lerp(ThemeExtension? other, double t) { + if (other is! CustomColors) { + return this; + } + return CustomColors( + income: Color.lerp(income, other.income, t), + expense: Color.lerp(expense, other.expense, t), + ); + } +} From 82af8ac11a0e22c4ecfce49bdcad0b535ad5a86f Mon Sep 17 00:00:00 2001 From: Sanders Date: Sun, 29 Jun 2025 23:04:28 +0300 Subject: [PATCH 10/35] Adds localization support with English and Russian Initializes internationalization (i18n) for the application. Introduces `AppLocalizations` to manage localized strings, along with English and Russian translations. Configures Flutter to use the localization delegates and supported locales. This allows the app to display text in the user's preferred language. --- .vscode/settings.json | 3 + lib/l10n/app_localizations.dart | 344 +++++++++++++++++++++++++++++ lib/l10n/app_localizations_en.dart | 117 ++++++++++ lib/l10n/app_localizations_ru.dart | 118 ++++++++++ lib/main.dart | 4 +- 5 files changed, 584 insertions(+), 2 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 lib/l10n/app_localizations.dart create mode 100644 lib/l10n/app_localizations_en.dart create mode 100644 lib/l10n/app_localizations_ru.dart diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..dcdfdac --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dart.flutterSdkPath": "C:\\Sanders\\Flutter\\flutter_windows_3.32.2-stable\\flutter" +} \ No newline at end of file diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..3e11e1d --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,344 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_ru.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('ru'), + ]; + + /// No description provided for @appTitle. + /// + /// In en, this message translates to: + /// **'Budget App'** + String get appTitle; + + /// No description provided for @homePageTitle. + /// + /// In en, this message translates to: + /// **'Home'** + String get homePageTitle; + + /// No description provided for @reportsPageTitle. + /// + /// In en, this message translates to: + /// **'Reports'** + String get reportsPageTitle; + + /// No description provided for @settingsPageTitle. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settingsPageTitle; + + /// No description provided for @addTransactionButton. + /// + /// In en, this message translates to: + /// **'Add new transaction'** + String get addTransactionButton; + + /// No description provided for @noTransactionsText. + /// + /// In en, this message translates to: + /// **'No transactions yet'** + String get noTransactionsText; + + /// No description provided for @loadingTransactionsText. + /// + /// In en, this message translates to: + /// **'Loading transactions...'** + String get loadingTransactionsText; + + /// No description provided for @transactionErrorText. + /// + /// In en, this message translates to: + /// **'Error: {message}'** + String transactionErrorText(Object message); + + /// No description provided for @loginPageTitle. + /// + /// In en, this message translates to: + /// **'Login'** + String get loginPageTitle; + + /// No description provided for @nameFieldLabel. + /// + /// In en, this message translates to: + /// **'Name'** + String get nameFieldLabel; + + /// No description provided for @emailFieldLabel. + /// + /// In en, this message translates to: + /// **'Email'** + String get emailFieldLabel; + + /// No description provided for @nameFieldEmptyError. + /// + /// In en, this message translates to: + /// **'Please enter your name'** + String get nameFieldEmptyError; + + /// No description provided for @emailFieldEmptyError. + /// + /// In en, this message translates to: + /// **'Please enter your email'** + String get emailFieldEmptyError; + + /// No description provided for @loginButtonText. + /// + /// In en, this message translates to: + /// **'Login / Register'** + String get loginButtonText; + + /// No description provided for @darkModeSetting. + /// + /// In en, this message translates to: + /// **'Dark Theme'** + String get darkModeSetting; + + /// No description provided for @darkModeDescription. + /// + /// In en, this message translates to: + /// **'Toggle between light and dark theme'** + String get darkModeDescription; + + /// No description provided for @languageSetting. + /// + /// In en, this message translates to: + /// **'Language'** + String get languageSetting; + + /// No description provided for @languageDescription. + /// + /// In en, this message translates to: + /// **'Change application language'** + String get languageDescription; + + /// No description provided for @russianLanguage. + /// + /// In en, this message translates to: + /// **'Russian'** + String get russianLanguage; + + /// No description provided for @englishLanguage. + /// + /// In en, this message translates to: + /// **'English'** + String get englishLanguage; + + /// No description provided for @defaultUser. + /// + /// In en, this message translates to: + /// **'Default User'** + String get defaultUser; + + /// No description provided for @currencySetting. + /// + /// In en, this message translates to: + /// **'Default Currency'** + String get currencySetting; + + /// No description provided for @currencyDescription. + /// + /// In en, this message translates to: + /// **'Set the default currency for transactions'** + String get currencyDescription; + + /// No description provided for @transactionsHistoryTitle. + /// + /// In en, this message translates to: + /// **'Transactions History'** + String get transactionsHistoryTitle; + + /// No description provided for @balance. + /// + /// In en, this message translates to: + /// **'Balance'** + String get balance; + + /// No description provided for @income. + /// + /// In en, this message translates to: + /// **'Income'** + String get income; + + /// No description provided for @expense. + /// + /// In en, this message translates to: + /// **'Expense'** + String get expense; + + /// No description provided for @amount. + /// + /// In en, this message translates to: + /// **'Amount'** + String get amount; + + /// No description provided for @vendor. + /// + /// In en, this message translates to: + /// **'Vendor'** + String get vendor; + + /// No description provided for @category. + /// + /// In en, this message translates to: + /// **'Category'** + String get category; + + /// No description provided for @date. + /// + /// In en, this message translates to: + /// **'Date'** + String get date; + + /// No description provided for @requiredField. + /// + /// In en, this message translates to: + /// **'Required field'** + String get requiredField; + + /// No description provided for @invalidNumber. + /// + /// In en, this message translates to: + /// **'Invalid number'** + String get invalidNumber; + + /// No description provided for @cancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// No description provided for @save. + /// + /// In en, this message translates to: + /// **'Save'** + String get save; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'ru'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'ru': + return AppLocalizationsRu(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..9e87354 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,117 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appTitle => 'Budget App'; + + @override + String get homePageTitle => 'Home'; + + @override + String get reportsPageTitle => 'Reports'; + + @override + String get settingsPageTitle => 'Settings'; + + @override + String get addTransactionButton => 'Add new transaction'; + + @override + String get noTransactionsText => 'No transactions yet'; + + @override + String get loadingTransactionsText => 'Loading transactions...'; + + @override + String transactionErrorText(Object message) { + return 'Error: $message'; + } + + @override + String get loginPageTitle => 'Login'; + + @override + String get nameFieldLabel => 'Name'; + + @override + String get emailFieldLabel => 'Email'; + + @override + String get nameFieldEmptyError => 'Please enter your name'; + + @override + String get emailFieldEmptyError => 'Please enter your email'; + + @override + String get loginButtonText => 'Login / Register'; + + @override + String get darkModeSetting => 'Dark Theme'; + + @override + String get darkModeDescription => 'Toggle between light and dark theme'; + + @override + String get languageSetting => 'Language'; + + @override + String get languageDescription => 'Change application language'; + + @override + String get russianLanguage => 'Russian'; + + @override + String get englishLanguage => 'English'; + + @override + String get defaultUser => 'Default User'; + + @override + String get currencySetting => 'Default Currency'; + + @override + String get currencyDescription => 'Set the default currency for transactions'; + + @override + String get transactionsHistoryTitle => 'Transactions History'; + + @override + String get balance => 'Balance'; + + @override + String get income => 'Income'; + + @override + String get expense => 'Expense'; + + @override + String get amount => 'Amount'; + + @override + String get vendor => 'Vendor'; + + @override + String get category => 'Category'; + + @override + String get date => 'Date'; + + @override + String get requiredField => 'Required field'; + + @override + String get invalidNumber => 'Invalid number'; + + @override + String get cancel => 'Cancel'; + + @override + String get save => 'Save'; +} diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart new file mode 100644 index 0000000..04e5b37 --- /dev/null +++ b/lib/l10n/app_localizations_ru.dart @@ -0,0 +1,118 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get appTitle => 'Бюджетное приложение'; + + @override + String get homePageTitle => 'Главная'; + + @override + String get reportsPageTitle => 'Отчеты'; + + @override + String get settingsPageTitle => 'Настройки'; + + @override + String get addTransactionButton => 'Добавить новую транзакцию'; + + @override + String get noTransactionsText => 'Нет транзакций'; + + @override + String get loadingTransactionsText => 'Загрузка транзакций...'; + + @override + String transactionErrorText(Object message) { + return 'Ошибка: $message'; + } + + @override + String get loginPageTitle => 'Вход'; + + @override + String get nameFieldLabel => 'Имя'; + + @override + String get emailFieldLabel => 'Email'; + + @override + String get nameFieldEmptyError => 'Пожалуйста, введите имя'; + + @override + String get emailFieldEmptyError => 'Пожалуйста, введите email'; + + @override + String get loginButtonText => 'Войти / Зарегистрироваться'; + + @override + String get darkModeSetting => 'Темная тема'; + + @override + String get darkModeDescription => 'Переключить между светлой и темной темой'; + + @override + String get languageSetting => 'Язык'; + + @override + String get languageDescription => 'Изменить язык приложения'; + + @override + String get russianLanguage => 'Русский'; + + @override + String get englishLanguage => 'Английский'; + + @override + String get defaultUser => 'Пользователь по умолчанию'; + + @override + String get currencySetting => 'Валюта по умолчанию'; + + @override + String get currencyDescription => + 'Установить валюту по умолчанию для транзакций'; + + @override + String get transactionsHistoryTitle => 'История транзакций'; + + @override + String get balance => 'Баланс'; + + @override + String get income => 'Доходы'; + + @override + String get expense => 'Расходы'; + + @override + String get amount => 'Сумма'; + + @override + String get vendor => 'Название'; + + @override + String get category => 'Категория'; + + @override + String get date => 'Дата'; + + @override + String get requiredField => 'Обязательное поле'; + + @override + String get invalidNumber => 'Неверный формат числа'; + + @override + String get cancel => 'Отмена'; + + @override + String get save => 'Сохранить'; +} diff --git a/lib/main.dart b/lib/main.dart index cf00e19..1a53aad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:budget_app/pages/home/home_page.dart'; import 'theme/app_theme.dart'; import 'injection_container.dart' as di; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit +import 'logic/transaction/transaction_bloc.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -30,6 +31,7 @@ class _MyAppState extends State { providers: [ BlocProvider(create: (context) => GetIt.instance()..add(AuthStarted())), BlocProvider(create: (context) => GetIt.instance()), + BlocProvider(create: (context) => GetIt.instance()), ], child: BlocBuilder( builder: (context, settingsState) { @@ -62,5 +64,3 @@ class _MyAppState extends State { ); } } - - From 4ceb7107824ad9a8b5b191625c8fc66df1261b2e Mon Sep 17 00:00:00 2001 From: Sanders Date: Sun, 29 Jun 2025 23:34:57 +0300 Subject: [PATCH 11/35] fix --- .vscode/launch.json | 31 +++++++ lib/pages/home/home_page.dart | 164 ++++++++-------------------------- 2 files changed, 70 insertions(+), 125 deletions(-) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..f2365c6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Flutter", + "type": "dart", + "request": "launch", + "program": "lib/main.dart" + }, + { + "name": "budget_app", + "request": "launch", + "type": "dart" + }, + { + "name": "budget_app (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "budget_app (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 29e7d5c..3a64977 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -36,6 +36,8 @@ class _HomePageState extends State { final authState = context.read().state; if (authState is AuthAuthenticated) { _currentUserId = authState.user.id; + // Загружаем транзакции через глобальный TransactionBloc + context.read().add(LoadTransactions(userId: _currentUserId)); } else { // Если пользователь не аутентифицирован, можно перенаправить на страницу входа // или использовать ID по умолчанию, если это применимо. @@ -56,49 +58,43 @@ class _HomePageState extends State { context, )!; // Получаем экземпляр локализации - return BlocProvider( - create: (context) { - return GetIt.instance() - ..add(LoadTransactions(userId: _currentUserId)); - }, - child: Scaffold( - appBar: AppBar( - title: Text(localizations.appTitle), // Локализованный заголовок - // Кнопки действий в AppBar теперь не нужны, так как настройки перенесены в BottomNavigationBar - ), - body: Center( - child: _widgetOptions[_selectedIndex], // Отображаем выбранный виджет - ), - floatingActionButton: FloatingActionButton( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return const AddTransactionDialog(); - }, - ); - }, - child: const Icon(Icons.add), - ), - bottomNavigationBar: BottomNavigationBar( - items: [ - BottomNavigationBarItem( - icon: const Icon(Icons.home), - label: localizations.homePageTitle, // Локализованный текст - ), - BottomNavigationBarItem( - icon: const Icon(Icons.bar_chart), - label: localizations.reportsPageTitle, // Локализованный текст - ), - BottomNavigationBarItem( - icon: const Icon(Icons.settings), - label: localizations.settingsPageTitle, // Локализованный текст - ), - ], - currentIndex: _selectedIndex, - selectedItemColor: Theme.of(context).colorScheme.primary, - onTap: _onItemTapped, - ), + return Scaffold( + appBar: AppBar( + title: Text(localizations.appTitle), // Локализованный заголовок + // Кнопки действий в AppBar теперь не нужны, так как настройки перенесены в BottomNavigationBar + ), + body: Center( + child: _widgetOptions[_selectedIndex], // Отображаем выбранный виджет + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return const AddTransactionDialog(); + }, + ); + }, + child: const Icon(Icons.add), + ), + bottomNavigationBar: BottomNavigationBar( + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.home), + label: localizations.homePageTitle, // Локализованный текст + ), + BottomNavigationBarItem( + icon: const Icon(Icons.bar_chart), + label: localizations.reportsPageTitle, // Локализованный текст + ), + BottomNavigationBarItem( + icon: const Icon(Icons.settings), + label: localizations.settingsPageTitle, // Локализованный текст + ), + ], + currentIndex: _selectedIndex, + selectedItemColor: Theme.of(context).colorScheme.primary, + onTap: _onItemTapped, ), ); } @@ -203,85 +199,3 @@ class TransactionsPage extends StatelessWidget { ); } } - -class HomeView extends StatelessWidget { - const HomeView({super.key}); - - // // - // Основной метод построения интерфейса виджета. - // - // Возвращает Scaffold - базовую структуру страницы Material Design, - // которая включает: - // 1. AppBar (верхнюю панель) - // 2. Body (основное содержимое) - // // - @override - Widget build(BuildContext context) { - final localizations = AppLocalizations.of( - context, - )!; // Получаем экземпляр локализации - - return Scaffold( - // Верхняя панель приложения - appBar: AppBar( - // Заголовок приложения - title: const Text('Budget App'), - - // Кнопки в правой части AppBar - actions: [ - // Кнопка настроек - IconButton( - icon: const Icon(Icons.settings), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SettingsPage()), - ); - }, - tooltip: 'Настройки', - ), - ], - ), - - body: BlocBuilder( - builder: (context, state) { - if (state is TransactionLoading) { - return const Center(child: CircularProgressIndicator()); - } else if (state is TransactionLoaded) { - if (state.transactions.isEmpty) { - return const Center(child: Text('Нет транзакций')); - } - return ListView.builder( - itemCount: state.transactions.length, - itemBuilder: (context, index) { - final transaction = state.transactions[index]; - return ListTile( - leading: Icon( - transaction.category.icon, - color: transaction.category.color, - ), - title: Text(transaction.vendor), - subtitle: Text(transaction.category.name), - trailing: Text( - '${transaction.amount.toStringAsFixed(2)} ${transaction.currency}', - style: TextStyle( - color: transaction.isIncome ? Colors.green : Colors.red, - ), - ), - ); - }, - ); - } else if (state is TransactionError) { - return Center( - child: Text(localizations.transactionErrorText(state.message)), - ); // Локализованный текст - } else { - return Center( - child: Text(localizations.loadingTransactionsText), - ); // Локализованный текст - } - }, - ), - ); - } -} From 5ff11c544965df6c442408e8f858197d84dd9d1e Mon Sep 17 00:00:00 2001 From: Sanders Date: Mon, 30 Jun 2025 10:26:27 +0300 Subject: [PATCH 12/35] Improves transaction input and display Adds a tag selection to transaction dialog and refines the display of transactions. The changes introduce the ability to tag transactions, improving categorization and filtering. It also modifies the transaction input dialog to include time, as well as refines the appearance of transaction items on the home page by using a custom widget wrapped in a card. The UI of the summary widget is also improved by using theme colors and increasing the overall visibility. --- lib/l10n/app_en.arb | 5 +- lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_ru.dart | 3 + lib/l10n/app_ru.arb | 5 +- lib/pages/home/home_page.dart | 43 ++-- .../home/widgets/add_transaction_dialog.dart | 95 +++++++-- lib/pages/home/widgets/summary_widget.dart | 189 +++++++++--------- lib/pages/home/widgets/transaction_item.dart | 106 ++++++++++ lib/theme/app_theme.dart | 31 ++- lib/theme/custom_colors.dart | 15 +- 11 files changed, 359 insertions(+), 142 deletions(-) create mode 100644 lib/pages/home/widgets/transaction_item.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8e365e4..b8d4664 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -34,5 +34,6 @@ "requiredField": "Required field", "invalidNumber": "Invalid number", "cancel": "Cancel", - "save": "Save" -} \ No newline at end of file + "save": "Save", + "tag": "Tag" +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3e11e1d..e13b623 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -307,6 +307,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Save'** String get save; + + /// No description provided for @tag. + /// + /// In en, this message translates to: + /// **'Tag'** + String get tag; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9e87354..32c89e0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -114,4 +114,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get save => 'Save'; + + @override + String get tag => 'Tag'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 04e5b37..4e65f99 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -115,4 +115,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get save => 'Сохранить'; + + @override + String get tag => 'Тег'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5edc8c2..2cd8620 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -34,5 +34,6 @@ "requiredField": "Обязательное поле", "invalidNumber": "Неверный формат числа", "cancel": "Отмена", - "save": "Сохранить" -} \ No newline at end of file + "save": "Сохранить", + "tag": "Тег" +} diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 3a64977..4e13785 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -9,7 +9,8 @@ import '../../services/user_service.dart'; import '../home/widgets/summary_widget.dart'; // Импортируем новый виджет сводки import '../reports_page.dart'; // Импортируем новую страницу отчетов import '../settings_page.dart'; -import 'widgets/add_transaction_dialog.dart'; // Добавлен импорт для UserService +import 'widgets/add_transaction_dialog.dart'; +import 'widgets/transaction_item.dart'; // Импортируем новый виджет для элемента транзакции class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -160,29 +161,23 @@ class TransactionsPage extends StatelessWidget { child: Text(localizations.noTransactionsText), ) // Локализованный текст else - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: state.transactions.length, - itemBuilder: (context, index) { - final transaction = state.transactions[index]; - return ListTile( - leading: Icon( - transaction.category.icon, - color: transaction.category.color, - ), - title: Text(transaction.vendor), - subtitle: Text(transaction.category.name), - trailing: Text( - '${transaction.amount.toStringAsFixed(2)} ${transaction.currency}', - style: TextStyle( - color: transaction.isIncome - ? Colors.green - : Colors.red, - ), - ), - ); - }, + // Оборачиваем список транзакций в Card для создания единого блока + Card( + margin: const EdgeInsets.all(16.0), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.0), + ), + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: state.transactions.length, + itemBuilder: (context, index) { + final transaction = state.transactions[index]; + // Возвращаем новый кастомный виджет для транзакции + return TransactionItem(transaction: transaction); + }, + ), ), ], ); diff --git a/lib/pages/home/widgets/add_transaction_dialog.dart b/lib/pages/home/widgets/add_transaction_dialog.dart index 7eb7dd4..215d623 100644 --- a/lib/pages/home/widgets/add_transaction_dialog.dart +++ b/lib/pages/home/widgets/add_transaction_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:intl/intl.dart'; @@ -6,7 +7,9 @@ import 'package:intl/intl.dart'; import '../../../l10n/app_localizations.dart'; import '../../../logic/auth/auth_bloc.dart'; import '../../../logic/transaction/transaction_bloc.dart'; +import '../../../data/repositories/interfaces/itag_repository.dart'; // Импортируем интерфейс репозитория тегов import '../../../models/category.dart'; +import '../../../models/tag.dart'; // Импортируем модель тега import '../../../models/transaction_record.dart'; import '../../../services/user_service.dart'; import '../../../utils/category_utils.dart'; @@ -26,12 +29,16 @@ class _AddTransactionDialogState extends State { bool _isIncome = false; Category? _selectedCategory; - DateTime _selectedDate = DateTime.now(); + // Комментарий: Добавляем состояние для выбранного тега. + Tag? _selectedTag; + // Комментарий: Заменяем _selectedDate на _selectedDateTime для хранения даты и времени. + DateTime _selectedDateTime = DateTime.now(); @override void initState() { super.initState(); - _dateController.text = DateFormat.yMd().format(_selectedDate); + // Комментарий: Устанавливаем начальное значение с датой и временем. + _dateController.text = DateFormat.yMd().add_Hm().format(_selectedDateTime); } @override @@ -42,19 +49,37 @@ class _AddTransactionDialogState extends State { super.dispose(); } - Future _selectDate(BuildContext context) async { - final DateTime? picked = await showDatePicker( + // Комментарий: Этот метод теперь обрабатывает выбор и даты, и времени. + Future _selectDateTime(BuildContext context) async { + final DateTime? pickedDate = await showDatePicker( context: context, - initialDate: _selectedDate, + initialDate: _selectedDateTime, firstDate: DateTime(2000), lastDate: DateTime(2101), ); - if (picked != null && picked != _selectedDate) { - setState(() { - _selectedDate = picked; - _dateController.text = DateFormat.yMd().format(_selectedDate); - }); - } + // Комментарий: Если пользователь не выбрал дату, выходим из функции. + if (pickedDate == null) return; + + // ignore: use_build_context_synchronously + final TimeOfDay? pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_selectedDateTime), + ); + // Комментарий: Если пользователь не выбрал время, выходим из функции. + if (pickedTime == null) return; + + // Комментарий: Обновляем состояние с новой датой и временем. + setState(() { + _selectedDateTime = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + // Комментарий: Обновляем текстовое поле с отформатированной датой и временем. + _dateController.text = DateFormat.yMd().add_Hm().format(_selectedDateTime); + }); } void _submitForm() { @@ -93,7 +118,10 @@ class _AddTransactionDialogState extends State { amount: amount, vendor: _vendorController.text, category: _selectedCategory!, - dateTime: _selectedDate, + // Комментарий: Используем _selectedDateTime для сохранения точного времени транзакции. + dateTime: _selectedDateTime, + // Комментарий: Добавляем выбранный тег в транзакцию. + tag: _selectedTag, currency: GetIt.instance().currentUser?.defaultCurrency ?? 'USD', userId: authState.user.id, @@ -135,7 +163,13 @@ class _AddTransactionDialogState extends State { TextFormField( controller: _amountController, decoration: InputDecoration(labelText: localizations.amount), - keyboardType: TextInputType.number, + // Комментарий: Устанавливаем числовую клавиатуру с поддержкой десятичных чисел. + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + // Комментарий: Добавляем фильтр для ввода только чисел и одной точки. + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], validator: (value) { if (value == null || value.isEmpty) { return localizations.requiredField; @@ -173,13 +207,46 @@ class _AddTransactionDialogState extends State { validator: (value) => value == null ? localizations.requiredField : null, ), + // Комментарий: Добавляем выпадающий список для выбора тега. + // Он будет загружать теги асинхронно для текущего пользователя. + FutureBuilder>( + future: GetIt.instance().getAllByUser( + (context.read().state as AuthAuthenticated).user.id, + ), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Text('Error: ${snapshot.error}'); + } + final tags = snapshot.data ?? []; + return DropdownButtonFormField( + value: _selectedTag, + decoration: InputDecoration(labelText: localizations.tag), + items: tags.map((Tag tag) { + return DropdownMenuItem( + value: tag, + child: Text(tag.name), + ); + }).toList(), + onChanged: (Tag? newValue) { + setState(() { + _selectedTag = newValue; + }); + }, + // Комментарий: Тег не является обязательным полем. + ); + }, + ), TextFormField( controller: _dateController, decoration: InputDecoration( labelText: localizations.date, suffixIcon: IconButton( icon: const Icon(Icons.calendar_today), - onPressed: () => _selectDate(context), + // Комментарий: Вызываем новый метод для выбора даты и времени. + onPressed: () => _selectDateTime(context), ), ), readOnly: true, diff --git a/lib/pages/home/widgets/summary_widget.dart b/lib/pages/home/widgets/summary_widget.dart index 9d24f5b..8a4b2a0 100644 --- a/lib/pages/home/widgets/summary_widget.dart +++ b/lib/pages/home/widgets/summary_widget.dart @@ -27,82 +27,81 @@ class SummaryWidget extends StatelessWidget { final customColors = Theme.of(context).extension()!; final theme = Theme.of(context); - // Определяем цвет для градиента в зависимости от темы. - final isDarkMode = theme.brightness == Brightness.dark; - final gradientColor = isDarkMode ? Colors.grey[800]! : Colors.grey[200]!; - - // Используем Card для придания виджету тени и скругленных углов. + // Используем Card для создания тени и скругленных углов. + // Увеличиваем радиус скругления и elevation для более выраженного эффекта. return Card( - elevation: 4.0, + elevation: 8.0, margin: const EdgeInsets.all(16.0), - clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.0), + borderRadius: BorderRadius.circular(16.0), ), - child: Container( - padding: const EdgeInsets.all(16.0), - decoration: BoxDecoration( - // Добавляем градиентный фон для более современного вида. - gradient: LinearGradient( - colors: [ - theme.colorScheme.surface, - gradientColor, - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0, horizontal: 16.0), child: Column( + mainAxisSize: MainAxisSize.min, children: [ // Центральный блок с балансом. - Center( - child: Column( + // Используем акцентный цвет для заголовка. + Text( + localizations.balance, + style: theme.textTheme.titleLarge?.copyWith( + color: customColors.accent, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8.0), + // Анимированное отображение общей суммы. + // Увеличиваем шрифт для большей наглядности. + AnimatedDigitWidget( + value: balance, + fractionDigits: 2, + textStyle: theme.textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + suffix: ' $currencySymbol', + ), + const SizedBox(height: 24.0), + + // Добавляем разделитель с цветом из темы. + Divider( + height: 1, + thickness: 1, + color: customColors.divider, + ), + const SizedBox(height: 24.0), + + // Разделение на доходы и расходы. + // Используем IntrinsicHeight для выравнивания высоты дочерних элементов. + IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - // Заголовок секции. - Text( - localizations.balance, - style: theme.textTheme.titleLarge, + // Виджет для отображения доходов. + _buildIncomeExpense( + context, + localizations.income, + income, + customColors.income!, // Используем цвет дохода из темы + Icons.arrow_circle_up_outlined, ), - const SizedBox(height: 8.0), - // Анимированное отображение общей суммы. - AnimatedDigitWidget( - value: balance, - fractionDigits: 2, - textStyle: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - suffix: ' $currencySymbol', + // Вертикальный разделитель. + VerticalDivider( + width: 1, + thickness: 1, + color: customColors.divider, + ), + // Виджет для отображения расходов. + _buildIncomeExpense( + context, + localizations.expense, + expense, + customColors.expense!, // Используем цвет расхода из темы + Icons.arrow_circle_down_outlined, ), ], ), ), - const SizedBox(height: 16.0), - - // Добавляем разделитель для лучшей структуры. - const Divider(height: 24.0), - - // Разделение на доходы и расходы. - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - // Виджет для отображения доходов. - _buildIncomeExpense( - context, - localizations.income, - income, - customColors.income!, // Используем цвет дохода из темы - Icons.arrow_upward, - ), - // Виджет для отображения расходов. - _buildIncomeExpense( - context, - localizations.expense, - expense, - customColors.expense!, // Используем цвет расхода из темы - Icons.arrow_downward, - ), - ], - ), ], ), ), @@ -110,37 +109,43 @@ class SummaryWidget extends StatelessWidget { } // Вспомогательный метод для создания виджета дохода/расхода. + // Изменен для более компактного и чистого вида. Widget _buildIncomeExpense( - BuildContext context, String title, double amount, Color color, IconData icon) { - return Row( - children: [ - // Иконка для наглядности. - Icon( - icon, - color: color, - size: 28.0, - ), - const SizedBox(width: 8.0), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: Theme.of(context).textTheme.titleMedium, + BuildContext context, + String title, + double amount, + Color color, + IconData icon, + ) { + final theme = Theme.of(context); + return Expanded( + child: Column( + children: [ + // Иконка для наглядности. + Icon( + icon, + color: color, + size: 32.0, + ), + const SizedBox(height: 8.0), + // Заголовок. + Text( + title, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 4.0), + // Анимированное отображение суммы. + // Используем основной цвет текста для суммы. + AnimatedDigitWidget( + value: amount, + fractionDigits: 2, + textStyle: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, ), - const SizedBox(height: 4.0), - // Анимированное отображение суммы. - AnimatedDigitWidget( - value: amount, - fractionDigits: 2, - textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: color, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ], + ), + ], + ), ); } } diff --git a/lib/pages/home/widgets/transaction_item.dart b/lib/pages/home/widgets/transaction_item.dart new file mode 100644 index 0000000..0b9e357 --- /dev/null +++ b/lib/pages/home/widgets/transaction_item.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../../../models/transaction_record.dart'; + +/// Виджет для отображения одной транзакции в списке. +/// +/// Этот виджет представляет собой карточку с подробной информацией о транзакции, +/// включая поставщика, сумму, категорию, тег и дату. +class TransactionItem extends StatelessWidget { + final TransactionRecord transaction; + + const TransactionItem({super.key, required this.transaction}); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + final theme = Theme.of(context); + // Форматируем дату в соответствии с локалью + final formattedDate = DateFormat.yMMMd(localizations.localeName).format(transaction.dateTime); + + // Убираем Card, так как обертка будет в родительском виджете. + // Добавляем разделитель и уменьшаем отступы для компактности. + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Верхняя строка: Название поставщика и сумма + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Название поставщика + Expanded( + child: Text( + transaction.vendor, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // Сумма транзакции + // Используем .abs() чтобы избежать двойного минуса для расходов + Text( + '${transaction.isIncome ? '+' : '-'}${transaction.amount.abs().toStringAsFixed(2)} ${transaction.currency}', + style: theme.textTheme.titleSmall?.copyWith( + color: transaction.isIncome + ? theme.colorScheme.primary + : theme.colorScheme.error, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8.0), + // Средняя строка: Категория и тег + Row( + children: [ + // Иконка категории + Icon( + transaction.category.icon, + color: transaction.category.color, + size: 20.0, // Уменьшаем размер иконки + ), + const SizedBox(width: 8.0), + // Название категории и тега + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + transaction.category.name, + style: theme.textTheme.bodySmall, + ), + if (transaction.tag != null) + Text( + '#${transaction.tag!.name}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ), + // Дата в правом углу + Text( + formattedDate, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.5), + ), + ), + ], + ), + ], + ), + ), + // Разделитель между транзакциями + const Divider(height: 1, thickness: 1, indent: 16, endIndent: 16), + ], + ); + } +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index fc8f2dd..ec3775f 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -37,10 +37,19 @@ class AppTheme { ), ), ), - extensions: const >[ + // Комментарий: Добавляем тему для выпадающих меню, чтобы цвет соответствовал фону. + dropdownMenuTheme: DropdownMenuThemeData( + menuStyle: MenuStyle( + backgroundColor: MaterialStateProperty.all(Colors.white), + ), + ), + // Изменяем цвета в соответствии с черно-белой палитрой + extensions: >[ CustomColors( - income: Colors.green, - expense: Colors.red, + income: Colors.grey[800]!, // Темно-серый для доходов + expense: Colors.grey[600]!, // Серый для расходов + divider: Colors.grey[300]!, // Светло-серый для разделителей + accent: Colors.black, // Черный для акцентов ), ], ); @@ -81,13 +90,21 @@ class AppTheme { ), ), ), - extensions: const >[ + // Комментарий: Добавляем тему для выпадающих меню, чтобы цвет соответствовал фону. + dropdownMenuTheme: DropdownMenuThemeData( + menuStyle: MenuStyle( + backgroundColor: MaterialStateProperty.all(Colors.grey[800]), + ), + ), + // Изменяем цвета в соответствии с черно-белой палитрой + extensions: >[ CustomColors( - income: Colors.greenAccent, - expense: Colors.redAccent, + income: Colors.grey[300]!, // Светло-серый для доходов + expense: Colors.grey[500]!, // Серый для расходов + divider: Colors.grey[700]!, // Темно-серый для разделителей + accent: Colors.white, // Белый для акцентов ), ], ); } } - diff --git a/lib/theme/custom_colors.dart b/lib/theme/custom_colors.dart index 43d6bdd..ffa1a9e 100644 --- a/lib/theme/custom_colors.dart +++ b/lib/theme/custom_colors.dart @@ -7,16 +7,27 @@ class CustomColors extends ThemeExtension { const CustomColors({ required this.income, required this.expense, + this.divider, // Добавлен цвет для разделителей + this.accent, // Добавлен цвет для акцентов }); final Color? income; final Color? expense; + final Color? divider; + final Color? accent; @override - CustomColors copyWith({Color? income, Color? expense}) { + CustomColors copyWith({ + Color? income, + Color? expense, + Color? divider, + Color? accent, + }) { return CustomColors( income: income ?? this.income, expense: expense ?? this.expense, + divider: divider ?? this.divider, + accent: accent ?? this.accent, ); } @@ -28,6 +39,8 @@ class CustomColors extends ThemeExtension { return CustomColors( income: Color.lerp(income, other.income, t), expense: Color.lerp(expense, other.expense, t), + divider: Color.lerp(divider, other.divider, t), + accent: Color.lerp(accent, other.accent, t), ); } } From 829ca93d9b552275bf554458b7741d95d096d0fd Mon Sep 17 00:00:00 2001 From: Sanders Date: Mon, 30 Jun 2025 13:40:52 +0300 Subject: [PATCH 13/35] Adds SMS reading functionality Implements SMS reading functionality to automatically track expenses. - Adds necessary permissions for reading SMS messages in AndroidManifest.xml. - Registers SmsService and SmsCubit in the dependency injection container. - Adds a new SMS page to the bottom navigation bar. - Introduces new localization strings for the SMS page title and permission denied message. - Sets minSdk to 23. --- .clinerules/GEMINI.md | 28 + android/app/build.gradle.kts | 2 +- android/app/src/main/AndroidManifest.xml | 10 + .../reports/problems/problems-report.html | 663 ++++++++++++++++++ lib/injection_container.dart | 6 + lib/l10n/app_en.arb | 4 +- lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_ru.dart | 6 + lib/l10n/app_ru.arb | 4 +- lib/logic/sms/sms_cubit.dart | 33 + lib/logic/sms/sms_state.dart | 39 ++ lib/main.dart | 2 + lib/pages/home/home_page.dart | 100 +-- lib/pages/home/widgets/summary_widget.dart | 326 ++++++--- lib/pages/sms/sms_page.dart | 54 ++ lib/pages/sms/widgets/sms_message_widget.dart | 20 + lib/services/sms_service.dart | 37 + pubspec.lock | 8 + pubspec.yaml | 1 + 20 files changed, 1229 insertions(+), 132 deletions(-) create mode 100644 .clinerules/GEMINI.md create mode 100644 android/build/reports/problems/problems-report.html create mode 100644 lib/logic/sms/sms_cubit.dart create mode 100644 lib/logic/sms/sms_state.dart create mode 100644 lib/pages/sms/sms_page.dart create mode 100644 lib/pages/sms/widgets/sms_message_widget.dart create mode 100644 lib/services/sms_service.dart diff --git a/.clinerules/GEMINI.md b/.clinerules/GEMINI.md new file mode 100644 index 0000000..93a84bf --- /dev/null +++ b/.clinerules/GEMINI.md @@ -0,0 +1,28 @@ +# Project: Budget App + +## General Instructions: + +- Это проект на Flutter используй только его +- Комментируй в коде каждое изменение, которое ты делаешь, что бы мне было понятно и я учился на этом. +- Комментарии и твои ответы должны быть на русском языке +- When generating new Flutter code, please follow the existing coding style. +- Цветовая палитра черно-белая +- Все настройки цветов выноси в тему +- Все элементы должны разбиваться на мелкие и иметь логичную структуру по попкам +- Весь текст должен иметь локализацию чрезе flutter_localizations. Смотри папку l10n +- Разработка ведется под windows, ты можешь исопльзовать его консольные команды + +## Coding Style: + +- Interface names should be prefixed with `I` (e.g., `IUserService`). +- Private class members should be prefixed with an underscore (`_`). +- Учитывай, что в проекте используется bloc cubit архитектура + +## Role +- You are a Flutter assistant that helps users write more efficient and optimizable Flutter code. +- You specialize in identifying patterns that enable Flutter Compiler to automatically apply optimizations, reducing unnecessary re-renders and improving application performance. + +## Follow these guidelines in all code you produce and suggest +- Prefer composition and small components: Break down UI into small, reusable components rather than writing large monolithic components. The code you generate should promote clarity and reusability by composing components together. +- Design for a good user experience - Provide clear, minimal, and non-blocking UI states. When data is loading, show lightweight placeholders (e.g., skeleton screens) rather than intrusive spinners everywhere. Handle errors gracefully with a dedicated error boundary or a friendly inline message. Where possible, render partial data as it becomes available rather than making the user wait for everything. Suspense allows you to declare the loading states in your component tree in a natural way, preventing “flash” states and improving perceived performance. +- \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f42a70e..8c41c28 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -24,7 +24,7 @@ android { applicationId = "ru.sanderrs.budget_app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 23 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 865a103..49ce1fe 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,14 @@ + + + + + + + + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 1b2991f..5b31d6d 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -11,7 +11,9 @@ import 'data/repositories/interfaces/itransaction_repository.dart'; import 'data/repositories/interfaces/iuser_repository.dart'; import 'logic/auth/auth_bloc.dart'; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit +import 'logic/sms/sms_cubit.dart'; import 'logic/transaction/transaction_bloc.dart'; +import 'services/sms_service.dart'; import 'services/user_service.dart'; final getIt = GetIt.instance; @@ -40,6 +42,8 @@ Future initDependencies() async { UserService(getIt(), getIt(), getIt(), getIt()), ); + getIt.registerSingleton(SmsService()); + // Регистрация сервисов getIt.registerSingleton( SettingsCubit(getIt()), @@ -50,4 +54,6 @@ Future initDependencies() async { getIt.registerFactory( () => TransactionBloc(transactionRepository: getIt()), ); + + getIt.registerFactory(() => SmsCubit(getIt())); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b8d4664..f93bb20 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -35,5 +35,7 @@ "invalidNumber": "Invalid number", "cancel": "Cancel", "save": "Save", - "tag": "Tag" + "tag": "Tag", + "smsPageTitle": "SMS Messages", + "smsPermissionDenied": "SMS permission is required" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e13b623..1ee694e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -313,6 +313,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Tag'** String get tag; + + /// No description provided for @smsPageTitle. + /// + /// In en, this message translates to: + /// **'SMS Messages'** + String get smsPageTitle; + + /// No description provided for @smsPermissionDenied. + /// + /// In en, this message translates to: + /// **'SMS permission is required'** + String get smsPermissionDenied; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 32c89e0..db805ed 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -117,4 +117,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tag => 'Tag'; + + @override + String get smsPageTitle => 'SMS Messages'; + + @override + String get smsPermissionDenied => 'SMS permission is required'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4e65f99..20be8f3 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -118,4 +118,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tag => 'Тег'; + + @override + String get smsPageTitle => 'SMS Сообщения'; + + @override + String get smsPermissionDenied => 'Необходимо разрешение на чтение SMS'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2cd8620..730eecb 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -35,5 +35,7 @@ "invalidNumber": "Неверный формат числа", "cancel": "Отмена", "save": "Сохранить", - "tag": "Тег" + "tag": "Тег", + "smsPageTitle": "SMS Сообщения", + "smsPermissionDenied": "Необходимо разрешение на чтение SMS" } diff --git a/lib/logic/sms/sms_cubit.dart b/lib/logic/sms/sms_cubit.dart new file mode 100644 index 0000000..50915b7 --- /dev/null +++ b/lib/logic/sms/sms_cubit.dart @@ -0,0 +1,33 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:budget_app/logic/sms/sms_state.dart'; +import 'package:budget_app/services/sms_service.dart'; + +/// Cubit для управления состоянием SMS. +/// +/// Отвечает за загрузку SMS сообщений и обработку разрешений. +class SmsCubit extends Cubit { + final SmsService _smsService; + + SmsCubit(this._smsService) : super(SmsInitial()); + + /// Загружает последние 10 SMS сообщений. + /// + /// Перед загрузкой запрашивает необходимые разрешения. + /// В случае успеха, переходит в состояние [SmsLoaded]. + /// В случае отказа в разрешениях, переходит в состояние [SmsPermissionDenied]. + /// В случае ошибки, переходит в состояние [SmsError]. + Future loadLastMessages() async { + emit(SmsLoading()); + try { + final hasPermissions = await _smsService.requestPermissions(); + if (hasPermissions) { + final messages = await _smsService.getLastSmsMessages(10); + emit(SmsLoaded(messages)); + } else { + emit(SmsPermissionDenied()); + } + } catch (e) { + emit(SmsError(e.toString())); + } + } +} diff --git a/lib/logic/sms/sms_state.dart b/lib/logic/sms/sms_state.dart new file mode 100644 index 0000000..8ae8ee2 --- /dev/null +++ b/lib/logic/sms/sms_state.dart @@ -0,0 +1,39 @@ +import 'package:another_telephony/telephony.dart'; +import 'package:equatable/equatable.dart'; + +/// Абстрактный класс для состояний SMS. +abstract class SmsState extends Equatable { + const SmsState(); + + @override + List get props => []; +} + +/// Начальное состояние. +class SmsInitial extends SmsState {} + +/// Состояние загрузки SMS. +class SmsLoading extends SmsState {} + +/// Состояние, когда SMS успешно загружены. +class SmsLoaded extends SmsState { + final List messages; + + const SmsLoaded(this.messages); + + @override + List get props => [messages]; +} + +/// Состояние, когда отказано в разрешении на чтение SMS. +class SmsPermissionDenied extends SmsState {} + +/// Состояние ошибки при загрузке SMS. +class SmsError extends SmsState { + final String message; + + const SmsError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/main.dart b/lib/main.dart index 1a53aad..fd3581b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:budget_app/pages/home/home_page.dart'; import 'theme/app_theme.dart'; import 'injection_container.dart' as di; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit +import 'logic/sms/sms_cubit.dart'; import 'logic/transaction/transaction_bloc.dart'; void main() async { @@ -32,6 +33,7 @@ class _MyAppState extends State { BlocProvider(create: (context) => GetIt.instance()..add(AuthStarted())), BlocProvider(create: (context) => GetIt.instance()), BlocProvider(create: (context) => GetIt.instance()), + BlocProvider(create: (context) => GetIt.instance()), ], child: BlocBuilder( builder: (context, settingsState) { diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 4e13785..5e42076 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -9,6 +9,7 @@ import '../../services/user_service.dart'; import '../home/widgets/summary_widget.dart'; // Импортируем новый виджет сводки import '../reports_page.dart'; // Импортируем новую страницу отчетов import '../settings_page.dart'; +import '../sms/sms_page.dart'; import 'widgets/add_transaction_dialog.dart'; import 'widgets/transaction_item.dart'; // Импортируем новый виджет для элемента транзакции @@ -27,6 +28,7 @@ class _HomePageState extends State { static final List _widgetOptions = [ const TransactionsPage(), // Главная страница с транзакциями const ReportsPage(), // Страница отчетов + const SmsPage(), // Страница SMS const SettingsPage(), // Страница настроек ]; @@ -34,7 +36,7 @@ class _HomePageState extends State { void initState() { super.initState(); // Получаем ID текущего пользователя из AuthBloc - final authState = context.read().state; + final authState = context.read().state; if (authState is AuthAuthenticated) { _currentUserId = authState.user.id; // Загружаем транзакции через глобальный TransactionBloc @@ -88,6 +90,10 @@ class _HomePageState extends State { icon: const Icon(Icons.bar_chart), label: localizations.reportsPageTitle, // Локализованный текст ), + BottomNavigationBarItem( + icon: const Icon(Icons.sms), + label: localizations.smsPageTitle, + ), BottomNavigationBarItem( icon: const Icon(Icons.settings), label: localizations.settingsPageTitle, // Локализованный текст @@ -101,53 +107,63 @@ class _HomePageState extends State { } } -// Отдельный виджет для страницы транзакций, чтобы HomeView был чище -class TransactionsPage extends StatelessWidget { +// Изменение: TransactionsPage преобразован в StatefulWidget для управления состоянием выбранного месяца. +class TransactionsPage extends StatefulWidget { const TransactionsPage({super.key}); + @override + State createState() => _TransactionsPageState(); +} + +class _TransactionsPageState extends State { + // Добавление: Состояние для хранения выбранного месяца на уровне страницы. + late DateTime _selectedMonth; + + @override + void initState() { + super.initState(); + // Инициализация: Устанавливаем текущий месяц. + _selectedMonth = DateTime.now(); + } + + // Добавление: Метод для изменения месяца, который будет передаваться в SummaryWidget. + void _onMonthChanged(DateTime newMonth) { + setState(() { + _selectedMonth = newMonth; + }); + } + @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of( - context, - )!; // Получаем экземпляр локализации + final localizations = AppLocalizations.of(context)!; return BlocBuilder( builder: (context, state) { if (state is TransactionLoading) { return const Center(child: CircularProgressIndicator()); } else if (state is TransactionLoaded) { - // Используем ListView для вертикальной прокрутки + // Добавление: Фильтрация транзакций по выбранному месяцу. + final monthlyTransactions = state.transactions.where((t) { + return t.dateTime.month == _selectedMonth.month && t.dateTime.year == _selectedMonth.year; + }).toList(); + return ListView( children: [ - // Виджет сводки - // Рассчитываем доходы, расходы и баланс из транзакций + // Изменение: SummaryWidget теперь получает все транзакции, + // выбранный месяц и колбэк для его изменения. Builder( builder: (context) { - double income = 0.0; - double expense = 0.0; - for (var transaction in state.transactions) { - if (transaction.isIncome) { - income += transaction.amount; - } else { - expense += transaction.amount; - } - } - final balance = income - expense; - // Получаем текущего пользователя для определения валюты final userService = GetIt.instance(); - final currencySymbol = - userService.currentUser?.defaultCurrency ?? - '₽'; // Валюта по умолчанию, если не найдена + final currencySymbol = userService.currentUser?.defaultCurrency ?? '₽'; return SummaryWidget( - income: income, - expense: expense, - balance: balance, - currencySymbol: currencySymbol, // Передаем символ валюты - ); // Передаем реальные данные в SummaryWidget + transactions: state.transactions, // Передаем все транзакции для расчетов + selectedMonth: _selectedMonth, // Передаем текущий выбранный месяц + onMonthChanged: _onMonthChanged, // Передаем колбэк + currencySymbol: currencySymbol, + ); }, ), - // Заголовок для списка транзакций Padding( padding: const EdgeInsets.all(16.0), child: Text( @@ -155,26 +171,28 @@ class TransactionsPage extends StatelessWidget { style: Theme.of(context).textTheme.titleLarge, ), ), - // Список транзакций - if (state.transactions.isEmpty) + // Изменение: Отображаем отфильтрованный список транзакций. + if (monthlyTransactions.isEmpty) Center( - child: Text(localizations.noTransactionsText), - ) // Локализованный текст + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(localizations.noTransactionsText), + ), + ) else - // Оборачиваем список транзакций в Card для создания единого блока Card( margin: const EdgeInsets.all(16.0), elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), + // Изменение: Используем отфильтрованный список. child: ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: state.transactions.length, + itemCount: monthlyTransactions.length, itemBuilder: (context, index) { - final transaction = state.transactions[index]; - // Возвращаем новый кастомный виджет для транзакции + final transaction = monthlyTransactions[index]; return TransactionItem(transaction: transaction); }, ), @@ -182,13 +200,9 @@ class TransactionsPage extends StatelessWidget { ], ); } else if (state is TransactionError) { - return Center( - child: Text(localizations.transactionErrorText(state.message)), - ); // Локализованный текст + return Center(child: Text(localizations.transactionErrorText(state.message))); } else { - return Center( - child: Text(localizations.loadingTransactionsText), - ); // Локализованный текст + return Center(child: Text(localizations.loadingTransactionsText)); } }, ); diff --git a/lib/pages/home/widgets/summary_widget.dart b/lib/pages/home/widgets/summary_widget.dart index 8a4b2a0..bb65795 100644 --- a/lib/pages/home/widgets/summary_widget.dart +++ b/lib/pages/home/widgets/summary_widget.dart @@ -1,106 +1,121 @@ import 'package:animated_digit/animated_digit.dart'; import 'package:budget_app/l10n/app_localizations.dart'; +import 'package:budget_app/models/transaction_record.dart'; import 'package:budget_app/theme/custom_colors.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; -// Виджет для отображения сводки по доходам и расходам. -class SummaryWidget extends StatelessWidget { - // Добавлены параметры для отображения реальных данных - final double income; - final double expense; - final double balance; - final String currencySymbol; // Добавлен параметр для символа валюты +// Изменение: Виджет преобразован в StatefulWidget для управления состоянием выбранного месяца. +class SummaryWidget extends StatefulWidget { + // Изменение: Виджет теперь принимает список всех транзакций, а не готовые суммы. + final List transactions; + final String currencySymbol; + // Добавление: Принимает выбранный месяц и колбэк для его изменения от родителя. + final DateTime selectedMonth; + final ValueChanged onMonthChanged; const SummaryWidget({ super.key, - required this.income, - required this.expense, - required this.balance, - required this.currencySymbol, // Обязательный параметр + required this.transactions, + required this.currencySymbol, + required this.selectedMonth, + required this.onMonthChanged, }); @override - Widget build(BuildContext context) { - // Получаем локализацию и тему. - final localizations = AppLocalizations.of(context)!; - final customColors = Theme.of(context).extension()!; - final theme = Theme.of(context); + State createState() => _SummaryWidgetState(); +} - // Используем Card для создания тени и скругленных углов. - // Увеличиваем радиус скругления и elevation для более выраженного эффекта. +class _SummaryWidgetState extends State { + // Добавление: PageController для управления PageView. + late PageController _pageController; + // Добавление: Хранение начального месяца для расчетов. + late DateTime _initialMonth; + // Добавление: Общее количество месяцев для отображения. + int _monthCount = 0; + + @override + void initState() { + super.initState(); + // Изменение: Находим самую раннюю транзакцию для определения начального месяца. + if (widget.transactions.isNotEmpty) { + widget.transactions.sort((a, b) => a.dateTime.compareTo(b.dateTime)); + _initialMonth = DateTime(widget.transactions.first.dateTime.year, + widget.transactions.first.dateTime.month); + } else { + _initialMonth = DateTime(DateTime.now().year, DateTime.now().month); + } + + _monthCount = _calculateMonthDifference(DateTime.now(), _initialMonth) + 1; + if (_monthCount < 1) _monthCount = 1; // Как минимум один месяц должен быть + + // Инициализация PageController на последней странице (текущий месяц). + _pageController = PageController(initialPage: _monthCount - 1); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + // Добавление: Вспомогательный метод для расчета разницы в месяцах. + int _calculateMonthDifference(DateTime d1, DateTime d2) { + return (d1.year - d2.year) * 12 + d1.month - d2.month; + } + + // Добавление: Метод для форматирования названия месяца. + // Отображает год, если он отличается от текущего. + String _formatMonth(BuildContext context, DateTime date) { + final localizations = AppLocalizations.of(context)!; + final now = DateTime.now(); + final format = + date.year == now.year ? DateFormat.MMMM(localizations.localeName) : DateFormat.yMMMM(localizations.localeName); + return format.format(date); + } + + @override + Widget build(BuildContext context) { return Card( elevation: 8.0, margin: const EdgeInsets.all(16.0), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16.0), ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 24.0, horizontal: 16.0), + clipBehavior: Clip.antiAlias, // Обрезаем контент по границам карточки + child: SizedBox( + height: 300, + // Изменение: Используем Column для разделения PageView и индикатора. child: Column( - mainAxisSize: MainAxisSize.min, children: [ - // Центральный блок с балансом. - // Используем акцентный цвет для заголовка. - Text( - localizations.balance, - style: theme.textTheme.titleLarge?.copyWith( - color: customColors.accent, - fontWeight: FontWeight.bold, + // PageView будет занимать все доступное пространство. + Expanded( + child: PageView.builder( + controller: _pageController, + itemCount: _monthCount, + onPageChanged: (index) { + final newMonth = DateTime( + _initialMonth.year, + _initialMonth.month + index, + 1, + ); + widget.onMonthChanged(newMonth); + }, + itemBuilder: (context, index) { + final month = DateTime( + _initialMonth.year, + _initialMonth.month + index, + 1, + ); + return _buildPage(context, month); + }, ), ), - const SizedBox(height: 8.0), - // Анимированное отображение общей суммы. - // Увеличиваем шрифт для большей наглядности. - AnimatedDigitWidget( - value: balance, - fractionDigits: 2, - textStyle: theme.textTheme.displaySmall?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, - ), - suffix: ' $currencySymbol', - ), - const SizedBox(height: 24.0), - - // Добавляем разделитель с цветом из темы. - Divider( - height: 1, - thickness: 1, - color: customColors.divider, - ), - const SizedBox(height: 24.0), - - // Разделение на доходы и расходы. - // Используем IntrinsicHeight для выравнивания высоты дочерних элементов. - IntrinsicHeight( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - // Виджет для отображения доходов. - _buildIncomeExpense( - context, - localizations.income, - income, - customColors.income!, // Используем цвет дохода из темы - Icons.arrow_circle_up_outlined, - ), - // Вертикальный разделитель. - VerticalDivider( - width: 1, - thickness: 1, - color: customColors.divider, - ), - // Виджет для отображения расходов. - _buildIncomeExpense( - context, - localizations.expense, - expense, - customColors.expense!, // Используем цвет расхода из темы - Icons.arrow_circle_down_outlined, - ), - ], - ), + // Добавление: Индикатор теперь находится вне PageView. + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildPageIndicator(context), ), ], ), @@ -108,8 +123,151 @@ class SummaryWidget extends StatelessWidget { ); } - // Вспомогательный метод для создания виджета дохода/расхода. - // Изменен для более компактного и чистого вида. + // Добавление: Метод для построения одной страницы (одного месяца). + Widget _buildPage(BuildContext context, DateTime month) { + final localizations = AppLocalizations.of(context)!; + final customColors = Theme.of(context).extension()!; + final theme = Theme.of(context); + + final monthlyTransactions = widget.transactions.where((t) { + return t.dateTime.month == month.month && t.dateTime.year == month.year; + }).toList(); + + final income = monthlyTransactions + .where((t) => t.isIncome) + .fold(0.0, (sum, item) => sum + item.amount); + final expense = monthlyTransactions + .where((t) => !t.isIncome) + .fold(0.0, (sum, item) => sum + item.amount); + final balance = income - expense; + + // Изменение: Уменьшены вертикальные отступы для предотвращения переполнения. + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + localizations.balance, + style: theme.textTheme.titleLarge?.copyWith( + color: customColors.accent, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8.0), + AnimatedDigitWidget( + value: balance, + fractionDigits: 2, + textStyle: theme.textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + suffix: ' ${widget.currencySymbol}', + ), + const SizedBox(height: 16.0), + Divider( + height: 1, + thickness: 1, + color: customColors.divider, + ), + const SizedBox(height: 16.0), + IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildIncomeExpense( + context, + localizations.income, + income, + customColors.income!, + Icons.arrow_circle_up_outlined, + ), + VerticalDivider( + width: 1, + thickness: 1, + color: customColors.divider, + ), + _buildIncomeExpense( + context, + localizations.expense, + expense, + customColors.expense!, + Icons.arrow_circle_down_outlined, + ), + ], + ), + ), + const Spacer(), // Занимает оставшееся место + Text( + _formatMonth(context, month), + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + ), + // Удаление: Индикатор перенесен из страницы. + ], + ), + ); + } + + // Изменение: Индикатор теперь слушает PageController. + Widget _buildPageIndicator(BuildContext context) { + final customColors = Theme.of(context).extension()!; + return AnimatedBuilder( + animation: _pageController, + builder: (context, child) { + // Проверяем, инициализирован ли контроллер + final page = _pageController.hasClients ? _pageController.page ?? 0 : _monthCount - 1.0; + final isFirstMonth = page < 0.5; + final isLastMonth = page > _monthCount - 1.5; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Анимированная точка "назад" + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + width: isFirstMonth ? 0 : 8.0, + height: 8.0, + margin: EdgeInsets.symmetric(horizontal: isFirstMonth ? 0 : 4.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: customColors.divider, + ), + ), + + // Активная точка + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + width: 12.0, + height: 12.0, + margin: const EdgeInsets.symmetric(horizontal: 4.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: customColors.accent, + ), + ), + + // Анимированная точка "вперед" + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + width: isLastMonth ? 0 : 8.0, + height: 8.0, + margin: EdgeInsets.symmetric(horizontal: isLastMonth ? 0 : 4.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: customColors.divider, + ), + ), + ], + ); + }, + ); + } + Widget _buildIncomeExpense( BuildContext context, String title, @@ -121,21 +279,17 @@ class SummaryWidget extends StatelessWidget { return Expanded( child: Column( children: [ - // Иконка для наглядности. Icon( icon, color: color, size: 32.0, ), const SizedBox(height: 8.0), - // Заголовок. Text( title, style: theme.textTheme.titleMedium, ), const SizedBox(height: 4.0), - // Анимированное отображение суммы. - // Используем основной цвет текста для суммы. AnimatedDigitWidget( value: amount, fractionDigits: 2, diff --git a/lib/pages/sms/sms_page.dart b/lib/pages/sms/sms_page.dart new file mode 100644 index 0000000..9ce39be --- /dev/null +++ b/lib/pages/sms/sms_page.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:budget_app/logic/sms/sms_cubit.dart'; +import 'package:budget_app/logic/sms/sms_state.dart'; +import 'package:budget_app/pages/sms/widgets/sms_message_widget.dart'; +import 'package:budget_app/l10n/app_localizations.dart'; + +/// Экран для отображения SMS сообщений. +/// +/// Использует [SmsCubit] для получения и отображения +/// последних SMS сообщений. +class SmsPage extends StatelessWidget { + const SmsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.smsPageTitle), + ), + body: BlocBuilder( + builder: (context, state) { + if (state is SmsInitial) { + // Начальное состояние, запускаем загрузку + context.read().loadLastMessages(); + return const Center(child: CircularProgressIndicator()); + } else if (state is SmsLoading) { + // Состояние загрузки + return const Center(child: CircularProgressIndicator()); + } else if (state is SmsLoaded) { + // Состояние успешной загрузки + return ListView.builder( + itemCount: state.messages.length, + itemBuilder: (context, index) { + return SmsMessageWidget(message: state.messages[index]); + }, + ); + } else if (state is SmsPermissionDenied) { + // Состояние отказа в разрешении + return Center( + child: Text(AppLocalizations.of(context)!.smsPermissionDenied), + ); + } else if (state is SmsError) { + // Состояние ошибки + return Center( + child: Text(state.message), + ); + } + return const SizedBox.shrink(); + }, + ), + ); + } +} diff --git a/lib/pages/sms/widgets/sms_message_widget.dart b/lib/pages/sms/widgets/sms_message_widget.dart new file mode 100644 index 0000000..50a6183 --- /dev/null +++ b/lib/pages/sms/widgets/sms_message_widget.dart @@ -0,0 +1,20 @@ +import 'package:another_telephony/telephony.dart'; +import 'package:flutter/material.dart'; + +/// Виджет для отображения одного SMS сообщения. +class SmsMessageWidget extends StatelessWidget { + final SmsMessage message; + + const SmsMessageWidget({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(message.body ?? ''), + ), + ); + } +} diff --git a/lib/services/sms_service.dart b/lib/services/sms_service.dart new file mode 100644 index 0000000..34998d5 --- /dev/null +++ b/lib/services/sms_service.dart @@ -0,0 +1,37 @@ +import 'package:another_telephony/telephony.dart'; + +/// Сервис для работы с SMS сообщениями. +/// +/// Использует пакет [telephony] для доступа к SMS на устройстве. +/// Предоставляет методы для запроса разрешений и получения +/// последних SMS сообщений. +class SmsService { + final Telephony _telephony = Telephony.instance; + + /// Запрашивает разрешения на чтение и отправку SMS. + /// + /// Возвращает [true], если разрешения были предоставлены, + /// иначе [false]. + Future requestPermissions() async { + return await _telephony.requestPhoneAndSmsPermissions ?? false; + } + + /// Возвращает список последних SMS сообщений. + /// + /// [count] - количество сообщений для получения. + /// + /// Возвращает список объектов [SmsMessage]. + /// В случае ошибки или отсутствия разрешений, возвращает пустой список. + Future> getLastSmsMessages(int count) async { + final bool? permissionsGranted = + await _telephony.requestPhoneAndSmsPermissions; + if (permissionsGranted ?? false) { + final List messages = await _telephony.getInboxSms( + columns: [SmsColumn.BODY], + sortOrder: [OrderBy(SmsColumn.DATE, sort: Sort.DESC)], + ); + return messages.take(count).toList(); + } + return []; + } +} diff --git a/pubspec.lock b/pubspec.lock index 96fb53d..aa74e64 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.3" + another_telephony: + dependency: "direct main" + description: + name: another_telephony + sha256: "7dd16759099ea3e4ce762c4f5bbfa097940335555d5caa916aaa52eec6a70cb6" + url: "https://pub.dev" + source: hosted + version: "0.4.1" args: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 15a4100..b8ac46d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ environment: dependencies: flutter: sdk: flutter + another_telephony: ^0.4.1 animated_digit: ^3.2.0 # The following adds the Cupertino Icons font to your application. From 6da0da45b7247426a5ec2b9726d74faa5c9de14f Mon Sep 17 00:00:00 2001 From: Sanders Date: Mon, 30 Jun 2025 15:59:43 +0300 Subject: [PATCH 14/35] Adds category management feature Adds the ability to manage categories in the settings page. This includes: - Registers CategoryCubit in the dependency injection container. - Introduces a new CategoryListPage for editing categories. - Adds translations for category management related text. - Adds unselected icon color to the theme. - Updates the date format in the transaction dialog. --- GEMINI.md | 28 -- lib/injection_container.dart | 4 + lib/l10n/app_en.arb | 7 +- lib/l10n/app_localizations.dart | 30 +++ lib/l10n/app_localizations_en.dart | 15 ++ lib/l10n/app_localizations_ru.dart | 16 ++ lib/l10n/app_ru.arb | 7 +- lib/logic/category/category_cubit.dart | 49 ++++ lib/pages/category/category_edit_page.dart | 167 ++++++++++++ lib/pages/category/category_list_page.dart | 100 ++++++++ lib/pages/home/home_page.dart | 2 + .../home/widgets/add_transaction_dialog.dart | 4 +- lib/pages/settings_page.dart | 15 ++ lib/theme/app_theme.dart | 2 + lib/theme/custom_colors.dart | 5 + pubspec.lock | 240 ++++++++++++++++++ pubspec.yaml | 2 + 17 files changed, 661 insertions(+), 32 deletions(-) delete mode 100644 GEMINI.md create mode 100644 lib/logic/category/category_cubit.dart create mode 100644 lib/pages/category/category_edit_page.dart create mode 100644 lib/pages/category/category_list_page.dart diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 93a84bf..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,28 +0,0 @@ -# Project: Budget App - -## General Instructions: - -- Это проект на Flutter используй только его -- Комментируй в коде каждое изменение, которое ты делаешь, что бы мне было понятно и я учился на этом. -- Комментарии и твои ответы должны быть на русском языке -- When generating new Flutter code, please follow the existing coding style. -- Цветовая палитра черно-белая -- Все настройки цветов выноси в тему -- Все элементы должны разбиваться на мелкие и иметь логичную структуру по попкам -- Весь текст должен иметь локализацию чрезе flutter_localizations. Смотри папку l10n -- Разработка ведется под windows, ты можешь исопльзовать его консольные команды - -## Coding Style: - -- Interface names should be prefixed with `I` (e.g., `IUserService`). -- Private class members should be prefixed with an underscore (`_`). -- Учитывай, что в проекте используется bloc cubit архитектура - -## Role -- You are a Flutter assistant that helps users write more efficient and optimizable Flutter code. -- You specialize in identifying patterns that enable Flutter Compiler to automatically apply optimizations, reducing unnecessary re-renders and improving application performance. - -## Follow these guidelines in all code you produce and suggest -- Prefer composition and small components: Break down UI into small, reusable components rather than writing large monolithic components. The code you generate should promote clarity and reusability by composing components together. -- Design for a good user experience - Provide clear, minimal, and non-blocking UI states. When data is loading, show lightweight placeholders (e.g., skeleton screens) rather than intrusive spinners everywhere. Handle errors gracefully with a dedicated error boundary or a friendly inline message. Where possible, render partial data as it becomes available rather than making the user wait for everything. Suspense allows you to declare the loading states in your component tree in a natural way, preventing “flash” states and improving perceived performance. -- \ No newline at end of file diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 5b31d6d..609ce7f 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -10,6 +10,7 @@ import 'data/repositories/interfaces/itag_repository.dart'; import 'data/repositories/interfaces/itransaction_repository.dart'; import 'data/repositories/interfaces/iuser_repository.dart'; import 'logic/auth/auth_bloc.dart'; +import 'logic/category/category_cubit.dart'; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit import 'logic/sms/sms_cubit.dart'; import 'logic/transaction/transaction_bloc.dart'; @@ -56,4 +57,7 @@ Future initDependencies() async { ); getIt.registerFactory(() => SmsCubit(getIt())); + getIt.registerFactory( + () => CategoryCubit(getIt()), + ); // Регистрируем CategoryCubit с зависимостью от UserService } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f93bb20..102cbe9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -37,5 +37,10 @@ "save": "Save", "tag": "Tag", "smsPageTitle": "SMS Messages", - "smsPermissionDenied": "SMS permission is required" + "smsPermissionDenied": "SMS permission is required", + "editCategories": "Edit Categories", + "editCategoriesDescription": "Add, edit, or delete categories", + "color": "Color", + "chooseIcon": "Pick icon", + "chooseIconHint": "Search" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1ee694e..fd0cd48 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -325,6 +325,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'SMS permission is required'** String get smsPermissionDenied; + + /// No description provided for @editCategories. + /// + /// In en, this message translates to: + /// **'Edit Categories'** + String get editCategories; + + /// No description provided for @editCategoriesDescription. + /// + /// In en, this message translates to: + /// **'Add, edit, or delete categories'** + String get editCategoriesDescription; + + /// No description provided for @color. + /// + /// In en, this message translates to: + /// **'Color'** + String get color; + + /// No description provided for @chooseIcon. + /// + /// In en, this message translates to: + /// **'Pick icon'** + String get chooseIcon; + + /// No description provided for @chooseIconHint. + /// + /// In en, this message translates to: + /// **'Search'** + String get chooseIconHint; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index db805ed..5daa938 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -123,4 +123,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get smsPermissionDenied => 'SMS permission is required'; + + @override + String get editCategories => 'Edit Categories'; + + @override + String get editCategoriesDescription => 'Add, edit, or delete categories'; + + @override + String get color => 'Color'; + + @override + String get chooseIcon => 'Pick icon'; + + @override + String get chooseIconHint => 'Search'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 20be8f3..5928c23 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -124,4 +124,20 @@ class AppLocalizationsRu extends AppLocalizations { @override String get smsPermissionDenied => 'Необходимо разрешение на чтение SMS'; + + @override + String get editCategories => 'Редактировать категории'; + + @override + String get editCategoriesDescription => + 'Добавляйте, редактируйте или удаляйте категории'; + + @override + String get color => 'Цвет'; + + @override + String get chooseIcon => 'Выберите иконку'; + + @override + String get chooseIconHint => 'Поиск по анлгийскому наименованию'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 730eecb..72f7f47 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -37,5 +37,10 @@ "save": "Сохранить", "tag": "Тег", "smsPageTitle": "SMS Сообщения", - "smsPermissionDenied": "Необходимо разрешение на чтение SMS" + "smsPermissionDenied": "Необходимо разрешение на чтение SMS", + "editCategories": "Редактировать категории", + "editCategoriesDescription": "Добавляйте, редактируйте или удаляйте категории", + "color": "Цвет", + "chooseIcon": "Выберите иконку", + "chooseIconHint": "Поиск по анлгийскому наименованию" } diff --git a/lib/logic/category/category_cubit.dart b/lib/logic/category/category_cubit.dart new file mode 100644 index 0000000..dc3b9c9 --- /dev/null +++ b/lib/logic/category/category_cubit.dart @@ -0,0 +1,49 @@ + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:budget_app/models/category.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:budget_app/services/user_service.dart'; // Импортируем UserService + +class CategoryCubit extends Cubit> { + final UserService _userService; // Добавляем зависимость от UserService + final Box _categoryBox; // Используем Box для типизации + + CategoryCubit(this._userService) // Принимаем UserService через конструктор + : _categoryBox = Hive.box('categories'), + super([]); + + // Загружает категории, фильтруя их по userId текущего пользователя + void loadCategories() { + final currentUserId = _userService.currentUser?.id; + if (currentUserId != null) { + final userCategories = _categoryBox.values + .where((category) => category.userId == currentUserId) + .toList(); + emit(List.from(userCategories)); + } else { + emit([]); // Если пользователя нет, список категорий пуст + } + } + + // Добавляет новую категорию, присваивая ей userId текущего пользователя + void addCategory(Category category) { + final currentUserId = _userService.currentUser?.id; + if (currentUserId != null) { + final newCategory = category.copyWith(userId: currentUserId); // Присваиваем userId + _categoryBox.put(newCategory.id, newCategory); + loadCategories(); + } + } + + // Обновляет существующую категорию + void updateCategory(Category category) { + _categoryBox.put(category.id, category); + loadCategories(); + } + + // Удаляет категорию по ее id + void deleteCategory(String id) { + _categoryBox.delete(id); + loadCategories(); + } +} diff --git a/lib/pages/category/category_edit_page.dart b/lib/pages/category/category_edit_page.dart new file mode 100644 index 0000000..ec66b29 --- /dev/null +++ b/lib/pages/category/category_edit_page.dart @@ -0,0 +1,167 @@ +import 'package:budget_app/l10n/app_localizations.dart'; +import 'package:budget_app/models/category.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; +import 'package:flutter_iconpicker/Models/configuration.dart'; +import 'package:flutter_iconpicker/flutter_iconpicker.dart'; + +class CategoryEditPage extends StatefulWidget { + final Category? category; + final Function(String, Color, IconData) onSave; + + const CategoryEditPage({super.key, this.category, required this.onSave}); + + @override + _CategoryEditPageState createState() => _CategoryEditPageState(); +} + +class _CategoryEditPageState extends State { + final _formKey = GlobalKey(); + late String _name; + late Color _color; + late IconData _icon; + + @override + void initState() { + super.initState(); + _name = widget.category?.name ?? ''; + _color = widget.category?.color ?? Colors.blue; + _icon = widget.category?.icon ?? Icons.ac_unit; + } + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar( + title: Text( + widget.category == null + ? localizations.addTransactionButton + : localizations.editCategories, + ), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + initialValue: _name, + decoration: InputDecoration( + labelText: localizations.nameFieldLabel, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return localizations.nameFieldEmptyError; + } + return null; + }, + onSaved: (value) { + _name = value!; + }, + ), + const SizedBox(height: 20), + Row( + children: [ + Text(localizations.color), + const SizedBox(width: 10), + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(localizations.color), + content: SingleChildScrollView( + child: ColorPicker( + pickerColor: _color, + onColorChanged: (color) { + setState(() { + _color = color; + }); + }, + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(localizations.save), + ), + ], + ), + ); + }, + child: CircleAvatar(backgroundColor: _color), + ), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Text(localizations.tag), + const SizedBox(width: 10), + GestureDetector( + onTap: () async { + final icon = await showIconPicker( + context, + configuration: SinglePickerConfiguration( + adaptiveDialog: true, + showTooltips: true, + showSearchBar: true, + preSelected: IconPickerIcon( + name: '', + data: _icon, + pack: IconPack.material, + ), + title: Text( + localizations.chooseIcon, + textScaler: const TextScaler.linear(1.25), + ), + searchHintText: localizations.chooseIconHint, + iconPickerShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + //iconPackModes: IconNotifier.starterPacks, + searchComparator: + (String search, IconPickerIcon icon) => + search.toLowerCase().contains( + icon.name + .replaceAll('_', ' ') + .toLowerCase(), + ) || + icon.name.toLowerCase().contains( + search.toLowerCase(), + ), + ), + ); + + if (icon != null) { + setState(() { + _icon = icon.data; + }); + } + }, + child: Icon(_icon), + ), + ], + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + widget.onSave(_name, _color, _icon); + Navigator.pop(context); + } + }, + child: Text(localizations.save), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/category/category_list_page.dart b/lib/pages/category/category_list_page.dart new file mode 100644 index 0000000..713ab11 --- /dev/null +++ b/lib/pages/category/category_list_page.dart @@ -0,0 +1,100 @@ +import 'package:budget_app/l10n/app_localizations.dart'; +import 'package:budget_app/logic/category/category_cubit.dart'; +import 'package:budget_app/models/category.dart'; +import 'package:budget_app/pages/category/category_edit_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; // Импортируем GetIt + +class CategoryListPage extends StatelessWidget { + const CategoryListPage({super.key}); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar(title: Text(localizations.editCategories)), + body: BlocProvider( + create: (context) => + GetIt.instance() + ..loadCategories(), // Получаем CategoryCubit из GetIt + child: BlocBuilder>( + builder: (context, categories) { + return ListView.builder( + itemCount: categories.length, + itemBuilder: (context, index) { + final category = categories[index]; + return ListTile( + leading: CircleAvatar( + backgroundColor: category.color, + child: Icon(category.icon, color: Colors.white), + ), + title: Text(category.name), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CategoryEditPage( + category: category, + onSave: (name, color, icon) { + final updatedCategory = category.copyWith( + name: name, + color: color, + icon: icon, + ); + context.read().updateCategory( + updatedCategory, + ); // Обновляем категорию по ее id + }, + ), + ), + ); + }, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + context.read().deleteCategory( + category.id, + ); // Удаляем категорию по ее id + }, + ), + ], + ), + ); + }, + ); + }, + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CategoryEditPage( + onSave: (name, color, icon) { + final newCategory = Category( + name: name, + color: color, + icon: icon, + isIncome: false, // по умолчанию false + userId: 'default_user', + // userId будет установлен в CategoryCubit + ); + context.read().addCategory(newCategory); + }, + ), + ), + ); + }, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 5e42076..7629910 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; +import '../../theme/custom_colors.dart'; import '/l10n/app_localizations.dart'; import '../../logic/auth/auth_bloc.dart'; @@ -101,6 +102,7 @@ class _HomePageState extends State { ], currentIndex: _selectedIndex, selectedItemColor: Theme.of(context).colorScheme.primary, + unselectedItemColor: Theme.of(context).extension()?.unselectedIcon, onTap: _onItemTapped, ), ); diff --git a/lib/pages/home/widgets/add_transaction_dialog.dart b/lib/pages/home/widgets/add_transaction_dialog.dart index 215d623..fbe8d48 100644 --- a/lib/pages/home/widgets/add_transaction_dialog.dart +++ b/lib/pages/home/widgets/add_transaction_dialog.dart @@ -38,7 +38,7 @@ class _AddTransactionDialogState extends State { void initState() { super.initState(); // Комментарий: Устанавливаем начальное значение с датой и временем. - _dateController.text = DateFormat.yMd().add_Hm().format(_selectedDateTime); + _dateController.text = DateFormat('dd-MM-yyyy').add_Hm().format(_selectedDateTime); } @override @@ -78,7 +78,7 @@ class _AddTransactionDialogState extends State { pickedTime.minute, ); // Комментарий: Обновляем текстовое поле с отформатированной датой и временем. - _dateController.text = DateFormat.yMd().add_Hm().format(_selectedDateTime); + _dateController.text = DateFormat('dd-MM-yyyy').add_Hm().format(_selectedDateTime); }); } diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 0875888..7ae27ab 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,3 +1,4 @@ +import 'package:budget_app/pages/category/category_list_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '/l10n/app_localizations.dart'; @@ -97,6 +98,20 @@ class SettingsPage extends StatelessWidget { ), ), const Divider(), + // Комментарий: ListTile для перехода на страницу редактирования категорий. + ListTile( + title: Text(localizations.editCategories), + subtitle: Text(localizations.editCategoriesDescription), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CategoryListPage(), + ), + ); + }, + ), + const Divider(), ], ), ); diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index ec3775f..e60c95b 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -50,6 +50,7 @@ class AppTheme { expense: Colors.grey[600]!, // Серый для расходов divider: Colors.grey[300]!, // Светло-серый для разделителей accent: Colors.black, // Черный для акцентов + unselectedIcon: Colors.grey[500]!, // Серый для невыбранных иконок ), ], ); @@ -103,6 +104,7 @@ class AppTheme { expense: Colors.grey[500]!, // Серый для расходов divider: Colors.grey[700]!, // Темно-серый для разделителей accent: Colors.white, // Белый для акцентов + unselectedIcon: Colors.grey[400]!, // Светло-серый для невыбранных иконок ), ], ); diff --git a/lib/theme/custom_colors.dart b/lib/theme/custom_colors.dart index ffa1a9e..edb1005 100644 --- a/lib/theme/custom_colors.dart +++ b/lib/theme/custom_colors.dart @@ -9,12 +9,14 @@ class CustomColors extends ThemeExtension { required this.expense, this.divider, // Добавлен цвет для разделителей this.accent, // Добавлен цвет для акцентов + this.unselectedIcon, // Цвет для невыбранных иконок }); final Color? income; final Color? expense; final Color? divider; final Color? accent; + final Color? unselectedIcon; // Цвет для невыбранных иконок @override CustomColors copyWith({ @@ -22,12 +24,14 @@ class CustomColors extends ThemeExtension { Color? expense, Color? divider, Color? accent, + Color? unselectedIcon, }) { return CustomColors( income: income ?? this.income, expense: expense ?? this.expense, divider: divider ?? this.divider, accent: accent ?? this.accent, + unselectedIcon: unselectedIcon ?? this.unselectedIcon, ); } @@ -41,6 +45,7 @@ class CustomColors extends ThemeExtension { expense: Color.lerp(expense, other.expense, t), divider: Color.lerp(divider, other.divider, t), accent: Color.lerp(accent, other.accent, t), + unselectedIcon: Color.lerp(unselectedIcon, other.unselectedIcon, t), ); } } diff --git a/pubspec.lock b/pubspec.lock index aa74e64..a67c496 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" args: dependency: transitive description: @@ -145,6 +153,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + chunked_stream: + dependency: transitive + description: + name: chunked_stream + sha256: b2fde5f81d780f0c1699b8347cae2e413412ae947fc6e64727cc48c6bb54c95c + url: "https://pub.dev" + source: hosted + version: "1.4.2" + circular_buffer: + dependency: transitive + description: + name: circular_buffer + sha256: b3a315fef3fee7fe58879643fc8ce21c7c2449d01c1a8a396dc9e24687f335c4 + url: "https://pub.dev" + source: hosted + version: "0.12.0" clock: dependency: transitive description: @@ -185,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csv: + dependency: transitive + description: + name: csv + sha256: c6aa2679b2a18cb57652920f674488d89712efaf4d3fdf2e537215b35fc19d6c + url: "https://pub.dev" + source: hosted + version: "6.0.0" cupertino_icons: dependency: "direct main" description: @@ -193,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_console: + dependency: transitive + description: + name: dart_console + sha256: "03c23e1f9cc3ac02b608f834808003e6510a5b292a0449f43dfac1c78bd8ee85" + url: "https://pub.dev" + source: hosted + version: "4.1.2" dart_style: dependency: transitive description: @@ -201,6 +241,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + dcli: + dependency: transitive + description: + name: dcli + sha256: "881e88bbad0ada4e3a085a0b55e05afa8e4199392c0c45ac18e3dedc37305b9b" + url: "https://pub.dev" + source: hosted + version: "6.1.2" + dcli_common: + dependency: transitive + description: + name: dcli_common + sha256: f8f77bea6a6d7e4ec2dc24cb4f274fc582938057c2cba44ed0650195ecfcd3ad + url: "https://pub.dev" + source: hosted + version: "6.1.2" + dcli_core: + dependency: transitive + description: + name: dcli_core + sha256: "29fb4833aa950900936646190b30315db511a853273b9fe31d360e5f72c7560b" + url: "https://pub.dev" + source: hosted + version: "6.1.2" + dcli_terminal: + dependency: transitive + description: + name: dcli_terminal + sha256: fb50860855c6b2841aed5bcfb315fa83d1401e109691b6585773a24c149dc4b0 + url: "https://pub.dev" + source: hosted + version: "6.1.2" equatable: dependency: "direct main" description: @@ -254,6 +326,22 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" + flutter_colorpicker: + dependency: "direct main" + description: + name: flutter_colorpicker + sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + flutter_iconpicker: + dependency: "direct main" + description: + name: flutter_iconpicker + sha256: d53b35bcb73325fcfdd36931769a8e7ff33b38e3b7c39b518a226fd0a5f3dc29 + url: "https://pub.dev" + source: hosted + version: "4.0.1" flutter_lints: dependency: "direct dev" description: @@ -272,6 +360,14 @@ packages: description: flutter source: sdk version: "0.0.0" + font_awesome_flutter: + dependency: transitive + description: + name: font_awesome_flutter + sha256: d3a89184101baec7f4600d58840a764d2ef760fe1c5a20ef9e6b0e9b24a07a3a + url: "https://pub.dev" + source: hosted + version: "10.8.0" frontend_server_client: dependency: transitive description: @@ -280,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + functional_data: + dependency: transitive + description: + name: functional_data + sha256: "76d17dc707c40e552014f5a49c0afcc3f1e3f05e800cd6b7872940bfe41a5039" + url: "https://pub.dev" + source: hosted + version: "1.2.0" get_it: dependency: "direct main" description: @@ -296,6 +400,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + globbing: + dependency: transitive + description: + name: globbing + sha256: "4f89cfaf6fa74c9c1740a96259da06bd45411ede56744e28017cc534a12b6e2d" + url: "https://pub.dev" + source: hosted + version: "1.0.0" graphs: dependency: transitive description: @@ -352,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + ini: + dependency: transitive + description: + name: ini + sha256: "12a76c53591ffdf86d1265be3f986888a6dfeb34a85957774bc65912d989a173" + url: "https://pub.dev" + source: hosted + version: "2.1.0" intl: dependency: "direct main" description: @@ -384,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + json2yaml: + dependency: transitive + description: + name: json2yaml + sha256: da94630fbc56079426fdd167ae58373286f603371075b69bf46d848d63ba3e51 + url: "https://pub.dev" + source: hosted + version: "3.0.1" json_annotation: dependency: transitive description: @@ -424,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + lists: + dependency: transitive + description: + name: lists + sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27" + url: "https://pub.dev" + source: hosted + version: "1.0.1" logger: dependency: "direct main" description: @@ -472,6 +608,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_synchronization_temp: + dependency: transitive + description: + name: native_synchronization_temp + sha256: f9ad36a5054c606db10e3dc0c9c352e6d0d56d08621af5c470abf9fa41da40fa + url: "https://pub.dev" + source: hosted + version: "0.7.1" nested: dependency: transitive description: @@ -568,6 +712,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.1" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" provider: dependency: transitive description: @@ -584,6 +736,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pubspec_lock: + dependency: transitive + description: + name: pubspec_lock + sha256: ed5fc1ecd0cdc0e14475a091afcb2c4cbb00e74cebff17635e9abbec18d76cc4 + url: "https://pub.dev" + source: hosted + version: "3.0.2" + pubspec_manager: + dependency: transitive + description: + name: pubspec_manager + sha256: "4000db36057ddc9c95f1c56fd209ce54b1e7c621280f52e159a83342b1e33d62" + url: "https://pub.dev" + source: hosted + version: "1.0.2" pubspec_parse: dependency: transitive description: @@ -592,6 +760,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + scope: + dependency: transitive + description: + name: scope + sha256: "0b056e5b64ca16a2db9e1eb35cf7fd05a9e99a6b15140f82bfa651d081e4819b" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + scrollview_observer: + dependency: transitive + description: + name: scrollview_observer + sha256: "174d4efe7b79459a07662175c4db42c9862dcf78d3978e6e9c2d6c0d8137f4ca" + url: "https://pub.dev" + source: hosted + version: "1.26.1" + settings_yaml: + dependency: transitive + description: + name: settings_yaml + sha256: "31c389f57d21518866ff36ec08cb15bf5c28aa6d324c09ba34a5547474f2b603" + url: "https://pub.dev" + source: hosted + version: "8.3.0" shelf: dependency: transitive description: @@ -677,6 +869,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + strings: + dependency: transitive + description: + name: strings + sha256: "052836499f03897d3860a603b330c1ea3c8a14177b21f34b15a1295f36024aae" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + sum_types: + dependency: transitive + description: + name: sum_types + sha256: c0a0fad9a518d011987e1d9f27fc336194294e55dafdc3699363e52aa5776e09 + url: "https://pub.dev" + source: hosted + version: "0.3.5" + system_info2: + dependency: transitive + description: + name: system_info2 + sha256: "65206bbef475217008b5827374767550a5420ce70a04d2d7e94d1d2253f3efc9" + url: "https://pub.dev" + source: hosted + version: "4.0.0" term_glyph: dependency: transitive description: @@ -709,6 +925,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1" + url: "https://pub.dev" + source: hosted + version: "0.3.1" uuid: dependency: "direct main" description: @@ -717,6 +941,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.1" + validators2: + dependency: transitive + description: + name: validators2 + sha256: "5c63054b2f47b6a3f39e0d0e3f5d38829db4545250144a34c9e1585466de4814" + url: "https://pub.dev" + source: hosted + version: "5.0.0" vector_math: dependency: transitive description: @@ -765,6 +997,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + url: "https://pub.dev" + source: hosted + version: "5.14.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b8ac46d..082c402 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,8 @@ dependencies: sdk: flutter intl: ^0.20.2 bloc: + flutter_colorpicker: ^1.1.0 + flutter_iconpicker: ^4.0.1 dev_dependencies: From 368c62f3fbfb61276e2814712f829e1d6fb6f273 Mon Sep 17 00:00:00 2001 From: Sanders Date: Mon, 30 Jun 2025 17:48:54 +0300 Subject: [PATCH 15/35] Adds category type functionality Implements category type selection (income/expense) during category creation/editing. This enhancement introduces a switch to define categories as either income or expense, providing more granular control over financial tracking. It also includes localization support for new strings ("Icon", "Category type", "Add category"). A random color and icon are assigned to new categories for better user experience. Finally, uses AnimatedList for categories in the list page. --- GEMINI.md | 28 +++ lib/l10n/app_en.arb | 5 +- lib/l10n/app_localizations.dart | 18 ++ lib/l10n/app_localizations_en.dart | 9 + lib/l10n/app_localizations_ru.dart | 9 + lib/l10n/app_ru.arb | 5 +- lib/pages/category/category_edit_page.dart | 47 ++++- lib/pages/category/category_list_page.dart | 178 ++++++++++-------- .../category/widgets/add_category_button.dart | 23 +++ .../category/widgets/category_actions.dart | 36 ++++ .../category/widgets/category_list_item.dart | 75 ++++++++ 11 files changed, 344 insertions(+), 89 deletions(-) create mode 100644 GEMINI.md create mode 100644 lib/pages/category/widgets/add_category_button.dart create mode 100644 lib/pages/category/widgets/category_actions.dart create mode 100644 lib/pages/category/widgets/category_list_item.dart diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..93a84bf --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,28 @@ +# Project: Budget App + +## General Instructions: + +- Это проект на Flutter используй только его +- Комментируй в коде каждое изменение, которое ты делаешь, что бы мне было понятно и я учился на этом. +- Комментарии и твои ответы должны быть на русском языке +- When generating new Flutter code, please follow the existing coding style. +- Цветовая палитра черно-белая +- Все настройки цветов выноси в тему +- Все элементы должны разбиваться на мелкие и иметь логичную структуру по попкам +- Весь текст должен иметь локализацию чрезе flutter_localizations. Смотри папку l10n +- Разработка ведется под windows, ты можешь исопльзовать его консольные команды + +## Coding Style: + +- Interface names should be prefixed with `I` (e.g., `IUserService`). +- Private class members should be prefixed with an underscore (`_`). +- Учитывай, что в проекте используется bloc cubit архитектура + +## Role +- You are a Flutter assistant that helps users write more efficient and optimizable Flutter code. +- You specialize in identifying patterns that enable Flutter Compiler to automatically apply optimizations, reducing unnecessary re-renders and improving application performance. + +## Follow these guidelines in all code you produce and suggest +- Prefer composition and small components: Break down UI into small, reusable components rather than writing large monolithic components. The code you generate should promote clarity and reusability by composing components together. +- Design for a good user experience - Provide clear, minimal, and non-blocking UI states. When data is loading, show lightweight placeholders (e.g., skeleton screens) rather than intrusive spinners everywhere. Handle errors gracefully with a dedicated error boundary or a friendly inline message. Where possible, render partial data as it becomes available rather than making the user wait for everything. Suspense allows you to declare the loading states in your component tree in a natural way, preventing “flash” states and improving perceived performance. +- \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 102cbe9..ec88d24 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -36,11 +36,14 @@ "cancel": "Cancel", "save": "Save", "tag": "Tag", + "icon": "Icon", "smsPageTitle": "SMS Messages", "smsPermissionDenied": "SMS permission is required", "editCategories": "Edit Categories", "editCategoriesDescription": "Add, edit, or delete categories", "color": "Color", "chooseIcon": "Pick icon", - "chooseIconHint": "Search" + "chooseIconHint": "Search", + "categoryType": "Category type", + "addCategory": "Add category" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index fd0cd48..5d0fefc 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -314,6 +314,12 @@ abstract class AppLocalizations { /// **'Tag'** String get tag; + /// No description provided for @icon. + /// + /// In en, this message translates to: + /// **'Icon'** + String get icon; + /// No description provided for @smsPageTitle. /// /// In en, this message translates to: @@ -355,6 +361,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Search'** String get chooseIconHint; + + /// No description provided for @categoryType. + /// + /// In en, this message translates to: + /// **'Category type'** + String get categoryType; + + /// No description provided for @addCategory. + /// + /// In en, this message translates to: + /// **'Add category'** + String get addCategory; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 5daa938..04215b9 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -118,6 +118,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tag => 'Tag'; + @override + String get icon => 'Icon'; + @override String get smsPageTitle => 'SMS Messages'; @@ -138,4 +141,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chooseIconHint => 'Search'; + + @override + String get categoryType => 'Category type'; + + @override + String get addCategory => 'Add category'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 5928c23..dee4073 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -119,6 +119,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tag => 'Тег'; + @override + String get icon => 'Иконка'; + @override String get smsPageTitle => 'SMS Сообщения'; @@ -140,4 +143,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chooseIconHint => 'Поиск по анлгийскому наименованию'; + + @override + String get categoryType => 'Тип категории'; + + @override + String get addCategory => 'Добавить категорию'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 72f7f47..eefbf08 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -36,11 +36,14 @@ "cancel": "Отмена", "save": "Сохранить", "tag": "Тег", + "icon": "Иконка", "smsPageTitle": "SMS Сообщения", "smsPermissionDenied": "Необходимо разрешение на чтение SMS", "editCategories": "Редактировать категории", "editCategoriesDescription": "Добавляйте, редактируйте или удаляйте категории", "color": "Цвет", "chooseIcon": "Выберите иконку", - "chooseIconHint": "Поиск по анлгийскому наименованию" + "chooseIconHint": "Поиск по анлгийскому наименованию", + "categoryType": "Тип категории", + "addCategory": "Добавить категорию" } diff --git a/lib/pages/category/category_edit_page.dart b/lib/pages/category/category_edit_page.dart index ec66b29..a7e451a 100644 --- a/lib/pages/category/category_edit_page.dart +++ b/lib/pages/category/category_edit_page.dart @@ -1,5 +1,6 @@ import 'package:budget_app/l10n/app_localizations.dart'; import 'package:budget_app/models/category.dart'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:flutter_iconpicker/Models/configuration.dart'; @@ -7,7 +8,7 @@ import 'package:flutter_iconpicker/flutter_iconpicker.dart'; class CategoryEditPage extends StatefulWidget { final Category? category; - final Function(String, Color, IconData) onSave; + final Function(String, Color, IconData, bool) onSave; const CategoryEditPage({super.key, this.category, required this.onSave}); @@ -20,13 +21,28 @@ class _CategoryEditPageState extends State { late String _name; late Color _color; late IconData _icon; + late bool _isIncome; // Добавляем состояние для типа категории (доход/расход) @override void initState() { super.initState(); _name = widget.category?.name ?? ''; - _color = widget.category?.color ?? Colors.blue; - _icon = widget.category?.icon ?? Icons.ac_unit; + // Генерация случайного цвета из всей палитры для новой категории + _color = widget.category?.color ?? Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0); + // Генерация случайной иконки из предопределённого набора для новой категории + _icon = widget.category?.icon ?? [ + Icons.attach_money, + Icons.shopping_cart, + Icons.food_bank, + Icons.home, + Icons.directions_car, + Icons.medical_services, + Icons.school, + Icons.work, + Icons.credit_card, + Icons.savings, + ][Random().nextInt(10)]; + _isIncome = widget.category?.isIncome ?? false; // Инициализируем значение типа категории } @override @@ -36,7 +52,7 @@ class _CategoryEditPageState extends State { appBar: AppBar( title: Text( widget.category == null - ? localizations.addTransactionButton + ? localizations.addCategory : localizations.editCategories, ), ), @@ -100,7 +116,7 @@ class _CategoryEditPageState extends State { const SizedBox(height: 20), Row( children: [ - Text(localizations.tag), + Text(localizations.icon), const SizedBox(width: 10), GestureDetector( onTap: () async { @@ -148,11 +164,30 @@ class _CategoryEditPageState extends State { ], ), const SizedBox(height: 20), + // Добавляем выбор типа категории (доход/расход) + Row( + children: [ + Text(localizations.categoryType), + const SizedBox(width: 10), + Switch( + value: _isIncome, + onChanged: (value) { + setState(() { + _isIncome = value; + }); + }, + ), + const SizedBox(width: 10), + Text(_isIncome ? localizations.income : localizations.expense), + ], + ), + const SizedBox(height: 20), ElevatedButton( onPressed: () { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); - widget.onSave(_name, _color, _icon); + // Добавляем параметр _isIncome в вызов onSave + widget.onSave(_name, _color, _icon, _isIncome); Navigator.pop(context); } }, diff --git a/lib/pages/category/category_list_page.dart b/lib/pages/category/category_list_page.dart index 713ab11..e708e68 100644 --- a/lib/pages/category/category_list_page.dart +++ b/lib/pages/category/category_list_page.dart @@ -2,98 +2,114 @@ import 'package:budget_app/l10n/app_localizations.dart'; import 'package:budget_app/logic/category/category_cubit.dart'; import 'package:budget_app/models/category.dart'; import 'package:budget_app/pages/category/category_edit_page.dart'; +import 'package:budget_app/pages/category/widgets/category_list_item.dart'; +import 'package:budget_app/pages/category/widgets/add_category_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; // Импортируем GetIt +import 'package:get_it/get_it.dart'; -class CategoryListPage extends StatelessWidget { +class CategoryListPage extends StatefulWidget { const CategoryListPage({super.key}); + @override + State createState() => _CategoryListPageState(); +} + +class _CategoryListPageState extends State { + final GlobalKey _listKey = GlobalKey(); + @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; - return Scaffold( - appBar: AppBar(title: Text(localizations.editCategories)), - body: BlocProvider( - create: (context) => - GetIt.instance() - ..loadCategories(), // Получаем CategoryCubit из GetIt - child: BlocBuilder>( - builder: (context, categories) { - return ListView.builder( - itemCount: categories.length, - itemBuilder: (context, index) { - final category = categories[index]; - return ListTile( - leading: CircleAvatar( - backgroundColor: category.color, - child: Icon(category.icon, color: Colors.white), - ), - title: Text(category.name), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CategoryEditPage( - category: category, - onSave: (name, color, icon) { - final updatedCategory = category.copyWith( - name: name, - color: color, - icon: icon, - ); - context.read().updateCategory( - updatedCategory, - ); // Обновляем категорию по ее id - }, - ), - ), - ); - }, - ), - IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - context.read().deleteCategory( - category.id, - ); // Удаляем категорию по ее id - }, - ), - ], - ), - ); - }, + // Оборачиваем Scaffold в BlocProvider, чтобы все дочерние виджеты, + // включая floatingActionButton, имели доступ к CategoryCubit. + return BlocProvider( + create: (context) => GetIt.instance()..loadCategories(), + // Используем Builder для получения нового контекста, который "видит" BlocProvider. + child: Builder(builder: (context) { + final localizations = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar(title: Text(localizations.editCategories)), + body: BlocBuilder>( + builder: (context, categories) { + return AnimatedList( + key: _listKey, + initialItemCount: categories.length, + itemBuilder: (context, index, animation) { + final category = categories[index]; + return SizeTransition( + sizeFactor: animation, + child: CategoryListItem( + category: category, + onEdit: () => _editCategory(context, category), + onDelete: () => + _deleteCategory(context, category, index), + ), + ); + }, + ); + }, + ), + floatingActionButton: AddCategoryButton( + // Теперь этот context имеет доступ к CategoryCubit + onPressed: () => _addCategory(context), + ), + ); + }), + ); + } + + void _addCategory(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CategoryEditPage( + onSave: (name, color, icon, isIncome) { + final newCategory = Category( + name: name, + color: color, + icon: icon, + isIncome: isIncome, + userId: 'default_user', ); + context.read().addCategory(newCategory); + _listKey.currentState?.insertItem(0); }, ), ), - floatingActionButton: FloatingActionButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CategoryEditPage( - onSave: (name, color, icon) { - final newCategory = Category( - name: name, - color: color, - icon: icon, - isIncome: false, // по умолчанию false - userId: 'default_user', - // userId будет установлен в CategoryCubit - ); - context.read().addCategory(newCategory); - }, - ), - ), - ); - }, - child: const Icon(Icons.add), + ); + } + + void _editCategory(BuildContext context, Category category) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CategoryEditPage( + category: category, + onSave: (name, color, icon, isIncome) { + final updatedCategory = category.copyWith( + name: name, + color: color, + icon: icon, + isIncome: isIncome, + ); + context.read().updateCategory(updatedCategory); + }, + ), + ), + ); + } + + void _deleteCategory(BuildContext context, Category category, int index) { + context.read().deleteCategory(category.id); + _listKey.currentState?.removeItem( + index, + (context, animation) => SizeTransition( + sizeFactor: animation, + child: CategoryListItem( + category: category, + onEdit: () {}, + onDelete: () {}, + ), ), ); } diff --git a/lib/pages/category/widgets/add_category_button.dart b/lib/pages/category/widgets/add_category_button.dart new file mode 100644 index 0000000..f3ba44a --- /dev/null +++ b/lib/pages/category/widgets/add_category_button.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:budget_app/theme/app_theme.dart'; + +/// Плавающая кнопка добавления категории с анимацией +class AddCategoryButton extends StatelessWidget { + final VoidCallback onPressed; + + const AddCategoryButton({ + super.key, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return FloatingActionButton( + onPressed: onPressed, + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + elevation: 4, + child: const Icon(Icons.add), + ); + } +} diff --git a/lib/pages/category/widgets/category_actions.dart b/lib/pages/category/widgets/category_actions.dart new file mode 100644 index 0000000..14355b1 --- /dev/null +++ b/lib/pages/category/widgets/category_actions.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:budget_app/theme/app_theme.dart'; +import 'package:budget_app/theme/custom_colors.dart'; + +/// Виджет кнопок действий для категории (редактировать/удалить) +class CategoryActions extends StatelessWidget { + final VoidCallback onEdit; + final VoidCallback onDelete; + + const CategoryActions({ + super.key, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).extension(); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.edit, color: colors?.accent), + onPressed: onEdit, + splashRadius: 20, + ), + IconButton( + icon: Icon(Icons.delete, color: colors?.accent), + onPressed: onDelete, + splashRadius: 20, + ), + ], + ); + } +} diff --git a/lib/pages/category/widgets/category_list_item.dart b/lib/pages/category/widgets/category_list_item.dart new file mode 100644 index 0000000..8e7abbd --- /dev/null +++ b/lib/pages/category/widgets/category_list_item.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:budget_app/models/category.dart'; +import 'package:budget_app/theme/app_theme.dart'; +import 'package:budget_app/theme/custom_colors.dart'; +import './category_actions.dart'; + +/// Виджет элемента списка категорий с улучшенным дизайном +class CategoryListItem extends StatelessWidget { + final Category category; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const CategoryListItem({ + super.key, + required this.category, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.extension(); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + // Аватар категории + CircleAvatar( + backgroundColor: category.color.withOpacity(0.2), + radius: 24, + child: Icon( + category.icon, + color: colors?.accent, + size: 20, + ), + ), + const SizedBox(width: 12), + + // Информация о категории + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + category.isIncome ? 'Доход' : 'Расход', + style: theme.textTheme.bodySmall?.copyWith( + color: colors?.unselectedIcon, + ), + ), + ], + ), + ), + + // Кнопки действий + CategoryActions( + onEdit: onEdit, + onDelete: onDelete, + ), + ], + ), + ), + ); + } +} From f304660c742f33535fd0eaf30339e082d0fa48e6 Mon Sep 17 00:00:00 2001 From: Sanders Date: Mon, 30 Jun 2025 17:49:46 +0300 Subject: [PATCH 16/35] fix --- lib/pages/category/widgets/add_category_button.dart | 1 - lib/pages/category/widgets/category_actions.dart | 1 - lib/pages/category/widgets/category_list_item.dart | 1 - 3 files changed, 3 deletions(-) diff --git a/lib/pages/category/widgets/add_category_button.dart b/lib/pages/category/widgets/add_category_button.dart index f3ba44a..b966fb2 100644 --- a/lib/pages/category/widgets/add_category_button.dart +++ b/lib/pages/category/widgets/add_category_button.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:budget_app/theme/app_theme.dart'; /// Плавающая кнопка добавления категории с анимацией class AddCategoryButton extends StatelessWidget { diff --git a/lib/pages/category/widgets/category_actions.dart b/lib/pages/category/widgets/category_actions.dart index 14355b1..2ada43a 100644 --- a/lib/pages/category/widgets/category_actions.dart +++ b/lib/pages/category/widgets/category_actions.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:budget_app/theme/app_theme.dart'; import 'package:budget_app/theme/custom_colors.dart'; /// Виджет кнопок действий для категории (редактировать/удалить) diff --git a/lib/pages/category/widgets/category_list_item.dart b/lib/pages/category/widgets/category_list_item.dart index 8e7abbd..cb49974 100644 --- a/lib/pages/category/widgets/category_list_item.dart +++ b/lib/pages/category/widgets/category_list_item.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:budget_app/models/category.dart'; -import 'package:budget_app/theme/app_theme.dart'; import 'package:budget_app/theme/custom_colors.dart'; import './category_actions.dart'; From a89ab1c910345ef164167350337f9a7ee51c8db4 Mon Sep 17 00:00:00 2001 From: Sanders Date: Thu, 3 Jul 2025 16:21:19 +0300 Subject: [PATCH 17/35] Adds SMS message loading and processing Implements the ability to load and process SMS messages to automatically create transaction records. This includes: - Adding an `SmsMessage` model and Hive adapter. - Creating an `ISmsMessageRepository` and its Hive implementation. - Integrating SMS loading functionality into the `SmsCubit`. - Adding a UI element in settings to trigger SMS loading. - Persisting last SMS sync time in user preferences. --- GEMINI.md | 28 ----- lib/data/database/hive_service.dart | 16 ++- .../hive_sms_message_repository.dart | 50 ++++++++ .../interfaces/isms_message_repository.dart | 25 ++++ lib/hive/hive_registrar.g.dart | 3 + lib/injection_container.dart | 10 +- lib/l10n/app_en.arb | 8 +- lib/l10n/app_localizations.dart | 36 ++++++ lib/l10n/app_localizations_en.dart | 19 +++ lib/l10n/app_localizations_ru.dart | 20 ++++ lib/l10n/app_ru.arb | 8 +- lib/logic/sms/sms_cubit.dart | 88 +++++++++++++- lib/logic/sms/sms_state.dart | 2 +- lib/logic/tag/tag_cubit.dart | 49 ++++++++ lib/models/sms_message.dart | 42 +++++++ lib/models/sms_message.g.dart | 53 +++++++++ lib/models/user.dart | 20 +++- lib/models/user.g.dart | 7 +- lib/pages/settings_page.dart | 28 ++++- lib/pages/sms/widgets/sms_message_widget.dart | 2 +- lib/pages/tag/tag_edit_page.dart | 74 ++++++++++++ lib/pages/tag/tag_list_page.dart | 109 ++++++++++++++++++ lib/pages/tag/widgets/add_tag_button.dart | 21 ++++ lib/pages/tag/widgets/tag_list_item.dart | 47 ++++++++ lib/services/sms_service.dart | 39 ++++++- 25 files changed, 753 insertions(+), 51 deletions(-) delete mode 100644 GEMINI.md create mode 100644 lib/data/repositories/hive_sms_message_repository.dart create mode 100644 lib/data/repositories/interfaces/isms_message_repository.dart create mode 100644 lib/logic/tag/tag_cubit.dart create mode 100644 lib/models/sms_message.dart create mode 100644 lib/models/sms_message.g.dart create mode 100644 lib/pages/tag/tag_edit_page.dart create mode 100644 lib/pages/tag/tag_list_page.dart create mode 100644 lib/pages/tag/widgets/add_tag_button.dart create mode 100644 lib/pages/tag/widgets/tag_list_item.dart diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 93a84bf..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,28 +0,0 @@ -# Project: Budget App - -## General Instructions: - -- Это проект на Flutter используй только его -- Комментируй в коде каждое изменение, которое ты делаешь, что бы мне было понятно и я учился на этом. -- Комментарии и твои ответы должны быть на русском языке -- When generating new Flutter code, please follow the existing coding style. -- Цветовая палитра черно-белая -- Все настройки цветов выноси в тему -- Все элементы должны разбиваться на мелкие и иметь логичную структуру по попкам -- Весь текст должен иметь локализацию чрезе flutter_localizations. Смотри папку l10n -- Разработка ведется под windows, ты можешь исопльзовать его консольные команды - -## Coding Style: - -- Interface names should be prefixed with `I` (e.g., `IUserService`). -- Private class members should be prefixed with an underscore (`_`). -- Учитывай, что в проекте используется bloc cubit архитектура - -## Role -- You are a Flutter assistant that helps users write more efficient and optimizable Flutter code. -- You specialize in identifying patterns that enable Flutter Compiler to automatically apply optimizations, reducing unnecessary re-renders and improving application performance. - -## Follow these guidelines in all code you produce and suggest -- Prefer composition and small components: Break down UI into small, reusable components rather than writing large monolithic components. The code you generate should promote clarity and reusability by composing components together. -- Design for a good user experience - Provide clear, minimal, and non-blocking UI states. When data is loading, show lightweight placeholders (e.g., skeleton screens) rather than intrusive spinners everywhere. Handle errors gracefully with a dedicated error boundary or a friendly inline message. Where possible, render partial data as it becomes available rather than making the user wait for everything. Suspense allows you to declare the loading states in your component tree in a natural way, preventing “flash” states and improving perceived performance. -- \ No newline at end of file diff --git a/lib/data/database/hive_service.dart b/lib/data/database/hive_service.dart index 77d71b6..56c30ea 100644 --- a/lib/data/database/hive_service.dart +++ b/lib/data/database/hive_service.dart @@ -1,7 +1,9 @@ import 'package:budget_app/hive/hive_registrar.g.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:logger/logger.dart'; + import '/models/category.dart'; +import '/models/sms_message.dart'; import '/models/tag.dart'; import '/models/transaction_record.dart'; import '/models/user.dart'; @@ -17,7 +19,8 @@ class HiveService { static const String _tagBox = 'tags'; static const String _transactionBox = 'transactions'; static const String _userBox = 'users'; - + static const String _smsMessageBox = 'smsMessages'; + // ID системного пользователя по умолчанию static const String _defaultUserId = 'default_user'; @@ -35,6 +38,7 @@ class HiveService { Hive.openBox(_tagBox), Hive.openBox(_transactionBox), Hive.openBox(_userBox), + Hive.openBox(_smsMessageBox), ]); // Проверка и заполнение начальными данными @@ -43,9 +47,11 @@ class HiveService { static Box get categories => Hive.box(_categoryBox); static Box get tags => Hive.box(_tagBox); - static Box get transactions => + static Box get transactions => Hive.box(_transactionBox); static Box get users => Hive.box(_userBox); + static Box get smsMessages => + Hive.box(_smsMessageBox); /// Проверяет и заполняет боксы начальными данными при первом запуске static Future _checkAndFillInitialData() async { @@ -72,7 +78,7 @@ class HiveService { final tagBox = tags; if (tagBox.isEmpty) { - _logger.i('Filling initial tags'); + _logger.i('Filling initial tags'); await tagBox.addAll(TagUtils.getDefaultTags(_defaultUserId)); await tagBox.flush(); } @@ -80,7 +86,9 @@ class HiveService { final transactionBox = transactions; if (transactionBox.isEmpty) { _logger.i('Filling sample transactions'); - await transactionBox.addAll(TransactionUtils.getSampleTransactions(_defaultUserId)); + await transactionBox.addAll( + TransactionUtils.getSampleTransactions(_defaultUserId), + ); await transactionBox.flush(); } } diff --git a/lib/data/repositories/hive_sms_message_repository.dart b/lib/data/repositories/hive_sms_message_repository.dart new file mode 100644 index 0000000..7583071 --- /dev/null +++ b/lib/data/repositories/hive_sms_message_repository.dart @@ -0,0 +1,50 @@ +import 'package:hive_ce/hive.dart'; +import '/data/repositories/interfaces/isms_message_repository.dart'; +import '/models/sms_message.dart'; + +/// Hive-реализация репозитория для работы с SMS сообщениями +class HiveSmsMessageRepository implements ISmsMessageRepository { + final Box _box; + + HiveSmsMessageRepository(this._box); + + @override + Future> getAll() async { + return _box.values.toList(); + } + + @override + Future getById(String id) async { + return _box.get(id); + } + + @override + Future add(SmsMessage message) async { + await _box.put(message.id, message); + } + + @override + Future update(SmsMessage message) async { + await add(message); + } + + @override + Future delete(String id) async { + await _box.delete(id); + } + + @override + Future addAll(List messages) async { + final Map messageMap = { + for (var msg in messages) msg.id: msg + }; + await _box.putAll(messageMap); + } + + @override + Future> getByTransactionId(String transactionId) async { + return _box.values + .where((msg) => msg.transactionId == transactionId) + .toList(); + } +} diff --git a/lib/data/repositories/interfaces/isms_message_repository.dart b/lib/data/repositories/interfaces/isms_message_repository.dart new file mode 100644 index 0000000..9c61377 --- /dev/null +++ b/lib/data/repositories/interfaces/isms_message_repository.dart @@ -0,0 +1,25 @@ +import '/models/sms_message.dart'; + +// Интерфейс репозитория для работы с SMS сообщениями +abstract class ISmsMessageRepository { + /// Получает все SMS сообщения + Future> getAll(); + + /// Получает SMS сообщение по ID + Future getById(String id); + + /// Добавляет новое SMS сообщение + Future add(SmsMessage message); + + /// Обновляет существующее SMS сообщение + Future update(SmsMessage message); + + /// Удаляет SMS сообщение по ID + Future delete(String id); + + /// Добавляет несколько SMS сообщений + Future addAll(List messages); + + /// Получает SMS сообщения, связанные с транзакцией + Future> getByTransactionId(String transactionId); +} diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index 7d8e23c..f62746b 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -5,6 +5,7 @@ import 'package:hive_ce/hive.dart'; import 'package:budget_app/hive/hive_adapters.dart'; import 'package:budget_app/models/category.dart'; +import 'package:budget_app/models/sms_message.dart'; import 'package:budget_app/models/tag.dart'; import 'package:budget_app/models/transaction_record.dart'; import 'package:budget_app/models/user.dart'; @@ -14,6 +15,7 @@ extension HiveRegistrar on HiveInterface { registerAdapter(CategoryAdapter()); registerAdapter(ColorAdapter()); registerAdapter(IconDataAdapter()); + registerAdapter(SmsMessageAdapter()); registerAdapter(TagAdapter()); registerAdapter(TransactionRecordAdapter()); registerAdapter(UserAdapter()); @@ -25,6 +27,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(CategoryAdapter()); registerAdapter(ColorAdapter()); registerAdapter(IconDataAdapter()); + registerAdapter(SmsMessageAdapter()); registerAdapter(TagAdapter()); registerAdapter(TransactionRecordAdapter()); registerAdapter(UserAdapter()); diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 609ce7f..59be842 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -1,3 +1,5 @@ +import 'package:budget_app/data/repositories/hive_sms_message_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/isms_message_repository.dart'; import 'package:get_it/get_it.dart'; import 'data/database/hive_service.dart'; @@ -13,6 +15,7 @@ import 'logic/auth/auth_bloc.dart'; import 'logic/category/category_cubit.dart'; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit import 'logic/sms/sms_cubit.dart'; +import 'logic/tag/tag_cubit.dart'; import 'logic/transaction/transaction_bloc.dart'; import 'services/sms_service.dart'; import 'services/user_service.dart'; @@ -28,6 +31,9 @@ Future initDependencies() async { HiveCategoryRepository(HiveService.categories), ); + getIt.registerSingleton( + HiveSmsMessageRepository(HiveService.smsMessages), + ); getIt.registerSingleton(HiveTagRepository(HiveService.tags)); getIt.registerSingleton( @@ -56,8 +62,10 @@ Future initDependencies() async { () => TransactionBloc(transactionRepository: getIt()), ); - getIt.registerFactory(() => SmsCubit(getIt())); + getIt.registerFactory(() => SmsCubit(getIt(), getIt(), getIt())); getIt.registerFactory( () => CategoryCubit(getIt()), ); // Регистрируем CategoryCubit с зависимостью от UserService + + getIt.registerFactory(() => TagCubit(getIt())); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ec88d24..098e583 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -45,5 +45,11 @@ "chooseIcon": "Pick icon", "chooseIconHint": "Search", "categoryType": "Category type", - "addCategory": "Add category" + "addCategory": "Add category", + "editTags": "Edit Tags", + "editTagsDescription": "Add, edit, or delete tags", + "addTag": "Add tag", + "editTag": "Edit tag", + "loadSmsMessages": "Load SMS Messages", + "loadSmsMessagesDescription": "Load and process SMS messages to automatically create transactions" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5d0fefc..7857909 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -373,6 +373,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Add category'** String get addCategory; + + /// No description provided for @editTags. + /// + /// In en, this message translates to: + /// **'Edit Tags'** + String get editTags; + + /// No description provided for @editTagsDescription. + /// + /// In en, this message translates to: + /// **'Add, edit, or delete tags'** + String get editTagsDescription; + + /// No description provided for @addTag. + /// + /// In en, this message translates to: + /// **'Add tag'** + String get addTag; + + /// No description provided for @editTag. + /// + /// In en, this message translates to: + /// **'Edit tag'** + String get editTag; + + /// No description provided for @loadSmsMessages. + /// + /// In en, this message translates to: + /// **'Load SMS Messages'** + String get loadSmsMessages; + + /// No description provided for @loadSmsMessagesDescription. + /// + /// In en, this message translates to: + /// **'Load and process SMS messages to automatically create transactions'** + String get loadSmsMessagesDescription; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 04215b9..b3334c5 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -147,4 +147,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get addCategory => 'Add category'; + + @override + String get editTags => 'Edit Tags'; + + @override + String get editTagsDescription => 'Add, edit, or delete tags'; + + @override + String get addTag => 'Add tag'; + + @override + String get editTag => 'Edit tag'; + + @override + String get loadSmsMessages => 'Load SMS Messages'; + + @override + String get loadSmsMessagesDescription => + 'Load and process SMS messages to automatically create transactions'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index dee4073..d252472 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -149,4 +149,24 @@ class AppLocalizationsRu extends AppLocalizations { @override String get addCategory => 'Добавить категорию'; + + @override + String get editTags => 'Редактировать теги'; + + @override + String get editTagsDescription => + 'Добавляйте, редактируйте или удаляйте теги'; + + @override + String get addTag => 'Добавить тег'; + + @override + String get editTag => 'Редактировать тег'; + + @override + String get loadSmsMessages => 'Загрузить SMS'; + + @override + String get loadSmsMessagesDescription => + 'Загрузить и обработать SMS-сообщения для автоматического создания транзакций'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index eefbf08..3577763 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -45,5 +45,11 @@ "chooseIcon": "Выберите иконку", "chooseIconHint": "Поиск по анлгийскому наименованию", "categoryType": "Тип категории", - "addCategory": "Добавить категорию" + "addCategory": "Добавить категорию", + "editTags": "Редактировать теги", + "editTagsDescription": "Добавляйте, редактируйте или удаляйте теги", + "addTag": "Добавить тег", + "editTag": "Редактировать тег", + "loadSmsMessages": "Загрузить SMS", + "loadSmsMessagesDescription": "Загрузить и обработать SMS-сообщения для автоматического создания транзакций" } diff --git a/lib/logic/sms/sms_cubit.dart b/lib/logic/sms/sms_cubit.dart index 50915b7..d09a7cc 100644 --- a/lib/logic/sms/sms_cubit.dart +++ b/lib/logic/sms/sms_cubit.dart @@ -1,14 +1,24 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:budget_app/logic/sms/sms_state.dart'; +import 'package:budget_app/models/sms_message.dart'; +import 'package:budget_app/models/transaction_record.dart'; import 'package:budget_app/services/sms_service.dart'; +import 'package:budget_app/services/user_service.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:budget_app/data/repositories/interfaces/isms_message_repository.dart'; /// Cubit для управления состоянием SMS. /// /// Отвечает за загрузку SMS сообщений и обработку разрешений. class SmsCubit extends Cubit { final SmsService _smsService; + final ISmsMessageRepository _smsRepository; + final UserService _userService; - SmsCubit(this._smsService) : super(SmsInitial()); + SmsCubit( + this._smsService, + this._smsRepository, + this._userService + ) : super(SmsInitial()); /// Загружает последние 10 SMS сообщений. /// @@ -30,4 +40,78 @@ class SmsCubit extends Cubit { emit(SmsError(e.toString())); } } + + // Комментарий: Метод для загрузки и сохранения SMS-сообщений. + Future loadSmsMessages() async { + // Комментарий: Устанавливаем состояние загрузки, чтобы UI мог отобразить индикатор. + emit(SmsLoading()); + try { + // Комментарий: Запрашиваем разрешение на чтение SMS. + final hasPermissions = await _smsService.requestPermissions(); + if (hasPermissions) { + // Комментарий: Получаем время последней синхронизации из настроек пользователя. + final user = _userService.currentUser; + + // Комментарий: Получаем все SMS-сообщения с момента последней синхронизации. + final messages = await _smsService.getSmsMessagesSince(DateTime.now()); + + // Комментарий: Сохраняем новые сообщения через репозиторий и создаем транзакции. + await _smsRepository.addAll(messages); + for (final message in messages) { + // Комментарий: Пытаемся создать транзакцию из SMS. + _createTransactionFromSms(message); + } + + // Комментарий: Устанавливаем состояние успешной загрузки. + emit(SmsLoaded(messages)); + } else { + // Комментарий: Если разрешение не получено, устанавливаем состояние "в доступе отказано". + emit(SmsPermissionDenied()); + } + } catch (e) { + // Комментарий: В случае ошибки устанавливаем состояние ошибки и передаем сообщение. + emit(SmsError(e.toString())); + } + } + + // Комментарий: Метод для создания транзакции из SMS-сообщения. + Future _createTransactionFromSms(SmsMessage sms) async { + // Комментарий: Здесь будет логика для парсинга SMS и создания транзакции. + // Пример простой логики парсинга (нужно будет доработать под реальные SMS). + final body = sms.body?.toLowerCase() ?? ''; + double? amount; + TransactionRecord? type; + + // Комментарий: Поиск суммы в сообщении. + final amountRegex = RegExp(r'(\d+(\.\d{1,2})?)'); + final match = amountRegex.firstMatch(body); + if (match != null) { + amount = double.tryParse(match.group(1)!); + } + + // TODO + // Комментарий: Определение типа транзакции (доход/расход). + // if (body.contains('покупка') || body.contains('списание')) { + // type = Transaction.expense; + // } else if (body.contains('зачисление') || body.contains('пополнение')) { + // type = TransactionType.income; + // } + + // if (amount != null && type != null) { + // // Комментарий: Создаем новую транзакцию. + // final transaction = TransactionRecord( + // amount: amount, + // type: type, + // date: sms.date ?? DateTime.now(), + // description: sms.body, // Описание берем из тела SMS + // // Комментарий: Здесь можно добавить логику для определения категории и тегов. + // ); + // // Комментарий: Добавляем событие AddTransaction в TransactionBloc. + // _transactionBloc.add(AddTransaction(transaction)); + // // Комментарий: Сохраняем ID транзакции в SMS-сообщении. + // await _smsRepository.update( + // sms.copyWith(transactionId: transaction.id) + // ); + // } + } } diff --git a/lib/logic/sms/sms_state.dart b/lib/logic/sms/sms_state.dart index 8ae8ee2..e885d6c 100644 --- a/lib/logic/sms/sms_state.dart +++ b/lib/logic/sms/sms_state.dart @@ -1,4 +1,4 @@ -import 'package:another_telephony/telephony.dart'; +import 'package:budget_app/models/sms_message.dart'; import 'package:equatable/equatable.dart'; /// Абстрактный класс для состояний SMS. diff --git a/lib/logic/tag/tag_cubit.dart b/lib/logic/tag/tag_cubit.dart new file mode 100644 index 0000000..78c4d9f --- /dev/null +++ b/lib/logic/tag/tag_cubit.dart @@ -0,0 +1,49 @@ + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:budget_app/models/tag.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:budget_app/services/user_service.dart'; + +class TagCubit extends Cubit> { + final UserService _userService; + final Box _tagBox; + + TagCubit(this._userService) + : _tagBox = Hive.box('tags'), + super([]); + + + void loadTags() { + final currentUserId = _userService.currentUser?.id; + if (currentUserId != null) { + final userTags = _tagBox.values + .where((tag) => tag.userId == currentUserId) + .toList(); + emit(List.from(userTags)); + } else { + emit([]); + } + } + + + void addTag(Tag tag) { + final currentUserId = _userService.currentUser?.id; + if (currentUserId != null) { + final newTag = tag.copyWith(userId: currentUserId); + _tagBox.put(newTag.id, newTag); + loadTags(); + } + } + + + void updateTag(Tag tag) { + _tagBox.put(tag.id, tag); + loadTags(); + } + + + void deleteTag(String id) { + _tagBox.delete(id); + loadTags(); + } +} diff --git a/lib/models/sms_message.dart b/lib/models/sms_message.dart new file mode 100644 index 0000000..bfc48e0 --- /dev/null +++ b/lib/models/sms_message.dart @@ -0,0 +1,42 @@ +import 'package:hive_ce/hive.dart'; +import '/utils/id_generator.dart'; + +part 'sms_message.g.dart'; + +@HiveType(typeId: 1004) +class SmsMessage extends HiveObject { + @HiveField(0) + final String id; + + @HiveField(1) + final String? body; + + @HiveField(2) + final String? sender; + + @HiveField(3) + final DateTime? date; + + // Комментарий: Добавлено поле для хранения идентификатора связанной транзакции. + @HiveField(4) + String? transactionId; + + SmsMessage({ + String? id, + this.body, + this.sender, + this.date, + this.transactionId, + }) : id = id ?? IdGenerator.generateId(); + + // Комментарий: Добавляем метод для обновления transactionId + SmsMessage copyWith({String? transactionId}) { + return SmsMessage( + id: id, + body: body, + sender: sender, + date: date, + transactionId: transactionId ?? this.transactionId, + ); + } +} diff --git a/lib/models/sms_message.g.dart b/lib/models/sms_message.g.dart new file mode 100644 index 0000000..4a4d537 --- /dev/null +++ b/lib/models/sms_message.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sms_message.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SmsMessageAdapter extends TypeAdapter { + @override + final typeId = 1004; + + @override + SmsMessage read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SmsMessage( + id: fields[0] as String?, + body: fields[1] as String?, + sender: fields[2] as String?, + date: fields[3] as DateTime?, + transactionId: fields[4] as String?, + ); + } + + @override + void write(BinaryWriter writer, SmsMessage obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.body) + ..writeByte(2) + ..write(obj.sender) + ..writeByte(3) + ..write(obj.date) + ..writeByte(4) + ..write(obj.transactionId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SmsMessageAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/user.dart b/lib/models/user.dart index b21bae0..7556f29 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -35,6 +35,10 @@ class User extends Equatable { @HiveField(6) // Новое поле для валюты по умолчанию final String defaultCurrency; + // Комментарий: Добавлено поле для хранения времени последней синхронизации SMS. + @HiveField(7) + final DateTime lastSmsSyncTime; + /// Конструктор пользователя /// id генерируется автоматически, если не передан User({ @@ -45,8 +49,12 @@ class User extends Equatable { DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное this.isDarkMode = false, // По умолчанию светлая тема this.defaultCurrency = 'RUB', // Валюта по умолчанию - RUB - }) : id = id ?? IdGenerator.generateId(), // Если id не передан, генерируем новый - updatedAt = updatedAt ?? DateTime.now(); // Устанавливаем текущее время по умолчанию + DateTime? lastSmsSyncTime, + }) : id = id ?? IdGenerator.generateId(), // Если id не передан, генерируем новый + updatedAt = updatedAt ?? DateTime.now(), + // Комментарий: Устанавливаем время последней синхронизации SMS по умолчанию на начало предыдущего месяца. + lastSmsSyncTime = lastSmsSyncTime ?? + DateTime(DateTime.now().year, DateTime.now().month - 1, 1); /// Преобразование объекта в Map для сохранения в JSON или передачи по сети Map toMap() { @@ -58,6 +66,7 @@ class User extends Equatable { 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map 'isDarkMode': isDarkMode, 'defaultCurrency': defaultCurrency, + 'lastSmsSyncTime': lastSmsSyncTime.toIso8601String(), }; } @@ -71,13 +80,16 @@ class User extends Equatable { updatedAt: DateTime.parse(map['updatedAt']), // Добавлено updatedAt при создании из Map isDarkMode: map['isDarkMode'] ?? false, defaultCurrency: map['defaultCurrency'] ?? 'RUB', + lastSmsSyncTime: map['lastSmsSyncTime'] != null + ? DateTime.parse(map['lastSmsSyncTime']) + : DateTime(DateTime.now().year, DateTime.now().month - 1, 1), ); } /// Переопределяем toString для удобного отображения в логах @override String toString() { - return 'User(id: $id, name: $name, email: $email, languageCode: $languageCode, isDarkMode: $isDarkMode, defaultCurrency: $defaultCurrency)'; + return 'User(id: $id, name: $name, email: $email, languageCode: $languageCode, isDarkMode: $isDarkMode, defaultCurrency: $defaultCurrency, lastSmsSyncTime: $lastSmsSyncTime)'; } /// Метод для создания копии объекта с возможностью изменения полей @@ -88,6 +100,7 @@ class User extends Equatable { String? languageCode, // Обновлено имя поля bool? isDarkMode, String? defaultCurrency, + DateTime? lastSmsSyncTime, }) { return User( id: id ?? this.id, @@ -97,6 +110,7 @@ class User extends Equatable { updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании isDarkMode: isDarkMode ?? this.isDarkMode, defaultCurrency: defaultCurrency ?? this.defaultCurrency, + lastSmsSyncTime: lastSmsSyncTime ?? this.lastSmsSyncTime, ); } diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index e1a5fe6..0e7ed7b 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -24,13 +24,14 @@ class UserAdapter extends TypeAdapter { updatedAt: fields[4] as DateTime?, isDarkMode: fields[5] == null ? false : fields[5] as bool, defaultCurrency: fields[6] == null ? 'RUB' : fields[6] as String, + lastSmsSyncTime: fields[7] as DateTime?, ); } @override void write(BinaryWriter writer, User obj) { writer - ..writeByte(7) + ..writeByte(8) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -44,7 +45,9 @@ class UserAdapter extends TypeAdapter { ..writeByte(5) ..write(obj.isDarkMode) ..writeByte(6) - ..write(obj.defaultCurrency); + ..write(obj.defaultCurrency) + ..writeByte(7) + ..write(obj.lastSmsSyncTime); } @override diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 7ae27ab..0504ba1 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,4 +1,6 @@ +import 'package:budget_app/logic/sms/sms_cubit.dart'; import 'package:budget_app/pages/category/category_list_page.dart'; +import 'package:budget_app/pages/tag/tag_list_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '/l10n/app_localizations.dart'; @@ -112,6 +114,30 @@ class SettingsPage extends StatelessWidget { }, ), const Divider(), + ListTile( + title: Text(localizations.editTags), + subtitle: Text(localizations.editTagsDescription), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TagListPage(), + ), + ); + }, + ), + const Divider(), + // Комментарий: ListTile для запуска процесса загрузки SMS-сообщений. + ListTile( + title: Text(localizations.loadSmsMessages), + subtitle: Text(localizations.loadSmsMessagesDescription), + onTap: () { + // Комментарий: При нажатии на кнопку мы вызываем метод `loadSmsMessages` у SmsCubit. + // Это инициирует процесс получения и сохранения SMS-сообщений. + context.read().loadSmsMessages(); + }, + ), + const Divider(), ], ), ); @@ -119,4 +145,4 @@ class SettingsPage extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/pages/sms/widgets/sms_message_widget.dart b/lib/pages/sms/widgets/sms_message_widget.dart index 50a6183..268ea40 100644 --- a/lib/pages/sms/widgets/sms_message_widget.dart +++ b/lib/pages/sms/widgets/sms_message_widget.dart @@ -1,4 +1,4 @@ -import 'package:another_telephony/telephony.dart'; +import 'package:budget_app/models/sms_message.dart'; import 'package:flutter/material.dart'; /// Виджет для отображения одного SMS сообщения. diff --git a/lib/pages/tag/tag_edit_page.dart b/lib/pages/tag/tag_edit_page.dart new file mode 100644 index 0000000..5f5e99b --- /dev/null +++ b/lib/pages/tag/tag_edit_page.dart @@ -0,0 +1,74 @@ +import 'package:budget_app/l10n/app_localizations.dart'; +import 'package:budget_app/models/tag.dart'; +import 'package:flutter/material.dart'; + +class TagEditPage extends StatefulWidget { + final Tag? tag; + final Function(String) onSave; + + const TagEditPage({super.key, this.tag, required this.onSave}); + + @override + _TagEditPageState createState() => _TagEditPageState(); +} + +class _TagEditPageState extends State { + final _formKey = GlobalKey(); + late String _name; + + @override + void initState() { + super.initState(); + _name = widget.tag?.name ?? ''; + } + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar( + title: Text( + widget.tag == null + ? localizations.addTag + : localizations.editTag, + ), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + initialValue: _name, + decoration: InputDecoration( + labelText: localizations.nameFieldLabel, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return localizations.nameFieldEmptyError; + } + return null; + }, + onSaved: (value) { + _name = value!; + }, + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + widget.onSave(_name); + Navigator.pop(context); + } + }, + child: Text(localizations.save), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/tag/tag_list_page.dart b/lib/pages/tag/tag_list_page.dart new file mode 100644 index 0000000..0296050 --- /dev/null +++ b/lib/pages/tag/tag_list_page.dart @@ -0,0 +1,109 @@ +import 'package:budget_app/l10n/app_localizations.dart'; +import 'package:budget_app/logic/tag/tag_cubit.dart'; +import 'package:budget_app/models/tag.dart'; +import 'package:budget_app/pages/tag/tag_edit_page.dart'; +import 'package:budget_app/pages/tag/widgets/tag_list_item.dart'; +import 'package:budget_app/pages/tag/widgets/add_tag_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; + +class TagListPage extends StatefulWidget { + const TagListPage({super.key}); + + @override + State createState() => _TagListPageState(); +} + +class _TagListPageState extends State { + final GlobalKey _listKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + + return BlocProvider( + create: (context) => GetIt.instance()..loadTags(), + + child: Builder(builder: (context) { + final localizations = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar(title: Text(localizations.editTags)), + body: BlocBuilder>( + builder: (context, tags) { + return AnimatedList( + key: _listKey, + initialItemCount: tags.length, + itemBuilder: (context, index, animation) { + final tag = tags[index]; + return SizeTransition( + sizeFactor: animation, + child: TagListItem( + tag: tag, + onEdit: () => _editTag(context, tag), + onDelete: () => + _deleteTag(context, tag, index), + ), + ); + }, + ); + }, + ), + floatingActionButton: AddTagButton( + + onPressed: () => _addTag(context), + ), + ); + }), + ); + } + + void _addTag(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => TagEditPage( + onSave: (name) { + final newTag = Tag( + name: name, + userId: 'default_user', + ); + context.read().addTag(newTag); + _listKey.currentState?.insertItem(0); + }, + ), + ), + ); + } + + void _editTag(BuildContext context, Tag tag) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => TagEditPage( + tag: tag, + onSave: (name) { + final updatedTag = tag.copyWith( + name: name, + ); + context.read().updateTag(updatedTag); + }, + ), + ), + ); + } + + void _deleteTag(BuildContext context, Tag tag, int index) { + context.read().deleteTag(tag.id); + _listKey.currentState?.removeItem( + index, + (context, animation) => SizeTransition( + sizeFactor: animation, + child: TagListItem( + tag: tag, + onEdit: () {}, + onDelete: () {}, + ), + ), + ); + } +} diff --git a/lib/pages/tag/widgets/add_tag_button.dart b/lib/pages/tag/widgets/add_tag_button.dart new file mode 100644 index 0000000..d4a9dad --- /dev/null +++ b/lib/pages/tag/widgets/add_tag_button.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class AddTagButton extends StatelessWidget { + final VoidCallback onPressed; + + const AddTagButton({ + super.key, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return FloatingActionButton( + onPressed: onPressed, + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Theme.of(context).colorScheme.onPrimary, + elevation: 4, + child: const Icon(Icons.add), + ); + } +} diff --git a/lib/pages/tag/widgets/tag_list_item.dart b/lib/pages/tag/widgets/tag_list_item.dart new file mode 100644 index 0000000..a24a1ab --- /dev/null +++ b/lib/pages/tag/widgets/tag_list_item.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:budget_app/models/tag.dart'; + +class TagListItem extends StatelessWidget { + final Tag tag; + final VoidCallback onEdit; + final VoidCallback onDelete; + + const TagListItem({ + super.key, + required this.tag, + required this.onEdit, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Text( + tag.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + IconButton( + icon: const Icon(Icons.edit), + onPressed: onEdit, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: onDelete, + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/sms_service.dart b/lib/services/sms_service.dart index 34998d5..1235354 100644 --- a/lib/services/sms_service.dart +++ b/lib/services/sms_service.dart @@ -1,4 +1,5 @@ -import 'package:another_telephony/telephony.dart'; +import 'package:another_telephony/telephony.dart' as telephony_package; +import 'package:budget_app/models/sms_message.dart'; /// Сервис для работы с SMS сообщениями. /// @@ -6,7 +7,7 @@ import 'package:another_telephony/telephony.dart'; /// Предоставляет методы для запроса разрешений и получения /// последних SMS сообщений. class SmsService { - final Telephony _telephony = Telephony.instance; + final telephony_package.Telephony _telephony = telephony_package.Telephony.instance; /// Запрашивает разрешения на чтение и отправку SMS. /// @@ -26,11 +27,37 @@ class SmsService { final bool? permissionsGranted = await _telephony.requestPhoneAndSmsPermissions; if (permissionsGranted ?? false) { - final List messages = await _telephony.getInboxSms( - columns: [SmsColumn.BODY], - sortOrder: [OrderBy(SmsColumn.DATE, sort: Sort.DESC)], + final List messages = await _telephony.getInboxSms( + columns: [telephony_package.SmsColumn.BODY, telephony_package.SmsColumn.ADDRESS, telephony_package.SmsColumn.DATE], + sortOrder: [telephony_package.OrderBy(telephony_package.SmsColumn.DATE, sort: telephony_package.Sort.DESC)], ); - return messages.take(count).toList(); + // Комментарий: Преобразуем сообщения из пакета telephony в нашу модель SmsMessage. + return messages.take(count).map((msg) => SmsMessage( + body: msg.body, + sender: msg.address, + date: DateTime.fromMillisecondsSinceEpoch(msg.date ?? 0), + )).toList(); + } + return []; + } + + // Комментарий: Метод для получения SMS-сообщений с определенной даты. + Future> getSmsMessagesSince(DateTime sinceDate) async { + final bool? permissionsGranted = await _telephony.requestPhoneAndSmsPermissions; + if (permissionsGranted ?? false) { + final List messages = await _telephony.getInboxSms( + columns: [telephony_package.SmsColumn.BODY, telephony_package.SmsColumn.ADDRESS, telephony_package.SmsColumn.DATE], + sortOrder: [telephony_package.OrderBy(telephony_package.SmsColumn.DATE, sort: telephony_package.Sort.ASC)], + ); + // Комментарий: Фильтруем сообщения по дате и преобразуем их в нашу модель. + return messages + .where((msg) => (msg.date ?? 0) >= sinceDate.millisecondsSinceEpoch) + .map((msg) => SmsMessage( + body: msg.body, + sender: msg.address, + date: DateTime.fromMillisecondsSinceEpoch(msg.date ?? 0), + )) + .toList(); } return []; } From 9bae137d085db04e8873fd5bdeda7f592a971af7 Mon Sep 17 00:00:00 2001 From: Sanders Date: Mon, 7 Jul 2025 00:29:15 +0300 Subject: [PATCH 18/35] Refactors user management to use UserCubit This commit refactors the user management system to utilize UserCubit, improving state management and separation of concerns. - Replaces UserService with UserCubit for managing user state. - Introduces AppSettings and GlobalSettings models and repositories for managing app-level settings and configurations. - Modifies dependency injection to register the new repositories and cubits. - Updates UI components (LoginPage, SettingsPage, HomePage, AddTransactionDialog, CategoryListPage, TagListPage) to interact with UserCubit and SettingsCubit. - Removes direct dependency on UserRepository in favor of UserCubit for accessing user information. - Streamlines the authentication process by using UserCubit to handle user creation and login. - Improves settings management by using dedicated repositories and cubits for app settings. --- lib/data/database/hive_service.dart | 63 +++---- .../hive_global_settings_repository.dart | 33 ++++ .../hive_settings_repository.dart | 40 ++++ .../iglobal_settings_repository.dart | 9 + .../interfaces/isettings_repository.dart | 16 ++ lib/hive/hive_registrar.g.dart | 6 + lib/injection_container.dart | 49 +++-- lib/logic/auth/auth_bloc.dart | 16 +- lib/logic/category/category_cubit.dart | 18 +- lib/logic/settings/settings_cubit.dart | 109 ++++++----- lib/logic/settings/settings_state.dart | 39 +++- lib/logic/sms/sms_cubit.dart | 11 +- lib/logic/tag/tag_cubit.dart | 18 +- lib/logic/user/user_cubit.dart | 171 ++++++++++++++++++ lib/logic/user/user_state.dart | 31 ++++ lib/main.dart | 17 +- lib/models/app_settings.dart | 88 +++++++++ lib/models/app_settings.g.dart | 53 ++++++ lib/models/global_settings.dart | 13 ++ lib/models/global_settings.g.dart | 39 ++++ lib/models/user.dart | 65 ++----- lib/models/user.g.dart | 17 +- lib/pages/category/category_list_page.dart | 13 +- lib/pages/home/home_page.dart | 41 ++--- .../home/widgets/add_transaction_dialog.dart | 49 +++-- lib/pages/login/login_page.dart | 131 +++++++++----- lib/pages/settings_page.dart | 137 ++++++++------ lib/pages/tag/tag_list_page.dart | 13 +- lib/services/user_service.dart | 93 ---------- lib/theme/app_theme.dart | 4 +- 30 files changed, 958 insertions(+), 444 deletions(-) create mode 100644 lib/data/repositories/hive_global_settings_repository.dart create mode 100644 lib/data/repositories/hive_settings_repository.dart create mode 100644 lib/data/repositories/interfaces/iglobal_settings_repository.dart create mode 100644 lib/data/repositories/interfaces/isettings_repository.dart create mode 100644 lib/logic/user/user_cubit.dart create mode 100644 lib/logic/user/user_state.dart create mode 100644 lib/models/app_settings.dart create mode 100644 lib/models/app_settings.g.dart create mode 100644 lib/models/global_settings.dart create mode 100644 lib/models/global_settings.g.dart delete mode 100644 lib/services/user_service.dart diff --git a/lib/data/database/hive_service.dart b/lib/data/database/hive_service.dart index 56c30ea..b7e9dda 100644 --- a/lib/data/database/hive_service.dart +++ b/lib/data/database/hive_service.dart @@ -1,4 +1,6 @@ import 'package:budget_app/hive/hive_registrar.g.dart'; +import 'package:budget_app/models/app_settings.dart'; // Добавлен импорт AppSettings +import 'package:budget_app/models/global_settings.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:logger/logger.dart'; @@ -7,44 +9,44 @@ import '/models/sms_message.dart'; import '/models/tag.dart'; import '/models/transaction_record.dart'; import '/models/user.dart'; -import '/utils/category_utils.dart'; -import '/utils/tag_utils.dart'; -import '/utils/transaction_utils.dart'; final _logger = Logger(); class HiveService { - static const String _settingsBox = 'settings'; + static const String _appSettingsBox = + 'app_settings'; // Переименовано для настроек приложения static const String _categoryBox = 'categories'; static const String _tagBox = 'tags'; static const String _transactionBox = 'transactions'; static const String _userBox = 'users'; static const String _smsMessageBox = 'smsMessages'; - - // ID системного пользователя по умолчанию - static const String _defaultUserId = 'default_user'; + static const String _globalSettings = 'global_settings'; static Future init() async { _logger.i('Initializing Hive database'); await Hive.initFlutter(); - //zfinal path = Directory.current.path; Hive.registerAdapters(); // Открытие всех Box'ов await Future.wait([ - Hive.openBox(_settingsBox), + Hive.openBox( + _appSettingsBox, + ), // Открываем Box для AppSettings Hive.openBox(_categoryBox), Hive.openBox(_tagBox), Hive.openBox(_transactionBox), Hive.openBox(_userBox), Hive.openBox(_smsMessageBox), + Hive.openBox(_globalSettings), ]); // Проверка и заполнение начальными данными - await _checkAndFillInitialData(); + _checkAndFillInitialData(); } + static Box get appSettings => + Hive.box(_appSettingsBox); // Геттер для настроек static Box get categories => Hive.box(_categoryBox); static Box get tags => Hive.box(_tagBox); static Box get transactions => @@ -52,44 +54,23 @@ class HiveService { static Box get users => Hive.box(_userBox); static Box get smsMessages => Hive.box(_smsMessageBox); + static Box get globalSettings => + Hive.box(_globalSettings); /// Проверяет и заполняет боксы начальными данными при первом запуске - static Future _checkAndFillInitialData() async { - // Создаем пользователя по умолчанию, если его нет + static void _checkAndFillInitialData() { final userBox = users; - if (userBox.isEmpty) { - _logger.i('Creating default user'); - final defaultUser = User( - id: _defaultUserId, - name: 'Пользователь по умолчанию', - email: 'default@example.com', - ); - await userBox.put(_defaultUserId, defaultUser); - await userBox.flush(); - } - final catBox = categories; - if (catBox.isEmpty) { - _logger.i('Filling initial categories for default user'); - // Теперь передаем userId в getDefaultCategories - await catBox.addAll(CategoryUtils.getDefaultCategories(_defaultUserId)); - await catBox.flush(); - } - final tagBox = tags; - if (tagBox.isEmpty) { - _logger.i('Filling initial tags'); - await tagBox.addAll(TagUtils.getDefaultTags(_defaultUserId)); - await tagBox.flush(); - } - final transactionBox = transactions; - if (transactionBox.isEmpty) { - _logger.i('Filling sample transactions'); - await transactionBox.addAll( - TransactionUtils.getSampleTransactions(_defaultUserId), + + if (userBox.isEmpty && + catBox.isEmpty && + tagBox.isEmpty && + transactionBox.isEmpty) { + _logger.i( + 'Initial data is empty. It will be filled when a user is created.', ); - await transactionBox.flush(); } } } diff --git a/lib/data/repositories/hive_global_settings_repository.dart b/lib/data/repositories/hive_global_settings_repository.dart new file mode 100644 index 0000000..2a054d5 --- /dev/null +++ b/lib/data/repositories/hive_global_settings_repository.dart @@ -0,0 +1,33 @@ +import 'package:hive_ce/hive.dart'; + +import '../../models/global_settings.dart'; +import 'interfaces/iglobal_settings_repository.dart'; + +/// Реализация репозитория глобальных настроек с использованием Hive +class HiveGlobalSettingsRepository implements IGlobalSettingsRepository { + static const String _settingsKey = 'global_settings'; + final Box _box; + + HiveGlobalSettingsRepository(this._box); + + @override + Future getCurrentUserId() async { + try { + final settings = _box.get(_settingsKey); + return settings?.currentUserId; + } catch (e) { + throw Exception('Ошибка получения ID текущего пользователя: $e'); + } + } + + @override + Future setCurrentUserId(String? userId) async { + try { + final settings = _box.get(_settingsKey) ?? GlobalSettings(); + settings.currentUserId = userId; + await _box.put(_settingsKey, settings); + } catch (e) { + throw Exception('Ошибка сохранения ID текущего пользователя: $e'); + } + } +} diff --git a/lib/data/repositories/hive_settings_repository.dart b/lib/data/repositories/hive_settings_repository.dart new file mode 100644 index 0000000..8a34d65 --- /dev/null +++ b/lib/data/repositories/hive_settings_repository.dart @@ -0,0 +1,40 @@ +import 'package:budget_app/data/repositories/interfaces/isettings_repository.dart'; +import 'package:budget_app/models/app_settings.dart'; +import 'package:hive_ce/hive.dart'; + +/// Реализация репозитория настроек с использованием Hive +class HiveSettingsRepository implements ISettingsRepository { + final Box _box; + + HiveSettingsRepository(this._box); + + @override + Future getSettings(String userId) async { + try { + // Возвращаем настройки пользователя или создаем новые по умолчанию + final settings = _box.get(userId, defaultValue: AppSettings(userId: userId)); + return settings!; // Гарантируем возврат не-null значения + } catch (e) { + throw Exception('Ошибка получения настроек: $e'); + } + } + + @override + Future saveSettings(AppSettings settings) async { + try { + // Сохраняем настройки с ключом = userId + await _box.put(settings.userId, settings); + } catch (e) { + throw Exception('Ошибка сохранения настроек: $e'); + } + } + + @override + Future deleteSettings(String userId) async { + try { + await _box.delete(userId); + } catch (e) { + throw Exception('Ошибка удаления настроек: $e'); + } + } +} diff --git a/lib/data/repositories/interfaces/iglobal_settings_repository.dart b/lib/data/repositories/interfaces/iglobal_settings_repository.dart new file mode 100644 index 0000000..f88eba6 --- /dev/null +++ b/lib/data/repositories/interfaces/iglobal_settings_repository.dart @@ -0,0 +1,9 @@ + +/// Интерфейс для глобальных настроек, не связанных с конкретным пользователем +abstract class IGlobalSettingsRepository { + /// Получает ID текущего пользователя + Future getCurrentUserId(); + + /// Устанавливает ID текущего пользователя + Future setCurrentUserId(String? userId); +} diff --git a/lib/data/repositories/interfaces/isettings_repository.dart b/lib/data/repositories/interfaces/isettings_repository.dart new file mode 100644 index 0000000..a9cf609 --- /dev/null +++ b/lib/data/repositories/interfaces/isettings_repository.dart @@ -0,0 +1,16 @@ +import 'package:budget_app/models/app_settings.dart'; + +/// Интерфейс репозитория для работы с настройками приложения +abstract class ISettingsRepository { + /// Получает настройки для указанного пользователя + /// [userId] - идентификатор пользователя + Future getSettings(String userId); + + /// Сохраняет настройки + /// [settings] - объект настроек для сохранения + Future saveSettings(AppSettings settings); + + /// Удаляет настройки для указанного пользователя + /// [userId] - идентификатор пользователя + Future deleteSettings(String userId); +} diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index f62746b..b655859 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -4,7 +4,9 @@ import 'package:hive_ce/hive.dart'; import 'package:budget_app/hive/hive_adapters.dart'; +import 'package:budget_app/models/app_settings.dart'; import 'package:budget_app/models/category.dart'; +import 'package:budget_app/models/global_settings.dart'; import 'package:budget_app/models/sms_message.dart'; import 'package:budget_app/models/tag.dart'; import 'package:budget_app/models/transaction_record.dart'; @@ -12,8 +14,10 @@ import 'package:budget_app/models/user.dart'; extension HiveRegistrar on HiveInterface { void registerAdapters() { + registerAdapter(AppSettingsAdapter()); registerAdapter(CategoryAdapter()); registerAdapter(ColorAdapter()); + registerAdapter(GlobalSettingsAdapter()); registerAdapter(IconDataAdapter()); registerAdapter(SmsMessageAdapter()); registerAdapter(TagAdapter()); @@ -24,8 +28,10 @@ extension HiveRegistrar on HiveInterface { extension IsolatedHiveRegistrar on IsolatedHiveInterface { void registerAdapters() { + registerAdapter(AppSettingsAdapter()); registerAdapter(CategoryAdapter()); registerAdapter(ColorAdapter()); + registerAdapter(GlobalSettingsAdapter()); registerAdapter(IconDataAdapter()); registerAdapter(SmsMessageAdapter()); registerAdapter(TagAdapter()); diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 59be842..4b573da 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -4,10 +4,14 @@ import 'package:get_it/get_it.dart'; import 'data/database/hive_service.dart'; import 'data/repositories/hive_category_repository.dart'; +import 'data/repositories/hive_global_settings_repository.dart'; +import 'data/repositories/hive_settings_repository.dart'; // Добавляем импорт HiveSettingsRepository import 'data/repositories/hive_tag_repository.dart'; import 'data/repositories/hive_transaction_repository.dart'; import 'data/repositories/hive_user_repository.dart'; import 'data/repositories/interfaces/icategory_repository.dart'; +import 'data/repositories/interfaces/iglobal_settings_repository.dart'; +import 'data/repositories/interfaces/isettings_repository.dart'; // Добавляем импорт ISettingsRepository import 'data/repositories/interfaces/itag_repository.dart'; import 'data/repositories/interfaces/itransaction_repository.dart'; import 'data/repositories/interfaces/iuser_repository.dart'; @@ -17,8 +21,8 @@ import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsC import 'logic/sms/sms_cubit.dart'; import 'logic/tag/tag_cubit.dart'; import 'logic/transaction/transaction_bloc.dart'; +import 'logic/user/user_cubit.dart'; // Импортируем UserCubit вместо UserService import 'services/sms_service.dart'; -import 'services/user_service.dart'; final getIt = GetIt.instance; @@ -31,9 +35,6 @@ Future initDependencies() async { HiveCategoryRepository(HiveService.categories), ); - getIt.registerSingleton( - HiveSmsMessageRepository(HiveService.smsMessages), - ); getIt.registerSingleton(HiveTagRepository(HiveService.tags)); getIt.registerSingleton( @@ -44,20 +45,31 @@ Future initDependencies() async { HiveUserRepository(HiveService.users), ); - // Services - getIt.registerSingleton( - UserService(getIt(), getIt(), getIt(), getIt()), + // Регистрируем ISettingsRepository с реализацией HiveSettingsRepository + getIt.registerSingleton( + HiveSettingsRepository(HiveService.appSettings), ); + getIt.registerSingleton( + HiveGlobalSettingsRepository(HiveService.globalSettings), + ); + + // Services getIt.registerSingleton(SmsService()); - // Регистрация сервисов - getIt.registerSingleton( - SettingsCubit(getIt()), - ); // Регистрируем Cubit и передаем IUserRepository + // Регистрируем UserCubit с передачей всех необходимых репозиториев + getIt.registerSingleton( + UserCubit( + settingsRepository: getIt(), + userRepository: getIt(), + categoryRepository: getIt(), + tagRepository: getIt(), + transactionRepository: getIt(), + ), + ); // Blocs - getIt.registerFactory(() => AuthBloc(userService: getIt())); + getIt.registerFactory(() => AuthBloc(userCubit: getIt())); getIt.registerFactory( () => TransactionBloc(transactionRepository: getIt()), ); @@ -65,7 +77,18 @@ Future initDependencies() async { getIt.registerFactory(() => SmsCubit(getIt(), getIt(), getIt())); getIt.registerFactory( () => CategoryCubit(getIt()), - ); // Регистрируем CategoryCubit с зависимостью от UserService + ); // Регистрируем CategoryCubit с зависимостью от UserCubit getIt.registerFactory(() => TagCubit(getIt())); + + + getIt.registerSingleton( + HiveSmsMessageRepository(HiveService.smsMessages), + ); + + // Регистрация сервисов + getIt.registerSingleton( + SettingsCubit(getIt()), + ); // Регистрируем Cubit и передаем IUserRepository и UserService + } diff --git a/lib/logic/auth/auth_bloc.dart b/lib/logic/auth/auth_bloc.dart index bc5ecd1..77d3366 100644 --- a/lib/logic/auth/auth_bloc.dart +++ b/lib/logic/auth/auth_bloc.dart @@ -1,24 +1,25 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:budget_app/models/user.dart'; -import 'package:budget_app/services/user_service.dart'; +import 'package:budget_app/logic/user/user_cubit.dart'; part 'auth_event.dart'; part 'auth_state.dart'; class AuthBloc extends Bloc { - final UserService _userService; + final UserCubit _userCubit; - AuthBloc({required UserService userService}) : _userService = userService, super(AuthInitial()) { + AuthBloc({required UserCubit userCubit}) : _userCubit = userCubit, super(AuthInitial()) { on(_onAuthStarted); on(_onAuthLoggedIn); on(_onAuthLoggedOut); } void _onAuthStarted(AuthStarted event, Emitter emit) { - final user = _userService.currentUser; - if (user != null) { - emit(AuthAuthenticated(user: user)); + // Получаем текущее состояние пользователя из UserCubit + final userState = _userCubit.state; + if (userState is UserLoaded && userState.user != null) { + emit(AuthAuthenticated(user: userState.user!)); } else { emit(AuthUnauthenticated()); } @@ -29,7 +30,8 @@ class AuthBloc extends Bloc { } void _onAuthLoggedOut(AuthLoggedOut event, Emitter emit) { - _userService.logout(); + // Вызываем logout у UserCubit + _userCubit.logout(); emit(AuthUnauthenticated()); } } diff --git a/lib/logic/category/category_cubit.dart b/lib/logic/category/category_cubit.dart index dc3b9c9..782df2d 100644 --- a/lib/logic/category/category_cubit.dart +++ b/lib/logic/category/category_cubit.dart @@ -2,20 +2,22 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:budget_app/models/category.dart'; import 'package:hive_ce/hive.dart'; -import 'package:budget_app/services/user_service.dart'; // Импортируем UserService +import 'package:budget_app/logic/user/user_cubit.dart'; // Импортируем UserCubit class CategoryCubit extends Cubit> { - final UserService _userService; // Добавляем зависимость от UserService + final UserCubit _userCubit; // Добавляем зависимость от UserCubit final Box _categoryBox; // Используем Box для типизации - CategoryCubit(this._userService) // Принимаем UserService через конструктор + CategoryCubit(this._userCubit) // Принимаем UserCubit через конструктор : _categoryBox = Hive.box('categories'), super([]); // Загружает категории, фильтруя их по userId текущего пользователя void loadCategories() { - final currentUserId = _userService.currentUser?.id; - if (currentUserId != null) { + // Получаем текущего пользователя из состояния UserCubit + final userState = _userCubit.state; + if (userState is UserLoaded && userState.user != null) { + final currentUserId = userState.user!.id; final userCategories = _categoryBox.values .where((category) => category.userId == currentUserId) .toList(); @@ -27,8 +29,10 @@ class CategoryCubit extends Cubit> { // Добавляет новую категорию, присваивая ей userId текущего пользователя void addCategory(Category category) { - final currentUserId = _userService.currentUser?.id; - if (currentUserId != null) { + // Получаем текущего пользователя из состояния UserCubit + final userState = _userCubit.state; + if (userState is UserLoaded && userState.user != null) { + final currentUserId = userState.user!.id; final newCategory = category.copyWith(userId: currentUserId); // Присваиваем userId _categoryBox.put(newCategory.id, newCategory); loadCategories(); diff --git a/lib/logic/settings/settings_cubit.dart b/lib/logic/settings/settings_cubit.dart index ef516eb..797f588 100644 --- a/lib/logic/settings/settings_cubit.dart +++ b/lib/logic/settings/settings_cubit.dart @@ -1,68 +1,77 @@ +import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; -import 'package:budget_app/data/repositories/interfaces/iuser_repository.dart'; // Комментарий: Импортируем интерфейс репозитория пользователя -import 'package:budget_app/models/user.dart'; // Комментарий: Импортируем модель пользователя +import 'package:flutter/foundation.dart'; +import '../../data/repositories/interfaces/isettings_repository.dart'; +import '../../models/app_settings.dart'; part 'settings_state.dart'; class SettingsCubit extends Cubit { - final IUserRepository _userRepository; // Комментарий: Зависимость от репозитория пользователя + final ISettingsRepository _settingsRepository; - SettingsCubit(this._userRepository) : super(const SettingsInitial()) { // Комментарий: Принимаем репозиторий в конструкторе - _loadSettings(); - } + SettingsCubit(this._settingsRepository) : super(SettingsInitial()); - void _loadSettings() async { - // Комментарий: Получаем всех пользователей. В реальном приложении здесь будет логика получения текущего залогиненного пользователя. - final users = await _userRepository.getAll(); - User currentUser; - if (users.isEmpty) { - // Комментарий: Если пользователей нет, создаем нового с дефолтными настройками. - currentUser = User(name: 'Default User', email: 'default@example.com'); - await _userRepository.add(currentUser); - } else { - // Комментарий: Используем первого пользователя как текущего. - currentUser = users.first; - } - - emit(state.copyWith( - isDarkMode: currentUser.isDarkMode, - languageCode: currentUser.languageCode, - defaultCurrency: currentUser.defaultCurrency, - )); - } - - Future setDarkMode(bool value) async { - final users = await _userRepository.getAll(); - if (users.isNotEmpty) { - final currentUser = users.first; - final updatedUser = currentUser.copyWith(isDarkMode: value); - await _userRepository.update(updatedUser); - emit(state.copyWith(isDarkMode: value)); + Future loadSettings(String userId) async { + emit(SettingsLoading()); + try { + final settings = await _settingsRepository.getSettings(userId); + emit(SettingsLoaded( + isDarkMode: settings.isDarkMode, + languageCode: settings.languageCode, + defaultCurrency: settings.defaultCurrency, + )); + } catch (e) { + emit(SettingsError(e.toString())); } } - Future setLanguageCode(String code) async { - final users = await _userRepository.getAll(); - if (users.isNotEmpty) { - final currentUser = users.first; - final updatedUser = currentUser.copyWith(languageCode: code); - await _userRepository.update(updatedUser); - emit(state.copyWith(languageCode: code)); + Future toggleDarkMode(bool value, String userId) async { + if (state is SettingsLoaded) { + try { + final currentState = state as SettingsLoaded; + final newState = currentState.copyWith(isDarkMode: value); + emit(newState); + await _saveSettings(newState, userId); + } catch (e) { + emit(SettingsError(e.toString())); + } } } - Future setDefaultCurrency(String currencyCode) async { - final users = await _userRepository.getAll(); - if (users.isNotEmpty) { - final currentUser = users.first; - final updatedUser = currentUser.copyWith(defaultCurrency: currencyCode); - await _userRepository.update(updatedUser); - emit(state.copyWith(defaultCurrency: currencyCode)); + Future changeLanguage(String languageCode, String userId) async { + if (state is SettingsLoaded) { + try { + final currentState = state as SettingsLoaded; + final newState = currentState.copyWith(languageCode: languageCode); + emit(newState); + await _saveSettings(newState, userId); + } catch (e) { + emit(SettingsError(e.toString())); + } } } - void toggleTheme() { - setDarkMode(!state.isDarkMode); + Future changeCurrency(String currency, String userId) async { + if (state is SettingsLoaded) { + try { + final currentState = state as SettingsLoaded; + final newState = currentState.copyWith(defaultCurrency: currency); + emit(newState); + await _saveSettings(newState, userId); + } catch (e) { + emit(SettingsError(e.toString())); + } + } } -} + + Future _saveSettings(SettingsLoaded settings, String userId) async { + final appSettings = AppSettings( + userId: userId, + isDarkMode: settings.isDarkMode, + languageCode: settings.languageCode, + defaultCurrency: settings.defaultCurrency, + ); + await _settingsRepository.saveSettings(appSettings); + } +} \ No newline at end of file diff --git a/lib/logic/settings/settings_state.dart b/lib/logic/settings/settings_state.dart index 16f98ff..fffc314 100644 --- a/lib/logic/settings/settings_state.dart +++ b/lib/logic/settings/settings_state.dart @@ -1,32 +1,51 @@ part of 'settings_cubit.dart'; -class SettingsState extends Equatable { +@immutable +abstract class SettingsState extends Equatable { + const SettingsState(); + + @override + List get props => []; +} + +class SettingsInitial extends SettingsState { + const SettingsInitial(); +} + +class SettingsLoading extends SettingsState {} + +class SettingsLoaded extends SettingsState { final bool isDarkMode; final String languageCode; - final String defaultCurrency; // Новое поле для валюты по умолчанию + final String defaultCurrency; - const SettingsState({ + const SettingsLoaded({ required this.isDarkMode, required this.languageCode, - required this.defaultCurrency, // Теперь обязательный параметр + required this.defaultCurrency, }); @override List get props => [isDarkMode, languageCode, defaultCurrency]; - SettingsState copyWith({ + SettingsLoaded copyWith({ bool? isDarkMode, String? languageCode, - String? defaultCurrency, // Добавляем в copyWith + String? defaultCurrency, }) { - return SettingsState( + return SettingsLoaded( isDarkMode: isDarkMode ?? this.isDarkMode, languageCode: languageCode ?? this.languageCode, - defaultCurrency: defaultCurrency ?? this.defaultCurrency, // Обновляем значение + defaultCurrency: defaultCurrency ?? this.defaultCurrency, ); } } -class SettingsInitial extends SettingsState { - const SettingsInitial() : super(isDarkMode: false, languageCode: 'ru', defaultCurrency: 'RUB'); // Инициализируем валюту по умолчанию +class SettingsError extends SettingsState { + final String message; + + const SettingsError(this.message); + + @override + List get props => [message]; } diff --git a/lib/logic/sms/sms_cubit.dart b/lib/logic/sms/sms_cubit.dart index d09a7cc..e4f1aea 100644 --- a/lib/logic/sms/sms_cubit.dart +++ b/lib/logic/sms/sms_cubit.dart @@ -2,7 +2,7 @@ import 'package:budget_app/logic/sms/sms_state.dart'; import 'package:budget_app/models/sms_message.dart'; import 'package:budget_app/models/transaction_record.dart'; import 'package:budget_app/services/sms_service.dart'; -import 'package:budget_app/services/user_service.dart'; +import 'package:budget_app/logic/user/user_cubit.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:budget_app/data/repositories/interfaces/isms_message_repository.dart'; @@ -12,12 +12,12 @@ import 'package:budget_app/data/repositories/interfaces/isms_message_repository. class SmsCubit extends Cubit { final SmsService _smsService; final ISmsMessageRepository _smsRepository; - final UserService _userService; + final UserCubit _userCubit; SmsCubit( this._smsService, this._smsRepository, - this._userService + this._userCubit ) : super(SmsInitial()); /// Загружает последние 10 SMS сообщений. @@ -49,8 +49,9 @@ class SmsCubit extends Cubit { // Комментарий: Запрашиваем разрешение на чтение SMS. final hasPermissions = await _smsService.requestPermissions(); if (hasPermissions) { - // Комментарий: Получаем время последней синхронизации из настроек пользователя. - final user = _userService.currentUser; + // Комментарий: Получаем текущего пользователя из состояния UserCubit. + final userState = _userCubit.state; + final user = userState is UserLoaded ? userState.user : null; // Комментарий: Получаем все SMS-сообщения с момента последней синхронизации. final messages = await _smsService.getSmsMessagesSince(DateTime.now()); diff --git a/lib/logic/tag/tag_cubit.dart b/lib/logic/tag/tag_cubit.dart index 78c4d9f..fe896f6 100644 --- a/lib/logic/tag/tag_cubit.dart +++ b/lib/logic/tag/tag_cubit.dart @@ -2,20 +2,22 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:budget_app/models/tag.dart'; import 'package:hive_ce/hive.dart'; -import 'package:budget_app/services/user_service.dart'; +import 'package:budget_app/logic/user/user_cubit.dart'; class TagCubit extends Cubit> { - final UserService _userService; + final UserCubit _userCubit; final Box _tagBox; - TagCubit(this._userService) + TagCubit(this._userCubit) : _tagBox = Hive.box('tags'), super([]); void loadTags() { - final currentUserId = _userService.currentUser?.id; - if (currentUserId != null) { + // Получаем текущего пользователя из состояния UserCubit + final userState = _userCubit.state; + if (userState is UserLoaded && userState.user != null) { + final currentUserId = userState.user!.id; final userTags = _tagBox.values .where((tag) => tag.userId == currentUserId) .toList(); @@ -27,8 +29,10 @@ class TagCubit extends Cubit> { void addTag(Tag tag) { - final currentUserId = _userService.currentUser?.id; - if (currentUserId != null) { + // Получаем текущего пользователя из состояния UserCubit + final userState = _userCubit.state; + if (userState is UserLoaded && userState.user != null) { + final currentUserId = userState.user!.id; final newTag = tag.copyWith(userId: currentUserId); _tagBox.put(newTag.id, newTag); loadTags(); diff --git a/lib/logic/user/user_cubit.dart b/lib/logic/user/user_cubit.dart new file mode 100644 index 0000000..8c5a7ef --- /dev/null +++ b/lib/logic/user/user_cubit.dart @@ -0,0 +1,171 @@ +import 'dart:async'; +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; + +import '/data/repositories/interfaces/iglobal_settings_repository.dart'; +import '/data/repositories/interfaces/iuser_repository.dart'; +import '/data/repositories/interfaces/icategory_repository.dart'; +import '/data/repositories/interfaces/itag_repository.dart'; +import '/data/repositories/interfaces/itransaction_repository.dart'; +import '/models/user.dart'; +import '/utils/category_utils.dart'; +import '/utils/tag_utils.dart'; +import '/utils/transaction_utils.dart'; +part 'user_state.dart'; + +/// Cubit для управления состоянием пользователя +class UserCubit extends Cubit { + final IGlobalSettingsRepository _settingsRepository; + final IUserRepository _userRepository; + final ICategoryRepository _categoryRepository; + final ITagRepository _tagRepository; + final ITransactionRepository _transactionRepository; + final Logger _logger = Logger(); + + UserCubit({ + required IGlobalSettingsRepository settingsRepository, + required IUserRepository userRepository, + required ICategoryRepository categoryRepository, + required ITagRepository tagRepository, + required ITransactionRepository transactionRepository, + }) : _settingsRepository = settingsRepository, + _userRepository = userRepository, + _categoryRepository = categoryRepository, + _tagRepository = tagRepository, + _transactionRepository = transactionRepository, + super(UserInitial()) { + _init(); + } + + Future _init() async { + emit(UserLoading()); + try { + final userId = await _settingsRepository.getCurrentUserId(); + User? currentUser; + + if (userId != null) { + currentUser = await _userRepository.getById(userId); + if (currentUser == null) { + _logger.w('User with ID $userId not found. Clearing key.'); + await _settingsRepository.setCurrentUserId(null); + } + } + + if (currentUser == null) { + final allUsers = await _userRepository.getAll(); + if (allUsers.isEmpty) { + _logger.i('No users found, creating default user'); + currentUser = await _createDefaultUser(); + } else { + _logger.i('Setting first user as current'); + currentUser = allUsers.first; + await _setCurrentUser(currentUser); + } + } + + emit(UserLoaded(currentUser)); + } catch (e, stack) { + _logger.e('Error initializing user cubit', error: e, stackTrace: stack); + emit(UserError('Ошибка загрузки пользователя: ${e.toString()}')); + } + } + + Future _createDefaultUser() async { + final user = User( + name: 'Пользователь по умолчанию', + email: 'default@example.com', + ); + await _userRepository.add(user); + + // Создаем начальные данные для нового пользователя + await _createInitialData(user.id); + + await _setCurrentUser(user); + return user; + } + + /// Создает начальные данные для нового пользователя (категории, теги, транзакции) + Future _createInitialData(String userId) async { + try { + // Добавляем начальные категории + await _categoryRepository.addAll( + CategoryUtils.getDefaultCategories(userId), + ); + + // Добавляем начальные теги + await _tagRepository.addAll( + TagUtils.getDefaultTags(userId), + ); + + // Добавляем примеры транзакций + await _transactionRepository.addAll( + TransactionUtils.getSampleTransactions(userId), + ); + + _logger.i('Initial data created for user: $userId'); + } catch (e, stack) { + _logger.e('Error creating initial data for user: $userId', error: e, stackTrace: stack); + // Не прерываем создание пользователя из-за ошибки создания начальных данных + } + } + + Future _setCurrentUser(User user) async { + await _settingsRepository.setCurrentUserId(user.id); + emit(UserLoaded(user)); + } + + /// Устанавливает текущего пользователя + Future setCurrentUser(User user) async { + emit(UserLoading()); + try { + await _setCurrentUser(user); + } catch (e, stack) { + _logger.e('Error setting current user', error: e, stackTrace: stack); + emit(UserError('Ошибка установки пользователя: ${e.toString()}')); + } + } + + /// Выход из системы + Future logout() async { + emit(UserLoading()); + try { + await _settingsRepository.setCurrentUserId(null); + emit(UserLoaded(null)); + } catch (e, stack) { + _logger.e('Error during logout', error: e, stackTrace: stack); + emit(UserError('Ошибка выхода из системы: ${e.toString()}')); + } + } + + /// Создает нового пользователя и устанавливает его как текущего + Future createAndSetUser(String name, String email) async { + emit(UserLoading()); + try { + final user = User( + name: name, + email: email, + ); + await _userRepository.add(user); + + // Создаем начальные данные для нового пользователя + await _createInitialData(user.id); + + await _setCurrentUser(user); + } catch (e, stack) { + _logger.e('Error creating user', error: e, stackTrace: stack); + emit(UserError('Ошибка создания пользователя: ${e.toString()}')); + } + } + + /// Возвращает всех пользователей + Future> getAllUsers() async { + try { + return await _userRepository.getAll(); + } catch (e, stack) { + _logger.e('Error getting all users', error: e, stackTrace: stack); + return []; + } + } +} + diff --git a/lib/logic/user/user_state.dart b/lib/logic/user/user_state.dart new file mode 100644 index 0000000..bf45d51 --- /dev/null +++ b/lib/logic/user/user_state.dart @@ -0,0 +1,31 @@ +part of 'user_cubit.dart'; + +/// Состояния управления пользователем +@immutable +abstract class UserState { + const UserState(); +} + +/// Начальное состояние (инициализация) +class UserInitial extends UserState { + const UserInitial(); +} + +/// Состояние загрузки данных +class UserLoading extends UserState { + const UserLoading(); +} + +/// Состояние успешной загрузки пользователя +class UserLoaded extends UserState { + final User? user; + + const UserLoaded(this.user); +} + +/// Состояние ошибки +class UserError extends UserState { + final String message; + + const UserError(this.message); +} diff --git a/lib/main.dart b/lib/main.dart index fd3581b..a2dac33 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'injection_container.dart' as di; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit import 'logic/sms/sms_cubit.dart'; import 'logic/transaction/transaction_bloc.dart'; +import 'logic/user/user_cubit.dart'; // Импортируем UserCubit void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -30,6 +31,7 @@ class _MyAppState extends State { Widget build(BuildContext context) { return MultiBlocProvider( providers: [ + BlocProvider(create: (context) => GetIt.instance()), // Добавляем UserCubit для управления пользователями BlocProvider(create: (context) => GetIt.instance()..add(AuthStarted())), BlocProvider(create: (context) => GetIt.instance()), BlocProvider(create: (context) => GetIt.instance()), @@ -37,12 +39,19 @@ class _MyAppState extends State { ], child: BlocBuilder( builder: (context, settingsState) { + // Используем значения по умолчанию, если настройки не загружены + final isDarkMode = settingsState is SettingsLoaded + ? settingsState.isDarkMode + : false; + final languageCode = settingsState is SettingsLoaded + ? settingsState.languageCode + : 'ru'; + return MaterialApp( - title: AppLocalizations.of(context)?.appTitle ?? 'Budget App', // Используем локализованный заголовок + title: AppLocalizations.of(context)?.appTitle ?? 'Budget App', theme: AppTheme.lightTheme(), darkTheme: AppTheme.darkTheme(), - themeMode: settingsState.isDarkMode ? ThemeMode.dark : ThemeMode.light, - // Добавляем локализацию + themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light, localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, @@ -50,7 +59,7 @@ class _MyAppState extends State { GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - locale: Locale(settingsState.languageCode), // Устанавливаем текущий язык из настроек + locale: Locale(languageCode), home: BlocBuilder( builder: (context, authState) { if (authState is AuthAuthenticated) { diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart new file mode 100644 index 0000000..9f5a69b --- /dev/null +++ b/lib/models/app_settings.dart @@ -0,0 +1,88 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive_ce/hive.dart'; + +part 'app_settings.g.dart'; + +@HiveType(typeId: 1005) +class AppSettings extends Equatable { + /// Ссылка на ID пользователя, которому принадлежат настройки + @HiveField(0) + final String userId; + + /// Код языка интерфейса (например, 'ru', 'en') + @HiveField(1) + final String languageCode; + + /// Режим темной темы + @HiveField(2) + final bool isDarkMode; + + /// Валюта по умолчанию + @HiveField(3) + final String defaultCurrency; + + /// Дата последнего обновления настроек + @HiveField(4) + final DateTime updatedAt; + + AppSettings({ + required this.userId, + this.languageCode = 'ru', + this.isDarkMode = false, + this.defaultCurrency = 'RUB', + DateTime? updatedAt, + }) : updatedAt = updatedAt ?? DateTime.now(); + + /// Преобразование в Map для сохранения + Map toMap() { + return { + 'userId': userId, + 'languageCode': languageCode, + 'isDarkMode': isDarkMode, + 'defaultCurrency': defaultCurrency, + 'updatedAt': updatedAt.toIso8601String(), + }; + } + + /// Создание из Map + factory AppSettings.fromMap(Map map) { + return AppSettings( + userId: map['userId'], + languageCode: map['languageCode'] ?? 'ru', + isDarkMode: map['isDarkMode'] ?? false, + defaultCurrency: map['defaultCurrency'] ?? 'RUB', + updatedAt: DateTime.parse(map['updatedAt']), + ); + } + + /// Создание копии с обновленными значениями + AppSettings copyWith({ + String? userId, + String? languageCode, + bool? isDarkMode, + String? defaultCurrency, + }) { + return AppSettings( + userId: userId ?? this.userId, + languageCode: languageCode ?? this.languageCode, + isDarkMode: isDarkMode ?? this.isDarkMode, + defaultCurrency: defaultCurrency ?? this.defaultCurrency, + updatedAt: DateTime.now(), + ); + } + + @override + List get props => [ + userId, + languageCode, + isDarkMode, + defaultCurrency, + updatedAt, + ]; + + @override + String toString() { + return 'AppSettings(userId: $userId, languageCode: $languageCode, ' + 'isDarkMode: $isDarkMode, defaultCurrency: $defaultCurrency)'; + } +} diff --git a/lib/models/app_settings.g.dart b/lib/models/app_settings.g.dart new file mode 100644 index 0000000..7069a2c --- /dev/null +++ b/lib/models/app_settings.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_settings.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class AppSettingsAdapter extends TypeAdapter { + @override + final typeId = 1005; + + @override + AppSettings read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AppSettings( + userId: fields[0] as String, + languageCode: fields[1] == null ? 'ru' : fields[1] as String, + isDarkMode: fields[2] == null ? false : fields[2] as bool, + defaultCurrency: fields[3] == null ? 'RUB' : fields[3] as String, + updatedAt: fields[4] as DateTime?, + ); + } + + @override + void write(BinaryWriter writer, AppSettings obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.userId) + ..writeByte(1) + ..write(obj.languageCode) + ..writeByte(2) + ..write(obj.isDarkMode) + ..writeByte(3) + ..write(obj.defaultCurrency) + ..writeByte(4) + ..write(obj.updatedAt); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppSettingsAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/global_settings.dart b/lib/models/global_settings.dart new file mode 100644 index 0000000..b97601a --- /dev/null +++ b/lib/models/global_settings.dart @@ -0,0 +1,13 @@ +import 'package:hive_ce/hive.dart'; + +part 'global_settings.g.dart'; + +@HiveType(typeId: 1006) +class GlobalSettings extends HiveObject { + @HiveField(0) + String? currentUserId; + + GlobalSettings({ + this.currentUserId, + }); +} diff --git a/lib/models/global_settings.g.dart b/lib/models/global_settings.g.dart new file mode 100644 index 0000000..99b067d --- /dev/null +++ b/lib/models/global_settings.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'global_settings.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class GlobalSettingsAdapter extends TypeAdapter { + @override + final typeId = 1006; + + @override + GlobalSettings read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return GlobalSettings(currentUserId: fields[0] as String?); + } + + @override + void write(BinaryWriter writer, GlobalSettings obj) { + writer + ..writeByte(1) + ..writeByte(0) + ..write(obj.currentUserId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GlobalSettingsAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/models/user.dart b/lib/models/user.dart index 7556f29..66024fb 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -3,7 +3,7 @@ import 'package:hive_ce/hive.dart'; import '../utils/id_generator.dart'; // Указываем Hive, что это модель для хранения в базе данных -// typeId: 3 - уникальный идентификатор типа для Hive (у нас уже есть 0,1,2) +// typeId: 1003 - уникальный идентификатор типа для Hive part 'user.g.dart'; @HiveType(typeId: 1003) @@ -11,48 +11,35 @@ part 'user.g.dart'; /// Содержит основную информацию для идентификации пользователя class User extends Equatable { /// Уникальный идентификатор пользователя - @HiveField(0) // Поле 0 в Hive - первое поле модели + @HiveField(0) final String id; /// Имя пользователя для отображения в интерфейсе - @HiveField(1) // Поле 1 в Hive - второе поле модели + @HiveField(1) final String name; /// Email пользователя (может использоваться для входа в будущем) - @HiveField(2) // Поле 2 в Hive - третье поле модели + @HiveField(2) final String email; - @HiveField(3) // Поле 3 в Hive - язык пользователя - final String languageCode; // Переименовано с 'language' на 'languageCode' - - @HiveField(4) + @HiveField(3) /// Дата и время последнего обновления объекта final DateTime updatedAt; - @HiveField(5) // Новое поле для режима темы (светлая/темная) - final bool isDarkMode; - - @HiveField(6) // Новое поле для валюты по умолчанию - final String defaultCurrency; - // Комментарий: Добавлено поле для хранения времени последней синхронизации SMS. - @HiveField(7) + @HiveField(4) final DateTime lastSmsSyncTime; /// Конструктор пользователя /// id генерируется автоматически, если не передан User({ - String? id, // Опциональный параметр - если null, сгенерируется автоматически - required this.name, // Обязательный параметр - required this.email, // Обязательный параметр - this.languageCode = 'ru', // Язык по умолчанию - русский - DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное - this.isDarkMode = false, // По умолчанию светлая тема - this.defaultCurrency = 'RUB', // Валюта по умолчанию - RUB + String? id, + required this.name, + required this.email, + DateTime? updatedAt, DateTime? lastSmsSyncTime, - }) : id = id ?? IdGenerator.generateId(), // Если id не передан, генерируем новый + }) : id = id ?? IdGenerator.generateId(), updatedAt = updatedAt ?? DateTime.now(), - // Комментарий: Устанавливаем время последней синхронизации SMS по умолчанию на начало предыдущего месяца. lastSmsSyncTime = lastSmsSyncTime ?? DateTime(DateTime.now().year, DateTime.now().month - 1, 1); @@ -62,10 +49,7 @@ class User extends Equatable { 'id': id, 'name': name, 'email': email, - 'languageCode': languageCode, // Обновлено имя поля - 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map - 'isDarkMode': isDarkMode, - 'defaultCurrency': defaultCurrency, + 'updatedAt': updatedAt.toIso8601String(), 'lastSmsSyncTime': lastSmsSyncTime.toIso8601String(), }; } @@ -76,10 +60,7 @@ class User extends Equatable { id: map['id'], name: map['name'], email: map['email'], - languageCode: map['languageCode'] ?? 'ru', // Обновлено имя поля - updatedAt: DateTime.parse(map['updatedAt']), // Добавлено updatedAt при создании из Map - isDarkMode: map['isDarkMode'] ?? false, - defaultCurrency: map['defaultCurrency'] ?? 'RUB', + updatedAt: DateTime.parse(map['updatedAt']), lastSmsSyncTime: map['lastSmsSyncTime'] != null ? DateTime.parse(map['lastSmsSyncTime']) : DateTime(DateTime.now().year, DateTime.now().month - 1, 1), @@ -89,33 +70,25 @@ class User extends Equatable { /// Переопределяем toString для удобного отображения в логах @override String toString() { - return 'User(id: $id, name: $name, email: $email, languageCode: $languageCode, isDarkMode: $isDarkMode, defaultCurrency: $defaultCurrency, lastSmsSyncTime: $lastSmsSyncTime)'; + return 'User(id: $id, name: $name, email: $email, lastSmsSyncTime: $lastSmsSyncTime)'; } - /// Метод для создания копии объекта с возможностью изменения полей + // Используем Equatable для сравнения объектов по их свойствам. + @override + List get props => [id]; + User copyWith({ String? id, String? name, String? email, - String? languageCode, // Обновлено имя поля - bool? isDarkMode, - String? defaultCurrency, DateTime? lastSmsSyncTime, }) { return User( id: id ?? this.id, name: name ?? this.name, email: email ?? this.email, - languageCode: languageCode ?? this.languageCode, // Обновлено имя поля - updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании - isDarkMode: isDarkMode ?? this.isDarkMode, - defaultCurrency: defaultCurrency ?? this.defaultCurrency, + updatedAt: DateTime.now(), lastSmsSyncTime: lastSmsSyncTime ?? this.lastSmsSyncTime, ); } - - // Используем Equatable для сравнения объектов по их свойствам. - // В данном случае, мы считаем пользователей уникальными по их 'id'. - @override - List get props => [id]; } diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index 0e7ed7b..9ac4782 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -20,18 +20,15 @@ class UserAdapter extends TypeAdapter { id: fields[0] as String?, name: fields[1] as String, email: fields[2] as String, - languageCode: fields[3] == null ? 'ru' : fields[3] as String, - updatedAt: fields[4] as DateTime?, - isDarkMode: fields[5] == null ? false : fields[5] as bool, - defaultCurrency: fields[6] == null ? 'RUB' : fields[6] as String, - lastSmsSyncTime: fields[7] as DateTime?, + updatedAt: fields[3] as DateTime?, + lastSmsSyncTime: fields[4] as DateTime?, ); } @override void write(BinaryWriter writer, User obj) { writer - ..writeByte(8) + ..writeByte(5) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -39,14 +36,8 @@ class UserAdapter extends TypeAdapter { ..writeByte(2) ..write(obj.email) ..writeByte(3) - ..write(obj.languageCode) - ..writeByte(4) ..write(obj.updatedAt) - ..writeByte(5) - ..write(obj.isDarkMode) - ..writeByte(6) - ..write(obj.defaultCurrency) - ..writeByte(7) + ..writeByte(4) ..write(obj.lastSmsSyncTime); } diff --git a/lib/pages/category/category_list_page.dart b/lib/pages/category/category_list_page.dart index e708e68..510ca7c 100644 --- a/lib/pages/category/category_list_page.dart +++ b/lib/pages/category/category_list_page.dart @@ -2,8 +2,9 @@ import 'package:budget_app/l10n/app_localizations.dart'; import 'package:budget_app/logic/category/category_cubit.dart'; import 'package:budget_app/models/category.dart'; import 'package:budget_app/pages/category/category_edit_page.dart'; -import 'package:budget_app/pages/category/widgets/category_list_item.dart'; import 'package:budget_app/pages/category/widgets/add_category_button.dart'; +import 'package:budget_app/pages/category/widgets/category_list_item.dart'; +import 'package:budget_app/logic/user/user_cubit.dart'; // Импортируем UserCubit import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; @@ -64,12 +65,20 @@ class _CategoryListPageState extends State { MaterialPageRoute( builder: (_) => CategoryEditPage( onSave: (name, color, icon, isIncome) { + // Получаем ID текущего пользователя из UserCubit + final userState = context.read().state; + if (userState is! UserLoaded || userState.user == null) { + // Обработка случая, когда пользователь не аутентифицирован + // Возможно, показать сообщение об ошибке или перенаправить на страницу входа + return; + } + final currentUserId = userState.user!.id; final newCategory = Category( name: name, color: color, icon: icon, isIncome: isIncome, - userId: 'default_user', + userId: currentUserId, ); context.read().addCategory(newCategory); _listKey.currentState?.insertItem(0); diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 7629910..e67a27b 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -1,12 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; import '../../theme/custom_colors.dart'; import '/l10n/app_localizations.dart'; -import '../../logic/auth/auth_bloc.dart'; +import '../../logic/settings/settings_cubit.dart'; import '../../logic/transaction/transaction_bloc.dart'; -import '../../services/user_service.dart'; +import '../../logic/user/user_cubit.dart'; import '../home/widgets/summary_widget.dart'; // Импортируем новый виджет сводки import '../reports_page.dart'; // Импортируем новую страницу отчетов import '../settings_page.dart'; @@ -36,17 +35,12 @@ class _HomePageState extends State { @override void initState() { super.initState(); - // Получаем ID текущего пользователя из AuthBloc - final authState = context.read().state; - if (authState is AuthAuthenticated) { - _currentUserId = authState.user.id; + // Получаем ID текущего пользователя из UserCubit + final userState = context.read().state; + if (userState is UserLoaded && userState.user != null) { + _currentUserId = userState.user!.id; // Загружаем транзакции через глобальный TransactionBloc context.read().add(LoadTransactions(userId: _currentUserId)); - } else { - // Если пользователь не аутентифицирован, можно перенаправить на страницу входа - // или использовать ID по умолчанию, если это применимо. - // В данном случае, для простоты, мы предполагаем, что пользователь всегда аутентифицирован. - _currentUserId = 'default_user'; // Заглушка, если что-то пошло не так } } @@ -153,16 +147,21 @@ class _TransactionsPageState extends State { children: [ // Изменение: SummaryWidget теперь получает все транзакции, // выбранный месяц и колбэк для его изменения. - Builder( - builder: (context) { - final userService = GetIt.instance(); - final currencySymbol = userService.currentUser?.defaultCurrency ?? '₽'; - + BlocBuilder( + builder: (context, settingsState) { + if (settingsState is SettingsLoaded) { + return SummaryWidget( + transactions: state.transactions, + selectedMonth: _selectedMonth, + onMonthChanged: _onMonthChanged, + currencySymbol: settingsState.defaultCurrency, + ); + } return SummaryWidget( - transactions: state.transactions, // Передаем все транзакции для расчетов - selectedMonth: _selectedMonth, // Передаем текущий выбранный месяц - onMonthChanged: _onMonthChanged, // Передаем колбэк - currencySymbol: currencySymbol, + transactions: state.transactions, + selectedMonth: _selectedMonth, + onMonthChanged: _onMonthChanged, + currencySymbol: 'RUB', ); }, ), diff --git a/lib/pages/home/widgets/add_transaction_dialog.dart b/lib/pages/home/widgets/add_transaction_dialog.dart index fbe8d48..c8cf9d9 100644 --- a/lib/pages/home/widgets/add_transaction_dialog.dart +++ b/lib/pages/home/widgets/add_transaction_dialog.dart @@ -6,12 +6,13 @@ import 'package:intl/intl.dart'; import '../../../l10n/app_localizations.dart'; import '../../../logic/auth/auth_bloc.dart'; +import '../../../logic/settings/settings_cubit.dart'; // Добавлен импорт SettingsCubit import '../../../logic/transaction/transaction_bloc.dart'; -import '../../../data/repositories/interfaces/itag_repository.dart'; // Импортируем интерфейс репозитория тегов +import '../../../data/repositories/interfaces/itag_repository.dart'; import '../../../models/category.dart'; -import '../../../models/tag.dart'; // Импортируем модель тега +import '../../../models/tag.dart'; import '../../../models/transaction_record.dart'; -import '../../../services/user_service.dart'; +import '../../../logic/user/user_cubit.dart'; import '../../../utils/category_utils.dart'; class AddTransactionDialog extends StatefulWidget { @@ -114,17 +115,33 @@ class _AddTransactionDialogState extends State { return; } + // Получаем валюту из настроек + final settingsState = context.read().state; + final currency = (settingsState is SettingsLoaded) + ? settingsState.defaultCurrency + : 'RUB'; + + // Получаем ID текущего пользователя из UserCubit + final userState = context.read().state; + if (userState is! UserLoaded || userState.user == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.transactionErrorText('User not found'), + ), + ), + ); + return; + } + final newTransaction = TransactionRecord( amount: amount, vendor: _vendorController.text, category: _selectedCategory!, - // Комментарий: Используем _selectedDateTime для сохранения точного времени транзакции. dateTime: _selectedDateTime, - // Комментарий: Добавляем выбранный тег в транзакцию. tag: _selectedTag, - currency: - GetIt.instance().currentUser?.defaultCurrency ?? 'USD', - userId: authState.user.id, + currency: currency, + userId: userState.user!.id, ); context.read().add( @@ -137,9 +154,15 @@ class _AddTransactionDialogState extends State { @override Widget build(BuildContext context) { final localizations = AppLocalizations.of(context)!; - final categories = CategoryUtils.getDefaultCategories( - (context.read().state as AuthAuthenticated).user.id, - ).where((c) => c.isIncome == _isIncome).toList(); + + // Получаем ID текущего пользователя из UserCubit + final userState = context.read().state; + final userId = userState is UserLoaded && userState.user != null + ? userState.user!.id + : ''; + + final categories = CategoryUtils.getDefaultCategories(userId) + .where((c) => c.isIncome == _isIncome).toList(); return AlertDialog( title: Text(localizations.addTransactionButton), @@ -210,9 +233,7 @@ class _AddTransactionDialogState extends State { // Комментарий: Добавляем выпадающий список для выбора тега. // Он будет загружать теги асинхронно для текущего пользователя. FutureBuilder>( - future: GetIt.instance().getAllByUser( - (context.read().state as AuthAuthenticated).user.id, - ), + future: GetIt.instance().getAllByUser(userId), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); diff --git a/lib/pages/login/login_page.dart b/lib/pages/login/login_page.dart index 7dcb879..05a2b42 100644 --- a/lib/pages/login/login_page.dart +++ b/lib/pages/login/login_page.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; import '/l10n/app_localizations.dart'; import '../../logic/auth/auth_bloc.dart'; -import '../../services/user_service.dart'; +import '../../logic/user/user_cubit.dart'; // Импортируем UserCubit вместо UserService class LoginPage extends StatefulWidget { const LoginPage({super.key}); @@ -20,56 +19,90 @@ class _LoginPageState extends State { @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of(context)!; // Получаем экземпляр локализации + final localizations = AppLocalizations.of( + context, + )!; // Получаем экземпляр локализации return Scaffold( - appBar: AppBar(title: Text(localizations.loginPageTitle)), // Локализованный заголовок - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Form( - key: _formKey, - child: Column( - children: [ - TextFormField( - controller: _nameController, - decoration: InputDecoration(labelText: localizations.nameFieldLabel), // Локализованный текст - validator: (value) { - if (value == null || value.isEmpty) { - return localizations.nameFieldEmptyError; // Локализованный текст - } - return null; - }, - ), - TextFormField( - controller: _emailController, - decoration: InputDecoration(labelText: localizations.emailFieldLabel), // Локализованный текст - validator: (value) { - if (value == null || value.isEmpty) { - return localizations.emailFieldEmptyError; // Локализованный текст - } - return null; - }, - ), - const SizedBox(height: 20), - ElevatedButton( - onPressed: () async { // Комментарий: Делаем onPressed асинхронным - if (_formKey.currentState!.validate()) { - final userService = GetIt.instance(); - final authBloc = context.read(); // Комментарий: Получаем AuthBloc до асинхронной операции - await userService.createAndSetUser( // Комментарий: Используем await - _nameController.text, - _emailController.text, - ); - if (!mounted) return; // Комментарий: Проверяем, что виджет все еще в дереве виджетов после await - final user = userService.currentUser; - if (user != null) { - authBloc.add(AuthLoggedIn(user: user)); // Комментарий: Используем полученный AuthBloc + appBar: AppBar( + title: Text(localizations.loginPageTitle), + ), // Локализованный заголовок + body: BlocListener( + // Слушаем изменения состояния UserCubit + listener: (context, state) { + if (state is UserLoaded && state.user != null) { + // Комментарий: При успешном создании пользователя отправляем событие в AuthBloc + context.read().add(AuthLoggedIn(user: state.user!)); + } else if (state is UserError) { + // Комментарий: Показываем ошибку пользователю + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.message)), + ); + } + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: localizations.nameFieldLabel, + ), // Локализованный текст + validator: (value) { + if (value == null || value.isEmpty) { + return localizations + .nameFieldEmptyError; // Локализованный текст } - } - }, - child: Text(localizations.loginButtonText), // Локализованный текст - ), - ], + return null; + }, + ), + TextFormField( + controller: _emailController, + decoration: InputDecoration( + labelText: localizations.emailFieldLabel, + ), // Локализованный текст + validator: (value) { + if (value == null || value.isEmpty) { + return localizations + .emailFieldEmptyError; // Локализованный текст + } + return null; + }, + ), + const SizedBox(height: 20), + BlocBuilder( + // Строим кнопку в зависимости от состояния UserCubit + builder: (context, state) { + final isLoading = state is UserLoading; + + return ElevatedButton( + onPressed: isLoading ? null : () { + // Комментарий: Блокируем кнопку во время загрузки + if (_formKey.currentState!.validate()) { + // Комментарий: Используем UserCubit для создания пользователя + context.read().createAndSetUser( + _nameController.text, + _emailController.text, + ); + } + }, + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) // Показываем индикатор загрузки + : Text( + localizations.loginButtonText, + ), // Локализованный текст + ); + }, + ), + ], + ), ), ), ), diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 0504ba1..d76e625 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '/l10n/app_localizations.dart'; import '../logic/settings/settings_cubit.dart'; +import '../logic/user/user_cubit.dart'; // Комментарий: Мы преобразуем SettingsPage из StatefulWidget в StatelessWidget. // Это возможно, потому что теперь состояние управляется SettingsCubit, @@ -14,54 +15,66 @@ class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { - // Комментарий: Получаем экземпляр локализации для использования в тексте. - // Это позволяет нам отображать текст на языке, выбранном пользователем. final localizations = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( - // Комментарий: Используем локализованную строку для заголовка страницы. title: Text(localizations.settingsPageTitle), ), - // Комментарий: Используем BlocBuilder для перестройки UI при изменении состояния SettingsCubit. - // Он будет "слушать" изменения в SettingsCubit и автоматически перестраивать дочерние виджеты - // с новым состоянием (state). - body: BlocBuilder( - builder: (context, state) { - // Комментарий: `state` - это текущее состояние настроек (тема и язык). - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Комментарий: SwitchListTile для переключения темной/светлой темы. - // Это удобный виджет, который объединяет переключатель с текстом. - SwitchListTile( - title: Text(localizations.darkModeSetting), - subtitle: Text(localizations.darkModeDescription), - // Комментарий: Значение переключателя (включен/выключен) берется из `state.isDarkMode`. - value: state.isDarkMode, - // Комментарий: При изменении положения переключателя (onChanged) мы вызываем метод `setDarkMode` у Cubit. - // `context.read()` используется для доступа к Cubit без подписки на его изменения. - // Это хорошо для вызова методов. - onChanged: (value) { - context.read().setDarkMode(value); - }, - ), - const Divider(), - // Комментарий: ListTile для смены языка. - ListTile( - title: Text(localizations.languageSetting), - subtitle: Text(localizations.languageDescription), - trailing: DropdownButton( - // Комментарий: Текущее значение языка для выпадающего списка берется из `state.languageCode`. - value: state.languageCode, - // Комментарий: При выборе нового языка вызываем метод `setLanguageCode` у Cubit. - onChanged: (String? newValue) { - if (newValue != null) { - context.read().setLanguageCode(newValue); + body: BlocListener( + listener: (context, state) { + // Комментарий: Обрабатываем ошибки и показываем SnackBar пользователю + if (state is SettingsError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка настроек: ${state.message}'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }, + child: BlocBuilder( + builder: (context, state) { + if (state is SettingsLoaded) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: Text(localizations.darkModeSetting), + subtitle: Text(localizations.darkModeDescription), + value: state.isDarkMode, + // Комментарий: При изменении положения переключателя (onChanged) мы вызываем метод `toggleDarkMode` у Cubit. + // `context.read()` используется для доступа к Cubit без подписки на его изменения. + // Это хорошо для вызова методов. Также передаем userId из UserService. + onChanged: (value) { + // Получаем ID текущего пользователя из UserCubit + final userState = context.read().state; + if (userState is UserLoaded && userState.user != null) { + final userId = userState.user!.id; + context.read().toggleDarkMode(value, userId); } }, + ), + const Divider(), + ListTile( + title: Text(localizations.languageSetting), + subtitle: Text(localizations.languageDescription), + trailing: DropdownButton( + value: state.languageCode, + // Комментарий: При выборе нового языка вызываем метод `changeLanguage` у Cubit. + // Также передаем userId из UserService. + onChanged: (String? newValue) { + if (newValue != null) { + // Получаем ID текущего пользователя из UserCubit + final userState = context.read().state; + if (userState is UserLoaded && userState.user != null) { + final userId = userState.user!.id; + context.read().changeLanguage(newValue, userId); + } + } + }, // Комментарий: Формируем список доступных языков. items: ['en', 'ru'] .map>((String value) { @@ -75,20 +88,24 @@ class SettingsPage extends StatelessWidget { }).toList(), ), ), - const Divider(), - // Комментарий: ListTile для выбора валюты по умолчанию. - ListTile( - title: Text(localizations.currencySetting), - subtitle: Text(localizations.currencyDescription), - trailing: DropdownButton( - // Комментарий: Текущее значение валюты берется из `state.defaultCurrency`. - value: state.defaultCurrency, - // Комментарий: При выборе новой валюты вызываем метод `setDefaultCurrency` у Cubit. - onChanged: (String? newValue) { - if (newValue != null) { - context.read().setDefaultCurrency(newValue); - } - }, + const Divider(), + ListTile( + title: Text(localizations.currencySetting), + subtitle: Text(localizations.currencyDescription), + trailing: DropdownButton( + value: state.defaultCurrency, + // Комментарий: При выборе новой валюты вызываем метод `changeCurrency` у Cubit. + // Также передаем userId из UserService. + onChanged: (String? newValue) { + if (newValue != null) { + // Получаем ID текущего пользователя из UserCubit + final userState = context.read().state; + if (userState is UserLoaded && userState.user != null) { + final userId = userState.user!.id; + context.read().changeCurrency(newValue, userId); + } + } + }, // Комментарий: Формируем список доступных валют. Можно расширить этот список. items: ['RUB', 'USD', 'EUR'] .map>((String value) { @@ -138,10 +155,14 @@ class SettingsPage extends StatelessWidget { }, ), const Divider(), - ], - ), - ); - }, + ], + ), + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ), ), ); } diff --git a/lib/pages/tag/tag_list_page.dart b/lib/pages/tag/tag_list_page.dart index 0296050..532c690 100644 --- a/lib/pages/tag/tag_list_page.dart +++ b/lib/pages/tag/tag_list_page.dart @@ -2,8 +2,9 @@ import 'package:budget_app/l10n/app_localizations.dart'; import 'package:budget_app/logic/tag/tag_cubit.dart'; import 'package:budget_app/models/tag.dart'; import 'package:budget_app/pages/tag/tag_edit_page.dart'; -import 'package:budget_app/pages/tag/widgets/tag_list_item.dart'; import 'package:budget_app/pages/tag/widgets/add_tag_button.dart'; +import 'package:budget_app/pages/tag/widgets/tag_list_item.dart'; +import 'package:budget_app/logic/user/user_cubit.dart'; // Импортируем UserCubit import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; @@ -63,9 +64,17 @@ class _TagListPageState extends State { MaterialPageRoute( builder: (_) => TagEditPage( onSave: (name) { + // Получаем ID текущего пользователя из UserCubit + final userState = context.read().state; + if (userState is! UserLoaded || userState.user == null) { + // Обработка случая, когда пользователь не аутентифицирован + // Возможно, показать сообщение об ошибке или перенаправить на страницу входа + return; + } + final currentUserId = userState.user!.id; final newTag = Tag( name: name, - userId: 'default_user', + userId: currentUserId, ); context.read().addTag(newTag); _listKey.currentState?.insertItem(0); diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart deleted file mode 100644 index d06f725..0000000 --- a/lib/services/user_service.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:budget_app/data/repositories/interfaces/icategory_repository.dart'; -import 'package:budget_app/data/repositories/interfaces/itag_repository.dart'; -import 'package:budget_app/data/repositories/interfaces/itransaction_repository.dart'; -import 'package:budget_app/utils/category_utils.dart'; -import 'package:budget_app/utils/tag_utils.dart'; -import 'package:budget_app/utils/transaction_utils.dart'; -import 'package:flutter/foundation.dart'; -import 'package:hive_ce/hive.dart'; - -import '/data/repositories/interfaces/iuser_repository.dart'; -import '/models/user.dart'; - -/// Сервис для управления текущим пользователем приложения -/// Использует ChangeNotifier для уведомления UI об изменениях -class UserService extends ChangeNotifier { - // Ключ для сохранения ID текущего пользователя в настройках - static const String _currentUserKey = 'currentUserId'; - - late final Box _settingsBox; // Box для хранения настроек - final IUserRepository _userRepository; - final ICategoryRepository _categoryRepository; - final ITagRepository _tagRepository; - final ITransactionRepository _transactionRepository; - - User? _currentUser; // Текущий активный пользователь - - /// Геттер для получения текущего пользователя - User? get currentUser => _currentUser; - - /// Проверяем, есть ли активный пользователь - bool get hasCurrentUser => _currentUser != null; - - /// Конструктор сервиса - UserService( - this._userRepository, - this._categoryRepository, - this._tagRepository, - this._transactionRepository, - ) { - _settingsBox = Hive.box('settings'); - _loadCurrentUser(); // Загружаем сохраненного пользователя при запуске - } - - /// Загружаем текущего пользователя из настроек - Future _loadCurrentUser() async { - final userId = _settingsBox.get(_currentUserKey); - if (userId != null) { - _currentUser = await _userRepository.getById(userId); - notifyListeners(); // Уведомляем UI об изменении - } - } - - /// Устанавливаем текущего пользователя - Future setCurrentUser(User user) async { - _currentUser = user; - // Сохраняем ID пользователя в настройках для следующего запуска - await _settingsBox.put(_currentUserKey, user.id); - notifyListeners(); // Уведомляем UI об изменении - } - - /// Выход из аккаунта (сброс текущего пользователя) - Future logout() async { - _currentUser = null; - await _settingsBox.delete(_currentUserKey); - notifyListeners(); // Уведомляем UI об изменении - } - - /// Создание нового пользователя и установка его как текущего - Future createAndSetUser(String name, String email) async { - final user = User( - name: name, - email: email, - languageCode: 'ru', - ); // Устанавливаем язык по умолчанию - await _userRepository.add(user); - - // Добавляем начальные категории, теги и транзакции для нового пользователя - await _categoryRepository.addAll( - CategoryUtils.getDefaultCategories(user.id), - ); - await _tagRepository.addAll(TagUtils.getDefaultTags(user.id)); - await _transactionRepository.addAll( - TransactionUtils.getSampleTransactions(user.id), - ); - - await setCurrentUser(user); - } - - /// Получение всех пользователей (для выбора) - Future> getAllUsers() async { - return await _userRepository.getAll(); - } -} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index e60c95b..6a547bc 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -40,7 +40,7 @@ class AppTheme { // Комментарий: Добавляем тему для выпадающих меню, чтобы цвет соответствовал фону. dropdownMenuTheme: DropdownMenuThemeData( menuStyle: MenuStyle( - backgroundColor: MaterialStateProperty.all(Colors.white), + backgroundColor: WidgetStateProperty.all(Colors.white), ), ), // Изменяем цвета в соответствии с черно-белой палитрой @@ -94,7 +94,7 @@ class AppTheme { // Комментарий: Добавляем тему для выпадающих меню, чтобы цвет соответствовал фону. dropdownMenuTheme: DropdownMenuThemeData( menuStyle: MenuStyle( - backgroundColor: MaterialStateProperty.all(Colors.grey[800]), + backgroundColor: WidgetStateProperty.all(Colors.grey[800]), ), ), // Изменяем цвета в соответствии с черно-белой палитрой From 4670cdf5fb3ac40ca3a8f686edb2f5533d2ce09d Mon Sep 17 00:00:00 2001 From: Sanders Date: Mon, 7 Jul 2025 18:11:03 +0300 Subject: [PATCH 19/35] Fix for loop --- lib/logic/auth/auth_bloc.dart | 11 ++- lib/logic/user/user_cubit.dart | 73 +++++++++++------ lib/logic/user/user_state.dart | 10 ++- lib/main.dart | 122 +++++++++++++++++----------- lib/pages/login/login_page.dart | 2 + lib/pages/splash/splash_screen.dart | 61 ++++++++++++++ 6 files changed, 203 insertions(+), 76 deletions(-) create mode 100644 lib/pages/splash/splash_screen.dart diff --git a/lib/logic/auth/auth_bloc.dart b/lib/logic/auth/auth_bloc.dart index 77d3366..db4b0bb 100644 --- a/lib/logic/auth/auth_bloc.dart +++ b/lib/logic/auth/auth_bloc.dart @@ -15,8 +15,13 @@ class AuthBloc extends Bloc { on(_onAuthLoggedOut); } - void _onAuthStarted(AuthStarted event, Emitter emit) { - // Получаем текущее состояние пользователя из UserCubit + void _onAuthStarted(AuthStarted event, Emitter emit) async { + // Комментарий: Инициализируем UserCubit при старте AuthBloc. + // Это гарантирует, что UserCubit загрузит данные пользователя + // перед тем, как AuthBloc будет принимать решение об аутентификации. + await _userCubit.init(); + + // Получаем текущее состояние пользователя из UserCubit после инициализации final userState = _userCubit.state; if (userState is UserLoaded && userState.user != null) { emit(AuthAuthenticated(user: userState.user!)); @@ -26,6 +31,8 @@ class AuthBloc extends Bloc { } void _onAuthLoggedIn(AuthLoggedIn event, Emitter emit) { + // Комментарий: Обновляем UserCubit с новым пользователем. + _userCubit.setUser(event.user); emit(AuthAuthenticated(user: event.user)); } diff --git a/lib/logic/user/user_cubit.dart b/lib/logic/user/user_cubit.dart index 8c5a7ef..9e0926e 100644 --- a/lib/logic/user/user_cubit.dart +++ b/lib/logic/user/user_cubit.dart @@ -1,17 +1,19 @@ import 'dart:async'; + import 'package:bloc/bloc.dart'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; -import '/data/repositories/interfaces/iglobal_settings_repository.dart'; -import '/data/repositories/interfaces/iuser_repository.dart'; import '/data/repositories/interfaces/icategory_repository.dart'; +import '/data/repositories/interfaces/iglobal_settings_repository.dart'; import '/data/repositories/interfaces/itag_repository.dart'; import '/data/repositories/interfaces/itransaction_repository.dart'; +import '/data/repositories/interfaces/iuser_repository.dart'; import '/models/user.dart'; import '/utils/category_utils.dart'; import '/utils/tag_utils.dart'; import '/utils/transaction_utils.dart'; + part 'user_state.dart'; /// Cubit для управления состоянием пользователя @@ -29,21 +31,26 @@ class UserCubit extends Cubit { required ICategoryRepository categoryRepository, required ITagRepository tagRepository, required ITransactionRepository transactionRepository, - }) : _settingsRepository = settingsRepository, - _userRepository = userRepository, - _categoryRepository = categoryRepository, - _tagRepository = tagRepository, - _transactionRepository = transactionRepository, - super(UserInitial()) { - _init(); + }) : _settingsRepository = settingsRepository, + _userRepository = userRepository, + _categoryRepository = categoryRepository, + _tagRepository = tagRepository, + _transactionRepository = transactionRepository, + super(UserInitial()); + + // Метод для инициализации UserCubit + Future init() async { + await _init(); } Future _init() async { - emit(UserLoading()); + emit(UserLoading(progress: 0.1, message: 'Поиск пользователя...')); try { + // Этап 1: Проверка существующего пользователя final userId = await _settingsRepository.getCurrentUserId(); + emit(UserLoading(progress: 0.3, message: 'Проверка пользователя...')); + User? currentUser; - if (userId != null) { currentUser = await _userRepository.getById(userId); if (currentUser == null) { @@ -52,18 +59,30 @@ class UserCubit extends Cubit { } } + // Этап 2: Создание пользователя по умолчанию при необходимости if (currentUser == null) { + emit(UserLoading(progress: 0.5, message: 'Проверка данных...')); final allUsers = await _userRepository.getAll(); if (allUsers.isEmpty) { _logger.i('No users found, creating default user'); - currentUser = await _createDefaultUser(); + emit(UserLoading(progress: 0.6, message: 'Создание пользователя...')); + // Комментарий: Вызываем создание пользователя по умолчанию. + // Этот метод сам установит состояние UserLoaded, поэтому после него нужно завершить выполнение _init. + await _createDefaultUser(); + return; } else { _logger.i('Setting first user as current'); currentUser = allUsers.first; + // Комментарий: Устанавливаем первого пользователя как текущего. + // Этот метод также устанавливает состояние UserLoaded, поэтому выходим. await _setCurrentUser(currentUser); + return; } } + // Этап 3: Завершение инициализации (этот блок теперь выполняется только для уже существующих пользователей) + emit(UserLoading(progress: 1.0, message: 'Завершение...')); + await Future.delayed(const Duration(milliseconds: 300)); emit(UserLoaded(currentUser)); } catch (e, stack) { _logger.e('Error initializing user cubit', error: e, stackTrace: stack); @@ -85,28 +104,29 @@ class UserCubit extends Cubit { return user; } - /// Создает начальные данные для нового пользователя (категории, теги, транзакции) Future _createInitialData(String userId) async { try { - // Добавляем начальные категории + // Разбиваем создание данных на этапы + emit(UserLoading(progress: 0.7, message: 'Создание категорий...')); await _categoryRepository.addAll( CategoryUtils.getDefaultCategories(userId), ); - // Добавляем начальные теги - await _tagRepository.addAll( - TagUtils.getDefaultTags(userId), - ); + emit(UserLoading(progress: 0.8, message: 'Создание тегов...')); + await _tagRepository.addAll(TagUtils.getDefaultTags(userId)); - // Добавляем примеры транзакций + emit(UserLoading(progress: 0.9, message: 'Создание транзакций...')); await _transactionRepository.addAll( TransactionUtils.getSampleTransactions(userId), ); _logger.i('Initial data created for user: $userId'); } catch (e, stack) { - _logger.e('Error creating initial data for user: $userId', error: e, stackTrace: stack); - // Не прерываем создание пользователя из-за ошибки создания начальных данных + _logger.e( + 'Error creating initial data for user: $userId', + error: e, + stackTrace: stack, + ); } } @@ -126,6 +146,11 @@ class UserCubit extends Cubit { } } + /// Устанавливает текущего пользователя (прямая установка без загрузки) + void setUser(User user) { + emit(UserLoaded(user)); + } + /// Выход из системы Future logout() async { emit(UserLoading()); @@ -142,10 +167,7 @@ class UserCubit extends Cubit { Future createAndSetUser(String name, String email) async { emit(UserLoading()); try { - final user = User( - name: name, - email: email, - ); + final user = User(name: name, email: email); await _userRepository.add(user); // Создаем начальные данные для нового пользователя @@ -168,4 +190,3 @@ class UserCubit extends Cubit { } } } - diff --git a/lib/logic/user/user_state.dart b/lib/logic/user/user_state.dart index bf45d51..ca52ce9 100644 --- a/lib/logic/user/user_state.dart +++ b/lib/logic/user/user_state.dart @@ -11,9 +11,15 @@ class UserInitial extends UserState { const UserInitial(); } -/// Состояние загрузки данных +/// Состояние загрузки данных с прогрессом и сообщением class UserLoading extends UserState { - const UserLoading(); + final double progress; + final String message; + + const UserLoading({ + this.progress = 0.0, + this.message = '', + }); } /// Состояние успешной загрузки пользователя diff --git a/lib/main.dart b/lib/main.dart index a2dac33..a3efb1f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,34 @@ +import 'package:budget_app/pages/home/home_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:get_it/get_it.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; // Добавляем импорт +import 'package:get_it/get_it.dart'; + import '/l10n/app_localizations.dart'; -import 'logic/auth/auth_bloc.dart'; -import 'pages/login/login_page.dart'; -import 'package:budget_app/pages/home/home_page.dart'; -import 'theme/app_theme.dart'; import 'injection_container.dart' as di; +import 'logic/auth/auth_bloc.dart'; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit import 'logic/sms/sms_cubit.dart'; import 'logic/transaction/transaction_bloc.dart'; import 'logic/user/user_cubit.dart'; // Импортируем UserCubit +import 'pages/login/login_page.dart'; +import 'theme/app_theme.dart'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import '/l10n/app_localizations.dart'; +import '/logic/auth/auth_bloc.dart'; +import '/logic/settings/settings_cubit.dart'; +import '/logic/sms/sms_cubit.dart'; +import '/logic/transaction/transaction_bloc.dart'; +import '/logic/user/user_cubit.dart'; +import '/pages/home/home_page.dart'; +import '/pages/login/login_page.dart'; +import '/pages/splash/splash_screen.dart'; +import '/theme/app_theme.dart'; +import 'injection_container.dart' as di; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -19,58 +36,71 @@ void main() async { runApp(const MyApp()); } -class MyApp extends StatefulWidget { +class MyApp extends StatelessWidget { const MyApp({super.key}); - @override - State createState() => _MyAppState(); -} - -class _MyAppState extends State { @override Widget build(BuildContext context) { return MultiBlocProvider( providers: [ - BlocProvider(create: (context) => GetIt.instance()), // Добавляем UserCubit для управления пользователями - BlocProvider(create: (context) => GetIt.instance()..add(AuthStarted())), + BlocProvider( + create: (context) => GetIt.instance()..init(), + ), + BlocProvider( + create: (context) => GetIt.instance(), + ), BlocProvider(create: (context) => GetIt.instance()), BlocProvider(create: (context) => GetIt.instance()), BlocProvider(create: (context) => GetIt.instance()), ], - child: BlocBuilder( - builder: (context, settingsState) { - // Используем значения по умолчанию, если настройки не загружены - final isDarkMode = settingsState is SettingsLoaded - ? settingsState.isDarkMode - : false; - final languageCode = settingsState is SettingsLoaded - ? settingsState.languageCode - : 'ru'; - - return MaterialApp( - title: AppLocalizations.of(context)?.appTitle ?? 'Budget App', - theme: AppTheme.lightTheme(), - darkTheme: AppTheme.darkTheme(), - themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light, - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: AppLocalizations.supportedLocales, - locale: Locale(languageCode), - home: BlocBuilder( - builder: (context, authState) { - if (authState is AuthAuthenticated) { - return const HomePage(); - } else { - return const LoginPage(); - } - }, - ), - ); + child: BlocListener( + listener: (context, userState) { + if (userState is UserLoaded && userState.user != null) { + context.read().add(AuthStarted()); + } }, + child: BlocBuilder( + builder: (context, settingsState) { + final isDarkMode = settingsState is SettingsLoaded + ? settingsState.isDarkMode + : false; + final languageCode = settingsState is SettingsLoaded + ? settingsState.languageCode + : 'ru'; + + return MaterialApp( + title: AppLocalizations.of(context)?.appTitle ?? 'Budget App', + theme: AppTheme.lightTheme(), + darkTheme: AppTheme.darkTheme(), + themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: Locale(languageCode), + home: BlocBuilder( + builder: (context, userState) { + if (userState is UserLoading) { + return const SplashScreen(); + } + + return BlocBuilder( + builder: (context, authState) { + if (authState is AuthAuthenticated) { + return const HomePage(); + } else { + return const LoginPage(); + } + }, + ); + }, + ), + ); + }, + ), ), ); } diff --git a/lib/pages/login/login_page.dart b/lib/pages/login/login_page.dart index 05a2b42..4f4a962 100644 --- a/lib/pages/login/login_page.dart +++ b/lib/pages/login/login_page.dart @@ -29,6 +29,8 @@ class _LoginPageState extends State { ), // Локализованный заголовок body: BlocListener( // Слушаем изменения состояния UserCubit + listenWhen: (previous, current) => + current is UserLoaded || current is UserError, listener: (context, state) { if (state is UserLoaded && state.user != null) { // Комментарий: При успешном создании пользователя отправляем событие в AuthBloc diff --git a/lib/pages/splash/splash_screen.dart b/lib/pages/splash/splash_screen.dart new file mode 100644 index 0000000..9dfe5d9 --- /dev/null +++ b/lib/pages/splash/splash_screen.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '/logic/user/user_cubit.dart'; + +class SplashScreen extends StatelessWidget { + const SplashScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: BlocBuilder( + builder: (context, state) { + if (state is UserLoading) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 20), + Text( + state.message, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 10), + Text( + '${(state.progress * 100).toInt()}%', + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ); + } + + if (state is UserError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Ошибка загрузки', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 20), + Text(state.message), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => context.read().init(), + child: const Text('Повторить'), + ), + ], + ), + ); + } + + return const Center(child: CircularProgressIndicator()); + }, + ), + ); + } +} From c997feac4a5374ecc19d0262a289d99daa290c08 Mon Sep 17 00:00:00 2001 From: Sanders Date: Mon, 7 Jul 2025 18:44:08 +0300 Subject: [PATCH 20/35] Fix login and settings --- lib/logic/auth/auth_bloc.dart | 10 +- lib/logic/user/user_cubit.dart | 45 ++---- lib/main.dart | 9 +- lib/pages/settings_page.dart | 272 ++++++++++++++++++--------------- 4 files changed, 174 insertions(+), 162 deletions(-) diff --git a/lib/logic/auth/auth_bloc.dart b/lib/logic/auth/auth_bloc.dart index db4b0bb..e46c547 100644 --- a/lib/logic/auth/auth_bloc.dart +++ b/lib/logic/auth/auth_bloc.dart @@ -16,16 +16,14 @@ class AuthBloc extends Bloc { } void _onAuthStarted(AuthStarted event, Emitter emit) async { - // Комментарий: Инициализируем UserCubit при старте AuthBloc. - // Это гарантирует, что UserCubit загрузит данные пользователя - // перед тем, как AuthBloc будет принимать решение об аутентификации. - await _userCubit.init(); - - // Получаем текущее состояние пользователя из UserCubit после инициализации + // Комментарий: Этот метод теперь просто проверяет ТЕКУЩЕЕ состояние UserCubit, + // не инициируя его повторную загрузку. Инициализация происходит один раз в main.dart. final userState = _userCubit.state; if (userState is UserLoaded && userState.user != null) { + // Если пользователь уже загружен в UserCubit, считаем его аутентифицированным. emit(AuthAuthenticated(user: userState.user!)); } else { + // Если пользователь не загружен (null) или состояние другое, считаем его неаутентифицированным. emit(AuthUnauthenticated()); } } diff --git a/lib/logic/user/user_cubit.dart b/lib/logic/user/user_cubit.dart index 9e0926e..e34d7db 100644 --- a/lib/logic/user/user_cubit.dart +++ b/lib/logic/user/user_cubit.dart @@ -23,7 +23,17 @@ class UserCubit extends Cubit { final ICategoryRepository _categoryRepository; final ITagRepository _tagRepository; final ITransactionRepository _transactionRepository; - final Logger _logger = Logger(); + final Logger _logger = Logger( + printer: PrettyPrinter( + methodCount: 15, // Number of method calls to be displayed + errorMethodCount: 8, // Number of method calls if stacktrace is provided + lineLength: 120, // Width of the output + colors: true, // Colorful log messages + printEmojis: true, // Print an emoji for each log message + // Should each log print contain a timestamp + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, + ), + ); UserCubit({ required IGlobalSettingsRepository settingsRepository, @@ -44,48 +54,25 @@ class UserCubit extends Cubit { } Future _init() async { - emit(UserLoading(progress: 0.1, message: 'Поиск пользователя...')); + emit(UserLoading(progress: 0.1, message: 'Инициализация...')); try { - // Этап 1: Проверка существующего пользователя final userId = await _settingsRepository.getCurrentUserId(); - emit(UserLoading(progress: 0.3, message: 'Проверка пользователя...')); - User? currentUser; + if (userId != null) { + emit(UserLoading(progress: 0.3, message: 'Поиск пользователя...')); currentUser = await _userRepository.getById(userId); if (currentUser == null) { - _logger.w('User with ID $userId not found. Clearing key.'); await _settingsRepository.setCurrentUserId(null); } } - // Этап 2: Создание пользователя по умолчанию при необходимости - if (currentUser == null) { - emit(UserLoading(progress: 0.5, message: 'Проверка данных...')); - final allUsers = await _userRepository.getAll(); - if (allUsers.isEmpty) { - _logger.i('No users found, creating default user'); - emit(UserLoading(progress: 0.6, message: 'Создание пользователя...')); - // Комментарий: Вызываем создание пользователя по умолчанию. - // Этот метод сам установит состояние UserLoaded, поэтому после него нужно завершить выполнение _init. - await _createDefaultUser(); - return; - } else { - _logger.i('Setting first user as current'); - currentUser = allUsers.first; - // Комментарий: Устанавливаем первого пользователя как текущего. - // Этот метод также устанавливает состояние UserLoaded, поэтому выходим. - await _setCurrentUser(currentUser); - return; - } - } - - // Этап 3: Завершение инициализации (этот блок теперь выполняется только для уже существующих пользователей) emit(UserLoading(progress: 1.0, message: 'Завершение...')); await Future.delayed(const Duration(milliseconds: 300)); emit(UserLoaded(currentUser)); + } catch (e, stack) { - _logger.e('Error initializing user cubit', error: e, stackTrace: stack); + _logger.e('--- UserCubit: FATAL ERROR in _init ---', error: e, stackTrace: stack); emit(UserError('Ошибка загрузки пользователя: ${e.toString()}')); } } diff --git a/lib/main.dart b/lib/main.dart index a3efb1f..5b2a7cb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -54,10 +54,11 @@ class MyApp extends StatelessWidget { BlocProvider(create: (context) => GetIt.instance()), ], child: BlocListener( + // Запускаем AuthBloc, как только UserCubit завершил загрузку, + // независимо от того, найден пользователь или нет. + listenWhen: (previous, current) => current is UserLoaded, listener: (context, userState) { - if (userState is UserLoaded && userState.user != null) { - context.read().add(AuthStarted()); - } + context.read().add(AuthStarted()); }, child: BlocBuilder( builder: (context, settingsState) { @@ -83,7 +84,7 @@ class MyApp extends StatelessWidget { locale: Locale(languageCode), home: BlocBuilder( builder: (context, userState) { - if (userState is UserLoading) { + if (userState is UserLoading || userState is UserInitial) { return const SplashScreen(); } diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index d76e625..1f81934 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -33,131 +33,157 @@ class SettingsPage extends StatelessWidget { ); } }, - child: BlocBuilder( - builder: (context, state) { - if (state is SettingsLoaded) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SwitchListTile( - title: Text(localizations.darkModeSetting), - subtitle: Text(localizations.darkModeDescription), - value: state.isDarkMode, - // Комментарий: При изменении положения переключателя (onChanged) мы вызываем метод `toggleDarkMode` у Cubit. - // `context.read()` используется для доступа к Cubit без подписки на его изменения. - // Это хорошо для вызова методов. Также передаем userId из UserService. - onChanged: (value) { - // Получаем ID текущего пользователя из UserCubit - final userState = context.read().state; - if (userState is UserLoaded && userState.user != null) { - final userId = userState.user!.id; - context.read().toggleDarkMode(value, userId); - } - }, - ), - const Divider(), - ListTile( - title: Text(localizations.languageSetting), - subtitle: Text(localizations.languageDescription), - trailing: DropdownButton( - value: state.languageCode, - // Комментарий: При выборе нового языка вызываем метод `changeLanguage` у Cubit. - // Также передаем userId из UserService. - onChanged: (String? newValue) { - if (newValue != null) { - // Получаем ID текущего пользователя из UserCubit - final userState = context.read().state; - if (userState is UserLoaded && userState.user != null) { - final userId = userState.user!.id; - context.read().changeLanguage(newValue, userId); - } - } - }, - // Комментарий: Формируем список доступных языков. - items: ['en', 'ru'] - .map>((String value) { - return DropdownMenuItem( - value: value, - // Комментарий: Отображаем локализованное название языка. - child: Text(value == 'en' - ? localizations.englishLanguage - : localizations.russianLanguage), - ); - }).toList(), - ), - ), - const Divider(), - ListTile( - title: Text(localizations.currencySetting), - subtitle: Text(localizations.currencyDescription), - trailing: DropdownButton( - value: state.defaultCurrency, - // Комментарий: При выборе новой валюты вызываем метод `changeCurrency` у Cubit. - // Также передаем userId из UserService. - onChanged: (String? newValue) { - if (newValue != null) { - // Получаем ID текущего пользователя из UserCubit - final userState = context.read().state; - if (userState is UserLoaded && userState.user != null) { - final userId = userState.user!.id; - context.read().changeCurrency(newValue, userId); - } - } - }, - // Комментарий: Формируем список доступных валют. Можно расширить этот список. - items: ['RUB', 'USD', 'EUR'] - .map>((String value) { - return DropdownMenuItem( - value: value, - child: Text(value), - ); - }).toList(), - ), - ), - const Divider(), - // Комментарий: ListTile для перехода на страницу редактирования категорий. - ListTile( - title: Text(localizations.editCategories), - subtitle: Text(localizations.editCategoriesDescription), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const CategoryListPage(), + child: BlocBuilder( + builder: (context, userState) { + if (userState is UserLoaded && userState.user != null) { + // Комментарий: Как только пользователь загружен, мы загружаем его настройки. + context.read().loadSettings(userState.user!.id); + return BlocBuilder( + builder: (context, state) { + if (state is SettingsLoaded) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: Text(localizations.darkModeSetting), + subtitle: Text(localizations.darkModeDescription), + value: state.isDarkMode, + // Комментарий: При изменении положения переключателя (onChanged) мы вызываем метод `toggleDarkMode` у Cubit. + // `context.read()` используется для доступа к Cubit без подписки на его изменения. + // Это хорошо для вызова методов. Также передаем userId из UserService. + onChanged: (value) { + // Получаем ID текущего пользователя из UserCubit + final userState = context.read().state; + if (userState is UserLoaded && + userState.user != null) { + final userId = userState.user!.id; + context + .read() + .toggleDarkMode(value, userId); + } + }, + ), + const Divider(), + ListTile( + title: Text(localizations.languageSetting), + subtitle: Text(localizations.languageDescription), + trailing: DropdownButton( + value: state.languageCode, + // Комментарий: При выборе нового языка вызываем метод `changeLanguage` у Cubit. + // Также передаем userId из UserService. + onChanged: (String? newValue) { + if (newValue != null) { + // Получаем ID текущего пользователя из UserCubit + final userState = + context.read().state; + if (userState is UserLoaded && + userState.user != null) { + final userId = userState.user!.id; + context + .read() + .changeLanguage(newValue, userId); + } + } + }, + // Комментарий: Формируем список доступных языков. + items: ['en', 'ru'] + .map>( + (String value) { + return DropdownMenuItem( + value: value, + // Комментарий: Отображаем локализованное название языка. + child: Text(value == 'en' + ? localizations.englishLanguage + : localizations.russianLanguage), + ); + }).toList(), + ), + ), + const Divider(), + ListTile( + title: Text(localizations.currencySetting), + subtitle: Text(localizations.currencyDescription), + trailing: DropdownButton( + value: state.defaultCurrency, + // Комментарий: При выборе новой валюты вызываем метод `changeCurrency` у Cubit. + // Также передаем userId из UserService. + onChanged: (String? newValue) { + if (newValue != null) { + // Получаем ID текущего пользователя из UserCubit + final userState = + context.read().state; + if (userState is UserLoaded && + userState.user != null) { + final userId = userState.user!.id; + context + .read() + .changeCurrency(newValue, userId); + } + } + }, + // Комментарий: Формируем список доступных валют. Можно расширить этот список. + items: ['RUB', 'USD', 'EUR'] + .map>( + (String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + ), + ), + const Divider(), + // Комментарий: ListTile для перехода на страницу редактирования категорий. + ListTile( + title: Text(localizations.editCategories), + subtitle: + Text(localizations.editCategoriesDescription), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const CategoryListPage(), + ), + ); + }, + ), + const Divider(), + ListTile( + title: Text(localizations.editTags), + subtitle: Text(localizations.editTagsDescription), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TagListPage(), + ), + ); + }, + ), + const Divider(), + // Комментарий: ListTile для запуска процесса загрузки SMS-сообщений. + ListTile( + title: Text(localizations.loadSmsMessages), + subtitle: + Text(localizations.loadSmsMessagesDescription), + onTap: () { + // Комментарий: При нажатии на кнопку мы вызываем метод `loadSmsMessages` у SmsCubit. + // Это инициирует процесс получения и сохранения SMS-сообщений. + context.read().loadSmsMessages(); + }, + ), + const Divider(), + ], ), ); - }, - ), - const Divider(), - ListTile( - title: Text(localizations.editTags), - subtitle: Text(localizations.editTagsDescription), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const TagListPage(), - ), - ); - }, - ), - const Divider(), - // Комментарий: ListTile для запуска процесса загрузки SMS-сообщений. - ListTile( - title: Text(localizations.loadSmsMessages), - subtitle: Text(localizations.loadSmsMessagesDescription), - onTap: () { - // Комментарий: При нажатии на кнопку мы вызываем метод `loadSmsMessages` у SmsCubit. - // Это инициирует процесс получения и сохранения SMS-сообщений. - context.read().loadSmsMessages(); - }, - ), - const Divider(), - ], - ), - ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, + ); } else { return const Center(child: CircularProgressIndicator()); } From d19f58d65e2ae7ccd00d27af8449e459ae04df7b Mon Sep 17 00:00:00 2001 From: Sanders Date: Wed, 9 Jul 2025 22:06:28 +0300 Subject: [PATCH 21/35] Adds user ID to SMS messages Ensures SMS messages are associated with a specific user by adding a `userId` field to the `SmsMessage` model and passing the user ID when retrieving SMS messages. This change ensures that SMS messages are correctly linked to the user they belong to, which is necessary for filtering and displaying the appropriate messages. It retrieves the user ID from the `UserCubit` state. Handles the case where the user state is not loaded, emitting an error state. --- lib/logic/sms/sms_cubit.dart | 35 +++++++++++++++++++++------------- lib/logic/user/user_cubit.dart | 11 ++++++----- lib/models/sms_message.dart | 8 +++++++- lib/models/sms_message.g.dart | 7 +++++-- lib/services/sms_service.dart | 6 ++++-- 5 files changed, 44 insertions(+), 23 deletions(-) diff --git a/lib/logic/sms/sms_cubit.dart b/lib/logic/sms/sms_cubit.dart index e4f1aea..5cd9844 100644 --- a/lib/logic/sms/sms_cubit.dart +++ b/lib/logic/sms/sms_cubit.dart @@ -31,8 +31,13 @@ class SmsCubit extends Cubit { try { final hasPermissions = await _smsService.requestPermissions(); if (hasPermissions) { - final messages = await _smsService.getLastSmsMessages(10); - emit(SmsLoaded(messages)); + final userState = _userCubit.state; + if (userState is UserLoaded) { + final messages = await _smsService.getLastSmsMessages(10, userState.user!.id); + emit(SmsLoaded(messages)); + } else { + emit(SmsError("User not loaded")); + } } else { emit(SmsPermissionDenied()); } @@ -51,20 +56,24 @@ class SmsCubit extends Cubit { if (hasPermissions) { // Комментарий: Получаем текущего пользователя из состояния UserCubit. final userState = _userCubit.state; - final user = userState is UserLoaded ? userState.user : null; + if (userState is UserLoaded) { + final user = userState.user; - // Комментарий: Получаем все SMS-сообщения с момента последней синхронизации. - final messages = await _smsService.getSmsMessagesSince(DateTime.now()); + // Комментарий: Получаем все SMS-сообщения с момента последней синхронизации. + final messages = await _smsService.getSmsMessagesSince(user!.lastSmsSyncTime, user.id); - // Комментарий: Сохраняем новые сообщения через репозиторий и создаем транзакции. - await _smsRepository.addAll(messages); - for (final message in messages) { - // Комментарий: Пытаемся создать транзакцию из SMS. - _createTransactionFromSms(message); + // Комментарий: Сохраняем новые сообщения через репозиторий и создаем транзакции. + await _smsRepository.addAll(messages); + for (final message in messages) { + // Комментарий: Пытаемся создать транзакцию из SMS. + _createTransactionFromSms(message); + } + + // Комментарий: Устанавливаем состояние успешной загрузки. + emit(SmsLoaded(messages)); + } else { + emit(SmsError("User not loaded")); } - - // Комментарий: Устанавливаем состояние успешной загрузки. - emit(SmsLoaded(messages)); } else { // Комментарий: Если разрешение не получено, устанавливаем состояние "в доступе отказано". emit(SmsPermissionDenied()); diff --git a/lib/logic/user/user_cubit.dart b/lib/logic/user/user_cubit.dart index e34d7db..00d16c1 100644 --- a/lib/logic/user/user_cubit.dart +++ b/lib/logic/user/user_cubit.dart @@ -25,7 +25,7 @@ class UserCubit extends Cubit { final ITransactionRepository _transactionRepository; final Logger _logger = Logger( printer: PrettyPrinter( - methodCount: 15, // Number of method calls to be displayed + methodCount: 2, // Number of method calls to be displayed errorMethodCount: 8, // Number of method calls if stacktrace is provided lineLength: 120, // Width of the output colors: true, // Colorful log messages @@ -70,9 +70,12 @@ class UserCubit extends Cubit { emit(UserLoading(progress: 1.0, message: 'Завершение...')); await Future.delayed(const Duration(milliseconds: 300)); emit(UserLoaded(currentUser)); - } catch (e, stack) { - _logger.e('--- UserCubit: FATAL ERROR in _init ---', error: e, stackTrace: stack); + _logger.e( + '--- UserCubit: FATAL ERROR in _init ---', + error: e, + stackTrace: stack, + ); emit(UserError('Ошибка загрузки пользователя: ${e.toString()}')); } } @@ -106,8 +109,6 @@ class UserCubit extends Cubit { await _transactionRepository.addAll( TransactionUtils.getSampleTransactions(userId), ); - - _logger.i('Initial data created for user: $userId'); } catch (e, stack) { _logger.e( 'Error creating initial data for user: $userId', diff --git a/lib/models/sms_message.dart b/lib/models/sms_message.dart index bfc48e0..3eaf5e6 100644 --- a/lib/models/sms_message.dart +++ b/lib/models/sms_message.dart @@ -21,22 +21,28 @@ class SmsMessage extends HiveObject { @HiveField(4) String? transactionId; + // Комментарий: Добавлено поле для связи с пользователем. + @HiveField(5) + final String userId; + SmsMessage({ String? id, this.body, this.sender, this.date, this.transactionId, + required this.userId, }) : id = id ?? IdGenerator.generateId(); // Комментарий: Добавляем метод для обновления transactionId - SmsMessage copyWith({String? transactionId}) { + SmsMessage copyWith({String? transactionId, String? userId}) { return SmsMessage( id: id, body: body, sender: sender, date: date, transactionId: transactionId ?? this.transactionId, + userId: userId ?? this.userId, ); } } diff --git a/lib/models/sms_message.g.dart b/lib/models/sms_message.g.dart index 4a4d537..9f166ce 100644 --- a/lib/models/sms_message.g.dart +++ b/lib/models/sms_message.g.dart @@ -22,13 +22,14 @@ class SmsMessageAdapter extends TypeAdapter { sender: fields[2] as String?, date: fields[3] as DateTime?, transactionId: fields[4] as String?, + userId: fields[5] as String, ); } @override void write(BinaryWriter writer, SmsMessage obj) { writer - ..writeByte(5) + ..writeByte(6) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -38,7 +39,9 @@ class SmsMessageAdapter extends TypeAdapter { ..writeByte(3) ..write(obj.date) ..writeByte(4) - ..write(obj.transactionId); + ..write(obj.transactionId) + ..writeByte(5) + ..write(obj.userId); } @override diff --git a/lib/services/sms_service.dart b/lib/services/sms_service.dart index 1235354..e3850f8 100644 --- a/lib/services/sms_service.dart +++ b/lib/services/sms_service.dart @@ -23,7 +23,7 @@ class SmsService { /// /// Возвращает список объектов [SmsMessage]. /// В случае ошибки или отсутствия разрешений, возвращает пустой список. - Future> getLastSmsMessages(int count) async { + Future> getLastSmsMessages(int count, String userId) async { final bool? permissionsGranted = await _telephony.requestPhoneAndSmsPermissions; if (permissionsGranted ?? false) { @@ -36,13 +36,14 @@ class SmsService { body: msg.body, sender: msg.address, date: DateTime.fromMillisecondsSinceEpoch(msg.date ?? 0), + userId: userId, )).toList(); } return []; } // Комментарий: Метод для получения SMS-сообщений с определенной даты. - Future> getSmsMessagesSince(DateTime sinceDate) async { + Future> getSmsMessagesSince(DateTime sinceDate, String userId) async { final bool? permissionsGranted = await _telephony.requestPhoneAndSmsPermissions; if (permissionsGranted ?? false) { final List messages = await _telephony.getInboxSms( @@ -56,6 +57,7 @@ class SmsService { body: msg.body, sender: msg.address, date: DateTime.fromMillisecondsSinceEpoch(msg.date ?? 0), + userId: userId, )) .toList(); } From 8040839ba3954f85f51c493b8d756cd278726140 Mon Sep 17 00:00:00 2001 From: Sanders Date: Wed, 9 Jul 2025 22:30:26 +0300 Subject: [PATCH 22/35] Adds SMS handler settings to Hive Adds the necessary classes and configurations to store and retrieve SMS handler settings using Hive database. This includes registering the `SmsHandlerSettings` model, its adapter, and related enum adapters with Hive, opening a new box for storing settings and making it accessible via `HiveService`. Additionally, this change registers a new repository for accessing sms handler settings data. --- lib/data/database/hive_service.dart | 5 + .../hive_sms_handler_repository.dart | 57 +++++++++ .../interfaces/isms_handler_repository.dart | 30 +++++ lib/hive/hive_registrar.g.dart | 7 + lib/injection_container.dart | 6 + lib/models/sms_handler_settings.dart | 61 +++++++++ lib/models/sms_handler_settings.g.dart | 121 ++++++++++++++++++ 7 files changed, 287 insertions(+) create mode 100644 lib/data/repositories/hive_sms_handler_repository.dart create mode 100644 lib/data/repositories/interfaces/isms_handler_repository.dart create mode 100644 lib/models/sms_handler_settings.dart create mode 100644 lib/models/sms_handler_settings.g.dart diff --git a/lib/data/database/hive_service.dart b/lib/data/database/hive_service.dart index b7e9dda..c802503 100644 --- a/lib/data/database/hive_service.dart +++ b/lib/data/database/hive_service.dart @@ -1,6 +1,7 @@ import 'package:budget_app/hive/hive_registrar.g.dart'; import 'package:budget_app/models/app_settings.dart'; // Добавлен импорт AppSettings import 'package:budget_app/models/global_settings.dart'; +import 'package:budget_app/models/sms_handler_settings.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:logger/logger.dart'; @@ -21,6 +22,7 @@ class HiveService { static const String _userBox = 'users'; static const String _smsMessageBox = 'smsMessages'; static const String _globalSettings = 'global_settings'; + static const String _smsHandlerSettingsBox = 'sms_handler_settings'; static Future init() async { _logger.i('Initializing Hive database'); @@ -39,6 +41,7 @@ class HiveService { Hive.openBox(_userBox), Hive.openBox(_smsMessageBox), Hive.openBox(_globalSettings), + Hive.openBox(_smsHandlerSettingsBox), ]); // Проверка и заполнение начальными данными @@ -56,6 +59,8 @@ class HiveService { Hive.box(_smsMessageBox); static Box get globalSettings => Hive.box(_globalSettings); + static Box get smsHandlerSettings => + Hive.box(_smsHandlerSettingsBox); /// Проверяет и заполняет боксы начальными данными при первом запуске static void _checkAndFillInitialData() { diff --git a/lib/data/repositories/hive_sms_handler_repository.dart b/lib/data/repositories/hive_sms_handler_repository.dart new file mode 100644 index 0000000..47e64e4 --- /dev/null +++ b/lib/data/repositories/hive_sms_handler_repository.dart @@ -0,0 +1,57 @@ +import 'package:hive_ce/hive.dart'; + +import '../../models/sms_handler_settings.dart'; +import 'interfaces/isms_handler_repository.dart'; + +/// Реализация репозитория для настроек обработки СМС с использованием Hive. +class HiveSmsHandlerRepository implements ISmsHandlerRepository { + final Box _smsHandlerBox; + + HiveSmsHandlerRepository(this._smsHandlerBox); + + @override + Future getSmsHandlerSettings(String userId) async { + // В Hive мы будем использовать ID пользователя как ключ для его настроек. + return _smsHandlerBox.get(userId); + } + + @override + Future saveSmsHandlerSettings(SmsHandlerSettings settings) async { + // Сохраняем объект настроек по ключу, равному ID пользователя. + await _smsHandlerBox.put(settings.userId, settings); + } + + @override + Future saveRuleForSender(String userId, String sender, SmsProcessingRule rule) async { + // 1. Получаем текущие настройки. + SmsHandlerSettings? settings = await getSmsHandlerSettings(userId); + + if (settings == null) { + // 2. Если настроек нет, создаем новый объект. + settings = SmsHandlerSettings( + userId: userId, + rulesBySender: {sender: rule}, // Создаем карту с первым правилом + ); + } else { + // 3. Если настройки есть, обновляем или добавляем правило. + settings.rulesBySender[sender] = rule; + } + + // 4. Сохраняем обновленный объект настроек. + await saveSmsHandlerSettings(settings); + } + + @override + Future deleteRuleForSender(String userId, String sender) async { + // 1. Получаем текущие настройки. + final settings = await getSmsHandlerSettings(userId); + + if (settings != null) { + // 2. Если настройки существуют, удаляем правило для отправителя. + settings.rulesBySender.remove(sender); + // 3. Сохраняем измененный объект. + await saveSmsHandlerSettings(settings); + } + // Если настроек нет, ничего не делаем. + } +} diff --git a/lib/data/repositories/interfaces/isms_handler_repository.dart b/lib/data/repositories/interfaces/isms_handler_repository.dart new file mode 100644 index 0000000..13e28d8 --- /dev/null +++ b/lib/data/repositories/interfaces/isms_handler_repository.dart @@ -0,0 +1,30 @@ +import '../../../models/sms_handler_settings.dart'; + +/// Абстрактный класс (интерфейс) для репозитория настроек обработки СМС. +/// Определяет контракт, по которому UI и бизнес-логика будут взаимодействовать +/// с данными о настройках, не зная деталей их реализации (Hive, Firebase, etc). +abstract class ISmsHandlerRepository { + /// Получает настройки обработки СМС для указанного пользователя. + /// + /// [userId] - Уникальный идентификатор пользователя. + /// Возвращает [SmsHandlerSettings] или null, если настроек нет. + Future getSmsHandlerSettings(String userId); + + /// Сохраняет или обновляет настройки обработки СМС для пользователя. + /// + /// [settings] - Объект с настройками, который нужно сохранить. + Future saveSmsHandlerSettings(SmsHandlerSettings settings); + + /// Добавляет или обновляет правило для конкретного отправителя. + /// + /// [userId] - ID пользователя. + /// [sender] - Идентификатор отправителя (например, 'SBERBANK'). + /// [rule] - Правило обработки. + Future saveRuleForSender(String userId, String sender, SmsProcessingRule rule); + + /// Удаляет правило для конкретного отправителя. + /// + /// [userId] - ID пользователя. + /// [sender] - Идентификатор отправителя, чье правило нужно удалить. + Future deleteRuleForSender(String userId, String sender); +} diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index b655859..8c28cb0 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -7,6 +7,7 @@ import 'package:budget_app/hive/hive_adapters.dart'; import 'package:budget_app/models/app_settings.dart'; import 'package:budget_app/models/category.dart'; import 'package:budget_app/models/global_settings.dart'; +import 'package:budget_app/models/sms_handler_settings.dart'; import 'package:budget_app/models/sms_message.dart'; import 'package:budget_app/models/tag.dart'; import 'package:budget_app/models/transaction_record.dart'; @@ -19,7 +20,10 @@ extension HiveRegistrar on HiveInterface { registerAdapter(ColorAdapter()); registerAdapter(GlobalSettingsAdapter()); registerAdapter(IconDataAdapter()); + registerAdapter(SmsHandlerSettingsAdapter()); registerAdapter(SmsMessageAdapter()); + registerAdapter(SmsProcessingRuleAdapter()); + registerAdapter(SmsProcessingTypeAdapter()); registerAdapter(TagAdapter()); registerAdapter(TransactionRecordAdapter()); registerAdapter(UserAdapter()); @@ -33,7 +37,10 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(ColorAdapter()); registerAdapter(GlobalSettingsAdapter()); registerAdapter(IconDataAdapter()); + registerAdapter(SmsHandlerSettingsAdapter()); registerAdapter(SmsMessageAdapter()); + registerAdapter(SmsProcessingRuleAdapter()); + registerAdapter(SmsProcessingTypeAdapter()); registerAdapter(TagAdapter()); registerAdapter(TransactionRecordAdapter()); registerAdapter(UserAdapter()); diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 4b573da..28b34cc 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -1,4 +1,6 @@ +import 'package:budget_app/data/repositories/hive_sms_handler_repository.dart'; import 'package:budget_app/data/repositories/hive_sms_message_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/isms_handler_repository.dart'; import 'package:budget_app/data/repositories/interfaces/isms_message_repository.dart'; import 'package:get_it/get_it.dart'; @@ -54,6 +56,10 @@ Future initDependencies() async { HiveGlobalSettingsRepository(HiveService.globalSettings), ); + getIt.registerSingleton( + HiveSmsHandlerRepository(HiveService.smsHandlerSettings), + ); + // Services getIt.registerSingleton(SmsService()); diff --git a/lib/models/sms_handler_settings.dart b/lib/models/sms_handler_settings.dart new file mode 100644 index 0000000..86efbba --- /dev/null +++ b/lib/models/sms_handler_settings.dart @@ -0,0 +1,61 @@ + +import 'package:hive_ce/hive.dart'; + +part 'sms_handler_settings.g.dart'; + +/// Перечисление для определения типа обработки СМС. +@HiveType(typeId: 10) +enum SmsProcessingType { + /// Обработка с использованием регулярного выражения. + @HiveField(0) + regexp, + + /// Обработка с использованием кастомной функции. + @HiveField(1) + customFunction, +} + +/// Модель для хранения правила обработки СМС от конкретного отправителя. +@HiveType(typeId: 11) +class SmsProcessingRule extends HiveObject { + /// Тип обработки (regexp или кастомная функция). + @HiveField(0) + final SmsProcessingType type; + + /// Шаблон регулярного выражения (используется, если type == SmsProcessingType.regexp). + @HiveField(1) + final String? pattern; + + /// Идентификатор кастомной функции (используется, если type == SmsProcessingType.customFunction). + /// В коде этот ID будет сопоставляться с реальной функцией. + @HiveField(2) + final String? customFunctionId; + + SmsProcessingRule({ + required this.type, + this.pattern, + this.customFunctionId, + }) : assert( + (type == SmsProcessingType.regexp && pattern != null) || + (type == SmsProcessingType.customFunction && customFunctionId != null), + 'Pattern must be provided for regexp type, and customFunctionId for customFunction type.', + ); +} + +/// Модель для хранения всех настроек обработки СМС для одного пользователя. +@HiveType(typeId: 12) +class SmsHandlerSettings extends HiveObject { + /// Уникальный идентификатор пользователя, к которому относятся эти настройки. + @HiveField(0) + final String userId; + + /// Карта правил обработки, где ключ - это идентификатор отправителя (например, 'SBERBANK' или номер телефона), + /// а значение - правило обработки для этого отправителя. + @HiveField(1) + final Map rulesBySender; + + SmsHandlerSettings({ + required this.userId, + required this.rulesBySender, + }); +} diff --git a/lib/models/sms_handler_settings.g.dart b/lib/models/sms_handler_settings.g.dart new file mode 100644 index 0000000..78522b0 --- /dev/null +++ b/lib/models/sms_handler_settings.g.dart @@ -0,0 +1,121 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sms_handler_settings.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SmsProcessingRuleAdapter extends TypeAdapter { + @override + final typeId = 11; + + @override + SmsProcessingRule read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SmsProcessingRule( + type: fields[0] as SmsProcessingType, + pattern: fields[1] as String?, + customFunctionId: fields[2] as String?, + ); + } + + @override + void write(BinaryWriter writer, SmsProcessingRule obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.type) + ..writeByte(1) + ..write(obj.pattern) + ..writeByte(2) + ..write(obj.customFunctionId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SmsProcessingRuleAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SmsHandlerSettingsAdapter extends TypeAdapter { + @override + final typeId = 12; + + @override + SmsHandlerSettings read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SmsHandlerSettings( + userId: fields[0] as String, + rulesBySender: (fields[1] as Map).cast(), + ); + } + + @override + void write(BinaryWriter writer, SmsHandlerSettings obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.userId) + ..writeByte(1) + ..write(obj.rulesBySender); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SmsHandlerSettingsAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SmsProcessingTypeAdapter extends TypeAdapter { + @override + final typeId = 10; + + @override + SmsProcessingType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return SmsProcessingType.regexp; + case 1: + return SmsProcessingType.customFunction; + default: + return SmsProcessingType.regexp; + } + } + + @override + void write(BinaryWriter writer, SmsProcessingType obj) { + switch (obj) { + case SmsProcessingType.regexp: + writer.writeByte(0); + case SmsProcessingType.customFunction: + writer.writeByte(1); + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SmsProcessingTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} From 1ceacf88fb1b06c1369b8716f4a762e48ce5262f Mon Sep 17 00:00:00 2001 From: Sanders Date: Wed, 9 Jul 2025 23:26:00 +0300 Subject: [PATCH 23/35] Refactors HiveService and dependency injection Improves the management of Hive boxes by introducing separate initialization for global and user-specific data. This change enhances data isolation and allows for dynamic dependency injection based on the authentication state. It introduces `initGlobalDependencies` and `initUserSpecificDependencies` to manage the application's dependencies. User-scoped repositories and cubits are now registered/unregistered upon login/logout. The UserCubit is updated to allow dynamic injection of user-specific repositories after authentication. The TagCubit is refactored to use a repository. --- lib/data/database/hive_service.dart | 160 ++++++++------- .../repositories/hive_tag_repository.dart | 23 ++- lib/injection_container.dart | 183 +++++++++++++----- lib/logic/auth/auth_bloc.dart | 18 +- lib/logic/tag/tag_cubit.dart | 75 ++++--- lib/logic/tag/tag_state.dart | 18 ++ lib/logic/user/user_cubit.dart | 47 +++-- lib/main.dart | 95 +++++---- lib/pages/tag/tag_list_page.dart | 56 +++--- 9 files changed, 418 insertions(+), 257 deletions(-) create mode 100644 lib/logic/tag/tag_state.dart diff --git a/lib/data/database/hive_service.dart b/lib/data/database/hive_service.dart index c802503..d56b01f 100644 --- a/lib/data/database/hive_service.dart +++ b/lib/data/database/hive_service.dart @@ -1,81 +1,107 @@ -import 'package:budget_app/hive/hive_registrar.g.dart'; -import 'package:budget_app/models/app_settings.dart'; // Добавлен импорт AppSettings -import 'package:budget_app/models/global_settings.dart'; -import 'package:budget_app/models/sms_handler_settings.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; -import 'package:logger/logger.dart'; -import '/models/category.dart'; -import '/models/sms_message.dart'; -import '/models/tag.dart'; -import '/models/transaction_record.dart'; -import '/models/user.dart'; - -final _logger = Logger(); +import '../../models/app_settings.dart'; +import '../../models/category.dart'; +import '../../models/global_settings.dart'; +import '../../models/sms_handler_settings.dart'; +import '../../models/sms_message.dart'; +import '../../models/tag.dart'; +import '../../models/transaction_record.dart'; +import '../../models/user.dart'; +/// Сервис для управления Hive Box. +/// Отвечает за инициализацию, открытие и закрытие глобальных и пользовательских хранилищ. class HiveService { - static const String _appSettingsBox = - 'app_settings'; // Переименовано для настроек приложения - static const String _categoryBox = 'categories'; - static const String _tagBox = 'tags'; - static const String _transactionBox = 'transactions'; - static const String _userBox = 'users'; - static const String _smsMessageBox = 'smsMessages'; - static const String _globalSettings = 'global_settings'; - static const String _smsHandlerSettingsBox = 'sms_handler_settings'; + // --- Глобальные Box --- + // Эти хранилища не зависят от пользователя и инициализируются один раз. + static late final Box users; + static late final Box globalSettings; - static Future init() async { - _logger.i('Initializing Hive database'); + /// Инициализация глобальных Hive Box. + /// Должен вызываться при старте приложения. + static Future initGlobalBoxes() async { await Hive.initFlutter(); + // Регистрация адаптеров Hive... + _registerAdapters(); - Hive.registerAdapters(); - - // Открытие всех Box'ов - await Future.wait([ - Hive.openBox( - _appSettingsBox, - ), // Открываем Box для AppSettings - Hive.openBox(_categoryBox), - Hive.openBox(_tagBox), - Hive.openBox(_transactionBox), - Hive.openBox(_userBox), - Hive.openBox(_smsMessageBox), - Hive.openBox(_globalSettings), - Hive.openBox(_smsHandlerSettingsBox), - ]); - - // Проверка и заполнение начальными данными - _checkAndFillInitialData(); + // Открытие глобальных хранилищ + users = await Hive.openBox('users'); + globalSettings = await Hive.openBox('global_settings'); } - static Box get appSettings => - Hive.box(_appSettingsBox); // Геттер для настроек - static Box get categories => Hive.box(_categoryBox); - static Box get tags => Hive.box(_tagBox); - static Box get transactions => - Hive.box(_transactionBox); - static Box get users => Hive.box(_userBox); - static Box get smsMessages => - Hive.box(_smsMessageBox); - static Box get globalSettings => - Hive.box(_globalSettings); - static Box get smsHandlerSettings => - Hive.box(_smsHandlerSettingsBox); + // --- Пользовательские Box --- + // Эти хранилища привязаны к конкретному пользователю. + // Они открываются после входа пользователя в систему. + static late Box categories; + static late Box tags; + static late Box transactions; + static late Box appSettings; + static late Box smsHandlerSettings; + static late Box smsMessages; - /// Проверяет и заполняет боксы начальными данными при первом запуске - static void _checkAndFillInitialData() { - final userBox = users; - final catBox = categories; - final tagBox = tags; - final transactionBox = transactions; + /// Инициализация Hive Box для конкретного пользователя. + /// [userId] - Уникальный идентификатор пользователя. + static Future initUserBoxes(String userId) async { + // Открываем Box'ы с именами, включающими userId для изоляции данных. + // Например: 'categories_user123' + categories = await Hive.openBox('categories_\$userId'); + tags = await Hive.openBox('tags_\$userId'); + transactions = await Hive.openBox( + 'transactions_\$userId', + ); + appSettings = await Hive.openBox('app_settings_\$userId'); + smsHandlerSettings = await Hive.openBox( + 'sms_handler_settings_\$userId', + ); + smsMessages = await Hive.openBox('sms_messages_\$userId'); + } - if (userBox.isEmpty && - catBox.isEmpty && - tagBox.isEmpty && - transactionBox.isEmpty) { - _logger.i( - 'Initial data is empty. It will be filled when a user is created.', - ); + /// Закрытие пользовательских Hive Box. + /// Должен вызываться при выходе пользователя из системы. + static Future closeUserBoxes() async { + await categories.close(); + await tags.close(); + await transactions.close(); + await appSettings.close(); + await smsHandlerSettings.close(); + await smsMessages.close(); + } + + /// Регистрация всех адаптеров Hive. + // Регистрация всех адаптеров Hive. + static void _registerAdapters() { + if (!Hive.isAdapterRegistered(UserAdapter().typeId)) { + Hive.registerAdapter(UserAdapter()); + } + if (!Hive.isAdapterRegistered(CategoryAdapter().typeId)) { + Hive.registerAdapter(CategoryAdapter()); + } + if (!Hive.isAdapterRegistered(TagAdapter().typeId)) { + Hive.registerAdapter(TagAdapter()); + } + if (!Hive.isAdapterRegistered(TransactionRecordAdapter().typeId)) { + Hive.registerAdapter(TransactionRecordAdapter()); + } + if (!Hive.isAdapterRegistered(AppSettingsAdapter().typeId)) { + Hive.registerAdapter(AppSettingsAdapter()); + } + if (!Hive.isAdapterRegistered(GlobalSettingsAdapter().typeId)) { + Hive.registerAdapter(GlobalSettingsAdapter()); + } + if (!Hive.isAdapterRegistered(SmsHandlerSettingsAdapter().typeId)) { + Hive.registerAdapter(SmsHandlerSettingsAdapter()); + } + if (!Hive.isAdapterRegistered(SmsMessageAdapter().typeId)) { + Hive.registerAdapter(SmsMessageAdapter()); + } + + // Добавляем регистрацию SmsProcessingRuleAdapter + if (!Hive.isAdapterRegistered(SmsProcessingRuleAdapter().typeId)) { + Hive.registerAdapter(SmsProcessingRuleAdapter()); + } + // Добавляем регистрацию SmsProcessingTypeAdapter + if (!Hive.isAdapterRegistered(SmsProcessingTypeAdapter().typeId)) { + Hive.registerAdapter(SmsProcessingTypeAdapter()); } } } diff --git a/lib/data/repositories/hive_tag_repository.dart b/lib/data/repositories/hive_tag_repository.dart index 7b4ff47..bee8b61 100644 --- a/lib/data/repositories/hive_tag_repository.dart +++ b/lib/data/repositories/hive_tag_repository.dart @@ -1,11 +1,19 @@ +import 'package:budget_app/data/database/hive_service.dart'; +import 'package:budget_app/data/repositories/interfaces/itag_repository.dart'; +import 'package:budget_app/models/tag.dart'; import 'package:hive_ce/hive.dart'; -import '/data/repositories/interfaces/itag_repository.dart'; -import '../../models/tag.dart'; class HiveTagRepository implements ITagRepository { - final Box _box; + HiveTagRepository(); // Конструктор без параметров - HiveTagRepository(this._box); + // Получаем бокс тегов из HiveService + Box get _box { + if (HiveService.tags.isOpen) { + return HiveService.tags; + } else { + throw Exception('Tags box is not initialized'); + } + } @override Future> getAll() async { @@ -32,13 +40,10 @@ class HiveTagRepository implements ITagRepository { await _box.delete(id); } - // Комментарий: Реализуем новый метод, объявленный в интерфейсе ITagRepository. @override Future> getAllByUser(String userId) async { - // Комментарий: Мы используем метод `where` для фильтрации всех записей в хранилище Hive. - // Он перебирает все теги (`_box.values`) и возвращает только те, - // у которых поле `userId` совпадает с идентификатором, переданным в метод. - return _box.values.where((tag) => tag.userId == userId).toList(); + // Фильтрация по userId больше не требуется, так как бокс уже пользовательский + return _box.values.toList(); } @override diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 28b34cc..575f149 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -1,100 +1,185 @@ -import 'package:budget_app/data/repositories/hive_sms_handler_repository.dart'; -import 'package:budget_app/data/repositories/hive_sms_message_repository.dart'; -import 'package:budget_app/data/repositories/interfaces/isms_handler_repository.dart'; -import 'package:budget_app/data/repositories/interfaces/isms_message_repository.dart'; import 'package:get_it/get_it.dart'; import 'data/database/hive_service.dart'; import 'data/repositories/hive_category_repository.dart'; import 'data/repositories/hive_global_settings_repository.dart'; -import 'data/repositories/hive_settings_repository.dart'; // Добавляем импорт HiveSettingsRepository +import 'data/repositories/hive_settings_repository.dart'; +import 'data/repositories/hive_sms_handler_repository.dart'; +import 'data/repositories/hive_sms_message_repository.dart'; import 'data/repositories/hive_tag_repository.dart'; import 'data/repositories/hive_transaction_repository.dart'; import 'data/repositories/hive_user_repository.dart'; import 'data/repositories/interfaces/icategory_repository.dart'; import 'data/repositories/interfaces/iglobal_settings_repository.dart'; -import 'data/repositories/interfaces/isettings_repository.dart'; // Добавляем импорт ISettingsRepository +import 'data/repositories/interfaces/isettings_repository.dart'; +import 'data/repositories/interfaces/isms_handler_repository.dart'; +import 'data/repositories/interfaces/isms_message_repository.dart'; import 'data/repositories/interfaces/itag_repository.dart'; import 'data/repositories/interfaces/itransaction_repository.dart'; import 'data/repositories/interfaces/iuser_repository.dart'; import 'logic/auth/auth_bloc.dart'; import 'logic/category/category_cubit.dart'; -import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit +import 'logic/settings/settings_cubit.dart'; import 'logic/sms/sms_cubit.dart'; import 'logic/tag/tag_cubit.dart'; import 'logic/transaction/transaction_bloc.dart'; -import 'logic/user/user_cubit.dart'; // Импортируем UserCubit вместо UserService +import 'logic/user/user_cubit.dart'; import 'services/sms_service.dart'; final getIt = GetIt.instance; -Future initDependencies() async { +/// Инициализация глобальных зависимостей, которые не зависят от пользователя. +/// Вызывается один раз при старте приложения. +Future initGlobalDependencies() async { // Инициализация Hive - await HiveService.init(); - - // Регистрация репозиториев - getIt.registerSingleton( - HiveCategoryRepository(HiveService.categories), - ); - - getIt.registerSingleton(HiveTagRepository(HiveService.tags)); - - getIt.registerSingleton( - HiveTransactionRepository(HiveService.transactions), - ); + await HiveService.initGlobalBoxes(); + // Глобальные репозитории getIt.registerSingleton( HiveUserRepository(HiveService.users), ); - - // Регистрируем ISettingsRepository с реализацией HiveSettingsRepository - getIt.registerSingleton( - HiveSettingsRepository(HiveService.appSettings), - ); - getIt.registerSingleton( HiveGlobalSettingsRepository(HiveService.globalSettings), ); - getIt.registerSingleton( - HiveSmsHandlerRepository(HiveService.smsHandlerSettings), - ); - - // Services + // Сервисы getIt.registerSingleton(SmsService()); - // Регистрируем UserCubit с передачей всех необходимых репозиториев + // Cubits & Blocs, которые нужны до входа пользователя getIt.registerSingleton( UserCubit( settingsRepository: getIt(), userRepository: getIt(), - categoryRepository: getIt(), - tagRepository: getIt(), - transactionRepository: getIt(), + // Эти репозитории будут заменены после входа пользователя + categoryRepository: + null, // Временно null, будет заменен в initUserSpecificDependencies + tagRepository: + null, // Временно null, будет заменен в initUserSpecificDependencies + transactionRepository: + null, // Временно null, будет заменен в initUserSpecificDependencies ), ); - - // Blocs getIt.registerFactory(() => AuthBloc(userCubit: getIt())); +} + +/// Инициализация зависимостей, специфичных для пользователя. +/// Вызывается после успешной аутентификации. +Future initUserSpecificDependencies(String userId) async { + // Открываем пользовательские Hive Box'ы + await HiveService.initUserBoxes(userId); + + // --- Регистрация пользовательских репозиториев --- + // Используем lazy singletons, чтобы их можно было легко сбросить и создать заново при смене пользователя. + + // Категории + if (getIt.isRegistered()) { + await getIt.unregister(); + } + getIt.registerLazySingleton( + () => HiveCategoryRepository(HiveService.categories), + ); + + // Теги + if (getIt.isRegistered()) { + await getIt.unregister(); + } + getIt.registerLazySingleton( + () => HiveTagRepository(), + ); + + // Транзакции + if (getIt.isRegistered()) { + await getIt.unregister(); + } + getIt.registerLazySingleton( + () => HiveTransactionRepository(HiveService.transactions), + ); + + // Настройки приложения + if (getIt.isRegistered()) { + await getIt.unregister(); + } + getIt.registerLazySingleton( + () => HiveSettingsRepository(HiveService.appSettings), + ); + + // Настройки обработчика SMS + if (getIt.isRegistered()) { + await getIt.unregister(); + } + getIt.registerLazySingleton( + () => HiveSmsHandlerRepository(HiveService.smsHandlerSettings), + ); + + // Сообщения SMS + if (getIt.isRegistered()) { + await getIt.unregister(); + } + getIt.registerLazySingleton( + () => HiveSmsMessageRepository(HiveService.smsMessages), + ); + + // --- Обновление UserCubit новыми репозиториями --- + // Мы не пересоздаем UserCubit, а просто обновляем его зависимости. + final userCubit = getIt(); + userCubit.categoryRepository = getIt(); + userCubit.tagRepository = getIt(); + userCubit.transactionRepository = getIt(); + + // --- Регистрация Cubits & Blocs, которые зависят от пользовательских данных --- + + // Настройки + if (getIt.isRegistered()) { + await getIt.unregister(); + } + getIt.registerSingleton( + SettingsCubit(getIt()), + ); + + // Транзакции + if (getIt.isRegistered()) { + await getIt.unregister(); + } getIt.registerFactory( () => TransactionBloc(transactionRepository: getIt()), ); + // SMS + if (getIt.isRegistered()) { + await getIt.unregister(); + } getIt.registerFactory(() => SmsCubit(getIt(), getIt(), getIt())); + + // Категории + if (getIt.isRegistered()) { + await getIt.unregister(); + } getIt.registerFactory( () => CategoryCubit(getIt()), - ); // Регистрируем CategoryCubit с зависимостью от UserCubit - - getIt.registerFactory(() => TagCubit(getIt())); - - - getIt.registerSingleton( - HiveSmsMessageRepository(HiveService.smsMessages), ); - // Регистрация сервисов - getIt.registerSingleton( - SettingsCubit(getIt()), - ); // Регистрируем Cubit и передаем IUserRepository и UserService + // Теги + if (getIt.isRegistered()) { + await getIt.unregister(); + } + getIt.registerFactory(() => TagCubit(getIt())); +} +/// Сброс пользовательских зависимостей при выходе из системы. +Future resetUserSpecificDependencies() async { + // Закрываем пользовательские Hive Box'ы + await HiveService.closeUserBoxes(); + + // Разрегистрация всех пользовательских зависимостей + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); } diff --git a/lib/logic/auth/auth_bloc.dart b/lib/logic/auth/auth_bloc.dart index e46c547..bbb3e11 100644 --- a/lib/logic/auth/auth_bloc.dart +++ b/lib/logic/auth/auth_bloc.dart @@ -2,6 +2,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:budget_app/models/user.dart'; import 'package:budget_app/logic/user/user_cubit.dart'; +import 'package:budget_app/injection_container.dart' as di; part 'auth_event.dart'; part 'auth_state.dart'; @@ -16,27 +17,28 @@ class AuthBloc extends Bloc { } void _onAuthStarted(AuthStarted event, Emitter emit) async { - // Комментарий: Этот метод теперь просто проверяет ТЕКУЩЕЕ состояние UserCubit, - // не инициируя его повторную загрузку. Инициализация происходит один раз в main.dart. final userState = _userCubit.state; if (userState is UserLoaded && userState.user != null) { - // Если пользователь уже загружен в UserCubit, считаем его аутентифицированным. + // Если пользователь уже загружен, инициализируем его зависимости. + await di.initUserSpecificDependencies(userState.user!.id); emit(AuthAuthenticated(user: userState.user!)); } else { - // Если пользователь не загружен (null) или состояние другое, считаем его неаутентифицированным. emit(AuthUnauthenticated()); } } - void _onAuthLoggedIn(AuthLoggedIn event, Emitter emit) { - // Комментарий: Обновляем UserCubit с новым пользователем. + void _onAuthLoggedIn(AuthLoggedIn event, Emitter emit) async { + // Инициализируем зависимости для вошедшего пользователя. + await di.initUserSpecificDependencies(event.user.id); _userCubit.setUser(event.user); emit(AuthAuthenticated(user: event.user)); } - void _onAuthLoggedOut(AuthLoggedOut event, Emitter emit) { - // Вызываем logout у UserCubit + void _onAuthLoggedOut(AuthLoggedOut event, Emitter emit) async { + // Сбрасываем пользовательские зависимости. + await di.resetUserSpecificDependencies(); _userCubit.logout(); emit(AuthUnauthenticated()); } } + diff --git a/lib/logic/tag/tag_cubit.dart b/lib/logic/tag/tag_cubit.dart index fe896f6..13eedbb 100644 --- a/lib/logic/tag/tag_cubit.dart +++ b/lib/logic/tag/tag_cubit.dart @@ -1,53 +1,52 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:budget_app/models/tag.dart'; -import 'package:hive_ce/hive.dart'; -import 'package:budget_app/logic/user/user_cubit.dart'; +import 'package:budget_app/data/repositories/interfaces/itag_repository.dart'; +import 'package:budget_app/logic/tag/tag_state.dart'; +import 'package:budget_app/models/tag.dart'; // Добавляем импорт модели Tag -class TagCubit extends Cubit> { - final UserCubit _userCubit; - final Box _tagBox; +class TagCubit extends Cubit { + final ITagRepository _repository; - TagCubit(this._userCubit) - : _tagBox = Hive.box('tags'), - super([]); + TagCubit(this._repository) : super(TagInitial()); - - void loadTags() { - // Получаем текущего пользователя из состояния UserCubit - final userState = _userCubit.state; - if (userState is UserLoaded && userState.user != null) { - final currentUserId = userState.user!.id; - final userTags = _tagBox.values - .where((tag) => tag.userId == currentUserId) - .toList(); - emit(List.from(userTags)); - } else { - emit([]); + // Загрузка тегов + Future loadTags() async { + emit(TagLoading()); + try { + final tags = await _repository.getAll(); + emit(TagLoaded(tags)); + } catch (e) { + emit(TagError('Ошибка загрузки тегов: ${e.toString()}')); } } - - void addTag(Tag tag) { - // Получаем текущего пользователя из состояния UserCubit - final userState = _userCubit.state; - if (userState is UserLoaded && userState.user != null) { - final currentUserId = userState.user!.id; - final newTag = tag.copyWith(userId: currentUserId); - _tagBox.put(newTag.id, newTag); - loadTags(); + // Добавление тега + Future addTag(Tag tag) async { + try { + await _repository.add(tag); + await loadTags(); // Перезагружаем список + } catch (e) { + emit(TagError('Ошибка добавления тега: ${e.toString()}')); } } - - void updateTag(Tag tag) { - _tagBox.put(tag.id, tag); - loadTags(); + // Обновление тега + Future updateTag(Tag tag) async { + try { + await _repository.update(tag); + await loadTags(); // Перезагружаем список + } catch (e) { + emit(TagError('Ошибка обновления тега: ${e.toString()}')); + } } - - void deleteTag(String id) { - _tagBox.delete(id); - loadTags(); + // Удаление тега + Future deleteTag(String id) async { + try { + await _repository.delete(id); + await loadTags(); // Перезагружаем список + } catch (e) { + emit(TagError('Ошибка удаления тега: ${e.toString()}')); + } } } diff --git a/lib/logic/tag/tag_state.dart b/lib/logic/tag/tag_state.dart new file mode 100644 index 0000000..47b9f96 --- /dev/null +++ b/lib/logic/tag/tag_state.dart @@ -0,0 +1,18 @@ +import 'package:budget_app/models/tag.dart'; + +// Состояния для TagCubit +abstract class TagState {} + +class TagInitial extends TagState {} + +class TagLoading extends TagState {} + +class TagLoaded extends TagState { + final List tags; + TagLoaded(this.tags); +} + +class TagError extends TagState { + final String message; + TagError(this.message); +} diff --git a/lib/logic/user/user_cubit.dart b/lib/logic/user/user_cubit.dart index 00d16c1..06f2cd0 100644 --- a/lib/logic/user/user_cubit.dart +++ b/lib/logic/user/user_cubit.dart @@ -20,9 +20,10 @@ part 'user_state.dart'; class UserCubit extends Cubit { final IGlobalSettingsRepository _settingsRepository; final IUserRepository _userRepository; - final ICategoryRepository _categoryRepository; - final ITagRepository _tagRepository; - final ITransactionRepository _transactionRepository; + // Эти репозитории теперь могут быть null и устанавливаются после входа пользователя. + ICategoryRepository? _categoryRepository; + ITagRepository? _tagRepository; + ITransactionRepository? _transactionRepository; final Logger _logger = Logger( printer: PrettyPrinter( methodCount: 2, // Number of method calls to be displayed @@ -38,15 +39,22 @@ class UserCubit extends Cubit { UserCubit({ required IGlobalSettingsRepository settingsRepository, required IUserRepository userRepository, - required ICategoryRepository categoryRepository, - required ITagRepository tagRepository, - required ITransactionRepository transactionRepository, - }) : _settingsRepository = settingsRepository, - _userRepository = userRepository, - _categoryRepository = categoryRepository, - _tagRepository = tagRepository, - _transactionRepository = transactionRepository, - super(UserInitial()); + // Репозитории сделаны опциональными в конструкторе. + ICategoryRepository? categoryRepository, + ITagRepository? tagRepository, + ITransactionRepository? transactionRepository, + }) : _settingsRepository = settingsRepository, + _userRepository = userRepository, + _categoryRepository = categoryRepository, + _tagRepository = tagRepository, + _transactionRepository = transactionRepository, + super(UserInitial()); + + // Сеттеры для внедрения зависимостей после аутентификации. + set categoryRepository(ICategoryRepository? repo) => _categoryRepository = repo; + set tagRepository(ITagRepository? repo) => _tagRepository = repo; + set transactionRepository(ITransactionRepository? repo) => + _transactionRepository = repo; // Метод для инициализации UserCubit Future init() async { @@ -95,21 +103,30 @@ class UserCubit extends Cubit { } Future _createInitialData(String userId) async { + // Проверяем, что репозитории были установлены, прежде чем их использовать. + if (_categoryRepository == null || + _tagRepository == null || + _transactionRepository == null) { + _logger.w('User-specific repositories are not initialized. Skipping initial data creation.'); + return; + } try { // Разбиваем создание данных на этапы emit(UserLoading(progress: 0.7, message: 'Создание категорий...')); - await _categoryRepository.addAll( + // Используем '!', так как мы уже проверили на null. + await _categoryRepository!.addAll( CategoryUtils.getDefaultCategories(userId), ); emit(UserLoading(progress: 0.8, message: 'Создание тегов...')); - await _tagRepository.addAll(TagUtils.getDefaultTags(userId)); + await _tagRepository!.addAll(TagUtils.getDefaultTags(userId)); emit(UserLoading(progress: 0.9, message: 'Создание транзакций...')); - await _transactionRepository.addAll( + await _transactionRepository!.addAll( TransactionUtils.getSampleTransactions(userId), ); } catch (e, stack) { + _logger.e( 'Error creating initial data for user: $userId', error: e, diff --git a/lib/main.dart b/lib/main.dart index 5b2a7cb..ebfd34b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; // Добав import 'package:get_it/get_it.dart'; import '/l10n/app_localizations.dart'; +import '/pages/splash/splash_screen.dart'; import 'injection_container.dart' as di; import 'logic/auth/auth_bloc.dart'; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit @@ -14,25 +15,9 @@ import 'logic/user/user_cubit.dart'; // Импортируем UserCubit import 'pages/login/login_page.dart'; import 'theme/app_theme.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; - -import '/l10n/app_localizations.dart'; -import '/logic/auth/auth_bloc.dart'; -import '/logic/settings/settings_cubit.dart'; -import '/logic/sms/sms_cubit.dart'; -import '/logic/transaction/transaction_bloc.dart'; -import '/logic/user/user_cubit.dart'; -import '/pages/home/home_page.dart'; -import '/pages/login/login_page.dart'; -import '/pages/splash/splash_screen.dart'; -import '/theme/app_theme.dart'; -import 'injection_container.dart' as di; - void main() async { WidgetsFlutterBinding.ensureInitialized(); - await di.initDependencies(); + await di.initGlobalDependencies(); runApp(const MyApp()); } @@ -41,6 +26,7 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { + // 1. Предоставляем глобальные Blocs, которые доступны всегда. return MultiBlocProvider( providers: [ BlocProvider( @@ -49,31 +35,63 @@ class MyApp extends StatelessWidget { BlocProvider( create: (context) => GetIt.instance(), ), - BlocProvider(create: (context) => GetIt.instance()), - BlocProvider(create: (context) => GetIt.instance()), - BlocProvider(create: (context) => GetIt.instance()), ], child: BlocListener( - // Запускаем AuthBloc, как только UserCubit завершил загрузку, - // независимо от того, найден пользователь или нет. + // 2. Запускаем проверку аутентификации, как только UserCubit загрузил данные. listenWhen: (previous, current) => current is UserLoaded, listener: (context, userState) { context.read().add(AuthStarted()); }, - child: BlocBuilder( - builder: (context, settingsState) { - final isDarkMode = settingsState is SettingsLoaded - ? settingsState.isDarkMode - : false; - final languageCode = settingsState is SettingsLoaded - ? settingsState.languageCode - : 'ru'; + // 3. В зависимости от статуса аутентификации, строим разное дерево виджетов. + child: BlocBuilder( + builder: (context, authState) { + // 4. Если пользователь аутентифицирован... + if (authState is AuthAuthenticated) { + // ...предоставляем все пользовательские зависимости. + return MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => GetIt.instance()), + BlocProvider(create: (context) => GetIt.instance()), + BlocProvider(create: (context) => GetIt.instance()), + // Можно добавить и остальные, если они нужны глобально в авторизованной зоне + ], + // И строим приложение с пользовательской темой. + child: BlocBuilder( + builder: (context, settingsState) { + final isDarkMode = settingsState is SettingsLoaded + ? settingsState.isDarkMode + : false; + final languageCode = settingsState is SettingsLoaded + ? settingsState.languageCode + : 'ru'; + return MaterialApp( + title: 'Budget App', + theme: AppTheme.lightTheme(), + darkTheme: AppTheme.darkTheme(), + themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: Locale(languageCode), + home: const HomePage(), + ); + }, + ), + ); + } + + // 5. Если пользователь НЕ аутентифицирован, показываем SplashScreen или LoginPage + // с темой по умолчанию. return MaterialApp( - title: AppLocalizations.of(context)?.appTitle ?? 'Budget App', + title: 'Budget App', theme: AppTheme.lightTheme(), darkTheme: AppTheme.darkTheme(), - themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light, + themeMode: ThemeMode.light, // Тема по умолчанию localizationsDelegates: const [ AppLocalizations.delegate, GlobalMaterialLocalizations.delegate, @@ -81,22 +99,13 @@ class MyApp extends StatelessWidget { GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - locale: Locale(languageCode), + locale: const Locale('ru'), // Язык по умолчанию home: BlocBuilder( builder: (context, userState) { if (userState is UserLoading || userState is UserInitial) { return const SplashScreen(); } - - return BlocBuilder( - builder: (context, authState) { - if (authState is AuthAuthenticated) { - return const HomePage(); - } else { - return const LoginPage(); - } - }, - ); + return const LoginPage(); }, ), ); diff --git a/lib/pages/tag/tag_list_page.dart b/lib/pages/tag/tag_list_page.dart index 532c690..4b8ac23 100644 --- a/lib/pages/tag/tag_list_page.dart +++ b/lib/pages/tag/tag_list_page.dart @@ -1,10 +1,11 @@ import 'package:budget_app/l10n/app_localizations.dart'; import 'package:budget_app/logic/tag/tag_cubit.dart'; +import 'package:budget_app/logic/tag/tag_state.dart'; // Импортируем состояния import 'package:budget_app/models/tag.dart'; import 'package:budget_app/pages/tag/tag_edit_page.dart'; import 'package:budget_app/pages/tag/widgets/add_tag_button.dart'; import 'package:budget_app/pages/tag/widgets/tag_list_item.dart'; -import 'package:budget_app/logic/user/user_cubit.dart'; // Импортируем UserCubit +import 'package:budget_app/logic/user/user_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; @@ -21,36 +22,40 @@ class _TagListPageState extends State { @override Widget build(BuildContext context) { - return BlocProvider( create: (context) => GetIt.instance()..loadTags(), - child: Builder(builder: (context) { final localizations = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar(title: Text(localizations.editTags)), - body: BlocBuilder>( - builder: (context, tags) { - return AnimatedList( - key: _listKey, - initialItemCount: tags.length, - itemBuilder: (context, index, animation) { - final tag = tags[index]; - return SizeTransition( - sizeFactor: animation, - child: TagListItem( - tag: tag, - onEdit: () => _editTag(context, tag), - onDelete: () => - _deleteTag(context, tag, index), - ), - ); - }, - ); + body: BlocBuilder( + builder: (context, state) { + if (state is TagLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is TagError) { + return Center(child: Text(state.message)); + } else if (state is TagLoaded) { + return AnimatedList( + key: _listKey, + initialItemCount: state.tags.length, + itemBuilder: (context, index, animation) { + final tag = state.tags[index]; + return SizeTransition( + sizeFactor: animation, + child: TagListItem( + tag: tag, + onEdit: () => _editTag(context, tag), + onDelete: () => _deleteTag(context, tag, index), + ), + ); + }, + ); + } else { + return const Center(child: Text('Начните работу с тегами')); + } }, ), floatingActionButton: AddTagButton( - onPressed: () => _addTag(context), ), ); @@ -64,11 +69,8 @@ class _TagListPageState extends State { MaterialPageRoute( builder: (_) => TagEditPage( onSave: (name) { - // Получаем ID текущего пользователя из UserCubit final userState = context.read().state; if (userState is! UserLoaded || userState.user == null) { - // Обработка случая, когда пользователь не аутентифицирован - // Возможно, показать сообщение об ошибке или перенаправить на страницу входа return; } final currentUserId = userState.user!.id; @@ -91,9 +93,7 @@ class _TagListPageState extends State { builder: (_) => TagEditPage( tag: tag, onSave: (name) { - final updatedTag = tag.copyWith( - name: name, - ); + final updatedTag = tag.copyWith(name: name); context.read().updateTag(updatedTag); }, ), From 56428e9a66a223c7cc222aeac7e530f9c690eaa1 Mon Sep 17 00:00:00 2001 From: Sanders Date: Thu, 10 Jul 2025 18:23:17 +0300 Subject: [PATCH 24/35] Register DateTime adapter for Hive --- lib/hive/hive_adapters.dart | 1 + lib/hive/hive_adapters.g.dart | 55 ++++++++++++++++++++++++++++++++++ lib/hive/hive_adapters.g.yaml | 22 +++++++++++++- lib/hive/hive_registrar.g.dart | 2 ++ 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index 8b6c07c..03911cc 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -6,5 +6,6 @@ import 'package:hive_ce/hive.dart'; @GenerateAdapters([ AdapterSpec(), AdapterSpec(), + AdapterSpec(), ]) part 'hive_adapters.g.dart'; diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index 255b017..9095817 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -83,3 +83,58 @@ class IconDataAdapter extends TypeAdapter { runtimeType == other.runtimeType && typeId == other.typeId; } + +class DateTimeAdapter extends TypeAdapter { + @override + final typeId = 2; + + @override + DateTime read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return DateTime( + (fields[0] as num).toInt(), + fields[1] == null ? 1 : (fields[1] as num).toInt(), + fields[2] == null ? 1 : (fields[2] as num).toInt(), + fields[3] == null ? 0 : (fields[3] as num).toInt(), + fields[4] == null ? 0 : (fields[4] as num).toInt(), + fields[5] == null ? 0 : (fields[5] as num).toInt(), + fields[6] == null ? 0 : (fields[6] as num).toInt(), + fields[7] == null ? 0 : (fields[7] as num).toInt(), + ); + } + + @override + void write(BinaryWriter writer, DateTime obj) { + writer + ..writeByte(8) + ..writeByte(0) + ..write(obj.year) + ..writeByte(1) + ..write(obj.month) + ..writeByte(2) + ..write(obj.day) + ..writeByte(3) + ..write(obj.hour) + ..writeByte(4) + ..write(obj.minute) + ..writeByte(5) + ..write(obj.second) + ..writeByte(6) + ..write(obj.millisecond) + ..writeByte(7) + ..write(obj.microsecond); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DateTimeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 257a15c..4b38796 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -1,7 +1,7 @@ # Generated by Hive CE # Manual modifications may be necessary for certain migrations # Check in to version control -nextTypeId: 2 +nextTypeId: 3 types: Color: typeId: 0 @@ -23,3 +23,23 @@ types: index: 3 fontFamilyFallback: index: 4 + DateTime: + typeId: 2 + nextIndex: 8 + fields: + year: + index: 0 + month: + index: 1 + day: + index: 2 + hour: + index: 3 + minute: + index: 4 + second: + index: 5 + millisecond: + index: 6 + microsecond: + index: 7 diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index 8c28cb0..7e3d1e9 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -18,6 +18,7 @@ extension HiveRegistrar on HiveInterface { registerAdapter(AppSettingsAdapter()); registerAdapter(CategoryAdapter()); registerAdapter(ColorAdapter()); + registerAdapter(DateTimeAdapter()); registerAdapter(GlobalSettingsAdapter()); registerAdapter(IconDataAdapter()); registerAdapter(SmsHandlerSettingsAdapter()); @@ -35,6 +36,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(AppSettingsAdapter()); registerAdapter(CategoryAdapter()); registerAdapter(ColorAdapter()); + registerAdapter(DateTimeAdapter()); registerAdapter(GlobalSettingsAdapter()); registerAdapter(IconDataAdapter()); registerAdapter(SmsHandlerSettingsAdapter()); From 4edde8a01b07c488f0f7253aca4399c66db65a27 Mon Sep 17 00:00:00 2001 From: Sanders Date: Thu, 10 Jul 2025 18:23:17 +0300 Subject: [PATCH 25/35] Remove manual adapter registration from HiveService --- lib/data/database/hive_service.dart | 41 ++--------------------------- 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/lib/data/database/hive_service.dart b/lib/data/database/hive_service.dart index d56b01f..02ae6bb 100644 --- a/lib/data/database/hive_service.dart +++ b/lib/data/database/hive_service.dart @@ -8,6 +8,7 @@ import '../../models/sms_message.dart'; import '../../models/tag.dart'; import '../../models/transaction_record.dart'; import '../../models/user.dart'; +import 'package:budget_app/hive/hive_registrar.g.dart'; /// Сервис для управления Hive Box. /// Отвечает за инициализацию, открытие и закрытие глобальных и пользовательских хранилищ. @@ -22,7 +23,7 @@ class HiveService { static Future initGlobalBoxes() async { await Hive.initFlutter(); // Регистрация адаптеров Hive... - _registerAdapters(); + Hive.registerAdapters(); // Открытие глобальных хранилищ users = await Hive.openBox('users'); @@ -66,42 +67,4 @@ class HiveService { await smsHandlerSettings.close(); await smsMessages.close(); } - - /// Регистрация всех адаптеров Hive. - // Регистрация всех адаптеров Hive. - static void _registerAdapters() { - if (!Hive.isAdapterRegistered(UserAdapter().typeId)) { - Hive.registerAdapter(UserAdapter()); - } - if (!Hive.isAdapterRegistered(CategoryAdapter().typeId)) { - Hive.registerAdapter(CategoryAdapter()); - } - if (!Hive.isAdapterRegistered(TagAdapter().typeId)) { - Hive.registerAdapter(TagAdapter()); - } - if (!Hive.isAdapterRegistered(TransactionRecordAdapter().typeId)) { - Hive.registerAdapter(TransactionRecordAdapter()); - } - if (!Hive.isAdapterRegistered(AppSettingsAdapter().typeId)) { - Hive.registerAdapter(AppSettingsAdapter()); - } - if (!Hive.isAdapterRegistered(GlobalSettingsAdapter().typeId)) { - Hive.registerAdapter(GlobalSettingsAdapter()); - } - if (!Hive.isAdapterRegistered(SmsHandlerSettingsAdapter().typeId)) { - Hive.registerAdapter(SmsHandlerSettingsAdapter()); - } - if (!Hive.isAdapterRegistered(SmsMessageAdapter().typeId)) { - Hive.registerAdapter(SmsMessageAdapter()); - } - - // Добавляем регистрацию SmsProcessingRuleAdapter - if (!Hive.isAdapterRegistered(SmsProcessingRuleAdapter().typeId)) { - Hive.registerAdapter(SmsProcessingRuleAdapter()); - } - // Добавляем регистрацию SmsProcessingTypeAdapter - if (!Hive.isAdapterRegistered(SmsProcessingTypeAdapter().typeId)) { - Hive.registerAdapter(SmsProcessingTypeAdapter()); - } - } } From adafdb88f8b5f0e6824ffd3f47549743594183dd Mon Sep 17 00:00:00 2001 From: Sanders Date: Thu, 10 Jul 2025 18:23:17 +0300 Subject: [PATCH 26/35] Improve error handling in HiveCategoryRepository --- .../hive_category_repository.dart | 84 ++++++++++++++----- 1 file changed, 63 insertions(+), 21 deletions(-) diff --git a/lib/data/repositories/hive_category_repository.dart b/lib/data/repositories/hive_category_repository.dart index e9277e4..37adda6 100644 --- a/lib/data/repositories/hive_category_repository.dart +++ b/lib/data/repositories/hive_category_repository.dart @@ -1,3 +1,4 @@ +import 'dart:async'; // Добавлено для Future import 'package:hive_ce/hive.dart'; import '/data/repositories/interfaces/icategory_repository.dart'; import '/models/category.dart'; @@ -9,68 +10,109 @@ class HiveCategoryRepository implements ICategoryRepository { @override Future> getAll() async { - return _box.values.toList(); + try { + return _box.values.toList(); + } catch (e) { + throw Exception('Ошибка получения категорий: $e'); + } } @override Future getById(String id) async { - return _box.get(id); + try { + return _box.get(id); + } catch (e) { + throw Exception('Ошибка получения категории: $e'); + } } @override Future add(Category category) async { - await _box.put(category.id, category); + try { + await _box.put(category.id, category); + } catch (e) { + throw Exception('Ошибка добавления категории: $e'); + } } @override Future update(Category category) async { - await add(category); + try { + await add(category); + } catch (e) { + throw Exception('Ошибка обновления категории: $e'); + } } @override Future delete(String id) async { - await _box.delete(id); + try { + await _box.delete(id); + } catch (e) { + throw Exception('Ошибка удаления категории: $e'); + } } @override Future> getIncomeCategories() async { - return _box.values.where((c) => c.isIncome).toList(); + try { + return _box.values.where((c) => c.isIncome).toList(); + } catch (e) { + throw Exception('Ошибка получения категорий доходов: $e'); + } } @override Future> getExpenseCategories() async { - return _box.values.where((c) => !c.isIncome).toList(); + try { + return _box.values.where((c) => !c.isIncome).toList(); + } catch (e) { + throw Exception('Ошибка получения категорий расходов: $e'); + } } // Новые методы для работы с пользователями @override Future> getAllByUser(String userId) async { - // Фильтруем все категории по userId - return _box.values.where((c) => c.userId == userId).toList(); + try { + return _box.values.where((c) => c.userId == userId).toList(); + } catch (e) { + throw Exception('Ошибка получения категорий пользователя: $e'); + } } @override Future> getIncomeCategoriesByUser(String userId) async { - // Получаем только категории доходов конкретного пользователя - return _box.values - .where((c) => c.userId == userId && c.isIncome) - .toList(); + try { + return _box.values + .where((c) => c.userId == userId && c.isIncome) + .toList(); + } catch (e) { + throw Exception('Ошибка получения категорий доходов пользователя: $e'); + } } @override Future> getExpenseCategoriesByUser(String userId) async { - // Получаем только категории расходов конкретного пользователя - return _box.values - .where((c) => c.userId == userId && !c.isIncome) - .toList(); + try { + return _box.values + .where((c) => c.userId == userId && !c.isIncome) + .toList(); + } catch (e) { + throw Exception('Ошибка получения категорий расходов пользователя: $e'); + } } @override Future addAll(List categories) async { - final Map categoryMap = { - for (var cat in categories) cat.id: cat - }; - await _box.putAll(categoryMap); + try { + final Map categoryMap = { + for (var cat in categories) cat.id: cat + }; + await _box.putAll(categoryMap); + } catch (e) { + throw Exception('Ошибка пакетного добавления категорий: $e'); + } } } From c829b0e6f5f87ac0b33d430b4e290ccb6a8bb175 Mon Sep 17 00:00:00 2001 From: Sanders Date: Thu, 10 Jul 2025 18:23:17 +0300 Subject: [PATCH 27/35] Refactor CategoryCubit to manage user-specific categories --- lib/injection_container.dart | 2 +- lib/logic/category/category_cubit.dart | 83 ++++++++++++++------------ 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 575f149..04e7c6e 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -155,7 +155,7 @@ Future initUserSpecificDependencies(String userId) async { await getIt.unregister(); } getIt.registerFactory( - () => CategoryCubit(getIt()), + () => CategoryCubit(getIt(), userId), // Добавляем userId ); // Теги diff --git a/lib/logic/category/category_cubit.dart b/lib/logic/category/category_cubit.dart index 782df2d..ab7f2b4 100644 --- a/lib/logic/category/category_cubit.dart +++ b/lib/logic/category/category_cubit.dart @@ -1,53 +1,60 @@ import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:budget_app/models/category.dart'; -import 'package:hive_ce/hive.dart'; -import 'package:budget_app/logic/user/user_cubit.dart'; // Импортируем UserCubit +import '../../data/repositories/interfaces/icategory_repository.dart'; +import '../../models/category.dart'; +import 'category_state.dart'; -class CategoryCubit extends Cubit> { - final UserCubit _userCubit; // Добавляем зависимость от UserCubit - final Box _categoryBox; // Используем Box для типизации +/// Cubit для управления категориями +/// Изменения: +/// - Добавлено хранение userId в cubit +/// - Упрощена работа с состояниями по аналогии с TagCubit +class CategoryCubit extends Cubit { + final ICategoryRepository repository; + final String userId; // Храним userId в cubit - CategoryCubit(this._userCubit) // Принимаем UserCubit через конструктор - : _categoryBox = Hive.box('categories'), - super([]); + CategoryCubit(this.repository, this.userId) : super(CategoryInitial()); - // Загружает категории, фильтруя их по userId текущего пользователя - void loadCategories() { - // Получаем текущего пользователя из состояния UserCubit - final userState = _userCubit.state; - if (userState is UserLoaded && userState.user != null) { - final currentUserId = userState.user!.id; - final userCategories = _categoryBox.values - .where((category) => category.userId == currentUserId) - .toList(); - emit(List.from(userCategories)); - } else { - emit([]); // Если пользователя нет, список категорий пуст + /// Загружает категории для текущего пользователя + Future loadCategories() async { + emit(CategoryLoading()); + try { + final categories = await repository.getAllByUser(userId); + emit(CategoryLoaded(categories)); + } catch (e) { + emit(CategoryError('Ошибка загрузки категорий: $e')); } } - // Добавляет новую категорию, присваивая ей userId текущего пользователя - void addCategory(Category category) { - // Получаем текущего пользователя из состояния UserCubit - final userState = _userCubit.state; - if (userState is UserLoaded && userState.user != null) { - final currentUserId = userState.user!.id; - final newCategory = category.copyWith(userId: currentUserId); // Присваиваем userId - _categoryBox.put(newCategory.id, newCategory); - loadCategories(); + /// Добавляет новую категорию + Future addCategory(Category category) async { + try { + // Устанавливаем userId для категории из cubit + final newCategory = category.copyWith(userId: userId); + await repository.add(newCategory); + // Перезагружаем список категорий + await loadCategories(); + } catch (e) { + emit(CategoryError('Ошибка добавления категории: $e')); } } - // Обновляет существующую категорию - void updateCategory(Category category) { - _categoryBox.put(category.id, category); - loadCategories(); + /// Обновляет существующую категорию + Future updateCategory(Category category) async { + try { + await repository.update(category); + await loadCategories(); + } catch (e) { + emit(CategoryError('Ошибка обновления категории: $e')); + } } - // Удаляет категорию по ее id - void deleteCategory(String id) { - _categoryBox.delete(id); - loadCategories(); + /// Удаляет категорию по её id + Future deleteCategory(String id) async { + try { + await repository.delete(id); + await loadCategories(); + } catch (e) { + emit(CategoryError('Ошибка удаления категории: $e')); + } } } From 193b79b38d90fcde42715b51e7032fafffae8b61 Mon Sep 17 00:00:00 2001 From: Sanders Date: Thu, 10 Jul 2025 18:23:17 +0300 Subject: [PATCH 28/35] Remove unused toMap and fromMap methods in User model --- lib/models/user.dart | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/lib/models/user.dart b/lib/models/user.dart index 66024fb..5cd5373 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -43,29 +43,6 @@ class User extends Equatable { lastSmsSyncTime = lastSmsSyncTime ?? DateTime(DateTime.now().year, DateTime.now().month - 1, 1); - /// Преобразование объекта в Map для сохранения в JSON или передачи по сети - Map toMap() { - return { - 'id': id, - 'name': name, - 'email': email, - 'updatedAt': updatedAt.toIso8601String(), - 'lastSmsSyncTime': lastSmsSyncTime.toIso8601String(), - }; - } - - /// Создание объекта User из Map (например, при загрузке из JSON) - factory User.fromMap(Map map) { - return User( - id: map['id'], - name: map['name'], - email: map['email'], - updatedAt: DateTime.parse(map['updatedAt']), - lastSmsSyncTime: map['lastSmsSyncTime'] != null - ? DateTime.parse(map['lastSmsSyncTime']) - : DateTime(DateTime.now().year, DateTime.now().month - 1, 1), - ); - } /// Переопределяем toString для удобного отображения в логах @override From 4b814a807d30359662ae4700588809b7c8d5f50e Mon Sep 17 00:00:00 2001 From: Sanders Date: Thu, 10 Jul 2025 18:23:18 +0300 Subject: [PATCH 29/35] Update CategoryListPage to use CategoryCubit with userId --- lib/pages/category/category_list_page.dart | 76 ++++++++++------------ 1 file changed, 34 insertions(+), 42 deletions(-) diff --git a/lib/pages/category/category_list_page.dart b/lib/pages/category/category_list_page.dart index 510ca7c..f76b323 100644 --- a/lib/pages/category/category_list_page.dart +++ b/lib/pages/category/category_list_page.dart @@ -1,10 +1,11 @@ import 'package:budget_app/l10n/app_localizations.dart'; import 'package:budget_app/logic/category/category_cubit.dart'; +import 'package:budget_app/logic/category/category_state.dart'; import 'package:budget_app/models/category.dart'; import 'package:budget_app/pages/category/category_edit_page.dart'; import 'package:budget_app/pages/category/widgets/add_category_button.dart'; import 'package:budget_app/pages/category/widgets/category_list_item.dart'; -import 'package:budget_app/logic/user/user_cubit.dart'; // Импортируем UserCubit +import 'package:budget_app/logic/user/user_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; @@ -17,41 +18,38 @@ class CategoryListPage extends StatefulWidget { } class _CategoryListPageState extends State { - final GlobalKey _listKey = GlobalKey(); - @override Widget build(BuildContext context) { - // Оборачиваем Scaffold в BlocProvider, чтобы все дочерние виджеты, - // включая floatingActionButton, имели доступ к CategoryCubit. return BlocProvider( create: (context) => GetIt.instance()..loadCategories(), - // Используем Builder для получения нового контекста, который "видит" BlocProvider. child: Builder(builder: (context) { final localizations = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar(title: Text(localizations.editCategories)), - body: BlocBuilder>( - builder: (context, categories) { - return AnimatedList( - key: _listKey, - initialItemCount: categories.length, - itemBuilder: (context, index, animation) { - final category = categories[index]; - return SizeTransition( - sizeFactor: animation, - child: CategoryListItem( + body: BlocBuilder( + builder: (context, state) { + if (state is CategoryLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is CategoryError) { + return Center(child: Text(state.message)); + } else if (state is CategoryLoaded) { + return ListView.builder( + itemCount: state.categories.length, + itemBuilder: (context, index) { + final category = state.categories[index]; + return CategoryListItem( category: category, onEdit: () => _editCategory(context, category), - onDelete: () => - _deleteCategory(context, category, index), - ), - ); - }, - ); + onDelete: () => _deleteCategory(context, category), + ); + }, + ); + } + // CategoryInitial + return const Center(child: Text('Нет категорий')); }, ), floatingActionButton: AddCategoryButton( - // Теперь этот context имеет доступ к CategoryCubit onPressed: () => _addCategory(context), ), ); @@ -65,13 +63,9 @@ class _CategoryListPageState extends State { MaterialPageRoute( builder: (_) => CategoryEditPage( onSave: (name, color, icon, isIncome) { - // Получаем ID текущего пользователя из UserCubit final userState = context.read().state; - if (userState is! UserLoaded || userState.user == null) { - // Обработка случая, когда пользователь не аутентифицирован - // Возможно, показать сообщение об ошибке или перенаправить на страницу входа - return; - } + if (userState is! UserLoaded || userState.user == null) return; + final currentUserId = userState.user!.id; final newCategory = Category( name: name, @@ -80,8 +74,8 @@ class _CategoryListPageState extends State { isIncome: isIncome, userId: currentUserId, ); + context.read().addCategory(newCategory); - _listKey.currentState?.insertItem(0); }, ), ), @@ -95,12 +89,17 @@ class _CategoryListPageState extends State { builder: (_) => CategoryEditPage( category: category, onSave: (name, color, icon, isIncome) { + final userState = context.read().state; + if (userState is! UserLoaded || userState.user == null) return; + + final currentUserId = userState.user!.id; final updatedCategory = category.copyWith( name: name, color: color, icon: icon, isIncome: isIncome, ); + context.read().updateCategory(updatedCategory); }, ), @@ -108,18 +107,11 @@ class _CategoryListPageState extends State { ); } - void _deleteCategory(BuildContext context, Category category, int index) { + void _deleteCategory(BuildContext context, Category category) { + final userState = context.read().state; + if (userState is! UserLoaded || userState.user == null) return; + + final currentUserId = userState.user!.id; context.read().deleteCategory(category.id); - _listKey.currentState?.removeItem( - index, - (context, animation) => SizeTransition( - sizeFactor: animation, - child: CategoryListItem( - category: category, - onEdit: () {}, - onDelete: () {}, - ), - ), - ); } } From fe61b50494017ce67db08ec2b2058fb6065afed2 Mon Sep 17 00:00:00 2001 From: Sanders Date: Thu, 10 Jul 2025 18:23:18 +0300 Subject: [PATCH 30/35] Fix navigation to CategoryListPage --- lib/pages/settings_page.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 1f81934..29d76ca 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -144,8 +144,7 @@ class SettingsPage extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => - const CategoryListPage(), + builder: (context) => const CategoryListPage(), ), ); }, From 8b75299d41c88f35d83b6988169721a4e354db43 Mon Sep 17 00:00:00 2001 From: Sanders Date: Sat, 12 Jul 2025 21:54:00 +0300 Subject: [PATCH 31/35] Refactors data repositories for single-user mode This commit refactors the data repositories to remove user-specific filtering. It simplifies the data access logic by retrieving all data from the Hive boxes and removing the need to filter by `userId` in the repository methods. This change makes the app function in single-user mode and removes the need to manage multiple user contexts within the data layer. Specifically: - Removes `userId` parameter from repository methods. - Updates the setting repository to use fixed keys. - Removes user ID filtering from queries. - Removes user ID parameters from Cubits and Blocs - Updates initial data creation --- .../hive_category_repository.dart | 6 +- .../hive_settings_repository.dart | 28 +- .../hive_sms_handler_repository.dart | 15 +- .../hive_transaction_repository.dart | 34 --- .../interfaces/icategory_repository.dart | 10 - .../interfaces/isettings_repository.dart | 4 +- .../interfaces/isms_handler_repository.dart | 8 +- .../interfaces/itag_repository.dart | 6 - .../interfaces/itransaction_repository.dart | 7 - lib/hive/hive_adapters.dart | 2 - lib/hive/hive_adapters.g.dart | 87 ------- lib/hive/hive_adapters.g.yaml | 26 -- lib/hive/hive_registrar.g.dart | 4 - lib/injection_container.dart | 2 +- lib/logic/category/category_cubit.dart | 11 +- lib/logic/category/category_state.dart | 30 +++ lib/logic/settings/settings_cubit.dart | 19 +- lib/logic/sms/sms_cubit.dart | 11 +- lib/logic/transaction/transaction_bloc.dart | 9 +- lib/logic/user/user_cubit.dart | 25 +- lib/models/app_settings.dart | 22 +- lib/models/app_settings.g.dart | 5 +- lib/models/category.dart | 9 - lib/models/category.g.dart | 5 +- lib/models/sms_handler_settings.dart | 16 +- lib/models/sms_handler_settings.g.dart | 5 +- lib/models/sms_message.dart | 6 - lib/models/sms_message.g.dart | 7 +- lib/models/tag.dart | 10 - lib/models/tag.g.dart | 5 +- lib/models/transaction_record.dart | 8 - lib/models/transaction_record.g.dart | 5 +- lib/pages/category/category_list_page.dart | 14 - .../home/widgets/add_transaction_dialog.dart | 29 +-- lib/pages/settings_page.dart | 246 +++++++++--------- lib/pages/tag/tag_list_page.dart | 6 - lib/services/sms_service.dart | 6 +- lib/utils/category_utils.dart | 14 +- lib/utils/tag_utils.dart | 9 +- lib/utils/transaction_utils.dart | 12 +- 40 files changed, 252 insertions(+), 531 deletions(-) create mode 100644 lib/logic/category/category_state.dart diff --git a/lib/data/repositories/hive_category_repository.dart b/lib/data/repositories/hive_category_repository.dart index 37adda6..38a0e09 100644 --- a/lib/data/repositories/hive_category_repository.dart +++ b/lib/data/repositories/hive_category_repository.dart @@ -76,7 +76,7 @@ class HiveCategoryRepository implements ICategoryRepository { @override Future> getAllByUser(String userId) async { try { - return _box.values.where((c) => c.userId == userId).toList(); + return _box.values.toList(); } catch (e) { throw Exception('Ошибка получения категорий пользователя: $e'); } @@ -86,7 +86,7 @@ class HiveCategoryRepository implements ICategoryRepository { Future> getIncomeCategoriesByUser(String userId) async { try { return _box.values - .where((c) => c.userId == userId && c.isIncome) + .where((c) => c.isIncome) .toList(); } catch (e) { throw Exception('Ошибка получения категорий доходов пользователя: $e'); @@ -97,7 +97,7 @@ class HiveCategoryRepository implements ICategoryRepository { Future> getExpenseCategoriesByUser(String userId) async { try { return _box.values - .where((c) => c.userId == userId && !c.isIncome) + .where((c) => !c.isIncome) .toList(); } catch (e) { throw Exception('Ошибка получения категорий расходов пользователя: $e'); diff --git a/lib/data/repositories/hive_settings_repository.dart b/lib/data/repositories/hive_settings_repository.dart index 8a34d65..797311f 100644 --- a/lib/data/repositories/hive_settings_repository.dart +++ b/lib/data/repositories/hive_settings_repository.dart @@ -5,15 +5,26 @@ import 'package:hive_ce/hive.dart'; /// Реализация репозитория настроек с использованием Hive class HiveSettingsRepository implements ISettingsRepository { final Box _box; + + // Фиксированный ключ для хранения настроек приложения + static const String _settingsKey = 'user_settings'; HiveSettingsRepository(this._box); @override - Future getSettings(String userId) async { + Future getSettings() async { try { - // Возвращаем настройки пользователя или создаем новые по умолчанию - final settings = _box.get(userId, defaultValue: AppSettings(userId: userId)); - return settings!; // Гарантируем возврат не-null значения + // Получаем настройки по ключу вместо индекса + final settings = _box.get(_settingsKey); + + // Если настройки не существуют, создаем новые с значениями по умолчанию + if (settings == null) { + final defaultSettings = AppSettings(); + await _box.put(_settingsKey, defaultSettings); + return defaultSettings; + } + + return settings; } catch (e) { throw Exception('Ошибка получения настроек: $e'); } @@ -22,17 +33,18 @@ class HiveSettingsRepository implements ISettingsRepository { @override Future saveSettings(AppSettings settings) async { try { - // Сохраняем настройки с ключом = userId - await _box.put(settings.userId, settings); + // Сохраняем настройки с фиксированным ключом + await _box.put(_settingsKey, settings); } catch (e) { throw Exception('Ошибка сохранения настроек: $e'); } } @override - Future deleteSettings(String userId) async { + Future deleteSettings() async { try { - await _box.delete(userId); + // Удаляем настройки по фиксированному ключу + await _box.delete(_settingsKey); } catch (e) { throw Exception('Ошибка удаления настроек: $e'); } diff --git a/lib/data/repositories/hive_sms_handler_repository.dart b/lib/data/repositories/hive_sms_handler_repository.dart index 47e64e4..e6e7219 100644 --- a/lib/data/repositories/hive_sms_handler_repository.dart +++ b/lib/data/repositories/hive_sms_handler_repository.dart @@ -10,26 +10,25 @@ class HiveSmsHandlerRepository implements ISmsHandlerRepository { HiveSmsHandlerRepository(this._smsHandlerBox); @override - Future getSmsHandlerSettings(String userId) async { + Future getSmsHandlerSettings() async { // В Hive мы будем использовать ID пользователя как ключ для его настроек. - return _smsHandlerBox.get(userId); + return _smsHandlerBox.getAt(1); } @override Future saveSmsHandlerSettings(SmsHandlerSettings settings) async { // Сохраняем объект настроек по ключу, равному ID пользователя. - await _smsHandlerBox.put(settings.userId, settings); + await _smsHandlerBox.put(settings.id, settings); } @override - Future saveRuleForSender(String userId, String sender, SmsProcessingRule rule) async { + Future saveRuleForSender(String sender, SmsProcessingRule rule) async { // 1. Получаем текущие настройки. - SmsHandlerSettings? settings = await getSmsHandlerSettings(userId); + SmsHandlerSettings? settings = await getSmsHandlerSettings(); if (settings == null) { // 2. Если настроек нет, создаем новый объект. settings = SmsHandlerSettings( - userId: userId, rulesBySender: {sender: rule}, // Создаем карту с первым правилом ); } else { @@ -42,9 +41,9 @@ class HiveSmsHandlerRepository implements ISmsHandlerRepository { } @override - Future deleteRuleForSender(String userId, String sender) async { + Future deleteRuleForSender(String sender) async { // 1. Получаем текущие настройки. - final settings = await getSmsHandlerSettings(userId); + final settings = await getSmsHandlerSettings(); if (settings != null) { // 2. Если настройки существуют, удаляем правило для отправителя. diff --git a/lib/data/repositories/hive_transaction_repository.dart b/lib/data/repositories/hive_transaction_repository.dart index 36b0457..bbb4962 100644 --- a/lib/data/repositories/hive_transaction_repository.dart +++ b/lib/data/repositories/hive_transaction_repository.dart @@ -60,40 +60,6 @@ class HiveTransactionRepository implements ITransactionRepository { .toList(); } - // Добавляем новый метод для получения всех транзакций конкретного пользователя - @override - Future> getAllByUser(String userId) async { - // Фильтруем все транзакции в Box по userId - return _box.values.where((t) => t.userId == userId).toList(); - } - - // Добавляем новый метод для получения транзакций пользователя по диапазону дат - @override - Future> getByUserAndDateRange(String userId, DateTime from, DateTime to) async { - // Фильтруем транзакции сначала по userId, затем по диапазону дат - return _box.values - .where((t) => t.userId == userId && t.dateTime.isAfter(from) && t.dateTime.isBefore(to)) - .toList(); - } - - // Добавляем новый метод для получения транзакций пользователя по категории - @override - Future> getByUserAndCategory(String userId, String categoryId) async { - // Фильтруем транзакции сначала по userId, затем по категории - return _box.values - .where((t) => t.userId == userId && t.category.id == categoryId) - .toList(); - } - - // Добавляем новый метод для получения транзакций пользователя по тегу - @override - Future> getByUserAndTag(String userId, String tagId) async { - // Фильтруем транзакции сначала по userId, затем по тегу - return _box.values - .where((t) => t.userId == userId && t.tag?.id == tagId) - .toList(); - } - @override Future addAll(List transactions) async { final Map transactionMap = { diff --git a/lib/data/repositories/interfaces/icategory_repository.dart b/lib/data/repositories/interfaces/icategory_repository.dart index a2eb91c..c43e4ee 100644 --- a/lib/data/repositories/interfaces/icategory_repository.dart +++ b/lib/data/repositories/interfaces/icategory_repository.dart @@ -10,16 +10,6 @@ abstract class ICategoryRepository { Future> getIncomeCategories(); Future> getExpenseCategories(); - // Новые методы для работы с пользователями - /// Получить все категории конкретного пользователя - Future> getAllByUser(String userId); - - /// Получить категории доходов конкретного пользователя - Future> getIncomeCategoriesByUser(String userId); - - /// Получить категории расходов конкретного пользователя - Future> getExpenseCategoriesByUser(String userId); - /// Добавить список категорий Future addAll(List categories); } diff --git a/lib/data/repositories/interfaces/isettings_repository.dart b/lib/data/repositories/interfaces/isettings_repository.dart index a9cf609..76c4b65 100644 --- a/lib/data/repositories/interfaces/isettings_repository.dart +++ b/lib/data/repositories/interfaces/isettings_repository.dart @@ -4,7 +4,7 @@ import 'package:budget_app/models/app_settings.dart'; abstract class ISettingsRepository { /// Получает настройки для указанного пользователя /// [userId] - идентификатор пользователя - Future getSettings(String userId); + Future getSettings(); /// Сохраняет настройки /// [settings] - объект настроек для сохранения @@ -12,5 +12,5 @@ abstract class ISettingsRepository { /// Удаляет настройки для указанного пользователя /// [userId] - идентификатор пользователя - Future deleteSettings(String userId); + Future deleteSettings(); } diff --git a/lib/data/repositories/interfaces/isms_handler_repository.dart b/lib/data/repositories/interfaces/isms_handler_repository.dart index 13e28d8..c2605f8 100644 --- a/lib/data/repositories/interfaces/isms_handler_repository.dart +++ b/lib/data/repositories/interfaces/isms_handler_repository.dart @@ -6,9 +6,7 @@ import '../../../models/sms_handler_settings.dart'; abstract class ISmsHandlerRepository { /// Получает настройки обработки СМС для указанного пользователя. /// - /// [userId] - Уникальный идентификатор пользователя. - /// Возвращает [SmsHandlerSettings] или null, если настроек нет. - Future getSmsHandlerSettings(String userId); + Future getSmsHandlerSettings(); /// Сохраняет или обновляет настройки обработки СМС для пользователя. /// @@ -20,11 +18,11 @@ abstract class ISmsHandlerRepository { /// [userId] - ID пользователя. /// [sender] - Идентификатор отправителя (например, 'SBERBANK'). /// [rule] - Правило обработки. - Future saveRuleForSender(String userId, String sender, SmsProcessingRule rule); + Future saveRuleForSender(String sender, SmsProcessingRule rule); /// Удаляет правило для конкретного отправителя. /// /// [userId] - ID пользователя. /// [sender] - Идентификатор отправителя, чье правило нужно удалить. - Future deleteRuleForSender(String userId, String sender); + Future deleteRuleForSender(String sender); } diff --git a/lib/data/repositories/interfaces/itag_repository.dart b/lib/data/repositories/interfaces/itag_repository.dart index 266e9a3..99f0ec9 100644 --- a/lib/data/repositories/interfaces/itag_repository.dart +++ b/lib/data/repositories/interfaces/itag_repository.dart @@ -7,12 +7,6 @@ abstract class ITagRepository { Future update(Tag tag); Future delete(String id); - // Комментарий: Добавляем новый абстрактный метод в интерфейс. - // Все классы, которые реализуют этот интерфейс, должны будут предоставить - // реализацию этого метода. Это гарантирует, что наш репозиторий - // сможет получать теги для конкретного пользователя. - Future> getAllByUser(String userId); - /// Добавить список тегов Future addAll(List tags); } diff --git a/lib/data/repositories/interfaces/itransaction_repository.dart b/lib/data/repositories/interfaces/itransaction_repository.dart index 876652d..4437e20 100644 --- a/lib/data/repositories/interfaces/itransaction_repository.dart +++ b/lib/data/repositories/interfaces/itransaction_repository.dart @@ -11,13 +11,6 @@ abstract class ITransactionRepository { Future> getByCategory(String categoryId); Future> getByTag(String tagId); - // Добавляем новые методы для работы с транзакциями конкретного пользователя - // Это важно для многопользовательской архитектуры - Future> getAllByUser(String userId); - Future> getByUserAndDateRange(String userId, DateTime from, DateTime to); - Future> getByUserAndCategory(String userId, String categoryId); - Future> getByUserAndTag(String userId, String tagId); - /// Добавить список транзакций Future addAll(List transactions); } diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index 03911cc..8beb5f6 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -4,8 +4,6 @@ import 'package:hive_ce/hive.dart'; @GenerateAdapters([ - AdapterSpec(), AdapterSpec(), - AdapterSpec(), ]) part 'hive_adapters.g.dart'; diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index 9095817..0a774a3 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -6,38 +6,6 @@ part of 'hive_adapters.dart'; // AdaptersGenerator // ************************************************************************** -class ColorAdapter extends TypeAdapter { - @override - final typeId = 0; - - @override - Color read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return Color((fields[0] as num).toInt()); - } - - @override - void write(BinaryWriter writer, Color obj) { - writer - ..writeByte(1) - ..writeByte(0) - ..write(obj.value); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is ColorAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - class IconDataAdapter extends TypeAdapter { @override final typeId = 1; @@ -83,58 +51,3 @@ class IconDataAdapter extends TypeAdapter { runtimeType == other.runtimeType && typeId == other.typeId; } - -class DateTimeAdapter extends TypeAdapter { - @override - final typeId = 2; - - @override - DateTime read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return DateTime( - (fields[0] as num).toInt(), - fields[1] == null ? 1 : (fields[1] as num).toInt(), - fields[2] == null ? 1 : (fields[2] as num).toInt(), - fields[3] == null ? 0 : (fields[3] as num).toInt(), - fields[4] == null ? 0 : (fields[4] as num).toInt(), - fields[5] == null ? 0 : (fields[5] as num).toInt(), - fields[6] == null ? 0 : (fields[6] as num).toInt(), - fields[7] == null ? 0 : (fields[7] as num).toInt(), - ); - } - - @override - void write(BinaryWriter writer, DateTime obj) { - writer - ..writeByte(8) - ..writeByte(0) - ..write(obj.year) - ..writeByte(1) - ..write(obj.month) - ..writeByte(2) - ..write(obj.day) - ..writeByte(3) - ..write(obj.hour) - ..writeByte(4) - ..write(obj.minute) - ..writeByte(5) - ..write(obj.second) - ..writeByte(6) - ..write(obj.millisecond) - ..writeByte(7) - ..write(obj.microsecond); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is DateTimeAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 4b38796..8740adf 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -3,12 +3,6 @@ # Check in to version control nextTypeId: 3 types: - Color: - typeId: 0 - nextIndex: 1 - fields: - value: - index: 0 IconData: typeId: 1 nextIndex: 5 @@ -23,23 +17,3 @@ types: index: 3 fontFamilyFallback: index: 4 - DateTime: - typeId: 2 - nextIndex: 8 - fields: - year: - index: 0 - month: - index: 1 - day: - index: 2 - hour: - index: 3 - minute: - index: 4 - second: - index: 5 - millisecond: - index: 6 - microsecond: - index: 7 diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index 7e3d1e9..710b1ef 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -17,8 +17,6 @@ extension HiveRegistrar on HiveInterface { void registerAdapters() { registerAdapter(AppSettingsAdapter()); registerAdapter(CategoryAdapter()); - registerAdapter(ColorAdapter()); - registerAdapter(DateTimeAdapter()); registerAdapter(GlobalSettingsAdapter()); registerAdapter(IconDataAdapter()); registerAdapter(SmsHandlerSettingsAdapter()); @@ -35,8 +33,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { void registerAdapters() { registerAdapter(AppSettingsAdapter()); registerAdapter(CategoryAdapter()); - registerAdapter(ColorAdapter()); - registerAdapter(DateTimeAdapter()); registerAdapter(GlobalSettingsAdapter()); registerAdapter(IconDataAdapter()); registerAdapter(SmsHandlerSettingsAdapter()); diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 04e7c6e..021c48e 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -155,7 +155,7 @@ Future initUserSpecificDependencies(String userId) async { await getIt.unregister(); } getIt.registerFactory( - () => CategoryCubit(getIt(), userId), // Добавляем userId + () => CategoryCubit(getIt()), // Добавляем userId ); // Теги diff --git a/lib/logic/category/category_cubit.dart b/lib/logic/category/category_cubit.dart index ab7f2b4..c2fab28 100644 --- a/lib/logic/category/category_cubit.dart +++ b/lib/logic/category/category_cubit.dart @@ -1,8 +1,10 @@ +import 'package:equatable/equatable.dart'; // equatable для сравнения объектов import 'package:flutter_bloc/flutter_bloc.dart'; import '../../data/repositories/interfaces/icategory_repository.dart'; import '../../models/category.dart'; -import 'category_state.dart'; + +part 'category_state.dart'; // Используем part для разделения файла /// Cubit для управления категориями /// Изменения: @@ -10,15 +12,14 @@ import 'category_state.dart'; /// - Упрощена работа с состояниями по аналогии с TagCubit class CategoryCubit extends Cubit { final ICategoryRepository repository; - final String userId; // Храним userId в cubit - CategoryCubit(this.repository, this.userId) : super(CategoryInitial()); + CategoryCubit(this.repository) : super(CategoryInitial()); /// Загружает категории для текущего пользователя Future loadCategories() async { emit(CategoryLoading()); try { - final categories = await repository.getAllByUser(userId); + final categories = await repository.getAll(); emit(CategoryLoaded(categories)); } catch (e) { emit(CategoryError('Ошибка загрузки категорий: $e')); @@ -29,7 +30,7 @@ class CategoryCubit extends Cubit { Future addCategory(Category category) async { try { // Устанавливаем userId для категории из cubit - final newCategory = category.copyWith(userId: userId); + final newCategory = category.copyWith(); await repository.add(newCategory); // Перезагружаем список категорий await loadCategories(); diff --git a/lib/logic/category/category_state.dart b/lib/logic/category/category_state.dart new file mode 100644 index 0000000..24191ee --- /dev/null +++ b/lib/logic/category/category_state.dart @@ -0,0 +1,30 @@ +part of 'category_cubit.dart'; + +abstract class CategoryState extends Equatable { + const CategoryState(); + + @override + List get props => []; +} + +class CategoryInitial extends CategoryState {} + +class CategoryLoading extends CategoryState {} + +class CategoryLoaded extends CategoryState { + final List categories; + + const CategoryLoaded(this.categories); + + @override + List get props => [categories]; +} + +class CategoryError extends CategoryState { + final String message; + + const CategoryError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/logic/settings/settings_cubit.dart b/lib/logic/settings/settings_cubit.dart index 797f588..f4891b3 100644 --- a/lib/logic/settings/settings_cubit.dart +++ b/lib/logic/settings/settings_cubit.dart @@ -12,10 +12,10 @@ class SettingsCubit extends Cubit { SettingsCubit(this._settingsRepository) : super(SettingsInitial()); - Future loadSettings(String userId) async { + Future loadSettings() async { emit(SettingsLoading()); try { - final settings = await _settingsRepository.getSettings(userId); + final settings = await _settingsRepository.getSettings(); emit(SettingsLoaded( isDarkMode: settings.isDarkMode, languageCode: settings.languageCode, @@ -26,48 +26,47 @@ class SettingsCubit extends Cubit { } } - Future toggleDarkMode(bool value, String userId) async { + Future toggleDarkMode(bool value) async { if (state is SettingsLoaded) { try { final currentState = state as SettingsLoaded; final newState = currentState.copyWith(isDarkMode: value); emit(newState); - await _saveSettings(newState, userId); + await _saveSettings(newState); } catch (e) { emit(SettingsError(e.toString())); } } } - Future changeLanguage(String languageCode, String userId) async { + Future changeLanguage(String languageCode) async { if (state is SettingsLoaded) { try { final currentState = state as SettingsLoaded; final newState = currentState.copyWith(languageCode: languageCode); emit(newState); - await _saveSettings(newState, userId); + await _saveSettings(newState); } catch (e) { emit(SettingsError(e.toString())); } } } - Future changeCurrency(String currency, String userId) async { + Future changeCurrency(String currency) async { if (state is SettingsLoaded) { try { final currentState = state as SettingsLoaded; final newState = currentState.copyWith(defaultCurrency: currency); emit(newState); - await _saveSettings(newState, userId); + await _saveSettings(newState); } catch (e) { emit(SettingsError(e.toString())); } } } - Future _saveSettings(SettingsLoaded settings, String userId) async { + Future _saveSettings(SettingsLoaded settings) async { final appSettings = AppSettings( - userId: userId, isDarkMode: settings.isDarkMode, languageCode: settings.languageCode, defaultCurrency: settings.defaultCurrency, diff --git a/lib/logic/sms/sms_cubit.dart b/lib/logic/sms/sms_cubit.dart index 5cd9844..264d63f 100644 --- a/lib/logic/sms/sms_cubit.dart +++ b/lib/logic/sms/sms_cubit.dart @@ -31,13 +31,8 @@ class SmsCubit extends Cubit { try { final hasPermissions = await _smsService.requestPermissions(); if (hasPermissions) { - final userState = _userCubit.state; - if (userState is UserLoaded) { - final messages = await _smsService.getLastSmsMessages(10, userState.user!.id); - emit(SmsLoaded(messages)); - } else { - emit(SmsError("User not loaded")); - } + final messages = await _smsService.getLastSmsMessages(10); + emit(SmsLoaded(messages)); } else { emit(SmsPermissionDenied()); } @@ -60,7 +55,7 @@ class SmsCubit extends Cubit { final user = userState.user; // Комментарий: Получаем все SMS-сообщения с момента последней синхронизации. - final messages = await _smsService.getSmsMessagesSince(user!.lastSmsSyncTime, user.id); + final messages = await _smsService.getSmsMessagesSince(user!.lastSmsSyncTime); // Комментарий: Сохраняем новые сообщения через репозиторий и создаем транзакции. await _smsRepository.addAll(messages); diff --git a/lib/logic/transaction/transaction_bloc.dart b/lib/logic/transaction/transaction_bloc.dart index 46701e9..e499bd8 100644 --- a/lib/logic/transaction/transaction_bloc.dart +++ b/lib/logic/transaction/transaction_bloc.dart @@ -21,7 +21,7 @@ class TransactionBloc extends Bloc { void _onLoadTransactions(LoadTransactions event, Emitter emit) async { emit(TransactionLoading()); try { - final transactions = await _transactionRepository.getAllByUser(event.userId); + final transactions = await _transactionRepository.getAll(); emit(TransactionLoaded(transactions: transactions)); } catch (e) { emit(TransactionError(message: e.toString())); @@ -31,7 +31,7 @@ class TransactionBloc extends Bloc { void _onAddTransaction(AddTransaction event, Emitter emit) async { try { await _transactionRepository.add(event.transaction); - final transactions = await _transactionRepository.getAllByUser(event.transaction.userId); + final transactions = await _transactionRepository.getAll(); emit(TransactionLoaded(transactions: transactions)); } catch (e) { emit(TransactionError(message: e.toString())); @@ -41,7 +41,7 @@ class TransactionBloc extends Bloc { void _onUpdateTransaction(UpdateTransaction event, Emitter emit) async { try { await _transactionRepository.update(event.transaction); - final transactions = await _transactionRepository.getAllByUser(event.transaction.userId); + final transactions = await _transactionRepository.getAll(); emit(TransactionLoaded(transactions: transactions)); } catch (e) { emit(TransactionError(message: e.toString())); @@ -54,9 +54,8 @@ class TransactionBloc extends Bloc { if (state is TransactionLoaded) { final loadedState = state as TransactionLoaded; if (loadedState.transactions.isNotEmpty) { - final userId = loadedState.transactions.first.userId; await _transactionRepository.delete(event.transactionId); - final transactions = await _transactionRepository.getAllByUser(userId); + final transactions = await _transactionRepository.getAll(); emit(TransactionLoaded(transactions: transactions)); } } diff --git a/lib/logic/user/user_cubit.dart b/lib/logic/user/user_cubit.dart index 06f2cd0..9f08511 100644 --- a/lib/logic/user/user_cubit.dart +++ b/lib/logic/user/user_cubit.dart @@ -87,22 +87,7 @@ class UserCubit extends Cubit { emit(UserError('Ошибка загрузки пользователя: ${e.toString()}')); } } - - Future _createDefaultUser() async { - final user = User( - name: 'Пользователь по умолчанию', - email: 'default@example.com', - ); - await _userRepository.add(user); - - // Создаем начальные данные для нового пользователя - await _createInitialData(user.id); - - await _setCurrentUser(user); - return user; - } - - Future _createInitialData(String userId) async { + Future createInitialData(String userId) async { // Проверяем, что репозитории были установлены, прежде чем их использовать. if (_categoryRepository == null || _tagRepository == null || @@ -115,15 +100,15 @@ class UserCubit extends Cubit { emit(UserLoading(progress: 0.7, message: 'Создание категорий...')); // Используем '!', так как мы уже проверили на null. await _categoryRepository!.addAll( - CategoryUtils.getDefaultCategories(userId), + CategoryUtils.getDefaultCategories(), ); emit(UserLoading(progress: 0.8, message: 'Создание тегов...')); - await _tagRepository!.addAll(TagUtils.getDefaultTags(userId)); + await _tagRepository!.addAll(TagUtils.getDefaultTags()); emit(UserLoading(progress: 0.9, message: 'Создание транзакций...')); await _transactionRepository!.addAll( - TransactionUtils.getSampleTransactions(userId), + TransactionUtils.getSampleTransactions(), ); } catch (e, stack) { @@ -176,7 +161,7 @@ class UserCubit extends Cubit { await _userRepository.add(user); // Создаем начальные данные для нового пользователя - await _createInitialData(user.id); + await createInitialData(user.id); await _setCurrentUser(user); } catch (e, stack) { diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 9f5a69b..d01c438 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -1,13 +1,13 @@ import 'package:equatable/equatable.dart'; import 'package:hive_ce/hive.dart'; +import '../utils/id_generator.dart'; part 'app_settings.g.dart'; @HiveType(typeId: 1005) class AppSettings extends Equatable { - /// Ссылка на ID пользователя, которому принадлежат настройки @HiveField(0) - final String userId; + final String id; /// Код языка интерфейса (например, 'ru', 'en') @HiveField(1) @@ -26,17 +26,17 @@ class AppSettings extends Equatable { final DateTime updatedAt; AppSettings({ - required this.userId, + String? id, this.languageCode = 'ru', this.isDarkMode = false, this.defaultCurrency = 'RUB', DateTime? updatedAt, - }) : updatedAt = updatedAt ?? DateTime.now(); + }) : id = id ?? IdGenerator.generateId(), + updatedAt = updatedAt ?? DateTime.now(); /// Преобразование в Map для сохранения Map toMap() { return { - 'userId': userId, 'languageCode': languageCode, 'isDarkMode': isDarkMode, 'defaultCurrency': defaultCurrency, @@ -47,7 +47,6 @@ class AppSettings extends Equatable { /// Создание из Map factory AppSettings.fromMap(Map map) { return AppSettings( - userId: map['userId'], languageCode: map['languageCode'] ?? 'ru', isDarkMode: map['isDarkMode'] ?? false, defaultCurrency: map['defaultCurrency'] ?? 'RUB', @@ -63,7 +62,6 @@ class AppSettings extends Equatable { String? defaultCurrency, }) { return AppSettings( - userId: userId ?? this.userId, languageCode: languageCode ?? this.languageCode, isDarkMode: isDarkMode ?? this.isDarkMode, defaultCurrency: defaultCurrency ?? this.defaultCurrency, @@ -72,17 +70,11 @@ class AppSettings extends Equatable { } @override - List get props => [ - userId, - languageCode, - isDarkMode, - defaultCurrency, - updatedAt, - ]; + List get props => [id]; @override String toString() { - return 'AppSettings(userId: $userId, languageCode: $languageCode, ' + return 'AppSettings(languageCode: $languageCode, ' 'isDarkMode: $isDarkMode, defaultCurrency: $defaultCurrency)'; } } diff --git a/lib/models/app_settings.g.dart b/lib/models/app_settings.g.dart index 7069a2c..3387a9b 100644 --- a/lib/models/app_settings.g.dart +++ b/lib/models/app_settings.g.dart @@ -17,7 +17,6 @@ class AppSettingsAdapter extends TypeAdapter { for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), }; return AppSettings( - userId: fields[0] as String, languageCode: fields[1] == null ? 'ru' : fields[1] as String, isDarkMode: fields[2] == null ? false : fields[2] as bool, defaultCurrency: fields[3] == null ? 'RUB' : fields[3] as String, @@ -28,9 +27,7 @@ class AppSettingsAdapter extends TypeAdapter { @override void write(BinaryWriter writer, AppSettings obj) { writer - ..writeByte(5) - ..writeByte(0) - ..write(obj.userId) + ..writeByte(4) ..writeByte(1) ..write(obj.languageCode) ..writeByte(2) diff --git a/lib/models/category.dart b/lib/models/category.dart index 99c2817..c4e88bb 100644 --- a/lib/models/category.dart +++ b/lib/models/category.dart @@ -32,11 +32,6 @@ class Category extends Equatable { /// false - расход (например покупки) final bool isIncome; - @HiveField(5) - /// Идентификатор пользователя, которому принадлежит эта категория - /// Это позволяет разделять категории между разными пользователями - final String userId; - @HiveField(6) /// Дата и время последнего обновления объекта final DateTime updatedAt; @@ -48,7 +43,6 @@ class Category extends Equatable { required this.color, required this.icon, required this.isIncome, - required this.userId, // Теперь userId обязательный параметр DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное }) : id = id ?? IdGenerator.generateId(), updatedAt = updatedAt ?? DateTime.now(); // Устанавливаем текущее время по умолчанию @@ -61,7 +55,6 @@ class Category extends Equatable { 'color': color.toARGB32(), // Сохраняем только значение цвета 'icon': icon.codePoint, 'isIncome': isIncome, - 'userId': userId, // Добавляем userId в Map 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map }; } @@ -74,7 +67,6 @@ class Category extends Equatable { color: Color(map['color']), icon: IconData(map['icon'], fontFamily: 'MaterialIcons'), isIncome: map['isIncome'], - userId: map['userId'], // Добавляем userId при создании из Map updatedAt: DateTime.parse(map['updatedAt']), // Добавлено updatedAt при создании из Map ); } @@ -94,7 +86,6 @@ class Category extends Equatable { color: color ?? this.color, icon: icon ?? this.icon, isIncome: isIncome ?? this.isIncome, - userId: userId ?? this.userId, updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании ); } diff --git a/lib/models/category.g.dart b/lib/models/category.g.dart index 64cd3f3..790cc54 100644 --- a/lib/models/category.g.dart +++ b/lib/models/category.g.dart @@ -22,7 +22,6 @@ class CategoryAdapter extends TypeAdapter { color: fields[2] as Color, icon: fields[3] as IconData, isIncome: fields[4] as bool, - userId: fields[5] as String, updatedAt: fields[6] as DateTime?, ); } @@ -30,7 +29,7 @@ class CategoryAdapter extends TypeAdapter { @override void write(BinaryWriter writer, Category obj) { writer - ..writeByte(7) + ..writeByte(6) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -41,8 +40,6 @@ class CategoryAdapter extends TypeAdapter { ..write(obj.icon) ..writeByte(4) ..write(obj.isIncome) - ..writeByte(5) - ..write(obj.userId) ..writeByte(6) ..write(obj.updatedAt); } diff --git a/lib/models/sms_handler_settings.dart b/lib/models/sms_handler_settings.dart index 86efbba..3b4c934 100644 --- a/lib/models/sms_handler_settings.dart +++ b/lib/models/sms_handler_settings.dart @@ -1,6 +1,7 @@ import 'package:hive_ce/hive.dart'; +import '/utils/id_generator.dart'; part 'sms_handler_settings.g.dart'; /// Перечисление для определения типа обработки СМС. @@ -31,7 +32,11 @@ class SmsProcessingRule extends HiveObject { @HiveField(2) final String? customFunctionId; + @HiveField(3) + final String id; + SmsProcessingRule({ + String? id, required this.type, this.pattern, this.customFunctionId, @@ -39,23 +44,22 @@ class SmsProcessingRule extends HiveObject { (type == SmsProcessingType.regexp && pattern != null) || (type == SmsProcessingType.customFunction && customFunctionId != null), 'Pattern must be provided for regexp type, and customFunctionId for customFunction type.', - ); + ), id = id ?? IdGenerator.generateId(); } /// Модель для хранения всех настроек обработки СМС для одного пользователя. @HiveType(typeId: 12) class SmsHandlerSettings extends HiveObject { - /// Уникальный идентификатор пользователя, к которому относятся эти настройки. - @HiveField(0) - final String userId; + @HiveField(0) + final String id; /// Карта правил обработки, где ключ - это идентификатор отправителя (например, 'SBERBANK' или номер телефона), /// а значение - правило обработки для этого отправителя. @HiveField(1) final Map rulesBySender; SmsHandlerSettings({ - required this.userId, + String? id, required this.rulesBySender, - }); + }) : id = id ?? IdGenerator.generateId(); } diff --git a/lib/models/sms_handler_settings.g.dart b/lib/models/sms_handler_settings.g.dart index 78522b0..085c470 100644 --- a/lib/models/sms_handler_settings.g.dart +++ b/lib/models/sms_handler_settings.g.dart @@ -57,7 +57,6 @@ class SmsHandlerSettingsAdapter extends TypeAdapter { for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), }; return SmsHandlerSettings( - userId: fields[0] as String, rulesBySender: (fields[1] as Map).cast(), ); } @@ -65,9 +64,7 @@ class SmsHandlerSettingsAdapter extends TypeAdapter { @override void write(BinaryWriter writer, SmsHandlerSettings obj) { writer - ..writeByte(2) - ..writeByte(0) - ..write(obj.userId) + ..writeByte(1) ..writeByte(1) ..write(obj.rulesBySender); } diff --git a/lib/models/sms_message.dart b/lib/models/sms_message.dart index 3eaf5e6..75b923f 100644 --- a/lib/models/sms_message.dart +++ b/lib/models/sms_message.dart @@ -21,17 +21,12 @@ class SmsMessage extends HiveObject { @HiveField(4) String? transactionId; - // Комментарий: Добавлено поле для связи с пользователем. - @HiveField(5) - final String userId; - SmsMessage({ String? id, this.body, this.sender, this.date, this.transactionId, - required this.userId, }) : id = id ?? IdGenerator.generateId(); // Комментарий: Добавляем метод для обновления transactionId @@ -42,7 +37,6 @@ class SmsMessage extends HiveObject { sender: sender, date: date, transactionId: transactionId ?? this.transactionId, - userId: userId ?? this.userId, ); } } diff --git a/lib/models/sms_message.g.dart b/lib/models/sms_message.g.dart index 9f166ce..4a4d537 100644 --- a/lib/models/sms_message.g.dart +++ b/lib/models/sms_message.g.dart @@ -22,14 +22,13 @@ class SmsMessageAdapter extends TypeAdapter { sender: fields[2] as String?, date: fields[3] as DateTime?, transactionId: fields[4] as String?, - userId: fields[5] as String, ); } @override void write(BinaryWriter writer, SmsMessage obj) { writer - ..writeByte(6) + ..writeByte(5) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -39,9 +38,7 @@ class SmsMessageAdapter extends TypeAdapter { ..writeByte(3) ..write(obj.date) ..writeByte(4) - ..write(obj.transactionId) - ..writeByte(5) - ..write(obj.userId); + ..write(obj.transactionId); } @override diff --git a/lib/models/tag.dart b/lib/models/tag.dart index 07eb45b..c584f25 100644 --- a/lib/models/tag.dart +++ b/lib/models/tag.dart @@ -14,12 +14,6 @@ class Tag extends Equatable { /// Название тега (например: "Важное", "Работа") final String name; - // Комментарий: Добавляем новое поле для хранения идентификатора пользователя. - // Это позволит нам связать каждый тег с конкретным пользователем. - // Мы используем аннотацию @HiveField(2), чтобы указать Hive, как сохранять это поле. - @HiveField(2) - final String userId; - @HiveField(3) /// Дата и время последнего обновления объекта final DateTime updatedAt; @@ -30,7 +24,6 @@ class Tag extends Equatable { required this.name, // Комментарий: Добавляем userId в конструктор как обязательный параметр. // Теперь при создании тега необходимо будет указать, какому пользователю он принадлежит. - required this.userId, DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное }) : id = id ?? IdGenerator.generateId(), updatedAt = updatedAt ?? DateTime.now(); // Устанавливаем текущее время по умолчанию @@ -42,7 +35,6 @@ class Tag extends Equatable { 'name': name, // Комментарий: Добавляем userId в Map. Это нужно для сохранения // данных в форматах, которые не работают напрямую с объектами Dart (например, при отправке на сервер). - 'userId': userId, 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map }; } @@ -54,7 +46,6 @@ class Tag extends Equatable { name: map['name'], // Комментарий: Извлекаем userId из Map при создании объекта. // Это позволит восстановить полный объект Tag из данных, например, из базы данных. - userId: map['userId'], updatedAt: DateTime.parse(map['updatedAt']), // Добавлено updatedAt при создании из Map ); } @@ -68,7 +59,6 @@ class Tag extends Equatable { return Tag( id: id ?? this.id, name: name ?? this.name, - userId: userId ?? this.userId, updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании ); } diff --git a/lib/models/tag.g.dart b/lib/models/tag.g.dart index 30b33c6..98f587a 100644 --- a/lib/models/tag.g.dart +++ b/lib/models/tag.g.dart @@ -19,7 +19,6 @@ class TagAdapter extends TypeAdapter { return Tag( id: fields[0] as String?, name: fields[1] as String, - userId: fields[2] as String, updatedAt: fields[3] as DateTime?, ); } @@ -27,13 +26,11 @@ class TagAdapter extends TypeAdapter { @override void write(BinaryWriter writer, Tag obj) { writer - ..writeByte(4) + ..writeByte(3) ..writeByte(0) ..write(obj.id) ..writeByte(1) ..write(obj.name) - ..writeByte(2) - ..write(obj.userId) ..writeByte(3) ..write(obj.updatedAt); } diff --git a/lib/models/transaction_record.dart b/lib/models/transaction_record.dart index 53f670e..d292451 100644 --- a/lib/models/transaction_record.dart +++ b/lib/models/transaction_record.dart @@ -39,10 +39,6 @@ class TransactionRecord extends Equatable { /// Валюта операции (код валюты, например "RUB", "USD") final String currency; - // Добавляем новое поле для хранения идентификатора пользователя - @HiveField(7) // Используем следующий доступный номер поля Hive - final String userId; - @HiveField(8) /// Дата и время последнего обновления объекта final DateTime updatedAt; @@ -56,7 +52,6 @@ class TransactionRecord extends Equatable { required this.dateTime, required this.vendor, required this.currency, - required this.userId, // Добавляем userId в конструктор DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное }) : id = id ?? IdGenerator.generateId(), updatedAt = @@ -73,7 +68,6 @@ class TransactionRecord extends Equatable { 'dateTime': dateTime.toIso8601String(), 'vendor': vendor, 'currency': currency, - 'userId': userId, // Добавляем userId в Map 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map }; } @@ -88,7 +82,6 @@ class TransactionRecord extends Equatable { dateTime: DateTime.parse(map['dateTime']), vendor: map['vendor'], currency: map['currency'], - userId: map['userId'], // Извлекаем userId из Map updatedAt: DateTime.parse( map['updatedAt'], ), // Добавлено updatedAt при создании из Map @@ -118,7 +111,6 @@ class TransactionRecord extends Equatable { dateTime: dateTime ?? this.dateTime, vendor: vendor ?? this.vendor, currency: currency ?? this.currency, - userId: userId ?? this.userId, updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании ); } diff --git a/lib/models/transaction_record.g.dart b/lib/models/transaction_record.g.dart index 2a90916..03956e2 100644 --- a/lib/models/transaction_record.g.dart +++ b/lib/models/transaction_record.g.dart @@ -24,7 +24,6 @@ class TransactionRecordAdapter extends TypeAdapter { dateTime: fields[4] as DateTime, vendor: fields[5] as String, currency: fields[6] as String, - userId: fields[7] as String, updatedAt: fields[8] as DateTime?, ); } @@ -32,7 +31,7 @@ class TransactionRecordAdapter extends TypeAdapter { @override void write(BinaryWriter writer, TransactionRecord obj) { writer - ..writeByte(9) + ..writeByte(8) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -47,8 +46,6 @@ class TransactionRecordAdapter extends TypeAdapter { ..write(obj.vendor) ..writeByte(6) ..write(obj.currency) - ..writeByte(7) - ..write(obj.userId) ..writeByte(8) ..write(obj.updatedAt); } diff --git a/lib/pages/category/category_list_page.dart b/lib/pages/category/category_list_page.dart index f76b323..5eef059 100644 --- a/lib/pages/category/category_list_page.dart +++ b/lib/pages/category/category_list_page.dart @@ -1,6 +1,5 @@ import 'package:budget_app/l10n/app_localizations.dart'; import 'package:budget_app/logic/category/category_cubit.dart'; -import 'package:budget_app/logic/category/category_state.dart'; import 'package:budget_app/models/category.dart'; import 'package:budget_app/pages/category/category_edit_page.dart'; import 'package:budget_app/pages/category/widgets/add_category_button.dart'; @@ -63,16 +62,11 @@ class _CategoryListPageState extends State { MaterialPageRoute( builder: (_) => CategoryEditPage( onSave: (name, color, icon, isIncome) { - final userState = context.read().state; - if (userState is! UserLoaded || userState.user == null) return; - - final currentUserId = userState.user!.id; final newCategory = Category( name: name, color: color, icon: icon, isIncome: isIncome, - userId: currentUserId, ); context.read().addCategory(newCategory); @@ -89,10 +83,6 @@ class _CategoryListPageState extends State { builder: (_) => CategoryEditPage( category: category, onSave: (name, color, icon, isIncome) { - final userState = context.read().state; - if (userState is! UserLoaded || userState.user == null) return; - - final currentUserId = userState.user!.id; final updatedCategory = category.copyWith( name: name, color: color, @@ -108,10 +98,6 @@ class _CategoryListPageState extends State { } void _deleteCategory(BuildContext context, Category category) { - final userState = context.read().state; - if (userState is! UserLoaded || userState.user == null) return; - - final currentUserId = userState.user!.id; context.read().deleteCategory(category.id); } } diff --git a/lib/pages/home/widgets/add_transaction_dialog.dart b/lib/pages/home/widgets/add_transaction_dialog.dart index c8cf9d9..63df9b6 100644 --- a/lib/pages/home/widgets/add_transaction_dialog.dart +++ b/lib/pages/home/widgets/add_transaction_dialog.dart @@ -122,17 +122,14 @@ class _AddTransactionDialogState extends State { : 'RUB'; // Получаем ID текущего пользователя из UserCubit - final userState = context.read().state; - if (userState is! UserLoaded || userState.user == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.transactionErrorText('User not found'), - ), + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.transactionErrorText('User not found'), ), - ); - return; - } + ), + ); final newTransaction = TransactionRecord( amount: amount, @@ -141,7 +138,6 @@ class _AddTransactionDialogState extends State { dateTime: _selectedDateTime, tag: _selectedTag, currency: currency, - userId: userState.user!.id, ); context.read().add( @@ -154,14 +150,9 @@ class _AddTransactionDialogState extends State { @override Widget build(BuildContext context) { final localizations = AppLocalizations.of(context)!; + - // Получаем ID текущего пользователя из UserCubit - final userState = context.read().state; - final userId = userState is UserLoaded && userState.user != null - ? userState.user!.id - : ''; - - final categories = CategoryUtils.getDefaultCategories(userId) + final categories = CategoryUtils.getDefaultCategories() .where((c) => c.isIncome == _isIncome).toList(); return AlertDialog( @@ -233,7 +224,7 @@ class _AddTransactionDialogState extends State { // Комментарий: Добавляем выпадающий список для выбора тега. // Он будет загружать теги асинхронно для текущего пользователя. FutureBuilder>( - future: GetIt.instance().getAllByUser(userId), + future: GetIt.instance().getAll(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 29d76ca..dbb860d 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -37,145 +37,131 @@ class SettingsPage extends StatelessWidget { builder: (context, userState) { if (userState is UserLoaded && userState.user != null) { // Комментарий: Как только пользователь загружен, мы загружаем его настройки. - context.read().loadSettings(userState.user!.id); + context.read().loadSettings(); return BlocBuilder( builder: (context, state) { if (state is SettingsLoaded) { return Padding( padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SwitchListTile( - title: Text(localizations.darkModeSetting), - subtitle: Text(localizations.darkModeDescription), - value: state.isDarkMode, - // Комментарий: При изменении положения переключателя (onChanged) мы вызываем метод `toggleDarkMode` у Cubit. - // `context.read()` используется для доступа к Cubit без подписки на его изменения. - // Это хорошо для вызова методов. Также передаем userId из UserService. - onChanged: (value) { - // Получаем ID текущего пользователя из UserCubit - final userState = context.read().state; - if (userState is UserLoaded && - userState.user != null) { - final userId = userState.user!.id; - context - .read() - .toggleDarkMode(value, userId); - } - }, - ), - const Divider(), - ListTile( - title: Text(localizations.languageSetting), - subtitle: Text(localizations.languageDescription), - trailing: DropdownButton( - value: state.languageCode, - // Комментарий: При выборе нового языка вызываем метод `changeLanguage` у Cubit. - // Также передаем userId из UserService. - onChanged: (String? newValue) { - if (newValue != null) { - // Получаем ID текущего пользователя из UserCubit - final userState = - context.read().state; - if (userState is UserLoaded && - userState.user != null) { - final userId = userState.user!.id; - context - .read() - .changeLanguage(newValue, userId); - } - } + // Комментарий: Обернули Column в SingleChildScrollView, чтобы избежать переполнения по вертикали. + // Это позволяет прокручивать содержимое, если оно не помещается на экране. + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SwitchListTile( + title: Text(localizations.darkModeSetting), + subtitle: Text(localizations.darkModeDescription), + value: state.isDarkMode, + // Комментарий: При изменении положения переключателя (onChanged) мы вызываем метод `toggleDarkMode` у Cubit. + // `context.read()` используется для доступа к Cubit без подписки на его изменения. + // Это хорошо для вызова методов. Также передаем userId из UserService. + onChanged: (value) { + // Получаем ID текущего пользователя из UserCubit + context + .read() + .toggleDarkMode(value); }, - // Комментарий: Формируем список доступных языков. - items: ['en', 'ru'] - .map>( - (String value) { - return DropdownMenuItem( - value: value, - // Комментарий: Отображаем локализованное название языка. - child: Text(value == 'en' - ? localizations.englishLanguage - : localizations.russianLanguage), - ); - }).toList(), ), - ), - const Divider(), - ListTile( - title: Text(localizations.currencySetting), - subtitle: Text(localizations.currencyDescription), - trailing: DropdownButton( - value: state.defaultCurrency, - // Комментарий: При выборе новой валюты вызываем метод `changeCurrency` у Cubit. - // Также передаем userId из UserService. - onChanged: (String? newValue) { - if (newValue != null) { - // Получаем ID текущего пользователя из UserCubit - final userState = - context.read().state; - if (userState is UserLoaded && - userState.user != null) { - final userId = userState.user!.id; - context - .read() - .changeCurrency(newValue, userId); + const Divider(), + ListTile( + title: Text(localizations.languageSetting), + subtitle: Text(localizations.languageDescription), + trailing: DropdownButton( + value: state.languageCode, + // Комментарий: При выборе нового языка вызываем метод `changeLanguage` у Cubit. + // Также передаем userId из UserService. + onChanged: (String? newValue) { + if (newValue != null) { + context + .read() + .changeLanguage(newValue); } - } - }, - // Комментарий: Формируем список доступных валют. Можно расширить этот список. - items: ['RUB', 'USD', 'EUR'] - .map>( - (String value) { - return DropdownMenuItem( - value: value, - child: Text(value), - ); - }).toList(), + }, + // Комментарий: Формируем список доступных языков. + items: ['en', 'ru'] + .map>( + (String value) { + return DropdownMenuItem( + value: value, + // Комментарий: Отображаем локализованное название языка. + child: Text(value == 'en' + ? localizations.englishLanguage + : localizations.russianLanguage), + ); + }).toList(), + ), ), - ), - const Divider(), - // Комментарий: ListTile для перехода на страницу редактирования категорий. - ListTile( - title: Text(localizations.editCategories), - subtitle: - Text(localizations.editCategoriesDescription), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const CategoryListPage(), - ), - ); - }, - ), - const Divider(), - ListTile( - title: Text(localizations.editTags), - subtitle: Text(localizations.editTagsDescription), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const TagListPage(), - ), - ); - }, - ), - const Divider(), - // Комментарий: ListTile для запуска процесса загрузки SMS-сообщений. - ListTile( - title: Text(localizations.loadSmsMessages), - subtitle: - Text(localizations.loadSmsMessagesDescription), - onTap: () { - // Комментарий: При нажатии на кнопку мы вызываем метод `loadSmsMessages` у SmsCubit. - // Это инициирует процесс получения и сохранения SMS-сообщений. - context.read().loadSmsMessages(); - }, - ), - const Divider(), - ], + const Divider(), + ListTile( + title: Text(localizations.currencySetting), + subtitle: Text(localizations.currencyDescription), + trailing: DropdownButton( + value: state.defaultCurrency, + // Комментарий: При выборе новой валюты вызываем метод `changeCurrency` у Cubit. + // Также передаем userId из UserService. + onChanged: (String? newValue) { + if (newValue != null) { + + context + .read() + .changeCurrency(newValue); + } + }, + // Комментарий: Формируем список доступных валют. Можно расширить этот список. + items: ['RUB', 'USD', 'EUR'] + .map>( + (String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + ), + ), + const Divider(), + // Комментарий: ListTile для перехода на страницу редактирования категорий. + ListTile( + title: Text(localizations.editCategories), + subtitle: + Text(localizations.editCategoriesDescription), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CategoryListPage(), + ), + ); + }, + ), + const Divider(), + ListTile( + title: Text(localizations.editTags), + subtitle: Text(localizations.editTagsDescription), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TagListPage(), + ), + ); + }, + ), + const Divider(), + // Комментарий: ListTile для запуска процесса загрузки SMS-сообщений. + ListTile( + title: Text(localizations.loadSmsMessages), + subtitle: + Text(localizations.loadSmsMessagesDescription), + onTap: () { + // Комментарий: При нажатии на кнопку мы вызываем метод `loadSmsMessages` у SmsCubit. + // Это инициирует процесс получения и сохранения SMS-сообщений. + context.read().loadSmsMessages(); + }, + ), + const Divider(), + ], + ), ), ); } else { diff --git a/lib/pages/tag/tag_list_page.dart b/lib/pages/tag/tag_list_page.dart index 4b8ac23..f9c3db1 100644 --- a/lib/pages/tag/tag_list_page.dart +++ b/lib/pages/tag/tag_list_page.dart @@ -69,14 +69,8 @@ class _TagListPageState extends State { MaterialPageRoute( builder: (_) => TagEditPage( onSave: (name) { - final userState = context.read().state; - if (userState is! UserLoaded || userState.user == null) { - return; - } - final currentUserId = userState.user!.id; final newTag = Tag( name: name, - userId: currentUserId, ); context.read().addTag(newTag); _listKey.currentState?.insertItem(0); diff --git a/lib/services/sms_service.dart b/lib/services/sms_service.dart index e3850f8..1235354 100644 --- a/lib/services/sms_service.dart +++ b/lib/services/sms_service.dart @@ -23,7 +23,7 @@ class SmsService { /// /// Возвращает список объектов [SmsMessage]. /// В случае ошибки или отсутствия разрешений, возвращает пустой список. - Future> getLastSmsMessages(int count, String userId) async { + Future> getLastSmsMessages(int count) async { final bool? permissionsGranted = await _telephony.requestPhoneAndSmsPermissions; if (permissionsGranted ?? false) { @@ -36,14 +36,13 @@ class SmsService { body: msg.body, sender: msg.address, date: DateTime.fromMillisecondsSinceEpoch(msg.date ?? 0), - userId: userId, )).toList(); } return []; } // Комментарий: Метод для получения SMS-сообщений с определенной даты. - Future> getSmsMessagesSince(DateTime sinceDate, String userId) async { + Future> getSmsMessagesSince(DateTime sinceDate) async { final bool? permissionsGranted = await _telephony.requestPhoneAndSmsPermissions; if (permissionsGranted ?? false) { final List messages = await _telephony.getInboxSms( @@ -57,7 +56,6 @@ class SmsService { body: msg.body, sender: msg.address, date: DateTime.fromMillisecondsSinceEpoch(msg.date ?? 0), - userId: userId, )) .toList(); } diff --git a/lib/utils/category_utils.dart b/lib/utils/category_utils.dart index 91a183d..4554de1 100644 --- a/lib/utils/category_utils.dart +++ b/lib/utils/category_utils.dart @@ -7,7 +7,7 @@ class CategoryUtils { /// Возвращает список предопределенных категорий для конкретного пользователя /// Включает как категории доходов, так и расходов /// [userId] - идентификатор пользователя, для которого создаются категории - static List getDefaultCategories(String userId) { + static List getDefaultCategories() { return [ // Категории доходов Category( @@ -16,7 +16,6 @@ class CategoryUtils { color: Colors.green, icon: Icons.attach_money, isIncome: true, - userId: userId, // Привязываем к конкретному пользователю ), Category( id: 'income_gift', @@ -24,7 +23,6 @@ class CategoryUtils { color: Colors.blue, icon: Icons.card_giftcard, isIncome: true, - userId: userId, // Привязываем к конкретному пользователю ), Category( id: 'income_freelance', @@ -32,7 +30,6 @@ class CategoryUtils { color: Colors.teal, icon: Icons.computer, isIncome: true, - userId: userId, // Привязываем к конкретному пользователю ), // Категории расходов @@ -42,7 +39,6 @@ class CategoryUtils { color: Colors.red, icon: Icons.fastfood, isIncome: false, - userId: userId, // Привязываем к конкретному пользователю ), Category( id: 'expense_transport', @@ -50,7 +46,6 @@ class CategoryUtils { color: Colors.orange, icon: Icons.directions_car, isIncome: false, - userId: userId, // Привязываем к конкретному пользователю ), Category( id: 'expense_entertainment', @@ -58,7 +53,6 @@ class CategoryUtils { color: Colors.purple, icon: Icons.movie, isIncome: false, - userId: userId, // Привязываем к конкретному пользователю ), Category( id: 'expense_utilities', @@ -66,7 +60,6 @@ class CategoryUtils { color: Colors.blueGrey, icon: Icons.home, isIncome: false, - userId: userId, // Привязываем к конкретному пользователю ), Category( id: 'expense_shopping', @@ -74,7 +67,6 @@ class CategoryUtils { color: Colors.pink, icon: Icons.shopping_bag, isIncome: false, - userId: userId, // Привязываем к конкретному пользователю ), ]; } @@ -82,12 +74,12 @@ class CategoryUtils { /// Возвращает только категории доходов для конкретного пользователя /// [userId] - идентификатор пользователя static List getIncomeCategories(String userId) { - return getDefaultCategories(userId).where((c) => c.isIncome).toList(); + return getDefaultCategories().where((c) => c.isIncome).toList(); } /// Возвращает только категории расходов для конкретного пользователя /// [userId] - идентификатор пользователя static List getExpenseCategories(String userId) { - return getDefaultCategories(userId).where((c) => !c.isIncome).toList(); + return getDefaultCategories().where((c) => !c.isIncome).toList(); } } diff --git a/lib/utils/tag_utils.dart b/lib/utils/tag_utils.dart index b556950..0b80982 100644 --- a/lib/utils/tag_utils.dart +++ b/lib/utils/tag_utils.dart @@ -5,35 +5,28 @@ class TagUtils { // Комментарий: Мы изменили сигнатуру метода, добавив параметр `userId`. // Это необходимо, потому что конструктор `Tag` теперь требует `userId`. /// Возвращает список предопределенных тегов для конкретного пользователя - static List getDefaultTags(String userId) { + static List getDefaultTags() { return [ Tag( id: 'tag_important', name: 'Важное', - // Комментарий: Передаем `userId` при создании каждого тега по умолчанию. - // Таким образом, эти теги будут принадлежать конкретному пользователю. - userId: userId, ), Tag( id: 'tag_work', name: 'Работа', - userId: userId, ), Tag( id: 'tag_family', name: 'Семья', - userId: userId, ), Tag( id: 'tag_friends', name: 'Друзья', - userId: userId, ), Tag( id: 'tag_holiday', name: 'Отпуск', - userId: userId, ), ]; } diff --git a/lib/utils/transaction_utils.dart b/lib/utils/transaction_utils.dart index 20d2e5e..ed277ba 100644 --- a/lib/utils/transaction_utils.dart +++ b/lib/utils/transaction_utils.dart @@ -5,11 +5,10 @@ import 'tag_utils.dart'; /// Утилиты для работы с тестовыми транзакциями class TransactionUtils { /// Возвращает список тестовых транзакций для конкретного пользователя - /// [userId] - идентификатор пользователя, для которого создаются транзакции - static List getSampleTransactions(String userId) { + static List getSampleTransactions() { // Получаем категории и теги для этого пользователя - final categories = CategoryUtils.getDefaultCategories(userId); - final tags = TagUtils.getDefaultTags(userId); + final categories = CategoryUtils.getDefaultCategories(); + final tags = TagUtils.getDefaultTags(); return [ // Доходы @@ -20,7 +19,6 @@ class TransactionUtils { dateTime: DateTime.now().subtract(const Duration(days: 5)), vendor: 'ООО "Рога и копыта"', currency: 'RUB', - userId: userId, // Указываем userId для тестовой транзакции ), TransactionRecord( category: categories.firstWhere((c) => c.id == 'income_freelance'), @@ -29,7 +27,6 @@ class TransactionUtils { dateTime: DateTime.now().subtract(const Duration(days: 2)), vendor: 'Фриланс проект', currency: 'RUB', - userId: userId, // Указываем userId для тестовой транзакции ), // Расходы @@ -40,7 +37,6 @@ class TransactionUtils { dateTime: DateTime.now().subtract(const Duration(days: 1)), vendor: 'Пятерочка', currency: 'RUB', - userId: userId, // Указываем userId для тестовой транзакции ), TransactionRecord( category: categories.firstWhere((c) => c.id == 'expense_transport'), @@ -49,7 +45,6 @@ class TransactionUtils { dateTime: DateTime.now().subtract(const Duration(hours: 12)), vendor: 'Яндекс Такси', currency: 'RUB', - userId: userId, // Указываем userId для тестовой транзакции ), TransactionRecord( category: categories.firstWhere((c) => c.id == 'expense_entertainment'), @@ -58,7 +53,6 @@ class TransactionUtils { dateTime: DateTime.now().subtract(const Duration(hours: 6)), vendor: 'Кинотеатр', currency: 'RUB', - userId: userId, // Указываем userId для тестовой транзакции ), ]; } From fd0c3dd9a87e4cbca4f3fe627600f067c892e3fb Mon Sep 17 00:00:00 2001 From: Sanders Date: Sat, 12 Jul 2025 21:56:39 +0300 Subject: [PATCH 32/35] Removes user-specific repository methods Removes the now-unnecessary user-specific methods from the category and tag repositories. The repositories are now user-specific at the box level, so filtering by user ID within the repository methods is redundant. Also, removes now-unused imports related to user logic in category and tag list pages, and the add transaction dialog. --- .../hive_category_repository.dart | 33 ------------------- .../repositories/hive_tag_repository.dart | 6 ---- lib/pages/category/category_list_page.dart | 1 - .../home/widgets/add_transaction_dialog.dart | 1 - lib/pages/tag/tag_list_page.dart | 1 - 5 files changed, 42 deletions(-) diff --git a/lib/data/repositories/hive_category_repository.dart b/lib/data/repositories/hive_category_repository.dart index 38a0e09..8b8032f 100644 --- a/lib/data/repositories/hive_category_repository.dart +++ b/lib/data/repositories/hive_category_repository.dart @@ -71,39 +71,6 @@ class HiveCategoryRepository implements ICategoryRepository { } } - // Новые методы для работы с пользователями - - @override - Future> getAllByUser(String userId) async { - try { - return _box.values.toList(); - } catch (e) { - throw Exception('Ошибка получения категорий пользователя: $e'); - } - } - - @override - Future> getIncomeCategoriesByUser(String userId) async { - try { - return _box.values - .where((c) => c.isIncome) - .toList(); - } catch (e) { - throw Exception('Ошибка получения категорий доходов пользователя: $e'); - } - } - - @override - Future> getExpenseCategoriesByUser(String userId) async { - try { - return _box.values - .where((c) => !c.isIncome) - .toList(); - } catch (e) { - throw Exception('Ошибка получения категорий расходов пользователя: $e'); - } - } - @override Future addAll(List categories) async { try { diff --git a/lib/data/repositories/hive_tag_repository.dart b/lib/data/repositories/hive_tag_repository.dart index bee8b61..6e5ea11 100644 --- a/lib/data/repositories/hive_tag_repository.dart +++ b/lib/data/repositories/hive_tag_repository.dart @@ -40,12 +40,6 @@ class HiveTagRepository implements ITagRepository { await _box.delete(id); } - @override - Future> getAllByUser(String userId) async { - // Фильтрация по userId больше не требуется, так как бокс уже пользовательский - return _box.values.toList(); - } - @override Future addAll(List tags) async { final Map tagMap = { diff --git a/lib/pages/category/category_list_page.dart b/lib/pages/category/category_list_page.dart index 5eef059..836af11 100644 --- a/lib/pages/category/category_list_page.dart +++ b/lib/pages/category/category_list_page.dart @@ -4,7 +4,6 @@ import 'package:budget_app/models/category.dart'; import 'package:budget_app/pages/category/category_edit_page.dart'; import 'package:budget_app/pages/category/widgets/add_category_button.dart'; import 'package:budget_app/pages/category/widgets/category_list_item.dart'; -import 'package:budget_app/logic/user/user_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; diff --git a/lib/pages/home/widgets/add_transaction_dialog.dart b/lib/pages/home/widgets/add_transaction_dialog.dart index 63df9b6..2f68efa 100644 --- a/lib/pages/home/widgets/add_transaction_dialog.dart +++ b/lib/pages/home/widgets/add_transaction_dialog.dart @@ -12,7 +12,6 @@ import '../../../data/repositories/interfaces/itag_repository.dart'; import '../../../models/category.dart'; import '../../../models/tag.dart'; import '../../../models/transaction_record.dart'; -import '../../../logic/user/user_cubit.dart'; import '../../../utils/category_utils.dart'; class AddTransactionDialog extends StatefulWidget { diff --git a/lib/pages/tag/tag_list_page.dart b/lib/pages/tag/tag_list_page.dart index f9c3db1..6cb2279 100644 --- a/lib/pages/tag/tag_list_page.dart +++ b/lib/pages/tag/tag_list_page.dart @@ -5,7 +5,6 @@ import 'package:budget_app/models/tag.dart'; import 'package:budget_app/pages/tag/tag_edit_page.dart'; import 'package:budget_app/pages/tag/widgets/add_tag_button.dart'; import 'package:budget_app/pages/tag/widgets/tag_list_item.dart'; -import 'package:budget_app/logic/user/user_cubit.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:get_it/get_it.dart'; From 85daede4a9a1f78265edac50642e96b0d5aa31d7 Mon Sep 17 00:00:00 2001 From: Sanders Date: Sat, 12 Jul 2025 22:40:31 +0300 Subject: [PATCH 33/35] Implements authentication flow with user registration This commit implements a complete authentication flow, including user registration. - Introduces an `AuthRegisterRequested` event to handle user registration. - Persists the user ID in settings upon successful authentication. - Modifies `AuthBloc` to load the user based on the stored ID, improving app launch persistence. - Refactors `UserCubit` to only manage the user state and removes authentication logic. - Removes `UserCubit` initialization from `main.dart` and triggers `AuthStarted` to initiate the authentication process. --- lib/injection_container.dart | 6 +- lib/logic/auth/auth_bloc.dart | 67 ++++++++++++-- lib/logic/auth/auth_event.dart | 14 ++- lib/logic/auth/auth_state.dart | 15 +++- lib/logic/user/user_cubit.dart | 68 +------------- lib/main.dart | 133 ++++++++++++---------------- lib/pages/login/login_page.dart | 62 +++++-------- lib/pages/splash/splash_screen.dart | 60 +++---------- 8 files changed, 183 insertions(+), 242 deletions(-) diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 021c48e..8f65adf 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -59,7 +59,11 @@ Future initGlobalDependencies() async { null, // Временно null, будет заменен в initUserSpecificDependencies ), ); - getIt.registerFactory(() => AuthBloc(userCubit: getIt())); + getIt.registerFactory(() => AuthBloc( + userCubit: getIt(), + settingsRepository: getIt(), + userRepository: getIt(), + )); } /// Инициализация зависимостей, специфичных для пользователя. diff --git a/lib/logic/auth/auth_bloc.dart b/lib/logic/auth/auth_bloc.dart index bbb3e11..e8ad580 100644 --- a/lib/logic/auth/auth_bloc.dart +++ b/lib/logic/auth/auth_bloc.dart @@ -3,27 +3,52 @@ import 'package:equatable/equatable.dart'; import 'package:budget_app/models/user.dart'; import 'package:budget_app/logic/user/user_cubit.dart'; import 'package:budget_app/injection_container.dart' as di; +import 'package:budget_app/data/repositories/interfaces/iglobal_settings_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/iuser_repository.dart'; part 'auth_event.dart'; part 'auth_state.dart'; class AuthBloc extends Bloc { final UserCubit _userCubit; + final IGlobalSettingsRepository _settingsRepository; + final IUserRepository _userRepository; - AuthBloc({required UserCubit userCubit}) : _userCubit = userCubit, super(AuthInitial()) { + AuthBloc({ + required UserCubit userCubit, + required IGlobalSettingsRepository settingsRepository, + required IUserRepository userRepository, + }) : _userCubit = userCubit, + _settingsRepository = settingsRepository, + _userRepository = userRepository, + super(AuthInitial()) { on(_onAuthStarted); on(_onAuthLoggedIn); on(_onAuthLoggedOut); + on(_onAuthRegisterRequested); } void _onAuthStarted(AuthStarted event, Emitter emit) async { - final userState = _userCubit.state; - if (userState is UserLoaded && userState.user != null) { - // Если пользователь уже загружен, инициализируем его зависимости. - await di.initUserSpecificDependencies(userState.user!.id); - emit(AuthAuthenticated(user: userState.user!)); - } else { - emit(AuthUnauthenticated()); + try { + final userId = await _settingsRepository.getCurrentUserId(); + if (userId != null) { + final user = await _userRepository.getById(userId); + if (user != null) { + // Пользователь найден, инициализируем зависимости и аутентифицируем + await di.initUserSpecificDependencies(user.id); + _userCubit.setUser(user); + emit(AuthAuthenticated(user: user)); + } else { + // ID есть, а пользователя нет (ошибка) -> сбрасываем + await _settingsRepository.setCurrentUserId(null); + emit(AuthUnauthenticated()); + } + } else { + // ID не найден, пользователь не аутентифицирован + emit(AuthUnauthenticated()); + } + } catch (e) { + emit(AuthError(e.toString())); } } @@ -31,14 +56,40 @@ class AuthBloc extends Bloc { // Инициализируем зависимости для вошедшего пользователя. await di.initUserSpecificDependencies(event.user.id); _userCubit.setUser(event.user); + await _settingsRepository.setCurrentUserId(event.user.id); emit(AuthAuthenticated(user: event.user)); } void _onAuthLoggedOut(AuthLoggedOut event, Emitter emit) async { // Сбрасываем пользовательские зависимости. await di.resetUserSpecificDependencies(); + await _settingsRepository.setCurrentUserId(null); _userCubit.logout(); emit(AuthUnauthenticated()); } + + Future _onAuthRegisterRequested( + AuthRegisterRequested event, Emitter emit) async { + emit(AuthLoading()); + try { + // 1. Создаем пользователя + final user = User(name: event.name, email: event.email); + await _userRepository.add(user); + + // 2. Инициализируем его зависимости + await di.initUserSpecificDependencies(user.id); + + // 3. Создаем начальные данные (теперь это сработает) + await _userCubit.createInitialData(user.id); + + // 4. Сохраняем и устанавливаем пользователя + await _settingsRepository.setCurrentUserId(user.id); + _userCubit.setUser(user); + emit(AuthAuthenticated(user: user)); + } catch (e) { + emit(AuthError(e.toString())); + } + } } + diff --git a/lib/logic/auth/auth_event.dart b/lib/logic/auth/auth_event.dart index 2f75eba..be23680 100644 --- a/lib/logic/auth/auth_event.dart +++ b/lib/logic/auth/auth_event.dart @@ -7,10 +7,8 @@ abstract class AuthEvent extends Equatable { List get props => []; } -// Событие, которое будет вызываться при инициализации BLoC class AuthStarted extends AuthEvent {} -// Событие, которое будет вызываться при входе пользователя class AuthLoggedIn extends AuthEvent { final User user; @@ -20,5 +18,15 @@ class AuthLoggedIn extends AuthEvent { List get props => [user]; } -// Событие, которое будет вызываться при выходе пользователя class AuthLoggedOut extends AuthEvent {} + +class AuthRegisterRequested extends AuthEvent { + final String name; + final String email; + + const AuthRegisterRequested({required this.name, required this.email}); + + @override + List get props => [name, email]; +} + diff --git a/lib/logic/auth/auth_state.dart b/lib/logic/auth/auth_state.dart index dca2f08..22de17c 100644 --- a/lib/logic/auth/auth_state.dart +++ b/lib/logic/auth/auth_state.dart @@ -7,10 +7,10 @@ abstract class AuthState extends Equatable { List get props => []; } -// Начальное состояние, пока мы не знаем, аутентифицирован ли пользователь class AuthInitial extends AuthState {} -// Состояние, когда пользователь аутентифицирован +class AuthLoading extends AuthState {} + class AuthAuthenticated extends AuthState { final User user; @@ -20,5 +20,14 @@ class AuthAuthenticated extends AuthState { List get props => [user]; } -// Состояние, когда пользователь не аутентифицирован class AuthUnauthenticated extends AuthState {} + +class AuthError extends AuthState { + final String message; + + const AuthError(this.message); + + @override + List get props => [message]; +} + diff --git a/lib/logic/user/user_cubit.dart b/lib/logic/user/user_cubit.dart index 9f08511..3a8d33c 100644 --- a/lib/logic/user/user_cubit.dart +++ b/lib/logic/user/user_cubit.dart @@ -16,7 +16,8 @@ import '/utils/transaction_utils.dart'; part 'user_state.dart'; -/// Cubit для управления состоянием пользователя +/// Cubit для управления состоянием пользователя. +/// Больше не управляет процессом входа, а только хранит состояние пользователя. class UserCubit extends Cubit { final IGlobalSettingsRepository _settingsRepository; final IUserRepository _userRepository; @@ -56,37 +57,6 @@ class UserCubit extends Cubit { set transactionRepository(ITransactionRepository? repo) => _transactionRepository = repo; - // Метод для инициализации UserCubit - Future init() async { - await _init(); - } - - Future _init() async { - emit(UserLoading(progress: 0.1, message: 'Инициализация...')); - try { - final userId = await _settingsRepository.getCurrentUserId(); - User? currentUser; - - if (userId != null) { - emit(UserLoading(progress: 0.3, message: 'Поиск пользователя...')); - currentUser = await _userRepository.getById(userId); - if (currentUser == null) { - await _settingsRepository.setCurrentUserId(null); - } - } - - emit(UserLoading(progress: 1.0, message: 'Завершение...')); - await Future.delayed(const Duration(milliseconds: 300)); - emit(UserLoaded(currentUser)); - } catch (e, stack) { - _logger.e( - '--- UserCubit: FATAL ERROR in _init ---', - error: e, - stackTrace: stack, - ); - emit(UserError('Ошибка загрузки пользователя: ${e.toString()}')); - } - } Future createInitialData(String userId) async { // Проверяем, что репозитории были установлены, прежде чем их использовать. if (_categoryRepository == null || @@ -120,22 +90,6 @@ class UserCubit extends Cubit { } } - Future _setCurrentUser(User user) async { - await _settingsRepository.setCurrentUserId(user.id); - emit(UserLoaded(user)); - } - - /// Устанавливает текущего пользователя - Future setCurrentUser(User user) async { - emit(UserLoading()); - try { - await _setCurrentUser(user); - } catch (e, stack) { - _logger.e('Error setting current user', error: e, stackTrace: stack); - emit(UserError('Ошибка установки пользователя: ${e.toString()}')); - } - } - /// Устанавливает текущего пользователя (прямая установка без загрузки) void setUser(User user) { emit(UserLoaded(user)); @@ -153,23 +107,6 @@ class UserCubit extends Cubit { } } - /// Создает нового пользователя и устанавливает его как текущего - Future createAndSetUser(String name, String email) async { - emit(UserLoading()); - try { - final user = User(name: name, email: email); - await _userRepository.add(user); - - // Создаем начальные данные для нового пользователя - await createInitialData(user.id); - - await _setCurrentUser(user); - } catch (e, stack) { - _logger.e('Error creating user', error: e, stackTrace: stack); - emit(UserError('Ошибка создания пользователя: ${e.toString()}')); - } - } - /// Возвращает всех пользователей Future> getAllUsers() async { try { @@ -180,3 +117,4 @@ class UserCubit extends Cubit { } } } + diff --git a/lib/main.dart b/lib/main.dart index ebfd34b..54cd407 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,91 +26,76 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - // 1. Предоставляем глобальные Blocs, которые доступны всегда. return MultiBlocProvider( providers: [ BlocProvider( - create: (context) => GetIt.instance()..init(), + create: (context) => GetIt.instance(), ), BlocProvider( - create: (context) => GetIt.instance(), + create: (context) => GetIt.instance()..add(AuthStarted()), ), ], - child: BlocListener( - // 2. Запускаем проверку аутентификации, как только UserCubit загрузил данные. - listenWhen: (previous, current) => current is UserLoaded, - listener: (context, userState) { - context.read().add(AuthStarted()); - }, - // 3. В зависимости от статуса аутентификации, строим разное дерево виджетов. - child: BlocBuilder( - builder: (context, authState) { - // 4. Если пользователь аутентифицирован... - if (authState is AuthAuthenticated) { - // ...предоставляем все пользовательские зависимости. - return MultiBlocProvider( - providers: [ - BlocProvider(create: (context) => GetIt.instance()), - BlocProvider(create: (context) => GetIt.instance()), - BlocProvider(create: (context) => GetIt.instance()), - // Можно добавить и остальные, если они нужны глобально в авторизованной зоне - ], - // И строим приложение с пользовательской темой. - child: BlocBuilder( - builder: (context, settingsState) { - final isDarkMode = settingsState is SettingsLoaded - ? settingsState.isDarkMode - : false; - final languageCode = settingsState is SettingsLoaded - ? settingsState.languageCode - : 'ru'; - - return MaterialApp( - title: 'Budget App', - theme: AppTheme.lightTheme(), - darkTheme: AppTheme.darkTheme(), - themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light, - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - supportedLocales: AppLocalizations.supportedLocales, - locale: Locale(languageCode), - home: const HomePage(), - ); - }, - ), - ); - } - - // 5. Если пользователь НЕ аутентифицирован, показываем SplashScreen или LoginPage - // с темой по умолчанию. - return MaterialApp( - title: 'Budget App', - theme: AppTheme.lightTheme(), - darkTheme: AppTheme.darkTheme(), - themeMode: ThemeMode.light, // Тема по умолчанию - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, + child: BlocBuilder( + builder: (context, authState) { + if (authState is AuthAuthenticated) { + return MultiBlocProvider( + providers: [ + BlocProvider(create: (context) => GetIt.instance()), + BlocProvider(create: (context) => GetIt.instance()), + BlocProvider(create: (context) => GetIt.instance()), ], - supportedLocales: AppLocalizations.supportedLocales, - locale: const Locale('ru'), // Язык по умолчанию - home: BlocBuilder( - builder: (context, userState) { - if (userState is UserLoading || userState is UserInitial) { - return const SplashScreen(); - } - return const LoginPage(); + child: BlocBuilder( + builder: (context, settingsState) { + final isDarkMode = settingsState is SettingsLoaded + ? settingsState.isDarkMode + : false; + final languageCode = settingsState is SettingsLoaded + ? settingsState.languageCode + : 'ru'; + + return MaterialApp( + title: 'Budget App', + theme: AppTheme.lightTheme(), + darkTheme: AppTheme.darkTheme(), + themeMode: isDarkMode ? ThemeMode.dark : ThemeMode.light, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: Locale(languageCode), + home: const HomePage(), + ); }, ), ); - }, - ), + } + + Widget homeWidget; + if (authState is AuthInitial) { + homeWidget = const SplashScreen(); + } else { + homeWidget = const LoginPage(); + } + + return MaterialApp( + title: 'Budget App', + theme: AppTheme.lightTheme(), + darkTheme: AppTheme.darkTheme(), + themeMode: ThemeMode.light, // Тема по умолчанию + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('ru'), // Язык по умолчанию + home: homeWidget, + ); + }, ), ); } diff --git a/lib/pages/login/login_page.dart b/lib/pages/login/login_page.dart index 4f4a962..8117bb2 100644 --- a/lib/pages/login/login_page.dart +++ b/lib/pages/login/login_page.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '/l10n/app_localizations.dart'; import '../../logic/auth/auth_bloc.dart'; -import '../../logic/user/user_cubit.dart'; // Импортируем UserCubit вместо UserService class LoginPage extends StatefulWidget { const LoginPage({super.key}); @@ -17,26 +16,26 @@ class _LoginPageState extends State { final _emailController = TextEditingController(); final _nameController = TextEditingController(); + void _login() { + if (_formKey.currentState!.validate()) { + context.read().add(AuthRegisterRequested( + name: _nameController.text, + email: _emailController.text, + )); + } + } + @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of( - context, - )!; // Получаем экземпляр локализации + final localizations = AppLocalizations.of(context)!; return Scaffold( appBar: AppBar( title: Text(localizations.loginPageTitle), - ), // Локализованный заголовок - body: BlocListener( - // Слушаем изменения состояния UserCubit - listenWhen: (previous, current) => - current is UserLoaded || current is UserError, + ), + body: BlocListener( listener: (context, state) { - if (state is UserLoaded && state.user != null) { - // Комментарий: При успешном создании пользователя отправляем событие в AuthBloc - context.read().add(AuthLoggedIn(user: state.user!)); - } else if (state is UserError) { - // Комментарий: Показываем ошибку пользователю + if (state is AuthError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(state.message)), ); @@ -52,11 +51,10 @@ class _LoginPageState extends State { controller: _nameController, decoration: InputDecoration( labelText: localizations.nameFieldLabel, - ), // Локализованный текст + ), validator: (value) { if (value == null || value.isEmpty) { - return localizations - .nameFieldEmptyError; // Локализованный текст + return localizations.nameFieldEmptyError; } return null; }, @@ -65,41 +63,28 @@ class _LoginPageState extends State { controller: _emailController, decoration: InputDecoration( labelText: localizations.emailFieldLabel, - ), // Локализованный текст + ), validator: (value) { if (value == null || value.isEmpty) { - return localizations - .emailFieldEmptyError; // Локализованный текст + return localizations.emailFieldEmptyError; } return null; }, ), const SizedBox(height: 20), - BlocBuilder( - // Строим кнопку в зависимости от состояния UserCubit + BlocBuilder( builder: (context, state) { - final isLoading = state is UserLoading; - + final isLoading = state is AuthLoading; + return ElevatedButton( - onPressed: isLoading ? null : () { - // Комментарий: Блокируем кнопку во время загрузки - if (_formKey.currentState!.validate()) { - // Комментарий: Используем UserCubit для создания пользователя - context.read().createAndSetUser( - _nameController.text, - _emailController.text, - ); - } - }, + onPressed: isLoading ? null : _login, child: isLoading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), - ) // Показываем индикатор загрузки - : Text( - localizations.loginButtonText, - ), // Локализованный текст + ) + : Text(localizations.loginButtonText), ); }, ), @@ -111,3 +96,4 @@ class _LoginPageState extends State { ); } } + diff --git a/lib/pages/splash/splash_screen.dart b/lib/pages/splash/splash_screen.dart index 9dfe5d9..7c46745 100644 --- a/lib/pages/splash/splash_screen.dart +++ b/lib/pages/splash/splash_screen.dart @@ -1,60 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '/logic/user/user_cubit.dart'; class SplashScreen extends StatelessWidget { const SplashScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - body: BlocBuilder( - builder: (context, state) { - if (state is UserLoading) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(height: 20), - Text( - state.message, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 10), - Text( - '${(state.progress * 100).toInt()}%', - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - ); - } - - if (state is UserError) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Ошибка загрузки', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 20), - Text(state.message), - const SizedBox(height: 20), - ElevatedButton( - onPressed: () => context.read().init(), - child: const Text('Повторить'), - ), - ], - ), - ); - } - - return const Center(child: CircularProgressIndicator()); - }, + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 20), + Text('Загрузка...'), + ], + ), ), ); } From c23a3db59dc8115a40b7860481b93c8695c7c3ae Mon Sep 17 00:00:00 2001 From: Sanders Date: Sat, 12 Jul 2025 23:53:50 +0300 Subject: [PATCH 34/35] Adds SMS processing settings Adds a popup menu to SMS messages to allow access to processing settings. This commit introduces the "Processing settings" option within a context menu that appears when an SMS message is tapped. This prepares the UI for future implementation of SMS processing configuration. --- lib/l10n/app_en.arb | 3 +- lib/l10n/app_localizations.dart | 6 +++ lib/l10n/app_localizations_en.dart | 3 ++ lib/l10n/app_localizations_ru.dart | 3 ++ lib/l10n/app_ru.arb | 3 +- lib/pages/sms/widgets/sms_message_widget.dart | 41 ++++++++++++++++--- 6 files changed, 52 insertions(+), 7 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 098e583..258c3b2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -51,5 +51,6 @@ "addTag": "Add tag", "editTag": "Edit tag", "loadSmsMessages": "Load SMS Messages", - "loadSmsMessagesDescription": "Load and process SMS messages to automatically create transactions" + "loadSmsMessagesDescription": "Load and process SMS messages to automatically create transactions", + "smsSettings": "Processing settings" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7857909..0548d87 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -409,6 +409,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Load and process SMS messages to automatically create transactions'** String get loadSmsMessagesDescription; + + /// No description provided for @smsSettings. + /// + /// In en, this message translates to: + /// **'Processing settings'** + String get smsSettings; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b3334c5..c217cea 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -166,4 +166,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loadSmsMessagesDescription => 'Load and process SMS messages to automatically create transactions'; + + @override + String get smsSettings => 'Processing settings'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d252472..6a993ba 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -169,4 +169,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get loadSmsMessagesDescription => 'Загрузить и обработать SMS-сообщения для автоматического создания транзакций'; + + @override + String get smsSettings => 'Настройка обработки'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3577763..e0d7aa4 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -51,5 +51,6 @@ "addTag": "Добавить тег", "editTag": "Редактировать тег", "loadSmsMessages": "Загрузить SMS", - "loadSmsMessagesDescription": "Загрузить и обработать SMS-сообщения для автоматического создания транзакций" + "loadSmsMessagesDescription": "Загрузить и обработать SMS-сообщения для автоматического создания транзакций", + "smsSettings": "Настройка обработки" } diff --git a/lib/pages/sms/widgets/sms_message_widget.dart b/lib/pages/sms/widgets/sms_message_widget.dart index 268ea40..b756d7b 100644 --- a/lib/pages/sms/widgets/sms_message_widget.dart +++ b/lib/pages/sms/widgets/sms_message_widget.dart @@ -1,19 +1,50 @@ +import 'package:budget_app/l10n/app_localizations.dart'; import 'package:budget_app/models/sms_message.dart'; import 'package:flutter/material.dart'; /// Виджет для отображения одного SMS сообщения. +/// +/// При нажатии на виджет появляется контекстное меню. class SmsMessageWidget extends StatelessWidget { final SmsMessage message; const SmsMessageWidget({super.key, required this.message}); + // Функция для отображения всплывающего меню. + // + // [context] - Контекст сборки. + // [details] - Детали о событии нажатия. + void _showPopupMenu(BuildContext context, TapDownDetails details) { + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + showMenu( + context: context, + position: RelativeRect.fromRect( + details.globalPosition & const Size(40, 40), // Размер области нажатия + Offset.zero & overlay.size, + ), + items: [ + PopupMenuItem( + child: Text(AppLocalizations.of(context)!.smsSettings), + onTap: () { + // TODO: Реализовать переход к настройкам обработки SMS + }, + ), + ], + ); + } + @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text(message.body ?? ''), + // Оборачиваем Card в GestureDetector для отслеживания нажатий. + return GestureDetector( + onTapDown: (details) => _showPopupMenu(context, details), + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(message.body ?? ''), + ), ), ); } From 2fca5203e2745880526ea671acd3682fc447910e Mon Sep 17 00:00:00 2001 From: Sanders Date: Sun, 13 Jul 2025 00:16:21 +0300 Subject: [PATCH 35/35] Improves SMS message display and interaction Enhances the SMS message widget to provide a richer user experience. Adds sender information, formatted date, message processing status and actions. Includes localization support for new UI elements. --- lib/l10n/app_en.arb | 6 +- lib/l10n/app_localizations.dart | 24 ++++++ lib/l10n/app_localizations_en.dart | 12 +++ lib/l10n/app_localizations_ru.dart | 12 +++ lib/l10n/app_ru.arb | 6 +- lib/pages/sms/widgets/sms_message_widget.dart | 82 +++++++++++++++++-- 6 files changed, 131 insertions(+), 11 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 258c3b2..ee5fd0b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -52,5 +52,9 @@ "editTag": "Edit tag", "loadSmsMessages": "Load SMS Messages", "loadSmsMessagesDescription": "Load and process SMS messages to automatically create transactions", - "smsSettings": "Processing settings" + "smsSettings": "Processing settings", + "createTransaction": "Create transaction", + "unknownSender": "Unknown sender", + "smsProcessed": "Processed", + "smsNotProcessed": "Not processed" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0548d87..683dd7b 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -415,6 +415,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Processing settings'** String get smsSettings; + + /// No description provided for @createTransaction. + /// + /// In en, this message translates to: + /// **'Create transaction'** + String get createTransaction; + + /// No description provided for @unknownSender. + /// + /// In en, this message translates to: + /// **'Unknown sender'** + String get unknownSender; + + /// No description provided for @smsProcessed. + /// + /// In en, this message translates to: + /// **'Processed'** + String get smsProcessed; + + /// No description provided for @smsNotProcessed. + /// + /// In en, this message translates to: + /// **'Not processed'** + String get smsNotProcessed; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c217cea..c2d8a21 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -169,4 +169,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get smsSettings => 'Processing settings'; + + @override + String get createTransaction => 'Create transaction'; + + @override + String get unknownSender => 'Unknown sender'; + + @override + String get smsProcessed => 'Processed'; + + @override + String get smsNotProcessed => 'Not processed'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 6a993ba..887f834 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -172,4 +172,16 @@ class AppLocalizationsRu extends AppLocalizations { @override String get smsSettings => 'Настройка обработки'; + + @override + String get createTransaction => 'Создать транзакцию'; + + @override + String get unknownSender => 'Неизвестный отправитель'; + + @override + String get smsProcessed => 'Обработано'; + + @override + String get smsNotProcessed => 'Не обработано'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e0d7aa4..ec4fcd8 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -52,5 +52,9 @@ "editTag": "Редактировать тег", "loadSmsMessages": "Загрузить SMS", "loadSmsMessagesDescription": "Загрузить и обработать SMS-сообщения для автоматического создания транзакций", - "smsSettings": "Настройка обработки" + "smsSettings": "Настройка обработки", + "createTransaction": "Создать транзакцию", + "unknownSender": "Неизвестный отправитель", + "smsProcessed": "Обработано", + "smsNotProcessed": "Не обработано" } diff --git a/lib/pages/sms/widgets/sms_message_widget.dart b/lib/pages/sms/widgets/sms_message_widget.dart index b756d7b..5ba9357 100644 --- a/lib/pages/sms/widgets/sms_message_widget.dart +++ b/lib/pages/sms/widgets/sms_message_widget.dart @@ -1,26 +1,32 @@ import 'package:budget_app/l10n/app_localizations.dart'; import 'package:budget_app/models/sms_message.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; -/// Виджет для отображения одного SMS сообщения. +/// Виджет для отображения одного SMS сообщения с улучшенным интерфейсом. /// -/// При нажатии на виджет появляется контекстное меню. +/// Отображает отправителя, дату, тело сообщения и статус обработки. +/// Подсвечивает суммы в тексте сообщения. class SmsMessageWidget extends StatelessWidget { final SmsMessage message; const SmsMessageWidget({super.key, required this.message}); - // Функция для отображения всплывающего меню. - // - // [context] - Контекст сборки. - // [details] - Детали о событии нажатия. + // Форматирование даты для отображения + String _formatDate(BuildContext context, DateTime? date) { + if (date == null) return ''; + final locale = Localizations.localeOf(context).toString(); + return DateFormat.yMd(locale).add_jm().format(date); + } + + // Функция для отображения всплывающего меню void _showPopupMenu(BuildContext context, TapDownDetails details) { final RenderBox overlay = Overlay.of(context).context.findRenderObject() as RenderBox; showMenu( context: context, position: RelativeRect.fromRect( - details.globalPosition & const Size(40, 40), // Размер области нажатия + details.globalPosition & const Size(40, 40), Offset.zero & overlay.size, ), items: [ @@ -30,20 +36,78 @@ class SmsMessageWidget extends StatelessWidget { // TODO: Реализовать переход к настройкам обработки SMS }, ), + PopupMenuItem( + child: Text(AppLocalizations.of(context)!.createTransaction), + onTap: () { + // TODO: Реализовать создание транзакции из SMS + }, + ), ], ); } @override Widget build(BuildContext context) { - // Оборачиваем Card в GestureDetector для отслеживания нажатий. return GestureDetector( onTapDown: (details) => _showPopupMenu(context, details), child: Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Padding( padding: const EdgeInsets.all(16.0), - child: Text(message.body ?? ''), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Заголовок: отправитель и дата + Row( + children: [ + Icon(Icons.person_outline, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + message.sender ?? AppLocalizations.of(context)!.unknownSender, + style: Theme.of(context).textTheme.titleSmall, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 16), + Text( + _formatDate(context, message.date), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + const SizedBox(height: 12), + + // Тело сообщения с подсветкой сумм + Text( + message.body ?? '', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + + // Футер: статус обработки + Row( + children: [ + Icon( + message.transactionId != null + ? Icons.check_circle_outline + : Icons.error_outline, + size: 16, + color: message.transactionId != null + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 4), + Text( + message.transactionId != null + ? AppLocalizations.of(context)!.smsProcessed + : AppLocalizations.of(context)!.smsNotProcessed, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ], + ), ), ), );