diff --git a/.clinerules/GEMINI.md b/.clinerules/GEMINI.md new file mode 100644 index 0000000..93a84bf --- /dev/null +++ b/.clinerules/GEMINI.md @@ -0,0 +1,28 @@ +# Project: Budget App + +## General Instructions: + +- Это проект на Flutter используй только его +- Комментируй в коде каждое изменение, которое ты делаешь, что бы мне было понятно и я учился на этом. +- Комментарии и твои ответы должны быть на русском языке +- When generating new Flutter code, please follow the existing coding style. +- Цветовая палитра черно-белая +- Все настройки цветов выноси в тему +- Все элементы должны разбиваться на мелкие и иметь логичную структуру по попкам +- Весь текст должен иметь локализацию чрезе flutter_localizations. Смотри папку l10n +- Разработка ведется под windows, ты можешь исопльзовать его консольные команды + +## Coding Style: + +- Interface names should be prefixed with `I` (e.g., `IUserService`). +- Private class members should be prefixed with an underscore (`_`). +- Учитывай, что в проекте используется bloc cubit архитектура + +## Role +- You are a Flutter assistant that helps users write more efficient and optimizable Flutter code. +- You specialize in identifying patterns that enable Flutter Compiler to automatically apply optimizations, reducing unnecessary re-renders and improving application performance. + +## Follow these guidelines in all code you produce and suggest +- Prefer composition and small components: Break down UI into small, reusable components rather than writing large monolithic components. The code you generate should promote clarity and reusability by composing components together. +- Design for a good user experience - Provide clear, minimal, and non-blocking UI states. When data is loading, show lightweight placeholders (e.g., skeleton screens) rather than intrusive spinners everywhere. Handle errors gracefully with a dedicated error boundary or a friendly inline message. Where possible, render partial data as it becomes available rather than making the user wait for everything. Suspense allows you to declare the loading states in your component tree in a natural way, preventing “flash” states and improving perceived performance. +- \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f42a70e..8c41c28 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -24,7 +24,7 @@ android { applicationId = "ru.sanderrs.budget_app" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + minSdk = 23 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 865a103..49ce1fe 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,14 @@ + + + + + + + + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 1b2991f..5b31d6d 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -11,7 +11,9 @@ import 'data/repositories/interfaces/itransaction_repository.dart'; import 'data/repositories/interfaces/iuser_repository.dart'; import 'logic/auth/auth_bloc.dart'; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit +import 'logic/sms/sms_cubit.dart'; import 'logic/transaction/transaction_bloc.dart'; +import 'services/sms_service.dart'; import 'services/user_service.dart'; final getIt = GetIt.instance; @@ -40,6 +42,8 @@ Future initDependencies() async { UserService(getIt(), getIt(), getIt(), getIt()), ); + getIt.registerSingleton(SmsService()); + // Регистрация сервисов getIt.registerSingleton( SettingsCubit(getIt()), @@ -50,4 +54,6 @@ Future initDependencies() async { getIt.registerFactory( () => TransactionBloc(transactionRepository: getIt()), ); + + getIt.registerFactory(() => SmsCubit(getIt())); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b8d4664..f93bb20 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -35,5 +35,7 @@ "invalidNumber": "Invalid number", "cancel": "Cancel", "save": "Save", - "tag": "Tag" + "tag": "Tag", + "smsPageTitle": "SMS Messages", + "smsPermissionDenied": "SMS permission is required" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e13b623..1ee694e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -313,6 +313,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Tag'** String get tag; + + /// No description provided for @smsPageTitle. + /// + /// In en, this message translates to: + /// **'SMS Messages'** + String get smsPageTitle; + + /// No description provided for @smsPermissionDenied. + /// + /// In en, this message translates to: + /// **'SMS permission is required'** + String get smsPermissionDenied; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 32c89e0..db805ed 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -117,4 +117,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tag => 'Tag'; + + @override + String get smsPageTitle => 'SMS Messages'; + + @override + String get smsPermissionDenied => 'SMS permission is required'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 4e65f99..20be8f3 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -118,4 +118,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tag => 'Тег'; + + @override + String get smsPageTitle => 'SMS Сообщения'; + + @override + String get smsPermissionDenied => 'Необходимо разрешение на чтение SMS'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2cd8620..730eecb 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -35,5 +35,7 @@ "invalidNumber": "Неверный формат числа", "cancel": "Отмена", "save": "Сохранить", - "tag": "Тег" + "tag": "Тег", + "smsPageTitle": "SMS Сообщения", + "smsPermissionDenied": "Необходимо разрешение на чтение SMS" } diff --git a/lib/logic/sms/sms_cubit.dart b/lib/logic/sms/sms_cubit.dart new file mode 100644 index 0000000..50915b7 --- /dev/null +++ b/lib/logic/sms/sms_cubit.dart @@ -0,0 +1,33 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:budget_app/logic/sms/sms_state.dart'; +import 'package:budget_app/services/sms_service.dart'; + +/// Cubit для управления состоянием SMS. +/// +/// Отвечает за загрузку SMS сообщений и обработку разрешений. +class SmsCubit extends Cubit { + final SmsService _smsService; + + SmsCubit(this._smsService) : super(SmsInitial()); + + /// Загружает последние 10 SMS сообщений. + /// + /// Перед загрузкой запрашивает необходимые разрешения. + /// В случае успеха, переходит в состояние [SmsLoaded]. + /// В случае отказа в разрешениях, переходит в состояние [SmsPermissionDenied]. + /// В случае ошибки, переходит в состояние [SmsError]. + Future loadLastMessages() async { + emit(SmsLoading()); + try { + final hasPermissions = await _smsService.requestPermissions(); + if (hasPermissions) { + final messages = await _smsService.getLastSmsMessages(10); + emit(SmsLoaded(messages)); + } else { + emit(SmsPermissionDenied()); + } + } catch (e) { + emit(SmsError(e.toString())); + } + } +} diff --git a/lib/logic/sms/sms_state.dart b/lib/logic/sms/sms_state.dart new file mode 100644 index 0000000..8ae8ee2 --- /dev/null +++ b/lib/logic/sms/sms_state.dart @@ -0,0 +1,39 @@ +import 'package:another_telephony/telephony.dart'; +import 'package:equatable/equatable.dart'; + +/// Абстрактный класс для состояний SMS. +abstract class SmsState extends Equatable { + const SmsState(); + + @override + List get props => []; +} + +/// Начальное состояние. +class SmsInitial extends SmsState {} + +/// Состояние загрузки SMS. +class SmsLoading extends SmsState {} + +/// Состояние, когда SMS успешно загружены. +class SmsLoaded extends SmsState { + final List messages; + + const SmsLoaded(this.messages); + + @override + List get props => [messages]; +} + +/// Состояние, когда отказано в разрешении на чтение SMS. +class SmsPermissionDenied extends SmsState {} + +/// Состояние ошибки при загрузке SMS. +class SmsError extends SmsState { + final String message; + + const SmsError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/main.dart b/lib/main.dart index 1a53aad..fd3581b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:budget_app/pages/home/home_page.dart'; import 'theme/app_theme.dart'; import 'injection_container.dart' as di; import 'logic/settings/settings_cubit.dart'; // Импортируем SettingsCubit +import 'logic/sms/sms_cubit.dart'; import 'logic/transaction/transaction_bloc.dart'; void main() async { @@ -32,6 +33,7 @@ class _MyAppState extends State { BlocProvider(create: (context) => GetIt.instance()..add(AuthStarted())), BlocProvider(create: (context) => GetIt.instance()), BlocProvider(create: (context) => GetIt.instance()), + BlocProvider(create: (context) => GetIt.instance()), ], child: BlocBuilder( builder: (context, settingsState) { diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 4e13785..5e42076 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -9,6 +9,7 @@ import '../../services/user_service.dart'; import '../home/widgets/summary_widget.dart'; // Импортируем новый виджет сводки import '../reports_page.dart'; // Импортируем новую страницу отчетов import '../settings_page.dart'; +import '../sms/sms_page.dart'; import 'widgets/add_transaction_dialog.dart'; import 'widgets/transaction_item.dart'; // Импортируем новый виджет для элемента транзакции @@ -27,6 +28,7 @@ class _HomePageState extends State { static final List _widgetOptions = [ const TransactionsPage(), // Главная страница с транзакциями const ReportsPage(), // Страница отчетов + const SmsPage(), // Страница SMS const SettingsPage(), // Страница настроек ]; @@ -34,7 +36,7 @@ class _HomePageState extends State { void initState() { super.initState(); // Получаем ID текущего пользователя из AuthBloc - final authState = context.read().state; + final authState = context.read().state; if (authState is AuthAuthenticated) { _currentUserId = authState.user.id; // Загружаем транзакции через глобальный TransactionBloc @@ -88,6 +90,10 @@ class _HomePageState extends State { icon: const Icon(Icons.bar_chart), label: localizations.reportsPageTitle, // Локализованный текст ), + BottomNavigationBarItem( + icon: const Icon(Icons.sms), + label: localizations.smsPageTitle, + ), BottomNavigationBarItem( icon: const Icon(Icons.settings), label: localizations.settingsPageTitle, // Локализованный текст @@ -101,53 +107,63 @@ class _HomePageState extends State { } } -// Отдельный виджет для страницы транзакций, чтобы HomeView был чище -class TransactionsPage extends StatelessWidget { +// Изменение: TransactionsPage преобразован в StatefulWidget для управления состоянием выбранного месяца. +class TransactionsPage extends StatefulWidget { const TransactionsPage({super.key}); + @override + State createState() => _TransactionsPageState(); +} + +class _TransactionsPageState extends State { + // Добавление: Состояние для хранения выбранного месяца на уровне страницы. + late DateTime _selectedMonth; + + @override + void initState() { + super.initState(); + // Инициализация: Устанавливаем текущий месяц. + _selectedMonth = DateTime.now(); + } + + // Добавление: Метод для изменения месяца, который будет передаваться в SummaryWidget. + void _onMonthChanged(DateTime newMonth) { + setState(() { + _selectedMonth = newMonth; + }); + } + @override Widget build(BuildContext context) { - final localizations = AppLocalizations.of( - context, - )!; // Получаем экземпляр локализации + final localizations = AppLocalizations.of(context)!; return BlocBuilder( builder: (context, state) { if (state is TransactionLoading) { return const Center(child: CircularProgressIndicator()); } else if (state is TransactionLoaded) { - // Используем ListView для вертикальной прокрутки + // Добавление: Фильтрация транзакций по выбранному месяцу. + final monthlyTransactions = state.transactions.where((t) { + return t.dateTime.month == _selectedMonth.month && t.dateTime.year == _selectedMonth.year; + }).toList(); + return ListView( children: [ - // Виджет сводки - // Рассчитываем доходы, расходы и баланс из транзакций + // Изменение: SummaryWidget теперь получает все транзакции, + // выбранный месяц и колбэк для его изменения. Builder( builder: (context) { - double income = 0.0; - double expense = 0.0; - for (var transaction in state.transactions) { - if (transaction.isIncome) { - income += transaction.amount; - } else { - expense += transaction.amount; - } - } - final balance = income - expense; - // Получаем текущего пользователя для определения валюты final userService = GetIt.instance(); - final currencySymbol = - userService.currentUser?.defaultCurrency ?? - '₽'; // Валюта по умолчанию, если не найдена + final currencySymbol = userService.currentUser?.defaultCurrency ?? '₽'; return SummaryWidget( - income: income, - expense: expense, - balance: balance, - currencySymbol: currencySymbol, // Передаем символ валюты - ); // Передаем реальные данные в SummaryWidget + transactions: state.transactions, // Передаем все транзакции для расчетов + selectedMonth: _selectedMonth, // Передаем текущий выбранный месяц + onMonthChanged: _onMonthChanged, // Передаем колбэк + currencySymbol: currencySymbol, + ); }, ), - // Заголовок для списка транзакций Padding( padding: const EdgeInsets.all(16.0), child: Text( @@ -155,26 +171,28 @@ class TransactionsPage extends StatelessWidget { style: Theme.of(context).textTheme.titleLarge, ), ), - // Список транзакций - if (state.transactions.isEmpty) + // Изменение: Отображаем отфильтрованный список транзакций. + if (monthlyTransactions.isEmpty) Center( - child: Text(localizations.noTransactionsText), - ) // Локализованный текст + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(localizations.noTransactionsText), + ), + ) else - // Оборачиваем список транзакций в Card для создания единого блока Card( margin: const EdgeInsets.all(16.0), elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), + // Изменение: Используем отфильтрованный список. child: ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: state.transactions.length, + itemCount: monthlyTransactions.length, itemBuilder: (context, index) { - final transaction = state.transactions[index]; - // Возвращаем новый кастомный виджет для транзакции + final transaction = monthlyTransactions[index]; return TransactionItem(transaction: transaction); }, ), @@ -182,13 +200,9 @@ class TransactionsPage extends StatelessWidget { ], ); } else if (state is TransactionError) { - return Center( - child: Text(localizations.transactionErrorText(state.message)), - ); // Локализованный текст + return Center(child: Text(localizations.transactionErrorText(state.message))); } else { - return Center( - child: Text(localizations.loadingTransactionsText), - ); // Локализованный текст + return Center(child: Text(localizations.loadingTransactionsText)); } }, ); diff --git a/lib/pages/home/widgets/summary_widget.dart b/lib/pages/home/widgets/summary_widget.dart index 8a4b2a0..bb65795 100644 --- a/lib/pages/home/widgets/summary_widget.dart +++ b/lib/pages/home/widgets/summary_widget.dart @@ -1,106 +1,121 @@ import 'package:animated_digit/animated_digit.dart'; import 'package:budget_app/l10n/app_localizations.dart'; +import 'package:budget_app/models/transaction_record.dart'; import 'package:budget_app/theme/custom_colors.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; -// Виджет для отображения сводки по доходам и расходам. -class SummaryWidget extends StatelessWidget { - // Добавлены параметры для отображения реальных данных - final double income; - final double expense; - final double balance; - final String currencySymbol; // Добавлен параметр для символа валюты +// Изменение: Виджет преобразован в StatefulWidget для управления состоянием выбранного месяца. +class SummaryWidget extends StatefulWidget { + // Изменение: Виджет теперь принимает список всех транзакций, а не готовые суммы. + final List transactions; + final String currencySymbol; + // Добавление: Принимает выбранный месяц и колбэк для его изменения от родителя. + final DateTime selectedMonth; + final ValueChanged onMonthChanged; const SummaryWidget({ super.key, - required this.income, - required this.expense, - required this.balance, - required this.currencySymbol, // Обязательный параметр + required this.transactions, + required this.currencySymbol, + required this.selectedMonth, + required this.onMonthChanged, }); @override - Widget build(BuildContext context) { - // Получаем локализацию и тему. - final localizations = AppLocalizations.of(context)!; - final customColors = Theme.of(context).extension()!; - final theme = Theme.of(context); + State createState() => _SummaryWidgetState(); +} - // Используем Card для создания тени и скругленных углов. - // Увеличиваем радиус скругления и elevation для более выраженного эффекта. +class _SummaryWidgetState extends State { + // Добавление: PageController для управления PageView. + late PageController _pageController; + // Добавление: Хранение начального месяца для расчетов. + late DateTime _initialMonth; + // Добавление: Общее количество месяцев для отображения. + int _monthCount = 0; + + @override + void initState() { + super.initState(); + // Изменение: Находим самую раннюю транзакцию для определения начального месяца. + if (widget.transactions.isNotEmpty) { + widget.transactions.sort((a, b) => a.dateTime.compareTo(b.dateTime)); + _initialMonth = DateTime(widget.transactions.first.dateTime.year, + widget.transactions.first.dateTime.month); + } else { + _initialMonth = DateTime(DateTime.now().year, DateTime.now().month); + } + + _monthCount = _calculateMonthDifference(DateTime.now(), _initialMonth) + 1; + if (_monthCount < 1) _monthCount = 1; // Как минимум один месяц должен быть + + // Инициализация PageController на последней странице (текущий месяц). + _pageController = PageController(initialPage: _monthCount - 1); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + // Добавление: Вспомогательный метод для расчета разницы в месяцах. + int _calculateMonthDifference(DateTime d1, DateTime d2) { + return (d1.year - d2.year) * 12 + d1.month - d2.month; + } + + // Добавление: Метод для форматирования названия месяца. + // Отображает год, если он отличается от текущего. + String _formatMonth(BuildContext context, DateTime date) { + final localizations = AppLocalizations.of(context)!; + final now = DateTime.now(); + final format = + date.year == now.year ? DateFormat.MMMM(localizations.localeName) : DateFormat.yMMMM(localizations.localeName); + return format.format(date); + } + + @override + Widget build(BuildContext context) { return Card( elevation: 8.0, margin: const EdgeInsets.all(16.0), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16.0), ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 24.0, horizontal: 16.0), + clipBehavior: Clip.antiAlias, // Обрезаем контент по границам карточки + child: SizedBox( + height: 300, + // Изменение: Используем Column для разделения PageView и индикатора. child: Column( - mainAxisSize: MainAxisSize.min, children: [ - // Центральный блок с балансом. - // Используем акцентный цвет для заголовка. - Text( - localizations.balance, - style: theme.textTheme.titleLarge?.copyWith( - color: customColors.accent, - fontWeight: FontWeight.bold, + // PageView будет занимать все доступное пространство. + Expanded( + child: PageView.builder( + controller: _pageController, + itemCount: _monthCount, + onPageChanged: (index) { + final newMonth = DateTime( + _initialMonth.year, + _initialMonth.month + index, + 1, + ); + widget.onMonthChanged(newMonth); + }, + itemBuilder: (context, index) { + final month = DateTime( + _initialMonth.year, + _initialMonth.month + index, + 1, + ); + return _buildPage(context, month); + }, ), ), - const SizedBox(height: 8.0), - // Анимированное отображение общей суммы. - // Увеличиваем шрифт для большей наглядности. - AnimatedDigitWidget( - value: balance, - fractionDigits: 2, - textStyle: theme.textTheme.displaySmall?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, - ), - suffix: ' $currencySymbol', - ), - const SizedBox(height: 24.0), - - // Добавляем разделитель с цветом из темы. - Divider( - height: 1, - thickness: 1, - color: customColors.divider, - ), - const SizedBox(height: 24.0), - - // Разделение на доходы и расходы. - // Используем IntrinsicHeight для выравнивания высоты дочерних элементов. - IntrinsicHeight( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - // Виджет для отображения доходов. - _buildIncomeExpense( - context, - localizations.income, - income, - customColors.income!, // Используем цвет дохода из темы - Icons.arrow_circle_up_outlined, - ), - // Вертикальный разделитель. - VerticalDivider( - width: 1, - thickness: 1, - color: customColors.divider, - ), - // Виджет для отображения расходов. - _buildIncomeExpense( - context, - localizations.expense, - expense, - customColors.expense!, // Используем цвет расхода из темы - Icons.arrow_circle_down_outlined, - ), - ], - ), + // Добавление: Индикатор теперь находится вне PageView. + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: _buildPageIndicator(context), ), ], ), @@ -108,8 +123,151 @@ class SummaryWidget extends StatelessWidget { ); } - // Вспомогательный метод для создания виджета дохода/расхода. - // Изменен для более компактного и чистого вида. + // Добавление: Метод для построения одной страницы (одного месяца). + Widget _buildPage(BuildContext context, DateTime month) { + final localizations = AppLocalizations.of(context)!; + final customColors = Theme.of(context).extension()!; + final theme = Theme.of(context); + + final monthlyTransactions = widget.transactions.where((t) { + return t.dateTime.month == month.month && t.dateTime.year == month.year; + }).toList(); + + final income = monthlyTransactions + .where((t) => t.isIncome) + .fold(0.0, (sum, item) => sum + item.amount); + final expense = monthlyTransactions + .where((t) => !t.isIncome) + .fold(0.0, (sum, item) => sum + item.amount); + final balance = income - expense; + + // Изменение: Уменьшены вертикальные отступы для предотвращения переполнения. + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + localizations.balance, + style: theme.textTheme.titleLarge?.copyWith( + color: customColors.accent, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8.0), + AnimatedDigitWidget( + value: balance, + fractionDigits: 2, + textStyle: theme.textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.onSurface, + ), + suffix: ' ${widget.currencySymbol}', + ), + const SizedBox(height: 16.0), + Divider( + height: 1, + thickness: 1, + color: customColors.divider, + ), + const SizedBox(height: 16.0), + IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildIncomeExpense( + context, + localizations.income, + income, + customColors.income!, + Icons.arrow_circle_up_outlined, + ), + VerticalDivider( + width: 1, + thickness: 1, + color: customColors.divider, + ), + _buildIncomeExpense( + context, + localizations.expense, + expense, + customColors.expense!, + Icons.arrow_circle_down_outlined, + ), + ], + ), + ), + const Spacer(), // Занимает оставшееся место + Text( + _formatMonth(context, month), + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface.withOpacity(0.7), + ), + ), + // Удаление: Индикатор перенесен из страницы. + ], + ), + ); + } + + // Изменение: Индикатор теперь слушает PageController. + Widget _buildPageIndicator(BuildContext context) { + final customColors = Theme.of(context).extension()!; + return AnimatedBuilder( + animation: _pageController, + builder: (context, child) { + // Проверяем, инициализирован ли контроллер + final page = _pageController.hasClients ? _pageController.page ?? 0 : _monthCount - 1.0; + final isFirstMonth = page < 0.5; + final isLastMonth = page > _monthCount - 1.5; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Анимированная точка "назад" + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + width: isFirstMonth ? 0 : 8.0, + height: 8.0, + margin: EdgeInsets.symmetric(horizontal: isFirstMonth ? 0 : 4.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: customColors.divider, + ), + ), + + // Активная точка + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + width: 12.0, + height: 12.0, + margin: const EdgeInsets.symmetric(horizontal: 4.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: customColors.accent, + ), + ), + + // Анимированная точка "вперед" + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + width: isLastMonth ? 0 : 8.0, + height: 8.0, + margin: EdgeInsets.symmetric(horizontal: isLastMonth ? 0 : 4.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: customColors.divider, + ), + ), + ], + ); + }, + ); + } + Widget _buildIncomeExpense( BuildContext context, String title, @@ -121,21 +279,17 @@ class SummaryWidget extends StatelessWidget { return Expanded( child: Column( children: [ - // Иконка для наглядности. Icon( icon, color: color, size: 32.0, ), const SizedBox(height: 8.0), - // Заголовок. Text( title, style: theme.textTheme.titleMedium, ), const SizedBox(height: 4.0), - // Анимированное отображение суммы. - // Используем основной цвет текста для суммы. AnimatedDigitWidget( value: amount, fractionDigits: 2, diff --git a/lib/pages/sms/sms_page.dart b/lib/pages/sms/sms_page.dart new file mode 100644 index 0000000..9ce39be --- /dev/null +++ b/lib/pages/sms/sms_page.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:budget_app/logic/sms/sms_cubit.dart'; +import 'package:budget_app/logic/sms/sms_state.dart'; +import 'package:budget_app/pages/sms/widgets/sms_message_widget.dart'; +import 'package:budget_app/l10n/app_localizations.dart'; + +/// Экран для отображения SMS сообщений. +/// +/// Использует [SmsCubit] для получения и отображения +/// последних SMS сообщений. +class SmsPage extends StatelessWidget { + const SmsPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.smsPageTitle), + ), + body: BlocBuilder( + builder: (context, state) { + if (state is SmsInitial) { + // Начальное состояние, запускаем загрузку + context.read().loadLastMessages(); + return const Center(child: CircularProgressIndicator()); + } else if (state is SmsLoading) { + // Состояние загрузки + return const Center(child: CircularProgressIndicator()); + } else if (state is SmsLoaded) { + // Состояние успешной загрузки + return ListView.builder( + itemCount: state.messages.length, + itemBuilder: (context, index) { + return SmsMessageWidget(message: state.messages[index]); + }, + ); + } else if (state is SmsPermissionDenied) { + // Состояние отказа в разрешении + return Center( + child: Text(AppLocalizations.of(context)!.smsPermissionDenied), + ); + } else if (state is SmsError) { + // Состояние ошибки + return Center( + child: Text(state.message), + ); + } + return const SizedBox.shrink(); + }, + ), + ); + } +} diff --git a/lib/pages/sms/widgets/sms_message_widget.dart b/lib/pages/sms/widgets/sms_message_widget.dart new file mode 100644 index 0000000..50a6183 --- /dev/null +++ b/lib/pages/sms/widgets/sms_message_widget.dart @@ -0,0 +1,20 @@ +import 'package:another_telephony/telephony.dart'; +import 'package:flutter/material.dart'; + +/// Виджет для отображения одного SMS сообщения. +class SmsMessageWidget extends StatelessWidget { + final SmsMessage message; + + const SmsMessageWidget({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(message.body ?? ''), + ), + ); + } +} diff --git a/lib/services/sms_service.dart b/lib/services/sms_service.dart new file mode 100644 index 0000000..34998d5 --- /dev/null +++ b/lib/services/sms_service.dart @@ -0,0 +1,37 @@ +import 'package:another_telephony/telephony.dart'; + +/// Сервис для работы с SMS сообщениями. +/// +/// Использует пакет [telephony] для доступа к SMS на устройстве. +/// Предоставляет методы для запроса разрешений и получения +/// последних SMS сообщений. +class SmsService { + final Telephony _telephony = Telephony.instance; + + /// Запрашивает разрешения на чтение и отправку SMS. + /// + /// Возвращает [true], если разрешения были предоставлены, + /// иначе [false]. + Future requestPermissions() async { + return await _telephony.requestPhoneAndSmsPermissions ?? false; + } + + /// Возвращает список последних SMS сообщений. + /// + /// [count] - количество сообщений для получения. + /// + /// Возвращает список объектов [SmsMessage]. + /// В случае ошибки или отсутствия разрешений, возвращает пустой список. + Future> getLastSmsMessages(int count) async { + final bool? permissionsGranted = + await _telephony.requestPhoneAndSmsPermissions; + if (permissionsGranted ?? false) { + final List messages = await _telephony.getInboxSms( + columns: [SmsColumn.BODY], + sortOrder: [OrderBy(SmsColumn.DATE, sort: Sort.DESC)], + ); + return messages.take(count).toList(); + } + return []; + } +} diff --git a/pubspec.lock b/pubspec.lock index 96fb53d..aa74e64 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.3" + another_telephony: + dependency: "direct main" + description: + name: another_telephony + sha256: "7dd16759099ea3e4ce762c4f5bbfa097940335555d5caa916aaa52eec6a70cb6" + url: "https://pub.dev" + source: hosted + version: "0.4.1" args: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 15a4100..b8ac46d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,6 +30,7 @@ environment: dependencies: flutter: sdk: flutter + another_telephony: ^0.4.1 animated_digit: ^3.2.0 # The following adds the Cupertino Icons font to your application.