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/.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/.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/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/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/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/database/hive_service.dart b/lib/data/database/hive_service.dart index 77d71b6..02ae6bb 100644 --- a/lib/data/database/hive_service.dart +++ b/lib/data/database/hive_service.dart @@ -1,87 +1,70 @@ -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/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(); +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'; +import 'package:budget_app/hive/hive_registrar.g.dart'; +/// Сервис для управления Hive Box. +/// Отвечает за инициализацию, открытие и закрытие глобальных и пользовательских хранилищ. class HiveService { - static const String _settingsBox = 'settings'; - static const String _categoryBox = 'categories'; - static const String _tagBox = 'tags'; - static const String _transactionBox = 'transactions'; - static const String _userBox = 'users'; - - // ID системного пользователя по умолчанию - static const String _defaultUserId = 'default_user'; + // --- Глобальные 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(); - - //zfinal path = Directory.current.path; + // Регистрация адаптеров Hive... Hive.registerAdapters(); - // Открытие всех Box'ов - await Future.wait([ - Hive.openBox(_settingsBox), - Hive.openBox(_categoryBox), - Hive.openBox(_tagBox), - Hive.openBox(_transactionBox), - Hive.openBox(_userBox), - ]); - - // Проверка и заполнение начальными данными - await _checkAndFillInitialData(); + // Открытие глобальных хранилищ + users = await Hive.openBox('users'); + globalSettings = await Hive.openBox('global_settings'); } - 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); + // --- Пользовательские 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 Future _checkAndFillInitialData() async { - // Создаем пользователя по умолчанию, если его нет - 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(); - } + /// Инициализация 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'); + } - 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)); - await transactionBox.flush(); - } + /// Закрытие пользовательских Hive Box. + /// Должен вызываться при выходе пользователя из системы. + static Future closeUserBoxes() async { + await categories.close(); + await tags.close(); + await transactions.close(); + await appSettings.close(); + await smsHandlerSettings.close(); + await smsMessages.close(); } } diff --git a/lib/data/repositories/hive_category_repository.dart b/lib/data/repositories/hive_category_repository.dart index dc4d876..8b8032f 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,60 +10,76 @@ 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(); - } - - // Новые методы для работы с пользователями - - @override - Future> getAllByUser(String userId) async { - // Фильтруем все категории по userId - return _box.values.where((c) => c.userId == userId).toList(); + try { + return _box.values.where((c) => !c.isIncome).toList(); + } catch (e) { + throw Exception('Ошибка получения категорий расходов: $e'); + } } @override - Future> getIncomeCategoriesByUser(String userId) async { - // Получаем только категории доходов конкретного пользователя - return _box.values - .where((c) => c.userId == userId && c.isIncome) - .toList(); - } - - @override - Future> getExpenseCategoriesByUser(String userId) async { - // Получаем только категории расходов конкретного пользователя - return _box.values - .where((c) => c.userId == userId && !c.isIncome) - .toList(); + Future addAll(List categories) async { + try { + final Map categoryMap = { + for (var cat in categories) cat.id: cat + }; + await _box.putAll(categoryMap); + } catch (e) { + throw Exception('Ошибка пакетного добавления категорий: $e'); + } } } 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..797311f --- /dev/null +++ b/lib/data/repositories/hive_settings_repository.dart @@ -0,0 +1,52 @@ +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; + + // Фиксированный ключ для хранения настроек приложения + static const String _settingsKey = 'user_settings'; + + HiveSettingsRepository(this._box); + + @override + Future getSettings() async { + try { + // Получаем настройки по ключу вместо индекса + 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'); + } + } + + @override + Future saveSettings(AppSettings settings) async { + try { + // Сохраняем настройки с фиксированным ключом + await _box.put(_settingsKey, settings); + } catch (e) { + throw Exception('Ошибка сохранения настроек: $e'); + } + } + + @override + Future deleteSettings() async { + try { + // Удаляем настройки по фиксированному ключу + 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 new file mode 100644 index 0000000..e6e7219 --- /dev/null +++ b/lib/data/repositories/hive_sms_handler_repository.dart @@ -0,0 +1,56 @@ +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() async { + // В Hive мы будем использовать ID пользователя как ключ для его настроек. + return _smsHandlerBox.getAt(1); + } + + @override + Future saveSmsHandlerSettings(SmsHandlerSettings settings) async { + // Сохраняем объект настроек по ключу, равному ID пользователя. + await _smsHandlerBox.put(settings.id, settings); + } + + @override + Future saveRuleForSender(String sender, SmsProcessingRule rule) async { + // 1. Получаем текущие настройки. + SmsHandlerSettings? settings = await getSmsHandlerSettings(); + + if (settings == null) { + // 2. Если настроек нет, создаем новый объект. + settings = SmsHandlerSettings( + rulesBySender: {sender: rule}, // Создаем карту с первым правилом + ); + } else { + // 3. Если настройки есть, обновляем или добавляем правило. + settings.rulesBySender[sender] = rule; + } + + // 4. Сохраняем обновленный объект настроек. + await saveSmsHandlerSettings(settings); + } + + @override + Future deleteRuleForSender(String sender) async { + // 1. Получаем текущие настройки. + final settings = await getSmsHandlerSettings(); + + if (settings != null) { + // 2. Если настройки существуют, удаляем правило для отправителя. + settings.rulesBySender.remove(sender); + // 3. Сохраняем измененный объект. + await saveSmsHandlerSettings(settings); + } + // Если настроек нет, ничего не делаем. + } +} 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/hive_tag_repository.dart b/lib/data/repositories/hive_tag_repository.dart index 7dd5ffc..6e5ea11 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,12 +40,11 @@ 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(); + 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..bbb4962 100644 --- a/lib/data/repositories/hive_transaction_repository.dart +++ b/lib/data/repositories/hive_transaction_repository.dart @@ -60,37 +60,11 @@ 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(); + 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..c43e4ee 100644 --- a/lib/data/repositories/interfaces/icategory_repository.dart +++ b/lib/data/repositories/interfaces/icategory_repository.dart @@ -10,13 +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/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..76c4b65 --- /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(); + + /// Сохраняет настройки + /// [settings] - объект настроек для сохранения + Future saveSettings(AppSettings settings); + + /// Удаляет настройки для указанного пользователя + /// [userId] - идентификатор пользователя + Future deleteSettings(); +} 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..c2605f8 --- /dev/null +++ b/lib/data/repositories/interfaces/isms_handler_repository.dart @@ -0,0 +1,28 @@ +import '../../../models/sms_handler_settings.dart'; + +/// Абстрактный класс (интерфейс) для репозитория настроек обработки СМС. +/// Определяет контракт, по которому UI и бизнес-логика будут взаимодействовать +/// с данными о настройках, не зная деталей их реализации (Hive, Firebase, etc). +abstract class ISmsHandlerRepository { + /// Получает настройки обработки СМС для указанного пользователя. + /// + Future getSmsHandlerSettings(); + + /// Сохраняет или обновляет настройки обработки СМС для пользователя. + /// + /// [settings] - Объект с настройками, который нужно сохранить. + Future saveSmsHandlerSettings(SmsHandlerSettings settings); + + /// Добавляет или обновляет правило для конкретного отправителя. + /// + /// [userId] - ID пользователя. + /// [sender] - Идентификатор отправителя (например, 'SBERBANK'). + /// [rule] - Правило обработки. + Future saveRuleForSender(String sender, SmsProcessingRule rule); + + /// Удаляет правило для конкретного отправителя. + /// + /// [userId] - ID пользователя. + /// [sender] - Идентификатор отправителя, чье правило нужно удалить. + Future deleteRuleForSender(String sender); +} 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/data/repositories/interfaces/itag_repository.dart b/lib/data/repositories/interfaces/itag_repository.dart index cf5c73d..99f0ec9 100644 --- a/lib/data/repositories/interfaces/itag_repository.dart +++ b/lib/data/repositories/interfaces/itag_repository.dart @@ -7,9 +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 1e36afd..4437e20 100644 --- a/lib/data/repositories/interfaces/itransaction_repository.dart +++ b/lib/data/repositories/interfaces/itransaction_repository.dart @@ -11,10 +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 0b50f24..8beb5f6 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -1,15 +1,9 @@ -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(), AdapterSpec(), ]) part 'hive_adapters.g.dart'; diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index 255b017..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; diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 257a15c..8740adf 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -1,14 +1,8 @@ # 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 - nextIndex: 1 - fields: - value: - index: 0 IconData: typeId: 1 nextIndex: 5 diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index 7d8e23c..710b1ef 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -4,16 +4,25 @@ 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_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'; import 'package:budget_app/models/user.dart'; extension HiveRegistrar on HiveInterface { void registerAdapters() { + registerAdapter(AppSettingsAdapter()); registerAdapter(CategoryAdapter()); - registerAdapter(ColorAdapter()); + registerAdapter(GlobalSettingsAdapter()); registerAdapter(IconDataAdapter()); + registerAdapter(SmsHandlerSettingsAdapter()); + registerAdapter(SmsMessageAdapter()); + registerAdapter(SmsProcessingRuleAdapter()); + registerAdapter(SmsProcessingTypeAdapter()); registerAdapter(TagAdapter()); registerAdapter(TransactionRecordAdapter()); registerAdapter(UserAdapter()); @@ -22,9 +31,14 @@ extension HiveRegistrar on HiveInterface { extension IsolatedHiveRegistrar on IsolatedHiveInterface { void registerAdapters() { + registerAdapter(AppSettingsAdapter()); registerAdapter(CategoryAdapter()); - 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 c85ac5a..8f65adf 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -1,32 +1,189 @@ 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'; +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'; +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 'services/settings_service.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'; +import 'logic/sms/sms_cubit.dart'; +import 'logic/tag/tag_cubit.dart'; +import 'logic/transaction/transaction_bloc.dart'; +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(); + await HiveService.initGlobalBoxes(); - // Регистрация сервисов - getIt.registerSingleton(SettingsService()); + // Глобальные репозитории + getIt.registerSingleton( + HiveUserRepository(HiveService.users), + ); + getIt.registerSingleton( + HiveGlobalSettingsRepository(HiveService.globalSettings), + ); - // Регистрация репозиториев - getIt.registerSingleton( - HiveCategoryRepository(HiveService.categories), - ); - - getIt.registerSingleton( - HiveTagRepository(HiveService.tags), - ); - - getIt.registerSingleton( - HiveTransactionRepository(HiveService.transactions), + // Сервисы + getIt.registerSingleton(SmsService()); + + // Cubits & Blocs, которые нужны до входа пользователя + getIt.registerSingleton( + UserCubit( + settingsRepository: getIt(), + userRepository: getIt(), + // Эти репозитории будут заменены после входа пользователя + categoryRepository: + null, // Временно null, будет заменен в initUserSpecificDependencies + tagRepository: + null, // Временно null, будет заменен в initUserSpecificDependencies + transactionRepository: + null, // Временно null, будет заменен в initUserSpecificDependencies + ), ); + getIt.registerFactory(() => AuthBloc( + userCubit: getIt(), + settingsRepository: getIt(), + userRepository: 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()), // Добавляем userId + ); + + // Теги + 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/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..ee5fd0b --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,60 @@ +{ + "@@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", + "currencySetting": "Default Currency", + "currencyDescription": "Set the default currency for transactions", + "transactionsHistoryTitle": "Transactions History", + "balance": "Balance", + "income": "Income", + "expense": "Expense", + "amount": "Amount", + "vendor": "Vendor", + "category": "Category", + "date": "Date", + "requiredField": "Required field", + "invalidNumber": "Invalid number", + "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", + "categoryType": "Category type", + "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", + "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 new file mode 100644 index 0000000..683dd7b --- /dev/null +++ b/lib/l10n/app_localizations.dart @@ -0,0 +1,476 @@ +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; + + /// No description provided for @tag. + /// + /// In en, this message translates to: + /// **'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: + /// **'SMS Messages'** + String get smsPageTitle; + + /// No description provided for @smsPermissionDenied. + /// + /// 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; + + /// 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; + + /// 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; + + /// No description provided for @smsSettings. + /// + /// 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 + 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..c2d8a21 --- /dev/null +++ b/lib/l10n/app_localizations_en.dart @@ -0,0 +1,184 @@ +// 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'; + + @override + String get tag => 'Tag'; + + @override + String get icon => 'Icon'; + + @override + String get smsPageTitle => 'SMS Messages'; + + @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'; + + @override + String get categoryType => 'Category type'; + + @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'; + + @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 new file mode 100644 index 0000000..887f834 --- /dev/null +++ b/lib/l10n/app_localizations_ru.dart @@ -0,0 +1,187 @@ +// 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 => 'Сохранить'; + + @override + String get tag => 'Тег'; + + @override + String get icon => 'Иконка'; + + @override + String get smsPageTitle => 'SMS Сообщения'; + + @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 => 'Поиск по анлгийскому наименованию'; + + @override + String get categoryType => 'Тип категории'; + + @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-сообщения для автоматического создания транзакций'; + + @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 new file mode 100644 index 0000000..ec4fcd8 --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,60 @@ +{ + "@@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": "Пользователь по умолчанию", + "currencySetting": "Валюта по умолчанию", + "currencyDescription": "Установить валюту по умолчанию для транзакций", + "transactionsHistoryTitle": "История транзакций", + "balance": "Баланс", + "income": "Доходы", + "expense": "Расходы", + "amount": "Сумма", + "vendor": "Название", + "category": "Категория", + "date": "Дата", + "requiredField": "Обязательное поле", + "invalidNumber": "Неверный формат числа", + "cancel": "Отмена", + "save": "Сохранить", + "tag": "Тег", + "icon": "Иконка", + "smsPageTitle": "SMS Сообщения", + "smsPermissionDenied": "Необходимо разрешение на чтение SMS", + "editCategories": "Редактировать категории", + "editCategoriesDescription": "Добавляйте, редактируйте или удаляйте категории", + "color": "Цвет", + "chooseIcon": "Выберите иконку", + "chooseIconHint": "Поиск по анлгийскому наименованию", + "categoryType": "Тип категории", + "addCategory": "Добавить категорию", + "editTags": "Редактировать теги", + "editTagsDescription": "Добавляйте, редактируйте или удаляйте теги", + "addTag": "Добавить тег", + "editTag": "Редактировать тег", + "loadSmsMessages": "Загрузить SMS", + "loadSmsMessagesDescription": "Загрузить и обработать SMS-сообщения для автоматического создания транзакций", + "smsSettings": "Настройка обработки", + "createTransaction": "Создать транзакцию", + "unknownSender": "Неизвестный отправитель", + "smsProcessed": "Обработано", + "smsNotProcessed": "Не обработано" +} 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..e8ad580 --- /dev/null +++ b/lib/logic/auth/auth_bloc.dart @@ -0,0 +1,95 @@ +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; +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, + 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 { + 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())); + } + } + + void _onAuthLoggedIn(AuthLoggedIn event, Emitter emit) async { + // Инициализируем зависимости для вошедшего пользователя. + 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 new file mode 100644 index 0000000..be23680 --- /dev/null +++ b/lib/logic/auth/auth_event.dart @@ -0,0 +1,32 @@ +part of 'auth_bloc.dart'; + +abstract class AuthEvent extends Equatable { + const AuthEvent(); + + @override + List get props => []; +} + +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 {} + +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 new file mode 100644 index 0000000..22de17c --- /dev/null +++ b/lib/logic/auth/auth_state.dart @@ -0,0 +1,33 @@ +part of 'auth_bloc.dart'; + +abstract class AuthState extends Equatable { + const AuthState(); + + @override + List get props => []; +} + +class AuthInitial extends AuthState {} + +class AuthLoading extends AuthState {} + +class AuthAuthenticated extends AuthState { + final User user; + + const AuthAuthenticated({required this.user}); + + @override + 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/category/category_cubit.dart b/lib/logic/category/category_cubit.dart new file mode 100644 index 0000000..c2fab28 --- /dev/null +++ b/lib/logic/category/category_cubit.dart @@ -0,0 +1,61 @@ + +import 'package:equatable/equatable.dart'; // equatable для сравнения объектов +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../data/repositories/interfaces/icategory_repository.dart'; +import '../../models/category.dart'; + +part 'category_state.dart'; // Используем part для разделения файла + +/// Cubit для управления категориями +/// Изменения: +/// - Добавлено хранение userId в cubit +/// - Упрощена работа с состояниями по аналогии с TagCubit +class CategoryCubit extends Cubit { + final ICategoryRepository repository; + + CategoryCubit(this.repository) : super(CategoryInitial()); + + /// Загружает категории для текущего пользователя + Future loadCategories() async { + emit(CategoryLoading()); + try { + final categories = await repository.getAll(); + emit(CategoryLoaded(categories)); + } catch (e) { + emit(CategoryError('Ошибка загрузки категорий: $e')); + } + } + + /// Добавляет новую категорию + Future addCategory(Category category) async { + try { + // Устанавливаем userId для категории из cubit + final newCategory = category.copyWith(); + await repository.add(newCategory); + // Перезагружаем список категорий + await loadCategories(); + } catch (e) { + emit(CategoryError('Ошибка добавления категории: $e')); + } + } + + /// Обновляет существующую категорию + Future updateCategory(Category category) async { + try { + await repository.update(category); + await loadCategories(); + } catch (e) { + emit(CategoryError('Ошибка обновления категории: $e')); + } + } + + /// Удаляет категорию по её id + Future deleteCategory(String id) async { + try { + await repository.delete(id); + await loadCategories(); + } catch (e) { + emit(CategoryError('Ошибка удаления категории: $e')); + } + } +} 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 new file mode 100644 index 0000000..f4891b3 --- /dev/null +++ b/lib/logic/settings/settings_cubit.dart @@ -0,0 +1,76 @@ +import 'dart:async'; +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.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 ISettingsRepository _settingsRepository; + + SettingsCubit(this._settingsRepository) : super(SettingsInitial()); + + Future loadSettings() async { + emit(SettingsLoading()); + try { + final settings = await _settingsRepository.getSettings(); + emit(SettingsLoaded( + isDarkMode: settings.isDarkMode, + languageCode: settings.languageCode, + defaultCurrency: settings.defaultCurrency, + )); + } catch (e) { + emit(SettingsError(e.toString())); + } + } + + 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); + } catch (e) { + emit(SettingsError(e.toString())); + } + } + } + + 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); + } catch (e) { + emit(SettingsError(e.toString())); + } + } + } + + 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); + } catch (e) { + emit(SettingsError(e.toString())); + } + } + } + + Future _saveSettings(SettingsLoaded settings) async { + final appSettings = AppSettings( + 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 new file mode 100644 index 0000000..fffc314 --- /dev/null +++ b/lib/logic/settings/settings_state.dart @@ -0,0 +1,51 @@ +part of 'settings_cubit.dart'; + +@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; + + const SettingsLoaded({ + required this.isDarkMode, + required this.languageCode, + required this.defaultCurrency, + }); + + @override + List get props => [isDarkMode, languageCode, defaultCurrency]; + + SettingsLoaded copyWith({ + bool? isDarkMode, + String? languageCode, + String? defaultCurrency, + }) { + return SettingsLoaded( + isDarkMode: isDarkMode ?? this.isDarkMode, + languageCode: languageCode ?? this.languageCode, + defaultCurrency: defaultCurrency ?? this.defaultCurrency, + ); + } +} + +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 new file mode 100644 index 0000000..264d63f --- /dev/null +++ b/lib/logic/sms/sms_cubit.dart @@ -0,0 +1,122 @@ +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/logic/user/user_cubit.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 UserCubit _userCubit; + + SmsCubit( + this._smsService, + this._smsRepository, + this._userCubit + ) : 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())); + } + } + + // Комментарий: Метод для загрузки и сохранения SMS-сообщений. + Future loadSmsMessages() async { + // Комментарий: Устанавливаем состояние загрузки, чтобы UI мог отобразить индикатор. + emit(SmsLoading()); + try { + // Комментарий: Запрашиваем разрешение на чтение SMS. + final hasPermissions = await _smsService.requestPermissions(); + if (hasPermissions) { + // Комментарий: Получаем текущего пользователя из состояния UserCubit. + final userState = _userCubit.state; + if (userState is UserLoaded) { + final user = userState.user; + + // Комментарий: Получаем все SMS-сообщения с момента последней синхронизации. + final messages = await _smsService.getSmsMessagesSince(user!.lastSmsSyncTime); + + // Комментарий: Сохраняем новые сообщения через репозиторий и создаем транзакции. + await _smsRepository.addAll(messages); + for (final message in messages) { + // Комментарий: Пытаемся создать транзакцию из SMS. + _createTransactionFromSms(message); + } + + // Комментарий: Устанавливаем состояние успешной загрузки. + emit(SmsLoaded(messages)); + } else { + emit(SmsError("User not loaded")); + } + } 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 new file mode 100644 index 0000000..e885d6c --- /dev/null +++ b/lib/logic/sms/sms_state.dart @@ -0,0 +1,39 @@ +import 'package:budget_app/models/sms_message.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/logic/tag/tag_cubit.dart b/lib/logic/tag/tag_cubit.dart new file mode 100644 index 0000000..13eedbb --- /dev/null +++ b/lib/logic/tag/tag_cubit.dart @@ -0,0 +1,52 @@ + +import 'package:flutter_bloc/flutter_bloc.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 ITagRepository _repository; + + TagCubit(this._repository) : super(TagInitial()); + + // Загрузка тегов + Future loadTags() async { + emit(TagLoading()); + try { + final tags = await _repository.getAll(); + emit(TagLoaded(tags)); + } catch (e) { + emit(TagError('Ошибка загрузки тегов: ${e.toString()}')); + } + } + + // Добавление тега + Future addTag(Tag tag) async { + try { + await _repository.add(tag); + await loadTags(); // Перезагружаем список + } catch (e) { + emit(TagError('Ошибка добавления тега: ${e.toString()}')); + } + } + + // Обновление тега + Future updateTag(Tag tag) async { + try { + await _repository.update(tag); + await loadTags(); // Перезагружаем список + } catch (e) { + emit(TagError('Ошибка обновления тега: ${e.toString()}')); + } + } + + // Удаление тега + 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/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..e499bd8 --- /dev/null +++ b/lib/logic/transaction/transaction_bloc.dart @@ -0,0 +1,66 @@ +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.getAll(); + 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.getAll(); + 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.getAll(); + 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) { + await _transactionRepository.delete(event.transactionId); + final transactions = await _transactionRepository.getAll(); + 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/logic/user/user_cubit.dart b/lib/logic/user/user_cubit.dart new file mode 100644 index 0000000..3a8d33c --- /dev/null +++ b/lib/logic/user/user_cubit.dart @@ -0,0 +1,120 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.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 для управления состоянием пользователя. +/// Больше не управляет процессом входа, а только хранит состояние пользователя. +class UserCubit extends Cubit { + final IGlobalSettingsRepository _settingsRepository; + final IUserRepository _userRepository; + // Эти репозитории теперь могут быть null и устанавливаются после входа пользователя. + ICategoryRepository? _categoryRepository; + ITagRepository? _tagRepository; + ITransactionRepository? _transactionRepository; + final Logger _logger = Logger( + printer: PrettyPrinter( + 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 + printEmojis: true, // Print an emoji for each log message + // Should each log print contain a timestamp + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, + ), + ); + + UserCubit({ + required IGlobalSettingsRepository settingsRepository, + required IUserRepository userRepository, + // Репозитории сделаны опциональными в конструкторе. + 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; + + 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: 'Создание категорий...')); + // Используем '!', так как мы уже проверили на null. + await _categoryRepository!.addAll( + CategoryUtils.getDefaultCategories(), + ); + + emit(UserLoading(progress: 0.8, message: 'Создание тегов...')); + await _tagRepository!.addAll(TagUtils.getDefaultTags()); + + emit(UserLoading(progress: 0.9, message: 'Создание транзакций...')); + await _transactionRepository!.addAll( + TransactionUtils.getSampleTransactions(), + ); + } catch (e, stack) { + + _logger.e( + 'Error creating initial data for user: $userId', + error: e, + stackTrace: stack, + ); + } + } + + /// Устанавливает текущего пользователя (прямая установка без загрузки) + void setUser(User user) { + emit(UserLoaded(user)); + } + + /// Выход из системы + 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> 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..ca52ce9 --- /dev/null +++ b/lib/logic/user/user_state.dart @@ -0,0 +1,37 @@ +part of 'user_cubit.dart'; + +/// Состояния управления пользователем +@immutable +abstract class UserState { + const UserState(); +} + +/// Начальное состояние (инициализация) +class UserInitial extends UserState { + const UserInitial(); +} + +/// Состояние загрузки данных с прогрессом и сообщением +class UserLoading extends UserState { + final double progress; + final String message; + + const UserLoading({ + this.progress = 0.0, + this.message = '', + }); +} + +/// Состояние успешной загрузки пользователя +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 4e0ab91..54cd407 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,57 +1,102 @@ +import 'package:budget_app/pages/home/home_page.dart'; import 'package:flutter/material.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:flutter_bloc/flutter_bloc.dart'; +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 +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'; + void main() async { WidgetsFlutterBinding.ensureInitialized(); - await di.initDependencies(); + await di.initGlobalDependencies(); runApp(const MyApp()); } -/// Главный виджет приложения -/// -/// Управляет: -/// - Состоянием темы (темная/светлая) -/// - Конфигурацией MaterialApp -class MyApp extends StatefulWidget { +class MyApp extends StatelessWidget { const MyApp({super.key}); - @override - State createState() => _MyAppState(); -} - -class _MyAppState extends State { - late final SettingsService _settingsService; - - @override - void initState() { - super.initState(); - _settingsService = GetIt.instance(); - _settingsService.addListener(_onThemeChanged); - } - - @override - void dispose() { - _settingsService.removeListener(_onThemeChanged); - super.dispose(); - } - - void _onThemeChanged() { - 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: const HomePage(), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => GetIt.instance(), + ), + BlocProvider( + create: (context) => GetIt.instance()..add(AuthStarted()), + ), + ], + 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()), + ], + 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/models/app_settings.dart b/lib/models/app_settings.dart new file mode 100644 index 0000000..d01c438 --- /dev/null +++ b/lib/models/app_settings.dart @@ -0,0 +1,80 @@ +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 { + @HiveField(0) + final String id; + + /// Код языка интерфейса (например, 'ru', 'en') + @HiveField(1) + final String languageCode; + + /// Режим темной темы + @HiveField(2) + final bool isDarkMode; + + /// Валюта по умолчанию + @HiveField(3) + final String defaultCurrency; + + /// Дата последнего обновления настроек + @HiveField(4) + final DateTime updatedAt; + + AppSettings({ + String? id, + this.languageCode = 'ru', + this.isDarkMode = false, + this.defaultCurrency = 'RUB', + DateTime? updatedAt, + }) : id = id ?? IdGenerator.generateId(), + updatedAt = updatedAt ?? DateTime.now(); + + /// Преобразование в Map для сохранения + Map toMap() { + return { + 'languageCode': languageCode, + 'isDarkMode': isDarkMode, + 'defaultCurrency': defaultCurrency, + 'updatedAt': updatedAt.toIso8601String(), + }; + } + + /// Создание из Map + factory AppSettings.fromMap(Map map) { + return AppSettings( + 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( + languageCode: languageCode ?? this.languageCode, + isDarkMode: isDarkMode ?? this.isDarkMode, + defaultCurrency: defaultCurrency ?? this.defaultCurrency, + updatedAt: DateTime.now(), + ); + } + + @override + List get props => [id]; + + @override + String toString() { + return 'AppSettings(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..3387a9b --- /dev/null +++ b/lib/models/app_settings.g.dart @@ -0,0 +1,50 @@ +// 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( + 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(4) + ..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/category.dart b/lib/models/category.dart index b308c88..c4e88bb 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; @@ -31,10 +32,9 @@ class Category { /// false - расход (например покупки) final bool isIncome; - @HiveField(5) - /// Идентификатор пользователя, которому принадлежит эта категория - /// Это позволяет разделять категории между разными пользователями - final String userId; + @HiveField(6) + /// Дата и время последнего обновления объекта + final DateTime updatedAt; /// Конструктор с обязательными параметрами Category({ @@ -43,18 +43,19 @@ class Category { required this.color, 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() { return { 'id': id, 'name': name, - 'color': color.value, // Сохраняем только значение цвета + 'color': color.toARGB32(), // Сохраняем только значение цвета 'icon': icon.codePoint, 'isIncome': isIncome, - 'userId': userId, // Добавляем userId в Map + 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map }; } @@ -66,7 +67,31 @@ class Category { 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 ); } + + /// Метод для создания копии объекта с возможностью изменения полей + 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, + updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании + ); + } + + // Используем Equatable для сравнения объектов по их свойствам. + // В данном случае, мы считаем категории уникальными по их 'id'. + @override + List get props => [id]; } diff --git a/lib/models/category.g.dart b/lib/models/category.g.dart index 6e7f75e..790cc54 100644 --- a/lib/models/category.g.dart +++ b/lib/models/category.g.dart @@ -22,7 +22,7 @@ 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?, ); } @@ -40,8 +40,8 @@ class CategoryAdapter extends TypeAdapter { ..write(obj.icon) ..writeByte(4) ..write(obj.isIncome) - ..writeByte(5) - ..write(obj.userId); + ..writeByte(6) + ..write(obj.updatedAt); } @override 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/sms_handler_settings.dart b/lib/models/sms_handler_settings.dart new file mode 100644 index 0000000..3b4c934 --- /dev/null +++ b/lib/models/sms_handler_settings.dart @@ -0,0 +1,65 @@ + +import 'package:hive_ce/hive.dart'; + +import '/utils/id_generator.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; + + @HiveField(3) + final String id; + + SmsProcessingRule({ + String? id, + 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.', + ), id = id ?? IdGenerator.generateId(); +} + +/// Модель для хранения всех настроек обработки СМС для одного пользователя. +@HiveType(typeId: 12) +class SmsHandlerSettings extends HiveObject { + + @HiveField(0) + final String id; + /// Карта правил обработки, где ключ - это идентификатор отправителя (например, 'SBERBANK' или номер телефона), + /// а значение - правило обработки для этого отправителя. + @HiveField(1) + final Map rulesBySender; + + SmsHandlerSettings({ + 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 new file mode 100644 index 0000000..085c470 --- /dev/null +++ b/lib/models/sms_handler_settings.g.dart @@ -0,0 +1,118 @@ +// 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( + rulesBySender: (fields[1] as Map).cast(), + ); + } + + @override + void write(BinaryWriter writer, SmsHandlerSettings obj) { + writer + ..writeByte(1) + ..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; +} diff --git a/lib/models/sms_message.dart b/lib/models/sms_message.dart new file mode 100644 index 0000000..75b923f --- /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, String? userId}) { + 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/tag.dart b/lib/models/tag.dart index bb0dfc8..c584f25 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; @@ -13,11 +14,9 @@ class Tag { /// Название тега (например: "Важное", "Работа") final String name; - // Комментарий: Добавляем новое поле для хранения идентификатора пользователя. - // Это позволит нам связать каждый тег с конкретным пользователем. - // Мы используем аннотацию @HiveField(2), чтобы указать Hive, как сохранять это поле. - @HiveField(2) - final String userId; + @HiveField(3) + /// Дата и время последнего обновления объекта + final DateTime updatedAt; /// Конструктор с обязательными параметрами Tag({ @@ -25,8 +24,9 @@ class Tag { required this.name, // Комментарий: Добавляем userId в конструктор как обязательный параметр. // Теперь при создании тега необходимо будет указать, какому пользователю он принадлежит. - required this.userId, - }) : id = id ?? IdGenerator.generateId(); + DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное + }) : id = id ?? IdGenerator.generateId(), + updatedAt = updatedAt ?? DateTime.now(); // Устанавливаем текущее время по умолчанию /// Преобразование объекта в Map Map toMap() { @@ -35,7 +35,7 @@ class Tag { 'name': name, // Комментарий: Добавляем userId в Map. Это нужно для сохранения // данных в форматах, которые не работают напрямую с объектами Dart (например, при отправке на сервер). - 'userId': userId, + 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map }; } @@ -45,8 +45,26 @@ class Tag { id: map['id'], name: map['name'], // Комментарий: Извлекаем userId из Map при создании объекта. - // Это позволяет восстановить полный объект Tag из данных, например, из базы данных. - userId: map['userId'], + // Это позволит восстановить полный объект Tag из данных, например, из базы данных. + 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, + updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании + ); + } + + // Используем Equatable для сравнения объектов по их свойствам. + // В данном случае, мы считаем теги уникальными по их 'id'. + @override + List get props => [id]; } diff --git a/lib/models/tag.g.dart b/lib/models/tag.g.dart index f2d5e60..98f587a 100644 --- a/lib/models/tag.g.dart +++ b/lib/models/tag.g.dart @@ -19,7 +19,7 @@ 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?, ); } @@ -31,8 +31,8 @@ class TagAdapter extends TypeAdapter { ..write(obj.id) ..writeByte(1) ..write(obj.name) - ..writeByte(2) - ..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..d292451 100644 --- a/lib/models/transaction_record.dart +++ b/lib/models/transaction_record.dart @@ -1,5 +1,6 @@ -import 'package:flutter/material.dart'; +import 'package:equatable/equatable.dart'; import 'package:hive_ce/hive.dart'; + import '../../utils/id_generator.dart'; import 'category.dart'; import 'tag.dart'; @@ -9,8 +10,7 @@ part 'transaction_record.g.dart'; @HiveType(typeId: 1002) /// Модель записи о транзакции - основной элемент учета бюджета /// Содержит все детали финансовой операции -class TransactionRecord { - +class TransactionRecord extends Equatable { /// Уникальный идентификатор транзакции @HiveField(0) final String id; @@ -39,9 +39,9 @@ class TransactionRecord { /// Валюта операции (код валюты, например "RUB", "USD") final String currency; - // Добавляем новое поле для хранения идентификатора пользователя - @HiveField(7) // Используем следующий доступный номер поля Hive - final String userId; + @HiveField(8) + /// Дата и время последнего обновления объекта + final DateTime updatedAt; /// Конструктор с обязательными параметрами TransactionRecord({ @@ -52,8 +52,11 @@ class TransactionRecord { required this.dateTime, 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() { @@ -65,7 +68,7 @@ class TransactionRecord { 'dateTime': dateTime.toIso8601String(), 'vendor': vendor, 'currency': currency, - 'userId': userId, // Добавляем userId в Map + 'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map }; } @@ -79,11 +82,41 @@ class TransactionRecord { dateTime: DateTime.parse(map['dateTime']), 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, + updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании + ); + } + + // Используем Equatable для сравнения объектов по их свойствам. + // В данном случае, мы считаем записи транзакций уникальными по их 'id'. + @override + List get props => [id]; } diff --git a/lib/models/transaction_record.g.dart b/lib/models/transaction_record.g.dart index c4c70e1..03956e2 100644 --- a/lib/models/transaction_record.g.dart +++ b/lib/models/transaction_record.g.dart @@ -24,7 +24,7 @@ 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?, ); } @@ -46,8 +46,8 @@ class TransactionRecordAdapter extends TypeAdapter { ..write(obj.vendor) ..writeByte(6) ..write(obj.currency) - ..writeByte(7) - ..write(obj.userId); + ..writeByte(8) + ..write(obj.updatedAt); } @override diff --git a/lib/models/user.dart b/lib/models/user.dart index 6337552..5cd5373 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -1,55 +1,71 @@ +import 'package:equatable/equatable.dart'; 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) /// Модель пользователя приложения /// Содержит основную информацию для идентификации пользователя -class User { +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) + /// Дата и время последнего обновления объекта + final DateTime updatedAt; + + // Комментарий: Добавлено поле для хранения времени последней синхронизации SMS. + @HiveField(4) + final DateTime lastSmsSyncTime; + /// Конструктор пользователя /// id генерируется автоматически, если не передан User({ - String? id, // Опциональный параметр - если null, сгенерируется автоматически - required this.name, // Обязательный параметр - required this.email, // Обязательный параметр - }) : id = id ?? IdGenerator.generateId(); // Если id не передан, генерируем новый + String? id, + required this.name, + required this.email, + DateTime? updatedAt, + DateTime? lastSmsSyncTime, + }) : id = id ?? IdGenerator.generateId(), + updatedAt = updatedAt ?? DateTime.now(), + lastSmsSyncTime = lastSmsSyncTime ?? + DateTime(DateTime.now().year, DateTime.now().month - 1, 1); - /// Преобразование объекта в Map для сохранения в JSON или передачи по сети - Map toMap() { - return { - 'id': id, - 'name': name, - 'email': email, - }; - } - - /// Создание объекта User из Map (например, при загрузке из JSON) - factory User.fromMap(Map map) { - return User( - id: map['id'], - name: map['name'], - email: map['email'], - ); - } /// Переопределяем toString для удобного отображения в логах @override String toString() { - return 'User(id: $id, name: $name, email: $email)'; + return 'User(id: $id, name: $name, email: $email, lastSmsSyncTime: $lastSmsSyncTime)'; + } + + // Используем Equatable для сравнения объектов по их свойствам. + @override + List get props => [id]; + + User copyWith({ + String? id, + String? name, + String? email, + DateTime? lastSmsSyncTime, + }) { + return User( + id: id ?? this.id, + name: name ?? this.name, + email: email ?? this.email, + updatedAt: DateTime.now(), + lastSmsSyncTime: lastSmsSyncTime ?? this.lastSmsSyncTime, + ); } } diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index cea03e5..9ac4782 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -20,19 +20,25 @@ class UserAdapter extends TypeAdapter { id: fields[0] as String?, name: fields[1] as String, email: fields[2] as String, + updatedAt: fields[3] as DateTime?, + lastSmsSyncTime: fields[4] as DateTime?, ); } @override void write(BinaryWriter writer, User obj) { writer - ..writeByte(3) + ..writeByte(5) ..writeByte(0) ..write(obj.id) ..writeByte(1) ..write(obj.name) ..writeByte(2) - ..write(obj.email); + ..write(obj.email) + ..writeByte(3) + ..write(obj.updatedAt) + ..writeByte(4) + ..write(obj.lastSmsSyncTime); } @override diff --git a/lib/pages/category/category_edit_page.dart b/lib/pages/category/category_edit_page.dart new file mode 100644 index 0000000..a7e451a --- /dev/null +++ b/lib/pages/category/category_edit_page.dart @@ -0,0 +1,202 @@ +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'; +import 'package:flutter_iconpicker/flutter_iconpicker.dart'; + +class CategoryEditPage extends StatefulWidget { + final Category? category; + final Function(String, Color, IconData, bool) 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; + late bool _isIncome; // Добавляем состояние для типа категории (доход/расход) + + @override + void initState() { + super.initState(); + _name = widget.category?.name ?? ''; + // Генерация случайного цвета из всей палитры для новой категории + _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 + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar( + title: Text( + widget.category == null + ? localizations.addCategory + : 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.icon), + 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), + // Добавляем выбор типа категории (доход/расход) + 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(); + // Добавляем параметр _isIncome в вызов onSave + widget.onSave(_name, _color, _icon, _isIncome); + 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..836af11 --- /dev/null +++ b/lib/pages/category/category_list_page.dart @@ -0,0 +1,102 @@ +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/add_category_button.dart'; +import 'package:budget_app/pages/category/widgets/category_list_item.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; + +class CategoryListPage extends StatefulWidget { + const CategoryListPage({super.key}); + + @override + State createState() => _CategoryListPageState(); +} + +class _CategoryListPageState extends State { + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => GetIt.instance()..loadCategories(), + child: Builder(builder: (context) { + final localizations = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar(title: Text(localizations.editCategories)), + 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), + ); + }, + ); + } + // CategoryInitial + return const Center(child: Text('Нет категорий')); + }, + ), + floatingActionButton: AddCategoryButton( + 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, + ); + + context.read().addCategory(newCategory); + }, + ), + ), + ); + } + + 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) { + context.read().deleteCategory(category.id); + } +} 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..b966fb2 --- /dev/null +++ b/lib/pages/category/widgets/add_category_button.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.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..2ada43a --- /dev/null +++ b/lib/pages/category/widgets/category_actions.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.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..cb49974 --- /dev/null +++ b/lib/pages/category/widgets/category_list_item.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:budget_app/models/category.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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart new file mode 100644 index 0000000..e67a27b --- /dev/null +++ b/lib/pages/home/home_page.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../theme/custom_colors.dart'; + +import '/l10n/app_localizations.dart'; +import '../../logic/settings/settings_cubit.dart'; +import '../../logic/transaction/transaction_bloc.dart'; +import '../../logic/user/user_cubit.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'; // Импортируем новый виджет для элемента транзакции + +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 SmsPage(), // Страница SMS + const SettingsPage(), // Страница настроек + ]; + + @override + void initState() { + super.initState(); + // Получаем 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)); + } + } + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of( + context, + )!; // Получаем экземпляр локализации + + 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.sms), + label: localizations.smsPageTitle, + ), + BottomNavigationBarItem( + icon: const Icon(Icons.settings), + label: localizations.settingsPageTitle, // Локализованный текст + ), + ], + currentIndex: _selectedIndex, + selectedItemColor: Theme.of(context).colorScheme.primary, + unselectedItemColor: Theme.of(context).extension()?.unselectedIcon, + onTap: _onItemTapped, + ), + ); + } +} + +// Изменение: 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)!; + + return BlocBuilder( + builder: (context, state) { + if (state is TransactionLoading) { + return const Center(child: CircularProgressIndicator()); + } else if (state is TransactionLoaded) { + // Добавление: Фильтрация транзакций по выбранному месяцу. + final monthlyTransactions = state.transactions.where((t) { + return t.dateTime.month == _selectedMonth.month && t.dateTime.year == _selectedMonth.year; + }).toList(); + + return ListView( + children: [ + // Изменение: SummaryWidget теперь получает все транзакции, + // выбранный месяц и колбэк для его изменения. + 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: 'RUB', + ); + }, + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + localizations.transactionsHistoryTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + // Изменение: Отображаем отфильтрованный список транзакций. + if (monthlyTransactions.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(localizations.noTransactionsText), + ), + ) + else + 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: monthlyTransactions.length, + itemBuilder: (context, index) { + final transaction = monthlyTransactions[index]; + return TransactionItem(transaction: transaction); + }, + ), + ), + ], + ); + } 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/home/widgets/add_transaction_dialog.dart b/lib/pages/home/widgets/add_transaction_dialog.dart new file mode 100644 index 0000000..2f68efa --- /dev/null +++ b/lib/pages/home/widgets/add_transaction_dialog.dart @@ -0,0 +1,278 @@ +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'; + +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 '../../../models/category.dart'; +import '../../../models/tag.dart'; +import '../../../models/transaction_record.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; + // Комментарий: Добавляем состояние для выбранного тега. + Tag? _selectedTag; + // Комментарий: Заменяем _selectedDate на _selectedDateTime для хранения даты и времени. + DateTime _selectedDateTime = DateTime.now(); + + @override + void initState() { + super.initState(); + // Комментарий: Устанавливаем начальное значение с датой и временем. + _dateController.text = DateFormat('dd-MM-yyyy').add_Hm().format(_selectedDateTime); + } + + @override + void dispose() { + _amountController.dispose(); + _vendorController.dispose(); + _dateController.dispose(); + super.dispose(); + } + + // Комментарий: Этот метод теперь обрабатывает выбор и даты, и времени. + Future _selectDateTime(BuildContext context) async { + final DateTime? pickedDate = await showDatePicker( + context: context, + initialDate: _selectedDateTime, + firstDate: DateTime(2000), + lastDate: DateTime(2101), + ); + // Комментарий: Если пользователь не выбрал дату, выходим из функции. + 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('dd-MM-yyyy').add_Hm().format(_selectedDateTime); + }); + } + + 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 settingsState = context.read().state; + final currency = (settingsState is SettingsLoaded) + ? settingsState.defaultCurrency + : 'RUB'; + + // Получаем ID текущего пользователя из UserCubit + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.transactionErrorText('User not found'), + ), + ), + ); + + final newTransaction = TransactionRecord( + amount: amount, + vendor: _vendorController.text, + category: _selectedCategory!, + dateTime: _selectedDateTime, + tag: _selectedTag, + currency: currency, + ); + + context.read().add( + AddTransaction(transaction: newTransaction), + ); + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + + + final categories = CategoryUtils.getDefaultCategories() + .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: + const TextInputType.numberWithOptions(decimal: true), + // Комментарий: Добавляем фильтр для ввода только чисел и одной точки. + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], + 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, + ), + // Комментарий: Добавляем выпадающий список для выбора тега. + // Он будет загружать теги асинхронно для текущего пользователя. + FutureBuilder>( + future: GetIt.instance().getAll(), + 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: () => _selectDateTime(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/pages/home/widgets/summary_widget.dart b/lib/pages/home/widgets/summary_widget.dart new file mode 100644 index 0000000..bb65795 --- /dev/null +++ b/lib/pages/home/widgets/summary_widget.dart @@ -0,0 +1,305 @@ + +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'; + +// Изменение: Виджет преобразован в StatefulWidget для управления состоянием выбранного месяца. +class SummaryWidget extends StatefulWidget { + // Изменение: Виджет теперь принимает список всех транзакций, а не готовые суммы. + final List transactions; + final String currencySymbol; + // Добавление: Принимает выбранный месяц и колбэк для его изменения от родителя. + final DateTime selectedMonth; + final ValueChanged onMonthChanged; + + const SummaryWidget({ + super.key, + required this.transactions, + required this.currencySymbol, + required this.selectedMonth, + required this.onMonthChanged, + }); + + @override + State createState() => _SummaryWidgetState(); +} + +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), + ), + clipBehavior: Clip.antiAlias, // Обрезаем контент по границам карточки + child: SizedBox( + height: 300, + // Изменение: Используем Column для разделения PageView и индикатора. + child: Column( + children: [ + // 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); + }, + ), + ), + // Добавление: Индикатор теперь находится вне PageView. + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildPageIndicator(context), + ), + ], + ), + ), + ); + } + + // Добавление: Метод для построения одной страницы (одного месяца). + 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, + 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, + ), + ), + ], + ), + ); + } +} 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/pages/home_page.dart b/lib/pages/home_page.dart deleted file mode 100644 index ea14564..0000000 --- a/lib/pages/home_page.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/material.dart'; -import 'settings_page.dart'; - -/** - * Главная страница приложения для управления бюджетом. - * - * Этот виджет является основным экраном приложения и содержит: - * - AppBar с заголовком и кнопкой настроек - * - Основной контент с приветственным сообщением - * - * Особенности реализации: - * 1. Наследуется от StatelessWidget, так как не содержит собственного состояния - * 2. Использует Material Design через Scaffold - * 3. Управление темой вынесено в SettingsService - */ -class HomePage extends StatelessWidget { - /// Конструктор виджета - /// - /// @param key - опциональный ключ для идентификации виджета - const HomePage({super.key}); - - /** - * Основной метод построения интерфейса виджета. - * - * Возвращает Scaffold - базовую структуру страницы Material Design, - * которая включает: - * 1. AppBar (верхнюю панель) - * 2. Body (основное содержимое) - */ - @override - Widget build(BuildContext 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: 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), - ), - ], - ), - ), - ); - } -} 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..8117bb2 --- /dev/null +++ b/lib/pages/login/login_page.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '/l10n/app_localizations.dart'; +import '../../logic/auth/auth_bloc.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(); + + 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)!; + + return Scaffold( + appBar: AppBar( + title: Text(localizations.loginPageTitle), + ), + body: BlocListener( + listener: (context, state) { + if (state is AuthError) { + 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; + } + 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( + builder: (context, state) { + final isLoading = state is AuthLoading; + + return ElevatedButton( + onPressed: isLoading ? null : _login, + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : 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..dbb860d 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -1,56 +1,178 @@ +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:get_it/get_it.dart'; -import '../services/settings_service.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'; -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)!; + return Scaffold( appBar: AppBar( - title: const Text('Настройки'), + title: Text(localizations.settingsPageTitle), ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SwitchListTile( - title: const Text('Темная тема'), - subtitle: const Text('Переключить между светлой и темной темой'), - value: _settingsService.isDarkMode, - onChanged: (value) async { - await _settingsService.setDarkMode(value); - }, - ), - const Divider(), - // Здесь можно добавить другие настройки - ], + 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, userState) { + if (userState is UserLoaded && userState.user != null) { + // Комментарий: Как только пользователь загружен, мы загружаем его настройки. + context.read().loadSettings(); + return BlocBuilder( + builder: (context, state) { + if (state is SettingsLoaded) { + return Padding( + padding: const EdgeInsets.all(16.0), + // Комментарий: Обернули 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); + }, + ), + 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: ['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) { + + 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 { + return const Center(child: CircularProgressIndicator()); + } + }, + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, ), ), ); 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..5ba9357 --- /dev/null +++ b/lib/pages/sms/widgets/sms_message_widget.dart @@ -0,0 +1,115 @@ +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 сообщения с улучшенным интерфейсом. +/// +/// Отображает отправителя, дату, тело сообщения и статус обработки. +/// Подсвечивает суммы в тексте сообщения. +class SmsMessageWidget extends StatelessWidget { + final SmsMessage message; + + const SmsMessageWidget({super.key, required this.message}); + + // Форматирование даты для отображения + 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), + Offset.zero & overlay.size, + ), + items: [ + PopupMenuItem( + child: Text(AppLocalizations.of(context)!.smsSettings), + onTap: () { + // TODO: Реализовать переход к настройкам обработки SMS + }, + ), + PopupMenuItem( + child: Text(AppLocalizations.of(context)!.createTransaction), + onTap: () { + // TODO: Реализовать создание транзакции из SMS + }, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + 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: 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, + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/splash/splash_screen.dart b/lib/pages/splash/splash_screen.dart new file mode 100644 index 0000000..7c46745 --- /dev/null +++ b/lib/pages/splash/splash_screen.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class SplashScreen extends StatelessWidget { + const SplashScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 20), + Text('Загрузка...'), + ], + ), + ), + ); + } +} 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..6cb2279 --- /dev/null +++ b/lib/pages/tag/tag_list_page.dart @@ -0,0 +1,111 @@ +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: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, 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), + ), + ); + }), + ); + } + + void _addTag(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => TagEditPage( + onSave: (name) { + final newTag = Tag( + name: name, + ); + 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/settings_service.dart b/lib/services/settings_service.dart deleted file mode 100644 index 67ca0ed..0000000 --- a/lib/services/settings_service.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hive_ce/hive.dart'; - -class SettingsService extends ChangeNotifier { - static const String _darkModeKey = 'darkMode'; - 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); - } -} diff --git a/lib/services/sms_service.dart b/lib/services/sms_service.dart new file mode 100644 index 0000000..1235354 --- /dev/null +++ b/lib/services/sms_service.dart @@ -0,0 +1,64 @@ +import 'package:another_telephony/telephony.dart' as telephony_package; +import 'package:budget_app/models/sms_message.dart'; + +/// Сервис для работы с SMS сообщениями. +/// +/// Использует пакет [telephony] для доступа к SMS на устройстве. +/// Предоставляет методы для запроса разрешений и получения +/// последних SMS сообщений. +class SmsService { + final telephony_package.Telephony _telephony = telephony_package.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: [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)], + ); + // Комментарий: Преобразуем сообщения из пакета 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 []; + } +} diff --git a/lib/services/user_service.dart b/lib/services/user_service.dart deleted file mode 100644 index 8b9d8f8..0000000 --- a/lib/services/user_service.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:hive_ce/hive.dart'; -import '/models/user.dart'; -import '/data/repositories/interfaces/iuser_repository.dart'; - -/// Сервис для управления текущим пользователем приложения -/// Использует ChangeNotifier для уведомления UI об изменениях -class UserService extends ChangeNotifier { - // Ключ для сохранения ID текущего пользователя в настройках - static const String _currentUserKey = 'currentUserId'; - - late final Box _settingsBox; // Box для хранения настроек - final IUserRepository _userRepository; // Репозиторий для работы с пользователями - - User? _currentUser; // Текущий активный пользователь - - /// Геттер для получения текущего пользователя - User? get currentUser => _currentUser; - - /// Проверяем, есть ли активный пользователь - bool get hasCurrentUser => _currentUser != null; - - /// Конструктор сервиса - UserService(this._userRepository) { - _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); - await _userRepository.add(user); - 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 087b776..6a547bc 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, // Фон поверхностей - background: Colors.grey[50]!, // Общий фон - очень светлый серый ), appBarTheme: const AppBarTheme( backgroundColor: Colors.white, // Белый фон AppBar @@ -36,6 +37,22 @@ class AppTheme { ), ), ), + // Комментарий: Добавляем тему для выпадающих меню, чтобы цвет соответствовал фону. + dropdownMenuTheme: DropdownMenuThemeData( + menuStyle: MenuStyle( + backgroundColor: WidgetStateProperty.all(Colors.white), + ), + ), + // Изменяем цвета в соответствии с черно-белой палитрой + extensions: >[ + CustomColors( + income: Colors.grey[800]!, // Темно-серый для доходов + expense: Colors.grey[600]!, // Серый для расходов + divider: Colors.grey[300]!, // Светло-серый для разделителей + accent: Colors.black, // Черный для акцентов + unselectedIcon: Colors.grey[500]!, // Серый для невыбранных иконок + ), + ], ); } @@ -49,7 +66,6 @@ class AppTheme { primary: Colors.white, // Основной цвет - белый secondary: Colors.grey[300]!, // Вторичный цвет - светлый серый surface: Colors.grey[900]!, // Фон поверхностей - background: Colors.black, // Общий фон - черный ), appBarTheme: AppBarTheme( backgroundColor: Colors.grey[900]!, // Темно-серый фон AppBar @@ -75,6 +91,22 @@ class AppTheme { ), ), ), + // Комментарий: Добавляем тему для выпадающих меню, чтобы цвет соответствовал фону. + dropdownMenuTheme: DropdownMenuThemeData( + menuStyle: MenuStyle( + backgroundColor: WidgetStateProperty.all(Colors.grey[800]), + ), + ), + // Изменяем цвета в соответствии с черно-белой палитрой + extensions: >[ + CustomColors( + income: Colors.grey[300]!, // Светло-серый для доходов + 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 new file mode 100644 index 0000000..edb1005 --- /dev/null +++ b/lib/theme/custom_colors.dart @@ -0,0 +1,51 @@ + +import 'package:flutter/material.dart'; + +// Расширение темы для добавления пользовательских цветов. +@immutable +class CustomColors extends ThemeExtension { + const CustomColors({ + required this.income, + 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({ + Color? income, + 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, + ); + } + + @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), + divider: Color.lerp(divider, other.divider, t), + accent: Color.lerp(accent, other.accent, t), + unselectedIcon: Color.lerp(unselectedIcon, other.unselectedIcon, t), + ); + } +} diff --git a/lib/utils/category_utils.dart b/lib/utils/category_utils.dart index 5725479..4554de1 100644 --- a/lib/utils/category_utils.dart +++ b/lib/utils/category_utils.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; + import '../models/category.dart'; -import '../services/user_service.dart'; /// Утилиты для работы с категориями 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 43b7af7..0b80982 100644 --- a/lib/utils/tag_utils.dart +++ b/lib/utils/tag_utils.dart @@ -5,34 +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 b5c8872..ed277ba 100644 --- a/lib/utils/transaction_utils.dart +++ b/lib/utils/transaction_utils.dart @@ -1,17 +1,14 @@ import '../models/transaction_record.dart'; -import '../models/category.dart'; -import '../models/tag.dart'; import 'category_utils.dart'; 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 [ // Доходы @@ -22,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'), @@ -31,7 +27,6 @@ class TransactionUtils { dateTime: DateTime.now().subtract(const Duration(days: 2)), vendor: 'Фриланс проект', currency: 'RUB', - userId: userId, // Указываем userId для тестовой транзакции ), // Расходы @@ -42,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'), @@ -51,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'), @@ -60,7 +53,6 @@ class TransactionUtils { dateTime: DateTime.now().subtract(const Duration(hours: 6)), vendor: 'Кинотеатр', currency: 'RUB', - userId: userId, // Указываем userId для тестовой транзакции ), ]; } diff --git a/pubspec.lock b/pubspec.lock index e07ff53..a67c496 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,42 @@ 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" + animated_digit: + dependency: "direct main" + description: + name: animated_digit + sha256: "22300a550b83e08ac4a0ef9c6fc7e800bbebc34978d997e6346da03d69fbb7a8" + 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" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" args: dependency: transitive description: @@ -33,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + bloc: + dependency: "direct main" + description: + name: bloc + sha256: "52c10575f4445c61dd9e0cafcc6356fdd827c4c64dd7945ef3c4105f6b6ac189" + url: "https://pub.dev" + source: hosted + version: "9.0.0" boolean_selector: dependency: transitive description: @@ -45,10 +77,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: @@ -69,26 +101,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: @@ -121,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: @@ -161,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: @@ -169,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: @@ -177,6 +241,46 @@ 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: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" fake_async: dependency: transitive description: @@ -214,6 +318,30 @@ 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_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: @@ -222,11 +350,24 @@ 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 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: @@ -235,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: @@ -251,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: @@ -307,6 +464,22 @@ 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: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: @@ -331,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: @@ -371,14 +552,22 @@ 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: name: logger - sha256: be4b23575aac7ebf01f225a241eb7f6b5641eeaf43c6a8613510fc2f8cf187d1 + sha256: "2621da01aabaf223f8f961e751f2c943dbb374dc3559b982f200ccedadaa6999" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" logging: dependency: transitive description: @@ -419,6 +608,22 @@ 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: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" package_config: dependency: transitive description: @@ -507,6 +712,22 @@ 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: + name: provider + sha256: "4abbd070a04e9ddc287673bf5a030c7ca8b685ff70218720abab8b092f53dd84" + url: "https://pub.dev" + source: hosted + version: "6.1.5" pub_semver: dependency: transitive description: @@ -515,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: @@ -523,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: @@ -568,6 +829,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: @@ -600,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: @@ -632,14 +925,30 @@ 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: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "3.0.7" + 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: @@ -660,10 +969,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: @@ -688,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 95eac71..082c402 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,23 +30,34 @@ 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. # 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 + flutter_localizations: + sdk: flutter + intl: ^0.20.2 + bloc: + flutter_colorpicker: ^1.1.0 + flutter_iconpicker: ^4.0.1 + 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 @@ -54,6 +65,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