This commit is contained in:
2025-07-20 23:37:34 +03:00
parent b3dfa8abf8
commit dd468c3246
14 changed files with 669 additions and 2 deletions
-1
View File
@@ -105,7 +105,6 @@ The app can automatically parse SMS messages from banks to create transactions:
### Performance Considerations
- Create small, focused widgets that can be efficiently rebuilt
- Use `const` constructors where possible
- Implement proper `shouldRebuild` logic in BlocBuilder/BlocListener
- Avoid heavy operations in build methods
+4
View File
@@ -2,6 +2,7 @@ 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';
@@ -43,6 +44,7 @@ class HiveService {
static late Box<SmsMessage> smsMessages;
static late Box<AiRule> aiRules;
static late Box<PrefilledTransaction> prefilledTransactions;
static late Box<AiSettings> aiSettings;
/// Инициализация Hive Box для конкретного пользователя.
/// [userId] - Уникальный идентификатор пользователя.
@@ -61,6 +63,7 @@ class HiveService {
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.
@@ -74,5 +77,6 @@ class HiveService {
await smsMessages.close();
await aiRules.close();
await prefilledTransactions.close();
await aiSettings.close();
}
}
@@ -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,18 @@
import '/models/ai_settings.dart';
abstract class IAiSettingsRepository {
/// Получить настройки ИИ
AiSettings? getSettings();
/// Сохранить настройки ИИ
Future<void> saveSettings(AiSettings settings);
/// Удалить настройки ИИ
Future<void> deleteSettings();
/// Проверить существование настроек
bool hasSettings();
/// Получить настройки по умолчанию
AiSettings getDefaultSettings();
}
+3
View File
@@ -5,6 +5,7 @@
import 'package:hive_ce/hive.dart';
import 'package:budget_app/hive/hive_adapters.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';
@@ -19,6 +20,7 @@ extension HiveRegistrar on HiveInterface {
void registerAdapters() {
registerAdapter(AiRuleAdapter());
registerAdapter(AiRuleTypeAdapter());
registerAdapter(AiSettingsAdapter());
registerAdapter(AppSettingsAdapter());
registerAdapter(CategoryAdapter());
registerAdapter(GlobalSettingsAdapter());
@@ -40,6 +42,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
void registerAdapters() {
registerAdapter(AiRuleAdapter());
registerAdapter(AiRuleTypeAdapter());
registerAdapter(AiSettingsAdapter());
registerAdapter(AppSettingsAdapter());
registerAdapter(CategoryAdapter());
registerAdapter(GlobalSettingsAdapter());
+38
View File
@@ -5,6 +5,7 @@ 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';
@@ -14,6 +15,7 @@ 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';
@@ -22,6 +24,7 @@ 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';
@@ -30,9 +33,11 @@ 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/interfaces/iai_service.dart';
import 'services/interfaces/iuser_service.dart';
final getIt = GetIt.instance;
@@ -148,12 +153,30 @@ Future<void> initUserSpecificDependencies(String userId) async {
() => 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>(() {
final aiSettings = getIt<IAiSettingsRepository>().getSettings() ??
getIt<IAiSettingsRepository>().getDefaultSettings();
return OpenRouterAiService(aiSettings);
});
// --- Обновление UserCubit новыми репозиториями ---
// Мы не пересоздаем UserCubit, а просто обновляем его зависимости.
final userCubit = getIt<UserCubit>();
@@ -231,6 +254,17 @@ Future<void> initUserSpecificDependencies(String userId) async {
getIt<IPrefilledTransactionRepository>(),
),
);
// AI Cubit
if (getIt.isRegistered<AiCubit>()) {
await getIt.unregister<AiCubit>();
}
getIt.registerFactory<AiCubit>(
() => AiCubit(
settingsRepository: getIt<IAiSettingsRepository>(),
aiService: getIt<IAiService>(),
),
);
}
/// Сброс пользовательских зависимостей при выходе из системы.
@@ -252,4 +286,8 @@ Future<void> resetUserSpecificDependencies() async {
await getIt.unregister<CategoryCubit>();
await getIt.unregister<TagCubit>();
await getIt.unregister<IAiRuleRepository>();
await getIt.unregister<IAiSettingsRepository>();
if (getIt.isRegistered<IAiService>()) {
await getIt.unregister<IAiService>();
}
}
+185
View File
@@ -0,0 +1,185 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:logger/logger.dart';
import '/models/ai_settings.dart';
import '/data/repositories/interfaces/iai_settings_repository.dart';
import '/services/interfaces/iai_service.dart';
part 'ai_state.dart';
class AiCubit extends Cubit<AiState> {
final IAiSettingsRepository _settingsRepository;
final IAiService _aiService;
final Logger _logger = Logger();
AiCubit({
required IAiSettingsRepository settingsRepository,
required IAiService aiService,
}) : _settingsRepository = settingsRepository,
_aiService = aiService,
super(AiInitial()) {
_loadSettings();
}
void _loadSettings() {
try {
final settings = _settingsRepository.getSettings();
if (settings != null) {
_aiService.setApiKey(settings.apiKey ?? '');
emit(AiLoaded(settings: settings));
} else {
final defaultSettings = _settingsRepository.getDefaultSettings();
emit(AiLoaded(settings: defaultSettings));
}
} catch (e) {
_logger.e('Ошибка загрузки настроек ИИ: $e');
emit(AiError(message: 'Ошибка загрузки настроек ИИ: $e'));
}
}
Future<void> updateSettings(AiSettings settings) async {
try {
emit(AiLoading());
await _settingsRepository.saveSettings(settings);
_aiService.setApiKey(settings.apiKey ?? '');
emit(AiLoaded(settings: settings));
} catch (e) {
_logger.e('Ошибка сохранения настроек ИИ: $e');
emit(AiError(message: 'Ошибка сохранения настроек: $e'));
}
}
Future<void> sendMessage(String message) async {
final currentState = state;
if (currentState is! AiLoaded) {
emit(AiError(message: 'Настройки ИИ не загружены'));
return;
}
if (!currentState.settings.isEnabled) {
emit(AiError(message: 'ИИ сервис отключен в настройках'));
return;
}
if (!_aiService.isConfigured()) {
emit(AiError(message: 'API ключ не настроен'));
return;
}
try {
emit(AiLoading());
final response = await _aiService.sendMessage(message);
emit(AiLoaded(
settings: currentState.settings,
lastResponse: response,
lastMessage: message,
));
} catch (e) {
_logger.e('Ошибка отправки сообщения ИИ: $e');
emit(AiError(message: e.toString()));
emit(AiLoaded(settings: currentState.settings));
}
}
Future<void> sendMessageWithParams({
required String message,
String? model,
double? temperature,
int? maxTokens,
}) async {
final currentState = state;
if (currentState is! AiLoaded) {
emit(AiError(message: 'Настройки ИИ не загружены'));
return;
}
if (!currentState.settings.isEnabled) {
emit(AiError(message: 'ИИ сервис отключен в настройках'));
return;
}
if (!_aiService.isConfigured()) {
emit(AiError(message: 'API ключ не настроен'));
return;
}
try {
emit(AiLoading());
final response = await _aiService.sendMessageWithParams(
message: message,
model: model,
temperature: temperature,
maxTokens: maxTokens,
);
emit(AiLoaded(
settings: currentState.settings,
lastResponse: response,
lastMessage: message,
));
} catch (e) {
_logger.e('Ошибка отправки сообщения ИИ с параметрами: $e');
emit(AiError(message: e.toString()));
emit(AiLoaded(settings: currentState.settings));
}
}
Future<void> checkHealth() async {
final currentState = state;
if (currentState is! AiLoaded) {
emit(AiError(message: 'Настройки ИИ не загружены'));
return;
}
try {
emit(AiLoading());
final isHealthy = await _aiService.checkHealth();
emit(AiLoaded(
settings: currentState.settings,
isHealthy: isHealthy,
));
} catch (e) {
_logger.e('Ошибка проверки здоровья ИИ сервиса: $e');
emit(AiError(message: 'Ошибка проверки соединения: $e'));
emit(AiLoaded(settings: currentState.settings));
}
}
Future<void> loadAvailableModels() async {
final currentState = state;
if (currentState is! AiLoaded) {
emit(AiError(message: 'Настройки ИИ не загружены'));
return;
}
if (!_aiService.isConfigured()) {
emit(AiError(message: 'API ключ не настроен'));
return;
}
try {
emit(AiLoading());
final models = await _aiService.getAvailableModels();
emit(AiLoaded(
settings: currentState.settings,
availableModels: models,
));
} catch (e) {
_logger.e('Ошибка загрузки доступных моделей: $e');
emit(AiError(message: 'Ошибка загрузки моделей: $e'));
emit(AiLoaded(settings: currentState.settings));
}
}
Future<void> resetSettings() async {
try {
emit(AiLoading());
await _settingsRepository.deleteSettings();
final defaultSettings = _settingsRepository.getDefaultSettings();
_aiService.setApiKey('');
emit(AiLoaded(settings: defaultSettings));
} catch (e) {
_logger.e('Ошибка сброса настроек ИИ: $e');
emit(AiError(message: 'Ошибка сброса настроек: $e'));
}
}
}
+62
View File
@@ -0,0 +1,62 @@
part of 'ai_cubit.dart';
abstract class AiState extends Equatable {
const AiState();
@override
List<Object?> get props => [];
}
class AiInitial extends AiState {}
class AiLoading extends AiState {}
class AiLoaded extends AiState {
final AiSettings settings;
final String? lastResponse;
final String? lastMessage;
final bool? isHealthy;
final List<String>? availableModels;
const AiLoaded({
required this.settings,
this.lastResponse,
this.lastMessage,
this.isHealthy,
this.availableModels,
});
AiLoaded copyWith({
AiSettings? settings,
String? lastResponse,
String? lastMessage,
bool? isHealthy,
List<String>? availableModels,
}) {
return AiLoaded(
settings: settings ?? this.settings,
lastResponse: lastResponse ?? this.lastResponse,
lastMessage: lastMessage ?? this.lastMessage,
isHealthy: isHealthy ?? this.isHealthy,
availableModels: availableModels ?? this.availableModels,
);
}
@override
List<Object?> get props => [
settings,
lastResponse,
lastMessage,
isHealthy,
availableModels,
];
}
class AiError extends AiState {
final String message;
const AiError({required this.message});
@override
List<Object?> get props => [message];
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:hive_ce/hive.dart';
part 'ai_settings.g.dart';
@HiveType(typeId: 9)
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,
this.baseUrl = 'https://openrouter.ai/api/v1',
this.defaultModel = 'openai/gpt-3.5-turbo',
this.timeoutSeconds = 30,
this.temperature = 0.7,
this.maxTokens = 1000,
this.isEnabled = false,
});
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)';
}
}
+63
View File
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ai_settings.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AiSettingsAdapter extends TypeAdapter<AiSettings> {
@override
final typeId = 9;
@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] == null
? 'https://openrouter.ai/api/v1'
: fields[1] as String,
defaultModel: fields[2] == null
? 'openai/gpt-3.5-turbo'
: 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 ? false : 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;
}
+24
View File
@@ -0,0 +1,24 @@
abstract class IAiService {
/// Отправить текстовый запрос к ИИ и получить ответ
Future<String> sendMessage(String message);
/// Отправить запрос с настраиваемыми параметрами
Future<String> sendMessageWithParams({
required String message,
String? model,
double? temperature,
int? maxTokens,
});
/// Проверить доступность сервиса
Future<bool> checkHealth();
/// Получить список доступных моделей
Future<List<String>> getAvailableModels();
/// Настроить API ключ
void setApiKey(String apiKey);
/// Проверить настройки соединения
bool isConfigured();
}
+172
View File
@@ -0,0 +1,172 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:logger/logger.dart';
import '/models/ai_settings.dart';
import 'interfaces/iai_service.dart';
class OpenRouterAiService implements IAiService {
final Logger _logger = Logger();
final AiSettings _settings;
String? _apiKey;
OpenRouterAiService(this._settings) {
_apiKey = _settings.apiKey;
}
@override
void setApiKey(String apiKey) {
_apiKey = apiKey;
}
@override
bool isConfigured() {
return _apiKey != null && _apiKey!.isNotEmpty;
}
@override
Future<String> sendMessage(String message) async {
return sendMessageWithParams(
message: message,
model: _settings.defaultModel,
temperature: _settings.temperature,
maxTokens: _settings.maxTokens,
);
}
@override
Future<String> sendMessageWithParams({
required String message,
String? model,
double? temperature,
int? maxTokens,
}) async {
if (!isConfigured()) {
throw Exception('OpenRouter API ключ не настроен');
}
if (!_settings.isEnabled) {
throw Exception('ИИ сервис отключен в настройках');
}
try {
final url = Uri.parse('${_settings.baseUrl}/chat/completions');
final body = {
'model': model ?? _settings.defaultModel,
'messages': [
{
'role': 'user',
'content': message,
}
],
'temperature': temperature ?? _settings.temperature,
'max_tokens': maxTokens ?? _settings.maxTokens,
};
final response = await http.post(
url,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $_apiKey',
'HTTP-Referer': 'https://github.com/your-repo',
'X-Title': 'Budget App',
},
body: jsonEncode(body),
).timeout(Duration(seconds: _settings.timeoutSeconds));
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
if (responseData['choices'] != null &&
responseData['choices'].isNotEmpty &&
responseData['choices'][0]['message'] != null) {
return responseData['choices'][0]['message']['content'] ?? 'Пустой ответ от ИИ';
} else {
throw Exception('Неверный формат ответа от OpenRouter');
}
} else {
final errorData = jsonDecode(response.body);
final errorMessage = errorData['error']?['message'] ?? 'Неизвестная ошибка';
throw Exception('Ошибка OpenRouter API (${response.statusCode}): $errorMessage');
}
} on SocketException {
throw Exception('Нет подключения к интернету');
} on http.ClientException {
throw Exception('Ошибка HTTP клиента');
} on FormatException {
throw Exception('Ошибка парсинга ответа');
} catch (e) {
_logger.e('Ошибка при отправке запроса к OpenRouter: $e');
rethrow;
}
}
@override
Future<bool> checkHealth() async {
if (!isConfigured()) {
return false;
}
try {
final url = Uri.parse('${_settings.baseUrl}/models');
final response = await http.get(
url,
headers: {
'Authorization': 'Bearer $_apiKey',
},
).timeout(Duration(seconds: 10));
return response.statusCode == 200;
} catch (e) {
_logger.w('Проверка здоровья OpenRouter API не удалась: $e');
return false;
}
}
@override
Future<List<String>> getAvailableModels() async {
if (!isConfigured()) {
throw Exception('OpenRouter API ключ не настроен');
}
try {
final url = Uri.parse('${_settings.baseUrl}/models');
final response = await http.get(
url,
headers: {
'Authorization': 'Bearer $_apiKey',
},
).timeout(Duration(seconds: _settings.timeoutSeconds));
if (response.statusCode == 200) {
final responseData = jsonDecode(response.body);
if (responseData['data'] != null) {
final models = <String>[];
for (final model in responseData['data']) {
if (model['id'] != null) {
models.add(model['id'] as String);
}
}
return models;
} else {
return [];
}
} else {
throw Exception('Ошибка получения моделей (${response.statusCode})');
}
} catch (e) {
_logger.e('Ошибка при получении списка моделей: $e');
return [
'openai/gpt-3.5-turbo',
'openai/gpt-4',
'anthropic/claude-3-haiku',
'anthropic/claude-3-sonnet',
'meta-llama/llama-3.1-8b-instruct',
];
}
}
}
+1 -1
View File
@@ -457,7 +457,7 @@ packages:
source: hosted
version: "1.9.2"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
+1
View File
@@ -53,6 +53,7 @@ dependencies:
freezed_annotation: ^3.1.0
json_annotation: ^4.8.1
crypto: ^3.0.3
http: ^1.1.0
dev_dependencies: