mirror of
https://github.com/koloideal/Casha.git
synced 2026-06-10 10:25:28 +03:00
stableee
This commit is contained in:
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -17,7 +18,7 @@ const _uuid = Uuid();
|
|||||||
|
|
||||||
class AddTransactionScreen extends ConsumerStatefulWidget {
|
class AddTransactionScreen extends ConsumerStatefulWidget {
|
||||||
final Transaction? initial;
|
final Transaction? initial;
|
||||||
|
|
||||||
const AddTransactionScreen({super.key, this.initial});
|
const AddTransactionScreen({super.key, this.initial});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -44,7 +45,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
|
|||||||
_selectedTime = widget.initial != null
|
_selectedTime = widget.initial != null
|
||||||
? TimeOfDay.fromDateTime(widget.initial!.date)
|
? TimeOfDay.fromDateTime(widget.initial!.date)
|
||||||
: TimeOfDay(hour: now.hour, minute: now.minute);
|
: TimeOfDay(hour: now.hour, minute: now.minute);
|
||||||
|
|
||||||
_shakeController = AnimationController(
|
_shakeController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 5000),
|
duration: const Duration(milliseconds: 5000),
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,24 +82,24 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
|
|||||||
|
|
||||||
String? _validateAndParseAmount(String raw) {
|
String? _validateAndParseAmount(String raw) {
|
||||||
final trimmed = raw.trim();
|
final trimmed = raw.trim();
|
||||||
|
|
||||||
if (trimmed.isEmpty) return null;
|
if (trimmed.isEmpty) return null;
|
||||||
|
|
||||||
final normalized = trimmed.replaceAll(',', '.');
|
final normalized = trimmed.replaceAll(',', '.');
|
||||||
|
|
||||||
final validPattern = RegExp(r'^\d+\.?\d*$');
|
final validPattern = RegExp(r'^\d+\.?\d*$');
|
||||||
if (!validPattern.hasMatch(normalized)) return null;
|
if (!validPattern.hasMatch(normalized)) return null;
|
||||||
|
|
||||||
final value = double.tryParse(normalized);
|
final value = double.tryParse(normalized);
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
|
|
||||||
if (value <= 0) return null;
|
if (value <= 0) return null;
|
||||||
|
|
||||||
if (value > 999_999_999) return null;
|
if (value > 999_999_999) return null;
|
||||||
|
|
||||||
final parts = normalized.split('.');
|
final parts = normalized.split('.');
|
||||||
if (parts.length == 2 && parts[1].length > 2) return null;
|
if (parts.length == 2 && parts[1].length > 2) return null;
|
||||||
|
|
||||||
return normalized; // valid, return normalized string
|
return normalized; // valid, return normalized string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,15 +111,15 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
|
|||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
final raw = _amountController.text;
|
final raw = _amountController.text;
|
||||||
final parsed = _validateAndParseAmount(raw);
|
final parsed = _validateAndParseAmount(raw);
|
||||||
|
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
_triggerError();
|
_triggerError();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final amount = double.parse(parsed);
|
final amount = double.parse(parsed);
|
||||||
final state = ref.read(addTransactionProvider(widget.initial));
|
final state = ref.read(addTransactionProvider(widget.initial));
|
||||||
|
|
||||||
final finalDateTime = DateTime(
|
final finalDateTime = DateTime(
|
||||||
_selectedDate.year,
|
_selectedDate.year,
|
||||||
_selectedDate.month,
|
_selectedDate.month,
|
||||||
@@ -127,12 +127,18 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
|
|||||||
_selectedTime.hour,
|
_selectedTime.hour,
|
||||||
_selectedTime.minute,
|
_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(
|
final tx = Transaction(
|
||||||
id: state.editingId ?? _uuid.v4(),
|
id: state.editingId ?? _uuid.v4(),
|
||||||
@@ -150,9 +156,11 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
|
|||||||
} else {
|
} else {
|
||||||
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();
|
||||||
|
|
||||||
if (mounted) context.pop();
|
if (mounted) context.pop();
|
||||||
@@ -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,20 +308,28 @@ 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(
|
||||||
fontWeight: FontWeight.w600,
|
color: Theme.of(
|
||||||
),
|
context,
|
||||||
|
).colorScheme.onSurface.withOpacity(0.7),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
),
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: '0.00',
|
hintText: '0.00',
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
@@ -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,38 +399,57 @@ 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(
|
||||||
fontWeight: FontWeight.w500,
|
color: Theme.of(
|
||||||
),
|
context,
|
||||||
|
).colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
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,
|
||||||
fontWeight: FontWeight.w500,
|
).format(_selectedDate),
|
||||||
),
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -414,37 +467,51 @@ 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(
|
||||||
fontWeight: FontWeight.w500,
|
color: Theme.of(
|
||||||
),
|
context,
|
||||||
|
).colorScheme.onSurface.withOpacity(0.6),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
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(
|
||||||
fontWeight: FontWeight.w500,
|
color: Theme.of(
|
||||||
),
|
context,
|
||||||
|
).colorScheme.onSurface,
|
||||||
|
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) =>
|
onChanged: (v) => ref
|
||||||
ref.read(addTransactionProvider(widget.initial).notifier).setNote(v.trim()),
|
.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,
|
||||||
@@ -550,10 +632,10 @@ class _SectionLabel extends StatelessWidget {
|
|||||||
return Text(
|
return Text(
|
||||||
text,
|
text,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,14 +712,24 @@ 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
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
? 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),
|
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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
final raw = _prefs.getString(_transactionsKey);
|
Result<List<Transaction>> loadTransactions() {
|
||||||
if (raw == null) return [];
|
return resultOf(() {
|
||||||
final list = jsonDecode(raw) as List<dynamic>;
|
final raw = _prefs.getString(_transactionsKey);
|
||||||
return list
|
if (raw == null) return <Transaction>[];
|
||||||
.map((e) => Transaction.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
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 {
|
/// Load transactions (legacy - throws on error)
|
||||||
final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList());
|
List<Transaction> loadTransactionsUnsafe() {
|
||||||
await _prefs.setString(_transactionsKey, encoded);
|
final result = loadTransactions();
|
||||||
|
return result.getOrDefault([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addTransaction(Transaction transaction) async {
|
/// Save transactions with error handling
|
||||||
final list = loadTransactions();
|
Future<Result<void>> saveTransactions(List<Transaction> transactions) async {
|
||||||
list.add(transaction);
|
return asyncResultOf(() async {
|
||||||
await saveTransactions(list);
|
final encoded = jsonEncode(transactions.map((t) => t.toJson()).toList());
|
||||||
|
await _prefs.setString(_transactionsKey, encoded);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateTransaction(Transaction transaction) async {
|
/// Add transaction with error handling
|
||||||
final list = loadTransactions();
|
Future<Result<void>> addTransaction(Transaction transaction) async {
|
||||||
final index = list.indexWhere((t) => t.id == transaction.id);
|
return asyncResultOf(() async {
|
||||||
if (index != -1) {
|
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;
|
list[index] = transaction;
|
||||||
await saveTransactions(list);
|
final saveResult = await saveTransactions(list);
|
||||||
}
|
if (saveResult.isFailure) {
|
||||||
|
throw Exception(saveResult.errorOrNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteTransaction(String id) async {
|
/// Delete transaction with error handling
|
||||||
final list = loadTransactions()..removeWhere((t) => t.id == id);
|
Future<Result<void>> deleteTransaction(String id) async {
|
||||||
await saveTransactions(list);
|
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
|
||||||
if (budget == null) {
|
Future<Result<void>> saveBudget(double? budget) async {
|
||||||
await _prefs.remove(_budgetKey);
|
return asyncResultOf(() async {
|
||||||
} else {
|
if (budget == null) {
|
||||||
await _prefs.setDouble(_budgetKey, budget);
|
await _prefs.remove(_budgetKey);
|
||||||
}
|
} else {
|
||||||
|
if (budget < 0) {
|
||||||
|
throw Exception('Budget cannot be negative');
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user