big
This commit is contained in:
2026-06-28 02:19:57 +03:00
parent ed45748c95
commit bc51609f85
26 changed files with 1757 additions and 1525 deletions
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'transaction.dart';
class AppCategory {
final String key;
final TransactionType type;
final String labelEn;
final String labelRu;
final IconData icon;
final Color color;
final String iconName;
final bool isCustom;
final int? id;
const AppCategory({
required this.key,
required this.type,
required this.labelEn,
required this.labelRu,
required this.icon,
required this.color,
required this.iconName,
this.isCustom = false,
this.id,
});
String label(bool isRu) {
final value = isRu ? labelRu : labelEn;
return value.isEmpty ? key : value;
}
}
+173
View File
@@ -0,0 +1,173 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/constants.dart';
import '../../core/utils/result.dart';
import '../../data/database/app_database.dart';
import '../../data/repositories/category_repository.dart';
import '../../features/dashboard/provider.dart';
import '../models/app_category.dart';
import '../models/transaction.dart';
import '../services/translation_service.dart';
final categoryRepositoryProvider = Provider<CategoryRepository>((ref) {
return CategoryRepository(ref.watch(appDatabaseProvider));
});
final customCategoriesProvider = StreamProvider<List<Category>>((ref) {
return ref.watch(categoryRepositoryProvider).watchAll();
});
final translationServiceProvider = Provider<TranslationService>((ref) {
return TranslationService();
});
final categoryActionsProvider = Provider<CategoryActions>((ref) {
return CategoryActions(ref);
});
class CategoryActions {
final Ref _ref;
CategoryActions(this._ref);
CategoryRepository get _repo => _ref.read(categoryRepositoryProvider);
Future<Result<void>> create({
required TransactionType type,
required String labelEn,
required String labelRu,
required String iconName,
required int colorValue,
}) {
return asyncResultOf(() async {
final en = labelEn.trim();
final ru = labelRu.trim();
if (en.isEmpty && ru.isEmpty) {
throw Exception('Category name is required');
}
final key = 'cat_${DateTime.now().microsecondsSinceEpoch}';
await _repo.add(
name: key,
type: type.name,
labelEn: en.isEmpty ? ru : en,
labelRu: ru.isEmpty ? en : ru,
iconName: iconName,
colorValue: colorValue,
);
});
}
Future<Result<void>> edit({
required int id,
required TransactionType type,
required String labelEn,
required String labelRu,
required String iconName,
required int colorValue,
}) {
return asyncResultOf(() async {
final en = labelEn.trim();
final ru = labelRu.trim();
if (en.isEmpty && ru.isEmpty) {
throw Exception('Category name is required');
}
await _repo.updateFields(
id,
type: type.name,
labelEn: en.isEmpty ? ru : en,
labelRu: ru.isEmpty ? en : ru,
iconName: iconName,
colorValue: colorValue,
);
});
}
Future<Result<void>> remove(int id) {
return asyncResultOf(() async {
await _repo.delete(id);
});
}
}
class CategoryCatalog {
final List<AppCategory> all;
const CategoryCatalog(this.all);
List<AppCategory> forType(TransactionType type) =>
all.where((c) => c.type == type).toList();
List<AppCategory> get custom => all.where((c) => c.isCustom).toList();
AppCategory? byKey(String key) =>
all.firstWhereOrNull((c) => c.key == key);
IconData iconFor(String key) =>
byKey(key)?.icon ?? Icons.category_rounded;
Color colorFor(String key, [Color? fallback]) =>
byKey(key)?.color ?? fallback ?? AppColors.accent;
String labelFor(String key, bool isRu) =>
byKey(key)?.label(isRu) ?? key;
bool hasKey(String key) => byKey(key) != null;
}
final categoryCatalogProvider = Provider<CategoryCatalog>((ref) {
final custom = ref.watch(customCategoriesProvider).value ?? const [];
final mapped = custom.map(_fromRow).toList();
return CategoryCatalog([..._defaultCategories(), ...mapped]);
});
List<AppCategory> _defaultCategories() {
final result = <AppCategory>[];
for (final key in AppCategories.expenseCategories) {
result.add(_defaultCategory(key, TransactionType.expense));
}
for (final key in AppCategories.incomeCategories) {
result.add(_defaultCategory(key, TransactionType.income));
}
return result;
}
AppCategory _defaultCategory(String key, TransactionType type) {
final iconName = AppCategories.iconNames[key] ?? 'category';
return AppCategory(
key: key,
type: type,
labelEn: key,
labelRu: AppCategories.ruLabels[key] ?? key,
icon: categoryIconByName(iconName),
color: AppCategories.colors[key] ?? AppColors.accent,
iconName: iconName,
isCustom: false,
);
}
AppCategory _fromRow(Category row) {
final type =
row.type == 'income' ? TransactionType.income : TransactionType.expense;
final labelEn = (row.labelEn != null && row.labelEn!.isNotEmpty)
? row.labelEn!
: row.name;
final labelRu = (row.labelRu != null && row.labelRu!.isNotEmpty)
? row.labelRu!
: row.name;
final colorValue = int.tryParse(row.color ?? '');
final color = colorValue != null
? Color(colorValue)
: kCategoryColors[row.id % kCategoryColors.length];
return AppCategory(
key: row.name,
type: type,
labelEn: labelEn,
labelRu: labelRu,
icon: categoryIconByName(row.icon),
color: color,
iconName: row.icon ?? 'category',
isCustom: true,
id: row.id,
);
}
-18
View File
@@ -8,7 +8,6 @@ const _uuid = Uuid();
class StorageService {
static const _transactionsKey = 'transactions';
static const _budgetKey = 'monthly_budget';
static const _currencyKey = 'currency_symbol';
static const _themeKey = 'is_dark_mode';
@@ -90,23 +89,6 @@ class StorageService {
});
}
double? loadBudget() {
return _prefs.getDouble(_budgetKey);
}
Future<Result<void>> saveBudget(double? budget) async {
return asyncResultOf(() async {
if (budget == null) {
await _prefs.remove(_budgetKey);
} else {
if (budget < 0) {
throw Exception('Budget cannot be negative');
}
await _prefs.setDouble(_budgetKey, budget);
}
});
}
String loadCurrency() {
return _prefs.getString(_currencyKey) ?? '\$';
}
@@ -0,0 +1,107 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
enum TranslateDirection { ruToEn, enToRu }
class TranslationResult {
final String text;
final bool fromCache;
const TranslationResult(this.text, {this.fromCache = false});
}
class TranslationService {
static const Map<String, String> _ruToEn = {
'еда': 'Food',
'продукты': 'Groceries',
'транспорт': 'Transport',
'покупки': 'Shopping',
'здоровье': 'Health',
'развлечения': 'Entertainment',
'жильё': 'Housing',
'жилье': 'Housing',
'аренда': 'Rent',
'образование': 'Education',
'путешествия': 'Travel',
'зарплата': 'Salary',
'фриланс': 'Freelance',
'инвестиции': 'Investment',
'подарок': 'Gift',
'подарки': 'Gifts',
'возврат': 'Refund',
'другое': 'Other',
'коммунальные': 'Utilities',
'одежда': 'Clothing',
'спорт': 'Sports',
'красота': 'Beauty',
'питомцы': 'Pets',
'животные': 'Pets',
'бизнес': 'Business',
'накопления': 'Savings',
'кафе': 'Cafe',
'кофе': 'Coffee',
'ресторан': 'Restaurant',
'связь': 'Communication',
'интернет': 'Internet',
'налоги': 'Taxes',
'страховка': 'Insurance',
'медицина': 'Medicine',
'дети': 'Children',
'хобби': 'Hobby',
'музыка': 'Music',
'игры': 'Games',
'книги': 'Books',
'топливо': 'Fuel',
'такси': 'Taxi',
};
static final Map<String, String> _enToRu = {
for (final entry in _ruToEn.entries) entry.value.toLowerCase(): entry.key,
};
String? _dictionaryLookup(String input, TranslateDirection direction) {
final normalized = input.trim().toLowerCase();
if (normalized.isEmpty) return null;
final map = direction == TranslateDirection.ruToEn ? _ruToEn : _enToRu;
final hit = map[normalized];
if (hit == null) return null;
return _capitalize(hit);
}
Future<TranslationResult?> translate(
String input,
TranslateDirection direction,
) async {
final trimmed = input.trim();
if (trimmed.isEmpty) return null;
final cached = _dictionaryLookup(trimmed, direction);
if (cached != null) {
return TranslationResult(cached, fromCache: true);
}
final pair = direction == TranslateDirection.ruToEn ? 'ru|en' : 'en|ru';
final uri = Uri.parse(
'https://api.mymemory.translated.net/get'
'?q=${Uri.encodeQueryComponent(trimmed)}&langpair=$pair',
);
try {
final response =
await http.get(uri).timeout(const Duration(seconds: 8));
if (response.statusCode != 200) return null;
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final data = decoded['responseData'] as Map<String, dynamic>?;
final translated = data?['translatedText'] as String?;
if (translated == null || translated.trim().isEmpty) return null;
return TranslationResult(_capitalize(translated.trim()));
} catch (_) {
return null;
}
}
String _capitalize(String value) {
if (value.isEmpty) return value;
return value[0].toUpperCase() + value.substring(1);
}
}