Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e53d0006 | ||
|
|
4944fcbcec | ||
|
|
1da6090572 | ||
|
|
8495d3b1cf | ||
|
|
f1739b81de | ||
|
|
78b80904f5 | ||
|
|
ea9c31b782 | ||
|
|
dd468c3246 | ||
|
|
b3dfa8abf8 | ||
|
|
51f119e8f6 | ||
|
|
2df0d4a886 | ||
|
|
4bd509b561 | ||
|
|
a7a9c9504e | ||
|
|
1d33d3876f | ||
|
|
7e32711383 | ||
|
|
ce56101b2b | ||
|
|
91466000ee | ||
|
|
6e26a79e24 | ||
|
|
83f653fcf4 | ||
|
|
1c88330a9f | ||
|
|
812fc7f322 | ||
|
|
f0933739b9 | ||
|
|
b8278828a6 | ||
|
|
77701f6b35 | ||
|
|
0233607004 | ||
|
|
46981bb958 | ||
|
|
d2854521f8 | ||
|
|
a9e1c02967 | ||
|
|
9699433b3f |
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
|
||||
|
||||
@GenerateAdapters([
|
||||
AdapterSpec<IconData>(),
|
||||
])
|
||||
part 'hive_adapters.g.dart';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,18 +2,4 @@
|
||||
# Manual modifications may be necessary for certain migrations
|
||||
# Check in to version control
|
||||
nextTypeId: 3
|
||||
types:
|
||||
IconData:
|
||||
typeId: 1
|
||||
nextIndex: 5
|
||||
fields:
|
||||
codePoint:
|
||||
index: 0
|
||||
fontFamily:
|
||||
index: 1
|
||||
fontPackage:
|
||||
index: 2
|
||||
matchTextDirection:
|
||||
index: 3
|
||||
fontFamilyFallback:
|
||||
index: 4
|
||||
types: {}
|
||||
|
||||
@@ -3,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());
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": "Пользователь не аутентифицирован. Пожалуйста, войдите снова."
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
@@ -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)
|
||||
// );
|
||||
// }
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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}');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Модель для хранения всех настроек обработки СМС для одного пользователя.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ? 'Активные' : 'Неактивные';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
// Удаление: Индикатор перенесен из страницы.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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('Должна быть хотя бы одна категория доходов или расходов');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user