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()));
}
}
+222 -105
View File
@@ -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<AddTransactionScreen>
_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<AddTransactionScreen>
_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<AddTransactionScreen>
} 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<AddTransactionScreen>
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<AddTransactionScreen>
Future<void> _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<AddTransactionScreen>
_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<AddTransactionScreen>
} 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<AddTransactionScreen>
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<AddTransactionScreen>
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<AddTransactionScreen>
),
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<AddTransactionScreen>
_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<AddTransactionScreen>
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<AddTransactionScreen>
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<AddTransactionScreen>
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<AddTransactionScreen>
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<AddTransactionScreen>
_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<AddTransactionScreen>
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<AddTransactionScreen>
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<AddTransactionScreen>
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<AddTransactionScreen>
),
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<AddTransactionScreen>
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<AddTransactionScreen>
),
)
: 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(
+18 -10
View File
@@ -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<List<Transaction>> {
final StorageService _storage;
TransactionsNotifier(this._storage) : super(_storage.loadTransactions());
TransactionsNotifier(this._storage)
: super(_storage.loadTransactionsUnsafe());
Future<void> add(Transaction transaction) async {
await _storage.addTransaction(transaction);
state = _storage.loadTransactions();
Future<Result<void>> add(Transaction transaction) async {
final result = await _storage.addTransaction(transaction);
return result.onSuccess((_) {
state = _storage.loadTransactionsUnsafe();
});
}
Future<void> update(Transaction transaction) async {
await _storage.updateTransaction(transaction);
state = _storage.loadTransactions();
Future<Result<void>> update(Transaction transaction) async {
final result = await _storage.updateTransaction(transaction);
return result.onSuccess((_) {
state = _storage.loadTransactionsUnsafe();
});
}
Future<void> delete(String id) async {
await _storage.deleteTransaction(id);
state = _storage.loadTransactions();
Future<Result<void>> delete(String id) async {
final result = await _storage.deleteTransaction(id);
return result.onSuccess((_) {
state = _storage.loadTransactionsUnsafe();
});
}
void restore(Transaction transaction) {
+162 -31
View File
@@ -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<Transaction> loadTransactions() {
final raw = _prefs.getString(_transactionsKey);
if (raw == null) return [];
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => Transaction.fromJson(e as Map<String, dynamic>))
.toList();
/// Load all transactions with error handling
Result<List<Transaction>> loadTransactions() {
return resultOf(() {
final raw = _prefs.getString(_transactionsKey);
if (raw == null) return <Transaction>[];
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => Transaction.fromJson(e as Map<String, dynamic>))
.toList();
});
}
Future<void> saveTransactions(List<Transaction> transactions) async {
final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList());
await _prefs.setString(_transactionsKey, encoded);
/// Load transactions (legacy - throws on error)
List<Transaction> loadTransactionsUnsafe() {
final result = loadTransactions();
return result.getOrDefault([]);
}
Future<void> addTransaction(Transaction transaction) async {
final list = loadTransactions();
list.add(transaction);
await saveTransactions(list);
/// 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());
await _prefs.setString(_transactionsKey, encoded);
});
}
Future<void> 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<Result<void>> 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<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);
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<void> deleteTransaction(String id) async {
final list = loadTransactions()..removeWhere((t) => t.id == id);
await saveTransactions(list);
/// 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() {
return _prefs.getDouble(_budgetKey);
}
Future<void> saveBudget(double? budget) async {
if (budget == null) {
await _prefs.remove(_budgetKey);
} else {
await _prefs.setDouble(_budgetKey, budget);
}
/// Save budget with error handling
Future<Result<void>> saveBudget(double? budget) async {
return asyncResultOf(() async {
if (budget == null) {
await _prefs.remove(_budgetKey);
} else {
if (budget < 0) {
throw Exception('Budget cannot be negative');
}
await _prefs.setDouble(_budgetKey, budget);
}
});
}
String loadCurrency() {
@@ -78,7 +131,8 @@ class StorageService {
}
Future<void> 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<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),
);
}
}