29 Commits
Author SHA1 Message Date
Sanders 43e53d0006 Fix ai 2025-08-20 18:28:31 +03:00
Sanders 4944fcbcec Fix ai 2025-08-20 18:28:10 +03:00
Sanders 1da6090572 Fixes 2025-08-20 11:19:27 +03:00
Sanders 8495d3b1cf Fix ai 2025-08-02 19:04:48 +03:00
Sanders f1739b81de Remake ai 2025-08-02 16:34:22 +03:00
Sanders 78b80904f5 Fixes 2025-08-02 15:25:25 +03:00
Sanders ea9c31b782 Add ai 2025-07-21 00:23:59 +03:00
Sanders dd468c3246 Add ai 2025-07-20 23:37:34 +03:00
Sanders b3dfa8abf8 Fixes 2025-07-19 21:16:01 +03:00
Sanders 51f119e8f6 Fix ai rules 2025-07-19 20:01:17 +03:00
Sanders 2df0d4a886 Fix ai rules 2025-07-19 18:58:31 +03:00
Sanders 4bd509b561 Fix ai add 2025-07-19 17:53:37 +03:00
Sanders a7a9c9504e Fix ai filters 2025-07-19 14:48:08 +03:00
Sanders 1d33d3876f Add ai rules 2025-07-19 14:21:36 +03:00
Sanders 7e32711383 Rework sms 2025-07-19 13:04:17 +03:00
Sanders ce56101b2b Fixed sms filter 2025-07-18 20:20:30 +03:00
Sanders 91466000ee Rework sms 2025-07-18 19:19:55 +03:00
Sanders 6e26a79e24 Rework sms lit 2025-07-18 16:05:36 +03:00
Sanders 83f653fcf4 Before rewrite sms 2025-07-18 14:47:45 +03:00
Sanders 1c88330a9f fixes list sms 2025-07-18 13:30:40 +03:00
Sanders 812fc7f322 small fix 2025-07-18 11:06:33 +03:00
Sanders f0933739b9 Fixes for sms 2025-07-18 11:02:30 +03:00
Sanders b8278828a6 Rework add trans 2025-07-17 14:53:25 +03:00
Sanders 77701f6b35 Change transaction create 2025-07-17 14:07:52 +03:00
Sanders 0233607004 change edit tran 2025-07-17 10:30:02 +03:00
Sanders 46981bb958 Фильтрация смс 2025-07-16 23:37:12 +03:00
Sanders d2854521f8 Fix sms 2025-07-16 21:00:30 +03:00
Sanders a9e1c02967 Move sms transaction create 2025-07-16 17:52:23 +03:00
Sanders 9699433b3f Add transaction processing logic 2025-07-16 14:21:02 +03:00
127 changed files with 16114 additions and 834 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"permissions": {
"allow": [
"Bash(flutter gen-l10n:*)",
"Bash(flutter analyze:*)",
"Bash(mkdir:*)",
"Bash(flutter packages pub run build_runner build:*)",
"Bash(flutter pub:*)",
"Bash(flutter test:*)"
],
"deny": []
}
}
+15
View File
@@ -0,0 +1,15 @@
# AI Service Configuration for Tests
# Copy this file to .env and fill in your actual values
# OpenRouter API Key for integration tests
# Get your key from https://openrouter.ai/
OPENROUTER_API_KEY=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
# Default AI Model
AI_DEFAULT_MODEL=google/gemini-2.5-flash
# Alternative AI services (optional)
# Test configuration
RUN_INTEGRATION_TESTS=true
TEST_AI_TIMEOUT_SECONDS=30
+14
View File
@@ -0,0 +1,14 @@
# AI Service Configuration for Tests
# Copy this file to .env and fill in your actual values
# OpenRouter API Key for integration tests
# Get your key from https://openrouter.ai/
OPENROUTER_API_KEY=your_openrouter_api_key_here
# Alternative AI services (optional)
OPENAI_API_KEY=your_openai_api_key_here
ANTHROPIC_API_KEY=your_anthropic_api_key_here
# Test configuration
RUN_INTEGRATION_TESTS=false
TEST_AI_TIMEOUT_SECONDS=30
+288
View File
@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../database/database.dart' as db;
import '../models/category.dart';
import '../controllers/expenses_controller.dart';
import '../widgets/summary_card.dart';
import '../widgets/expandable_section.dart';
import '../widgets/spending_pie_chart.dart';
import '../widgets/transactions_section.dart';
import 'profile_screen.dart';
class ExpensesScreen extends StatefulWidget {
final Function toggleTheme;
final bool isDarkMode;
final db.AppDatabase database;
const ExpensesScreen({
Key? key,
required this.toggleTheme,
required this.isDarkMode,
required this.database,
}) : super(key: key);
@override
State<ExpensesScreen> createState() => _ExpensesScreenState();
}
class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStateMixin {
late AnimationController _pieChartAnimationController;
late Animation<double> _pieChartAnimation;
late AnimationController _pieChartExpandController;
late Animation<double> _pieChartHeightFactor;
late ExpensesController _controller;
int _selectedNavIndex = 0;
@override
void initState() {
super.initState();
_controller = ExpensesController(widget.database);
_pieChartAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_pieChartAnimation = CurvedAnimation(
parent: _pieChartAnimationController,
curve: Curves.easeInOut,
);
_pieChartExpandController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
value: 1.0,
);
_pieChartHeightFactor = CurvedAnimation(
parent: _pieChartExpandController,
curve: Curves.easeInOut,
);
_pieChartAnimationController.forward();
}
@override
void dispose() {
_pieChartAnimationController.dispose();
_pieChartExpandController.dispose();
super.dispose();
}
void _togglePieChartVisibility() {
setState(() {
_controller.togglePieChartVisibility();
if (_controller.isPieChartExpanded) {
_pieChartExpandController.forward();
} else {
_pieChartExpandController.reverse();
}
});
}
void _toggleFilterVisibility() {
setState(() {
_controller.toggleFilterVisibility();
});
}
void _selectPieCategory(int index) {
setState(() {
_controller.selectPieCategory(index);
});
}
void _applyFilter(String filter) {
setState(() {
_controller.applyFilter(filter);
});
}
void _addSampleTransaction() async {
try {
await _controller.addSampleTransaction();
if (mounted) {
showFToast(
context: context,
builder: (context) => const FToast(
title: Text('Success'),
description: Text('Transaction added successfully'),
),
);
}
} catch (e) {
if (mounted) {
showFToast(
context: context,
builder: (context) => FToast(
title: const Text('Error'),
description: Text('Error adding transaction: $e'),
style: FToastStyle.destructive,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(context),
body: StreamBuilder<List<Category>>(
stream: _controller.watchCategoryTotals(),
builder: (context, categorySnapshot) {
if (categorySnapshot.connectionState == ConnectionState.waiting && !categorySnapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
if (categorySnapshot.hasError) {
return Center(child: Text('Error loading categories: ${categorySnapshot.error}'));
}
final categories = categorySnapshot.data ?? [];
final totalExpenses = categories.fold(0.0, (sum, item) => sum + item.amount);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SummaryCard(totalExpenses: totalExpenses),
ExpandableSection(
title: 'Spending Breakdown',
icon: Icons.pie_chart_outline_rounded,
isExpanded: _controller.isPieChartExpanded,
onTap: _togglePieChartVisibility,
heightFactor: _pieChartHeightFactor,
child: SpendingPieChart(
categories: categories,
totalExpenses: totalExpenses,
selectedPieIndex: _controller.selectedPieIndex,
onSelectPieCategory: _selectPieCategory,
animation: _pieChartAnimation,
),
),
TransactionsSection(
transactionsStream: _controller.watchTransactions(),
isFilterVisible: _controller.isFilterVisible,
selectedFilter: _controller.selectedFilter,
onToggleFilter: _toggleFilterVisibility,
onApplyFilter: _applyFilter,
),
const SizedBox(height: 80),
],
),
),
),
],
);
},
),
bottomNavigationBar: _buildBottomNavigationBar(),
floatingActionButton: FButton(
label: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add),
SizedBox(width: 8),
Text('Add'),
],
),
style: FButtonStyle.primary,
onPress: _addSampleTransaction,
),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
);
}
AppBar _buildAppBar(BuildContext context) {
return AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.account_balance_wallet_outlined,
color: context.theme.colorScheme.primary,
size: 24,
),
const SizedBox(width: 8),
const Text('My Finances'),
],
),
centerTitle: true,
leading: IconButton(
tooltip: widget.isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode',
icon: Icon(
widget.isDarkMode ? Icons.wb_sunny_outlined : Icons.nightlight_round,
color: widget.isDarkMode ? Colors.yellow.shade300 : Colors.blue.shade700,
),
onPressed: () => widget.toggleTheme(),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: Hero(
tag: 'profileAvatar',
child: Material(
type: MaterialType.transparency,
child: IconButton(
tooltip: 'View Profile',
icon: FAvatar(
size: 36,
backgroundColor: context.theme.colorScheme.primary.withOpacity(0.1),
child: Icon(
Icons.person_outline,
color: context.theme.colorScheme.primary,
size: 20,
),
),
onPressed: () {
Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (_, __, ___) => const ProfileScreen(),
transitionsBuilder: (_, animation, __, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: const Duration(milliseconds: 350),
),
);
},
),
),
),
),
],
);
}
BottomNavigationBar _buildBottomNavigationBar() {
return BottomNavigationBar(
currentIndex: _selectedNavIndex,
onTap: (index) {
setState(() {
_selectedNavIndex = index;
});
},
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_filled),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart_rounded),
label: 'Reports',
),
BottomNavigationBarItem(
icon: Icon(Icons.settings_outlined),
activeIcon: Icon(Icons.settings),
label: 'Settings',
),
],
);
}
}
+117
View File
@@ -0,0 +1,117 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Build and Run
- `flutter run` - Run the app in debug mode
- `flutter build apk` - Build APK for Android
- `flutter build ios` - Build for iOS
### Code Generation
- `flutter packages pub run build_runner build` - Generate Hive adapters and other generated code
- `flutter packages pub run build_runner build --delete-conflicting-outputs` - Clean build with conflict resolution
### Testing and Quality
- `flutter test` - Run all tests
- `flutter analyze` - Run static analysis using flutter_lints
- `flutter pub get` - Install dependencies
- `flutter pub upgrade` - Upgrade dependencies
### Localization
- `flutter gen-l10n` - Generate localization files (configured in l10n.yaml)
## Architecture Overview
This is a Flutter budget tracking application with SMS transaction parsing capabilities. The app follows Clean Architecture principles with BLoC pattern for state management.
### Key Architectural Components
**Dependency Injection**: Uses GetIt for dependency injection with a two-stage initialization:
- Global dependencies (user management, auth) initialized at startup
- User-specific dependencies (transactions, categories, etc.) initialized after authentication
**Database**: Hive (local NoSQL database) for data persistence with code generation for type adapters
**State Management**: BLoC pattern with flutter_bloc:
- Blocs for complex state (AuthBloc, TransactionBloc)
- Cubits for simpler state (UserCubit, CategoryCubit, TagCubit, SettingsCubit, SmsCubit)
**SMS Integration**: Uses another_telephony package to read SMS messages and automatically parse bank transaction notifications
### Project Structure
```
lib/
├── data/
│ ├── database/ # Hive database service
│ └── repositories/ # Data access layer with interfaces
├── hive/ # Hive type adapters (generated)
├── logic/ # BLoC/Cubit state management
├── models/ # Data models with Hive adapters
├── pages/ # UI screens and widgets
├── services/ # Business logic services
├── theme/ # App theming
├── utils/ # Utility functions
├── l10n/ # Localization files
├── main.dart # App entry point
└── injection_container.dart # Dependency injection setup
```
### Key Models
- `User` - User authentication and profile
- `TransactionRecord` - Financial transactions
- `Category` - Transaction categories with icons/colors
- `Tag` - Transaction tags
- `SmsMessage` - SMS messages for transaction parsing
- `AppSettings` - User preferences (theme, language)
### Authentication Flow
1. App starts with AuthBloc checking existing user
2. If no user, shows LoginPage
3. After login, calls `initUserSpecificDependencies()` to set up user data
4. User data is scoped and isolated per user via Hive boxes
### SMS Transaction Processing
The app can automatically parse SMS messages from banks to create transactions:
- Monitors incoming SMS via another_telephony
- Parses transaction details using configurable patterns
- Creates transactions automatically with suggested categories
### Localization
- Supports Russian (ru) and English (en)
- Uses flutter_localizations with ARB files
- Configured in l10n.yaml
## Coding Style Guidelines
### Naming Conventions
- Interface names should be prefixed with `I` (e.g., `IUserService`, `ITransactionRepository`)
- Private class members should be prefixed with an underscore (`_`)
- Follow dart naming conventions: camelCase for variables/methods, PascalCase for classes
### Flutter-Specific Best Practices
- **Prefer composition and small components**: Break down UI into small, reusable components rather than writing large monolithic widgets
- **Design for good user experience**: Provide clear, minimal, and non-blocking UI states
- **Use lightweight placeholders**: When data is loading, show skeleton screens rather than heavy loading indicators
- **Optimize for Flutter Compiler**: Write code that enables automatic optimizations and reduces unnecessary re-renders
### BLoC/Cubit Architecture
- Use **Blocs** for complex state management with events (AuthBloc, TransactionBloc)
- Use **Cubits** for simpler state management (UserCubit, CategoryCubit, TagCubit, SettingsCubit, SmsCubit)
- Follow the established pattern in the `logic/` directory
- Ensure proper separation of concerns between UI and business logic
### Performance Considerations
- Create small, focused widgets that can be efficiently rebuilt
- Implement proper `shouldRebuild` logic in BlocBuilder/BlocListener
- Avoid heavy operations in build methods
## Important Notes
- The project uses Russian comments and some Russian text in UI
- SMS functionality requires Android permissions for reading SMS
- Hive boxes are user-scoped for data isolation
- The default test is outdated and needs updating for the actual app structure
- Build runner is required for Hive adapter generation after model changes
+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
+12
View File
@@ -1,5 +1,8 @@
import 'package:budget_app/models/prefilled_transaction.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import '../../models/ai_rule.dart';
import '../../models/ai_settings.dart';
import '../../models/app_settings.dart';
import '../../models/category.dart';
import '../../models/global_settings.dart';
@@ -39,6 +42,9 @@ class HiveService {
static late Box<AppSettings> appSettings;
static late Box<SmsHandlerSettings> smsHandlerSettings;
static late Box<SmsMessage> smsMessages;
static late Box<AiRule> aiRules;
static late Box<PrefilledTransaction> prefilledTransactions;
static late Box<AiSettings> aiSettings;
/// Инициализация Hive Box для конкретного пользователя.
/// [userId] - Уникальный идентификатор пользователя.
@@ -55,6 +61,9 @@ class HiveService {
'sms_handler_settings_\$userId',
);
smsMessages = await Hive.openBox<SmsMessage>('sms_messages_\$userId');
aiRules = await Hive.openBox<AiRule>('ai_rules_\$userId');
prefilledTransactions = await Hive.openBox<PrefilledTransaction>('prefilled_transactions_\$userId');
aiSettings = await Hive.openBox<AiSettings>('ai_settings_\$userId');
}
/// Закрытие пользовательских Hive Box.
@@ -66,5 +75,8 @@ class HiveService {
await appSettings.close();
await smsHandlerSettings.close();
await smsMessages.close();
await aiRules.close();
await prefilledTransactions.close();
await aiSettings.close();
}
}
@@ -0,0 +1,131 @@
import 'dart:async';
import 'package:hive_ce/hive.dart';
import '/data/repositories/interfaces/iai_rule_repository.dart';
import '/models/ai_rule.dart';
class HiveAiRuleRepository implements IAiRuleRepository {
final Box<AiRule> _box;
HiveAiRuleRepository(this._box);
@override
Future<List<AiRule>> getAll() async {
try {
return _box.values.toList();
} catch (e) {
throw Exception('Ошибка получения правил ИИ: $e');
}
}
@override
Future<AiRule?> getById(String id) async {
try {
return _box.get(id);
} catch (e) {
throw Exception('Ошибка получения правила ИИ: $e');
}
}
@override
Future<void> add(AiRule rule) async {
try {
await _box.put(rule.id, rule);
} catch (e) {
throw Exception('Ошибка добавления правила ИИ: $e');
}
}
@override
Future<void> update(AiRule rule) async {
try {
await add(rule);
} catch (e) {
throw Exception('Ошибка обновления правила ИИ: $e');
}
}
@override
Future<void> delete(String id) async {
try {
await _box.delete(id);
} catch (e) {
throw Exception('Ошибка удаления правила ИИ: $e');
}
}
@override
Future<List<AiRule>> getByType(AiRuleType type) async {
try {
return _box.values.where((rule) => rule.type == type).toList();
} catch (e) {
throw Exception('Ошибка получения правил ИИ по типу: $e');
}
}
@override
Future<List<AiRule>> getActiveRules() async {
try {
return _box.values.where((rule) => rule.isActive).toList();
} catch (e) {
throw Exception('Ошибка получения активных правил ИИ: $e');
}
}
@override
Future<List<AiRule>> getByStatus(ProcessingStatus status) async {
try {
return _box.values.where((rule) => rule.processingStatus == status).toList();
} catch (e) {
throw Exception('Ошибка получения правил ИИ по статусу: $e');
}
}
@override
Future<List<AiRule>> getByPriority() async {
try {
final rules = _box.values.toList();
rules.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return rules;
} catch (e) {
throw Exception('Ошибка получения правил ИИ: $e');
}
}
@override
Future<void> addAll(List<AiRule> rules) async {
try {
final Map<String, AiRule> ruleMap = {
for (var rule in rules) rule.id: rule
};
await _box.putAll(ruleMap);
} catch (e) {
throw Exception('Ошибка пакетного добавления правил ИИ: $e');
}
}
@override
Future<void> updateStatus(String id, ProcessingStatus status) async {
try {
final rule = await getById(id);
if (rule != null) {
final updatedRule = rule.copyWith(processingStatus: status);
await update(updatedRule);
}
} catch (e) {
throw Exception('Ошибка обновления статуса правила ИИ: $e');
}
}
@override
Future<void> toggleActive(String id) async {
try {
final rule = await getById(id);
if (rule != null) {
final updatedRule = rule.copyWith(isActive: !rule.isActive);
await update(updatedRule);
}
} catch (e) {
throw Exception('Ошибка переключения активности правила ИИ: $e');
}
}
}
@@ -0,0 +1,36 @@
import 'package:hive_ce/hive.dart';
import '/models/ai_settings.dart';
import 'interfaces/iai_settings_repository.dart';
class HiveAiSettingsRepository implements IAiSettingsRepository {
final Box<AiSettings> _box;
HiveAiSettingsRepository(this._box);
static const String _settingsKey = 'ai_settings';
@override
AiSettings? getSettings() {
return _box.get(_settingsKey);
}
@override
Future<void> saveSettings(AiSettings settings) async {
await _box.put(_settingsKey, settings);
}
@override
Future<void> deleteSettings() async {
await _box.delete(_settingsKey);
}
@override
bool hasSettings() {
return _box.containsKey(_settingsKey);
}
@override
AiSettings getDefaultSettings() {
return AiSettings();
}
}
@@ -0,0 +1,59 @@
import 'package:budget_app/data/repositories/interfaces/iprefilled_transaction_repository.dart';
import 'package:budget_app/models/prefilled_transaction.dart';
import 'package:hive_ce/hive.dart';
/// Реализация репозитория для работы с предварительно заполненными транзакциями через Hive.
class HivePrefilledTransactionRepository
implements IPrefilledTransactionRepository {
final Box<PrefilledTransaction> _box;
HivePrefilledTransactionRepository(this._box);
@override
Future<List<PrefilledTransaction>> getAll() async {
try {
// Получаем все значения из бокса и возвращаем их в виде списка.
return _box.values.toList();
} catch (e) {
// В случае ошибки выбрасываем исключение для дальнейшей обработки.
throw Exception(
'Ошибка получения предварительно заполненных транзакций: $e',
);
}
}
@override
Future<void> add(PrefilledTransaction transaction) async {
try {
// Используем smsMessageId как ключ, поскольку transactionId может быть null
await _box.put(transaction.smsMessageId, transaction);
} catch (e) {
throw Exception(
'Ошибка добавления предварительно заполненной транзакции: $e',
);
}
}
@override
Future<void> update(PrefilledTransaction transaction) async {
try {
// Используем smsMessageId как ключ, поскольку transactionId может быть null
await _box.put(transaction.smsMessageId, transaction);
} catch (e) {
throw Exception(
'Ошибка обновления предварительно заполненной транзакции: $e',
);
}
}
@override
Future<void> delete(String transactionId) async {
try {
await _box.delete(transactionId);
} catch (e) {
throw Exception(
'Ошибка удаления предварительно заполненной транзакции: $e',
);
}
}
}
@@ -9,16 +9,18 @@ class HiveSmsHandlerRepository implements ISmsHandlerRepository {
HiveSmsHandlerRepository(this._smsHandlerBox);
static const String _settingsKey = 'sms_settings';
@override
Future<SmsHandlerSettings?> getSmsHandlerSettings() async {
// В Hive мы будем использовать ID пользователя как ключ для его настроек.
return _smsHandlerBox.getAt(1);
return _smsHandlerBox.get(_settingsKey);
}
@override
Future<void> saveSmsHandlerSettings(SmsHandlerSettings settings) async {
// Сохраняем объект настроек по ключу, равному ID пользователя.
await _smsHandlerBox.put(settings.id, settings);
await _smsHandlerBox.put(_settingsKey, settings);
}
@override
@@ -1,10 +1,12 @@
import 'package:hive_ce/hive.dart';
import 'package:logger/logger.dart';
import '/data/repositories/interfaces/isms_message_repository.dart';
import '/models/sms_message.dart';
/// Hive-реализация репозитория для работы с SMS сообщениями
class HiveSmsMessageRepository implements ISmsMessageRepository {
final Box<SmsMessage> _box;
final Logger _logger = Logger();
HiveSmsMessageRepository(this._box);
@@ -25,7 +27,20 @@ class HiveSmsMessageRepository implements ISmsMessageRepository {
@override
Future<void> update(SmsMessage message) async {
_logger.d('Updating SMS message in Hive: ${message.id}');
_logger.d('Message transactionId: ${message.transactionId}');
_logger.d('Full message: ${message.toMap()}');
try {
await add(message);
_logger.i('SMS message updated successfully in Hive');
} catch (e, stackTrace) {
_logger.e('Failed to update SMS message in Hive',
error: e,
stackTrace: stackTrace
);
rethrow;
}
}
@override
@@ -47,4 +62,95 @@ class HiveSmsMessageRepository implements ISmsMessageRepository {
.where((msg) => msg.transactionId == transactionId)
.toList();
}
@override
Future<List<SmsMessage>> getPaged({
required int page,
required int pageSize,
String? statusFilter,
DateTime? monthFilter,
}) async {
// 1. Фильтрация по статусу
Iterable<SmsMessage> filtered = _box.values;
if (statusFilter != null) {
final SmsStatus? filterStatus = _parseStatusFilter(statusFilter);
if (filterStatus != null) {
filtered = filtered.where((msg) => msg.status == filterStatus);
}
}
// 2. Фильтрация по месяцу
if (monthFilter != null) {
filtered = filtered.where((msg) =>
msg.date?.year == monthFilter.year &&
msg.date?.month == monthFilter.month
);
}
// 3. Сортировка по дате (новые сверху)
final sorted = filtered.toList()
..sort((a, b) => (b.date ?? DateTime(0)).compareTo(a.date ?? DateTime(0)));
// 4. Пагинация
final start = (page - 1) * pageSize;
if (start >= sorted.length) return [];
final end = start + pageSize;
return sorted.sublist(
start,
end.clamp(0, sorted.length),
);
}
@override
Future<DateTime?> getLastSmsDate() async {
final messages = _box.values.toList();
if (messages.isEmpty) return null;
messages.sort((a, b) => (b.date ?? DateTime(0)).compareTo(a.date ?? DateTime(0)));
return messages.first.date;
}
@override
Future<void> addSmsMessages(List<SmsMessage> messages) async {
await addAll(messages);
}
@override
Future<List<SmsMessage>> getUnprocessedSms() async {
final unprocessed = _box.values
.where((msg) => msg.status == SmsStatus.pending)
.toList();
// Сортировка по дате (новые сверху)
unprocessed.sort((a, b) => (b.date ?? DateTime(0)).compareTo(a.date ?? DateTime(0)));
return unprocessed;
}
@override
Future<void> updateSmsStatus(String id, SmsStatus status) async {
final message = _box.get(id);
if (message != null) {
final updatedMessage = message.copyWith(status: status);
await _box.put(id, updatedMessage);
}
}
/// Преобразует строковый фильтр в enum SmsStatus
SmsStatus? _parseStatusFilter(String statusFilter) {
switch (statusFilter.toLowerCase()) {
case 'pending':
return SmsStatus.pending;
case 'processed':
return SmsStatus.processed;
case 'ignored':
return SmsStatus.ignored;
case 'error':
return SmsStatus.error;
default:
return null;
}
}
}
@@ -48,7 +48,7 @@ class HiveTransactionRepository implements ITransactionRepository {
Future<List<TransactionRecord>> getByCategory(String categoryId) async {
// Этот метод также может быть изменен для фильтрации по пользователю
return _box.values
.where((t) => t.category.id == categoryId)
.where((t) => t.categoryId == categoryId)
.toList();
}
@@ -56,7 +56,7 @@ class HiveTransactionRepository implements ITransactionRepository {
Future<List<TransactionRecord>> getByTag(String tagId) async {
// Этот метод также может быть изменен для фильтрации по пользователю
return _box.values
.where((t) => t.tag?.id == tagId)
.where((t) => t.tagId == tagId)
.toList();
}
@@ -50,4 +50,19 @@ class HiveUserRepository implements IUserRepository {
// Удаляем пользователя по id
await _box.delete(id);
}
@override
Future<void> updateLastSmsSyncTime(String userId, DateTime syncTime) async {
// Получаем пользователя по id
final user = await getById(userId);
if (user == null) {
throw Exception('User with id $userId not found');
}
// Создаем копию пользователя с обновленным временем синхронизации
final updatedUser = user.copyWith(lastSmsSyncTime: syncTime);
// Сохраняем обновленного пользователя
await update(updatedUser);
}
}
@@ -0,0 +1,39 @@
import '/models/ai_rule.dart';
abstract class IAiRuleRepository {
/// Получить все правила ИИ
Future<List<AiRule>> getAll();
/// Получить правило по ID
Future<AiRule?> getById(String id);
/// Добавить новое правило
Future<void> add(AiRule rule);
/// Обновить существующее правило
Future<void> update(AiRule rule);
/// Удалить правило по ID
Future<void> delete(String id);
/// Получить правила по типу
Future<List<AiRule>> getByType(AiRuleType type);
/// Получить активные правила
Future<List<AiRule>> getActiveRules();
/// Получить правила по статусу обработки
Future<List<AiRule>> getByStatus(ProcessingStatus status);
/// Получить правила отсортированные по приоритету
Future<List<AiRule>> getByPriority();
/// Добавить список правил
Future<void> addAll(List<AiRule> rules);
/// Изменить статус правила
Future<void> updateStatus(String id, ProcessingStatus status);
/// Переключить активность правила
Future<void> toggleActive(String id);
}
@@ -0,0 +1,18 @@
import '/models/ai_settings.dart';
abstract class IAiSettingsRepository {
/// Получить настройки ИИ
AiSettings? getSettings();
/// Сохранить настройки ИИ
Future<void> saveSettings(AiSettings settings);
/// Удалить настройки ИИ
Future<void> deleteSettings();
/// Проверить существование настроек
bool hasSettings();
/// Получить настройки по умолчанию
AiSettings getDefaultSettings();
}
@@ -0,0 +1,16 @@
import 'package:budget_app/models/prefilled_transaction.dart';
/// Абстрактный класс для репозитория предварительно заполненных транзакций.
abstract class IPrefilledTransactionRepository {
/// Возвращает список всех предварительно заполненных транзакций.
Future<List<PrefilledTransaction>> getAll();
/// Добавляет новую предварительно заполненную транзакцию.
Future<void> add(PrefilledTransaction transaction);
/// Обновляет существующую предварительно заполненную транзакцию.
Future<void> update(PrefilledTransaction transaction);
/// Удаляет предварительно заполненную транзакцию по ее ID.
Future<void> delete(String transactionId);
}
@@ -22,4 +22,24 @@ abstract class ISmsMessageRepository {
/// Получает SMS сообщения, связанные с транзакцией
Future<List<SmsMessage>> getByTransactionId(String transactionId);
/// Получает SMS сообщения с пагинацией и фильтрацией
Future<List<SmsMessage>> getPaged({
required int page,
required int pageSize,
String? statusFilter,
DateTime? monthFilter,
});
/// Получает дату последнего SMS сообщения
Future<DateTime?> getLastSmsDate();
/// Добавляет несколько SMS сообщений
Future<void> addSmsMessages(List<SmsMessage> messages);
/// Получает необработанные SMS сообщения
Future<List<SmsMessage>> getUnprocessedSms();
/// Обновляет статус SMS сообщения
Future<void> updateSmsStatus(String id, SmsStatus status);
}
@@ -20,4 +20,7 @@ abstract class IUserRepository {
/// Удалить пользователя
Future<void> delete(String id);
/// Обновить время последней синхронизации SMS для пользователя
Future<void> updateLastSmsSyncTime(String userId, DateTime syncTime);
}
-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: {}
+20 -3
View File
@@ -3,10 +3,13 @@
// 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';
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/prefilled_transaction.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';
@@ -15,14 +18,21 @@ import 'package:budget_app/models/user.dart';
extension HiveRegistrar on HiveInterface {
void registerAdapters() {
registerAdapter(AiNonTransactionResponseAdapter());
registerAdapter(AiRuleAdapter());
registerAdapter(AiRuleTypeAdapter());
registerAdapter(AiSettingsAdapter());
registerAdapter(AiTransactionResponseAdapter());
registerAdapter(AppSettingsAdapter());
registerAdapter(CategoryAdapter());
registerAdapter(GlobalSettingsAdapter());
registerAdapter(IconDataAdapter());
registerAdapter(PrefilledTransactionAdapter());
registerAdapter(ProcessingStatusAdapter());
registerAdapter(SmsHandlerSettingsAdapter());
registerAdapter(SmsMessageAdapter());
registerAdapter(SmsProcessingRuleAdapter());
registerAdapter(SmsProcessingTypeAdapter());
registerAdapter(SmsStatusAdapter());
registerAdapter(TagAdapter());
registerAdapter(TransactionRecordAdapter());
registerAdapter(UserAdapter());
@@ -31,14 +41,21 @@ extension HiveRegistrar on HiveInterface {
extension IsolatedHiveRegistrar on IsolatedHiveInterface {
void registerAdapters() {
registerAdapter(AiNonTransactionResponseAdapter());
registerAdapter(AiRuleAdapter());
registerAdapter(AiRuleTypeAdapter());
registerAdapter(AiSettingsAdapter());
registerAdapter(AiTransactionResponseAdapter());
registerAdapter(AppSettingsAdapter());
registerAdapter(CategoryAdapter());
registerAdapter(GlobalSettingsAdapter());
registerAdapter(IconDataAdapter());
registerAdapter(PrefilledTransactionAdapter());
registerAdapter(ProcessingStatusAdapter());
registerAdapter(SmsHandlerSettingsAdapter());
registerAdapter(SmsMessageAdapter());
registerAdapter(SmsProcessingRuleAdapter());
registerAdapter(SmsProcessingTypeAdapter());
registerAdapter(SmsStatusAdapter());
registerAdapter(TagAdapter());
registerAdapter(TransactionRecordAdapter());
registerAdapter(UserAdapter());
+63
View File
@@ -0,0 +1,63 @@
// Generated by Hive CE
// Do not modify
// Check in to version control
import 'package:hive_ce/hive.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';
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/prefilled_transaction.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(AiNonTransactionResponseAdapter());
registerAdapter(AiRuleAdapter());
registerAdapter(AiRuleTypeAdapter());
registerAdapter(AiSettingsAdapter());
registerAdapter(AiTransactionResponseAdapter());
registerAdapter(AppSettingsAdapter());
registerAdapter(CategoryAdapter());
registerAdapter(GlobalSettingsAdapter());
registerAdapter(PrefilledTransactionAdapter());
registerAdapter(ProcessingStatusAdapter());
registerAdapter(SmsHandlerSettingsAdapter());
registerAdapter(SmsMessageAdapter());
registerAdapter(SmsProcessingRuleAdapter());
registerAdapter(SmsProcessingTypeAdapter());
registerAdapter(SmsStatusAdapter());
registerAdapter(TagAdapter());
registerAdapter(TransactionRecordAdapter());
registerAdapter(UserAdapter());
}
}
extension IsolatedHiveRegistrar on IsolatedHiveInterface {
void registerAdapters() {
registerAdapter(AiNonTransactionResponseAdapter());
registerAdapter(AiRuleAdapter());
registerAdapter(AiRuleTypeAdapter());
registerAdapter(AiSettingsAdapter());
registerAdapter(AiTransactionResponseAdapter());
registerAdapter(AppSettingsAdapter());
registerAdapter(CategoryAdapter());
registerAdapter(GlobalSettingsAdapter());
registerAdapter(PrefilledTransactionAdapter());
registerAdapter(ProcessingStatusAdapter());
registerAdapter(SmsHandlerSettingsAdapter());
registerAdapter(SmsMessageAdapter());
registerAdapter(SmsProcessingRuleAdapter());
registerAdapter(SmsProcessingTypeAdapter());
registerAdapter(SmsStatusAdapter());
registerAdapter(TagAdapter());
registerAdapter(TransactionRecordAdapter());
registerAdapter(UserAdapter());
}
}
+153 -14
View File
@@ -1,6 +1,12 @@
import 'package:budget_app/data/repositories/hive_prefilled_transaction_repository.dart';
import 'package:budget_app/data/repositories/interfaces/iprefilled_transaction_repository.dart';
import 'package:budget_app/logic/prefilled_transaction/prefilled_transaction_cubit.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_it/get_it.dart';
import 'data/database/hive_service.dart';
import 'data/repositories/hive_ai_rule_repository.dart';
import 'data/repositories/hive_ai_settings_repository.dart';
import 'data/repositories/hive_category_repository.dart';
import 'data/repositories/hive_global_settings_repository.dart';
import 'data/repositories/hive_settings_repository.dart';
@@ -9,6 +15,8 @@ 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/iai_rule_repository.dart';
import 'data/repositories/interfaces/iai_settings_repository.dart';
import 'data/repositories/interfaces/icategory_repository.dart';
import 'data/repositories/interfaces/iglobal_settings_repository.dart';
import 'data/repositories/interfaces/isettings_repository.dart';
@@ -17,20 +25,36 @@ import 'data/repositories/interfaces/isms_message_repository.dart';
import 'data/repositories/interfaces/itag_repository.dart';
import 'data/repositories/interfaces/itransaction_repository.dart';
import 'data/repositories/interfaces/iuser_repository.dart';
import 'logic/ai/ai_cubit.dart';
import 'logic/ai_rules/ai_rules_bloc.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/sms/sms_settings_cubit.dart';
import 'logic/tag/tag_cubit.dart';
import 'logic/transaction/transaction_bloc.dart';
import 'logic/user/user_cubit.dart';
import 'services/openrouter_ai_service.dart';
import 'services/sms_service.dart';
import 'services/sms_transaction_service.dart';
import 'services/user_service.dart';
import 'services/ai_transaction_processing_service.dart';
import 'services/interfaces/iai_service.dart';
import 'services/interfaces/iuser_service.dart';
final getIt = GetIt.instance;
/// Инициализация глобальных зависимостей, которые не зависят от пользователя.
/// Вызывается один раз при старте приложения.
Future<void> initGlobalDependencies() async {
// Загрузка переменных окружения
try {
await dotenv.load(fileName: ".env");
} catch (e) {
// Если файл .env не найден, продолжаем без него
print('Файл .env не найден или не загружен: $e');
}
// Инициализация Hive
await HiveService.initGlobalBoxes();
@@ -42,9 +66,6 @@ Future<void> initGlobalDependencies() async {
HiveGlobalSettingsRepository(HiveService.globalSettings),
);
// Сервисы
getIt.registerSingleton<SmsService>(SmsService());
// Cubits & Blocs, которые нужны до входа пользователя
getIt.registerSingleton<UserCubit>(
UserCubit(
@@ -64,6 +85,9 @@ Future<void> initGlobalDependencies() async {
settingsRepository: getIt(),
userRepository: getIt(),
));
// Глобальные сервисы
getIt.registerSingleton<IUserService>(UserService(getIt(), getIt()));
}
/// Инициализация зависимостей, специфичных для пользователя.
@@ -123,6 +147,66 @@ Future<void> initUserSpecificDependencies(String userId) async {
() => HiveSmsMessageRepository(HiveService.smsMessages),
);
// Правила ИИ
if (getIt.isRegistered<IAiRuleRepository>()) {
await getIt.unregister<IAiRuleRepository>();
}
getIt.registerLazySingleton<IAiRuleRepository>(
() => HiveAiRuleRepository(HiveService.aiRules),
);
// Предварительно заполненные транзакции
if (getIt.isRegistered<IPrefilledTransactionRepository>()) {
await getIt.unregister<IPrefilledTransactionRepository>();
}
getIt.registerLazySingleton<IPrefilledTransactionRepository>(
() => HivePrefilledTransactionRepository(HiveService.prefilledTransactions),
);
// Настройки ИИ
if (getIt.isRegistered<IAiSettingsRepository>()) {
await getIt.unregister<IAiSettingsRepository>();
}
getIt.registerLazySingleton<IAiSettingsRepository>(
() => HiveAiSettingsRepository(HiveService.aiSettings),
);
// SMS Service
if (getIt.isRegistered<SmsService>()) {
await getIt.unregister<SmsService>();
}
getIt.registerSingleton<SmsService>(SmsService());
// AI Service
if (getIt.isRegistered<IAiService>()) {
await getIt.unregister<IAiService>();
}
getIt.registerLazySingleton<IAiService>(() {
var aiSettings = getIt<IAiSettingsRepository>().getSettings() ??
getIt<IAiSettingsRepository>().getDefaultSettings();
// Если API ключ не установлен в настройках, попробуем взять из переменных окружения
if ((aiSettings.apiKey == null || aiSettings.apiKey!.isEmpty) &&
dotenv.env['OPENROUTER_API_KEY']?.isNotEmpty == true) {
aiSettings = aiSettings.copyWith(apiKey: dotenv.env['OPENROUTER_API_KEY']);
}
return OpenRouterAiService(aiSettings);
});
// AI Transaction Processing Service
if (getIt.isRegistered<AiTransactionProcessingService>()) {
await getIt.unregister<AiTransactionProcessingService>();
}
getIt.registerLazySingleton<AiTransactionProcessingService>(() =>
AiTransactionProcessingService(
aiService: getIt<IAiService>(),
prefilledRepository: getIt<IPrefilledTransactionRepository>(),
categoryRepository: getIt<ICategoryRepository>(),
aiRuleRepository: getIt<IAiRuleRepository>(),
)
);
// --- Обновление UserCubit новыми репозиториями ---
// Мы не пересоздаем UserCubit, а просто обновляем его зависимости.
final userCubit = getIt<UserCubit>();
@@ -144,29 +228,75 @@ Future<void> initUserSpecificDependencies(String userId) async {
if (getIt.isRegistered<TransactionBloc>()) {
await getIt.unregister<TransactionBloc>();
}
getIt.registerFactory<TransactionBloc>(
() => TransactionBloc(transactionRepository: getIt()),
getIt.registerSingleton<TransactionBloc>(
TransactionBloc(transactionRepository: getIt()),
);
// SMS
if (getIt.isRegistered<SmsCubit>()) {
await getIt.unregister<SmsCubit>();
// SMS Transaction Service
if (getIt.isRegistered<SmsTransactionService>()) {
await getIt.unregister<SmsTransactionService>();
}
getIt.registerFactory<SmsCubit>(() => SmsCubit(getIt(), getIt(), getIt()));
getIt.registerSingleton<SmsTransactionService>(SmsTransactionService(
getIt<ISmsHandlerRepository>(),
getIt<ITransactionRepository>(),
getIt<IAiRuleRepository>(),
getIt<TransactionBloc>(),
));
// Категории
if (getIt.isRegistered<CategoryCubit>()) {
await getIt.unregister<CategoryCubit>();
}
getIt.registerFactory<CategoryCubit>(
() => CategoryCubit(getIt()), // Добавляем userId
getIt.registerSingleton<CategoryCubit>(
CategoryCubit(getIt()),
);
// Теги
if (getIt.isRegistered<TagCubit>()) {
await getIt.unregister<TagCubit>();
}
getIt.registerFactory<TagCubit>(() => TagCubit(getIt()));
getIt.registerSingleton<TagCubit>(TagCubit(getIt()));
// Настройки обработки SMS
if (getIt.isRegistered<SmsSettingsCubit>()) {
await getIt.unregister<SmsSettingsCubit>();
}
getIt.registerFactory<SmsSettingsCubit>(
() => SmsSettingsCubit(
getIt<ISmsHandlerRepository>(),
),
);
// Правила ИИ
if (getIt.isRegistered<AiRulesBloc>()) {
await getIt.unregister<AiRulesBloc>();
}
getIt.registerFactory<AiRulesBloc>(
() => AiRulesBloc(
aiRuleRepository: getIt<IAiRuleRepository>(),
),
);
// Предварительно заполненные транзакции
if (getIt.isRegistered<PrefilledTransactionCubit>()) {
await getIt.unregister<PrefilledTransactionCubit>();
}
getIt.registerFactory<PrefilledTransactionCubit>(
() => PrefilledTransactionCubit(
getIt<IPrefilledTransactionRepository>(),
),
);
// AI Cubit
if (getIt.isRegistered<AiCubit>()) {
await getIt.unregister<AiCubit>();
}
getIt.registerFactory<AiCubit>(
() => AiCubit(
settingsRepository: getIt<IAiSettingsRepository>(),
aiService: getIt<IAiService>(),
),
);
}
/// Сброс пользовательских зависимостей при выходе из системы.
@@ -181,9 +311,18 @@ Future<void> resetUserSpecificDependencies() async {
await getIt.unregister<ISettingsRepository>();
await getIt.unregister<ISmsHandlerRepository>();
await getIt.unregister<ISmsMessageRepository>();
await getIt.unregister<SmsService>();
await getIt.unregister<SettingsCubit>();
await getIt.unregister<TransactionBloc>();
await getIt.unregister<SmsCubit>();
await getIt.unregister<SmsTransactionService>();
await getIt.unregister<CategoryCubit>();
await getIt.unregister<TagCubit>();
await getIt.unregister<IAiRuleRepository>();
await getIt.unregister<IAiSettingsRepository>();
if (getIt.isRegistered<IAiService>()) {
await getIt.unregister<IAiService>();
}
if (getIt.isRegistered<AiTransactionProcessingService>()) {
await getIt.unregister<AiTransactionProcessingService>();
}
}
+244 -1
View File
@@ -1,5 +1,6 @@
{
"@@locale": "en",
"editTransaction": "Edit transaction",
"appTitle": "Budget App",
"homePageTitle": "Home",
"reportsPageTitle": "Reports",
@@ -35,6 +36,7 @@
"invalidNumber": "Invalid number",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"tag": "Tag",
"icon": "Icon",
"smsPageTitle": "SMS Messages",
@@ -56,5 +58,246 @@
"createTransaction": "Create transaction",
"unknownSender": "Unknown sender",
"smsProcessed": "Processed",
"smsNotProcessed": "Not processed"
"smsNotProcessed": "Not processed",
"smsSettingsTitle": "SMS Processing Settings",
"smsSettingsForSender": "Settings for sender",
"ruleTypeLabel": "Processing type",
"regexpType": "Regular expression",
"customFunctionType": "Custom function",
"noProcessingType": "No processing required",
"ruleTypeRequired": "Processing type is required",
"regexpPatternHint": "Regular expression pattern",
"regexpPatternRequired": "Pattern is required",
"customFunctionIdHint": "Custom function ID",
"customFunctionIdRequired": "Function ID is required",
"ruleSavedSuccess": "Rule saved successfully",
"ruleDeletedSuccess": "Rule deleted successfully",
"unknownError": "An unknown error occurred",
"transactionCreatedSuccessfully": "Transaction created successfully",
"januaryShort": "January",
"februaryShort": "February",
"marchShort": "March",
"aprilShort": "April",
"mayShort": "May",
"juneShort": "June",
"julyShort": "July",
"augustShort": "August",
"septemberShort": "September",
"octoberShort": "October",
"novemberShort": "November",
"decemberShort": "December",
"januaryAbbr": "Jan",
"februaryAbbr": "Feb",
"marchAbbr": "Mar",
"aprilAbbr": "Apr",
"mayAbbr": "May",
"juneAbbr": "Jun",
"julyAbbr": "Jul",
"augustAbbr": "Aug",
"septemberAbbr": "Sep",
"octoberAbbr": "Oct",
"novemberAbbr": "Nov",
"decemberAbbr": "Dec",
"autoCreateTransactionsSetting": "Auto-create transactions from SMS",
"autoCreateTransactionsDescription": "Automatically create transactions when receiving SMS messages",
"allFilter": "All",
"newFilter": "New",
"processedFilter": "Processed",
"notRequiredFilter": "Not required",
"errorFilter": "Error",
"transactionCreated": "Transaction created",
"smsStatusProcessed": "Processed",
"smsStatusError": "Error",
"smsStatusNotRequired": "Not required",
"smsStatusPending": "Pending",
"aiRulesPageTitle": "AI Rules",
"createRule": "Create Rule",
"editRule": "Edit Rule",
"ruleType": "Rule Type",
"pointOfSale": "Point of Sale",
"skipSms": "Skip SMS",
"ruleName": "Rule Name",
"merchantPattern": "Merchant Pattern",
"merchantPatternHelper": "Regular expression to search in SMS text",
"category": "Category",
"categoryHelper": "Select category for auto-assignment",
"skipRegex": "Regular Expression",
"skipRegexHelper": "Pattern to skip unwanted SMS",
"settings": "Settings",
"activeRule": "Active Rule",
"activeRuleHelper": "Rule will be applied to new SMS",
"processingStatus": "Processing Status",
"statusCreated": "Created",
"statusProcessed": "Processed",
"statusNeedsAttention": "Needs Attention",
"statusRejected": "Rejected",
"pointOfSaleSettings": "Point of Sale Settings",
"skipSettings": "Skip Settings",
"merchantPatternRequired": "Pattern cannot be empty",
"categoryRequired": "Category must be selected",
"skipRegexRequired": "Regular expression cannot be empty",
"invalidRegex": "Invalid regular expression",
"ruleCreated": "Rule created",
"ruleUpdated": "Rule updated",
"ruleDeleted": "Rule deleted",
"ruleDuplicated": "Rule duplicated",
"testRule": "Test",
"testRuleTitle": "Test Rule",
"enterSmsText": "Enter SMS text for testing:",
"smsTextPlaceholder": "SMS message text...",
"duplicateRule": "Duplicate",
"deleteRule": "Delete",
"deleteRuleConfirm": "Are you sure you want to delete rule \"{ruleName}\"?",
"@deleteRuleConfirm": {
"placeholders": {
"ruleName": {
"type": "String"
}
}
},
"noRulesCreated": "No rules created",
"noRulesByFilter": "No rules match filter",
"createFirstRule": "Create your first rule for automatic SMS processing",
"changeFilters": "Try changing filters to find rules",
"importRules": "Import Rules",
"exportRules": "Export Rules",
"testAllRules": "Test All",
"importRulesMessage": "Import rules from file feature will be implemented in future versions.",
"exportRulesMessage": "Export rules to file feature will be implemented in future versions.",
"testAllRulesMessage": "Do you want to test all active rules on recent SMS messages?",
"testingStarted": "Testing started",
"unknownCategory": "Unknown Category",
"rulePatternLabel": "Rule",
"skipSmsRuleDefault": "Skip SMS Rule",
"closeText": "Close",
"cancelText": "Cancel",
"retryText": "Retry",
"startText": "Start",
"saveText": "Save",
"errorText": "Error",
"validationErrorsTitle": "Validation Errors",
"understandText": "Understand",
"notSetText": "Not set",
"notSelectedText": "Not selected",
"smsMessages": "SMS Messages",
"smsTooltip": "SMS Settings",
"processingRules": "Processing Rules",
"syncSms": "SMS Synchronization",
"autoProcessing": "Auto Processing",
"filterByStatus": "Filter by status:",
"allStatus": "All",
"pendingStatus": "Pending",
"processedStatus": "Processed",
"ignoredStatus": "Ignored",
"errorStatus": "Error",
"processingRulesDescription": "Here you can configure SMS message processing rules.",
"close": "Close",
"autoProcessingDescription": "Configure automatic processing of incoming SMS.",
"syncSmsStarted": "SMS synchronization started",
"noNewSmsMessages": "No new SMS messages.",
"errorLoading": "Error: {error}",
"@errorLoading": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"somethingWentWrong": "Something went wrong.",
"unknownSender": "Unknown",
"messageActions": "Message actions",
"ruleSettings": "Rule Settings",
"processMessage": "Process Message",
"createTransactionAction": "Create Transaction",
"ignoreAction": "Ignore",
"viewTransaction": "View Transaction",
"returnToProcessing": "Return to Processing",
"retry": "Retry",
"deleteMessage": "Delete message?",
"deleteConfirmation": "This action cannot be undone.",
"transactionDetails": "Transaction Details",
"transactionForSms": "Transaction for SMS from {sender}",
"@transactionForSms": {
"placeholders": {
"sender": {
"type": "String"
}
}
},
"notImplemented": "{feature} is not yet implemented",
"@notImplemented": {
"placeholders": {
"feature": {
"type": "String"
}
}
},
"deleteSmsFeature": "Delete messages",
"returnToProcessingFeature": "Return to processing function",
"daysAgo": "{days} days ago",
"@daysAgo": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"hoursAgo": "{hours} hours ago",
"@hoursAgo": {
"placeholders": {
"hours": {
"type": "int"
}
}
},
"minutesAgo": "{minutes} minutes ago",
"@minutesAgo": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"justNow": "Just now",
"ruleLoadError": "Error loading rule: {error}",
"@ruleLoadError": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"ruleSaveError": "Error saving rule: {error}",
"@ruleSaveError": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"ruleDeleteError": "Error deleting rule: {error}",
"@ruleDeleteError": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"noRuleForSender": "Processing rule not configured for sender: {sender}",
"@noRuleForSender": {
"placeholders": {
"sender": {
"type": "String"
}
}
},
"processingError": "Error processing SMS",
"serviceTransactionsTitle": "Service Transactions",
"noData": "No data",
"salesPoint": "Sales Point",
"confidence": "Confidence",
"exclusionRegex": "Exclusion Regex",
"notDefined": "Not defined",
"userNotAuthenticatedError": "User not authenticated. Please log in again."
}
File diff suppressed because it is too large Load Diff
+523 -1
View File
@@ -8,6 +8,9 @@ import 'app_localizations.dart';
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get editTransaction => 'Edit transaction';
@override
String get appTitle => 'Budget App';
@@ -115,6 +118,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get save => 'Save';
@override
String get delete => 'Delete';
@override
String get tag => 'Tag';
@@ -174,11 +180,527 @@ class AppLocalizationsEn extends AppLocalizations {
String get createTransaction => 'Create transaction';
@override
String get unknownSender => 'Unknown sender';
String get unknownSender => 'Unknown';
@override
String get smsProcessed => 'Processed';
@override
String get smsNotProcessed => 'Not processed';
@override
String get smsSettingsTitle => 'SMS Processing Settings';
@override
String get smsSettingsForSender => 'Settings for sender';
@override
String get ruleTypeLabel => 'Processing type';
@override
String get regexpType => 'Regular expression';
@override
String get customFunctionType => 'Custom function';
@override
String get noProcessingType => 'No processing required';
@override
String get ruleTypeRequired => 'Processing type is required';
@override
String get regexpPatternHint => 'Regular expression pattern';
@override
String get regexpPatternRequired => 'Pattern is required';
@override
String get customFunctionIdHint => 'Custom function ID';
@override
String get customFunctionIdRequired => 'Function ID is required';
@override
String get ruleSavedSuccess => 'Rule saved successfully';
@override
String get ruleDeletedSuccess => 'Rule deleted successfully';
@override
String get unknownError => 'An unknown error occurred';
@override
String get transactionCreatedSuccessfully =>
'Transaction created successfully';
@override
String get januaryShort => 'January';
@override
String get februaryShort => 'February';
@override
String get marchShort => 'March';
@override
String get aprilShort => 'April';
@override
String get mayShort => 'May';
@override
String get juneShort => 'June';
@override
String get julyShort => 'July';
@override
String get augustShort => 'August';
@override
String get septemberShort => 'September';
@override
String get octoberShort => 'October';
@override
String get novemberShort => 'November';
@override
String get decemberShort => 'December';
@override
String get januaryAbbr => 'Jan';
@override
String get februaryAbbr => 'Feb';
@override
String get marchAbbr => 'Mar';
@override
String get aprilAbbr => 'Apr';
@override
String get mayAbbr => 'May';
@override
String get juneAbbr => 'Jun';
@override
String get julyAbbr => 'Jul';
@override
String get augustAbbr => 'Aug';
@override
String get septemberAbbr => 'Sep';
@override
String get octoberAbbr => 'Oct';
@override
String get novemberAbbr => 'Nov';
@override
String get decemberAbbr => 'Dec';
@override
String get autoCreateTransactionsSetting =>
'Auto-create transactions from SMS';
@override
String get autoCreateTransactionsDescription =>
'Automatically create transactions when receiving SMS messages';
@override
String get allFilter => 'All';
@override
String get newFilter => 'New';
@override
String get processedFilter => 'Processed';
@override
String get notRequiredFilter => 'Not required';
@override
String get errorFilter => 'Error';
@override
String get transactionCreated => 'Transaction created';
@override
String get smsStatusProcessed => 'Processed';
@override
String get smsStatusError => 'Error';
@override
String get smsStatusNotRequired => 'Not required';
@override
String get smsStatusPending => 'Pending';
@override
String get aiRulesPageTitle => 'AI Rules';
@override
String get createRule => 'Create Rule';
@override
String get editRule => 'Edit Rule';
@override
String get ruleType => 'Rule Type';
@override
String get pointOfSale => 'Point of Sale';
@override
String get skipSms => 'Skip SMS';
@override
String get ruleName => 'Rule Name';
@override
String get merchantPattern => 'Merchant Pattern';
@override
String get merchantPatternHelper =>
'Regular expression to search in SMS text';
@override
String get categoryHelper => 'Select category for auto-assignment';
@override
String get skipRegex => 'Regular Expression';
@override
String get skipRegexHelper => 'Pattern to skip unwanted SMS';
@override
String get settings => 'Settings';
@override
String get activeRule => 'Active Rule';
@override
String get activeRuleHelper => 'Rule will be applied to new SMS';
@override
String get processingStatus => 'Processing Status';
@override
String get statusCreated => 'Created';
@override
String get statusProcessed => 'Processed';
@override
String get statusNeedsAttention => 'Needs Attention';
@override
String get statusRejected => 'Rejected';
@override
String get pointOfSaleSettings => 'Point of Sale Settings';
@override
String get skipSettings => 'Skip Settings';
@override
String get merchantPatternRequired => 'Pattern cannot be empty';
@override
String get categoryRequired => 'Category must be selected';
@override
String get skipRegexRequired => 'Regular expression cannot be empty';
@override
String get invalidRegex => 'Invalid regular expression';
@override
String get ruleCreated => 'Rule created';
@override
String get ruleUpdated => 'Rule updated';
@override
String get ruleDeleted => 'Rule deleted';
@override
String get ruleDuplicated => 'Rule duplicated';
@override
String get testRule => 'Test';
@override
String get testRuleTitle => 'Test Rule';
@override
String get enterSmsText => 'Enter SMS text for testing:';
@override
String get smsTextPlaceholder => 'SMS message text...';
@override
String get duplicateRule => 'Duplicate';
@override
String get deleteRule => 'Delete';
@override
String deleteRuleConfirm(String ruleName) {
return 'Are you sure you want to delete rule \"$ruleName\"?';
}
@override
String get noRulesCreated => 'No rules created';
@override
String get noRulesByFilter => 'No rules match filter';
@override
String get createFirstRule =>
'Create your first rule for automatic SMS processing';
@override
String get changeFilters => 'Try changing filters to find rules';
@override
String get importRules => 'Import Rules';
@override
String get exportRules => 'Export Rules';
@override
String get testAllRules => 'Test All';
@override
String get importRulesMessage =>
'Import rules from file feature will be implemented in future versions.';
@override
String get exportRulesMessage =>
'Export rules to file feature will be implemented in future versions.';
@override
String get testAllRulesMessage =>
'Do you want to test all active rules on recent SMS messages?';
@override
String get testingStarted => 'Testing started';
@override
String get unknownCategory => 'Unknown Category';
@override
String get rulePatternLabel => 'Rule';
@override
String get skipSmsRuleDefault => 'Skip SMS Rule';
@override
String get closeText => 'Close';
@override
String get cancelText => 'Cancel';
@override
String get retryText => 'Retry';
@override
String get startText => 'Start';
@override
String get saveText => 'Save';
@override
String get errorText => 'Error';
@override
String get validationErrorsTitle => 'Validation Errors';
@override
String get understandText => 'Understand';
@override
String get notSetText => 'Not set';
@override
String get notSelectedText => 'Not selected';
@override
String get smsMessages => 'SMS Messages';
@override
String get smsTooltip => 'SMS Settings';
@override
String get processingRules => 'Processing Rules';
@override
String get syncSms => 'SMS Synchronization';
@override
String get autoProcessing => 'Auto Processing';
@override
String get filterByStatus => 'Filter by status:';
@override
String get allStatus => 'All';
@override
String get pendingStatus => 'Pending';
@override
String get processedStatus => 'Processed';
@override
String get ignoredStatus => 'Ignored';
@override
String get errorStatus => 'Error';
@override
String get processingRulesDescription =>
'Here you can configure SMS message processing rules.';
@override
String get close => 'Close';
@override
String get autoProcessingDescription =>
'Configure automatic processing of incoming SMS.';
@override
String get syncSmsStarted => 'SMS synchronization started';
@override
String get noNewSmsMessages => 'No new SMS messages.';
@override
String errorLoading(String error) {
return 'Error: $error';
}
@override
String get somethingWentWrong => 'Something went wrong.';
@override
String get messageActions => 'Message actions';
@override
String get ruleSettings => 'Rule Settings';
@override
String get processMessage => 'Process Message';
@override
String get createTransactionAction => 'Create Transaction';
@override
String get ignoreAction => 'Ignore';
@override
String get viewTransaction => 'View Transaction';
@override
String get returnToProcessing => 'Return to Processing';
@override
String get retry => 'Retry';
@override
String get deleteMessage => 'Delete message?';
@override
String get deleteConfirmation => 'This action cannot be undone.';
@override
String get transactionDetails => 'Transaction Details';
@override
String transactionForSms(String sender) {
return 'Transaction for SMS from $sender';
}
@override
String notImplemented(String feature) {
return '$feature is not yet implemented';
}
@override
String get deleteSmsFeature => 'Delete messages';
@override
String get returnToProcessingFeature => 'Return to processing function';
@override
String daysAgo(int days) {
return '$days days ago';
}
@override
String hoursAgo(int hours) {
return '$hours hours ago';
}
@override
String minutesAgo(int minutes) {
return '$minutes minutes ago';
}
@override
String get justNow => 'Just now';
@override
String ruleLoadError(String error) {
return 'Error loading rule: $error';
}
@override
String ruleSaveError(String error) {
return 'Error saving rule: $error';
}
@override
String ruleDeleteError(String error) {
return 'Error deleting rule: $error';
}
@override
String noRuleForSender(String sender) {
return 'Processing rule not configured for sender: $sender';
}
@override
String get processingError => 'Error processing SMS';
@override
String get serviceTransactionsTitle => 'Service Transactions';
@override
String get noData => 'No data';
@override
String get salesPoint => 'Sales Point';
@override
String get confidence => 'Confidence';
@override
String get exclusionRegex => 'Exclusion Regex';
@override
String get notDefined => 'Not defined';
@override
String get userNotAuthenticatedError =>
'User not authenticated. Please log in again.';
}
+521 -1
View File
@@ -8,6 +8,9 @@ import 'app_localizations.dart';
class AppLocalizationsRu extends AppLocalizations {
AppLocalizationsRu([String locale = 'ru']) : super(locale);
@override
String get editTransaction => 'Редактировать транзакцию';
@override
String get appTitle => 'Бюджетное приложение';
@@ -116,6 +119,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get save => 'Сохранить';
@override
String get delete => 'Удалить';
@override
String get tag => 'Тег';
@@ -177,11 +183,525 @@ class AppLocalizationsRu extends AppLocalizations {
String get createTransaction => 'Создать транзакцию';
@override
String get unknownSender => 'Неизвестный отправитель';
String get unknownSender => 'Unknown';
@override
String get smsProcessed => 'Обработано';
@override
String get smsNotProcessed => 'Не обработано';
@override
String get smsSettingsTitle => 'Настройки обработки SMS';
@override
String get smsSettingsForSender => 'Настройки для отправителя';
@override
String get ruleTypeLabel => 'Тип обработки';
@override
String get regexpType => 'Регулярное выражение';
@override
String get customFunctionType => 'Кастомная функция';
@override
String get noProcessingType => 'Не требует обработки';
@override
String get ruleTypeRequired => 'Тип обработки обязателен';
@override
String get regexpPatternHint => 'Шаблон регулярного выражения';
@override
String get regexpPatternRequired => 'Шаблон обязателен';
@override
String get customFunctionIdHint => 'ID кастомной функции';
@override
String get customFunctionIdRequired => 'ID функции обязателен';
@override
String get ruleSavedSuccess => 'Правило успешно сохранено';
@override
String get ruleDeletedSuccess => 'Правило успешно удалено';
@override
String get unknownError => 'Произошла неизвестная ошибка';
@override
String get transactionCreatedSuccessfully => 'Транзакция успешно создана';
@override
String get januaryShort => 'Январь';
@override
String get februaryShort => 'Февраль';
@override
String get marchShort => 'Март';
@override
String get aprilShort => 'Апрель';
@override
String get mayShort => 'Май';
@override
String get juneShort => 'Июнь';
@override
String get julyShort => 'Июль';
@override
String get augustShort => 'Август';
@override
String get septemberShort => 'Сентябрь';
@override
String get octoberShort => 'Октябрь';
@override
String get novemberShort => 'Ноябрь';
@override
String get decemberShort => 'Декабрь';
@override
String get januaryAbbr => 'Янв';
@override
String get februaryAbbr => 'Фев';
@override
String get marchAbbr => 'Март';
@override
String get aprilAbbr => 'Апр';
@override
String get mayAbbr => 'Май';
@override
String get juneAbbr => 'Июнь';
@override
String get julyAbbr => 'Июль';
@override
String get augustAbbr => 'Авг';
@override
String get septemberAbbr => 'Сен';
@override
String get octoberAbbr => 'Окт';
@override
String get novemberAbbr => 'Ноя';
@override
String get decemberAbbr => 'Дек';
@override
String get autoCreateTransactionsSetting => 'Автосоздание транзакций из SMS';
@override
String get autoCreateTransactionsDescription =>
'Автоматически создавать транзакции при получении SMS';
@override
String get allFilter => 'Все';
@override
String get newFilter => 'Новые';
@override
String get processedFilter => 'Обработанные';
@override
String get notRequiredFilter => 'Не требуется';
@override
String get errorFilter => 'Ошибка';
@override
String get transactionCreated => 'Транзакция создана';
@override
String get smsStatusProcessed => 'Обработано';
@override
String get smsStatusError => 'Ошибка';
@override
String get smsStatusNotRequired => 'Не требуется';
@override
String get smsStatusPending => 'Ожидает';
@override
String get aiRulesPageTitle => 'Правила ИИ';
@override
String get createRule => 'Создать правило';
@override
String get editRule => 'Редактировать правило';
@override
String get ruleType => 'Тип правила';
@override
String get pointOfSale => 'Точка продаж';
@override
String get skipSms => 'Пропуск SMS';
@override
String get ruleName => 'Название правила';
@override
String get merchantPattern => 'Паттерн торговой точки';
@override
String get merchantPatternHelper =>
'Регулярное выражение для поиска в тексте SMS';
@override
String get categoryHelper => 'Выберите категорию для автоназначения';
@override
String get skipRegex => 'Регулярное выражение';
@override
String get skipRegexHelper => 'Шаблон для пропуска нежелательных SMS';
@override
String get settings => 'Настройки';
@override
String get activeRule => 'Активное правило';
@override
String get activeRuleHelper => 'Правило будет применяться к новым SMS';
@override
String get processingStatus => 'Статус обработки';
@override
String get statusCreated => 'Создано';
@override
String get statusProcessed => 'Обработано';
@override
String get statusNeedsAttention => 'Требует внимания';
@override
String get statusRejected => 'Отклонено';
@override
String get pointOfSaleSettings => 'Настройки точки продаж';
@override
String get skipSettings => 'Настройки пропуска';
@override
String get merchantPatternRequired => 'Паттерн не может быть пустым';
@override
String get categoryRequired => 'Категория должна быть выбрана';
@override
String get skipRegexRequired => 'Регулярное выражение не может быть пустым';
@override
String get invalidRegex => 'Некорректное регулярное выражение';
@override
String get ruleCreated => 'Правило создано';
@override
String get ruleUpdated => 'Правило обновлено';
@override
String get ruleDeleted => 'Правило удалено';
@override
String get ruleDuplicated => 'Правило дублировано';
@override
String get testRule => 'Тестировать';
@override
String get testRuleTitle => 'Тестировать правило';
@override
String get enterSmsText => 'Введите текст SMS для тестирования:';
@override
String get smsTextPlaceholder => 'Текст SMS сообщения...';
@override
String get duplicateRule => 'Дублировать';
@override
String get deleteRule => 'Удалить';
@override
String deleteRuleConfirm(String ruleName) {
return 'Вы уверены, что хотите удалить правило \"$ruleName\"?';
}
@override
String get noRulesCreated => 'Нет созданных правил';
@override
String get noRulesByFilter => 'Нет правил по фильтру';
@override
String get createFirstRule =>
'Создайте первое правило для автоматической обработки SMS';
@override
String get changeFilters => 'Попробуйте изменить фильтры для поиска правил';
@override
String get importRules => 'Импорт правил';
@override
String get exportRules => 'Экспорт правил';
@override
String get testAllRules => 'Тестировать все';
@override
String get importRulesMessage =>
'Функция импорта правил из файла будет реализована в следующих версиях.';
@override
String get exportRulesMessage =>
'Функция экспорта правил в файл будет реализована в следующих версиях.';
@override
String get testAllRulesMessage =>
'Вы хотите протестировать все активные правила на последних SMS сообщениях?';
@override
String get testingStarted => 'Тестирование начато';
@override
String get unknownCategory => 'Неизвестная категория';
@override
String get rulePatternLabel => 'Правило';
@override
String get skipSmsRuleDefault => 'Правило пропуска SMS';
@override
String get closeText => 'Закрыть';
@override
String get cancelText => 'Отмена';
@override
String get retryText => 'Повторить';
@override
String get startText => 'Начать';
@override
String get saveText => 'Сохранить';
@override
String get errorText => 'Ошибка';
@override
String get validationErrorsTitle => 'Ошибки валидации';
@override
String get understandText => 'Понятно';
@override
String get notSetText => 'Не задано';
@override
String get notSelectedText => 'Не выбрано';
@override
String get smsMessages => 'SMS Messages';
@override
String get smsTooltip => 'Настройки SMS';
@override
String get processingRules => 'Правила обработки';
@override
String get syncSms => 'Синхронизация SMS';
@override
String get autoProcessing => 'Автообработка';
@override
String get filterByStatus => 'Фильтр по статусу:';
@override
String get allStatus => 'Все';
@override
String get pendingStatus => 'Ожидает';
@override
String get processedStatus => 'Обработано';
@override
String get ignoredStatus => 'Игнорировать';
@override
String get errorStatus => 'Ошибка';
@override
String get processingRulesDescription =>
'Здесь можно настроить правила обработки SMS сообщений.';
@override
String get close => 'Закрыть';
@override
String get autoProcessingDescription =>
'Настройка автоматической обработки входящих SMS.';
@override
String get syncSmsStarted => 'Синхронизация SMS начата';
@override
String get noNewSmsMessages => 'No new SMS messages.';
@override
String errorLoading(String error) {
return 'Error: $error';
}
@override
String get somethingWentWrong => 'Something went wrong.';
@override
String get messageActions => 'Действия с сообщением';
@override
String get ruleSettings => 'Настройка правил';
@override
String get processMessage => 'Обработать сообщение';
@override
String get createTransactionAction => 'Создать транзакцию';
@override
String get ignoreAction => 'Игнорировать';
@override
String get viewTransaction => 'Посмотреть транзакцию';
@override
String get returnToProcessing => 'Вернуть в обработку';
@override
String get retry => 'Повторить';
@override
String get deleteMessage => 'Удалить сообщение?';
@override
String get deleteConfirmation => 'Это действие нельзя будет отменить.';
@override
String get transactionDetails => 'Детали транзакции';
@override
String transactionForSms(String sender) {
return 'Транзакция для SMS от $sender';
}
@override
String notImplemented(String feature) {
return '$feature пока не реализована';
}
@override
String get deleteSmsFeature => 'Удаление сообщений';
@override
String get returnToProcessingFeature => 'Функция возврата в обработку';
@override
String daysAgo(int days) {
return '$days дн. назад';
}
@override
String hoursAgo(int hours) {
return '$hours ч. назад';
}
@override
String minutesAgo(int minutes) {
return '$minutes мин. назад';
}
@override
String get justNow => 'Только что';
@override
String ruleLoadError(String error) {
return 'Ошибка загрузки правила: $error';
}
@override
String ruleSaveError(String error) {
return 'Ошибка сохранения правила: $error';
}
@override
String ruleDeleteError(String error) {
return 'Ошибка удаления правила: $error';
}
@override
String noRuleForSender(String sender) {
return 'Правило обработки не настроено для отправителя: $sender';
}
@override
String get processingError => 'Ошибка при обработке SMS';
@override
String get serviceTransactionsTitle => 'Служебные транзакции';
@override
String get noData => 'Нет данных';
@override
String get salesPoint => 'Точка продаж';
@override
String get confidence => 'Уверенность';
@override
String get exclusionRegex => 'Регулярное выражение для исключения';
@override
String get notDefined => 'Не определена';
@override
String get userNotAuthenticatedError =>
'Пользователь не аутентифицирован. Пожалуйста, войдите снова.';
}
+244 -1
View File
@@ -1,5 +1,6 @@
{
"@@locale": "ru",
"editTransaction": "Редактировать транзакцию",
"appTitle": "Бюджетное приложение",
"homePageTitle": "Главная",
"reportsPageTitle": "Отчеты",
@@ -35,6 +36,7 @@
"invalidNumber": "Неверный формат числа",
"cancel": "Отмена",
"save": "Сохранить",
"delete": "Удалить",
"tag": "Тег",
"icon": "Иконка",
"smsPageTitle": "SMS Сообщения",
@@ -56,5 +58,246 @@
"createTransaction": "Создать транзакцию",
"unknownSender": "Неизвестный отправитель",
"smsProcessed": "Обработано",
"smsNotProcessed": "Не обработано"
"smsNotProcessed": "Не обработано",
"smsSettingsTitle": "Настройки обработки SMS",
"smsSettingsForSender": "Настройки для отправителя",
"ruleTypeLabel": "Тип обработки",
"regexpType": "Регулярное выражение",
"customFunctionType": "Кастомная функция",
"noProcessingType": "Не требует обработки",
"ruleTypeRequired": "Тип обработки обязателен",
"regexpPatternHint": "Шаблон регулярного выражения",
"regexpPatternRequired": "Шаблон обязателен",
"customFunctionIdHint": "ID кастомной функции",
"customFunctionIdRequired": "ID функции обязателен",
"ruleSavedSuccess": "Правило успешно сохранено",
"ruleDeletedSuccess": "Правило успешно удалено",
"unknownError": "Произошла неизвестная ошибка",
"transactionCreatedSuccessfully": "Транзакция успешно создана",
"januaryShort": "Январь",
"februaryShort": "Февраль",
"marchShort": "Март",
"aprilShort": "Апрель",
"mayShort": "Май",
"juneShort": "Июнь",
"julyShort": "Июль",
"augustShort": "Август",
"septemberShort": "Сентябрь",
"octoberShort": "Октябрь",
"novemberShort": "Ноябрь",
"decemberShort": "Декабрь",
"januaryAbbr": "Янв",
"februaryAbbr": "Фев",
"marchAbbr": "Март",
"aprilAbbr": "Апр",
"mayAbbr": "Май",
"juneAbbr": "Июнь",
"julyAbbr": "Июль",
"augustAbbr": "Авг",
"septemberAbbr": "Сен",
"octoberAbbr": "Окт",
"novemberAbbr": "Ноя",
"decemberAbbr": "Дек",
"autoCreateTransactionsSetting": "Автосоздание транзакций из SMS",
"autoCreateTransactionsDescription": "Автоматически создавать транзакции при получении SMS",
"allFilter": "Все",
"newFilter": "Новые",
"processedFilter": "Обработанные",
"notRequiredFilter": "Не требуется",
"errorFilter": "Ошибка",
"transactionCreated": "Транзакция создана",
"smsStatusProcessed": "Обработано",
"smsStatusError": "Ошибка",
"smsStatusNotRequired": "Не требуется",
"smsStatusPending": "Ожидает",
"aiRulesPageTitle": "Правила ИИ",
"createRule": "Создать правило",
"editRule": "Редактировать правило",
"ruleType": "Тип правила",
"pointOfSale": "Точка продаж",
"skipSms": "Пропуск SMS",
"ruleName": "Название правила",
"merchantPattern": "Паттерн торговой точки",
"merchantPatternHelper": "Регулярное выражение для поиска в тексте SMS",
"category": "Категория",
"categoryHelper": "Выберите категорию для автоназначения",
"skipRegex": "Регулярное выражение",
"skipRegexHelper": "Шаблон для пропуска нежелательных SMS",
"settings": "Настройки",
"activeRule": "Активное правило",
"activeRuleHelper": "Правило будет применяться к новым SMS",
"processingStatus": "Статус обработки",
"statusCreated": "Создано",
"statusProcessed": "Обработано",
"statusNeedsAttention": "Требует внимания",
"statusRejected": "Отклонено",
"pointOfSaleSettings": "Настройки точки продаж",
"skipSettings": "Настройки пропуска",
"merchantPatternRequired": "Паттерн не может быть пустым",
"categoryRequired": "Категория должна быть выбрана",
"skipRegexRequired": "Регулярное выражение не может быть пустым",
"invalidRegex": "Некорректное регулярное выражение",
"ruleCreated": "Правило создано",
"ruleUpdated": "Правило обновлено",
"ruleDeleted": "Правило удалено",
"ruleDuplicated": "Правило дублировано",
"testRule": "Тестировать",
"testRuleTitle": "Тестировать правило",
"enterSmsText": "Введите текст SMS для тестирования:",
"smsTextPlaceholder": "Текст SMS сообщения...",
"duplicateRule": "Дублировать",
"deleteRule": "Удалить",
"deleteRuleConfirm": "Вы уверены, что хотите удалить правило \"{ruleName}\"?",
"@deleteRuleConfirm": {
"placeholders": {
"ruleName": {
"type": "String"
}
}
},
"noRulesCreated": "Нет созданных правил",
"noRulesByFilter": "Нет правил по фильтру",
"createFirstRule": "Создайте первое правило для автоматической обработки SMS",
"changeFilters": "Попробуйте изменить фильтры для поиска правил",
"importRules": "Импорт правил",
"exportRules": "Экспорт правил",
"testAllRules": "Тестировать все",
"importRulesMessage": "Функция импорта правил из файла будет реализована в следующих версиях.",
"exportRulesMessage": "Функция экспорта правил в файл будет реализована в следующих версиях.",
"testAllRulesMessage": "Вы хотите протестировать все активные правила на последних SMS сообщениях?",
"testingStarted": "Тестирование начато",
"unknownCategory": "Неизвестная категория",
"rulePatternLabel": "Правило",
"skipSmsRuleDefault": "Правило пропуска SMS",
"closeText": "Закрыть",
"cancelText": "Отмена",
"retryText": "Повторить",
"startText": "Начать",
"saveText": "Сохранить",
"errorText": "Ошибка",
"validationErrorsTitle": "Ошибки валидации",
"understandText": "Понятно",
"notSetText": "Не задано",
"notSelectedText": "Не выбрано",
"smsMessages": "SMS Messages",
"smsTooltip": "Настройки SMS",
"processingRules": "Правила обработки",
"syncSms": "Синхронизация SMS",
"autoProcessing": "Автообработка",
"filterByStatus": "Фильтр по статусу:",
"allStatus": "Все",
"pendingStatus": "Ожидает",
"processedStatus": "Обработано",
"ignoredStatus": "Игнорировать",
"errorStatus": "Ошибка",
"processingRulesDescription": "Здесь можно настроить правила обработки SMS сообщений.",
"close": "Закрыть",
"autoProcessingDescription": "Настройка автоматической обработки входящих SMS.",
"syncSmsStarted": "Синхронизация SMS начата",
"noNewSmsMessages": "No new SMS messages.",
"errorLoading": "Error: {error}",
"@errorLoading": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"somethingWentWrong": "Something went wrong.",
"unknownSender": "Unknown",
"messageActions": "Действия с сообщением",
"ruleSettings": "Настройка правил",
"processMessage": "Обработать сообщение",
"createTransactionAction": "Создать транзакцию",
"ignoreAction": "Игнорировать",
"viewTransaction": "Посмотреть транзакцию",
"returnToProcessing": "Вернуть в обработку",
"retry": "Повторить",
"deleteMessage": "Удалить сообщение?",
"deleteConfirmation": "Это действие нельзя будет отменить.",
"transactionDetails": "Детали транзакции",
"transactionForSms": "Транзакция для SMS от {sender}",
"@transactionForSms": {
"placeholders": {
"sender": {
"type": "String"
}
}
},
"notImplemented": "{feature} пока не реализована",
"@notImplemented": {
"placeholders": {
"feature": {
"type": "String"
}
}
},
"deleteSmsFeature": "Удаление сообщений",
"returnToProcessingFeature": "Функция возврата в обработку",
"daysAgo": "{days} дн. назад",
"@daysAgo": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"hoursAgo": "{hours} ч. назад",
"@hoursAgo": {
"placeholders": {
"hours": {
"type": "int"
}
}
},
"minutesAgo": "{minutes} мин. назад",
"@minutesAgo": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"justNow": "Только что",
"ruleLoadError": "Ошибка загрузки правила: {error}",
"@ruleLoadError": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"ruleSaveError": "Ошибка сохранения правила: {error}",
"@ruleSaveError": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"ruleDeleteError": "Ошибка удаления правила: {error}",
"@ruleDeleteError": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"noRuleForSender": "Правило обработки не настроено для отправителя: {sender}",
"@noRuleForSender": {
"placeholders": {
"sender": {
"type": "String"
}
}
},
"processingError": "Ошибка при обработке SMS",
"serviceTransactionsTitle": "Служебные транзакции",
"noData": "Нет данных",
"salesPoint": "Точка продаж",
"confidence": "Уверенность",
"exclusionRegex": "Регулярное выражение для исключения",
"notDefined": "Не определена",
"userNotAuthenticatedError": "Пользователь не аутентифицирован. Пожалуйста, войдите снова."
}
+185
View File
@@ -0,0 +1,185 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:logger/logger.dart';
import '/models/ai_settings.dart';
import '/data/repositories/interfaces/iai_settings_repository.dart';
import '/services/interfaces/iai_service.dart';
part 'ai_state.dart';
class AiCubit extends Cubit<AiState> {
final IAiSettingsRepository _settingsRepository;
final IAiService _aiService;
final Logger _logger = Logger();
AiCubit({
required IAiSettingsRepository settingsRepository,
required IAiService aiService,
}) : _settingsRepository = settingsRepository,
_aiService = aiService,
super(AiInitial()) {
_loadSettings();
}
void _loadSettings() {
try {
final settings = _settingsRepository.getSettings();
if (settings != null) {
_aiService.setApiKey(settings.apiKey ?? '');
emit(AiLoaded(settings: settings));
} else {
final defaultSettings = _settingsRepository.getDefaultSettings();
emit(AiLoaded(settings: defaultSettings));
}
} catch (e) {
_logger.e('Ошибка загрузки настроек ИИ: $e');
emit(AiError(message: 'Ошибка загрузки настроек ИИ: $e'));
}
}
Future<void> updateSettings(AiSettings settings) async {
try {
emit(AiLoading());
await _settingsRepository.saveSettings(settings);
_aiService.setApiKey(settings.apiKey ?? '');
emit(AiLoaded(settings: settings));
} catch (e) {
_logger.e('Ошибка сохранения настроек ИИ: $e');
emit(AiError(message: 'Ошибка сохранения настроек: $e'));
}
}
Future<void> sendMessage(String message) async {
final currentState = state;
if (currentState is! AiLoaded) {
emit(AiError(message: 'Настройки ИИ не загружены'));
return;
}
if (!currentState.settings.isEnabled) {
emit(AiError(message: 'ИИ сервис отключен в настройках'));
return;
}
if (!_aiService.isConfigured()) {
emit(AiError(message: 'API ключ не настроен'));
return;
}
try {
emit(AiLoading());
final response = await _aiService.sendMessage(message);
emit(AiLoaded(
settings: currentState.settings,
lastResponse: response,
lastMessage: message,
));
} catch (e) {
_logger.e('Ошибка отправки сообщения ИИ: $e');
emit(AiError(message: e.toString()));
emit(AiLoaded(settings: currentState.settings));
}
}
Future<void> sendMessageWithParams({
required String message,
String? model,
double? temperature,
int? maxTokens,
}) async {
final currentState = state;
if (currentState is! AiLoaded) {
emit(AiError(message: 'Настройки ИИ не загружены'));
return;
}
if (!currentState.settings.isEnabled) {
emit(AiError(message: 'ИИ сервис отключен в настройках'));
return;
}
if (!_aiService.isConfigured()) {
emit(AiError(message: 'API ключ не настроен'));
return;
}
try {
emit(AiLoading());
final response = await _aiService.sendMessageWithParams(
message: message,
model: model,
temperature: temperature,
maxTokens: maxTokens,
);
emit(AiLoaded(
settings: currentState.settings,
lastResponse: response,
lastMessage: message,
));
} catch (e) {
_logger.e('Ошибка отправки сообщения ИИ с параметрами: $e');
emit(AiError(message: e.toString()));
emit(AiLoaded(settings: currentState.settings));
}
}
Future<void> checkHealth() async {
final currentState = state;
if (currentState is! AiLoaded) {
emit(AiError(message: 'Настройки ИИ не загружены'));
return;
}
try {
emit(AiLoading());
final isHealthy = await _aiService.checkHealth();
emit(AiLoaded(
settings: currentState.settings,
isHealthy: isHealthy,
));
} catch (e) {
_logger.e('Ошибка проверки здоровья ИИ сервиса: $e');
emit(AiError(message: 'Ошибка проверки соединения: $e'));
emit(AiLoaded(settings: currentState.settings));
}
}
Future<void> loadAvailableModels() async {
final currentState = state;
if (currentState is! AiLoaded) {
emit(AiError(message: 'Настройки ИИ не загружены'));
return;
}
if (!_aiService.isConfigured()) {
emit(AiError(message: 'API ключ не настроен'));
return;
}
try {
emit(AiLoading());
final models = await _aiService.getAvailableModels();
emit(AiLoaded(
settings: currentState.settings,
availableModels: models,
));
} catch (e) {
_logger.e('Ошибка загрузки доступных моделей: $e');
emit(AiError(message: 'Ошибка загрузки моделей: $e'));
emit(AiLoaded(settings: currentState.settings));
}
}
Future<void> resetSettings() async {
try {
emit(AiLoading());
await _settingsRepository.deleteSettings();
final defaultSettings = _settingsRepository.getDefaultSettings();
_aiService.setApiKey('');
emit(AiLoaded(settings: defaultSettings));
} catch (e) {
_logger.e('Ошибка сброса настроек ИИ: $e');
emit(AiError(message: 'Ошибка сброса настроек: $e'));
}
}
}
+62
View File
@@ -0,0 +1,62 @@
part of 'ai_cubit.dart';
abstract class AiState extends Equatable {
const AiState();
@override
List<Object?> get props => [];
}
class AiInitial extends AiState {}
class AiLoading extends AiState {}
class AiLoaded extends AiState {
final AiSettings settings;
final String? lastResponse;
final String? lastMessage;
final bool? isHealthy;
final List<String>? availableModels;
const AiLoaded({
required this.settings,
this.lastResponse,
this.lastMessage,
this.isHealthy,
this.availableModels,
});
AiLoaded copyWith({
AiSettings? settings,
String? lastResponse,
String? lastMessage,
bool? isHealthy,
List<String>? availableModels,
}) {
return AiLoaded(
settings: settings ?? this.settings,
lastResponse: lastResponse ?? this.lastResponse,
lastMessage: lastMessage ?? this.lastMessage,
isHealthy: isHealthy ?? this.isHealthy,
availableModels: availableModels ?? this.availableModels,
);
}
@override
List<Object?> get props => [
settings,
lastResponse,
lastMessage,
isHealthy,
availableModels,
];
}
class AiError extends AiState {
final String message;
const AiError({required this.message});
@override
List<Object?> get props => [message];
}
+269
View File
@@ -0,0 +1,269 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import '/data/repositories/interfaces/iai_rule_repository.dart';
import '/models/ai_rule.dart';
part 'ai_rules_event.dart';
part 'ai_rules_state.dart';
class AiRulesBloc extends Bloc<AiRulesEvent, AiRulesState> {
final IAiRuleRepository _aiRuleRepository;
AiRulesBloc({required IAiRuleRepository aiRuleRepository})
: _aiRuleRepository = aiRuleRepository,
super(AiRulesInitial()) {
on<LoadRules>(_onLoadRules);
on<FilterRules>(_onFilterRules);
on<CreateRule>(_onCreateRule);
on<UpdateRule>(_onUpdateRule);
on<DeleteRule>(_onDeleteRule);
on<ToggleRuleActive>(_onToggleRuleActive);
on<UpdateRuleStatus>(_onUpdateRuleStatus);
on<TestRule>(_onTestRule);
on<ApplyRuleToSms>(_onApplyRuleToSms);
on<ValidateRule>(_onValidateRule);
}
void _onLoadRules(LoadRules event, Emitter<AiRulesState> emit) async {
emit(AiRulesLoading());
try {
final rules = await _aiRuleRepository.getByPriority();
emit(AiRulesLoaded(
rules: rules,
filteredRules: rules,
));
} catch (e) {
emit(AiRulesError(message: e.toString()));
}
}
void _onFilterRules(FilterRules event, Emitter<AiRulesState> emit) async {
if (state is AiRulesLoaded) {
final currentState = state as AiRulesLoaded;
List<AiRule> filteredRules = List.from(currentState.rules);
// Применяем фильтры только если они заданы
if (event.type != null) {
filteredRules = filteredRules.where((rule) => rule.type == event.type).toList();
}
if (event.status != null) {
filteredRules = filteredRules.where((rule) => rule.processingStatus == event.status).toList();
}
if (event.isActive != null) {
filteredRules = filteredRules.where((rule) => rule.isActive == event.isActive).toList();
}
emit(currentState.copyWith(
filteredRules: filteredRules,
activeTypeFilter: event.type,
activeStatusFilter: event.status,
activeActiveFilter: event.isActive,
));
}
}
void _onCreateRule(CreateRule event, Emitter<AiRulesState> emit) async {
try {
// Валидация перед сохранением
if (event.rule.type == AiRuleType.pointOfSale) {
if (event.rule.merchantPattern == null || event.rule.merchantPattern!.trim().isEmpty) {
emit(const AiRulesError(message: 'Паттерн для точки продаж не может быть пустым'));
return;
}
if (event.rule.categoryId == null || event.rule.categoryId!.trim().isEmpty) {
emit(const AiRulesError(message: 'Категория должна быть выбрана для правила точки продаж'));
return;
}
} else if (event.rule.type == AiRuleType.skipTemplate) {
if (event.rule.skipRegex == null || event.rule.skipRegex!.trim().isEmpty) {
emit(const AiRulesError(message: 'Регулярное выражение для пропуска не может быть пустым'));
return;
}
}
await _aiRuleRepository.add(event.rule);
emit(RuleSaved(rule: event.rule));
// Перезагружаем правила
add(const LoadRules());
} catch (e) {
emit(AiRulesError(message: e.toString()));
}
}
void _onUpdateRule(UpdateRule event, Emitter<AiRulesState> emit) async {
try {
// Валидация перед сохранением
if (event.rule.type == AiRuleType.pointOfSale) {
if (event.rule.merchantPattern == null || event.rule.merchantPattern!.trim().isEmpty) {
emit(const AiRulesError(message: 'Паттерн для точки продаж не может быть пустым'));
return;
}
if (event.rule.categoryId == null || event.rule.categoryId!.trim().isEmpty) {
emit(const AiRulesError(message: 'Категория должна быть выбрана для правила точки продаж'));
return;
}
} else if (event.rule.type == AiRuleType.skipTemplate) {
if (event.rule.skipRegex == null || event.rule.skipRegex!.trim().isEmpty) {
emit(const AiRulesError(message: 'Регулярное выражение для пропуска не может быть пустым'));
return;
}
}
await _aiRuleRepository.update(event.rule);
emit(RuleSaved(rule: event.rule));
// Перезагружаем правила
add(const LoadRules());
} catch (e) {
emit(AiRulesError(message: e.toString()));
}
}
void _onDeleteRule(DeleteRule event, Emitter<AiRulesState> emit) async {
try {
await _aiRuleRepository.delete(event.ruleId);
emit(RuleDeleted(ruleId: event.ruleId));
// Перезагружаем правила
add(const LoadRules());
} catch (e) {
emit(AiRulesError(message: e.toString()));
}
}
void _onToggleRuleActive(ToggleRuleActive event, Emitter<AiRulesState> emit) async {
try {
await _aiRuleRepository.toggleActive(event.ruleId);
// Перезагружаем правила
add(const LoadRules());
} catch (e) {
emit(AiRulesError(message: e.toString()));
}
}
void _onUpdateRuleStatus(UpdateRuleStatus event, Emitter<AiRulesState> emit) async {
try {
await _aiRuleRepository.updateStatus(event.ruleId, event.status);
// Перезагружаем правила
add(const LoadRules());
} catch (e) {
emit(AiRulesError(message: e.toString()));
}
}
void _onTestRule(TestRule event, Emitter<AiRulesState> emit) async {
emit(RuleProcessing(ruleId: event.ruleId, operation: 'testing'));
try {
final rule = await _aiRuleRepository.getById(event.ruleId);
if (rule == null) {
emit(const AiRulesError(message: 'Правило не найдено'));
return;
}
// Простое тестирование правила
Map<String, dynamic> testResult = {};
if (rule.type == AiRuleType.skipTemplate && rule.skipRegex != null) {
final regex = RegExp(rule.skipRegex!);
final matches = regex.hasMatch(event.smsText);
testResult = {
'matches': matches,
'regex': rule.skipRegex,
'confidence': matches ? rule.confidencePercentage : 0,
};
} else if (rule.type == AiRuleType.pointOfSale && rule.merchantPattern != null) {
final regex = RegExp(rule.merchantPattern!);
final matches = regex.hasMatch(event.smsText);
testResult = {
'matches': matches,
'pattern': rule.merchantPattern,
'categoryId': rule.categoryId,
'confidence': matches ? rule.confidencePercentage : 0,
};
}
emit(RuleProcessed(
ruleId: event.ruleId,
operation: 'testing',
result: testResult,
));
} catch (e) {
emit(AiRulesError(message: e.toString()));
}
}
void _onApplyRuleToSms(ApplyRuleToSms event, Emitter<AiRulesState> emit) async {
emit(RuleProcessing(ruleId: event.ruleId, operation: 'applying'));
try {
// Здесь будет логика применения правила к SMS
// Пока возвращаем успешный результат
emit(RuleProcessed(
ruleId: event.ruleId,
operation: 'applying',
result: {'success': true, 'smsId': event.smsId},
));
} catch (e) {
emit(AiRulesError(message: e.toString()));
}
}
void _onValidateRule(ValidateRule event, Emitter<AiRulesState> emit) async {
try {
List<String> errors = [];
// Валидация процента уверенности
if (event.rule.confidencePercentage < 0 || event.rule.confidencePercentage > 100) {
errors.add('Процент уверенности должен быть от 0 до 100');
}
// Валидация в зависимости от типа
if (event.rule.type == AiRuleType.pointOfSale) {
if (event.rule.merchantPattern == null || event.rule.merchantPattern!.trim().isEmpty) {
errors.add('Паттерн для точки продаж не может быть пустым');
}
if (event.rule.categoryId == null || event.rule.categoryId!.trim().isEmpty) {
errors.add('Категория должна быть выбрана для правила точки продаж');
}
// Валидация регулярного выражения
if (event.rule.merchantPattern != null) {
try {
RegExp(event.rule.merchantPattern!);
} catch (e) {
errors.add('Некорректное регулярное выражение для паттерна');
}
}
} else if (event.rule.type == AiRuleType.skipTemplate) {
if (event.rule.skipRegex == null || event.rule.skipRegex!.trim().isEmpty) {
errors.add('Регулярное выражение для пропуска не может быть пустым');
}
// Валидация регулярного выражения
if (event.rule.skipRegex != null) {
try {
RegExp(event.rule.skipRegex!);
} catch (e) {
errors.add('Некорректное регулярное выражение для пропуска');
}
}
}
emit(RuleValidated(
isValid: errors.isEmpty,
validationErrors: errors,
));
} catch (e) {
emit(AiRulesError(message: e.toString()));
}
}
}
+121
View File
@@ -0,0 +1,121 @@
part of 'ai_rules_bloc.dart';
abstract class AiRulesEvent extends Equatable {
const AiRulesEvent();
@override
List<Object?> get props => [];
}
/// Загрузить все правила
class LoadRules extends AiRulesEvent {
const LoadRules();
}
/// Фильтровать правила
class FilterRules extends AiRulesEvent {
final AiRuleType? type;
final ProcessingStatus? status;
final bool? isActive;
const FilterRules({
this.type,
this.status,
this.isActive,
});
@override
List<Object?> get props => [type, status, isActive];
}
/// Создать новое правило
class CreateRule extends AiRulesEvent {
final AiRule rule;
const CreateRule({required this.rule});
@override
List<Object> get props => [rule];
}
/// Обновить правило
class UpdateRule extends AiRulesEvent {
final AiRule rule;
const UpdateRule({required this.rule});
@override
List<Object> get props => [rule];
}
/// Удалить правило
class DeleteRule extends AiRulesEvent {
final String ruleId;
const DeleteRule({required this.ruleId});
@override
List<Object> get props => [ruleId];
}
/// Переключить активность правила
class ToggleRuleActive extends AiRulesEvent {
final String ruleId;
const ToggleRuleActive({required this.ruleId});
@override
List<Object> get props => [ruleId];
}
/// Обновить статус правила
class UpdateRuleStatus extends AiRulesEvent {
final String ruleId;
final ProcessingStatus status;
const UpdateRuleStatus({
required this.ruleId,
required this.status,
});
@override
List<Object> get props => [ruleId, status];
}
/// Тестировать правило на SMS сообщении
class TestRule extends AiRulesEvent {
final String ruleId;
final String smsText;
const TestRule({
required this.ruleId,
required this.smsText,
});
@override
List<Object> get props => [ruleId, smsText];
}
/// Применить правило к SMS сообщению
class ApplyRuleToSms extends AiRulesEvent {
final String ruleId;
final String smsId;
const ApplyRuleToSms({
required this.ruleId,
required this.smsId,
});
@override
List<Object> get props => [ruleId, smsId];
}
/// Валидировать правило
class ValidateRule extends AiRulesEvent {
final AiRule rule;
const ValidateRule({required this.rule});
@override
List<Object> get props => [rule];
}
+134
View File
@@ -0,0 +1,134 @@
part of 'ai_rules_bloc.dart';
abstract class AiRulesState extends Equatable {
const AiRulesState();
@override
List<Object?> get props => [];
}
/// Начальное состояние
class AiRulesInitial extends AiRulesState {}
/// Состояние загрузки
class AiRulesLoading extends AiRulesState {}
/// Состояние с загруженными правилами
class AiRulesLoaded extends AiRulesState {
final List<AiRule> rules;
final List<AiRule> filteredRules;
final AiRuleType? activeTypeFilter;
final ProcessingStatus? activeStatusFilter;
final bool? activeActiveFilter;
const AiRulesLoaded({
required this.rules,
required this.filteredRules,
this.activeTypeFilter,
this.activeStatusFilter,
this.activeActiveFilter,
});
@override
List<Object?> get props => [
rules,
filteredRules,
activeTypeFilter,
activeStatusFilter,
activeActiveFilter,
];
AiRulesLoaded copyWith({
List<AiRule>? rules,
List<AiRule>? filteredRules,
AiRuleType? activeTypeFilter,
ProcessingStatus? activeStatusFilter,
bool? activeActiveFilter,
bool clearTypeFilter = false,
bool clearStatusFilter = false,
bool clearActiveFilter = false,
bool clearAllFilters = false,
}) {
return AiRulesLoaded(
rules: rules ?? this.rules,
filteredRules: filteredRules ?? this.filteredRules,
activeTypeFilter: (clearAllFilters || clearTypeFilter) ? null : (activeTypeFilter ?? this.activeTypeFilter),
activeStatusFilter: (clearAllFilters || clearStatusFilter) ? null : (activeStatusFilter ?? this.activeStatusFilter),
activeActiveFilter: (clearAllFilters || clearActiveFilter) ? null : (activeActiveFilter ?? this.activeActiveFilter),
);
}
}
/// Состояние ошибки
class AiRulesError extends AiRulesState {
final String message;
const AiRulesError({required this.message});
@override
List<Object> get props => [message];
}
/// Состояние обработки правила (тестирование, применение к SMS)
class RuleProcessing extends AiRulesState {
final String ruleId;
final String operation;
const RuleProcessing({
required this.ruleId,
required this.operation,
});
@override
List<Object> get props => [ruleId, operation];
}
/// Состояние после обработки правила
class RuleProcessed extends AiRulesState {
final String ruleId;
final String operation;
final Map<String, dynamic> result;
const RuleProcessed({
required this.ruleId,
required this.operation,
required this.result,
});
@override
List<Object> get props => [ruleId, operation, result];
}
/// Состояние после валидации правила
class RuleValidated extends AiRulesState {
final bool isValid;
final List<String> validationErrors;
const RuleValidated({
required this.isValid,
this.validationErrors = const [],
});
@override
List<Object> get props => [isValid, validationErrors];
}
/// Состояние после успешного сохранения правила
class RuleSaved extends AiRulesState {
final AiRule rule;
const RuleSaved({required this.rule});
@override
List<Object> get props => [rule];
}
/// Состояние после успешного удаления правила
class RuleDeleted extends AiRulesState {
final String ruleId;
const RuleDeleted({required this.ruleId});
@override
List<Object> get props => [ruleId];
}
+11 -12
View File
@@ -1,10 +1,10 @@
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';
import 'package:budget_app/injection_container.dart' as di;
import 'package:budget_app/logic/user/user_cubit.dart';
import 'package:budget_app/models/user.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
part 'auth_event.dart';
part 'auth_state.dart';
@@ -56,6 +56,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
// Инициализируем зависимости для вошедшего пользователя.
await di.initUserSpecificDependencies(event.user.id);
_userCubit.setUser(event.user);
await _settingsRepository.setCurrentUserId(event.user.id);
emit(AuthAuthenticated(user: event.user));
}
@@ -69,7 +70,9 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
Future<void> _onAuthRegisterRequested(
AuthRegisterRequested event, Emitter<AuthState> emit) async {
AuthRegisterRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
try {
// 1. Создаем пользователя
@@ -82,14 +85,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
// 3. Создаем начальные данные (теперь это сработает)
await _userCubit.createInitialData(user.id);
// 4. Сохраняем и устанавливаем пользователя
await _settingsRepository.setCurrentUserId(user.id);
_userCubit.setUser(user);
emit(AuthAuthenticated(user: user));
// 4. Вызываем событие логина
add(AuthLoggedIn(user: user));
} catch (e) {
emit(AuthError(e.toString()));
}
}
}
@@ -0,0 +1,22 @@
import 'package:bloc/bloc.dart';
import 'package:budget_app/data/repositories/interfaces/iprefilled_transaction_repository.dart';
import 'package:budget_app/logic/prefilled_transaction/prefilled_transaction_state.dart';
/// Cubit для управления состоянием экрана предварительно заполненных транзакций.
class PrefilledTransactionCubit extends Cubit<PrefilledTransactionState> {
final IPrefilledTransactionRepository _repository;
PrefilledTransactionCubit(this._repository)
: super(PrefilledTransactionInitial());
/// Загружает все предварительно заполненные транзакции.
Future<void> loadTransactions() async {
try {
emit(PrefilledTransactionLoading());
final transactions = await _repository.getAll();
emit(PrefilledTransactionLoaded(transactions));
} catch (e) {
emit(PrefilledTransactionError(e.toString()));
}
}
}
@@ -0,0 +1,36 @@
import 'package:budget_app/models/prefilled_transaction.dart';
import 'package:equatable/equatable.dart';
/// Базовый класс для состояний экрана предварительно заполненных транзакций.
abstract class PrefilledTransactionState extends Equatable {
const PrefilledTransactionState();
@override
List<Object> get props => [];
}
/// Начальное состояние.
class PrefilledTransactionInitial extends PrefilledTransactionState {}
/// Состояние загрузки данных.
class PrefilledTransactionLoading extends PrefilledTransactionState {}
/// Состояние успешной загрузки данных.
class PrefilledTransactionLoaded extends PrefilledTransactionState {
final List<PrefilledTransaction> transactions;
const PrefilledTransactionLoaded(this.transactions);
@override
List<Object> get props => [transactions];
}
/// Состояние ошибки.
class PrefilledTransactionError extends PrefilledTransactionState {
final String message;
const PrefilledTransactionError(this.message);
@override
List<Object> get props => [message];
}
+15
View File
@@ -20,6 +20,7 @@ class SettingsCubit extends Cubit<SettingsState> {
isDarkMode: settings.isDarkMode,
languageCode: settings.languageCode,
defaultCurrency: settings.defaultCurrency,
autoCreateTransactionsFromSms: settings.autoCreateTransactionsFromSms,
));
} catch (e) {
emit(SettingsError(e.toString()));
@@ -65,11 +66,25 @@ class SettingsCubit extends Cubit<SettingsState> {
}
}
Future<void> toggleAutoCreateTransactions(bool value) async {
if (state is SettingsLoaded) {
try {
final currentState = state as SettingsLoaded;
final newState = currentState.copyWith(autoCreateTransactionsFromSms: value);
emit(newState);
await _saveSettings(newState);
} catch (e) {
emit(SettingsError(e.toString()));
}
}
}
Future<void> _saveSettings(SettingsLoaded settings) async {
final appSettings = AppSettings(
isDarkMode: settings.isDarkMode,
languageCode: settings.languageCode,
defaultCurrency: settings.defaultCurrency,
autoCreateTransactionsFromSms: settings.autoCreateTransactionsFromSms,
);
await _settingsRepository.saveSettings(appSettings);
}
+5 -1
View File
@@ -18,25 +18,29 @@ class SettingsLoaded extends SettingsState {
final bool isDarkMode;
final String languageCode;
final String defaultCurrency;
final bool autoCreateTransactionsFromSms;
const SettingsLoaded({
required this.isDarkMode,
required this.languageCode,
required this.defaultCurrency,
this.autoCreateTransactionsFromSms = false,
});
@override
List<Object> get props => [isDarkMode, languageCode, defaultCurrency];
List<Object> get props => [isDarkMode, languageCode, defaultCurrency, autoCreateTransactionsFromSms];
SettingsLoaded copyWith({
bool? isDarkMode,
String? languageCode,
String? defaultCurrency,
bool? autoCreateTransactionsFromSms,
}) {
return SettingsLoaded(
isDarkMode: isDarkMode ?? this.isDarkMode,
languageCode: languageCode ?? this.languageCode,
defaultCurrency: defaultCurrency ?? this.defaultCurrency,
autoCreateTransactionsFromSms: autoCreateTransactionsFromSms ?? this.autoCreateTransactionsFromSms,
);
}
}
@@ -0,0 +1,22 @@
import 'package:budget_app/models/sms_message.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
part 'sms_filter_state.dart';
// Cubit для управления состоянием фильтров
class SmsFilterCubit extends Cubit<SmsFilterState> {
SmsFilterCubit() : super(SmsFilterState(statusFilter: null));
void setStatusFilter(SmsStatus? status) {
// Явно обрабатываем null
if (status == null) {
emit(SmsFilterState(statusFilter: null));
} else {
emit(state.copyWith(statusFilter: status));
}
}
void clearFilters() {
emit(SmsFilterState(statusFilter: null));
}
}
@@ -0,0 +1,16 @@
part of 'sms_filter_cubit.dart';
// Состояние, хранящее параметры фильтрации
class SmsFilterState {
final SmsStatus? statusFilter;
SmsFilterState({required this.statusFilter});
SmsFilterState copyWith({
SmsStatus? statusFilter,
}) {
return SmsFilterState(
statusFilter: statusFilter ?? this.statusFilter,
);
}
}
+125
View File
@@ -0,0 +1,125 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:budget_app/models/sms_message.dart';
import 'package:budget_app/services/sms_transaction_service.dart';
import 'package:budget_app/services/sms_service.dart';
import 'package:budget_app/l10n/app_localizations.dart';
part 'sms_item_state.dart';
// Cubit для управления состоянием одного элемента списка СМС
class SmsItemCubit extends Cubit<SmsItemState> {
SmsMessage _currentMessage;
final SmsTransactionService _smsTransactionService;
final SmsService _smsService;
final Function(SmsMessage) onProcessed; // Callback для обновления сообщения в списке
SmsItemCubit({
required SmsMessage message,
required this.onProcessed,
required SmsTransactionService smsTransactionService,
required SmsService smsService,
}) : _currentMessage = message,
_smsTransactionService = smsTransactionService,
_smsService = smsService,
super(SmsItemInitial());
// Метод для получения актуального сообщения
SmsMessage get currentMessage => _currentMessage;
// Метод для обновления сообщения (когда приходят изменения извне)
void updateMessage(SmsMessage updatedMessage) {
if (_currentMessage.id == updatedMessage.id) {
_currentMessage = updatedMessage;
// Не эмитим новое состояние, просто обновляем данные
}
}
// Метод для создания транзакции из СМС
Future<void> createTransaction(AppLocalizations loc) async {
emit(SmsItemProcessing());
try {
// Вызываем сервис для создания транзакции
final result = await _smsTransactionService.createTransactionFromSms(_currentMessage);
switch (result) {
case SmsProcessingResult.success:
// Транзакция успешно создана - помечаем СМС как обработанное
final updatedMessage = _currentMessage.copyWith(status: SmsStatus.processed);
await _smsService.markAsProcessed(_currentMessage.id);
_currentMessage = updatedMessage; // Обновляем локальную копию
emit(SmsItemSuccess());
onProcessed(updatedMessage);
break;
case SmsProcessingResult.ignored:
// SMS должно быть проигнорировано - помечаем как ignored
final updatedMessage = _currentMessage.copyWith(status: SmsStatus.ignored);
await _smsService.markAsIgnored(_currentMessage.id);
_currentMessage = updatedMessage; // Обновляем локальную копию
emit(SmsItemIgnored());
onProcessed(updatedMessage);
break;
case SmsProcessingResult.noRule:
// Правило не найдено - сохраняем ошибку в сообщение
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) {
// Сохраняем ошибку исключения в сообщение
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);
}
}
// Метод для игнорирования СМС
Future<void> ignoreSms() async {
emit(SmsItemProcessing());
try {
// Помечаем СМС как игнорируемое
final updatedMessage = _currentMessage.copyWith(status: SmsStatus.ignored);
await _smsService.markAsIgnored(_currentMessage.id);
_currentMessage = updatedMessage; // Обновляем локальную копию
emit(SmsItemIgnored());
onProcessed(updatedMessage); // Передаем обновленное сообщение
} catch (e) {
// Сохраняем ошибку исключения в сообщение
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);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
part of 'sms_item_cubit.dart';
abstract class SmsItemState {}
// Начальное состояние элемента
class SmsItemInitial extends SmsItemState {}
// Состояние, когда над СМС выполняется операция (например, создание транзакции)
class SmsItemProcessing extends SmsItemState {}
// Состояние, когда операция успешно завершена
class SmsItemSuccess extends SmsItemState {}
// Состояние, когда СМС было отмечено как "игнорируемое"
class SmsItemIgnored extends SmsItemState {}
// Состояние ошибки при обработке
class SmsItemError extends SmsItemState {
final String error;
SmsItemError(this.error);
}
+89
View File
@@ -0,0 +1,89 @@
import 'dart:async';
import 'package:budget_app/logic/sms/filter/sms_filter_cubit.dart';
import 'package:budget_app/models/sms_message.dart';
import 'package:budget_app/services/sms_service.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../models/user.dart';
part 'sms_list_state.dart';
// Cubit для управления состоянием списка СМС
class SmsListCubit extends Cubit<SmsListState> {
final SmsService _smsService;
SmsFilterCubit? _filterCubit;
StreamSubscription? _filterSubscription;
List<SmsMessage> _allMessages = [];
SmsListCubit({required SmsService smsService})
: _smsService = smsService,
super(SmsListInitial());
void setFilterCubit(SmsFilterCubit filterCubit) {
_filterCubit = filterCubit;
_filterSubscription = _filterCubit!.stream.listen((_) {
_applyFilter();
});
}
// Метод для первоначальной загрузки СМС
Future<void> loadSms(User currentUser) async {
emit(SmsListLoading());
try {
// Загружаем все СМС сообщения
_allMessages = await _smsService.getAllSms(currentUser);
_applyFilter();
} catch (e) {
emit(SmsListError(e.toString()));
}
}
// Метод для обновления списка (например, после обработки одного из СМС)
void refreshSmsList() {
_applyFilter();
}
// Приватный метод для применения текущего фильтра
void _applyFilter() {
if (_filterCubit == null) {
emit(SmsListSuccess(List.from(_allMessages)));
return;
}
final currentState = _filterCubit!.state;
// Создаем новый список для гарантии обновления
final filteredMessages =
_allMessages.where((sms) {
// Явная проверка на null
if (currentState.statusFilter == null) {
return true; // Показываем все сообщения
}
return sms.status == currentState.statusFilter;
}).toList()..sort((a, b) {
// Сортировка от новых к старым
final aDate = a.date ?? DateTime(0);
final bDate = b.date ?? DateTime(0);
return bDate.compareTo(aDate);
});
emit(SmsListSuccess(List.from(filteredMessages)));
}
// Обновляет конкретное сообщение в списке
void updateMessage(SmsMessage updatedMessage) {
final index = _allMessages.indexWhere((m) => m.id == updatedMessage.id);
if (index != -1) {
_allMessages[index] = updatedMessage;
_applyFilter();
}
}
@override
Future<void> close() {
_filterSubscription?.cancel();
return super.close();
}
}
+42
View File
@@ -0,0 +1,42 @@
part of 'sms_list_cubit.dart';
abstract class SmsListState extends Equatable {
@override
List<Object?> get props => [];
}
// Начальное состояние
class SmsListInitial extends SmsListState {
@override
String toString() => 'SmsListInitial';
}
// Состояние загрузки
class SmsListLoading extends SmsListState {
@override
String toString() => 'SmsListLoading';
}
// Состояние успешной загрузки
class SmsListSuccess extends SmsListState {
final List<SmsMessage> messages;
SmsListSuccess(this.messages);
@override
List<Object?> get props => [messages];
@override
String toString() => 'SmsListSuccess(messages: ${messages.length})';
}
// Состояние ошибки
class SmsListError extends SmsListState {
final String error;
SmsListError(this.error);
@override
List<Object?> get props => [error];
@override
String toString() => 'SmsListError(error: $error)';
}
-122
View File
@@ -1,122 +0,0 @@
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<SmsState> {
final SmsService _smsService;
final ISmsMessageRepository _smsRepository;
final UserCubit _userCubit;
SmsCubit(
this._smsService,
this._smsRepository,
this._userCubit
) : 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()));
}
}
// Комментарий: Метод для загрузки и сохранения SMS-сообщений.
Future<void> 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<void> _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)
// );
// }
}
}
+68
View File
@@ -0,0 +1,68 @@
import 'package:budget_app/data/repositories/interfaces/isms_handler_repository.dart';
import 'package:budget_app/models/sms_handler_settings.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:budget_app/l10n/app_localizations.dart';
part 'sms_settings_state.dart';
/// Cubit для управления состоянием диалога настроек обработки SMS
class SmsSettingsCubit extends Cubit<SmsSettingsState> {
final ISmsHandlerRepository _smsHandlerRepository;
SmsProcessingRule? _originalRule;
SmsSettingsCubit(
this._smsHandlerRepository,
) : super(const SmsSettingsInitial());
/// Загружает правило для указанного отправителя
Future<void> loadRuleForSender(String sender, AppLocalizations loc) async {
try {
emit(const SmsSettingsLoading());
final settings = await _smsHandlerRepository.getSmsHandlerSettings();
_originalRule = settings?.rulesBySender[sender];
emit(SmsSettingsLoaded(_originalRule));
} catch (e) {
emit(SmsSettingsError(loc.ruleLoadError(e.toString())));
}
}
/// Обновляет правило
void updateRule(SmsProcessingRule rule) {
if (state is! SmsSettingsLoaded) return;
emit(SmsSettingsLoaded(rule));
}
/// Сохраняет правило
Future<void> saveRule(String sender, AppLocalizations loc) async {
if (state is! SmsSettingsLoaded) return;
final loadedState = state as SmsSettingsLoaded;
if (loadedState.rule == null) return;
try {
emit(const SmsSettingsLoading());
await _smsHandlerRepository.saveRuleForSender(sender, loadedState.rule!);
emit(const SmsSettingsSaved());
} catch (e) {
emit(SmsSettingsError(loc.ruleSaveError(e.toString())));
// Возвращаем предыдущее состояние в случае ошибки
emit(SmsSettingsLoaded(_originalRule));
}
}
/// Удаляет правило
Future<void> deleteRule(String sender, AppLocalizations loc) async {
if (state is! SmsSettingsLoaded) return;
final loadedState = state as SmsSettingsLoaded;
if (loadedState.rule?.id == null) return;
try {
emit(const SmsSettingsLoading());
await _smsHandlerRepository.deleteRuleForSender(sender);
emit(const SmsSettingsDeleted());
} catch (e) {
emit(SmsSettingsError(loc.ruleDeleteError(e.toString())));
// Возвращаем предыдущее состояние в случае ошибки
emit(SmsSettingsLoaded(_originalRule));
}
}
}
+35
View File
@@ -0,0 +1,35 @@
part of 'sms_settings_cubit.dart';
abstract class SmsSettingsState {
const SmsSettingsState();
}
class SmsSettingsInitial extends SmsSettingsState {
const SmsSettingsInitial();
}
class SmsSettingsLoading extends SmsSettingsState {
const SmsSettingsLoading();
}
class SmsSettingsLoaded extends SmsSettingsState {
final SmsProcessingRule? rule;
const SmsSettingsLoaded(this.rule);
}
class SmsSettingsSaved extends SmsSettingsState {
const SmsSettingsSaved();
}
class SmsSettingsDeleted extends SmsSettingsState {
const SmsSettingsDeleted();
}
class SmsSettingsTransactionCreated extends SmsSettingsState {
const SmsSettingsTransactionCreated();
}
class SmsSettingsError extends SmsSettingsState {
final String? errorMessage;
const SmsSettingsError(this.errorMessage);
}
-39
View File
@@ -1,39 +0,0 @@
import 'package:budget_app/models/sms_message.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];
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:equatable/equatable.dart';
abstract class SmsTransactionState extends Equatable {
const SmsTransactionState();
@override
List<Object?> get props => [];
}
class SmsTransactionInitial extends SmsTransactionState {
const SmsTransactionInitial();
}
class SmsTransactionProcessing extends SmsTransactionState {
const SmsTransactionProcessing();
}
class SmsTransactionCreated extends SmsTransactionState {
final bool wasShown;
const SmsTransactionCreated({this.wasShown = false});
@override
List<Object?> get props => [wasShown];
}
class SmsTransactionError extends SmsTransactionState {
final String message;
final bool wasShown;
const SmsTransactionError(this.message, {this.wasShown = false});
@override
List<Object?> get props => [message, wasShown];
}
+4 -4
View File
@@ -22,7 +22,7 @@ class TransactionBloc extends Bloc<TransactionEvent, TransactionState> {
emit(TransactionLoading());
try {
final transactions = await _transactionRepository.getAll();
emit(TransactionLoaded(transactions: transactions));
emit(TransactionLoaded(transactions: List.from(transactions)));
} catch (e) {
emit(TransactionError(message: e.toString()));
}
@@ -32,7 +32,7 @@ class TransactionBloc extends Bloc<TransactionEvent, TransactionState> {
try {
await _transactionRepository.add(event.transaction);
final transactions = await _transactionRepository.getAll();
emit(TransactionLoaded(transactions: transactions));
emit(TransactionLoaded(transactions: List.from(transactions)));
} catch (e) {
emit(TransactionError(message: e.toString()));
}
@@ -42,7 +42,7 @@ class TransactionBloc extends Bloc<TransactionEvent, TransactionState> {
try {
await _transactionRepository.update(event.transaction);
final transactions = await _transactionRepository.getAll();
emit(TransactionLoaded(transactions: transactions));
emit(TransactionLoaded(transactions: List.from(transactions)));
} catch (e) {
emit(TransactionError(message: e.toString()));
}
@@ -56,7 +56,7 @@ class TransactionBloc extends Bloc<TransactionEvent, TransactionState> {
if (loadedState.transactions.isNotEmpty) {
await _transactionRepository.delete(event.transactionId);
final transactions = await _transactionRepository.getAll();
emit(TransactionLoaded(transactions: transactions));
emit(TransactionLoaded(transactions: List.from(transactions)));
}
}
} catch (e) {
+2 -4
View File
@@ -8,12 +8,10 @@ abstract class TransactionEvent extends Equatable {
}
class LoadTransactions extends TransactionEvent {
final String userId;
const LoadTransactions({required this.userId});
const LoadTransactions();
@override
List<Object> get props => [userId];
List<Object> get props => [];
}
class AddTransaction extends TransactionEvent {
+14 -1
View File
@@ -116,5 +116,18 @@ class UserCubit extends Cubit<UserState> {
return [];
}
}
}
/// Обновляет данные пользователя
Future<void> updateUser(User updatedUser) async {
try {
if (state is UserLoaded) {
await _userRepository.update(updatedUser);
emit(UserLoaded(updatedUser));
_logger.i('User data updated successfully');
}
} catch (e, stack) {
_logger.e('Error updating user', error: e, stackTrace: stack);
throw Exception('Ошибка обновления пользователя');
}
}
}
+2 -2
View File
@@ -2,6 +2,7 @@ import 'package:budget_app/pages/home/home_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; // Добавляем импорт
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_it/get_it.dart';
import '/l10n/app_localizations.dart';
@@ -9,7 +10,6 @@ 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';
@@ -17,6 +17,7 @@ import 'theme/app_theme.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await dotenv.load(fileName: ".env");
await di.initGlobalDependencies();
runApp(const MyApp());
}
@@ -42,7 +43,6 @@ class MyApp extends StatelessWidget {
providers: [
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) {
+89
View File
@@ -0,0 +1,89 @@
/// Исключения для работы с AI сервисами
library;
/// Базовый класс для всех AI исключений
abstract class AiException implements Exception {
final String message;
final String? context;
final Exception? cause;
const AiException(this.message, {this.context, this.cause});
@override
String toString() {
final contextStr = context != null ? '[$context] ' : '';
final causeStr = cause != null ? ' (caused by: $cause)' : '';
return '$runtimeType: $contextStr$message$causeStr';
}
}
/// Ошибки обработки SMS через AI
class AiProcessingException extends AiException {
final String? smsId;
const AiProcessingException(
super.message, {
this.smsId,
super.context,
super.cause,
});
}
/// Ошибки парсинга ответа AI
class AiResponseParsingException extends AiProcessingException {
final String? rawResponse;
const AiResponseParsingException(
super.message, {
this.rawResponse,
super.smsId,
super.context,
super.cause,
});
}
/// Ошибки валидации данных от AI
class AiDataValidationException extends AiProcessingException {
final Map<String, dynamic>? invalidData;
const AiDataValidationException(
super.message, {
this.invalidData,
super.smsId,
super.context,
super.cause,
});
}
/// Ошибки конфигурации AI сервиса
class AiConfigurationException extends AiException {
const AiConfigurationException(
super.message, {
super.context,
super.cause,
});
}
/// Ошибки OpenRouter API
class OpenRouterApiException extends AiException {
final int statusCode;
final String? responseBody;
const OpenRouterApiException(
this.statusCode,
String message, {
this.responseBody,
String? context,
Exception? cause,
}) : super(message, context: context, cause: cause);
@override
String toString() {
final contextStr = context != null ? '[$context] ' : '';
final bodyStr = responseBody != null && responseBody!.isNotEmpty
? ' (response: ${responseBody!.length > 100 ? '${responseBody!.substring(0, 100)}...' : responseBody})'
: '';
final causeStr = cause != null ? ' (caused by: $cause)' : '';
return 'OpenRouterApiException($statusCode): $contextStr$message$bodyStr$causeStr';
}
}
+148
View File
@@ -0,0 +1,148 @@
/// Типизированные модели для ответов AI сервиса
library;
import 'package:hive_ce/hive.dart';
part 'ai_response.g.dart';
/// Базовый класс для ответов AI (без Hive аннотаций, так как абстрактный)
abstract class AiResponse {
final bool isTransaction;
final double confidence;
const AiResponse({
required this.isTransaction,
required this.confidence,
});
/// Валидация базовых полей ответа AI
bool isValid() {
return confidence >= 0.0 && confidence <= 1.0;
}
}
/// Ответ AI для транзакционных SMS
@HiveType(typeId: 21)
class AiTransactionResponse extends AiResponse {
@HiveField(0)
final double amount;
@HiveField(1)
final String vendor;
@HiveField(2)
final String? suggestedCategory;
@HiveField(3)
@override
final double confidence;
const AiTransactionResponse({
required this.amount,
required this.vendor,
this.suggestedCategory,
required this.confidence,
}) : super(isTransaction: true, confidence: confidence);
@override
bool isValid() {
return super.isValid() &&
amount != 0.0 &&
vendor.trim().isNotEmpty;
}
factory AiTransactionResponse.fromJson(Map<String, dynamic> json) {
return AiTransactionResponse(
amount: (json['amount'] as num).toDouble(),
vendor: json['vendor'] as String,
suggestedCategory: json['suggestedCategory'] as String?,
confidence: (json['confidence'] as num).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'isTransaction': isTransaction,
'amount': amount,
'vendor': vendor,
'confidence': confidence,
'suggestedCategory': suggestedCategory,
'exclusionRegex': null,
};
}
@override
String toString() {
return 'AiTransactionResponse(amount: $amount, vendor: $vendor, '
'category: $suggestedCategory, confidence: $confidence)';
}
}
/// Ответ AI для не-транзакционных SMS (реклама, уведомления и т.д.)
@HiveType(typeId: 22)
class AiNonTransactionResponse extends AiResponse {
@HiveField(0)
final String exclusionRegex;
@HiveField(1)
@override
final double confidence;
const AiNonTransactionResponse({
required this.exclusionRegex,
required this.confidence,
}) : super(isTransaction: false, confidence: confidence);
@override
bool isValid() {
return super.isValid() && exclusionRegex.trim().isNotEmpty;
}
factory AiNonTransactionResponse.fromJson(Map<String, dynamic> json) {
return AiNonTransactionResponse(
exclusionRegex: json['exclusionRegex'] as String,
confidence: (json['confidence'] as num).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'isTransaction': isTransaction,
'amount': null,
'vendor': null,
'confidence': confidence,
'suggestedCategory': null,
'exclusionRegex': exclusionRegex,
};
}
@override
String toString() {
return 'AiNonTransactionResponse(exclusionRegex: $exclusionRegex, '
'confidence: $confidence)';
}
}
/// Фабрика для создания типизированных ответов AI
class AiResponseFactory {
/// Создает типизированный ответ AI из JSON Map
static AiResponse fromJson(Map<String, dynamic> json) {
final isTransaction = json['isTransaction'] as bool? ?? false;
if (isTransaction) {
return AiTransactionResponse.fromJson(json);
} else {
return AiNonTransactionResponse.fromJson(json);
}
}
/// Создает Map для сохранения из типизированного ответа
static Map<String, dynamic> toJson(AiResponse response) {
if (response is AiTransactionResponse) {
return response.toJson();
} else if (response is AiNonTransactionResponse) {
return response.toJson();
}
throw ArgumentError('Неизвестный тип ответа AI: ${response.runtimeType}');
}
}
+88
View File
@@ -0,0 +1,88 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ai_response.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AiTransactionResponseAdapter extends TypeAdapter<AiTransactionResponse> {
@override
final typeId = 21;
@override
AiTransactionResponse read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AiTransactionResponse(
amount: (fields[0] as num).toDouble(),
vendor: fields[1] as String,
suggestedCategory: fields[2] as String?,
confidence: (fields[3] as num).toDouble(),
);
}
@override
void write(BinaryWriter writer, AiTransactionResponse obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.amount)
..writeByte(1)
..write(obj.vendor)
..writeByte(2)
..write(obj.suggestedCategory)
..writeByte(3)
..write(obj.confidence);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AiTransactionResponseAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class AiNonTransactionResponseAdapter
extends TypeAdapter<AiNonTransactionResponse> {
@override
final typeId = 22;
@override
AiNonTransactionResponse read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AiNonTransactionResponse(
exclusionRegex: fields[0] as String,
confidence: (fields[1] as num).toDouble(),
);
}
@override
void write(BinaryWriter writer, AiNonTransactionResponse obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.exclusionRegex)
..writeByte(1)
..write(obj.confidence);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AiNonTransactionResponseAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:equatable/equatable.dart';
import 'package:hive_ce/hive.dart';
import '../utils/id_generator.dart';
part 'ai_rule.g.dart';
/// Перечисление для типа правила ИИ
@HiveType(typeId: 20)
enum AiRuleType {
/// Правило для определения точки продаж
@HiveField(0)
pointOfSale,
/// Шаблон для пропуска сообщений
@HiveField(1)
skipTemplate,
}
/// Перечисление для статуса обработки правила
@HiveType(typeId: 24)
enum ProcessingStatus {
/// Создано
@HiveField(0)
created,
/// Обработано
@HiveField(1)
processed,
/// Требует внимания
@HiveField(2)
needsAttention,
/// Отклонено
@HiveField(3)
rejected,
}
/// Модель правила ИИ для обработки SMS сообщений
@HiveType(typeId: 23)
class AiRule extends Equatable {
/// Уникальный идентификатор правила
@HiveField(0)
final String id;
/// Название правила
@HiveField(1)
final String name;
/// Тип правила
@HiveField(2)
final AiRuleType type;
/// Активность правила
@HiveField(3)
final bool isActive;
/// Процент уверенности (0-100)
@HiveField(4)
final int confidencePercentage;
/// Статус обработки
@HiveField(5)
final ProcessingStatus processingStatus;
// Поля для точки продаж
/// Паттерн для определения торговой точки (регулярное выражение)
@HiveField(6)
final String? merchantPattern;
/// ID категории для автоматического назначения
@HiveField(7)
final String? categoryId;
// Поля для шаблона пропуска
/// Регулярное выражение для пропуска сообщений
@HiveField(8)
final String? skipRegex;
/// Дата создания
@HiveField(9)
final DateTime createdAt;
/// Дата последнего обновления
@HiveField(10)
final DateTime updatedAt;
/// Конструктор
AiRule({
String? id,
required this.name,
required this.type,
this.isActive = true,
this.confidencePercentage = 80,
this.processingStatus = ProcessingStatus.processed,
this.merchantPattern,
this.categoryId,
this.skipRegex,
DateTime? createdAt,
DateTime? updatedAt,
}) : id = id ?? IdGenerator.generateId(),
createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
/// Метод для преобразования объекта в Map
Map<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'type': type.name,
'isActive': isActive,
'confidencePercentage': confidencePercentage,
'processingStatus': processingStatus.name,
'merchantPattern': merchantPattern,
'categoryId': categoryId,
'skipRegex': skipRegex,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
};
}
/// Фабричный метод для создания объекта из Map
factory AiRule.fromMap(Map<String, dynamic> map) {
return AiRule(
id: map['id'],
name: map['name'],
type: AiRuleType.values.firstWhere((e) => e.name == map['type']),
isActive: map['isActive'],
confidencePercentage: map['confidencePercentage'],
processingStatus: ProcessingStatus.values.firstWhere(
(e) => e.name == map['processingStatus'],
),
merchantPattern: map['merchantPattern'],
categoryId: map['categoryId'],
skipRegex: map['skipRegex'],
createdAt: DateTime.parse(map['createdAt']),
updatedAt: DateTime.parse(map['updatedAt']),
);
}
/// Метод для создания копии объекта с возможностью изменения полей
AiRule copyWith({
String? name,
AiRuleType? type,
bool? isActive,
int? confidencePercentage,
ProcessingStatus? processingStatus,
String? merchantPattern,
String? categoryId,
String? skipRegex,
}) {
return AiRule(
id: id,
name: name ?? this.name,
type: type ?? this.type,
isActive: isActive ?? this.isActive,
confidencePercentage: confidencePercentage ?? this.confidencePercentage,
processingStatus: processingStatus ?? this.processingStatus,
merchantPattern: merchantPattern ?? this.merchantPattern,
categoryId: categoryId ?? this.categoryId,
skipRegex: skipRegex ?? this.skipRegex,
createdAt: createdAt,
updatedAt: DateTime.now(), // Обновляем updatedAt при каждом копировании
);
}
@override
List<Object?> get props => [id];
}
+155
View File
@@ -0,0 +1,155 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ai_rule.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AiRuleAdapter extends TypeAdapter<AiRule> {
@override
final typeId = 23;
@override
AiRule read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AiRule(
id: fields[0] as String?,
name: fields[1] as String,
type: fields[2] as AiRuleType,
isActive: fields[3] == null ? true : fields[3] as bool,
confidencePercentage: fields[4] == null ? 80 : (fields[4] as num).toInt(),
processingStatus: fields[5] == null
? ProcessingStatus.processed
: fields[5] as ProcessingStatus,
merchantPattern: fields[6] as String?,
categoryId: fields[7] as String?,
skipRegex: fields[8] as String?,
createdAt: fields[9] as DateTime?,
updatedAt: fields[10] as DateTime?,
);
}
@override
void write(BinaryWriter writer, AiRule obj) {
writer
..writeByte(11)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.name)
..writeByte(2)
..write(obj.type)
..writeByte(3)
..write(obj.isActive)
..writeByte(4)
..write(obj.confidencePercentage)
..writeByte(5)
..write(obj.processingStatus)
..writeByte(6)
..write(obj.merchantPattern)
..writeByte(7)
..write(obj.categoryId)
..writeByte(8)
..write(obj.skipRegex)
..writeByte(9)
..write(obj.createdAt)
..writeByte(10)
..write(obj.updatedAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AiRuleAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class AiRuleTypeAdapter extends TypeAdapter<AiRuleType> {
@override
final typeId = 20;
@override
AiRuleType read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return AiRuleType.pointOfSale;
case 1:
return AiRuleType.skipTemplate;
default:
return AiRuleType.pointOfSale;
}
}
@override
void write(BinaryWriter writer, AiRuleType obj) {
switch (obj) {
case AiRuleType.pointOfSale:
writer.writeByte(0);
case AiRuleType.skipTemplate:
writer.writeByte(1);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AiRuleTypeAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class ProcessingStatusAdapter extends TypeAdapter<ProcessingStatus> {
@override
final typeId = 24;
@override
ProcessingStatus read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return ProcessingStatus.created;
case 1:
return ProcessingStatus.processed;
case 2:
return ProcessingStatus.needsAttention;
case 3:
return ProcessingStatus.rejected;
default:
return ProcessingStatus.created;
}
}
@override
void write(BinaryWriter writer, ProcessingStatus obj) {
switch (obj) {
case ProcessingStatus.created:
writer.writeByte(0);
case ProcessingStatus.processed:
writer.writeByte(1);
case ProcessingStatus.needsAttention:
writer.writeByte(2);
case ProcessingStatus.rejected:
writer.writeByte(3);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ProcessingStatusAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:io';
import 'package:hive_ce/hive.dart';
part 'ai_settings.g.dart';
@HiveType(typeId: 25)
class AiSettings extends HiveObject {
@HiveField(0)
String? apiKey;
@HiveField(1)
String baseUrl;
@HiveField(2)
String defaultModel;
@HiveField(3)
int timeoutSeconds;
@HiveField(4)
double temperature;
@HiveField(5)
int maxTokens;
@HiveField(6)
bool isEnabled;
AiSettings({
this.apiKey,
String? baseUrl,
String? defaultModel,
this.timeoutSeconds = 30,
this.temperature = 0.7,
this.maxTokens = 1000,
this.isEnabled = true,
}) : baseUrl = baseUrl ?? 'https://openrouter.ai/api/v1',
defaultModel =
defaultModel ??
Platform.environment['AI_DEFAULT_MODEL'] ??
'google/gemini-2.5-flash';
AiSettings copyWith({
String? apiKey,
String? baseUrl,
String? defaultModel,
int? timeoutSeconds,
double? temperature,
int? maxTokens,
bool? isEnabled,
}) {
return AiSettings(
apiKey: apiKey ?? this.apiKey,
baseUrl: baseUrl ?? this.baseUrl,
defaultModel: defaultModel ?? this.defaultModel,
timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds,
temperature: temperature ?? this.temperature,
maxTokens: maxTokens ?? this.maxTokens,
isEnabled: isEnabled ?? this.isEnabled,
);
}
@override
String toString() {
return 'AiSettings(baseUrl: $baseUrl, defaultModel: $defaultModel, timeoutSeconds: $timeoutSeconds, temperature: $temperature, maxTokens: $maxTokens, isEnabled: $isEnabled)';
}
}
+59
View File
@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ai_settings.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AiSettingsAdapter extends TypeAdapter<AiSettings> {
@override
final typeId = 25;
@override
AiSettings read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AiSettings(
apiKey: fields[0] as String?,
baseUrl: fields[1] as String?,
defaultModel: fields[2] as String?,
timeoutSeconds: fields[3] == null ? 30 : (fields[3] as num).toInt(),
temperature: fields[4] == null ? 0.7 : (fields[4] as num).toDouble(),
maxTokens: fields[5] == null ? 1000 : (fields[5] as num).toInt(),
isEnabled: fields[6] == null ? true : fields[6] as bool,
);
}
@override
void write(BinaryWriter writer, AiSettings obj) {
writer
..writeByte(7)
..writeByte(0)
..write(obj.apiKey)
..writeByte(1)
..write(obj.baseUrl)
..writeByte(2)
..write(obj.defaultModel)
..writeByte(3)
..write(obj.timeoutSeconds)
..writeByte(4)
..write(obj.temperature)
..writeByte(5)
..write(obj.maxTokens)
..writeByte(6)
..write(obj.isEnabled);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AiSettingsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
+10 -1
View File
@@ -25,12 +25,16 @@ class AppSettings extends Equatable {
@HiveField(4)
final DateTime updatedAt;
@HiveField(5)
final bool autoCreateTransactionsFromSms;
AppSettings({
String? id,
this.languageCode = 'ru',
this.isDarkMode = false,
this.defaultCurrency = 'RUB',
DateTime? updatedAt,
this.autoCreateTransactionsFromSms = false,
}) : id = id ?? IdGenerator.generateId(),
updatedAt = updatedAt ?? DateTime.now();
@@ -41,6 +45,7 @@ class AppSettings extends Equatable {
'isDarkMode': isDarkMode,
'defaultCurrency': defaultCurrency,
'updatedAt': updatedAt.toIso8601String(),
'autoCreateTransactionsFromSms': autoCreateTransactionsFromSms,
};
}
@@ -51,6 +56,7 @@ class AppSettings extends Equatable {
isDarkMode: map['isDarkMode'] ?? false,
defaultCurrency: map['defaultCurrency'] ?? 'RUB',
updatedAt: DateTime.parse(map['updatedAt']),
autoCreateTransactionsFromSms: map['autoCreateTransactionsFromSms'] ?? false,
);
}
@@ -60,11 +66,13 @@ class AppSettings extends Equatable {
String? languageCode,
bool? isDarkMode,
String? defaultCurrency,
bool? autoCreateTransactionsFromSms,
}) {
return AppSettings(
languageCode: languageCode ?? this.languageCode,
isDarkMode: isDarkMode ?? this.isDarkMode,
defaultCurrency: defaultCurrency ?? this.defaultCurrency,
autoCreateTransactionsFromSms: autoCreateTransactionsFromSms ?? this.autoCreateTransactionsFromSms,
updatedAt: DateTime.now(),
);
}
@@ -75,6 +83,7 @@ class AppSettings extends Equatable {
@override
String toString() {
return 'AppSettings(languageCode: $languageCode, '
'isDarkMode: $isDarkMode, defaultCurrency: $defaultCurrency)';
'isDarkMode: $isDarkMode, defaultCurrency: $defaultCurrency, '
'autoCreateTransactionsFromSms: $autoCreateTransactionsFromSms)';
}
}
+10 -2
View File
@@ -17,17 +17,23 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AppSettings(
id: fields[0] as String?,
languageCode: fields[1] == null ? 'ru' : fields[1] as String,
isDarkMode: fields[2] == null ? false : fields[2] as bool,
defaultCurrency: fields[3] == null ? 'RUB' : fields[3] as String,
updatedAt: fields[4] as DateTime?,
autoCreateTransactionsFromSms: fields[5] == null
? false
: fields[5] as bool,
);
}
@override
void write(BinaryWriter writer, AppSettings obj) {
writer
..writeByte(4)
..writeByte(6)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.languageCode)
..writeByte(2)
@@ -35,7 +41,9 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
..writeByte(3)
..write(obj.defaultCurrency)
..writeByte(4)
..write(obj.updatedAt);
..write(obj.updatedAt)
..writeByte(5)
..write(obj.autoCreateTransactionsFromSms);
}
@override
+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)
+51
View File
@@ -0,0 +1,51 @@
import 'package:budget_app/models/category.dart';
import 'package:hive_ce/hive.dart';
part 'prefilled_transaction.g.dart';
/// Модель для предварительно заполненной транзакции из SMS.
@HiveType(typeId: 9)
class PrefilledTransaction extends HiveObject {
/// ID SMS сообщения, из которого была создана транзакция.
@HiveField(0)
late String smsMessageId;
/// ID созданной транзакции (может быть временным).
@HiveField(1)
late String? transactionId;
/// Сумма транзакции.
@HiveField(2)
late double? amount;
/// Точка продаж, извлеченная из SMS.
@HiveField(3)
late String? salesPoint;
/// Категория, определенная автоматически.
@HiveField(4)
late Category? calculatedCategory;
/// Процент уверенности в правильности определения категории.
@HiveField(5)
late double confidence;
/// Регулярное выражение, которое можно использовать для исключения подобных SMS в будущем.
@HiveField(6)
late String? exclusionRegex;
/// Флаг, указывающий является ли это транзакционным SMS.
@HiveField(7)
late bool isTransaction;
PrefilledTransaction({
required this.smsMessageId,
required this.transactionId,
required this.amount,
required this.salesPoint,
this.calculatedCategory,
required this.confidence,
this.exclusionRegex,
required this.isTransaction,
});
}
+62
View File
@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'prefilled_transaction.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class PrefilledTransactionAdapter extends TypeAdapter<PrefilledTransaction> {
@override
final typeId = 9;
@override
PrefilledTransaction read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return PrefilledTransaction(
smsMessageId: fields[0] as String,
transactionId: fields[1] as String?,
amount: (fields[2] as num?)?.toDouble(),
salesPoint: fields[3] as String?,
calculatedCategory: fields[4] as Category?,
confidence: (fields[5] as num).toDouble(),
exclusionRegex: fields[6] as String?,
isTransaction: fields[7] as bool,
);
}
@override
void write(BinaryWriter writer, PrefilledTransaction obj) {
writer
..writeByte(8)
..writeByte(0)
..write(obj.smsMessageId)
..writeByte(1)
..write(obj.transactionId)
..writeByte(2)
..write(obj.amount)
..writeByte(3)
..write(obj.salesPoint)
..writeByte(4)
..write(obj.calculatedCategory)
..writeByte(5)
..write(obj.confidence)
..writeByte(6)
..write(obj.exclusionRegex)
..writeByte(7)
..write(obj.isTransaction);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PrefilledTransactionAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:equatable/equatable.dart';
class SmsFilterModel extends Equatable {
final String? statusFilter;
final DateTime? monthFilter;
final DateTime? selectedMonth;
final bool isLoading;
const SmsFilterModel({
this.statusFilter,
this.monthFilter,
this.selectedMonth,
this.isLoading = false,
});
factory SmsFilterModel.fromJson(Map<String, dynamic> json) {
return SmsFilterModel(
statusFilter: json['statusFilter'] as String?,
monthFilter: json['monthFilter'] != null
? DateTime.parse(json['monthFilter'] as String)
: null,
selectedMonth: json['selectedMonth'] != null
? DateTime.parse(json['selectedMonth'] as String)
: null,
isLoading: json['isLoading'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() {
return {
'statusFilter': statusFilter,
'monthFilter': monthFilter?.toIso8601String(),
'selectedMonth': selectedMonth?.toIso8601String(),
'isLoading': isLoading,
};
}
factory SmsFilterModel.initial() => SmsFilterModel(
selectedMonth: DateTime.now(),
);
SmsFilterModel copyWith({
String? statusFilter,
DateTime? monthFilter,
DateTime? selectedMonth,
bool? isLoading,
}) {
return SmsFilterModel(
statusFilter: statusFilter ?? this.statusFilter,
monthFilter: monthFilter ?? this.monthFilter,
selectedMonth: selectedMonth ?? this.selectedMonth,
isLoading: isLoading ?? this.isLoading,
);
}
@override
List<Object?> get props => [statusFilter, monthFilter, selectedMonth, isLoading];
}
+37 -1
View File
@@ -1,4 +1,5 @@
import 'package:hive_ce/hive.dart';
import '/utils/id_generator.dart';
@@ -14,6 +15,10 @@ enum SmsProcessingType {
/// Обработка с использованием кастомной функции.
@HiveField(1)
customFunction,
/// Не требует обработки.
@HiveField(2)
noProcessing,
}
/// Модель для хранения правила обработки СМС от конкретного отправителя.
@@ -42,9 +47,40 @@ class SmsProcessingRule extends HiveObject {
this.customFunctionId,
}) : assert(
(type == SmsProcessingType.regexp && pattern != null) ||
(type == SmsProcessingType.customFunction && customFunctionId != null),
(type == SmsProcessingType.customFunction && customFunctionId != null) ||
(type == SmsProcessingType.noProcessing),
'Pattern must be provided for regexp type, and customFunctionId for customFunction type.',
), id = id ?? IdGenerator.generateId();
SmsProcessingRule copyWith({
SmsProcessingType? type,
String? pattern,
String? customFunctionId,
}) {
final newType = type ?? this.type;
// Логика для обновления полей в зависимости от типа
String? newPattern;
String? newCustomFunctionId;
if (newType == SmsProcessingType.regexp) {
newPattern = pattern ?? this.pattern;
newCustomFunctionId = null; // Сбрасываем customFunctionId для regexp
} else if (newType == SmsProcessingType.customFunction) {
newCustomFunctionId = customFunctionId ?? this.customFunctionId;
newPattern = null; // Сбрасываем pattern для customFunction
} else if (newType == SmsProcessingType.noProcessing) {
newPattern = null; // Сбрасываем pattern для noProcessing
newCustomFunctionId = null; // Сбрасываем customFunctionId для noProcessing
}
return SmsProcessingRule(
id: id,
type: newType,
pattern: newPattern,
customFunctionId: newCustomFunctionId,
);
}
}
/// Модель для хранения всех настроек обработки СМС для одного пользователя.
+13 -3
View File
@@ -17,6 +17,7 @@ class SmsProcessingRuleAdapter extends TypeAdapter<SmsProcessingRule> {
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return SmsProcessingRule(
id: fields[3] as String?,
type: fields[0] as SmsProcessingType,
pattern: fields[1] as String?,
customFunctionId: fields[2] as String?,
@@ -26,13 +27,15 @@ class SmsProcessingRuleAdapter extends TypeAdapter<SmsProcessingRule> {
@override
void write(BinaryWriter writer, SmsProcessingRule obj) {
writer
..writeByte(3)
..writeByte(4)
..writeByte(0)
..write(obj.type)
..writeByte(1)
..write(obj.pattern)
..writeByte(2)
..write(obj.customFunctionId);
..write(obj.customFunctionId)
..writeByte(3)
..write(obj.id);
}
@override
@@ -57,6 +60,7 @@ class SmsHandlerSettingsAdapter extends TypeAdapter<SmsHandlerSettings> {
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return SmsHandlerSettings(
id: fields[0] as String?,
rulesBySender: (fields[1] as Map).cast<String, SmsProcessingRule>(),
);
}
@@ -64,7 +68,9 @@ class SmsHandlerSettingsAdapter extends TypeAdapter<SmsHandlerSettings> {
@override
void write(BinaryWriter writer, SmsHandlerSettings obj) {
writer
..writeByte(1)
..writeByte(2)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.rulesBySender);
}
@@ -91,6 +97,8 @@ class SmsProcessingTypeAdapter extends TypeAdapter<SmsProcessingType> {
return SmsProcessingType.regexp;
case 1:
return SmsProcessingType.customFunction;
case 2:
return SmsProcessingType.noProcessing;
default:
return SmsProcessingType.regexp;
}
@@ -103,6 +111,8 @@ class SmsProcessingTypeAdapter extends TypeAdapter<SmsProcessingType> {
writer.writeByte(0);
case SmsProcessingType.customFunction:
writer.writeByte(1);
case SmsProcessingType.noProcessing:
writer.writeByte(2);
}
}
+62 -9
View File
@@ -1,10 +1,24 @@
import 'package:crypto/crypto.dart';
import 'dart:convert';
import 'package:equatable/equatable.dart';
import 'package:hive_ce/hive.dart';
import '/utils/id_generator.dart';
part 'sms_message.g.dart';
@HiveType(typeId: 1015)
enum SmsStatus {
@HiveField(0)
pending, // Ожидает обработки
@HiveField(1)
processed, // Успешно обработано
@HiveField(2)
ignored, // Помечено как "игнорировать"
@HiveField(3)
error, // Ошибка при обработке
}
@HiveType(typeId: 1004)
class SmsMessage extends HiveObject {
class SmsMessage extends Equatable {
@HiveField(0)
final String id;
@@ -17,26 +31,65 @@ class SmsMessage extends HiveObject {
@HiveField(3)
final DateTime? date;
// Комментарий: Добавлено поле для хранения идентификатора связанной транзакции.
@HiveField(4)
String? transactionId;
final String? transactionId;
@HiveField(5)
final SmsStatus status;
@HiveField(6)
final String? errorMessage;
SmsMessage({
String? id,
this.body,
this.sender,
this.date,
this.transactionId,
}) : id = id ?? IdGenerator.generateId();
this.status = SmsStatus.pending,
this.errorMessage,
}) : id = _generateId(body, sender, date);
// Комментарий: Добавляем метод для обновления transactionId
SmsMessage copyWith({String? transactionId, String? userId}) {
// Приватный метод для генерации уникального ID
static String _generateId(String? body, String? sender, DateTime? date) {
final input = '${sender ?? ''}${body ?? ''}${date?.millisecondsSinceEpoch ?? 0}';
return sha1.convert(utf8.encode(input)).toString();
}
SmsMessage copyWith({
String? transactionId,
SmsStatus? status,
String? errorMessage,
}) {
return SmsMessage(
id: id,
body: body,
sender: sender,
date: date,
transactionId: transactionId ?? this.transactionId,
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'body': body,
'sender': sender,
'date': date?.toIso8601String(),
'transactionId': transactionId,
'status': status.toString(),
'errorMessage': errorMessage,
};
}
@override
List<Object?> get props => [
id,
body,
sender,
date,
transactionId,
status,
errorMessage,
];
}
+53 -5
View File
@@ -17,20 +17,19 @@ class SmsMessageAdapter extends TypeAdapter<SmsMessage> {
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?,
status: fields[5] == null ? SmsStatus.pending : fields[5] as SmsStatus,
errorMessage: fields[6] as String?,
);
}
@override
void write(BinaryWriter writer, SmsMessage obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.id)
..writeByte(6)
..writeByte(1)
..write(obj.body)
..writeByte(2)
@@ -38,7 +37,11 @@ class SmsMessageAdapter extends TypeAdapter<SmsMessage> {
..writeByte(3)
..write(obj.date)
..writeByte(4)
..write(obj.transactionId);
..write(obj.transactionId)
..writeByte(5)
..write(obj.status)
..writeByte(6)
..write(obj.errorMessage);
}
@override
@@ -51,3 +54,48 @@ class SmsMessageAdapter extends TypeAdapter<SmsMessage> {
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
class SmsStatusAdapter extends TypeAdapter<SmsStatus> {
@override
final typeId = 1015;
@override
SmsStatus read(BinaryReader reader) {
switch (reader.readByte()) {
case 0:
return SmsStatus.pending;
case 1:
return SmsStatus.processed;
case 2:
return SmsStatus.ignored;
case 3:
return SmsStatus.error;
default:
return SmsStatus.pending;
}
}
@override
void write(BinaryWriter writer, SmsStatus obj) {
switch (obj) {
case SmsStatus.pending:
writer.writeByte(0);
case SmsStatus.processed:
writer.writeByte(1);
case SmsStatus.ignored:
writer.writeByte(2);
case SmsStatus.error:
writer.writeByte(3);
}
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SmsStatusAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}
+15 -19
View File
@@ -2,8 +2,6 @@ import 'package:equatable/equatable.dart';
import 'package:hive_ce/hive.dart';
import '../../utils/id_generator.dart';
import 'category.dart';
import 'tag.dart';
part 'transaction_record.g.dart';
@@ -16,12 +14,12 @@ class TransactionRecord extends Equatable {
final String id;
@HiveField(1)
/// Ссылка на категорию транзакции
final Category category;
/// ID категории транзакции
final String categoryId;
@HiveField(2)
/// Опциональная ссылка на тег (может быть null)
final Tag? tag;
/// Опциональный ID тега (может быть null)
final String? tagId;
@HiveField(3)
/// Сумма транзакции (отрицательная для расходов)
@@ -46,8 +44,8 @@ class TransactionRecord extends Equatable {
/// Конструктор с обязательными параметрами
TransactionRecord({
String? id,
required this.category,
this.tag,
required this.categoryId,
this.tagId,
required this.amount,
required this.dateTime,
required this.vendor,
@@ -62,8 +60,8 @@ class TransactionRecord extends Equatable {
Map<String, dynamic> toMap() {
return {
'id': id, // Добавляем id в map для fromMap
'category': category.toMap(),
'tag': tag?.toMap(),
'categoryId': categoryId,
'tagId': tagId,
'amount': amount,
'dateTime': dateTime.toIso8601String(),
'vendor': vendor,
@@ -76,8 +74,8 @@ class TransactionRecord extends Equatable {
factory TransactionRecord.fromMap(Map<String, dynamic> map) {
return TransactionRecord(
id: map['id'],
category: Category.fromMap(map['category']),
tag: map['tag'] != null ? Tag.fromMap(map['tag']) : null,
categoryId: map['categoryId'],
tagId: map['tagId'],
amount: map['amount'],
dateTime: DateTime.parse(map['dateTime']),
vendor: map['vendor'],
@@ -88,15 +86,13 @@ class TransactionRecord extends Equatable {
);
}
/// Вспомогательный геттер для определения типа операции
/// (доход/расход) на основе категории
bool get isIncome => category.isIncome;
// Примечание: isIncome геттер удален, так как теперь нужно загружать категорию по categoryId
/// Метод для создания копии объекта с возможностью изменения полей
TransactionRecord copyWith({
String? id,
Category? category,
Tag? tag,
String? categoryId,
String? tagId,
double? amount,
DateTime? dateTime,
String? vendor,
@@ -105,8 +101,8 @@ class TransactionRecord extends Equatable {
}) {
return TransactionRecord(
id: id ?? this.id,
category: category ?? this.category,
tag: tag ?? this.tag,
categoryId: categoryId ?? this.categoryId,
tagId: tagId ?? this.tagId,
amount: amount ?? this.amount,
dateTime: dateTime ?? this.dateTime,
vendor: vendor ?? this.vendor,
+4 -4
View File
@@ -18,8 +18,8 @@ class TransactionRecordAdapter extends TypeAdapter<TransactionRecord> {
};
return TransactionRecord(
id: fields[0] as String?,
category: fields[1] as Category,
tag: fields[2] as Tag?,
categoryId: fields[1] as String,
tagId: fields[2] as String?,
amount: (fields[3] as num).toDouble(),
dateTime: fields[4] as DateTime,
vendor: fields[5] as String,
@@ -35,9 +35,9 @@ class TransactionRecordAdapter extends TypeAdapter<TransactionRecord> {
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.category)
..write(obj.categoryId)
..writeByte(2)
..write(obj.tag)
..write(obj.tagId)
..writeByte(3)
..write(obj.amount)
..writeByte(4)
+270
View File
@@ -0,0 +1,270 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '/logic/ai_rules/ai_rules_bloc.dart';
import 'widgets/ai_rule_list_item.dart';
import 'widgets/ai_rule_edit_dialog.dart';
import 'widgets/rule_filter_widget.dart';
import '/l10n/app_localizations.dart';
class AiRulesPage extends StatelessWidget {
const AiRulesPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => GetIt.I<AiRulesBloc>()..add(const LoadRules()),
child: Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.aiRulesPageTitle),
actions: [
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
tooltip: 'Дополнительные действия',
onSelected: (value) {
_handleMenuAction(context, value);
},
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'import_rules',
child: Row(
children: [
const Icon(Icons.file_download),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.importRules),
],
),
),
PopupMenuItem<String>(
value: 'export_rules',
child: Row(
children: [
const Icon(Icons.file_upload),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.exportRules),
],
),
),
const PopupMenuDivider(),
PopupMenuItem<String>(
value: 'test_all_rules',
child: Row(
children: [
const Icon(Icons.play_arrow),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.testAllRules),
],
),
),
];
},
),
],
),
body: Column(
children: [
// Компактные фильтры
const RuleFilterWidget(),
// Список правил
Expanded(
child: BlocBuilder<AiRulesBloc, AiRulesState>(
builder: (context, state) {
if (state is AiRulesLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state is AiRulesError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Ошибка загрузки правил',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
state.message,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
context.read<AiRulesBloc>().add(const LoadRules());
},
child: Text(AppLocalizations.of(context)!.retryText),
),
],
),
);
}
if (state is AiRulesLoaded) {
if (state.filteredRules.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.smart_toy_outlined,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text(
state.rules.isEmpty
? AppLocalizations.of(context)!.noRulesCreated
: AppLocalizations.of(context)!.noRulesByFilter,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
state.rules.isEmpty
? AppLocalizations.of(context)!.createFirstRule
: AppLocalizations.of(context)!.changeFilters,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
if (state.rules.isEmpty) ...[
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _showCreateRuleDialog(context),
icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.createRule),
),
],
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.filteredRules.length,
itemBuilder: (context, index) {
final rule = state.filteredRules[index];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: AiRuleListItem(rule: rule),
);
},
);
}
return const SizedBox.shrink();
},
),
),
],
),
floatingActionButton: Builder(
builder: (innerContext) => FloatingActionButton(
onPressed: () => _showCreateRuleDialog(innerContext),
tooltip: AppLocalizations.of(context)!.createRule,
child: const Icon(Icons.add),
),
),
),
);
}
void _showCreateRuleDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: BlocProvider.of<AiRulesBloc>(context),
child: const AiRuleEditDialog(),
),
);
}
void _handleMenuAction(BuildContext context, String action) {
switch (action) {
case 'import_rules':
_showImportDialog(context);
break;
case 'export_rules':
_showExportDialog(context);
break;
case 'test_all_rules':
_showTestAllDialog(context);
break;
}
}
void _showImportDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.importRules),
content: Text(
AppLocalizations.of(context)!.importRulesMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context)!.closeText),
),
],
),
);
}
void _showExportDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.exportRules),
content: Text(
AppLocalizations.of(context)!.exportRulesMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context)!.closeText),
),
],
),
);
}
void _showTestAllDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.testAllRules),
content: Text(
AppLocalizations.of(context)!.testAllRulesMessage,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context)!.cancelText),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.testingStarted),
duration: const Duration(seconds: 2),
),
);
},
child: Text(AppLocalizations.of(context)!.startText),
),
],
),
);
}
}
@@ -0,0 +1,626 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '/logic/ai_rules/ai_rules_bloc.dart';
import '/models/ai_rule.dart';
import '/models/category.dart';
import '/data/repositories/interfaces/icategory_repository.dart';
import '/l10n/app_localizations.dart';
class AiRuleEditDialog extends StatefulWidget {
final AiRule? rule;
const AiRuleEditDialog({
super.key,
this.rule,
});
@override
State<AiRuleEditDialog> createState() => _AiRuleEditDialogState();
}
class _AiRuleEditDialogState extends State<AiRuleEditDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _merchantPatternController;
late final TextEditingController _categoryIdController;
late final TextEditingController _skipRegexController;
late AiRuleType _selectedType;
late bool _isActive;
late ProcessingStatus _processingStatus;
List<Category> _categories = [];
Category? _selectedCategory;
@override
void initState() {
super.initState();
final rule = widget.rule;
_nameController = TextEditingController(text: rule?.name ?? '');
_merchantPatternController = TextEditingController(text: rule?.merchantPattern ?? '');
_categoryIdController = TextEditingController(text: rule?.categoryId ?? '');
_skipRegexController = TextEditingController(text: rule?.skipRegex ?? '');
_selectedType = rule?.type ?? AiRuleType.pointOfSale;
_isActive = rule?.isActive ?? true;
_processingStatus = rule?.processingStatus ?? ProcessingStatus.processed;
_loadCategories();
}
@override
void dispose() {
_nameController.dispose();
_merchantPatternController.dispose();
_categoryIdController.dispose();
_skipRegexController.dispose();
super.dispose();
}
void _loadCategories() async {
try {
final categoryRepository = GetIt.instance<ICategoryRepository>();
final categories = await categoryRepository.getAll();
// Загружено категорий: ${categories.length}
setState(() {
_categories = categories;
// Найти выбранную категорию по ID, если правило уже существует
if (widget.rule?.categoryId != null && categories.isNotEmpty) {
try {
_selectedCategory = categories.firstWhere(
(category) => category.id == widget.rule!.categoryId,
);
// Найдена категория: ${_selectedCategory?.name}
} catch (e) {
// Категория не найдена: ${widget.rule!.categoryId}
_selectedCategory = null;
}
}
});
} catch (e) {
// Обработка ошибки загрузки категорий
// Ошибка загрузки категорий: $e
setState(() {
_categories = [];
});
}
}
@override
Widget build(BuildContext context) {
return BlocListener<AiRulesBloc, AiRulesState>(
listener: (context, state) {
if (state is RuleSaved) {
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.rule == null
? AppLocalizations.of(context)!.ruleCreated
: AppLocalizations.of(context)!.ruleUpdated,
),
backgroundColor: Colors.green,
),
);
} else if (state is AiRulesError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${AppLocalizations.of(context)!.errorText}: ${state.message}'),
backgroundColor: Colors.red,
),
);
} else if (state is RuleValidated && !state.isValid) {
_showValidationErrors(state.validationErrors);
}
},
child: Dialog(
child: Container(
width: MediaQuery.of(context).size.width * 0.9,
constraints: BoxConstraints(
maxWidth: 600,
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Заголовок
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: Row(
children: [
Icon(
widget.rule == null ? Icons.add : Icons.edit,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 12),
Text(
widget.rule == null ? AppLocalizations.of(context)!.createRule : AppLocalizations.of(context)!.editRule,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
),
),
],
),
),
// Форма
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Основные поля
_buildBasicFields(),
const SizedBox(height: 24),
// Тип правила
_buildTypeSelection(),
const SizedBox(height: 24),
// Поля в зависимости от типа
_buildTypeSpecificFields(),
const SizedBox(height: 24),
// Настройки
_buildSettings(),
],
),
),
),
),
// Кнопки действий
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(12),
),
),
child: Wrap(
alignment: WrapAlignment.end,
spacing: 12,
runSpacing: 12,
children: [
if (widget.rule != null) ...[
TextButton.icon(
onPressed: _validateAndTest,
icon: const Icon(Icons.play_arrow),
label: Text(AppLocalizations.of(context)!.testRule),
),
],
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context)!.cancel),
),
ElevatedButton(
onPressed: _saveRule,
child: Text(widget.rule == null ? AppLocalizations.of(context)!.createRule : AppLocalizations.of(context)!.save),
),
],
),
),
],
),
),
),
);
}
Widget _buildBasicFields() {
return TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.ruleName,
hintText: 'Например: Сбербанк - продуктовые',
border: const OutlineInputBorder(),
),
);
}
Widget _buildTypeSelection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.ruleType,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: SegmentedButton<AiRuleType>(
segments: [
ButtonSegment<AiRuleType>(
value: AiRuleType.pointOfSale,
icon: const Icon(Icons.store, size: 18),
label: Text(AppLocalizations.of(context)!.pointOfSale),
),
ButtonSegment<AiRuleType>(
value: AiRuleType.skipTemplate,
icon: const Icon(Icons.block, size: 18),
label: Text(AppLocalizations.of(context)!.skipSms),
),
],
selected: {_selectedType},
onSelectionChanged: (Set<AiRuleType> newSelection) {
setState(() {
_selectedType = newSelection.first;
});
},
),
),
],
);
}
Widget _buildTypeSpecificFields() {
if (_selectedType == AiRuleType.pointOfSale) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.pointOfSaleSettings,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
TextFormField(
controller: _merchantPatternController,
decoration: const InputDecoration(
labelText: 'Паттерн торговой точки *',
hintText: r'.*МАГНИТ.*|.*ПЯТЕРОЧКА.*',
border: OutlineInputBorder(),
helperText: 'Регулярное выражение для поиска в тексте SMS',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Паттерн не может быть пустым';
}
try {
RegExp(value);
} catch (e) {
return AppLocalizations.of(context)!.invalidRegex;
}
return null;
},
),
const SizedBox(height: 16),
// Отображение состояния загрузки категорий
if (_categories.isEmpty)
Container(
height: 56,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
child: const Center(
child: Text('Загрузка категорий...'),
),
)
else
DropdownButtonFormField<Category>(
value: _selectedCategory,
decoration: const InputDecoration(
labelText: 'Категория *',
border: OutlineInputBorder(),
helperText: 'Выберите категорию для автоназначения',
),
items: _categories.map<DropdownMenuItem<Category>>((Category category) {
return DropdownMenuItem<Category>(
value: category,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: category.color,
shape: BoxShape.circle,
),
child: Icon(
category.icon,
size: 12,
color: Colors.white,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
category.name,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
category.isIncome ? '' : '',
style: TextStyle(
color: category.isIncome ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
),
),
],
),
);
}).toList(),
onChanged: (Category? newValue) {
setState(() {
_selectedCategory = newValue;
});
},
validator: (Category? value) {
if (value == null) {
return 'Категория должна быть выбрана';
}
return null;
},
),
],
);
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Настройки пропуска',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
TextFormField(
controller: _skipRegexController,
decoration: const InputDecoration(
labelText: 'Регулярное выражение *',
hintText: r'.*РЕКЛАМА.*|.*СПАМ.*',
border: OutlineInputBorder(),
helperText: 'Шаблон для пропуска нежелательных SMS',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return AppLocalizations.of(context)!.skipRegexRequired;
}
try {
RegExp(value);
} catch (e) {
return AppLocalizations.of(context)!.invalidRegex;
}
return null;
},
),
],
);
}
}
Widget _buildSettings() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.settings,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
SwitchListTile(
title: Text(AppLocalizations.of(context)!.activeRule),
subtitle: Text(AppLocalizations.of(context)!.activeRuleHelper),
value: _isActive,
onChanged: (value) {
setState(() {
_isActive = value;
});
},
),
const SizedBox(height: 16),
DropdownButtonFormField<ProcessingStatus>(
value: _processingStatus,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.processingStatus,
border: const OutlineInputBorder(),
),
items: ProcessingStatus.values.map((status) {
String label;
switch (status) {
case ProcessingStatus.created:
label = AppLocalizations.of(context)!.statusCreated;
break;
case ProcessingStatus.processed:
label = AppLocalizations.of(context)!.statusProcessed;
break;
case ProcessingStatus.needsAttention:
label = AppLocalizations.of(context)!.statusNeedsAttention;
break;
case ProcessingStatus.rejected:
label = AppLocalizations.of(context)!.statusRejected;
break;
}
return DropdownMenuItem(
value: status,
child: Text(label),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_processingStatus = value;
});
}
},
),
],
);
}
void _saveRule() {
if (!_formKey.currentState!.validate()) {
return;
}
String generatedName = '';
if (_nameController.text.trim().isEmpty) {
if (_selectedType == AiRuleType.pointOfSale) {
final pattern = _merchantPatternController.text.trim();
final categoryName = _selectedCategory?.name ?? AppLocalizations.of(context)!.unknownCategory;
generatedName = '${AppLocalizations.of(context)!.rulePatternLabel} $pattern -> $categoryName';
} else {
generatedName = AppLocalizations.of(context)!.skipSmsRuleDefault;
}
} else {
generatedName = _nameController.text.trim();
}
final rule = AiRule(
id: widget.rule?.id,
name: generatedName,
type: _selectedType,
isActive: _isActive,
processingStatus: _processingStatus,
merchantPattern: _selectedType == AiRuleType.pointOfSale
? _merchantPatternController.text.trim()
: null,
categoryId: _selectedType == AiRuleType.pointOfSale
? _selectedCategory?.id
: null,
skipRegex: _selectedType == AiRuleType.skipTemplate
? _skipRegexController.text.trim()
: null,
createdAt: widget.rule?.createdAt,
);
// Валидация перед сохранением
context.read<AiRulesBloc>().add(ValidateRule(rule: rule));
// Сохранение правила
if (widget.rule == null) {
context.read<AiRulesBloc>().add(CreateRule(rule: rule));
} else {
context.read<AiRulesBloc>().add(UpdateRule(rule: rule));
}
}
void _validateAndTest() {
if (!_formKey.currentState!.validate()) {
return;
}
// Показать диалог тестирования
final TextEditingController testController = TextEditingController();
showDialog(
context: context,
builder: (testContext) => AlertDialog(
title: Text(AppLocalizations.of(context)!.testRuleTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(context)!.enterSmsText),
const SizedBox(height: 16),
TextField(
controller: testController,
maxLines: 3,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: AppLocalizations.of(context)!.smsTextPlaceholder,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(testContext).pop(),
child: const Text('Отмена'),
),
ElevatedButton(
onPressed: () {
if (testController.text.trim().isNotEmpty && widget.rule != null) {
context.read<AiRulesBloc>().add(
TestRule(ruleId: widget.rule!.id, smsText: testController.text.trim()),
);
Navigator.of(testContext).pop();
}
},
child: Text(AppLocalizations.of(context)!.testRule),
),
],
),
);
}
void _showValidationErrors(List<String> errors) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.validationErrorsTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: errors.map((error) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error, color: Colors.red, size: 16),
const SizedBox(width: 8),
Expanded(child: Text(error)),
],
),
)).toList(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context)!.understandText),
),
],
),
);
}
}
@@ -0,0 +1,427 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '/logic/ai_rules/ai_rules_bloc.dart';
import '/models/ai_rule.dart';
import 'ai_rule_edit_dialog.dart';
import '/l10n/app_localizations.dart';
class AiRuleListItem extends StatelessWidget {
final AiRule rule;
const AiRuleListItem({
super.key,
required this.rule,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
child: InkWell(
onTap: () => _showEditDialog(context),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Заголовок с типом и статусом
Row(
children: [
_buildTypeIcon(),
const SizedBox(width: 8),
Expanded(
child: Text(
rule.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
_buildStatusChip(context),
const SizedBox(width: 8),
Switch(
value: rule.isActive,
onChanged: (value) {
context.read<AiRulesBloc>().add(
ToggleRuleActive(ruleId: rule.id),
);
},
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
),
const SizedBox(height: 8),
// Детали правила
_buildRuleDetails(context),
const SizedBox(height: 12),
// Нижняя панель с действиями
Row(
children: [
// Уверенность (только для статусов "создано" и "требует внимания")
if (rule.processingStatus == ProcessingStatus.created ||
rule.processingStatus == ProcessingStatus.needsAttention) ...
[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.percent,
size: 16,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
'${rule.confidencePercentage}%',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.secondary,
),
),
],
),
),
],
const Spacer(),
// Действия
PopupMenuButton<String>(
iconSize: 20,
tooltip: 'Действия',
onSelected: (value) => _handleAction(context, value),
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit, size: 16),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.editRule),
],
),
),
PopupMenuItem(
value: 'test',
child: Row(
children: [
const Icon(Icons.play_arrow, size: 16),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.testRule),
],
),
),
PopupMenuItem(
value: 'duplicate',
child: Row(
children: [
const Icon(Icons.copy, size: 16),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.duplicateRule),
],
),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, size: 16, color: Colors.red),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.deleteRule, style: const TextStyle(color: Colors.red)),
],
),
),
],
),
],
),
],
),
),
),
);
}
Widget _buildTypeIcon() {
IconData iconData;
Color color;
switch (rule.type) {
case AiRuleType.pointOfSale:
iconData = Icons.store;
color = Colors.blue;
break;
case AiRuleType.skipTemplate:
iconData = Icons.block;
color = Colors.orange;
break;
}
return Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
iconData,
size: 20,
color: color,
),
);
}
Widget _buildStatusChip(BuildContext context) {
String label;
Color color;
IconData icon;
switch (rule.processingStatus) {
case ProcessingStatus.created:
label = AppLocalizations.of(context)!.statusCreated;
color = Colors.grey;
icon = Icons.fiber_new;
break;
case ProcessingStatus.processed:
label = AppLocalizations.of(context)!.statusProcessed;
color = Colors.green;
icon = Icons.check_circle;
break;
case ProcessingStatus.needsAttention:
label = AppLocalizations.of(context)!.statusNeedsAttention;
color = Colors.amber;
icon = Icons.warning;
break;
case ProcessingStatus.rejected:
label = AppLocalizations.of(context)!.statusRejected;
color = Colors.red;
icon = Icons.cancel;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 14,
color: color,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: color,
),
),
],
),
);
}
Widget _buildRuleDetails(BuildContext context) {
switch (rule.type) {
case AiRuleType.pointOfSale:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDetailRow(
context,
'${AppLocalizations.of(context)!.merchantPattern}:',
rule.merchantPattern ?? AppLocalizations.of(context)!.notSetText,
Icons.pattern,
),
const SizedBox(height: 4),
_buildDetailRow(
context,
'${AppLocalizations.of(context)!.category}:',
rule.categoryId ?? AppLocalizations.of(context)!.notSelectedText,
Icons.category,
),
],
);
case AiRuleType.skipTemplate:
return _buildDetailRow(
context,
'${AppLocalizations.of(context)!.skipRegex}:',
rule.skipRegex ?? AppLocalizations.of(context)!.notSetText,
Icons.code,
);
}
}
Widget _buildDetailRow(BuildContext context, String label, String value, IconData icon) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: RichText(
text: TextSpan(
style: Theme.of(context).textTheme.bodySmall,
children: [
TextSpan(
text: label,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const TextSpan(text: ' '),
TextSpan(
text: value,
style: TextStyle(
fontFamily: 'monospace',
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
),
],
);
}
void _showEditDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<AiRulesBloc>(),
child: AiRuleEditDialog(rule: rule),
),
);
}
void _handleAction(BuildContext context, String action) {
switch (action) {
case 'edit':
_showEditDialog(context);
break;
case 'test':
_showTestDialog(context);
break;
case 'duplicate':
_duplicateRule(context);
break;
case 'delete':
_showDeleteDialog(context);
break;
}
}
void _showTestDialog(BuildContext context) {
final TextEditingController controller = TextEditingController();
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<AiRulesBloc>(),
child: AlertDialog(
title: Text('${AppLocalizations.of(context)!.testRuleTitle} "${rule.name}"'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(context)!.enterSmsText),
const SizedBox(height: 16),
TextField(
controller: controller,
maxLines: 3,
decoration: InputDecoration(
border: const OutlineInputBorder(),
hintText: AppLocalizations.of(context)!.smsTextPlaceholder,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(AppLocalizations.of(context)!.cancelText),
),
ElevatedButton(
onPressed: () {
if (controller.text.trim().isNotEmpty) {
context.read<AiRulesBloc>().add(
TestRule(ruleId: rule.id, smsText: controller.text.trim()),
);
Navigator.of(dialogContext).pop();
}
},
child: Text(AppLocalizations.of(context)!.testRule),
),
],
),
),
);
}
void _duplicateRule(BuildContext context) {
final duplicatedRule = rule.copyWith(
name: '${rule.name} (копия)',
);
context.read<AiRulesBloc>().add(CreateRule(rule: duplicatedRule));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.ruleDuplicated),
duration: const Duration(seconds: 2),
),
);
}
void _showDeleteDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<AiRulesBloc>(),
child: AlertDialog(
title: Text(AppLocalizations.of(context)!.deleteRule),
content: Text(AppLocalizations.of(context)!.deleteRuleConfirm(rule.name)),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(AppLocalizations.of(context)!.cancelText),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
onPressed: () {
context.read<AiRulesBloc>().add(DeleteRule(ruleId: rule.id));
Navigator.of(dialogContext).pop();
},
child: Text(AppLocalizations.of(context)!.deleteRule),
),
],
),
),
);
}
}
@@ -0,0 +1,459 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '/logic/ai_rules/ai_rules_bloc.dart';
import '/models/ai_rule.dart';
import '/l10n/app_localizations.dart';
enum FilterSection { none, type, status, activity }
class RuleFilterWidget extends StatefulWidget {
const RuleFilterWidget({super.key});
@override
State<RuleFilterWidget> createState() => _RuleFilterWidgetState();
}
class _RuleFilterWidgetState extends State<RuleFilterWidget>
with SingleTickerProviderStateMixin {
FilterSection _expandedSection = FilterSection.none;
late AnimationController _animationController;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_animation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AiRulesBloc, AiRulesState>(
builder: (context, state) {
if (state is! AiRulesLoaded) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Основные кнопки фильтров
Row(
children: [
_buildFilterButton(
context,
state,
FilterSection.type,
'Тип',
_getTypeDisplayText(state.activeTypeFilter),
),
const SizedBox(width: 8),
_buildFilterButton(
context,
state,
FilterSection.status,
'Статус',
_getStatusDisplayText(state.activeStatusFilter),
),
const SizedBox(width: 8),
_buildFilterButton(
context,
state,
FilterSection.activity,
'Активность',
_getActivityDisplayText(state.activeActiveFilter),
),
const Spacer(),
// Кнопка сброса (только если есть активные фильтры)
if (_hasActiveFilters(state))
TextButton.icon(
onPressed: () => _clearAllFilters(context),
icon: const Icon(Icons.clear_all, size: 16),
label: const Text('Сброс'),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
],
),
// Раскрывающаяся секция
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
if (_expandedSection == FilterSection.none) {
return const SizedBox.shrink();
}
return SizeTransition(
sizeFactor: _animation,
child: Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(vertical: 8),
child: _buildExpandedContent(context, state),
),
);
},
),
],
),
);
},
);
}
Widget _buildFilterButton(
BuildContext context,
AiRulesLoaded state,
FilterSection section,
String label,
String value,
) {
final isExpanded = _expandedSection == section;
final hasActiveFilter = _hasActiveFilterForSection(state, section);
return GestureDetector(
onTap: () => _toggleSection(section),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: hasActiveFilter
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isExpanded || hasActiveFilter
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
width: isExpanded || hasActiveFilter ? 1.5 : 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label: $value',
style: TextStyle(
color: hasActiveFilter
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
fontWeight: hasActiveFilter ? FontWeight.w600 : FontWeight.w400,
fontSize: 14,
),
),
const SizedBox(width: 4),
Icon(
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 16,
color: hasActiveFilter
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
);
}
Widget _buildExpandedContent(BuildContext context, AiRulesLoaded state) {
switch (_expandedSection) {
case FilterSection.type:
return _buildTypeOptions(context, state);
case FilterSection.status:
return _buildStatusOptions(context, state);
case FilterSection.activity:
return _buildActivityOptions(context, state);
case FilterSection.none:
return const SizedBox.shrink();
}
}
Widget _buildTypeOptions(BuildContext context, AiRulesLoaded state) {
return Wrap(
spacing: 8,
children: [
_buildRadioOption(
context,
AppLocalizations.of(context)!.allFilter,
state.activeTypeFilter == null,
() => _applyTypeFilter(context, null),
Icons.all_inclusive,
),
_buildRadioOption(
context,
AppLocalizations.of(context)!.pointOfSale,
state.activeTypeFilter == AiRuleType.pointOfSale,
() => _applyTypeFilter(context, AiRuleType.pointOfSale),
Icons.store,
),
_buildRadioOption(
context,
AppLocalizations.of(context)!.skipSms,
state.activeTypeFilter == AiRuleType.skipTemplate,
() => _applyTypeFilter(context, AiRuleType.skipTemplate),
Icons.block,
),
],
);
}
Widget _buildStatusOptions(BuildContext context, AiRulesLoaded state) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildRadioOption(
context,
AppLocalizations.of(context)!.allFilter,
state.activeStatusFilter == null,
() => _applyStatusFilter(context, null),
Icons.all_inclusive,
),
_buildRadioOption(
context,
AppLocalizations.of(context)!.statusCreated,
state.activeStatusFilter == ProcessingStatus.created,
() => _applyStatusFilter(context, ProcessingStatus.created),
Icons.fiber_new,
),
_buildRadioOption(
context,
AppLocalizations.of(context)!.statusProcessed,
state.activeStatusFilter == ProcessingStatus.processed,
() => _applyStatusFilter(context, ProcessingStatus.processed),
Icons.check_circle,
),
_buildRadioOption(
context,
AppLocalizations.of(context)!.statusNeedsAttention,
state.activeStatusFilter == ProcessingStatus.needsAttention,
() => _applyStatusFilter(context, ProcessingStatus.needsAttention),
Icons.warning,
),
_buildRadioOption(
context,
AppLocalizations.of(context)!.statusRejected,
state.activeStatusFilter == ProcessingStatus.rejected,
() => _applyStatusFilter(context, ProcessingStatus.rejected),
Icons.cancel,
),
],
);
}
Widget _buildActivityOptions(BuildContext context, AiRulesLoaded state) {
return Wrap(
spacing: 8,
children: [
_buildRadioOption(
context,
AppLocalizations.of(context)!.allFilter,
state.activeActiveFilter == null,
() => _applyActivityFilter(context, null),
Icons.all_inclusive,
),
_buildRadioOption(
context,
'Активные',
state.activeActiveFilter == true,
() => _applyActivityFilter(context, true),
Icons.toggle_on,
),
_buildRadioOption(
context,
'Неактивные',
state.activeActiveFilter == false,
() => _applyActivityFilter(context, false),
Icons.toggle_off,
),
],
);
}
Widget _buildRadioOption(
BuildContext context,
String label,
bool isSelected,
VoidCallback onTap,
IconData icon,
) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: isSelected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
fontSize: 14,
),
),
],
),
),
);
}
void _toggleSection(FilterSection section) {
setState(() {
if (_expandedSection == section) {
_expandedSection = FilterSection.none;
_animationController.reverse();
} else {
_expandedSection = section;
_animationController.forward();
}
});
}
void _applyTypeFilter(BuildContext context, AiRuleType? type) {
final currentState = context.read<AiRulesBloc>().state;
if (currentState is AiRulesLoaded) {
context.read<AiRulesBloc>().add(
FilterRules(
type: type,
status: currentState.activeStatusFilter,
isActive: currentState.activeActiveFilter,
),
);
_closeExpandedSection();
}
}
void _applyStatusFilter(BuildContext context, ProcessingStatus? status) {
final currentState = context.read<AiRulesBloc>().state;
if (currentState is AiRulesLoaded) {
context.read<AiRulesBloc>().add(
FilterRules(
type: currentState.activeTypeFilter,
status: status,
isActive: currentState.activeActiveFilter,
),
);
_closeExpandedSection();
}
}
void _applyActivityFilter(BuildContext context, bool? isActive) {
final currentState = context.read<AiRulesBloc>().state;
if (currentState is AiRulesLoaded) {
context.read<AiRulesBloc>().add(
FilterRules(
type: currentState.activeTypeFilter,
status: currentState.activeStatusFilter,
isActive: isActive,
),
);
_closeExpandedSection();
}
}
void _closeExpandedSection() {
setState(() {
_expandedSection = FilterSection.none;
_animationController.reverse();
});
}
void _clearAllFilters(BuildContext context) {
context.read<AiRulesBloc>().add(const FilterRules());
_closeExpandedSection();
}
bool _hasActiveFilters(AiRulesLoaded state) {
return state.activeTypeFilter != null ||
state.activeStatusFilter != null ||
state.activeActiveFilter != null;
}
bool _hasActiveFilterForSection(AiRulesLoaded state, FilterSection section) {
switch (section) {
case FilterSection.type:
return state.activeTypeFilter != null;
case FilterSection.status:
return state.activeStatusFilter != null;
case FilterSection.activity:
return state.activeActiveFilter != null;
case FilterSection.none:
return false;
}
}
String _getTypeDisplayText(AiRuleType? type) {
if (type == null) return AppLocalizations.of(context)!.allFilter;
switch (type) {
case AiRuleType.pointOfSale:
return AppLocalizations.of(context)!.pointOfSale;
case AiRuleType.skipTemplate:
return AppLocalizations.of(context)!.skipSms;
}
}
String _getStatusDisplayText(ProcessingStatus? status) {
if (status == null) return AppLocalizations.of(context)!.allFilter;
switch (status) {
case ProcessingStatus.created:
return AppLocalizations.of(context)!.statusCreated;
case ProcessingStatus.processed:
return AppLocalizations.of(context)!.statusProcessed;
case ProcessingStatus.needsAttention:
return AppLocalizations.of(context)!.statusNeedsAttention;
case ProcessingStatus.rejected:
return AppLocalizations.of(context)!.statusRejected;
}
}
String _getActivityDisplayText(bool? isActive) {
if (isActive == null) return AppLocalizations.of(context)!.allFilter;
return isActive ? 'Активные' : 'Неактивные';
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ class CategoryEditPage extends StatefulWidget {
const CategoryEditPage({super.key, this.category, required this.onSave});
@override
_CategoryEditPageState createState() => _CategoryEditPageState();
State<CategoryEditPage> createState() => _CategoryEditPageState();
}
class _CategoryEditPageState extends State<CategoryEditPage> {
@@ -28,7 +28,7 @@ class _CategoryEditPageState extends State<CategoryEditPage> {
super.initState();
_name = widget.category?.name ?? '';
// Генерация случайного цвета из всей палитры для новой категории
_color = widget.category?.color ?? Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0);
_color = widget.category?.color ?? Color((Random().nextDouble() * 0xFFFFFF).toInt()).withValues(alpha: 1.0);
// Генерация случайной иконки из предопределённого набора для новой категории
_icon = widget.category?.icon ?? [
Icons.attach_money,
+2 -2
View File
@@ -64,7 +64,7 @@ class _CategoryListPageState extends State<CategoryListPage> {
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,
);
@@ -29,7 +29,7 @@ class CategoryListItem extends StatelessWidget {
children: [
// Аватар категории
CircleAvatar(
backgroundColor: category.color.withOpacity(0.2),
backgroundColor: category.color.withAlpha(51),
radius: 24,
child: Icon(
category.icon,
+26 -8
View File
@@ -1,11 +1,12 @@
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 '../../theme/custom_colors.dart';
import '../ai_rules/ai_rules_page.dart';
import '../home/widgets/summary_widget.dart'; // Импортируем новый виджет сводки
import '../reports_page.dart'; // Импортируем новую страницу отчетов
import '../settings_page.dart';
@@ -29,6 +30,7 @@ class _HomePageState extends State<HomePage> {
const TransactionsPage(), // Главная страница с транзакциями
const ReportsPage(), // Страница отчетов
const SmsPage(), // Страница SMS
const AiRulesPage(), // Страница правил ИИ
const SettingsPage(), // Страница настроек
];
@@ -40,7 +42,7 @@ class _HomePageState extends State<HomePage> {
if (userState is UserLoaded && userState.user != null) {
_currentUserId = userState.user!.id;
// Загружаем транзакции через глобальный TransactionBloc
context.read<TransactionBloc>().add(LoadTransactions(userId: _currentUserId));
context.read<TransactionBloc>().add(LoadTransactions());
}
}
@@ -64,18 +66,22 @@ class _HomePageState extends State<HomePage> {
body: Center(
child: _widgetOptions[_selectedIndex], // Отображаем выбранный виджет
),
floatingActionButton: FloatingActionButton(
floatingActionButton: _selectedIndex == 0
? FloatingActionButton(
onPressed: () {
showDialog(
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (BuildContext context) {
return const AddTransactionDialog();
},
);
},
child: const Icon(Icons.add),
),
)
: null,
bottomNavigationBar: BottomNavigationBar(
type: BottomNavigationBarType.fixed, // Показать все вкладки
items: <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: const Icon(Icons.home),
@@ -89,6 +95,10 @@ class _HomePageState extends State<HomePage> {
icon: const Icon(Icons.sms),
label: localizations.smsPageTitle,
),
BottomNavigationBarItem(
icon: const Icon(Icons.smart_toy),
label: localizations.aiRulesPageTitle,
),
BottomNavigationBarItem(
icon: const Icon(Icons.settings),
label: localizations.settingsPageTitle, // Локализованный текст
@@ -96,7 +106,9 @@ class _HomePageState extends State<HomePage> {
],
currentIndex: _selectedIndex,
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Theme.of(context).extension<CustomColors>()?.unselectedIcon,
unselectedItemColor: Theme.of(
context,
).extension<CustomColors>()?.unselectedIcon,
onTap: _onItemTapped,
),
);
@@ -140,9 +152,13 @@ class _TransactionsPageState extends State<TransactionsPage> {
} else if (state is TransactionLoaded) {
// Добавление: Фильтрация транзакций по выбранному месяцу.
final monthlyTransactions = state.transactions.where((t) {
return t.dateTime.month == _selectedMonth.month && t.dateTime.year == _selectedMonth.year;
return t.dateTime.month == _selectedMonth.month &&
t.dateTime.year == _selectedMonth.year;
}).toList();
// Сортируем транзакции по дате (от новых к старым)
monthlyTransactions.sort((a, b) => b.dateTime.compareTo(a.dateTime));
return ListView(
children: [
// Изменение: SummaryWidget теперь получает все транзакции,
@@ -201,7 +217,9 @@ class _TransactionsPageState extends State<TransactionsPage> {
],
);
} 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));
}
@@ -4,21 +4,36 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:intl/intl.dart';
import '../../../data/repositories/interfaces/icategory_repository.dart';
import '../../../data/repositories/interfaces/itag_repository.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});
final TransactionRecord? transaction;
const AddTransactionDialog({super.key, this.transaction});
@override
State<AddTransactionDialog> createState() => _AddTransactionDialogState();
static Future<void> show(
BuildContext context, {
TransactionRecord? transaction,
}) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AddTransactionDialog(transaction: transaction),
);
}
}
class _AddTransactionDialogState extends State<AddTransactionDialog> {
@@ -29,16 +44,82 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
bool _isIncome = false;
Category? _selectedCategory;
// Комментарий: Добавляем состояние для выбранного тега.
Tag? _selectedTag;
// Комментарий: Заменяем _selectedDate на _selectedDateTime для хранения даты и времени.
DateTime _selectedDateTime = DateTime.now();
List<Category> _expenseCategories = [];
List<Category> _incomeCategories = [];
List<Tag> _tags = [];
bool _isTagsLoaded = false;
@override
void initState() {
super.initState();
// Комментарий: Устанавливаем начальное значение с датой и временем.
_dateController.text = DateFormat('dd-MM-yyyy').add_Hm().format(_selectedDateTime);
// Кэшируем категории один раз
final allCategories = CategoryUtils.getDefaultCategories();
_expenseCategories = allCategories.where((c) => !c.isIncome).toList();
_incomeCategories = allCategories.where((c) => c.isIncome).toList();
// Загружаем теги один раз
_loadTags();
// Если передан transaction - заполняем поля его данными
if (widget.transaction != null) {
_loadTransactionData();
}
_dateController.text = DateFormat(
'dd-MM-yyyy',
).add_Hm().format(_selectedDateTime);
}
Future<void> _loadTags() async {
try {
final tags = await GetIt.instance<ITagRepository>().getAll();
setState(() {
_tags = tags;
_isTagsLoaded = true;
});
} catch (e) {
setState(() {
_isTagsLoaded = true;
});
}
}
Future<void> _loadTransactionData() async {
if (widget.transaction == null) return;
final t = widget.transaction!;
try {
// Загружаем категорию по ID
final categoryRepo = GetIt.instance<ICategoryRepository>();
final category = await categoryRepo.getById(t.categoryId);
// Загружаем тег по ID (если есть)
Tag? tag;
if (t.tagId != null) {
final tagRepo = GetIt.instance<ITagRepository>();
tag = await tagRepo.getById(t.tagId!);
}
setState(() {
_selectedCategory = category;
_selectedTag = tag;
_isIncome = category?.isIncome ?? false;
_selectedDateTime = t.dateTime;
_amountController.text = t.amount.toString();
_vendorController.text = t.vendor;
});
} catch (e) {
// В случае ошибки используем дефолтные значения
setState(() {
_selectedDateTime = t.dateTime;
_amountController.text = t.amount.toString();
_vendorController.text = t.vendor;
});
}
}
@override
@@ -49,6 +130,118 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
super.dispose();
}
Widget _buildIncomeExpenseToggle(
BuildContext context,
AppLocalizations localizations,
ThemeData theme,
) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
if (_isIncome) {
setState(() {
_isIncome = false;
_selectedCategory = null;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: !_isIncome
? theme.colorScheme.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.remove_circle_outline,
color: !_isIncome
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface.withValues(alpha: 0.7),
size: 18,
),
const SizedBox(width: 8),
Text(
localizations.expense,
style: TextStyle(
color: !_isIncome
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface.withValues(
alpha: 0.7,
),
fontWeight: !_isIncome
? FontWeight.w600
: FontWeight.w500,
),
),
],
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () {
if (!_isIncome) {
setState(() {
_isIncome = true;
_selectedCategory = null;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _isIncome
? theme.colorScheme.primary
: Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_circle_outline,
color: _isIncome
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface.withValues(alpha: 0.7),
size: 18,
),
const SizedBox(width: 8),
Text(
localizations.income,
style: TextStyle(
color: _isIncome
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface.withValues(
alpha: 0.7,
),
fontWeight: _isIncome
? FontWeight.w600
: FontWeight.w500,
),
),
],
),
),
),
),
],
),
);
}
// Комментарий: Этот метод теперь обрабатывает выбор и даты, и времени.
Future<void> _selectDateTime(BuildContext context) async {
final DateTime? pickedDate = await showDatePicker(
@@ -59,7 +252,7 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
);
// Комментарий: Если пользователь не выбрал дату, выходим из функции.
if (pickedDate == null) return;
if (!context.mounted) return;
// ignore: use_build_context_synchronously
final TimeOfDay? pickedTime = await showTimePicker(
context: context,
@@ -78,7 +271,9 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
pickedTime.minute,
);
// Комментарий: Обновляем текстовое поле с отформатированной датой и временем.
_dateController.text = DateFormat('dd-MM-yyyy').add_Hm().format(_selectedDateTime);
_dateController.text = DateFormat(
'dd-MM-yyyy',
).add_Hm().format(_selectedDateTime);
});
}
@@ -122,26 +317,34 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
// Получаем 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!,
categoryId: _selectedCategory!.id,
dateTime: _selectedDateTime,
tag: _selectedTag,
tagId: _selectedTag?.id,
currency: currency,
);
if (widget.transaction != null) {
// Режим редактирования
final updatedTransaction = widget.transaction!.copyWith(
amount: amount,
vendor: _vendorController.text,
categoryId: _selectedCategory!.id,
dateTime: _selectedDateTime,
tagId: _selectedTag?.id,
currency: currency,
);
context.read<TransactionBloc>().add(
UpdateTransaction(transaction: updatedTransaction),
);
} else {
// Режим добавления
context.read<TransactionBloc>().add(
AddTransaction(transaction: newTransaction),
);
}
Navigator.of(context).pop();
}
}
@@ -149,39 +352,67 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final categories = _isIncome ? _incomeCategories : _expenseCategories;
final categories = CategoryUtils.getDefaultCategories()
.where((c) => c.isIncome == _isIncome).toList();
return AlertDialog(
title: Text(localizations.addTransactionButton),
content: Form(
return Material(
child: Container(
height: mediaQuery.size.height * 0.9,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Text(
widget.transaction != null
? 'Редактировать транзакцию'
: localizations.addTransactionButton,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
_buildIncomeExpenseToggle(context, localizations, theme),
Expanded(
child: Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
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),
// Комментарий: Добавляем фильтр для ввода только чисел и одной точки.
decoration: InputDecoration(
labelText: localizations.amount,
prefixIcon: const Icon(Icons.attach_money),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.3),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d*'),
),
],
validator: (value) {
if (value == null || value.isEmpty) {
@@ -193,9 +424,19 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _vendorController,
decoration: InputDecoration(labelText: localizations.vendor),
decoration: InputDecoration(
labelText: localizations.vendor,
prefixIcon: const Icon(Icons.store),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.3),
),
validator: (value) {
if (value == null || value.isEmpty) {
return localizations.requiredField;
@@ -203,13 +444,100 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
return null;
},
),
DropdownButtonFormField<Category>(
const SizedBox(height: 16),
_buildCategoryField(localizations, theme, categories),
const SizedBox(height: 16),
_buildTagField(localizations, theme),
const SizedBox(height: 16),
_buildDateField(localizations, theme),
],
),
),
),
),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(top: BorderSide(color: theme.dividerColor)),
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(localizations.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(localizations.save),
),
),
],
),
),
),
],
),
),
);
}
Widget _buildCategoryField(
AppLocalizations localizations,
ThemeData theme,
List<Category> categories,
) {
return SizedBox(
width: double.infinity,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: DropdownButtonFormField<Category>(
key: ValueKey(_isIncome),
value: _selectedCategory,
decoration: InputDecoration(labelText: localizations.category),
decoration: InputDecoration(
labelText: localizations.category,
prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
),
items: categories.map((Category category) {
return DropdownMenuItem<Category>(
value: category,
child: Text(category.name),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: category.color,
shape: BoxShape.circle,
),
child: Icon(category.icon, color: Colors.white, size: 16),
),
const SizedBox(width: 12),
Text(category.name),
],
),
);
}).toList(),
onChanged: (Category? newValue) {
@@ -220,59 +548,60 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
validator: (value) =>
value == null ? localizations.requiredField : null,
),
// Комментарий: Добавляем выпадающий список для выбора тега.
// Он будет загружать теги асинхронно для текущего пользователя.
FutureBuilder<List<Tag>>(
future: GetIt.instance<ITagRepository>().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<Tag>(
value: _selectedTag,
decoration: InputDecoration(labelText: localizations.tag),
items: tags.map((Tag tag) {
return DropdownMenuItem<Tag>(
value: tag,
child: Text(tag.name),
),
);
}
Widget _buildTagField(AppLocalizations localizations, ThemeData theme) {
if (!_isTagsLoaded) {
return const SizedBox(
height: 56,
child: Center(child: CircularProgressIndicator()),
);
}
return SizedBox(
width: double.infinity,
child: DropdownButtonFormField<Tag>(
value: _selectedTag,
decoration: InputDecoration(
labelText: localizations.tag,
prefixIcon: const Icon(Icons.label),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
),
items: _tags.map((Tag tag) {
return DropdownMenuItem<Tag>(value: tag, child: Text(tag.name));
}).toList(),
onChanged: (Tag? newValue) {
setState(() {
_selectedTag = newValue;
});
},
// Комментарий: Тег не является обязательным полем.
);
},
),
TextFormField(
);
}
Widget _buildDateField(AppLocalizations localizations, ThemeData theme) {
return TextFormField(
controller: _dateController,
decoration: InputDecoration(
labelText: localizations.date,
prefixIcon: const Icon(Icons.schedule),
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_today),
// Комментарий: Вызываем новый метод для выбора даты и времени.
onPressed: () => _selectDateTime(context),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
),
readOnly: true,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(localizations.cancel),
),
ElevatedButton(onPressed: _submitForm, child: Text(localizations.save)),
],
);
}
}
+18 -3
View File
@@ -2,7 +2,9 @@
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/models/category.dart';
import 'package:budget_app/theme/custom_colors.dart';
import 'package:budget_app/utils/category_utils.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@@ -34,10 +36,17 @@ class _SummaryWidgetState extends State<SummaryWidget> {
late DateTime _initialMonth;
// Добавление: Общее количество месяцев для отображения.
int _monthCount = 0;
// Кэш категорий для определения isIncome
late Map<String, Category> _categoriesMap;
@override
void initState() {
super.initState();
// Инициализируем карту категорий
final categories = CategoryUtils.getDefaultCategories();
_categoriesMap = {for (var category in categories) category.id: category};
// Изменение: Находим самую раннюю транзакцию для определения начального месяца.
if (widget.transactions.isNotEmpty) {
widget.transactions.sort((a, b) => a.dateTime.compareTo(b.dateTime));
@@ -54,6 +63,12 @@ class _SummaryWidgetState extends State<SummaryWidget> {
_pageController = PageController(initialPage: _monthCount - 1);
}
// Helper метод для определения является ли транзакция доходом
bool _isIncomeTransaction(TransactionRecord transaction) {
final category = _categoriesMap[transaction.categoryId];
return category?.isIncome ?? false;
}
@override
void dispose() {
_pageController.dispose();
@@ -134,10 +149,10 @@ class _SummaryWidgetState extends State<SummaryWidget> {
}).toList();
final income = monthlyTransactions
.where((t) => t.isIncome)
.where((t) => _isIncomeTransaction(t))
.fold(0.0, (sum, item) => sum + item.amount);
final expense = monthlyTransactions
.where((t) => !t.isIncome)
.where((t) => !_isIncomeTransaction(t))
.fold(0.0, (sum, item) => sum + item.amount);
final balance = income - expense;
@@ -201,7 +216,7 @@ class _SummaryWidgetState extends State<SummaryWidget> {
Text(
_formatMonth(context, month),
style: theme.textTheme.titleMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
color: theme.colorScheme.onSurface.withAlpha(178),
),
),
// Удаление: Индикатор перенесен из страницы.
+106 -27
View File
@@ -1,54 +1,137 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:intl/intl.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/transaction_record.dart';
import '../../../models/category.dart';
import '../../../models/tag.dart';
import '../../../data/repositories/interfaces/icategory_repository.dart';
import '../../../data/repositories/interfaces/itag_repository.dart';
import 'add_transaction_dialog.dart';
/// Виджет для отображения одной транзакции в списке.
///
/// Этот виджет представляет собой карточку с подробной информацией о транзакции,
/// включая поставщика, сумму, категорию, тег и дату.
class TransactionItem extends StatelessWidget {
class TransactionItem extends StatefulWidget {
final TransactionRecord transaction;
const TransactionItem({super.key, required this.transaction});
@override
State<TransactionItem> createState() => _TransactionItemState();
}
class _TransactionItemState extends State<TransactionItem> {
Category? _category;
Tag? _tag;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
try {
final categoryRepo = GetIt.instance<ICategoryRepository>();
final category = await categoryRepo.getById(widget.transaction.categoryId);
Tag? tag;
if (widget.transaction.tagId != null) {
final tagRepo = GetIt.instance<ITagRepository>();
tag = await tagRepo.getById(widget.transaction.tagId!);
}
if (mounted) {
setState(() {
_category = category;
_tag = tag;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
final theme = Theme.of(context);
// Форматируем дату в соответствии с локалью
final formattedDate = DateFormat.yMMMd(localizations.localeName).format(transaction.dateTime);
final formattedDate = DateFormat.yMMMd(
localizations.localeName,
).format(widget.transaction.dateTime);
// Убираем Card, так как обертка будет в родительском виджете.
// Добавляем разделитель и уменьшаем отступы для компактности.
return Column(
if (_isLoading) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 16),
Expanded(
child: Text(
widget.transaction.vendor,
style: theme.textTheme.titleSmall,
),
),
],
),
),
);
}
final isIncome = _category?.isIncome ?? false;
return InkWell(
onTap: () {
showDialog(
context: context,
builder: (ctx) => AddTransactionDialog(transaction: widget.transaction),
);
},
highlightColor: Theme.of(context).primaryColor.withAlpha(26),
splashColor: Theme.of(context).primaryColor.withAlpha(51),
borderRadius: BorderRadius.circular(8),
radius: 300,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
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,
widget.transaction.vendor,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
// Сумма транзакции
// Используем .abs() чтобы избежать двойного минуса для расходов
Text(
'${transaction.isIncome ? '+' : '-'}${transaction.amount.abs().toStringAsFixed(2)} ${transaction.currency}',
'${isIncome ? '+' : '-'}${widget.transaction.amount.abs().toStringAsFixed(2)} ${widget.transaction.currency}',
style: theme.textTheme.titleSmall?.copyWith(
color: transaction.isIncome
color: isIncome
? theme.colorScheme.primary
: theme.colorScheme.error,
fontWeight: FontWeight.bold,
@@ -57,40 +140,36 @@ class TransactionItem extends StatelessWidget {
],
),
const SizedBox(height: 8.0),
// Средняя строка: Категория и тег
Row(
children: [
// Иконка категории
Icon(
transaction.category.icon,
color: transaction.category.color,
size: 20.0, // Уменьшаем размер иконки
_category?.icon ?? Icons.help_outline,
color: _category?.color ?? theme.colorScheme.onSurface,
size: 20.0,
),
const SizedBox(width: 8.0),
// Название категории и тега
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
transaction.category.name,
_category?.name ?? 'Unknown Category',
style: theme.textTheme.bodySmall,
),
if (transaction.tag != null)
if (_tag != null)
Text(
'#${transaction.tag!.name}',
'#${_tag!.name}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
color: theme.colorScheme.onSurface.withAlpha(153),
),
),
],
),
),
// Дата в правом углу
Text(
formattedDate,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
color: theme.colorScheme.onSurface.withAlpha(128),
),
),
],
@@ -98,9 +177,9 @@ class TransactionItem extends StatelessWidget {
],
),
),
// Разделитель между транзакциями
const Divider(height: 1, thickness: 1, indent: 16, endIndent: 16),
],
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:budget_app/injection_container.dart';
import 'package:budget_app/logic/prefilled_transaction/prefilled_transaction_cubit.dart';
import 'package:budget_app/logic/prefilled_transaction/prefilled_transaction_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '/l10n/app_localizations.dart';
/// Служебный экран для отображения предварительно заполненных транзакций.
class PrefilledTransactionsPage extends StatelessWidget {
const PrefilledTransactionsPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
getIt<PrefilledTransactionCubit>()..loadTransactions(),
child: Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.serviceTransactionsTitle),
),
body: BlocBuilder<PrefilledTransactionCubit, PrefilledTransactionState>(
builder: (context, state) {
if (state is PrefilledTransactionLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is PrefilledTransactionLoaded) {
if (state.transactions.isEmpty) {
return Center(
child: Text(AppLocalizations.of(context)!.noData),
);
}
return ListView.builder(
itemCount: state.transactions.length,
itemBuilder: (context, index) {
final transaction = state.transactions[index];
return Card(
margin: const EdgeInsets.all(8.0),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('SMS ID: ${transaction.smsMessageId}'),
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context)!.amount}: ${transaction.amount}',
),
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context)!.salesPoint}: ${transaction.salesPoint}',
),
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context)!.category}: ${transaction.calculatedCategory?.name ?? AppLocalizations.of(context)!.notDefined}',
),
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context)!.confidence}: ${(transaction.confidence * 100).toStringAsFixed(2)}%',
),
if (transaction.exclusionRegex != null) ...[
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context)!.exclusionRegex}: ${transaction.exclusionRegex}',
),
],
],
),
),
);
},
);
} else if (state is PrefilledTransactionError) {
return Center(child: Text(state.message));
} else {
return Center(child: Text(AppLocalizations.of(context)!.noData));
}
},
),
),
);
}
}
+26 -4
View File
@@ -1,5 +1,5 @@
import 'package:budget_app/logic/sms/sms_cubit.dart';
import 'package:budget_app/pages/category/category_list_page.dart';
import 'package:budget_app/pages/service/prefilled_transactions_page.dart';
import 'package:budget_app/pages/tag/tag_list_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -120,6 +120,15 @@ class SettingsPage extends StatelessWidget {
),
),
const Divider(),
SwitchListTile(
title: Text(localizations.autoCreateTransactionsSetting),
subtitle: Text(localizations.autoCreateTransactionsDescription),
value: state.autoCreateTransactionsFromSms,
onChanged: (value) {
context.read<SettingsCubit>().toggleAutoCreateTransactions(value);
},
),
const Divider(),
// Комментарий: ListTile для перехода на страницу редактирования категорий.
ListTile(
title: Text(localizations.editCategories),
@@ -148,15 +157,28 @@ class SettingsPage extends StatelessWidget {
},
),
const Divider(),
const Divider(),
// Комментарий: ListTile для перехода на служебный экран предварительно заполненных транзакций.
ListTile(
title: Text(localizations.serviceTransactionsTitle),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PrefilledTransactionsPage(),
),
);
},
),
const Divider(),
// Комментарий: ListTile для запуска процесса загрузки SMS-сообщений.
ListTile(
title: Text(localizations.loadSmsMessages),
subtitle:
Text(localizations.loadSmsMessagesDescription),
onTap: () {
// Комментарий: При нажатии на кнопку мы вызываем метод `loadSmsMessages` у SmsCubit.
// Это инициирует процесс получения и сохранения SMS-сообщений.
context.read<SmsCubit>().loadSmsMessages();
// TODO: Implement SMS loading functionality
// using SmsListCubit when it's properly registered
},
),
const Divider(),
+281 -35
View File
@@ -1,54 +1,300 @@
import 'package:budget_app/l10n/app_localizations.dart';
import 'package:budget_app/logic/sms/filter/sms_filter_cubit.dart';
import 'package:budget_app/logic/sms/list/sms_list_cubit.dart';
import 'package:budget_app/logic/sms/sms_settings_cubit.dart';
import 'package:budget_app/logic/user/user_cubit.dart';
import 'package:budget_app/models/sms_message.dart';
import 'package:budget_app/pages/sms/widgets/sms_list_view.dart';
import 'package:budget_app/services/sms_service.dart';
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';
import 'package:get_it/get_it.dart';
/// Экран для отображения SMS сообщений.
///
/// Использует [SmsCubit] для получения и отображения
/// последних SMS сообщений.
class SmsPage extends StatelessWidget {
const SmsPage({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (context) => SmsFilterCubit()),
BlocProvider(
create: (context) => SmsListCubit(smsService: GetIt.I<SmsService>()),
),
BlocProvider(create: (context) => GetIt.I<SmsSettingsCubit>()),
],
child: Builder(
builder: (context) {
final listCubit = context.read<SmsListCubit>();
final filterCubit = context.read<SmsFilterCubit>();
listCubit.setFilterCubit(filterCubit);
// Получаем пользователя и загружаем СМС
final userState = context.read<UserCubit>().state;
if (userState is UserLoaded && userState.user != null) {
listCubit.loadSms(userState.user!);
} else {
// Можно показать ошибку или пустой экран, если пользователя нет
}
final loc = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.smsPageTitle),
title: Text(loc.smsMessages),
actions: [
PopupMenuButton<String>(
key: const Key('sms_settings_menu'),
icon: const Icon(Icons.settings),
tooltip: loc.smsTooltip,
onSelected: (value) {
_handleSettingsMenuAction(context, value);
},
itemBuilder: (BuildContext context) {
return <PopupMenuEntry<String>>[
PopupMenuItem<String>(
value: 'processing_rules',
child: Row(
children: [
Icon(Icons.rule),
SizedBox(width: 8),
Text(loc.processingRules),
],
),
body: BlocBuilder<SmsCubit, SmsState>(
),
PopupMenuItem<String>(
value: 'sync_sms',
child: Row(
children: [
Icon(Icons.sync),
SizedBox(width: 8),
Text(loc.syncSms),
],
),
),
PopupMenuItem<String>(
value: 'auto_processing',
child: Row(
children: [
Icon(Icons.auto_mode),
SizedBox(width: 8),
Text(loc.autoProcessing),
],
),
),
];
},
),
],
),
body: Column(
children: [
Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: BlocBuilder<SmsFilterCubit, SmsFilterState>(
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]);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.filter_list,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Text(
loc.filterByStatus,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildStatusChip(
context,
loc.allStatus,
null,
state.statusFilter == null,
Icons.all_inclusive,
),
const SizedBox(width: 8),
_buildStatusChip(
context,
loc.pendingStatus,
SmsStatus.pending,
state.statusFilter == SmsStatus.pending,
Icons.access_time,
),
const SizedBox(width: 8),
_buildStatusChip(
context,
loc.processedStatus,
SmsStatus.processed,
state.statusFilter == SmsStatus.processed,
Icons.check_circle,
),
const SizedBox(width: 8),
_buildStatusChip(
context,
loc.ignoredStatus,
SmsStatus.ignored,
state.statusFilter == SmsStatus.ignored,
Icons.block,
),
const SizedBox(width: 8),
_buildStatusChip(
context,
loc.errorStatus,
SmsStatus.error,
state.statusFilter == SmsStatus.error,
Icons.error,
),
],
),
),
],
);
},
),
),
const Expanded(child: SmsListView()),
],
),
);
} 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();
},
),
);
}
Widget _buildStatusChip(
BuildContext context,
String label,
SmsStatus? status,
bool isSelected,
IconData icon,
) {
return FilterChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 16,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
),
),
],
),
selected: isSelected,
onSelected: (selected) {
context.read<SmsFilterCubit>().setStatusFilter(status);
},
selectedColor: Theme.of(context).colorScheme.primaryContainer,
checkmarkColor: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.surface,
side: BorderSide(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: isSelected ? 2 : 1,
),
elevation: isSelected ? 2 : 0,
pressElevation: 4,
);
}
void _handleSettingsMenuAction(BuildContext context, String action) {
switch (action) {
case 'processing_rules':
_showProcessingRulesDialog(context);
break;
case 'sync_sms':
_syncSmsMessages(context);
break;
case 'auto_processing':
_showAutoProcessingDialog(context);
break;
}
}
void _showProcessingRulesDialog(BuildContext context) {
final loc = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(loc.processingRules),
content: Text(loc.processingRulesDescription),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(loc.close),
),
],
),
);
}
void _syncSmsMessages(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final userState = context.read<UserCubit>().state;
if (userState is UserLoaded && userState.user != null) {
context.read<SmsListCubit>().loadSms(userState.user!);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(loc.syncSmsStarted),
duration: Duration(seconds: 2),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(loc.userNotAuthenticatedError),
duration: Duration(seconds: 2),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
void _showAutoProcessingDialog(BuildContext context) {
final loc = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(loc.autoProcessing),
content: Text(loc.autoProcessingDescription),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(loc.close),
),
],
),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:budget_app/logic/sms/list/sms_list_cubit.dart';
import 'package:budget_app/logic/sms/item/sms_item_cubit.dart';
import 'package:budget_app/pages/sms/widgets/sms_message_widget.dart';
import 'package:budget_app/services/sms_transaction_service.dart';
import 'package:budget_app/services/sms_service.dart';
import 'package:get_it/get_it.dart';
import 'package:budget_app/l10n/app_localizations.dart';
class SmsListView extends StatelessWidget {
const SmsListView({super.key});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return BlocBuilder<SmsListCubit, SmsListState>(
builder: (context, state) {
if (state is SmsListLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is SmsListSuccess) {
if (state.messages.isEmpty) {
return Center(child: Text(loc.noNewSmsMessages));
}
return ListView.builder(
itemCount: state.messages.length,
itemBuilder: (context, index) {
final message = state.messages[index];
// Для каждого элемента списка создаем свой SmsItemCubit
return KeyedSubtree(
key: ValueKey(message.id),
child: BlocProvider(
create: (context) => SmsItemCubit(
message: message,
smsTransactionService: GetIt.I<SmsTransactionService>(),
smsService: GetIt.I<SmsService>(),
// Передаем callback для обновления конкретного сообщения
onProcessed: (updatedMessage) {
context.read<SmsListCubit>().updateMessage(updatedMessage);
},
),
child: Builder(
builder: (cubitContext) {
// Обновляем кубит с актуальным сообщением при каждом rebuild
cubitContext.read<SmsItemCubit>().updateMessage(message);
return SmsMessageWidget(message: message);
},
),
),
);
},
);
} else if (state is SmsListError) {
return Center(child: Text(loc.errorLoading(state.error)));
} else {
return Center(child: Text(loc.somethingWentWrong));
}
},
);
}
}
+507 -82
View File
@@ -1,115 +1,540 @@
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';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:budget_app/logic/sms/item/sms_item_cubit.dart';
import 'package:budget_app/models/sms_message.dart';
import 'package:budget_app/pages/sms/widgets/sms_settings_dialog.dart';
import 'package:budget_app/l10n/app_localizations.dart';
/// Виджет для отображения одного SMS сообщения с улучшенным интерфейсом.
///
/// Отображает отправителя, дату, тело сообщения и статус обработки.
/// Подсвечивает суммы в тексте сообщения.
class SmsMessageWidget extends StatelessWidget {
final SmsMessage message;
const SmsMessageWidget({super.key, required this.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(
final loc = AppLocalizations.of(context)!;
return BlocBuilder<SmsItemCubit, SmsItemState>(
builder: (context, state) {
// Получаем актуальное сообщение из кубита
final currentMessage = context.read<SmsItemCubit>().currentMessage;
return Card(
elevation: 2,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: _getGradientForStatus(currentMessage.status, context),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Заголовок: отправитель и дата
Row(
children: [
Icon(Icons.person_outline, size: 16),
Builder(
builder: (context) => Icon(
Icons.account_circle,
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[400]
: Colors.grey[600],
size: 20,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
message.sender ?? AppLocalizations.of(context)!.unknownSender,
style: Theme.of(context).textTheme.titleSmall,
currentMessage.sender ?? loc.unknownSender,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
],
),
const SizedBox(height: 8),
Builder(
builder: (context) => Text(
currentMessage.body ?? '',
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[300]
: Colors.grey[700],
fontSize: 14,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 16),
Text(
_formatDate(context, message.date),
style: Theme.of(context).textTheme.bodySmall,
],
),
),
const SizedBox(width: 8),
_buildStatusIndicator(currentMessage.status, context, loc),
const SizedBox(width: 8),
_buildMenuButton(context, currentMessage, state, loc),
],
),
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,
),
],
),
_buildBottomRow(currentMessage, state, context, loc),
],
),
),
),
);
},
);
}
LinearGradient _getGradientForStatus(SmsStatus status, BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
switch (status) {
case SmsStatus.pending:
return LinearGradient(
colors: isDark ? [
Colors.grey.shade800,
Colors.grey.shade700,
] : [
Colors.grey.shade50,
Colors.grey.shade100,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
case SmsStatus.processed:
return LinearGradient(
colors: isDark ? [
Colors.grey.shade900,
Colors.grey.shade800,
] : [
Colors.grey.shade200,
Colors.grey.shade300,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
case SmsStatus.ignored:
return LinearGradient(
colors: isDark ? [
Colors.grey.shade900,
Colors.grey.shade800,
] : [
Colors.grey.shade100,
Colors.grey.shade200,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
case SmsStatus.error:
return LinearGradient(
colors: isDark ? [
Colors.grey.shade800,
Colors.grey.shade700,
] : [
Colors.grey.shade100,
Colors.grey.shade200,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}
}
Widget _buildStatusIndicator(SmsStatus status, BuildContext context, AppLocalizations loc) {
final isDark = Theme.of(context).brightness == Brightness.dark;
Color color;
IconData icon;
String label;
switch (status) {
case SmsStatus.pending:
color = isDark ? Colors.grey.shade400 : Colors.grey.shade600;
icon = Icons.access_time;
label = loc.pendingStatus;
break;
case SmsStatus.processed:
color = isDark ? Colors.grey.shade300 : Colors.black87;
icon = Icons.check_circle;
label = loc.processedStatus;
break;
case SmsStatus.ignored:
color = isDark ? Colors.grey.shade500 : Colors.grey.shade700;
icon = Icons.block;
label = loc.ignoredStatus;
break;
case SmsStatus.error:
color = isDark ? Colors.grey.shade400 : Colors.grey.shade600;
icon = Icons.error;
label = loc.errorStatus;
break;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
Widget _buildBottomRow(SmsMessage message, SmsItemState state, BuildContext context, AppLocalizations loc) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (message.date != null)
Text(
_formatDate(message.date!, loc),
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey[400]
: Colors.grey[600],
fontSize: 12,
),
),
if (state is SmsItemProcessing)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
// Показываем ошибку из состояния или из сохраненного сообщения
if ((state is SmsItemError) || (message.status == SmsStatus.error && message.errorMessage != null)) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey.shade800
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey.shade600
: Colors.grey.shade400,
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey.shade400
: Colors.grey.shade700,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
// Используем ошибку из состояния, если она есть, иначе из сохраненного сообщения
state is SmsItemError ? state.error : message.errorMessage!,
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? Colors.grey.shade300
: Colors.grey.shade700,
fontSize: 12,
),
),
),
],
),
),
],
],
);
}
Widget _buildMenuButton(BuildContext context, SmsMessage message, SmsItemState state, AppLocalizations loc) {
// Don't show menu if processing
if (state is SmsItemProcessing) {
return const SizedBox.shrink();
}
return PopupMenuButton<String>(
key: Key('sms_menu_${message.id}'),
icon: const Icon(Icons.more_vert, size: 20),
tooltip: 'Действия с сообщением',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 200,
maxWidth: 250,
),
onSelected: (String value) {
_handleMenuAction(context, value, message);
},
itemBuilder: (BuildContext context) {
List<PopupMenuEntry<String>> items = [];
// Always show settings and processing options first
items.addAll([
const PopupMenuItem<String>(
value: 'settings',
child: Row(
children: [
Icon(Icons.settings, color: Colors.blue),
SizedBox(width: 8),
Text('Настройка правил'),
],
),
),
const PopupMenuItem<String>(
value: 'process',
child: Row(
children: [
Icon(Icons.auto_awesome, color: Colors.purple),
SizedBox(width: 8),
Text('Обработать сообщение'),
],
),
),
]);
// Add divider if we have items
if (items.isNotEmpty) {
items.add(const PopupMenuDivider());
}
// Show different options based on status
if (message.status == SmsStatus.pending) {
items.addAll([
PopupMenuItem<String>(
value: 'create_transaction',
child: Row(
children: [
Icon(Icons.add_card, color: Colors.green),
SizedBox(width: 8),
Text(loc.createTransactionAction),
],
),
),
PopupMenuItem<String>(
value: 'ignore',
child: Row(
children: [
Icon(Icons.block, color: Colors.orange),
SizedBox(width: 8),
Text(loc.ignoreAction),
],
),
),
]);
} else if (message.status == SmsStatus.processed) {
items.add(
PopupMenuItem<String>(
value: 'view_transaction',
child: Row(
children: [
Icon(Icons.visibility, color: Colors.blue),
SizedBox(width: 8),
Text(loc.viewTransaction),
],
),
),
);
} else if (message.status == SmsStatus.ignored) {
items.add(
PopupMenuItem<String>(
value: 'unignore',
child: Row(
children: [
Icon(Icons.restore, color: Colors.blue),
SizedBox(width: 8),
Text(loc.returnToProcessing),
],
),
),
);
} else if (message.status == SmsStatus.error) {
items.addAll([
PopupMenuItem<String>(
value: 'retry',
child: Row(
children: [
Icon(Icons.refresh, color: Colors.blue),
SizedBox(width: 8),
Text(loc.retry),
],
),
),
PopupMenuItem<String>(
value: 'ignore',
child: Row(
children: [
Icon(Icons.block, color: Colors.orange),
SizedBox(width: 8),
Text(loc.ignoreAction),
],
),
),
]);
}
// Always add delete option
if (items.isNotEmpty) {
items.add(const PopupMenuDivider());
}
items.add(
PopupMenuItem<String>(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text(loc.delete, style: TextStyle(color: Colors.red)),
],
),
),
);
return items;
},
);
}
void _handleMenuAction(BuildContext context, String action, SmsMessage message) {
final cubit = context.read<SmsItemCubit>();
final loc = AppLocalizations.of(context)!;
switch (action) {
case 'settings':
_showSmsSettings(context, message);
break;
case 'process':
cubit.createTransaction(loc);
break;
case 'create_transaction':
cubit.createTransaction(loc);
break;
case 'ignore':
cubit.ignoreSms();
break;
case 'view_transaction':
_showTransactionDetails(context, message);
break;
case 'unignore':
// TODO: Implement unignore functionality
_showNotImplementedMessage(context, loc.returnToProcessingFeature);
break;
case 'retry':
cubit.createTransaction(loc);
break;
case 'delete':
_showDeleteConfirmation(context, message);
break;
}
}
void _showSmsSettings(BuildContext context, SmsMessage message) {
final loc = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (context) => SmsSettingsDialog(
sender: message.sender ?? loc.unknownSender,
),
);
}
void _showTransactionDetails(BuildContext context, SmsMessage message) {
final loc = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(loc.transactionDetails),
content: Text(loc.transactionForSms(message.sender ?? loc.unknownSender)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(loc.close),
),
],
),
);
}
void _showNotImplementedMessage(BuildContext context, String feature) {
final loc = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(loc.notImplemented(feature)),
duration: const Duration(seconds: 2),
),
);
}
void _showDeleteConfirmation(BuildContext context, SmsMessage message) {
final loc = AppLocalizations.of(context)!;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(loc.deleteMessage),
content: Text(loc.deleteConfirmation),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(loc.cancel),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_showNotImplementedMessage(context, loc.deleteSmsFeature);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(loc.delete),
),
],
),
);
}
String _formatDate(DateTime dateTime, AppLocalizations loc) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inDays > 0) {
return loc.daysAgo(difference.inDays);
} else if (difference.inHours > 0) {
return loc.hoursAgo(difference.inHours);
} else if (difference.inMinutes > 0) {
return loc.minutesAgo(difference.inMinutes);
} else {
return loc.justNow;
}
}
}
@@ -0,0 +1,253 @@
import 'package:budget_app/l10n/app_localizations.dart';
import 'package:budget_app/logic/sms/sms_settings_cubit.dart';
import 'package:budget_app/models/sms_handler_settings.dart';
import 'package:budget_app/services/custom_sms_functions.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
/// Диалоговое окно для настройки обработки SMS сообщений
class SmsSettingsDialog extends StatelessWidget {
final String sender;
const SmsSettingsDialog({super.key, required this.sender});
@override
Widget build(BuildContext context) {
// Ошибка возникала из-за вызова AppLocalizations.of(context) внутри `create` BlocProvider'а.
// `create` - это жизненный цикл, который вызывается только один раз, и в нем нельзя слушать InheritedWidget'ы.
// Чтобы это исправить, мы получаем `AppLocalizations` в методе `build`, который может безопасно слушать изменения,
// и передаем его в `loadRuleForSender`.
final loc = AppLocalizations.of(context)!;
return BlocProvider(
create: (context) {
final cubit = GetIt.I<SmsSettingsCubit>();
cubit.loadRuleForSender(sender, loc);
return cubit;
},
child: _SmsSettingsDialogView(sender: sender),
);
}
}
class _SmsSettingsDialogView extends StatefulWidget {
final String sender;
const _SmsSettingsDialogView({required this.sender});
@override
State<_SmsSettingsDialogView> createState() => _SmsSettingsDialogViewState();
}
class _SmsSettingsDialogViewState extends State<_SmsSettingsDialogView> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _patternController;
@override
void initState() {
super.initState();
_patternController = TextEditingController();
}
@override
void dispose() {
_patternController.dispose();
super.dispose();
}
void _showSnackBar(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return BlocConsumer<SmsSettingsCubit, SmsSettingsState>(
listener: (context, state) {
if (state is SmsSettingsLoaded) {
_patternController.text = state.rule?.pattern ?? '';
} else if (state is SmsSettingsSaved) {
_showSnackBar(loc.ruleSavedSuccess);
Navigator.pop(context, true); // Возвращаем true при успехе
} else if (state is SmsSettingsDeleted) {
_showSnackBar(loc.ruleDeletedSuccess);
Navigator.pop(context, true); // Возвращаем true при успехе
} else if (state is SmsSettingsError) {
_showSnackBar(state.errorMessage ?? loc.unknownError);
}
},
builder: (context, state) {
return AlertDialog(
title: Text(loc.smsSettingsTitle),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('${loc.smsSettingsForSender}: ${widget.sender}'),
const SizedBox(height: 16),
if (state is SmsSettingsLoading)
const Center(child: CircularProgressIndicator())
else if (state is SmsSettingsLoaded)
_buildForm(context, state, loc)
else
// Показываем пустую форму, если состояние еще не загружено
_buildForm(context, const SmsSettingsLoaded(null), loc),
],
),
),
),
actions: [
// Кнопка удаления (только если правило уже существует)
if (state is SmsSettingsLoaded && state.rule?.id != null)
TextButton(
onPressed: () {
context.read<SmsSettingsCubit>().deleteRule(widget.sender, loc);
},
child: Text(loc.delete),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(loc.cancel),
),
ElevatedButton(
onPressed: state is! SmsSettingsLoading
? () {
if (_formKey.currentState!.validate()) {
context.read<SmsSettingsCubit>().saveRule(
widget.sender,
loc,
);
}
}
: null,
child: Text(loc.save),
),
],
);
},
);
}
Widget _buildForm(
BuildContext context,
SmsSettingsLoaded state,
AppLocalizations loc,
) {
final rule =
state.rule ??
SmsProcessingRule(type: SmsProcessingType.regexp, pattern: '');
return Column(
children: [
// Поле выбора типа обработки
DropdownButtonFormField<SmsProcessingType>(
value: rule.type,
decoration: InputDecoration(labelText: loc.ruleTypeLabel),
items: SmsProcessingType.values.map((type) {
return DropdownMenuItem<SmsProcessingType>(
value: type,
child: Text(
type == SmsProcessingType.regexp
? loc.regexpType
: type == SmsProcessingType.customFunction
? loc.customFunctionType
: loc.noProcessingType,
),
);
}).toList(),
onChanged: (type) {
if (type != null) {
// При смене типа обработки, нужно предустановить значения по умолчанию
// для нового типа, чтобы избежать ошибок валидации.
if (type == SmsProcessingType.customFunction) {
// Устанавливаем первую доступную функцию как значение по умолчанию
final defaultFunctionId =
CustomSmsFunctions.availableFunctionIds.first;
// Очищаем контроллер при переключении на customFunction
_patternController.clear();
context.read<SmsSettingsCubit>().updateRule(
rule.copyWith(
type: type,
customFunctionId: defaultFunctionId,
),
);
} else if (type == SmsProcessingType.regexp) {
// Если переключаемся на regexp, используем текущее значение контроллера или пустую строку
final currentPattern = _patternController.text.isEmpty
? ''
: _patternController.text;
context.read<SmsSettingsCubit>().updateRule(
rule.copyWith(type: type, pattern: currentPattern),
);
} else if (type == SmsProcessingType.noProcessing) {
// Очищаем контроллер при переключении на noProcessing
_patternController.clear();
context.read<SmsSettingsCubit>().updateRule(
rule.copyWith(type: type),
);
} else {
context.read<SmsSettingsCubit>().updateRule(
rule.copyWith(type: type),
);
}
}
},
validator: (value) {
if (value == null) return loc.ruleTypeRequired;
return null;
},
),
const SizedBox(height: 16),
// Динамические поля в зависимости от типа
if (rule.type == SmsProcessingType.regexp)
TextFormField(
controller: _patternController,
decoration: InputDecoration(
labelText: loc.regexpPatternHint,
hintText: r'Пример: \d+\.\d{2}',
),
onChanged: (value) {
context.read<SmsSettingsCubit>().updateRule(
rule.copyWith(pattern: value),
);
},
validator: (value) {
if (rule.type == SmsProcessingType.regexp && (value == null || value.isEmpty)) {
return loc.regexpPatternRequired;
}
return null;
},
),
if (rule.type == SmsProcessingType.customFunction)
DropdownButtonFormField<String>(
value: rule.customFunctionId,
decoration: InputDecoration(labelText: loc.customFunctionIdHint),
items: CustomSmsFunctions.availableFunctionIds.map((id) {
return DropdownMenuItem<String>(
value: id,
child: Text(CustomSmsFunctions.functionNames[id] ?? id),
);
}).toList(),
onChanged: (value) {
if (value != null) {
context.read<SmsSettingsCubit>().updateRule(
rule.copyWith(customFunctionId: value),
);
}
},
validator: (value) {
if (rule.type == SmsProcessingType.customFunction && (value == null || value.isEmpty)) {
return loc.customFunctionIdRequired;
}
return null;
},
),
],
);
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ class TagEditPage extends StatefulWidget {
const TagEditPage({super.key, this.tag, required this.onSave});
@override
_TagEditPageState createState() => _TagEditPageState();
State<TagEditPage> createState() => _TagEditPageState();
}
class _TagEditPageState extends State<TagEditPage> {
+324
View File
@@ -0,0 +1,324 @@
/// Класс для валидации данных, полученных от AI сервиса
library;
import 'package:budget_app/models/ai_response.dart';
import 'package:budget_app/models/ai_exceptions.dart';
import 'package:logger/logger.dart';
/// Результат валидации AI данных
class ValidationResult {
final bool isValid;
final List<String> errors;
final List<String> warnings;
const ValidationResult({
required this.isValid,
this.errors = const [],
this.warnings = const [],
});
ValidationResult.valid() : this(isValid: true);
ValidationResult.invalid(List<String> errors, [List<String>? warnings])
: this(isValid: false, errors: errors, warnings: warnings ?? []);
bool get hasErrors => errors.isNotEmpty;
bool get hasWarnings => warnings.isNotEmpty;
@override
String toString() {
if (isValid) return 'ValidationResult.valid()';
return 'ValidationResult.invalid(errors: $errors, warnings: $warnings)';
}
}
/// Валидатор данных AI ответов
class AiDataValidator {
final Logger _logger = Logger();
/// Минимальное значение confidence (по умолчанию 0.0)
final double minConfidence;
/// Максимальное значение confidence (по умолчанию 1.0)
final double maxConfidence;
/// Максимальная длина vendor (по умолчанию 100 символов)
final int maxVendorLength;
/// Максимальная длина exclusionRegex (по умолчанию 500 символов)
final int maxRegexLength;
AiDataValidator({
this.minConfidence = 0.0,
this.maxConfidence = 1.0,
this.maxVendorLength = 100,
this.maxRegexLength = 500,
});
/// Валидирует ответ AI
ValidationResult validateResponse(AiResponse response, {String? smsId}) {
_logger.d('Валидация ответа AI${smsId != null ? ' для SMS ID: $smsId' : ''}');
try {
if (response is AiTransactionResponse) {
return _validateTransactionResponse(response, smsId: smsId);
} else if (response is AiNonTransactionResponse) {
return _validateNonTransactionResponse(response, smsId: smsId);
} else {
final error = 'Неизвестный тип ответа AI: ${response.runtimeType}';
_logger.e(error);
return ValidationResult.invalid([error]);
}
} catch (e, stackTrace) {
final error = 'Критическая ошибка валидации ответа AI: $e';
_logger.e(error);
_logger.d('Stack trace: $stackTrace');
return ValidationResult.invalid([error]);
}
}
/// Валидирует ответ AI и выбрасывает исключение при ошибке
void validateAndThrow(AiResponse response, {String? smsId}) {
final result = validateResponse(response, smsId: smsId);
if (!result.isValid) {
final error = 'Валидация ответа AI не пройдена: ${result.errors.join('; ')}';
_logger.e('$error для SMS ID: $smsId');
final invalidData = response is AiTransactionResponse
? response.toJson()
: (response as AiNonTransactionResponse).toJson();
throw AiDataValidationException(
error,
invalidData: invalidData,
smsId: smsId,
context: 'AiDataValidator',
);
}
// Логируем предупреждения, если есть
if (result.hasWarnings) {
_logger.w('Предупреждения при валидации AI ответа: ${result.warnings.join('; ')}');
}
}
/// Валидирует ответ для транзакций
ValidationResult _validateTransactionResponse(
AiTransactionResponse response,
{String? smsId}
) {
final errors = <String>[];
final warnings = <String>[];
// Проверяем базовую валидность
if (!response.isValid()) {
errors.add('Базовая валидация не пройдена');
}
// Проверяем confidence
final confidenceValidation = _validateConfidence(response.confidence);
if (confidenceValidation.hasErrors) {
errors.addAll(confidenceValidation.errors);
}
warnings.addAll(confidenceValidation.warnings);
// Проверяем amount
final amountValidation = _validateAmount(response.amount);
if (amountValidation.hasErrors) {
errors.addAll(amountValidation.errors);
}
warnings.addAll(amountValidation.warnings);
// Проверяем vendor
final vendorValidation = _validateVendor(response.vendor);
if (vendorValidation.hasErrors) {
errors.addAll(vendorValidation.errors);
}
warnings.addAll(vendorValidation.warnings);
// Проверяем suggestedCategory
if (response.suggestedCategory != null && response.suggestedCategory!.trim().isEmpty) {
warnings.add('Предложенная категория пустая');
}
_logger.d('Валидация транзакционного ответа: errors=${errors.length}, warnings=${warnings.length}');
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует ответ для не-транзакций
ValidationResult _validateNonTransactionResponse(
AiNonTransactionResponse response,
{String? smsId}
) {
final errors = <String>[];
final warnings = <String>[];
// Проверяем базовую валидность
if (!response.isValid()) {
errors.add('Базовая валидация не пройдена');
}
// Проверяем confidence
final confidenceValidation = _validateConfidence(response.confidence);
if (confidenceValidation.hasErrors) {
errors.addAll(confidenceValidation.errors);
}
warnings.addAll(confidenceValidation.warnings);
// Проверяем exclusionRegex
final regexValidation = _validateExclusionRegex(response.exclusionRegex);
if (regexValidation.hasErrors) {
errors.addAll(regexValidation.errors);
}
warnings.addAll(regexValidation.warnings);
_logger.d('Валидация не-транзакционного ответа: errors=${errors.length}, warnings=${warnings.length}');
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует значение confidence
ValidationResult _validateConfidence(double confidence) {
final errors = <String>[];
final warnings = <String>[];
if (confidence < minConfidence || confidence > maxConfidence) {
errors.add('Confidence должен быть от $minConfidence до $maxConfidence, получен: $confidence');
}
if (confidence < 0.5) {
warnings.add('Низкий уровень уверенности AI: $confidence');
}
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует сумму транзакции
ValidationResult _validateAmount(double amount) {
final errors = <String>[];
final warnings = <String>[];
if (amount == 0.0) {
errors.add('Сумма транзакции не может быть равна нулю');
}
if (amount.abs() > 1000000.0) {
warnings.add('Очень большая сумма транзакции: $amount');
}
if (amount.abs() < 1.0) {
warnings.add('Очень маленькая сумма транзакции: $amount');
}
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует название продавца/получателя
ValidationResult _validateVendor(String vendor) {
final errors = <String>[];
final warnings = <String>[];
if (vendor.trim().isEmpty) {
errors.add('Название продавца не может быть пустым');
}
if (vendor.length > maxVendorLength) {
errors.add('Название продавца слишком длинное (>${maxVendorLength} символов): ${vendor.length}');
}
if (vendor.length < 2) {
warnings.add('Очень короткое название продавца: "$vendor"');
}
// Проверяем на подозрительные символы
if (vendor.contains(RegExp(r'[<>{}[\]\\|`~]'))) {
warnings.add('Название продавца содержит подозрительные символы: "$vendor"');
}
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует регулярное выражение для исключения
ValidationResult _validateExclusionRegex(String exclusionRegex) {
final errors = <String>[];
final warnings = <String>[];
if (exclusionRegex.trim().isEmpty) {
errors.add('Регулярное выражение для исключения не может быть пустым');
}
if (exclusionRegex.length > maxRegexLength) {
errors.add('Регулярное выражение слишком длинное (>${maxRegexLength} символов): ${exclusionRegex.length}');
}
// Проверяем синтаксис regex
try {
RegExp(exclusionRegex);
} catch (e) {
errors.add('Некорректный синтаксис регулярного выражения: $e');
}
// Предупреждения для потенциально проблематичных regex
if (exclusionRegex == '.*') {
warnings.add('Regex ".*" исключит все сообщения');
}
if (exclusionRegex.contains('.*.*.*')) {
warnings.add('Избыточные .* в regex могут влиять на производительность');
}
return errors.isEmpty
? ValidationResult(isValid: true, warnings: warnings)
: ValidationResult.invalid(errors, warnings);
}
/// Валидирует массив ответов AI
List<ValidationResult> validateMultipleResponses(
List<AiResponse> responses,
{List<String>? smsIds}
) {
final results = <ValidationResult>[];
for (int i = 0; i < responses.length; i++) {
final response = responses[i];
final smsId = smsIds != null && i < smsIds.length ? smsIds[i] : null;
results.add(validateResponse(response, smsId: smsId));
}
return results;
}
/// Проверяет есть ли критические ошибки в массиве результатов валидации
bool hasAnyCriticalErrors(List<ValidationResult> results) {
return results.any((result) => !result.isValid);
}
/// Собирает все ошибки из массива результатов валидации
List<String> getAllErrors(List<ValidationResult> results) {
return results
.where((result) => result.hasErrors)
.expand((result) => result.errors)
.toList();
}
/// Собирает все предупреждения из массива результатов валидации
List<String> getAllWarnings(List<ValidationResult> results) {
return results
.where((result) => result.hasWarnings)
.expand((result) => result.warnings)
.toList();
}
}
+173
View File
@@ -0,0 +1,173 @@
/// Класс для построения промптов для AI анализа SMS сообщений
library;
import 'package:budget_app/models/category.dart';
/// Билдер промптов для AI анализа SMS
class AiPromptBuilder {
/// Основной шаблон промпта
static const String _basePromptTemplate = '''
=== ЗАДАЧА ===
Проанализируй SMS сообщение и определи:
1. Это финансовая транзакция или нет?
2. Если транзакция - извлеки сумму, продавца и категорию
3. Если не транзакция - создай regex для исключения похожих сообщений
=== SMS ДЛЯ АНАЛИЗА ===
"{smsBody}"
=== ДОСТУПНЫЕ КАТЕГОРИИ ===
{categoriesSection}
=== ФОРМАТ ОТВЕТА ===
Верни ответ ТОЛЬКО в виде JSON без дополнительных комментариев:
Если это финансовая транзакция:
{transactionFormat}
Если это НЕ финансовая транзакция (реклама, уведомления, спам и т.д.):
{nonTransactionFormat}
=== ПРИМЕРЫ ===
{examples}
=== ВАЖНЫЕ ПРАВИЛА ===
{rules}''';
/// Формат ответа для транзакций
static const String _transactionFormat = '''{
"isTransaction": true,
"amount": сумма (число, положительное для доходов, отрицательное для расходов),
"vendor": "название продавца/получателя",
"confidence": процент уверенности от 0 до 1,
"suggestedCategory": "выбери ТОЧНОЕ название из доступных категорий выше",
"exclusionRegex": null
}''';
/// Формат ответа для не-транзакций
static const String _nonTransactionFormat = '''{
"isTransaction": false,
"amount": null,
"vendor": null,
"confidence": процент уверенности от 0 до 1,
"suggestedCategory": null,
"exclusionRegex": "регулярное выражение для исключения похожих SMS в будущем"
}''';
/// Примеры анализа
static const String _examples = '''
SMS: "СБЕРБАНК: Покупка 1250.00р METRO CASH 15.01.2024 12:34"
Ответ:
{
"isTransaction": true,
"amount": -1250.00,
"vendor": "METRO CASH",
"confidence": 0.95,
"suggestedCategory": "Покупки",
"exclusionRegex": null
}
SMS: "Зарплата поступила на счет 50000.00р"
Ответ:
{
"isTransaction": true,
"amount": 50000.00,
"vendor": "Работодатель",
"confidence": 0.99,
"suggestedCategory": "Зарплата",
"exclusionRegex": null
}
SMS: "Получи кредит за 5 минут! Одобрим всех!"
Ответ:
{
"isTransaction": false,
"amount": null,
"vendor": null,
"confidence": 0.90,
"suggestedCategory": null,
"exclusionRegex": ".*кредит.*одобр.*"
}''';
/// Важные правила
static const String _rules = '''• exclusionRegex - только для НЕ-транзакций (реклама, спам, уведомления)
• suggestedCategory - только из списка выше, точное название
• amount - отрицательное для расходов, положительное для доходов
• vendor - короткое название продавца/источника без лишних деталей
• confidence - от 0 до 1 (насколько уверен в анализе)''';
/// Строит промпт для анализа SMS с помощью AI
static String buildSmsAnalysisPrompt(
String smsBody,
List<Category> categories,
) {
validatePromptParameters(smsBody: smsBody, categories: categories);
final categoriesSection = _buildCategoriesSection(categories);
return _basePromptTemplate
.replaceAll('{smsBody}', smsBody)
.replaceAll('{categoriesSection}', categoriesSection)
.replaceAll('{transactionFormat}', _transactionFormat)
.replaceAll('{nonTransactionFormat}', _nonTransactionFormat)
.replaceAll('{examples}', _examples)
.replaceAll('{rules}', _rules);
}
/// Создает секцию с доступными категориями
static String _buildCategoriesSection(List<Category> categories) {
final expenseCategories = categories
.where((cat) => !cat.isIncome)
.map((cat) => '"${cat.name}"')
.join(', ');
final incomeCategories = categories
.where((cat) => cat.isIncome)
.map((cat) => '"${cat.name}"')
.join(', ');
return '''Для расходов: $expenseCategories
Для доходов: $incomeCategories''';
}
/// Строит простой промпт для кастомного анализа (для тестирования)
static String buildCustomPrompt(String instruction, String content) {
if (instruction.trim().isEmpty || content.trim().isEmpty) {
throw ArgumentError('Инструкция и контент не могут быть пустыми');
}
return '''=== ЗАДАЧА ===
$instruction
=== КОНТЕНТ ДЛЯ АНАЛИЗА ===
"$content"
=== ФОРМАТ ОТВЕТА ===
Верни ответ в формате JSON.''';
}
/// Валидирует параметры для построения промпта
static void validatePromptParameters({
required String smsBody,
required List<Category> categories,
}) {
if (smsBody.trim().isEmpty) {
throw ArgumentError('SMS сообщение не может быть пустым');
}
if (categories.isEmpty) {
throw ArgumentError('Список категорий не может быть пустым');
}
// Проверяем что есть и расходные и доходные категории
final hasExpenseCategories = categories.any((cat) => !cat.isIncome);
final hasIncomeCategories = categories.any((cat) => cat.isIncome);
if (!hasExpenseCategories && !hasIncomeCategories) {
throw ArgumentError('Должна быть хотя бы одна категория доходов или расходов');
}
}
}
+214
View File
@@ -0,0 +1,214 @@
/// Класс для парсинга ответов AI сервиса
library;
import 'dart:convert';
import 'package:budget_app/models/ai_response.dart';
import 'package:budget_app/models/ai_exceptions.dart';
import 'package:logger/logger.dart';
/// Парсер ответов AI в типизированные модели
class AiResponseParser {
final Logger _logger = Logger();
/// Парсит ответ AI в типизированную модель
AiResponse parseResponse(String aiResponse, {String? smsId}) {
if (aiResponse.trim().isEmpty) {
final error = 'Пустой ответ от AI';
_logger.e('$error для SMS ID: $smsId');
throw AiProcessingException(error, smsId: smsId, context: 'AiResponseParser');
}
_logger.d('Начинаю парсинг ответа AI${smsId != null ? ' для SMS ID: $smsId' : ''}');
try {
final jsonData = _extractJsonFromResponse(aiResponse, smsId: smsId);
_logger.d('Извлеченные JSON данные: $jsonData');
return AiResponseFactory.fromJson(jsonData);
} on AiException {
// Перебрасываем AI исключения как есть
rethrow;
} catch (e, stackTrace) {
final error = 'Критическая ошибка парсинга ответа AI';
_logger.e('$error: $e');
_logger.d('Stack trace: $stackTrace');
final exception = e is Exception ? e : Exception(e.toString());
throw AiResponseParsingException(
error,
rawResponse: aiResponse,
smsId: smsId,
context: 'AiResponseParser',
cause: exception,
);
}
}
/// Извлекает JSON из ответа AI
Map<String, dynamic> _extractJsonFromResponse(String aiResponse, {String? smsId}) {
final cleanResponse = aiResponse.trim();
// Попробуем сначала распарсить весь ответ как JSON
try {
final decoded = jsonDecode(cleanResponse);
if (decoded is Map<String, dynamic>) {
return decoded;
} else {
throw const FormatException('Ответ не является JSON объектом');
}
} catch (e) {
_logger.w('Попытка парсинга всего ответа как JSON не удалась, '
'ищем JSON внутри текста: $e');
}
// Ищем JSON блок внутри текста
return _extractJsonBlock(cleanResponse, smsId: smsId);
}
/// Извлекает JSON блок из текста с правильным подсчетом скобок
Map<String, dynamic> _extractJsonBlock(String text, {String? smsId}) {
final jsonStart = text.indexOf('{');
if (jsonStart == -1) {
final error = 'JSON не найден в ответе AI (нет открывающей скобки)';
_logger.e('$error: $text');
throw AiResponseParsingException(
error,
rawResponse: text,
smsId: smsId,
context: 'AiResponseParser',
);
}
int braceCount = 0;
int jsonEnd = -1;
for (int i = jsonStart; i < text.length; i++) {
if (text[i] == '{') {
braceCount++;
} else if (text[i] == '}') {
braceCount--;
if (braceCount == 0) {
jsonEnd = i;
break;
}
}
}
if (jsonEnd == -1) {
final error = 'JSON не найден в ответе AI (несбалансированные скобки)';
_logger.e('$error: $text');
throw AiResponseParsingException(
error,
rawResponse: text,
smsId: smsId,
context: 'AiResponseParser',
);
}
final jsonString = text.substring(jsonStart, jsonEnd + 1);
_logger.d('Извлеченный JSON: $jsonString');
try {
final decoded = jsonDecode(jsonString);
if (decoded is Map<String, dynamic>) {
return decoded;
} else {
throw const FormatException('Извлеченный JSON не является объектом');
}
} catch (e) {
final error = 'Ошибка парсинга извлеченного JSON';
_logger.e('$error: $e, JSON: $jsonString');
throw AiResponseParsingException(
error,
rawResponse: jsonString,
smsId: smsId,
context: 'AiResponseParser',
cause: e is Exception ? e : Exception(e.toString()),
);
}
}
/// Парсит несколько ответов AI (batch processing)
List<AiResponse> parseMultipleResponses(
List<String> aiResponses,
{List<String>? smsIds}
) {
if (aiResponses.isEmpty) {
return [];
}
final results = <AiResponse>[];
final errors = <String>[];
for (int i = 0; i < aiResponses.length; i++) {
final response = aiResponses[i];
final smsId = smsIds != null && i < smsIds.length ? smsIds[i] : null;
try {
final parsed = parseResponse(response, smsId: smsId);
results.add(parsed);
} catch (e) {
final errorMsg = 'Ошибка парсинга ответа ${i + 1}${smsId != null ? ' (SMS ID: $smsId)' : ''}: $e';
errors.add(errorMsg);
_logger.e(errorMsg);
}
}
if (errors.isNotEmpty && results.isEmpty) {
// Если все ответы с ошибками, выбрасываем исключение
final error = 'Все ответы AI содержат ошибки: ${errors.join('; ')}';
throw AiProcessingException(error, context: 'AiResponseParser');
}
return results;
}
/// Проверяет является ли строка валидным JSON
bool isValidJson(String text) {
try {
jsonDecode(text.trim());
return true;
} catch (e) {
return false;
}
}
/// Извлекает только JSON часть из ответа (для предварительной проверки)
String? extractJsonString(String aiResponse) {
try {
final cleanResponse = aiResponse.trim();
// Если весь ответ - это JSON, возвращаем как есть
if (isValidJson(cleanResponse)) {
return cleanResponse;
}
// Ищем JSON блок
final jsonStart = cleanResponse.indexOf('{');
if (jsonStart == -1) return null;
int braceCount = 0;
int jsonEnd = -1;
for (int i = jsonStart; i < cleanResponse.length; i++) {
if (cleanResponse[i] == '{') {
braceCount++;
} else if (cleanResponse[i] == '}') {
braceCount--;
if (braceCount == 0) {
jsonEnd = i;
break;
}
}
}
if (jsonEnd == -1) return null;
final jsonString = cleanResponse.substring(jsonStart, jsonEnd + 1);
return isValidJson(jsonString) ? jsonString : null;
} catch (e) {
_logger.w('Ошибка извлечения JSON строки: $e');
return null;
}
}
}
@@ -0,0 +1,400 @@
import 'package:budget_app/data/repositories/interfaces/iai_rule_repository.dart';
import 'package:budget_app/data/repositories/interfaces/icategory_repository.dart';
import 'package:budget_app/data/repositories/interfaces/iprefilled_transaction_repository.dart';
import 'package:budget_app/models/ai_exceptions.dart';
import 'package:budget_app/models/ai_response.dart';
import 'package:budget_app/models/ai_rule.dart';
import 'package:budget_app/models/category.dart';
import 'package:budget_app/models/prefilled_transaction.dart';
import 'package:budget_app/models/transaction_record.dart';
import 'package:budget_app/services/ai_data_validator.dart';
import 'package:budget_app/services/ai_prompt_builder.dart';
import 'package:budget_app/services/ai_response_parser.dart';
import 'package:budget_app/services/interfaces/iai_service.dart';
import 'package:logger/logger.dart';
/// Сервис для обработки SMS с помощью ИИ и создания транзакций
class AiTransactionProcessingService {
final IAiService _aiService;
final IPrefilledTransactionRepository _prefilledRepository;
final ICategoryRepository _categoryRepository;
final IAiRuleRepository _aiRuleRepository;
final AiResponseParser _responseParser;
final AiDataValidator _dataValidator;
final Logger _logger = Logger();
AiTransactionProcessingService({
required IAiService aiService,
required IPrefilledTransactionRepository prefilledRepository,
required ICategoryRepository categoryRepository,
required IAiRuleRepository aiRuleRepository,
AiResponseParser? responseParser,
AiDataValidator? dataValidator,
}) : _aiService = aiService,
_prefilledRepository = prefilledRepository,
_categoryRepository = categoryRepository,
_aiRuleRepository = aiRuleRepository,
_responseParser = responseParser ?? AiResponseParser(),
_dataValidator = dataValidator ?? AiDataValidator();
/// Создает промпт для ИИ анализа SMS сообщения
Future<String> _buildAiPrompt(String smsBody) async {
final categories = await _categoryRepository.getAll();
return AiPromptBuilder.buildSmsAnalysisPrompt(smsBody, categories);
}
/// Анализирует SMS с помощью ИИ и создает PrefilledTransaction
Future<PrefilledTransaction> processSmsByAi(
String smsBody,
String smsMessageId,
) async {
// Валидация входных данных
if (smsBody.trim().isEmpty) {
final error = 'Пустое SMS сообщение';
_logger.e('$error для ID: $smsMessageId');
throw AiProcessingException(
error,
smsId: smsMessageId,
context: 'AiProcessing',
);
}
if (smsMessageId.trim().isEmpty) {
final error = 'Пустой ID SMS сообщения';
_logger.e(
'$error для: ${smsBody.substring(0, 50).replaceAll('\n', ' ')}...',
);
throw AiProcessingException(error, context: 'AiProcessing');
}
if (!_aiService.isConfigured()) {
final error = 'ИИ сервис не настроен';
_logger.e('$error для SMS ID: $smsMessageId');
throw AiConfigurationException(error, context: 'AiProcessing');
}
try {
_logger.d(
'Начинаю обработку SMS ID: $smsMessageId, длина: ${smsBody.length} символов',
);
final prompt = await _buildAiPrompt(smsBody);
final rawAiResponse = await _aiService.sendMessage(prompt);
if (rawAiResponse.trim().isEmpty) {
final error = 'Пустой ответ от ИИ';
_logger.e('$error для SMS ID: $smsMessageId');
throw AiProcessingException(
error,
smsId: smsMessageId,
context: 'AiProcessing',
);
}
_logger.d(
'Получен ответ от ИИ для SMS ID: $smsMessageId, длина ответа: ${rawAiResponse.length}',
);
// Парсим ответ AI в типизированную модель
final parsedResponse = _responseParser.parseResponse(
rawAiResponse,
smsId: smsMessageId,
);
_logger.d(
'Успешно распарсен ответ ИИ для SMS ID: $smsMessageId: $parsedResponse',
);
// Валидируем данные
_dataValidator.validateAndThrow(parsedResponse, smsId: smsMessageId);
if (parsedResponse is AiTransactionResponse) {
_logger.i(
'ИИ определил транзакционное SMS ID: $smsMessageId как ${parsedResponse.vendor} на сумму ${parsedResponse.amount}',
);
} else if (parsedResponse is AiNonTransactionResponse) {
_logger.i(
'ИИ определил не-транзакционное SMS ID: $smsMessageId с exclusion regex: ${parsedResponse.exclusionRegex}',
);
}
final result = await _createPrefilledFromAiResponse(
parsedResponse,
smsMessageId,
);
_logger.i(
'Успешно создан PrefilledTransaction для SMS ID: $smsMessageId',
);
return result;
} on AiException {
// Перебрасываем AI исключения как есть
rethrow;
} catch (e, stackTrace) {
final error = 'Неожиданная ошибка при обработке SMS';
_logger.e('$error ID $smsMessageId: $e');
_logger.d('Stack trace: $stackTrace');
final exception = e is Exception ? e : Exception(e.toString());
throw AiProcessingException(
error,
smsId: smsMessageId,
context: 'AiProcessing',
cause: exception,
);
}
}
/// Создает PrefilledTransaction из ответа ИИ
Future<PrefilledTransaction> _createPrefilledFromAiResponse(
AiResponse aiResponse,
String smsMessageId,
) async {
Category? suggestedCategory;
double? amount;
String? salesPoint;
String? exclusionRegex;
if (aiResponse is AiTransactionResponse) {
amount = aiResponse.amount;
salesPoint = aiResponse.vendor;
exclusionRegex = null;
// Ищем предложенную категорию
if (aiResponse.suggestedCategory != null) {
suggestedCategory = await _findCategoryByName(
aiResponse.suggestedCategory!,
);
}
} else if (aiResponse is AiNonTransactionResponse) {
amount = null;
salesPoint = null;
exclusionRegex = aiResponse.exclusionRegex;
}
final prefilled = PrefilledTransaction(
smsMessageId: smsMessageId,
transactionId: null, // Будет создан позже
amount: amount,
salesPoint: salesPoint,
calculatedCategory: suggestedCategory,
confidence: aiResponse.confidence, // Сохраняем как есть (0.0-1.0)
exclusionRegex: exclusionRegex,
isTransaction: aiResponse.isTransaction,
);
// Сохраняем в репозиторий
await _prefilledRepository.add(prefilled);
return prefilled;
}
/// Находит категорию по имени
Future<Category?> _findCategoryByName(String categoryName) async {
final categories = await _categoryRepository.getAll();
final trimmedName = categoryName.trim();
// Ищем категорию по точному совпадению имени (регистронезависимо)
final exactMatch = categories
.where(
(cat) => cat.name.toLowerCase().trim() == trimmedName.toLowerCase(),
)
.toList();
if (exactMatch.isNotEmpty) {
_logger.d(
'Найдена точная категория для "$trimmedName": ${exactMatch.first.name}',
);
return exactMatch.first;
}
// Если точного совпадения нет, ищем частичное для обратной совместимости
final partialMatch = categories
.where(
(cat) =>
cat.name.toLowerCase().trim().contains(
trimmedName.toLowerCase(),
) ||
trimmedName.toLowerCase().contains(cat.name.toLowerCase().trim()),
)
.toList();
if (partialMatch.isNotEmpty) {
_logger.d(
'Найдена частичная категория для "$trimmedName": ${partialMatch.first.name}',
);
return partialMatch.first;
}
_logger.w('Категория не найдена для предложения: "$trimmedName"');
return null;
}
/// Преобразует PrefilledTransaction в TransactionRecord
Future<TransactionRecord> convertToTransactionRecord(
PrefilledTransaction prefilled, {
String? categoryId,
String? tagId,
}) async {
if (prefilled.amount == null || prefilled.salesPoint == null) {
throw ArgumentError(
'PrefilledTransaction должен содержать amount и salesPoint',
);
}
final record = TransactionRecord(
amount: prefilled.amount!,
dateTime:
DateTime.now(), // В реальной реализации можно попытаться извлечь дату из SMS
vendor: prefilled.salesPoint!,
currency: 'RUB', // По умолчанию, можно сделать настраиваемым
categoryId: categoryId ?? prefilled.calculatedCategory?.id ?? 'unknown',
tagId: tagId,
);
// Обновляем prefilled с ID созданной транзакции
prefilled.transactionId = record.id;
await _prefilledRepository.update(prefilled);
return record;
}
/// Определяет тип правила на основе PrefilledTransaction
AiRuleType _determineRuleType(PrefilledTransaction prefilled) {
return prefilled.isTransaction
? AiRuleType.pointOfSale
: AiRuleType.skipTemplate;
}
/// Создает AiRule из PrefilledTransaction для автоматизации в будущем
Future<AiRule?> createAiRuleFromPrefilled(
PrefilledTransaction prefilled, {
String? customName,
}) async {
final ruleType = _determineRuleType(prefilled);
AiRule rule;
switch (ruleType) {
case AiRuleType.pointOfSale:
// Для правил точки продаж нужны salesPoint и calculatedCategory
if (prefilled.salesPoint == null ||
prefilled.calculatedCategory == null) {
_logger.w(
'Недостаточно данных для создания правила точки продаж: salesPoint или calculatedCategory отсутствуют',
);
return null;
}
final merchantPattern = _createMerchantPattern(prefilled.salesPoint!);
// Проверяем конфликты с существующими правилами
await _checkMerchantPatternConflicts(
merchantPattern,
prefilled.calculatedCategory!.id,
prefilled.salesPoint!,
);
rule = AiRule(
name: customName ?? 'Автоправило для ${prefilled.salesPoint}',
type: AiRuleType.pointOfSale,
merchantPattern: merchantPattern,
categoryId: prefilled.calculatedCategory!.id,
confidencePercentage: prefilled.confidence.round(),
);
break;
case AiRuleType.skipTemplate:
// Для skip правил нужен только exclusionRegex
if (prefilled.exclusionRegex == null ||
prefilled.exclusionRegex!.trim().isEmpty) {
_logger.w(
'Недостаточно данных для создания skip правила: exclusionRegex отсутствует',
);
return null;
}
// Определяем имя для skip правила
final ruleName =
customName ??
(prefilled.salesPoint != null
? 'Пропуск для ${prefilled.salesPoint}'
: 'Автопропуск не-транзакционных SMS');
rule = AiRule(
name: ruleName,
type: AiRuleType.skipTemplate,
skipRegex: prefilled.exclusionRegex,
confidencePercentage: prefilled.confidence.round(),
);
break;
}
await _aiRuleRepository.add(rule);
return rule;
}
/// Проверяет конфликты merchantPattern с существующими правилами
Future<void> _checkMerchantPatternConflicts(
String merchantPattern,
String categoryId,
String salesPoint,
) async {
final existingRules = await _aiRuleRepository.getByType(AiRuleType.pointOfSale);
// Проверяем все активные правила
final activeRules = existingRules.where((rule) =>
rule.isActive &&
rule.merchantPattern != null
).toList();
for (final rule in activeRules) {
try {
final existingRegex = RegExp(rule.merchantPattern!);
// Проверяем, соответствует ли наш salesPoint существующему паттерну
if (existingRegex.hasMatch(salesPoint)) {
if (rule.categoryId == categoryId) {
// Это идентичное правило (тот же мерчант + та же категория) - не конфликт
_logger.d('Найдено идентичное правило для "${salesPoint}" с категорией $categoryId: ${rule.name}');
continue;
} else {
// Конфликт: тот же мерчант, но другая категория
final error = 'Конфликт merchantPattern: "${salesPoint}" соответствует существующему правилу "${rule.name}" (категория: ${rule.categoryId})';
_logger.e('$error для нового правила с категорией: $categoryId');
throw AiProcessingException(
error,
context: 'MerchantPatternConflict',
);
}
}
// Обратная проверка: проверяем, может ли новый паттерн перехватить существующие salesPoint
final newRegex = RegExp(merchantPattern);
if (rule.merchantPattern != null && newRegex.hasMatch(rule.merchantPattern!)) {
if (rule.categoryId == categoryId) {
// Это идентичное правило - не конфликт
_logger.d('Новый паттерн идентичен существующему правилу: ${rule.name}');
continue;
} else {
// Конфликт: новый паттерн пересекается с правилом другой категории
final error = 'Конфликт merchantPattern: новый паттерн "${merchantPattern}" пересекается с существующим правилом "${rule.name}" (категория: ${rule.categoryId})';
_logger.e('$error для нового правила с категорией: $categoryId');
throw AiProcessingException(
error,
context: 'MerchantPatternConflict',
);
}
}
} catch (e) {
if (e is AiProcessingException) {
rethrow;
}
// Если проблема с регулярным выражением, логируем но не блокируем
_logger.w('Ошибка при проверке паттерна в правиле ${rule.id}: $e');
}
}
}
/// Создает паттерн для поиска продавца в SMS
String _createMerchantPattern(String salesPoint) {
// Простой паттерн - ищем точное совпадение или частичное
final escaped = RegExp.escape(salesPoint);
return '$escaped';
}
}
+94
View File
@@ -0,0 +1,94 @@
import 'package:budget_app/models/transaction_record.dart';
import 'package:budget_app/services/ai_transaction_processing_service.dart';
import 'package:get_it/get_it.dart';
import 'package:logger/logger.dart';
/// Псевдоним для типа функции обработки СМС
typedef SmsProcessingFunction =
Future<TransactionRecord?> Function(String smsBody);
/// Класс для управления кастомными функциями обработки СМС
class CustomSmsFunctions {
// Приватный конструктор, чтобы нельзя было создать экземпляр класса
CustomSmsFunctions._();
/// Карта доступных кастомных функций.
/// Ключ - это уникальный идентификатор функции, который будет храниться в настройках.
/// Значение - это сама функция.
static final Map<String, SmsProcessingFunction> _availableFunctions = {
'ai_request': _aiRequestHandler,
};
/// Карта с названиями функций для отображения в UI.
/// Ключ - идентификатор функции, значение - человекочитаемое имя.
static final Map<String, String> functionNames = {
'ai_request': 'Запрос к ИИ',
};
/// Возвращает список идентификаторов доступных функций.
static List<String> get availableFunctionIds =>
_availableFunctions.keys.toList();
/// Возвращает функцию по ее идентификатору.
static SmsProcessingFunction? getFunctionById(String id) {
return _availableFunctions[id];
}
// --- Реализации кастомных функций ---
/// Обработчик для функции "Запрос к ИИ".
/// Анализирует текст СМС с помощью ИИ и создает транзакцию.
static Future<TransactionRecord?> _aiRequestHandler(String smsBody) async {
final logger = Logger();
try {
logger.i('--- AI Request Handler ---');
logger.i('SMS Body: $smsBody');
// Получаем сервис обработки ИИ транзакций
final aiProcessingService =
GetIt.instance<AiTransactionProcessingService>();
// Создаем уникальный ID для SMS (в реальности будет передаваться извне)
final smsMessageId = DateTime.now().millisecondsSinceEpoch.toString();
// Обрабатываем SMS с помощью ИИ
final prefilledTransaction = await aiProcessingService.processSmsByAi(
smsBody,
smsMessageId,
);
logger.i(
'ИИ создал PrefilledTransaction: ${prefilledTransaction.salesPoint}, ${prefilledTransaction.amount}',
);
await aiProcessingService.createAiRuleFromPrefilled(prefilledTransaction);
// Проверяем, является ли это транзакционным SMS
if (prefilledTransaction.amount != null &&
prefilledTransaction.salesPoint != null) {
// Это транзакционный SMS - конвертируем в TransactionRecord
final transactionRecord = await aiProcessingService
.convertToTransactionRecord(prefilledTransaction);
logger.i(
'Создана транзакция: ${transactionRecord.vendor}, ${transactionRecord.amount}',
);
logger.i('--- End AI Request Handler ---');
return transactionRecord;
} else {
// Это не-транзакционный SMS (реклама, безопасность, спам и т.д.)
// PrefilledTransaction уже сохранен с exclusionRegex для будущего исключения
logger.i(
'Не-транзакционный SMS обработан и сохранен с exclusion regex: ${prefilledTransaction.exclusionRegex}',
);
logger.i('--- End AI Request Handler ---');
return null; // Не создаем TransactionRecord для не-транзакционных SMS
}
} catch (e) {
logger.e('Ошибка в AI Request Handler: $e');
return null;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
abstract class IAiService {
/// Отправить текстовый запрос к ИИ и получить ответ
Future<String> sendMessage(String message);
/// Отправить запрос с настраиваемыми параметрами
Future<String> sendMessageWithParams({
required String message,
String? model,
double? temperature,
int? maxTokens,
});
/// Проверить доступность сервиса
Future<bool> checkHealth();
/// Получить список доступных моделей
Future<List<String>> getAvailableModels();
/// Настроить API ключ
void setApiKey(String apiKey);
/// Проверить настройки соединения
bool isConfigured();
}
@@ -0,0 +1,13 @@
import '/models/user.dart';
/// Интерфейс для работы с текущим пользователем
abstract class IUserService {
/// Получить текущего аутентифицированного пользователя
Future<User?> getCurrentUser();
/// Проверить, аутентифицирован ли пользователь
Future<bool> isAuthenticated();
/// Получить ID текущего пользователя
Future<String?> getCurrentUserId();
}

Some files were not shown because too many files have changed in this diff Show More