This commit is contained in:
2026-03-22 17:38:38 +03:00
parent d67d8cb89c
commit 3e4b4db50c
5 changed files with 739 additions and 146 deletions
+165
View File
@@ -0,0 +1,165 @@
/// Result type for handling success and failure cases
/// Uses sealed classes for exhaustive pattern matching
sealed class Result<T> {
const Result();
/// Check if result is success
bool get isSuccess => this is Success<T>;
/// Check if result is failure
bool get isFailure => this is Failure<T>;
/// 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<R> map<R>(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<T> onSuccess(void Function(T data) callback) {
if (this case Success(data: final data)) {
callback(data);
}
return this;
}
/// Execute callback on failure
Result<T> 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<T> extends Result<T> {
final T data;
const Success(this.data);
@override
String toString() => 'Success($data)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Success<T> &&
runtimeType == other.runtimeType &&
data == other.data;
@override
int get hashCode => data.hashCode;
}
/// Failure result containing error message and optional exception
class Failure<T> extends Result<T> {
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<T> &&
runtimeType == other.runtimeType &&
message == other.message &&
exception == other.exception;
@override
int get hashCode => message.hashCode ^ exception.hashCode;
}
/// Extension for Future<Result<T>>
extension FutureResultExtension<T> on Future<Result<T>> {
/// Map async result
Future<Result<R>> mapAsync<R>(R Function(T data) transform) async {
final result = await this;
return result.map(transform);
}
/// Execute callback on success
Future<Result<T>> onSuccessAsync(
Future<void> 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<Result<T>> onFailureAsync(
Future<void> 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<T> resultOf<T>(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<Result<T>> asyncResultOf<T>(Future<T> Function() computation) async {
try {
final data = await computation();
return Success(data);
} catch (e) {
return Failure(e.toString(), e is Exception ? e : Exception(e.toString()));
}
}
+187 -70
View File
@@ -9,6 +9,7 @@ import '../../core/l10n/app_strings.dart';
import '../../core/l10n/locale_provider.dart'; import '../../core/l10n/locale_provider.dart';
import '../../core/services/haptic_service.dart'; import '../../core/services/haptic_service.dart';
import '../../shared/models/transaction.dart'; import '../../shared/models/transaction.dart';
import '../../shared/widgets/error_snackbar.dart';
import '../dashboard/provider.dart'; import '../dashboard/provider.dart';
import '../settings/provider.dart'; import '../settings/provider.dart';
import 'provider.dart'; import 'provider.dart';
@@ -52,10 +53,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
_borderColorAnimation = ColorTween( _borderColorAnimation = ColorTween(
begin: const Color(0xFFE05C6B), begin: const Color(0xFFE05C6B),
end: Colors.transparent, end: Colors.transparent,
).animate(CurvedAnimation( ).animate(CurvedAnimation(parent: _shakeController, curve: Curves.easeOut));
parent: _shakeController,
curve: Curves.easeOut,
));
_shakeController.addStatusListener((status) { _shakeController.addStatusListener((status) {
if (status == AnimationStatus.completed) { if (status == AnimationStatus.completed) {
if (mounted) setState(() => _showError = false); if (mounted) setState(() => _showError = false);
@@ -67,7 +65,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
} else { } else {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final curr = ref.read(currencyProvider); 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);
}); });
} }
} }
@@ -129,10 +129,16 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
); );
ref.read(addTransactionProvider(widget.initial).notifier).setAmount(amount); ref.read(addTransactionProvider(widget.initial).notifier).setAmount(amount);
ref.read(addTransactionProvider(widget.initial).notifier).setDate(finalDateTime); ref
ref.read(addTransactionProvider(widget.initial).notifier).setSubmitting(true); .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 note = _noteController.text.trim().isEmpty
? null
: _noteController.text.trim();
final tx = Transaction( final tx = Transaction(
id: state.editingId ?? _uuid.v4(), id: state.editingId ?? _uuid.v4(),
@@ -151,7 +157,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
await ref.read(transactionsProvider.notifier).add(tx); 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(); HapticService.medium();
@@ -166,9 +174,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
lastDate: DateTime.now(), lastDate: DateTime.now(),
builder: (context, child) => Theme( builder: (context, child) => Theme(
data: Theme.of(context).copyWith( data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith( colorScheme: Theme.of(
primary: AppColors.accent, context,
), ).colorScheme.copyWith(primary: AppColors.accent),
), ),
child: child!, child: child!,
), ),
@@ -193,9 +201,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
colorScheme: Theme.of(context).colorScheme.copyWith( colorScheme: Theme.of(
primary: const Color(0xFF7C6DED), context,
), ).colorScheme.copyWith(primary: const Color(0xFF7C6DED)),
), ),
child: child!, child: child!,
), ),
@@ -239,7 +247,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
ref.read(transactionsProvider.notifier).delete(widget.initial!.id); ref
.read(transactionsProvider.notifier)
.delete(widget.initial!.id);
Navigator.pop(ctx); Navigator.pop(ctx);
context.pop(); context.pop();
}, },
@@ -264,8 +274,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
_TypeToggle( _TypeToggle(
selected: state.type, selected: state.type,
strings: s, strings: s,
onChanged: (t) => onChanged: (t) => ref
ref.read(addTransactionProvider(widget.initial).notifier).setType(t), .read(addTransactionProvider(widget.initial).notifier)
.setType(t),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -286,7 +297,10 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12), 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( child: Row(
children: [ children: [
@@ -294,8 +308,11 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: 14),
child: Text( child: Text(
overrideCurrency, overrideCurrency,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), ?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.7),
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@@ -303,9 +320,14 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
Expanded( Expanded(
child: TextField( child: TextField(
controller: _amountController, controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
style: Theme.of(context).textTheme.headlineSmall?.copyWith( decimal: true,
color: Theme.of(context).colorScheme.onSurface, ),
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -316,11 +338,19 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
errorBorder: InputBorder.none, errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none,
filled: false, filled: false,
contentPadding: EdgeInsets.symmetric(vertical: 14), contentPadding: EdgeInsets.symmetric(
vertical: 14,
),
), ),
onChanged: (v) { onChanged: (v) {
final parsed = double.tryParse(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<AddTransactionScreen>
s.currency, s.currency,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_CurrencyPicker( _CurrencyPicker(
selected: state.overrideCurrencyCode, selected: state.overrideCurrencyCode,
onChanged: (symbol, code) => onChanged: (symbol, code) => ref
ref.read(addTransactionProvider(widget.initial).notifier).setCurrency(symbol, code), .read(addTransactionProvider(widget.initial).notifier)
.setCurrency(symbol, code),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -351,8 +384,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
_CategoryPicker( _CategoryPicker(
categories: categories, categories: categories,
selected: state.category, selected: state.category,
onChanged: (c) => onChanged: (c) => ref
ref.read(addTransactionProvider(widget.initial).notifier).setCategory(c), .read(addTransactionProvider(widget.initial).notifier)
.setCategory(c),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -365,8 +399,11 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
children: [ children: [
Text( Text(
s.date, s.date,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), ?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
@@ -374,27 +411,43 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
GestureDetector( GestureDetector(
onTap: _pickDate, onTap: _pickDate,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: isDark border: isDark
? null ? null
: Border.all(color: const Color(0xFFCCCCDD), width: 1), : Border.all(
color: const Color(0xFFCCCCDD),
width: 1,
),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Icons.calendar_today_rounded, Icons.calendar_today_rounded,
size: 16, size: 16,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
DateFormat('MMM d, yyyy', s.dateLocale).format(_selectedDate), DateFormat(
style: Theme.of(context).textTheme.bodyMedium?.copyWith( 'MMM d, yyyy',
color: Theme.of(context).colorScheme.onSurface, s.dateLocale,
).format(_selectedDate),
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -414,8 +467,11 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
children: [ children: [
Text( Text(
s.time, s.time,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), ?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
@@ -423,26 +479,37 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
GestureDetector( GestureDetector(
onTap: _pickTime, onTap: _pickTime,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: isDark border: isDark
? null ? null
: Border.all(color: const Color(0xFFCCCCDD), width: 1), : Border.all(
color: const Color(0xFFCCCCDD),
width: 1,
),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Icons.access_time_rounded, Icons.access_time_rounded,
size: 16, size: 16,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
_selectedTime.format(context), _selectedTime.format(context),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium
color: Theme.of(context).colorScheme.onSurface, ?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
@@ -464,11 +531,18 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
maxLines: 2, maxLines: 2,
maxLength: 20, maxLength: 20,
maxLengthEnforcement: MaxLengthEnforcement.enforced, maxLengthEnforcement: MaxLengthEnforcement.enforced,
buildCounter: (context, {required currentLength, required isFocused, maxLength}) => buildCounter:
Text( (
context, {
required currentLength,
required isFocused,
maxLength,
}) => Text(
'$currentLength/$maxLength', '$currentLength/$maxLength',
style: Theme.of(context).textTheme.bodySmall?.copyWith( 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, fontSize: 11,
), ),
), ),
@@ -482,11 +556,15 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), 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), const SizedBox(height: 32),
@@ -508,7 +586,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
backgroundColor: typeColor.withOpacity(0.1), backgroundColor: typeColor.withOpacity(0.1),
side: BorderSide(color: typeColor, width: 2), side: BorderSide(color: typeColor, width: 2),
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
foregroundColor: typeColor, foregroundColor: typeColor,
), ),
child: state.isSubmitting child: state.isSubmitting
@@ -521,7 +601,9 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
), ),
) )
: Text( : Text(
state.isEditing ? s.saveChanges : s.addTransaction, state.isEditing
? s.saveChanges
: s.addTransaction,
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -575,7 +657,9 @@ class _TypeToggle extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(14), 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( child: Row(
children: [ children: [
@@ -628,12 +712,22 @@ class _TypeOption extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(width: 6),
Text( Text(
label, label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isSelected ? color : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), color: isSelected
? color
: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.6),
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
), ),
), ),
@@ -672,22 +766,40 @@ class _CategoryPicker extends ConsumerWidget {
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration( 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), borderRadius: BorderRadius.circular(12),
border: isSelected border: isSelected
? Border.all(color: color, width: 1.5) ? 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( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(width: 6),
Text( Text(
s.categoryLabel(cat), s.categoryLabel(cat),
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isSelected ? color : Theme.of(context).colorScheme.onSurface.withOpacity(0.6), color: isSelected
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ? 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 { class _CurrencyPicker extends StatelessWidget {
final String selected; final String selected;
final void Function(String symbol, String code) onChanged; final void Function(String symbol, String code) onChanged;
const _CurrencyPicker({ const _CurrencyPicker({required this.selected, required this.onChanged});
required this.selected,
required this.onChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -724,13 +833,19 @@ class _CurrencyPicker extends StatelessWidget {
child: GestureDetector( child: GestureDetector(
onTap: () => onChanged(c.$2, c.$1), onTap: () => onChanged(c.$2, c.$1),
child: Container( 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), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration( 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), borderRadius: BorderRadius.circular(10),
border: Border.all( border: Border.all(
color: isSelected ? const Color(0xFF7C6DED) : Colors.transparent, color: isSelected
? const Color(0xFF7C6DED)
: Colors.transparent,
width: 1.5, width: 1.5,
), ),
), ),
@@ -741,7 +856,9 @@ class _CurrencyPicker extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isSelected ? const Color(0xFF7C6DED) : colorScheme.onSurface, color: isSelected
? const Color(0xFF7C6DED)
: colorScheme.onSurface,
), ),
), ),
Text( Text(
+18 -10
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../core/services/card_color_service.dart'; import '../../core/services/card_color_service.dart';
import '../../core/utils/result.dart';
import '../../shared/models/transaction.dart'; import '../../shared/models/transaction.dart';
import '../../shared/services/storage_service.dart'; import '../../shared/services/storage_service.dart';
import '../settings/provider.dart'; import '../settings/provider.dart';
@@ -23,21 +24,28 @@ final transactionsProvider =
class TransactionsNotifier extends StateNotifier<List<Transaction>> { class TransactionsNotifier extends StateNotifier<List<Transaction>> {
final StorageService _storage; final StorageService _storage;
TransactionsNotifier(this._storage) : super(_storage.loadTransactions()); TransactionsNotifier(this._storage)
: super(_storage.loadTransactionsUnsafe());
Future<void> add(Transaction transaction) async { Future<Result<void>> add(Transaction transaction) async {
await _storage.addTransaction(transaction); final result = await _storage.addTransaction(transaction);
state = _storage.loadTransactions(); return result.onSuccess((_) {
state = _storage.loadTransactionsUnsafe();
});
} }
Future<void> update(Transaction transaction) async { Future<Result<void>> update(Transaction transaction) async {
await _storage.updateTransaction(transaction); final result = await _storage.updateTransaction(transaction);
state = _storage.loadTransactions(); return result.onSuccess((_) {
state = _storage.loadTransactionsUnsafe();
});
} }
Future<void> delete(String id) async { Future<Result<void>> delete(String id) async {
await _storage.deleteTransaction(id); final result = await _storage.deleteTransaction(id);
state = _storage.loadTransactions(); return result.onSuccess((_) {
state = _storage.loadTransactionsUnsafe();
});
} }
void restore(Transaction transaction) { void restore(Transaction transaction) {
+149 -18
View File
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../../core/utils/result.dart';
import '../models/transaction.dart'; import '../models/transaction.dart';
const _uuid = Uuid(); const _uuid = Uuid();
@@ -15,50 +16,102 @@ class StorageService {
StorageService(this._prefs); StorageService(this._prefs);
List<Transaction> loadTransactions() { /// Load all transactions with error handling
Result<List<Transaction>> loadTransactions() {
return resultOf(() {
final raw = _prefs.getString(_transactionsKey); final raw = _prefs.getString(_transactionsKey);
if (raw == null) return []; if (raw == null) return <Transaction>[];
final list = jsonDecode(raw) as List<dynamic>; final list = jsonDecode(raw) as List<dynamic>;
return list return list
.map((e) => Transaction.fromJson(e as Map<String, dynamic>)) .map((e) => Transaction.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
});
} }
Future<void> saveTransactions(List<Transaction> transactions) async { /// Load transactions (legacy - throws on error)
List<Transaction> loadTransactionsUnsafe() {
final result = loadTransactions();
return result.getOrDefault([]);
}
/// Save transactions with error handling
Future<Result<void>> saveTransactions(List<Transaction> transactions) async {
return asyncResultOf(() async {
final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList()); final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList());
await _prefs.setString(_transactionsKey, encoded); await _prefs.setString(_transactionsKey, encoded);
});
} }
Future<void> addTransaction(Transaction transaction) async { /// Add transaction with error handling
final list = loadTransactions(); Future<Result<void>> addTransaction(Transaction transaction) async {
return asyncResultOf(() async {
final listResult = loadTransactions();
final list = listResult.getOrDefault([]);
list.add(transaction); list.add(transaction);
await saveTransactions(list);
final saveResult = await saveTransactions(list);
if (saveResult.isFailure) {
throw Exception(saveResult.errorOrNull);
}
});
} }
Future<void> updateTransaction(Transaction transaction) async { /// Update transaction with error handling
final list = loadTransactions(); Future<Result<void>> updateTransaction(Transaction transaction) async {
return asyncResultOf(() async {
final listResult = loadTransactions();
final list = listResult.getOrDefault([]);
final index = list.indexWhere((t) => t.id == transaction.id); final index = list.indexWhere((t) => t.id == transaction.id);
if (index != -1) { if (index == -1) {
list[index] = transaction; throw Exception('Transaction not found: ${transaction.id}');
await saveTransactions(list);
}
} }
Future<void> deleteTransaction(String id) async { list[index] = transaction;
final list = loadTransactions()..removeWhere((t) => t.id == id); final saveResult = await saveTransactions(list);
await saveTransactions(list); if (saveResult.isFailure) {
throw Exception(saveResult.errorOrNull);
}
});
}
/// Delete transaction with error handling
Future<Result<void>> 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() { double? loadBudget() {
return _prefs.getDouble(_budgetKey); return _prefs.getDouble(_budgetKey);
} }
Future<void> saveBudget(double? budget) async { /// Save budget with error handling
Future<Result<void>> saveBudget(double? budget) async {
return asyncResultOf(() async {
if (budget == null) { if (budget == null) {
await _prefs.remove(_budgetKey); await _prefs.remove(_budgetKey);
} else { } else {
if (budget < 0) {
throw Exception('Budget cannot be negative');
}
await _prefs.setDouble(_budgetKey, budget); await _prefs.setDouble(_budgetKey, budget);
} }
});
} }
String loadCurrency() { String loadCurrency() {
@@ -78,7 +131,8 @@ class StorageService {
} }
Future<void> processRecurringTransactions() async { Future<void> processRecurringTransactions() async {
final transactions = loadTransactions(); final transactionsResult = loadTransactions();
final transactions = transactionsResult.getOrDefault([]);
final now = DateTime.now(); final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day); final today = DateTime(now.year, now.month, now.day);
bool hasChanges = false; bool hasChanges = false;
@@ -104,7 +158,8 @@ class StorageService {
shouldCreate = daysDiff >= 7; shouldCreate = daysDiff >= 7;
break; break;
case RecurrenceType.monthly: case RecurrenceType.monthly:
shouldCreate = (today.year > lastDate.year || shouldCreate =
(today.year > lastDate.year ||
(today.year == lastDate.year && (today.year == lastDate.year &&
today.month > lastDate.month)) && today.month > lastDate.month)) &&
today.day >= lastDate.day; today.day >= lastDate.day;
@@ -139,4 +194,80 @@ class StorageService {
await saveTransactions(transactions); await saveTransactions(transactions);
} }
} }
/// Process recurring transactions with error handling (returns count)
Future<Result<int>> 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;
});
}
} }
+172
View File
@@ -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<T> on Result<T> {
/// Show snackbar on failure
Result<T> showErrorOnFailure(BuildContext context) {
onFailure((message) => showErrorSnackbar(context, message));
return this;
}
/// Show snackbar on success with custom message
Result<T> showSuccessMessage(BuildContext context, String message) {
onSuccess((_) => showSuccessSnackbar(context, message));
return this;
}
}
/// Extension for Future<Result<T>>
extension FutureResultUIExtension<T> on Future<Result<T>> {
/// Show snackbar on failure
Future<Result<T>> showErrorOnFailure(BuildContext context) async {
final result = await this;
result.onFailure((message) => showErrorSnackbar(context, message));
return result;
}
/// Show snackbar on success with custom message
Future<Result<T>> showSuccessMessage(
BuildContext context,
String message,
) async {
final result = await this;
result.onSuccess((_) => showSuccessSnackbar(context, message));
return result;
}
/// Show both success and error messages
Future<Result<T>> 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<void> show(
BuildContext context, {
required String title,
required String message,
VoidCallback? onRetry,
}) {
return showDialog(
context: context,
builder: (context) =>
ErrorDialog(title: title, message: message, onRetry: onRetry),
);
}
}