This commit is contained in:
2026-03-21 00:31:24 +03:00
parent 08fa3294e8
commit 340cf0a282
2 changed files with 142 additions and 63 deletions
+141 -62
View File
@@ -22,19 +22,33 @@ class AddTransactionScreen extends ConsumerStatefulWidget {
_AddTransactionScreenState(); _AddTransactionScreenState();
} }
class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> { class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen>
with SingleTickerProviderStateMixin {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _amountController = TextEditingController(); final _amountController = TextEditingController();
final _noteController = TextEditingController(); final _noteController = TextEditingController();
late FocusNode _amountFocusNode; late AnimationController _shakeController;
bool _amountFocused = false; late Animation<Color?> _borderColorAnimation;
bool _showError = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_amountFocusNode = FocusNode(); _shakeController = AnimationController(
_amountFocusNode.addListener(() { vsync: this,
setState(() => _amountFocused = _amountFocusNode.hasFocus); duration: const Duration(milliseconds: 5000),
);
_borderColorAnimation = ColorTween(
begin: const Color(0xFFE05C6B),
end: Colors.transparent,
).animate(CurvedAnimation(
parent: _shakeController,
curve: Curves.easeOut,
));
_shakeController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
if (mounted) setState(() => _showError = false);
}
}); });
if (widget.initial != null) { if (widget.initial != null) {
_amountController.text = widget.initial!.amount.toString(); _amountController.text = widget.initial!.amount.toString();
@@ -51,24 +65,69 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
void dispose() { void dispose() {
_amountController.dispose(); _amountController.dispose();
_noteController.dispose(); _noteController.dispose();
_amountFocusNode.dispose(); _shakeController.dispose();
super.dispose(); super.dispose();
} }
String? _validateAndParseAmount(String raw) {
// trim whitespace
final trimmed = raw.trim();
// empty check
if (trimmed.isEmpty) return null; // returns null = invalid, show dialog
// replace comma with dot for European locale input
final normalized = trimmed.replaceAll(',', '.');
// only digits and one dot allowed
final validPattern = RegExp(r'^\d+\.?\d*$');
if (!validPattern.hasMatch(normalized)) return null;
// parse
final value = double.tryParse(normalized);
if (value == null) return null;
// must be greater than zero
if (value <= 0) return null;
// must not exceed reasonable max (prevent overflow)
if (value > 999_999_999) return null;
// must not have more than 2 decimal places
final parts = normalized.split('.');
if (parts.length == 2 && parts[1].length > 2) return null;
return normalized; // valid, return normalized string
}
void _triggerError() {
setState(() => _showError = true);
_shakeController.forward(from: 0);
}
Future<void> _submit() async { Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return; 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 state = ref.read(addTransactionProvider(widget.initial));
ref.read(addTransactionProvider(widget.initial).notifier).setAmount(amount);
ref.read(addTransactionProvider(widget.initial).notifier).setSubmitting(true); ref.read(addTransactionProvider(widget.initial).notifier).setSubmitting(true);
final note = _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(),
amount: state.amount!, amount: amount,
category: state.category, category: state.category,
type: state.type, type: state.type,
date: state.date, date: state.date,
note: note.isEmpty ? null : note, note: note,
currency: state.overrideCurrency, currency: state.overrideCurrency,
currencyCode: state.overrideCurrencyCode, currencyCode: state.overrideCurrencyCode,
); );
@@ -169,62 +228,63 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
_SectionLabel('Amount'), _SectionLabel('Amount'),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( AnimatedBuilder(
decoration: BoxDecoration( animation: _borderColorAnimation,
color: Theme.of(context).inputDecorationTheme.fillColor, builder: (context, child) {
borderRadius: BorderRadius.circular(12), final isError = _showError;
border: Border.all( final normalBorder = isDark
color: _amountFocused ? Theme.of(context).colorScheme.primary : Colors.transparent, ? Colors.transparent
width: 1.5, : const Color(0xFFCCCCDD);
), final borderColor = isError
), ? (_borderColorAnimation.value ?? const Color(0xFFE05C6B))
child: Row( : normalBorder;
children: [
Padding( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration(
child: Text( color: Theme.of(context).colorScheme.surface,
overrideCurrency, borderRadius: BorderRadius.circular(12),
style: TextStyle( border: Border.all(color: borderColor, width: isError ? 1.5 : 1),
fontSize: 20,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
), ),
Expanded( child: Row(
child: TextFormField( children: [
controller: _amountController, Padding(
focusNode: _amountFocusNode, padding: const EdgeInsets.symmetric(horizontal: 14),
keyboardType: const TextInputType.numberWithOptions(decimal: true), child: Text(
inputFormatters: [ overrideCurrency,
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')), style: Theme.of(context).textTheme.bodyLarge?.copyWith(
], color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7),
style: Theme.of(context).textTheme.headlineSmall?.copyWith( 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, color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
decoration: const InputDecoration( decoration: const InputDecoration(
hintText: '0.00', hintText: '0.00',
border: InputBorder.none, border: InputBorder.none,
enabledBorder: InputBorder.none, enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none, focusedBorder: InputBorder.none,
filled: false, errorBorder: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 0, vertical: 16), focusedErrorBorder: InputBorder.none,
filled: false,
contentPadding: EdgeInsets.symmetric(vertical: 14),
),
onChanged: (v) {
final parsed = double.tryParse(v);
ref.read(addTransactionProvider(widget.initial).notifier).setAmount(parsed);
},
),
), ),
onChanged: (v) { ],
final parsed = double.tryParse(v);
ref.read(addTransactionProvider(widget.initial).notifier).setAmount(parsed);
},
validator: (v) {
if (v == null || v.isEmpty) return 'Enter an amount';
if (double.tryParse(v) == null) return 'Invalid amount';
if (double.parse(v) <= 0) return 'Amount must be > 0';
return null;
},
),
), ),
], );
), },
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -288,8 +348,27 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
controller: _noteController, controller: _noteController,
maxLines: 2, maxLines: 2,
maxLength: 20, maxLength: 20,
decoration: const InputDecoration( maxLengthEnforcement: MaxLengthEnforcement.enforced,
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),
fontSize: 11,
),
),
decoration: InputDecoration(
hintText: 'Add a note...', hintText: 'Add a note...',
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: isDark
? BorderSide.none
: const BorderSide(color: Color(0xFFCCCCDD), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF7C6DED), width: 1.5),
),
), ),
onChanged: (v) => onChanged: (v) =>
ref.read(addTransactionProvider(widget.initial).notifier).setNote(v.trim()), ref.read(addTransactionProvider(widget.initial).notifier).setNote(v.trim()),
+1 -1
View File
@@ -90,7 +90,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'My Finances', 'Casha',
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,