269 lines
8.4 KiB
Dart
269 lines
8.4 KiB
Dart
/// Admin-only dialog to create or edit a local test bank transaction.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../models/bank.dart';
|
|
import '../models/transaction_meta.dart';
|
|
import '../providers/bank_provider.dart';
|
|
import '../utils/helpers.dart';
|
|
|
|
Future<BankTransaction?> showAdminCreateTransactionDialog(
|
|
BuildContext context, {
|
|
BankTransaction? existing,
|
|
}) {
|
|
return showDialog<BankTransaction>(
|
|
context: context,
|
|
builder: (context) => _AdminCreateTransactionDialog(existing: existing),
|
|
);
|
|
}
|
|
|
|
String _stripManualStamp(String description) =>
|
|
description.replaceFirst(RegExp(r' ·#\d+$'), '');
|
|
|
|
class _AdminCreateTransactionDialog extends StatefulWidget {
|
|
const _AdminCreateTransactionDialog({this.existing});
|
|
|
|
final BankTransaction? existing;
|
|
|
|
@override
|
|
State<_AdminCreateTransactionDialog> createState() =>
|
|
_AdminCreateTransactionDialogState();
|
|
}
|
|
|
|
class _AdminCreateTransactionDialogState
|
|
extends State<_AdminCreateTransactionDialog> {
|
|
late DateTime _date;
|
|
late final TextEditingController _description;
|
|
late final TextEditingController _amount;
|
|
late final TextEditingController _currency;
|
|
late TxFlowFilter _flow;
|
|
late bool _pending;
|
|
var _saving = false;
|
|
String? _error;
|
|
|
|
bool get _editing => widget.existing != null;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final existing = widget.existing;
|
|
if (existing != null) {
|
|
final parsed = DateTime.tryParse(existing.date) ?? DateTime.now();
|
|
_date = DateTime(parsed.year, parsed.month, parsed.day);
|
|
_description = TextEditingController(
|
|
text: _stripManualStamp(existing.description),
|
|
);
|
|
_amount = TextEditingController(
|
|
text: existing.amount.abs().toStringAsFixed(2),
|
|
);
|
|
_currency = TextEditingController(text: existing.currency);
|
|
_flow = existing.amount >= 0
|
|
? TxFlowFilter.income
|
|
: TxFlowFilter.expense;
|
|
_pending = existing.pending;
|
|
} else {
|
|
final now = DateTime.now();
|
|
_date = DateTime(now.year, now.month, now.day);
|
|
_description = TextEditingController(text: 'Test merchant');
|
|
_amount = TextEditingController(text: '12.50');
|
|
_currency = TextEditingController(text: 'EUR');
|
|
_flow = TxFlowFilter.expense;
|
|
_pending = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_description.dispose();
|
|
_amount.dispose();
|
|
_currency.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _pickDate() async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _date,
|
|
firstDate: DateTime(2015),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
);
|
|
if (picked == null || !mounted) return;
|
|
setState(() => _date = DateTime(picked.year, picked.month, picked.day));
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
final raw = _amount.text.trim().replaceAll(',', '.');
|
|
final parsed = double.tryParse(raw);
|
|
if (parsed == null || parsed <= 0) {
|
|
setState(() => _error = 'Enter a positive amount');
|
|
return;
|
|
}
|
|
final signed = _flow == TxFlowFilter.income ? parsed : -parsed;
|
|
setState(() {
|
|
_saving = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final bank = context.read<BankController>();
|
|
final existing = widget.existing;
|
|
final BankTransaction tx;
|
|
if (existing != null) {
|
|
tx = await bank.updateManualTransaction(
|
|
previous: existing,
|
|
date: _date,
|
|
description: _description.text,
|
|
amount: signed,
|
|
currency: _currency.text,
|
|
pending: _pending,
|
|
);
|
|
} else {
|
|
tx = await bank.addManualTransaction(
|
|
date: _date,
|
|
description: _description.text,
|
|
amount: signed,
|
|
currency: _currency.text,
|
|
pending: _pending,
|
|
);
|
|
}
|
|
if (!mounted) return;
|
|
Navigator.pop(context, tx);
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_saving = false;
|
|
_error = '$error';
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return AlertDialog(
|
|
title: Text(_editing ? 'Edit test transaction' : 'Create test transaction'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
_editing
|
|
? 'Updates this device and removes the old row on the next PocketBase sync.'
|
|
: 'Saved on this device and included in the next PocketBase sync.',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Date'),
|
|
subtitle: Text(formatDate(_date)),
|
|
trailing: const Icon(Icons.calendar_today_outlined, size: 20),
|
|
onTap: _saving ? null : _pickDate,
|
|
),
|
|
TextField(
|
|
controller: _description,
|
|
enabled: !_saving,
|
|
textCapitalization: TextCapitalization.sentences,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description',
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
SegmentedButton<TxFlowFilter>(
|
|
segments: const [
|
|
ButtonSegment(
|
|
value: TxFlowFilter.expense,
|
|
label: Text('Expense'),
|
|
icon: Icon(Icons.north_east, size: 16),
|
|
),
|
|
ButtonSegment(
|
|
value: TxFlowFilter.income,
|
|
label: Text('Income'),
|
|
icon: Icon(Icons.south_west, size: 16),
|
|
),
|
|
],
|
|
selected: {_flow},
|
|
onSelectionChanged: _saving
|
|
? null
|
|
: (selection) {
|
|
setState(() => _flow = selection.first);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: TextField(
|
|
controller: _amount,
|
|
enabled: !_saving,
|
|
keyboardType: const TextInputType.numberWithOptions(
|
|
decimal: true,
|
|
),
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
|
|
],
|
|
decoration: const InputDecoration(
|
|
labelText: 'Amount',
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _currency,
|
|
enabled: !_saving,
|
|
textCapitalization: TextCapitalization.characters,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Currency',
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Pending'),
|
|
value: _pending,
|
|
onChanged: _saving
|
|
? null
|
|
: (value) => setState(() => _pending = value),
|
|
),
|
|
if (_error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
_error!,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _saving ? null : () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: _saving ? null : _save,
|
|
child: _saving
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(_editing ? 'Save' : 'Create'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|