mirror of
https://github.com/koloideal/Casha.git
synced 2026-08-08 17:51:14 +03:00
step
big
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user