Adds SMS reading functionality

Implements SMS reading functionality to automatically track expenses.

- Adds necessary permissions for reading SMS messages in AndroidManifest.xml.
- Registers SmsService and SmsCubit in the dependency injection container.
- Adds a new SMS page to the bottom navigation bar.
- Introduces new localization strings for the SMS page title and permission denied message.
- Sets minSdk to 23.
This commit is contained in:
2025-06-30 13:40:52 +03:00
parent 5ff11c5449
commit 829ca93d9b
20 changed files with 1229 additions and 132 deletions
+28
View File
@@ -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.
-
+1 -1
View File
@@ -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
+10
View File
@@ -1,4 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Разрешения для чтения SMS -->
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.READ_SMS"/>
<uses-permission android:name="android.permission.SEND_SMS"/>
<uses-permission android:name="android.permission.RECEIVE_SMS"/>
<application
android:label="budget_app"
android:name="${applicationName}"
File diff suppressed because one or more lines are too long
+6
View File
@@ -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<void> initDependencies() async {
UserService(getIt(), getIt(), getIt(), getIt()),
);
getIt.registerSingleton<SmsService>(SmsService());
// Регистрация сервисов
getIt.registerSingleton<SettingsCubit>(
SettingsCubit(getIt()),
@@ -50,4 +54,6 @@ Future<void> initDependencies() async {
getIt.registerFactory<TransactionBloc>(
() => TransactionBloc(transactionRepository: getIt()),
);
getIt.registerFactory<SmsCubit>(() => SmsCubit(getIt()));
}
+3 -1
View File
@@ -35,5 +35,7 @@
"invalidNumber": "Invalid number",
"cancel": "Cancel",
"save": "Save",
"tag": "Tag"
"tag": "Tag",
"smsPageTitle": "SMS Messages",
"smsPermissionDenied": "SMS permission is required"
}
+12
View File
@@ -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
+6
View File
@@ -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';
}
+6
View File
@@ -118,4 +118,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tag => 'Тег';
@override
String get smsPageTitle => 'SMS Сообщения';
@override
String get smsPermissionDenied => 'Необходимо разрешение на чтение SMS';
}
+3 -1
View File
@@ -35,5 +35,7 @@
"invalidNumber": "Неверный формат числа",
"cancel": "Отмена",
"save": "Сохранить",
"tag": "Тег"
"tag": "Тег",
"smsPageTitle": "SMS Сообщения",
"smsPermissionDenied": "Необходимо разрешение на чтение SMS"
}
+33
View File
@@ -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<SmsState> {
final SmsService _smsService;
SmsCubit(this._smsService) : super(SmsInitial());
/// Загружает последние 10 SMS сообщений.
///
/// Перед загрузкой запрашивает необходимые разрешения.
/// В случае успеха, переходит в состояние [SmsLoaded].
/// В случае отказа в разрешениях, переходит в состояние [SmsPermissionDenied].
/// В случае ошибки, переходит в состояние [SmsError].
Future<void> 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()));
}
}
}
+39
View File
@@ -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<Object> get props => [];
}
/// Начальное состояние.
class SmsInitial extends SmsState {}
/// Состояние загрузки SMS.
class SmsLoading extends SmsState {}
/// Состояние, когда SMS успешно загружены.
class SmsLoaded extends SmsState {
final List<SmsMessage> messages;
const SmsLoaded(this.messages);
@override
List<Object> get props => [messages];
}
/// Состояние, когда отказано в разрешении на чтение SMS.
class SmsPermissionDenied extends SmsState {}
/// Состояние ошибки при загрузке SMS.
class SmsError extends SmsState {
final String message;
const SmsError(this.message);
@override
List<Object> get props => [message];
}
+2
View File
@@ -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<MyApp> {
BlocProvider(create: (context) => GetIt.instance<AuthBloc>()..add(AuthStarted())),
BlocProvider(create: (context) => GetIt.instance<SettingsCubit>()),
BlocProvider(create: (context) => GetIt.instance<TransactionBloc>()),
BlocProvider(create: (context) => GetIt.instance<SmsCubit>()),
],
child: BlocBuilder<SettingsCubit, SettingsState>(
builder: (context, settingsState) {
+57 -43
View File
@@ -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<HomePage> {
static final List<Widget> _widgetOptions = <Widget>[
const TransactionsPage(), // Главная страница с транзакциями
const ReportsPage(), // Страница отчетов
const SmsPage(), // Страница SMS
const SettingsPage(), // Страница настроек
];
@@ -34,7 +36,7 @@ class _HomePageState extends State<HomePage> {
void initState() {
super.initState();
// Получаем ID текущего пользователя из AuthBloc
final authState = context.read<AuthBloc>().state;
final authState = context.read<AuthBloc>().state;
if (authState is AuthAuthenticated) {
_currentUserId = authState.user.id;
// Загружаем транзакции через глобальный TransactionBloc
@@ -88,6 +90,10 @@ class _HomePageState extends State<HomePage> {
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<HomePage> {
}
}
// Отдельный виджет для страницы транзакций, чтобы HomeView был чище
class TransactionsPage extends StatelessWidget {
// Изменение: TransactionsPage преобразован в StatefulWidget для управления состоянием выбранного месяца.
class TransactionsPage extends StatefulWidget {
const TransactionsPage({super.key});
@override
State<TransactionsPage> createState() => _TransactionsPageState();
}
class _TransactionsPageState extends State<TransactionsPage> {
// Добавление: Состояние для хранения выбранного месяца на уровне страницы.
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<TransactionBloc, TransactionState>(
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<UserService>();
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));
}
},
);
+240 -86
View File
@@ -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<TransactionRecord> transactions;
final String currencySymbol;
// Добавление: Принимает выбранный месяц и колбэк для его изменения от родителя.
final DateTime selectedMonth;
final ValueChanged<DateTime> 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<CustomColors>()!;
final theme = Theme.of(context);
State<SummaryWidget> createState() => _SummaryWidgetState();
}
// Используем Card для создания тени и скругленных углов.
// Увеличиваем радиус скругления и elevation для более выраженного эффекта.
class _SummaryWidgetState extends State<SummaryWidget> {
// Добавление: 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<CustomColors>()!;
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<CustomColors>()!;
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,
+54
View File
@@ -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<SmsCubit, SmsState>(
builder: (context, state) {
if (state is SmsInitial) {
// Начальное состояние, запускаем загрузку
context.read<SmsCubit>().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();
},
),
);
}
}
@@ -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 ?? ''),
),
);
}
}
+37
View File
@@ -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<bool> requestPermissions() async {
return await _telephony.requestPhoneAndSmsPermissions ?? false;
}
/// Возвращает список последних SMS сообщений.
///
/// [count] - количество сообщений для получения.
///
/// Возвращает список объектов [SmsMessage].
/// В случае ошибки или отсутствия разрешений, возвращает пустой список.
Future<List<SmsMessage>> getLastSmsMessages(int count) async {
final bool? permissionsGranted =
await _telephony.requestPhoneAndSmsPermissions;
if (permissionsGranted ?? false) {
final List<SmsMessage> messages = await _telephony.getInboxSms(
columns: [SmsColumn.BODY],
sortOrder: [OrderBy(SmsColumn.DATE, sort: Sort.DESC)],
);
return messages.take(count).toList();
}
return [];
}
}
+8
View File
@@ -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:
+1
View File
@@ -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.