From 2b895452485723d2cb133e985718626d9dc5e4c4 Mon Sep 17 00:00:00 2001 From: kolo Date: Sun, 28 Jun 2026 14:55:41 +0300 Subject: [PATCH] step --- lib/core/constants.dart | 56 ++++++++ lib/features/add_transaction/screen.dart | 4 +- .../widgets/account_selector.dart | 3 + lib/features/categories/screen.dart | 51 +++---- lib/features/dashboard/screen.dart | 1 + .../categories/category_editor_sheet.dart | 101 ++++++++++++-- lib/features/settings/screen.dart | 124 ++++++++---------- .../services/exchange_rate_service.dart | 1 + lib/shared/services/translation_service.dart | 4 +- 9 files changed, 234 insertions(+), 111 deletions(-) diff --git a/lib/core/constants.dart b/lib/core/constants.dart index dd51f45..559e88b 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -26,6 +26,14 @@ class AppCategories { 'Shopping', 'Health', 'Entertainment', + 'Housing', + 'Education', + 'Travel', + 'Utilities', + 'Clothing', + 'Sports', + 'Beauty', + 'Pets', 'Other' ]; @@ -35,6 +43,8 @@ class AppCategories { 'Gift', 'Investment', 'Refund', + 'Business', + 'Savings', 'Other' ]; @@ -50,11 +60,21 @@ class AppCategories { 'Shopping': Icons.shopping_bag_rounded, 'Health': Icons.favorite_rounded, 'Entertainment': Icons.movie_rounded, + 'Housing': Icons.home_rounded, + 'Education': Icons.school_rounded, + 'Travel': Icons.flight_rounded, + 'Utilities': Icons.bolt_rounded, + 'Clothing': Icons.checkroom_rounded, + 'Sports': Icons.fitness_center_rounded, + 'Beauty': Icons.brush_rounded, + 'Pets': Icons.pets_rounded, 'Salary': Icons.work_rounded, 'Freelance': Icons.laptop_rounded, 'Gift': Icons.card_giftcard_rounded, 'Investment': Icons.trending_up_rounded, 'Refund': Icons.money_rounded, + 'Business': Icons.business_center_rounded, + 'Savings': Icons.savings_rounded, 'Other': Icons.category_rounded, }; @@ -64,11 +84,21 @@ class AppCategories { 'Shopping': Color(0xFFFFD369), 'Health': Color(0xFF69FFB4), 'Entertainment': Color(0xFFFF69B4), + 'Housing': Color(0xFF69B4FF), + 'Education': Color(0xFFFFB469), + 'Travel': Color(0xFF69FFB4), + 'Utilities': Color(0xFFFFD369), + 'Clothing': Color(0xFFFF69B4), + 'Sports': Color(0xFF69FFB4), + 'Beauty': Color(0xFFFF69B4), + 'Pets': Color(0xFFB4FF69), 'Salary': Color(0xFF4CAF8C), 'Freelance': Color(0xFF69FFB4), 'Gift': Color(0xFFFFB469), 'Investment': Color(0xFF69B4FF), 'Refund': Color(0xFFB4FF69), + 'Business': Color(0xFFFF8C69), + 'Savings': Color(0xFF4CAF8C), 'Other': Color(0xFFB469FF), }; @@ -78,11 +108,21 @@ class AppCategories { 'Shopping': 'shopping_bag', 'Health': 'heart', 'Entertainment': 'movie', + 'Housing': 'home', + 'Education': 'school', + 'Travel': 'flight', + 'Utilities': 'bolt', + 'Clothing': 'checkroom', + 'Sports': 'fitness', + 'Beauty': 'brush', + 'Pets': 'pets', 'Salary': 'work', 'Freelance': 'laptop', 'Gift': 'gift', 'Investment': 'trending_up', 'Refund': 'money', + 'Business': 'work', + 'Savings': 'savings', 'Other': 'category', }; @@ -108,6 +148,22 @@ class AppCategories { 'Pets': 'Питомцы', 'Business': 'Бизнес', 'Savings': 'Накопления', + 'Dining': 'Ресторан', + 'Cafe': 'Кафе', + 'Coffee': 'Кофе', + 'Restaurant': 'Ресторан', + 'Fuel': 'Топливо', + 'Taxi': 'Такси', + 'Phone': 'Связь', + 'Internet': 'Интернет', + 'Insurance': 'Страховка', + 'Taxes': 'Налоги', + 'Medicine': 'Медицина', + 'Children': 'Дети', + 'Hobby': 'Хобби', + 'Music': 'Музыка', + 'Games': 'Игры', + 'Books': 'Книги', }; } diff --git a/lib/features/add_transaction/screen.dart b/lib/features/add_transaction/screen.dart index 081458c..bc81f85 100644 --- a/lib/features/add_transaction/screen.dart +++ b/lib/features/add_transaction/screen.dart @@ -396,7 +396,7 @@ class _AddTransactionScreenState extends ConsumerState child: child!, ), ); - if (picked != null) { + if (picked != null && mounted) { setState(() => _selectedDate = picked); } } @@ -423,7 +423,7 @@ class _AddTransactionScreenState extends ConsumerState child: child!, ), ); - if (picked != null) { + if (picked != null && mounted) { setState(() => _selectedTime = picked); } } diff --git a/lib/features/add_transaction/widgets/account_selector.dart b/lib/features/add_transaction/widgets/account_selector.dart index 90368b9..b52e471 100644 --- a/lib/features/add_transaction/widgets/account_selector.dart +++ b/lib/features/add_transaction/widgets/account_selector.dart @@ -27,6 +27,9 @@ class AccountSelector extends ConsumerWidget { return accountsAsync.when( data: (accounts) { + if (accounts.isEmpty) { + return const SizedBox.shrink(); + } final txAccountId = ref .read(addTransactionProvider(initial)) .selectedAccountId; diff --git a/lib/features/categories/screen.dart b/lib/features/categories/screen.dart index 273f5b6..26c6de4 100644 --- a/lib/features/categories/screen.dart +++ b/lib/features/categories/screen.dart @@ -45,18 +45,9 @@ class _CategoriesScreenState extends ConsumerState { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: AppBar( - title: Text( - s.statistics, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), body: SafeArea( child: ListView( - padding: const EdgeInsets.fromLTRB(20, 8, 20, 100), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 100), children: [ const AccountScopeChips(), const SizedBox(height: 16), @@ -232,6 +223,9 @@ class _PieChartSection extends ConsumerWidget { final fmt = ref.watch(amountFormatProvider); final entries = data.entries.toList(); final accent = isIncome ? AppColors.income : AppColors.expense; + if (entries.isEmpty) { + return const SizedBox.shrink(); + } final selectedIndex = touchedIndex >= 0 ? touchedIndex : 0; final selectedEntry = entries[selectedIndex.clamp(0, entries.length - 1)]; final selectedAmount = selectedEntry.value; @@ -267,7 +261,9 @@ class _PieChartSection extends ConsumerWidget { color: color, value: val, title: isTouched - ? '${(val / total * 100).toStringAsFixed(0)}%' + ? total > 0 + ? '${(val / total * 100).toStringAsFixed(0)}%' + : '0%' : '', radius: isTouched ? 60 : 52, titleStyle: const TextStyle( @@ -340,7 +336,9 @@ class _PieChartSection extends ConsumerWidget { ), ), Text( - '${(selectedAmount / total * 100).toStringAsFixed(1)}%', + total > 0 + ? '${(selectedAmount / total * 100).toStringAsFixed(1)}%' + : '0%', style: Theme.of(context).textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w700, color: accent, @@ -869,7 +867,7 @@ class _TimePeriodToggle extends ConsumerWidget { color: theme.colorScheme.onSurface.withOpacity(0.06), ), ), - padding: const EdgeInsets.all(4), + padding: const EdgeInsets.all(2), child: Row( children: [ Expanded( @@ -936,14 +934,17 @@ class _OverviewCard extends ConsumerWidget { ), ), const SizedBox(height: 12), - _FormattedAmount( - amount: amount, - currencyInfo: currencyInfo, - color: color, - fontSize: 40, - fontWeight: FontWeight.w800, - format: fmt, - center: true, + FittedBox( + fit: BoxFit.scaleDown, + child: _FormattedAmount( + amount: amount, + currencyInfo: currencyInfo, + color: color, + fontSize: 40, + fontWeight: FontWeight.w800, + format: fmt, + center: true, + ), ), const SizedBox(height: 14), Text( @@ -981,7 +982,7 @@ class _IncomeExpenseToggle extends ConsumerWidget { color: theme.colorScheme.onSurface.withOpacity(0.06), ), ), - padding: const EdgeInsets.all(4), + padding: const EdgeInsets.all(2), child: Row( children: [ Expanded( @@ -1025,7 +1026,7 @@ class _ToggleChip extends StatelessWidget { onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 10), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), decoration: BoxDecoration( color: isSelected ? color.withOpacity(0.15) : Colors.transparent, borderRadius: BorderRadius.circular(8), @@ -1033,8 +1034,10 @@ class _ToggleChip extends StatelessWidget { child: Text( label, textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 14, + fontSize: 13, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, color: isSelected ? color diff --git a/lib/features/dashboard/screen.dart b/lib/features/dashboard/screen.dart index d07e561..5358c07 100644 --- a/lib/features/dashboard/screen.dart +++ b/lib/features/dashboard/screen.dart @@ -222,6 +222,7 @@ class _DashboardScreenState extends ConsumerState { }); } + if (!mounted) return; overlayEntry?.remove(); overlayEntry = null; setState(() { diff --git a/lib/features/settings/categories/category_editor_sheet.dart b/lib/features/settings/categories/category_editor_sheet.dart index a1c2c93..bc6f0d2 100644 --- a/lib/features/settings/categories/category_editor_sheet.dart +++ b/lib/features/settings/categories/category_editor_sheet.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/constants.dart'; @@ -44,6 +46,11 @@ class _CategoryEditorSheetState extends ConsumerState { bool _translatingEn = false; bool _translatingRu = false; bool _saving = false; + DateTime? _lastTranslateTime; + bool _enOverflow = false; + bool _ruOverflow = false; + Timer? _enOverflowTimer; + Timer? _ruOverflowTimer; @override void initState() { @@ -62,6 +69,8 @@ class _CategoryEditorSheetState extends ConsumerState { @override void dispose() { + _enOverflowTimer?.cancel(); + _ruOverflowTimer?.cancel(); _enController.dispose(); _ruController.dispose(); super.dispose(); @@ -71,21 +80,50 @@ class _CategoryEditorSheetState extends ConsumerState { if (_enController.text.trim().isNotEmpty && _enSuggestion != null) { setState(() => _enSuggestion = null); } + if (_enController.text.length >= 20) { + _enOverflowTimer?.cancel(); + setState(() => _enOverflow = true); + _enOverflowTimer = Timer(const Duration(seconds: 1), () { + if (mounted) setState(() => _enOverflow = false); + }); + } } void _onRuChanged() { if (_ruController.text.trim().isNotEmpty && _ruSuggestion != null) { setState(() => _ruSuggestion = null); } + if (_ruController.text.length >= 20) { + _ruOverflowTimer?.cancel(); + setState(() => _ruOverflow = true); + _ruOverflowTimer = Timer(const Duration(seconds: 1), () { + if (mounted) setState(() => _ruOverflow = false); + }); + } + } + + bool _isThrottled() { + final now = DateTime.now(); + if (_lastTranslateTime != null && + now.difference(_lastTranslateTime!) < const Duration(seconds: 2)) { + return true; + } + _lastTranslateTime = now; + return false; } Future _translateToRu() async { final source = _enController.text.trim(); if (source.isEmpty) return; setState(() => _translatingRu = true); - final result = await ref - .read(translationServiceProvider) - .translate(source, TranslateDirection.enToRu); + final service = ref.read(translationServiceProvider); + TranslationResult? result; + if (_isThrottled()) { + final dict = service.dictionaryLookup(source, TranslateDirection.enToRu); + result = dict != null ? TranslationResult(dict, fromCache: true) : null; + } else { + result = await service.translate(source, TranslateDirection.enToRu); + } if (!mounted) return; setState(() { _translatingRu = false; @@ -100,9 +138,14 @@ class _CategoryEditorSheetState extends ConsumerState { final source = _ruController.text.trim(); if (source.isEmpty) return; setState(() => _translatingEn = true); - final result = await ref - .read(translationServiceProvider) - .translate(source, TranslateDirection.ruToEn); + final service = ref.read(translationServiceProvider); + TranslationResult? result; + if (_isThrottled()) { + final dict = service.dictionaryLookup(source, TranslateDirection.ruToEn); + result = dict != null ? TranslationResult(dict, fromCache: true) : null; + } else { + result = await service.translate(source, TranslateDirection.ruToEn); + } if (!mounted) return; setState(() { _translatingEn = false; @@ -122,21 +165,45 @@ class _CategoryEditorSheetState extends ConsumerState { } setState(() => _saving = true); HapticService.medium(); + + String labelEn = _enController.text.trim(); + String labelRu = _ruController.text.trim(); + + if (labelEn.isEmpty && labelRu.isNotEmpty) { + final result = await ref + .read(translationServiceProvider) + .translate(labelRu, TranslateDirection.ruToEn); + if (result != null && result.text.isNotEmpty) { + labelEn = result.text; + } else { + labelEn = labelRu; + } + } else if (labelRu.isEmpty && labelEn.isNotEmpty) { + final result = await ref + .read(translationServiceProvider) + .translate(labelEn, TranslateDirection.enToRu); + if (result != null && result.text.isNotEmpty) { + labelRu = result.text; + } else { + labelRu = labelEn; + } + } + final actions = ref.read(categoryActionsProvider); final existing = widget.existing; final result = existing != null && existing.id != null ? await actions.edit( id: existing.id!, type: _type, - labelEn: _enController.text, - labelRu: _ruController.text, + labelEn: labelEn, + labelRu: labelRu, iconName: _iconName, colorValue: _colorValue, ) : await actions.create( type: _type, - labelEn: _enController.text, - labelRu: _ruController.text, + labelEn: labelEn, + labelRu: labelRu, iconName: _iconName, colorValue: _colorValue, ); @@ -201,6 +268,7 @@ class _CategoryEditorSheetState extends ConsumerState { hint: s.nameEnHint, suggestion: _enSuggestion, isTranslating: _translatingEn, + isOverflow: _enOverflow, canTranslate: _ruController.text.trim().isNotEmpty, translatingLabel: s.translating, applyLabel: s.applyTranslation, @@ -217,6 +285,7 @@ class _CategoryEditorSheetState extends ConsumerState { hint: s.nameRuHint, suggestion: _ruSuggestion, isTranslating: _translatingRu, + isOverflow: _ruOverflow, canTranslate: _enController.text.trim().isNotEmpty, translatingLabel: s.translating, applyLabel: s.applyTranslation, @@ -384,6 +453,7 @@ class _TranslatableField extends StatelessWidget { final bool canTranslate; final String translatingLabel; final String applyLabel; + final bool isOverflow; final VoidCallback onTranslate; final VoidCallback onApply; @@ -393,6 +463,7 @@ class _TranslatableField extends StatelessWidget { required this.hint, required this.suggestion, required this.isTranslating, + required this.isOverflow, required this.canTranslate, required this.translatingLabel, required this.applyLabel, @@ -424,9 +495,11 @@ class _TranslatableField extends StatelessWidget { decoration: BoxDecoration( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(14), - border: isDark - ? null - : Border.all(color: const Color(0xFFDDDDEE), width: 1), + border: isOverflow + ? Border.all(color: AppColors.expense, width: 1.5) + : isDark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), ), child: Row( children: [ @@ -457,10 +530,12 @@ class _TranslatableField extends StatelessWidget { TextField( controller: controller, style: theme.textTheme.bodyLarge, + maxLength: 20, decoration: InputDecoration( hintText: showGhost ? '' : hint, isDense: true, filled: false, + counterText: '', enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, border: InputBorder.none, diff --git a/lib/features/settings/screen.dart b/lib/features/settings/screen.dart index a7eef2d..fdf2ac6 100644 --- a/lib/features/settings/screen.dart +++ b/lib/features/settings/screen.dart @@ -111,79 +111,63 @@ class SettingsScreen extends ConsumerWidget { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: AppBar( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - elevation: 0, - scrolledUnderElevation: 0, - centerTitle: true, - toolbarHeight: 80, - title: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Text( - 'Casha', - style: Theme.of(context).textTheme.displaySmall?.copyWith( - fontWeight: FontWeight.w800, - color: Theme.of(context).colorScheme.onSurface, - letterSpacing: -0.5, + body: SafeArea( + child: ListView( + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), + children: [ + const ThemeSection(), + const SizedBox(height: 16), + const CardTextColorSection(), + const SizedBox(height: 16), + const HapticSection(), + const SizedBox(height: 16), + const CurrencyConversionsSection(), + const SizedBox(height: 16), + const _BiometricSection(), + const LanguageSection(), + const SizedBox(height: 16), + const CurrencySection(), + const SizedBox(height: 16), + const AmountFormatSection(), + const SizedBox(height: 16), + const CategoriesSection(), + const SizedBox(height: 24), + Text( + s.dangerZone, + style: TextStyle( + fontSize: 12, + letterSpacing: 1.2, + color: const Color(0xFFE05C6B).withOpacity(0.8), + fontWeight: FontWeight.w600, + ), ), - ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => _confirmClearData(context, ref), + icon: const Icon(Icons.delete_forever, color: Color(0xFFE05C6B)), + label: Text( + s.clearAllTransactions, + style: const TextStyle(color: Color(0xFFE05C6B)), + ), + style: OutlinedButton.styleFrom( + side: BorderSide( + color: const Color(0xFFE05C6B).withOpacity(0.5), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 40), + const _FooterWidget(), + ], ), ), - body: ListView( - physics: const ClampingScrollPhysics(), - padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), - children: [ - const ThemeSection(), - const SizedBox(height: 16), - const CardTextColorSection(), - const SizedBox(height: 16), - const HapticSection(), - const SizedBox(height: 16), - const CurrencyConversionsSection(), - const SizedBox(height: 16), - const _BiometricSection(), - const LanguageSection(), - const SizedBox(height: 16), - const CurrencySection(), - const SizedBox(height: 16), - const AmountFormatSection(), - const SizedBox(height: 16), - const CategoriesSection(), - const SizedBox(height: 24), - Text( - s.dangerZone, - style: TextStyle( - fontSize: 12, - letterSpacing: 1.2, - color: const Color(0xFFE05C6B).withOpacity(0.8), - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => _confirmClearData(context, ref), - icon: const Icon(Icons.delete_forever, color: Color(0xFFE05C6B)), - label: Text( - s.clearAllTransactions, - style: const TextStyle(color: Color(0xFFE05C6B)), - ), - style: OutlinedButton.styleFrom( - side: BorderSide( - color: const Color(0xFFE05C6B).withOpacity(0.5), - ), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - const SizedBox(height: 40), - const _FooterWidget(), - ], - ), ); } } diff --git a/lib/shared/services/exchange_rate_service.dart b/lib/shared/services/exchange_rate_service.dart index c192462..4afcb9b 100644 --- a/lib/shared/services/exchange_rate_service.dart +++ b/lib/shared/services/exchange_rate_service.dart @@ -97,6 +97,7 @@ class ExchangeRateService { final fromRate = currentRates[from] ?? 1.0; final toRate = currentRates[to] ?? 1.0; + if (fromRate == 0) return amount; final amountInUsd = amount / fromRate; return amountInUsd * toRate; diff --git a/lib/shared/services/translation_service.dart b/lib/shared/services/translation_service.dart index f2d9407..4c0253c 100644 --- a/lib/shared/services/translation_service.dart +++ b/lib/shared/services/translation_service.dart @@ -59,7 +59,7 @@ class TranslationService { for (final entry in _ruToEn.entries) entry.value.toLowerCase(): entry.key, }; - String? _dictionaryLookup(String input, TranslateDirection direction) { + String? dictionaryLookup(String input, TranslateDirection direction) { final normalized = input.trim().toLowerCase(); if (normalized.isEmpty) return null; final map = direction == TranslateDirection.ruToEn ? _ruToEn : _enToRu; @@ -75,7 +75,7 @@ class TranslationService { final trimmed = input.trim(); if (trimmed.isEmpty) return null; - final cached = _dictionaryLookup(trimmed, direction); + final cached = dictionaryLookup(trimmed, direction); if (cached != null) { return TranslationResult(cached, fromCache: true); }