This commit is contained in:
2025-08-20 18:28:10 +03:00
parent 1da6090572
commit 4944fcbcec
17 changed files with 156 additions and 125 deletions
+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 = 23
minSdk = 230
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
-9
View File
@@ -1,9 +0,0 @@
import 'package:flutter/cupertino.dart';
import 'package:hive_ce/hive.dart';
@GenerateAdapters([
AdapterSpec<IconData>(),
])
part 'hive_adapters.g.dart';
-53
View File
@@ -1,53 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'hive_adapters.dart';
// **************************************************************************
// AdaptersGenerator
// **************************************************************************
class IconDataAdapter extends TypeAdapter<IconData> {
@override
final typeId = 1;
@override
IconData read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return IconData(
(fields[0] as num).toInt(),
fontFamily: fields[1] as String?,
fontPackage: fields[2] as String?,
matchTextDirection: fields[3] == null ? false : fields[3] as bool,
fontFamilyFallback: (fields[4] as List?)?.cast<String>(),
);
}
@override
void write(BinaryWriter writer, IconData obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.codePoint)
..writeByte(1)
..write(obj.fontFamily)
..writeByte(2)
..write(obj.fontPackage)
..writeByte(3)
..write(obj.matchTextDirection)
..writeByte(4)
..write(obj.fontFamilyFallback);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is IconDataAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
+1 -15
View File
@@ -2,18 +2,4 @@
# Manual modifications may be necessary for certain migrations
# Check in to version control
nextTypeId: 3
types:
IconData:
typeId: 1
nextIndex: 5
fields:
codePoint:
index: 0
fontFamily:
index: 1
fontPackage:
index: 2
matchTextDirection:
index: 3
fontFamilyFallback:
index: 4
types: {}
-3
View File
@@ -3,7 +3,6 @@
// Check in to version control
import 'package:hive_ce/hive.dart';
import 'package:budget_app/hive/hive_adapters.dart';
import 'package:budget_app/models/ai_response.dart';
import 'package:budget_app/models/ai_rule.dart';
import 'package:budget_app/models/ai_settings.dart';
@@ -27,7 +26,6 @@ extension HiveRegistrar on HiveInterface {
registerAdapter(AppSettingsAdapter());
registerAdapter(CategoryAdapter());
registerAdapter(GlobalSettingsAdapter());
registerAdapter(IconDataAdapter());
registerAdapter(PrefilledTransactionAdapter());
registerAdapter(ProcessingStatusAdapter());
registerAdapter(SmsHandlerSettingsAdapter());
@@ -51,7 +49,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
registerAdapter(AppSettingsAdapter());
registerAdapter(CategoryAdapter());
registerAdapter(GlobalSettingsAdapter());
registerAdapter(IconDataAdapter());
registerAdapter(PrefilledTransactionAdapter());
registerAdapter(ProcessingStatusAdapter());
registerAdapter(SmsHandlerSettingsAdapter());
+1
View File
@@ -239,6 +239,7 @@ Future<void> initUserSpecificDependencies(String userId) async {
getIt.registerSingleton<SmsTransactionService>(SmsTransactionService(
getIt<ISmsHandlerRepository>(),
getIt<ITransactionRepository>(),
getIt<IAiRuleRepository>(),
getIt<TransactionBloc>(),
));
+38 -5
View File
@@ -61,17 +61,41 @@ class SmsItemCubit extends Cubit<SmsItemState> {
break;
case SmsProcessingResult.noRule:
// Правило не найдено - показываем ошибку и не меняем статус
emit(SmsItemError(loc.noRuleForSender(_currentMessage.sender ?? loc.unknownSender)));
// Правило не найдено - сохраняем ошибку в сообщение
final errorMessage = loc.noRuleForSender(_currentMessage.sender ?? loc.unknownSender);
await _smsService.markAsError(_currentMessage.id, errorMessage);
final updatedMessage = _currentMessage.copyWith(
status: SmsStatus.error,
errorMessage: errorMessage,
);
_currentMessage = updatedMessage; // Обновляем локальную копию
emit(SmsItemError(errorMessage));
onProcessed(updatedMessage);
break;
case SmsProcessingResult.error:
// Ошибка обработки - показываем ошибку и не меняем статус
// Ошибка обработки - сохраняем ошибку в сообщение
await _smsService.markAsError(_currentMessage.id, loc.processingError);
final updatedMessage = _currentMessage.copyWith(
status: SmsStatus.error,
errorMessage: loc.processingError,
);
_currentMessage = updatedMessage; // Обновляем локальную копию
emit(SmsItemError(loc.processingError));
onProcessed(updatedMessage);
break;
}
} catch (e) {
emit(SmsItemError(e.toString()));
// Сохраняем ошибку исключения в сообщение
final errorMessage = e.toString();
await _smsService.markAsError(_currentMessage.id, errorMessage);
final updatedMessage = _currentMessage.copyWith(
status: SmsStatus.error,
errorMessage: errorMessage,
);
_currentMessage = updatedMessage; // Обновляем локальную копию
emit(SmsItemError(errorMessage));
onProcessed(updatedMessage);
}
}
@@ -86,7 +110,16 @@ class SmsItemCubit extends Cubit<SmsItemState> {
emit(SmsItemIgnored());
onProcessed(updatedMessage); // Передаем обновленное сообщение
} catch (e) {
emit(SmsItemError(e.toString()));
// Сохраняем ошибку исключения в сообщение
final errorMessage = e.toString();
await _smsService.markAsError(_currentMessage.id, errorMessage);
final updatedMessage = _currentMessage.copyWith(
status: SmsStatus.error,
errorMessage: errorMessage,
);
_currentMessage = updatedMessage; // Обновляем локальную копию
emit(SmsItemError(errorMessage));
onProcessed(updatedMessage);
}
}
}
+11 -7
View File
@@ -23,8 +23,8 @@ class Category extends Equatable {
final Color color;
@HiveField(3)
/// Иконка категории для быстрой визуальной идентификации
final IconData icon;
/// Код иконки категории для быстрой визуальной идентификации
final int iconCode;
@HiveField(4)
/// Флаг указывающий тип операции:
@@ -41,7 +41,7 @@ class Category extends Equatable {
String? id,
required this.name,
required this.color,
required this.icon,
required this.iconCode,
required this.isIncome,
DateTime? updatedAt, // Добавлено поле updatedAt, теперь необязательное
}) : id = id ?? IdGenerator.generateId(),
@@ -53,7 +53,7 @@ class Category extends Equatable {
'id': id,
'name': name,
'color': color.toARGB32(), // Сохраняем только значение цвета
'icon': icon.codePoint,
'icon': iconCode, // Сохраняем код иконки вместо IconData
'isIncome': isIncome,
'updatedAt': updatedAt.toIso8601String(), // Добавлено updatedAt в Map
};
@@ -65,7 +65,7 @@ class Category extends Equatable {
id: map['id'],
name: map['name'],
color: Color(map['color']),
icon: IconData(map['icon'], fontFamily: 'MaterialIcons'),
iconCode: map['icon'], // Используем код иконки вместо IconData
isIncome: map['isIncome'],
updatedAt: DateTime.parse(map['updatedAt']), // Добавлено updatedAt при создании из Map
);
@@ -76,7 +76,7 @@ class Category extends Equatable {
String? id,
String? name,
Color? color,
IconData? icon,
int? iconCode,
bool? isIncome,
String? userId,
}) {
@@ -84,12 +84,16 @@ class Category extends Equatable {
id: id ?? this.id,
name: name ?? this.name,
color: color ?? this.color,
icon: icon ?? this.icon,
iconCode: iconCode ?? this.iconCode,
isIncome: isIncome ?? this.isIncome,
updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании
);
}
/// Геттер для получения IconData из кода иконки
/// Это позволяет использовать иконки в UI без хранения IconData в Hive
IconData get icon => IconData(iconCode, fontFamily: 'MaterialIcons');
// Используем Equatable для сравнения объектов по их свойствам.
// В данном случае, мы считаем категории уникальными по их 'id'.
@override
+2 -2
View File
@@ -20,7 +20,7 @@ class CategoryAdapter extends TypeAdapter<Category> {
id: fields[0] as String?,
name: fields[1] as String,
color: fields[2] as Color,
icon: fields[3] as IconData,
iconCode: (fields[3] as num).toInt(),
isIncome: fields[4] as bool,
updatedAt: fields[6] as DateTime?,
);
@@ -37,7 +37,7 @@ class CategoryAdapter extends TypeAdapter<Category> {
..writeByte(2)
..write(obj.color)
..writeByte(3)
..write(obj.icon)
..write(obj.iconCode)
..writeByte(4)
..write(obj.isIncome)
..writeByte(6)
@@ -206,7 +206,10 @@ class _AiRuleEditDialogState extends State<AiRuleEditDialog> {
bottom: Radius.circular(12),
),
),
child: Row(
child: Wrap(
alignment: WrapAlignment.end,
spacing: 12,
runSpacing: 12,
children: [
if (widget.rule != null) ...[
TextButton.icon(
@@ -214,14 +217,11 @@ class _AiRuleEditDialogState extends State<AiRuleEditDialog> {
icon: const Icon(Icons.play_arrow),
label: Text(AppLocalizations.of(context)!.testRule),
),
const SizedBox(width: 12),
],
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context)!.cancel),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _saveRule,
child: Text(widget.rule == null ? AppLocalizations.of(context)!.createRule : AppLocalizations.of(context)!.save),
@@ -623,4 +623,4 @@ class _AiRuleEditDialogState extends State<AiRuleEditDialog> {
),
);
}
}
}
+3 -3
View File
@@ -59,12 +59,12 @@ class _CategoryListPageState extends State<CategoryListPage> {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CategoryEditPage(
builder: (_) => CategoryEditPage(
onSave: (name, color, icon, isIncome) {
final newCategory = Category(
name: name,
color: color,
icon: icon,
iconCode: icon.codePoint, // Используем код иконки вместо IconData
isIncome: isIncome,
);
@@ -85,7 +85,7 @@ class _CategoryListPageState extends State<CategoryListPage> {
final updatedCategory = category.copyWith(
name: name,
color: color,
icon: icon,
iconCode: icon.codePoint, // Используем код иконки вместо IconData
isIncome: isIncome,
);
@@ -235,7 +235,8 @@ class SmsMessageWidget extends StatelessWidget {
),
],
),
if (state is SmsItemError) ...[
// Показываем ошибку из состояния или из сохраненного сообщения
if ((state is SmsItemError) || (message.status == SmsStatus.error && message.errorMessage != null)) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
@@ -262,7 +263,8 @@ class SmsMessageWidget extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
state.error,
// Используем ошибку из состояния, если она есть, иначе из сохраненного сообщения
state is SmsItemError ? state.error : message.errorMessage!,
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey.shade300
+12
View File
@@ -68,6 +68,18 @@ class SmsService {
await _smsRepository.updateSmsStatus(smsId, SmsStatus.ignored);
}
/// Помечает СМС как содержащее ошибку с указанным сообщением.
Future<void> markAsError(String smsId, String errorMessage) async {
final message = await _smsRepository.getById(smsId);
if (message != null) {
final updatedMessage = message.copyWith(
status: SmsStatus.error,
errorMessage: errorMessage,
);
await _smsRepository.update(updatedMessage);
}
}
// Приватный метод для получения SMS с устройства с определенной даты.
Future<List<SmsMessage>> _getSmsMessagesSince(DateTime? sinceDate) async {
final List<telephony_package.SmsMessage> messages = await _telephony.getInboxSms(
+59 -1
View File
@@ -1,6 +1,8 @@
import 'package:budget_app/data/repositories/interfaces/iai_rule_repository.dart';
import 'package:budget_app/data/repositories/interfaces/isms_handler_repository.dart';
import 'package:budget_app/data/repositories/interfaces/itransaction_repository.dart';
import 'package:budget_app/logic/transaction/transaction_bloc.dart';
import 'package:budget_app/models/ai_rule.dart';
import 'package:budget_app/models/sms_handler_settings.dart';
import 'package:budget_app/models/sms_message.dart';
import 'package:budget_app/services/custom_sms_functions.dart';
@@ -18,19 +20,69 @@ enum SmsProcessingResult {
class SmsTransactionService {
final ISmsHandlerRepository _smsHandlerRepository;
final ITransactionRepository _transactionRepository;
final IAiRuleRepository _aiRuleRepository;
final TransactionBloc _transactionBloc;
final Logger _logger = Logger();
SmsTransactionService(
this._smsHandlerRepository,
this._transactionRepository,
this._aiRuleRepository,
this._transactionBloc,
);
/// Проверяет, соответствует ли текст SMS любому exclusion regex из активных skip правил
Future<bool> _shouldSkipSms(String smsBody) async {
try {
// Получаем все активные правила типа skipTemplate
final skipRules = await _aiRuleRepository.getByType(AiRuleType.skipTemplate);
// Фильтруем только активные правила с непустым skipRegex
final activeSkipRules = skipRules.where((rule) =>
rule.isActive && rule.skipRegex != null && rule.skipRegex!.isNotEmpty
).toList();
if (activeSkipRules.isEmpty) {
return false; // Нет активных skip правил
}
// Проверяем каждый regex на соответствие тексту SMS
for (final rule in activeSkipRules) {
try {
final regex = RegExp(rule.skipRegex!, caseSensitive: false);
if (regex.hasMatch(smsBody)) {
_logger.i(
'SMS соответствует exclusion regex правила "${rule.name}": ${rule.skipRegex}',
);
return true; // SMS должно быть пропущено
}
} catch (e) {
_logger.w(
'Ошибка компиляции regex "${rule.skipRegex}" в правиле "${rule.name}": $e',
);
}
}
return false; // SMS не соответствует ни одному exclusion regex
} catch (e) {
_logger.e('Ошибка при проверке skip правил: $e');
return false; // В случае ошибки продолжаем обработку
}
}
Future<SmsProcessingResult> createTransactionFromSms(
SmsMessage message,
) async {
try {
// Проверяем, соответствует ли SMS exclusion regex для пропуска
final shouldSkip = await _shouldSkipSms(message.body ?? '');
if (shouldSkip) {
_logger.i(
'SMS от ${message.sender} пропущено по exclusion regex: ${message.body}',
);
return SmsProcessingResult.ignored;
}
final settings = await _smsHandlerRepository.getSmsHandlerSettings();
final rule = settings?.rulesBySender[message.sender];
@@ -78,7 +130,13 @@ class SmsTransactionService {
throw Exception('Не удалось сохранить транзакцию: $e');
}
} else {
throw Exception('Не удалось создать транзакцию');
// Кастомная функция вернула null - это нормально для не-транзакционных SMS
// (например, реклама, спам, уведомления безопасности)
_logger.i(
'Кастомная функция обработки вернула null для SMS: ${message.id}. '
'Это не-транзакционное сообщение, которое должно быть проигнорировано.',
);
return SmsProcessingResult.ignored;
}
} else {
throw Exception(
+9 -9
View File
@@ -14,21 +14,21 @@ class CategoryUtils {
id: 'income_salary',
name: 'Зарплата',
color: Colors.green,
icon: Icons.attach_money,
iconCode: Icons.attach_money.codePoint, // Используем код иконки вместо IconData
isIncome: true,
),
Category(
id: 'income_gift',
name: 'Подарки',
color: Colors.blue,
icon: Icons.card_giftcard,
iconCode: Icons.card_giftcard.codePoint, // Используем код иконки вместо IconData
isIncome: true,
),
Category(
id: 'income_freelance',
name: 'Фриланс',
color: Colors.teal,
icon: Icons.computer,
iconCode: Icons.computer.codePoint, // Используем код иконки вместо IconData
isIncome: true,
),
@@ -37,42 +37,42 @@ class CategoryUtils {
id: 'expense_food',
name: 'Еда',
color: Colors.red,
icon: Icons.fastfood,
iconCode: Icons.fastfood.codePoint, // Используем код иконки вместо IconData
isIncome: false,
),
Category(
id: 'expense_transport',
name: 'Транспорт',
color: Colors.orange,
icon: Icons.directions_car,
iconCode: Icons.directions_car.codePoint, // Используем код иконки вместо IconData
isIncome: false,
),
Category(
id: 'expense_entertainment',
name: 'Развлечения',
color: Colors.purple,
icon: Icons.movie,
iconCode: Icons.movie.codePoint, // Используем код иконки вместо IconData
isIncome: false,
),
Category(
id: 'expense_utilities',
name: 'Коммунальные',
color: Colors.blueGrey,
icon: Icons.home,
iconCode: Icons.home.codePoint, // Используем код иконки вместо IconData
isIncome: false,
),
Category(
id: 'expense_shopping',
name: 'Покупки',
color: Colors.pink,
icon: Icons.shopping_bag,
iconCode: Icons.shopping_bag.codePoint, // Используем код иконки вместо IconData
isIncome: false,
),
Category(
id: 'ai_category',
name: 'AI Транзакция',
color: Colors.black,
icon: Icons.smart_toy,
iconCode: Icons.smart_toy.codePoint, // Используем код иконки вместо IconData
isIncome: false,
),
];
+2 -2
View File
@@ -19,7 +19,7 @@ class TestObjects {
id: id ?? 'test_category_id',
name: name,
color: Color(int.parse(color.replaceFirst('#', '0xFF'))),
icon: const IconData(0xe59c, fontFamily: 'MaterialIcons'), // shopping_cart icon
iconCode: 0xe59c, // shopping_cart icon code вместо IconData
isIncome: isIncome,
);
}
@@ -295,4 +295,4 @@ class TestObjects {
...testPointOfSaleRules,
...testSkipRules,
];
}
}
+8 -8
View File
@@ -11,9 +11,9 @@ void main() {
setUp(() {
// Создаем тестовые категории для использования в промптах
testCategories = [
Category(id: '1', name: 'Покупки', isIncome: false, icon: Icons.shopping_cart, color: Color(0xFF2196F3)),
Category(id: '2', name: 'Транспорт', isIncome: false, icon: Icons.directions_bus, color: Color(0xFF4CAF50)),
Category(id: '3', name: 'Зарплата', isIncome: true, icon: Icons.attach_money, color: Color(0xFFFF9800)),
Category(id: '1', name: 'Покупки', isIncome: false, iconCode: Icons.shopping_cart.codePoint, color: Color(0xFF2196F3)),
Category(id: '2', name: 'Транспорт', isIncome: false, iconCode: Icons.directions_bus.codePoint, color: Color(0xFF4CAF50)),
Category(id: '3', name: 'Зарплата', isIncome: true, iconCode: Icons.attach_money.codePoint, color: Color(0xFFFF9800)),
];
});
@@ -76,8 +76,8 @@ void main() {
test('должен корректно обрабатывать только категории расходов', () {
// Проверяет работу с односторонними категориями
final expenseOnlyCategories = [
Category(id: '1', name: 'Покупки', isIncome: false, icon: Icons.shopping_cart, color: Color(0xFF2196F3)),
Category(id: '2', name: 'Еда', isIncome: false, icon: Icons.restaurant, color: Color(0xFF4CAF50)),
Category(id: '1', name: 'Покупки', isIncome: false, iconCode: Icons.shopping_cart.codePoint, color: Color(0xFF2196F3)),
Category(id: '2', name: 'Еда', isIncome: false, iconCode: Icons.restaurant.codePoint, color: Color(0xFF4CAF50)),
];
const smsBody = 'Тестовое SMS';
@@ -90,8 +90,8 @@ void main() {
test('должен корректно обрабатывать только категории доходов', () {
// Проверяет работу только с доходными категориями
final incomeOnlyCategories = [
Category(id: '1', name: 'Зарплата', isIncome: true, icon: Icons.attach_money, color: Color(0xFFFF9800)),
Category(id: '2', name: 'Премия', isIncome: true, icon: Icons.card_giftcard, color: Color(0xFF9C27B0)),
Category(id: '1', name: 'Зарплата', isIncome: true, iconCode: Icons.attach_money.codePoint, color: Color(0xFFFF9800)),
Category(id: '2', name: 'Премия', isIncome: true, iconCode: Icons.card_giftcard.codePoint, color: Color(0xFF9C27B0)),
];
const smsBody = 'Тестовое SMS';
@@ -165,4 +165,4 @@ void main() {
});
});
});
}
}