diff --git a/lib/core/utils/result.dart b/lib/core/utils/result.dart new file mode 100644 index 0000000..1c55f33 --- /dev/null +++ b/lib/core/utils/result.dart @@ -0,0 +1,165 @@ +/// Result type for handling success and failure cases +/// Uses sealed classes for exhaustive pattern matching +sealed class Result { + const Result(); + + /// Check if result is success + bool get isSuccess => this is Success; + + /// Check if result is failure + bool get isFailure => this is Failure; + + /// Get data if success, null otherwise + T? get dataOrNull => switch (this) { + Success(data: final data) => data, + Failure() => null, + }; + + /// Get error if failure, null otherwise + String? get errorOrNull => switch (this) { + Success() => null, + Failure(message: final message) => message, + }; + + /// Transform success value + Result map(R Function(T data) transform) { + return switch (this) { + Success(data: final data) => Success(transform(data)), + Failure(message: final msg, exception: final ex) => Failure(msg, ex), + }; + } + + /// Execute callback on success + Result onSuccess(void Function(T data) callback) { + if (this case Success(data: final data)) { + callback(data); + } + return this; + } + + /// Execute callback on failure + Result onFailure(void Function(String message) callback) { + if (this case Failure(message: final message)) { + callback(message); + } + return this; + } + + /// Get data or throw exception + T getOrThrow() { + return switch (this) { + Success(data: final data) => data, + Failure(message: final msg, exception: final ex) => + throw ex ?? Exception(msg), + }; + } + + /// Get data or return default value + T getOrDefault(T defaultValue) { + return switch (this) { + Success(data: final data) => data, + Failure() => defaultValue, + }; + } + + /// Get data or compute from error + T getOrElse(T Function(String error) onError) { + return switch (this) { + Success(data: final data) => data, + Failure(message: final msg) => onError(msg), + }; + } +} + +/// Success result containing data +class Success extends Result { + final T data; + + const Success(this.data); + + @override + String toString() => 'Success($data)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Success && + runtimeType == other.runtimeType && + data == other.data; + + @override + int get hashCode => data.hashCode; +} + +/// Failure result containing error message and optional exception +class Failure extends Result { + final String message; + final Exception? exception; + + const Failure(this.message, [this.exception]); + + @override + String toString() => + 'Failure($message${exception != null ? ', $exception' : ''})'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Failure && + runtimeType == other.runtimeType && + message == other.message && + exception == other.exception; + + @override + int get hashCode => message.hashCode ^ exception.hashCode; +} + +/// Extension for Future> +extension FutureResultExtension on Future> { + /// Map async result + Future> mapAsync(R Function(T data) transform) async { + final result = await this; + return result.map(transform); + } + + /// Execute callback on success + Future> onSuccessAsync( + Future Function(T data) callback, + ) async { + final result = await this; + if (result case Success(data: final data)) { + await callback(data); + } + return result; + } + + /// Execute callback on failure + Future> onFailureAsync( + Future Function(String message) callback, + ) async { + final result = await this; + if (result case Failure(message: final message)) { + await callback(message); + } + return result; + } +} + +/// Helper to wrap try-catch blocks +Result resultOf(T Function() computation) { + try { + return Success(computation()); + } catch (e) { + return Failure(e.toString(), e is Exception ? e : Exception(e.toString())); + } +} + +/// Helper to wrap async try-catch blocks +Future> asyncResultOf(Future Function() computation) async { + try { + final data = await computation(); + return Success(data); + } catch (e) { + return Failure(e.toString(), e is Exception ? e : Exception(e.toString())); + } +} diff --git a/lib/features/add_transaction/screen.dart b/lib/features/add_transaction/screen.dart index 2605b71..43a053b 100644 --- a/lib/features/add_transaction/screen.dart +++ b/lib/features/add_transaction/screen.dart @@ -9,6 +9,7 @@ import '../../core/l10n/app_strings.dart'; import '../../core/l10n/locale_provider.dart'; import '../../core/services/haptic_service.dart'; import '../../shared/models/transaction.dart'; +import '../../shared/widgets/error_snackbar.dart'; import '../dashboard/provider.dart'; import '../settings/provider.dart'; import 'provider.dart'; @@ -17,7 +18,7 @@ const _uuid = Uuid(); class AddTransactionScreen extends ConsumerStatefulWidget { final Transaction? initial; - + const AddTransactionScreen({super.key, this.initial}); @override @@ -44,7 +45,7 @@ class _AddTransactionScreenState extends ConsumerState _selectedTime = widget.initial != null ? TimeOfDay.fromDateTime(widget.initial!.date) : TimeOfDay(hour: now.hour, minute: now.minute); - + _shakeController = AnimationController( vsync: this, duration: const Duration(milliseconds: 5000), @@ -52,10 +53,7 @@ class _AddTransactionScreenState extends ConsumerState _borderColorAnimation = ColorTween( begin: const Color(0xFFE05C6B), end: Colors.transparent, - ).animate(CurvedAnimation( - parent: _shakeController, - curve: Curves.easeOut, - )); + ).animate(CurvedAnimation(parent: _shakeController, curve: Curves.easeOut)); _shakeController.addStatusListener((status) { if (status == AnimationStatus.completed) { if (mounted) setState(() => _showError = false); @@ -67,7 +65,9 @@ class _AddTransactionScreenState extends ConsumerState } else { WidgetsBinding.instance.addPostFrameCallback((_) { final curr = ref.read(currencyProvider); - ref.read(addTransactionProvider(null).notifier).setCurrency(curr.symbol, curr.code); + ref + .read(addTransactionProvider(null).notifier) + .setCurrency(curr.symbol, curr.code); }); } } @@ -82,24 +82,24 @@ class _AddTransactionScreenState extends ConsumerState String? _validateAndParseAmount(String raw) { final trimmed = raw.trim(); - + if (trimmed.isEmpty) return null; - + final normalized = trimmed.replaceAll(',', '.'); - + final validPattern = RegExp(r'^\d+\.?\d*$'); if (!validPattern.hasMatch(normalized)) return null; - + final value = double.tryParse(normalized); if (value == null) return null; - + if (value <= 0) return null; - + if (value > 999_999_999) return null; - + final parts = normalized.split('.'); if (parts.length == 2 && parts[1].length > 2) return null; - + return normalized; // valid, return normalized string } @@ -111,15 +111,15 @@ class _AddTransactionScreenState extends ConsumerState Future _submit() async { final raw = _amountController.text; final parsed = _validateAndParseAmount(raw); - + if (parsed == null) { _triggerError(); return; } - + final amount = double.parse(parsed); final state = ref.read(addTransactionProvider(widget.initial)); - + final finalDateTime = DateTime( _selectedDate.year, _selectedDate.month, @@ -127,12 +127,18 @@ class _AddTransactionScreenState extends ConsumerState _selectedTime.hour, _selectedTime.minute, ); - - ref.read(addTransactionProvider(widget.initial).notifier).setAmount(amount); - ref.read(addTransactionProvider(widget.initial).notifier).setDate(finalDateTime); - ref.read(addTransactionProvider(widget.initial).notifier).setSubmitting(true); - final note = _noteController.text.trim().isEmpty ? null : _noteController.text.trim(); + ref.read(addTransactionProvider(widget.initial).notifier).setAmount(amount); + ref + .read(addTransactionProvider(widget.initial).notifier) + .setDate(finalDateTime); + ref + .read(addTransactionProvider(widget.initial).notifier) + .setSubmitting(true); + + final note = _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(); final tx = Transaction( id: state.editingId ?? _uuid.v4(), @@ -150,9 +156,11 @@ class _AddTransactionScreenState extends ConsumerState } else { await ref.read(transactionsProvider.notifier).add(tx); } - - ref.read(addTransactionProvider(widget.initial).notifier).setSubmitting(false); - + + ref + .read(addTransactionProvider(widget.initial).notifier) + .setSubmitting(false); + HapticService.medium(); if (mounted) context.pop(); @@ -166,9 +174,9 @@ class _AddTransactionScreenState extends ConsumerState lastDate: DateTime.now(), builder: (context, child) => Theme( data: Theme.of(context).copyWith( - colorScheme: Theme.of(context).colorScheme.copyWith( - primary: AppColors.accent, - ), + colorScheme: Theme.of( + context, + ).colorScheme.copyWith(primary: AppColors.accent), ), child: child!, ), @@ -193,9 +201,9 @@ class _AddTransactionScreenState extends ConsumerState borderRadius: BorderRadius.circular(12), ), ), - colorScheme: Theme.of(context).colorScheme.copyWith( - primary: const Color(0xFF7C6DED), - ), + colorScheme: Theme.of( + context, + ).colorScheme.copyWith(primary: const Color(0xFF7C6DED)), ), child: child!, ), @@ -239,7 +247,9 @@ class _AddTransactionScreenState extends ConsumerState ), TextButton( onPressed: () { - ref.read(transactionsProvider.notifier).delete(widget.initial!.id); + ref + .read(transactionsProvider.notifier) + .delete(widget.initial!.id); Navigator.pop(ctx); context.pop(); }, @@ -264,8 +274,9 @@ class _AddTransactionScreenState extends ConsumerState _TypeToggle( selected: state.type, strings: s, - onChanged: (t) => - ref.read(addTransactionProvider(widget.initial).notifier).setType(t), + onChanged: (t) => ref + .read(addTransactionProvider(widget.initial).notifier) + .setType(t), ), const SizedBox(height: 24), @@ -286,7 +297,10 @@ class _AddTransactionScreenState extends ConsumerState decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), - border: Border.all(color: borderColor, width: isError ? 1.5 : 1), + border: Border.all( + color: borderColor, + width: isError ? 1.5 : 1, + ), ), child: Row( children: [ @@ -294,20 +308,28 @@ class _AddTransactionScreenState extends ConsumerState padding: const EdgeInsets.symmetric(horizontal: 14), child: Text( overrideCurrency, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), - fontWeight: FontWeight.w600, - ), + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.7), + fontWeight: FontWeight.w600, + ), ), ), Expanded( child: TextField( controller: _amountController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w600, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, ), + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface, + fontWeight: FontWeight.w600, + ), decoration: const InputDecoration( hintText: '0.00', border: InputBorder.none, @@ -316,11 +338,19 @@ class _AddTransactionScreenState extends ConsumerState errorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, filled: false, - contentPadding: EdgeInsets.symmetric(vertical: 14), + contentPadding: EdgeInsets.symmetric( + vertical: 14, + ), ), onChanged: (v) { final parsed = double.tryParse(v); - ref.read(addTransactionProvider(widget.initial).notifier).setAmount(parsed); + ref + .read( + addTransactionProvider( + widget.initial, + ).notifier, + ) + .setAmount(parsed); }, ), ), @@ -335,14 +365,17 @@ class _AddTransactionScreenState extends ConsumerState s.currency, style: TextStyle( fontSize: 13, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), ), ), const SizedBox(height: 8), _CurrencyPicker( selected: state.overrideCurrencyCode, - onChanged: (symbol, code) => - ref.read(addTransactionProvider(widget.initial).notifier).setCurrency(symbol, code), + onChanged: (symbol, code) => ref + .read(addTransactionProvider(widget.initial).notifier) + .setCurrency(symbol, code), ), const SizedBox(height: 20), @@ -351,8 +384,9 @@ class _AddTransactionScreenState extends ConsumerState _CategoryPicker( categories: categories, selected: state.category, - onChanged: (c) => - ref.read(addTransactionProvider(widget.initial).notifier).setCategory(c), + onChanged: (c) => ref + .read(addTransactionProvider(widget.initial).notifier) + .setCategory(c), ), const SizedBox(height: 20), @@ -365,38 +399,57 @@ class _AddTransactionScreenState extends ConsumerState children: [ Text( s.date, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 6), GestureDetector( onTap: _pickDate, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), border: isDark ? null - : Border.all(color: const Color(0xFFCCCCDD), width: 1), + : Border.all( + color: const Color(0xFFCCCCDD), + width: 1, + ), ), child: Row( children: [ Icon( Icons.calendar_today_rounded, size: 16, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), ), const SizedBox(width: 8), Expanded( child: Text( - DateFormat('MMM d, yyyy', s.dateLocale).format(_selectedDate), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), + DateFormat( + 'MMM d, yyyy', + s.dateLocale, + ).format(_selectedDate), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), overflow: TextOverflow.ellipsis, ), ), @@ -414,37 +467,51 @@ class _AddTransactionScreenState extends ConsumerState children: [ Text( s.time, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + fontWeight: FontWeight.w500, + ), ), const SizedBox(height: 6), GestureDetector( onTap: _pickTime, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), border: isDark ? null - : Border.all(color: const Color(0xFFCCCCDD), width: 1), + : Border.all( + color: const Color(0xFFCCCCDD), + width: 1, + ), ), child: Row( children: [ Icon( Icons.access_time_rounded, size: 16, - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), ), const SizedBox(width: 8), Text( _selectedTime.format(context), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), ), ], ), @@ -464,11 +531,18 @@ class _AddTransactionScreenState extends ConsumerState maxLines: 2, maxLength: 20, maxLengthEnforcement: MaxLengthEnforcement.enforced, - buildCounter: (context, {required currentLength, required isFocused, maxLength}) => - Text( + buildCounter: + ( + context, { + required currentLength, + required isFocused, + maxLength, + }) => Text( '$currentLength/$maxLength', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.4), + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.4), fontSize: 11, ), ), @@ -482,11 +556,15 @@ class _AddTransactionScreenState extends ConsumerState ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFF7C6DED), width: 1.5), + borderSide: const BorderSide( + color: Color(0xFF7C6DED), + width: 1.5, + ), ), ), - onChanged: (v) => - ref.read(addTransactionProvider(widget.initial).notifier).setNote(v.trim()), + onChanged: (v) => ref + .read(addTransactionProvider(widget.initial).notifier) + .setNote(v.trim()), ), const SizedBox(height: 32), @@ -508,7 +586,9 @@ class _AddTransactionScreenState extends ConsumerState backgroundColor: typeColor.withOpacity(0.1), side: BorderSide(color: typeColor, width: 2), padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), foregroundColor: typeColor, ), child: state.isSubmitting @@ -521,7 +601,9 @@ class _AddTransactionScreenState extends ConsumerState ), ) : Text( - state.isEditing ? s.saveChanges : s.addTransaction, + state.isEditing + ? s.saveChanges + : s.addTransaction, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -550,10 +632,10 @@ class _SectionLabel extends StatelessWidget { return Text( text, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), ); } } @@ -575,7 +657,9 @@ class _TypeToggle extends StatelessWidget { decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(14), - border: isDark ? null : Border.all(color: const Color(0xFFDDDDEE), width: 1), + border: isDark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1), ), child: Row( children: [ @@ -628,14 +712,24 @@ class _TypeOption extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, color: isSelected ? color : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), size: 18), + Icon( + icon, + color: isSelected + ? color + : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), + size: 18, + ), const SizedBox(width: 6), Text( label, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: isSelected ? color : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), + color: isSelected + ? color + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), ), ], ), @@ -672,23 +766,41 @@ class _CategoryPicker extends ConsumerWidget { duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: isSelected ? color.withOpacity(0.2) : Theme.of(context).colorScheme.surface, + color: isSelected + ? color.withOpacity(0.2) + : Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(12), border: isSelected ? Border.all(color: color, width: 1.5) - : (isDark ? null : Border.all(color: const Color(0xFFDDDDEE), width: 1)), + : (isDark + ? null + : Border.all(color: const Color(0xFFDDDDEE), width: 1)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, color: isSelected ? color : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), size: 16), + Icon( + icon, + color: isSelected + ? color + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + size: 16, + ), const SizedBox(width: 6), Text( s.categoryLabel(cat), style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: isSelected ? color : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), + color: isSelected + ? color + : Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.6), + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), ), ], ), @@ -702,10 +814,7 @@ class _CategoryPicker extends ConsumerWidget { class _CurrencyPicker extends StatelessWidget { final String selected; final void Function(String symbol, String code) onChanged; - const _CurrencyPicker({ - required this.selected, - required this.onChanged, - }); + const _CurrencyPicker({required this.selected, required this.onChanged}); @override Widget build(BuildContext context) { @@ -724,13 +833,19 @@ class _CurrencyPicker extends StatelessWidget { child: GestureDetector( onTap: () => onChanged(c.$2, c.$1), child: Container( - margin: EdgeInsets.only(right: c.$1 == currencies.last.$1 ? 0 : 8), + margin: EdgeInsets.only( + right: c.$1 == currencies.last.$1 ? 0 : 8, + ), padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( - color: isSelected ? const Color(0xFF7C6DED).withOpacity(0.15) : Theme.of(context).cardColor, + color: isSelected + ? const Color(0xFF7C6DED).withOpacity(0.15) + : Theme.of(context).cardColor, borderRadius: BorderRadius.circular(10), border: Border.all( - color: isSelected ? const Color(0xFF7C6DED) : Colors.transparent, + color: isSelected + ? const Color(0xFF7C6DED) + : Colors.transparent, width: 1.5, ), ), @@ -741,7 +856,9 @@ class _CurrencyPicker extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: isSelected ? const Color(0xFF7C6DED) : colorScheme.onSurface, + color: isSelected + ? const Color(0xFF7C6DED) + : colorScheme.onSurface, ), ), Text( diff --git a/lib/features/dashboard/provider.dart b/lib/features/dashboard/provider.dart index c26db0f..b9878c6 100644 --- a/lib/features/dashboard/provider.dart +++ b/lib/features/dashboard/provider.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../core/services/card_color_service.dart'; +import '../../core/utils/result.dart'; import '../../shared/models/transaction.dart'; import '../../shared/services/storage_service.dart'; import '../settings/provider.dart'; @@ -23,21 +24,28 @@ final transactionsProvider = class TransactionsNotifier extends StateNotifier> { final StorageService _storage; - TransactionsNotifier(this._storage) : super(_storage.loadTransactions()); + TransactionsNotifier(this._storage) + : super(_storage.loadTransactionsUnsafe()); - Future add(Transaction transaction) async { - await _storage.addTransaction(transaction); - state = _storage.loadTransactions(); + Future> add(Transaction transaction) async { + final result = await _storage.addTransaction(transaction); + return result.onSuccess((_) { + state = _storage.loadTransactionsUnsafe(); + }); } - Future update(Transaction transaction) async { - await _storage.updateTransaction(transaction); - state = _storage.loadTransactions(); + Future> update(Transaction transaction) async { + final result = await _storage.updateTransaction(transaction); + return result.onSuccess((_) { + state = _storage.loadTransactionsUnsafe(); + }); } - Future delete(String id) async { - await _storage.deleteTransaction(id); - state = _storage.loadTransactions(); + Future> delete(String id) async { + final result = await _storage.deleteTransaction(id); + return result.onSuccess((_) { + state = _storage.loadTransactionsUnsafe(); + }); } void restore(Transaction transaction) { diff --git a/lib/shared/services/storage_service.dart b/lib/shared/services/storage_service.dart index 21a8d0b..648e7e7 100644 --- a/lib/shared/services/storage_service.dart +++ b/lib/shared/services/storage_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; +import '../../core/utils/result.dart'; import '../models/transaction.dart'; const _uuid = Uuid(); @@ -15,50 +16,102 @@ class StorageService { StorageService(this._prefs); - List loadTransactions() { - final raw = _prefs.getString(_transactionsKey); - if (raw == null) return []; - final list = jsonDecode(raw) as List; - return list - .map((e) => Transaction.fromJson(e as Map)) - .toList(); + /// Load all transactions with error handling + Result> loadTransactions() { + return resultOf(() { + final raw = _prefs.getString(_transactionsKey); + if (raw == null) return []; + + final list = jsonDecode(raw) as List; + return list + .map((e) => Transaction.fromJson(e as Map)) + .toList(); + }); } - Future saveTransactions(List transactions) async { - final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList()); - await _prefs.setString(_transactionsKey, encoded); + /// Load transactions (legacy - throws on error) + List loadTransactionsUnsafe() { + final result = loadTransactions(); + return result.getOrDefault([]); } - Future addTransaction(Transaction transaction) async { - final list = loadTransactions(); - list.add(transaction); - await saveTransactions(list); + /// Save transactions with error handling + Future> saveTransactions(List transactions) async { + return asyncResultOf(() async { + final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList()); + await _prefs.setString(_transactionsKey, encoded); + }); } - Future updateTransaction(Transaction transaction) async { - final list = loadTransactions(); - final index = list.indexWhere((t) => t.id == transaction.id); - if (index != -1) { + /// Add transaction with error handling + Future> addTransaction(Transaction transaction) async { + return asyncResultOf(() async { + final listResult = loadTransactions(); + final list = listResult.getOrDefault([]); + list.add(transaction); + + final saveResult = await saveTransactions(list); + if (saveResult.isFailure) { + throw Exception(saveResult.errorOrNull); + } + }); + } + + /// Update transaction with error handling + Future> updateTransaction(Transaction transaction) async { + return asyncResultOf(() async { + final listResult = loadTransactions(); + final list = listResult.getOrDefault([]); + + final index = list.indexWhere((t) => t.id == transaction.id); + if (index == -1) { + throw Exception('Transaction not found: ${transaction.id}'); + } + list[index] = transaction; - await saveTransactions(list); - } + final saveResult = await saveTransactions(list); + if (saveResult.isFailure) { + throw Exception(saveResult.errorOrNull); + } + }); } - Future deleteTransaction(String id) async { - final list = loadTransactions()..removeWhere((t) => t.id == id); - await saveTransactions(list); + /// Delete transaction with error handling + Future> deleteTransaction(String id) async { + return asyncResultOf(() async { + final listResult = loadTransactions(); + final list = listResult.getOrDefault([]); + + final initialLength = list.length; + list.removeWhere((t) => t.id == id); + + if (list.length == initialLength) { + throw Exception('Transaction not found: $id'); + } + + final saveResult = await saveTransactions(list); + if (saveResult.isFailure) { + throw Exception(saveResult.errorOrNull); + } + }); } double? loadBudget() { return _prefs.getDouble(_budgetKey); } - Future saveBudget(double? budget) async { - if (budget == null) { - await _prefs.remove(_budgetKey); - } else { - await _prefs.setDouble(_budgetKey, budget); - } + /// Save budget with error handling + Future> 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() { @@ -78,7 +131,8 @@ class StorageService { } Future processRecurringTransactions() async { - final transactions = loadTransactions(); + final transactionsResult = loadTransactions(); + final transactions = transactionsResult.getOrDefault([]); final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); bool hasChanges = false; @@ -104,7 +158,8 @@ class StorageService { shouldCreate = daysDiff >= 7; break; case RecurrenceType.monthly: - shouldCreate = (today.year > lastDate.year || + shouldCreate = + (today.year > lastDate.year || (today.year == lastDate.year && today.month > lastDate.month)) && today.day >= lastDate.day; @@ -139,4 +194,80 @@ class StorageService { await saveTransactions(transactions); } } + + /// Process recurring transactions with error handling (returns count) + Future> processRecurringTransactionsWithResult() async { + return asyncResultOf(() async { + final transactionsResult = loadTransactions(); + final transactions = transactionsResult.getOrDefault([]); + + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + int createdCount = 0; + + for (final tx in transactions) { + if (tx.recurrence == RecurrenceType.none) continue; + + final lastOccurrence = tx.lastOccurrence ?? tx.date; + final lastDate = DateTime( + lastOccurrence.year, + lastOccurrence.month, + lastOccurrence.day, + ); + + bool shouldCreate = false; + + switch (tx.recurrence) { + case RecurrenceType.daily: + shouldCreate = today.isAfter(lastDate); + break; + case RecurrenceType.weekly: + final daysDiff = today.difference(lastDate).inDays; + shouldCreate = daysDiff >= 7; + break; + case RecurrenceType.monthly: + shouldCreate = + (today.year > lastDate.year || + (today.year == lastDate.year && + today.month > lastDate.month)) && + today.day >= lastDate.day; + break; + case RecurrenceType.none: + break; + } + + if (shouldCreate) { + final newTx = Transaction( + id: _uuid.v4(), + amount: tx.amount, + category: tx.category, + type: tx.type, + date: today, + note: tx.note, + recurrence: tx.recurrence, + lastOccurrence: today, + currency: tx.currency, + currencyCode: tx.currencyCode, + ); + transactions.add(newTx); + + final index = transactions.indexWhere((t) => t.id == tx.id); + if (index != -1) { + transactions[index] = tx.copyWith(lastOccurrence: today); + } + + createdCount++; + } + } + + if (createdCount > 0) { + final saveResult = await saveTransactions(transactions); + if (saveResult.isFailure) { + throw Exception('Failed to save recurring transactions'); + } + } + + return createdCount; + }); + } } diff --git a/lib/shared/widgets/error_snackbar.dart b/lib/shared/widgets/error_snackbar.dart new file mode 100644 index 0000000..fabe8a0 --- /dev/null +++ b/lib/shared/widgets/error_snackbar.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import '../../core/utils/result.dart'; + +/// Show error snackbar with custom styling +void showErrorSnackbar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(message, style: const TextStyle(fontSize: 14))), + ], + ), + backgroundColor: const Color(0xFFE05C6B), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + duration: const Duration(seconds: 4), + action: SnackBarAction( + label: 'OK', + textColor: Colors.white, + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + ), + ); +} + +/// Show success snackbar with custom styling +void showSuccessSnackbar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle_outline, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(message, style: const TextStyle(fontSize: 14))), + ], + ), + backgroundColor: const Color(0xFF4CAF8C), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + duration: const Duration(seconds: 2), + ), + ); +} + +/// Show warning snackbar with custom styling +void showWarningSnackbar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text(message, style: const TextStyle(fontSize: 14))), + ], + ), + backgroundColor: const Color(0xFFFFB74D), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + margin: const EdgeInsets.all(16), + duration: const Duration(seconds: 3), + ), + ); +} + +/// Extension to handle Result with UI feedback +extension ResultUIExtension on Result { + /// Show snackbar on failure + Result showErrorOnFailure(BuildContext context) { + onFailure((message) => showErrorSnackbar(context, message)); + return this; + } + + /// Show snackbar on success with custom message + Result showSuccessMessage(BuildContext context, String message) { + onSuccess((_) => showSuccessSnackbar(context, message)); + return this; + } +} + +/// Extension for Future> +extension FutureResultUIExtension on Future> { + /// Show snackbar on failure + Future> showErrorOnFailure(BuildContext context) async { + final result = await this; + result.onFailure((message) => showErrorSnackbar(context, message)); + return result; + } + + /// Show snackbar on success with custom message + Future> showSuccessMessage( + BuildContext context, + String message, + ) async { + final result = await this; + result.onSuccess((_) => showSuccessSnackbar(context, message)); + return result; + } + + /// Show both success and error messages + Future> showFeedback( + BuildContext context, { + required String successMessage, + }) async { + final result = await this; + result + .onSuccess((_) => showSuccessSnackbar(context, successMessage)) + .onFailure((message) => showErrorSnackbar(context, message)); + return result; + } +} + +/// Error dialog widget +class ErrorDialog extends StatelessWidget { + final String title; + final String message; + final VoidCallback? onRetry; + + const ErrorDialog({ + super.key, + required this.title, + required this.message, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + const Icon(Icons.error_outline, color: Color(0xFFE05C6B), size: 28), + const SizedBox(width: 12), + Text(title), + ], + ), + content: Text(message), + actions: [ + if (onRetry != null) + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onRetry!(); + }, + child: const Text('Retry'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ); + } + + static Future show( + BuildContext context, { + required String title, + required String message, + VoidCallback? onRetry, + }) { + return showDialog( + context: context, + builder: (context) => + ErrorDialog(title: title, message: message, onRetry: onRetry), + ); + } +}