Files
fin65-mobile/lib/screens/quick_transaction_screen.dart

453 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import '../models/account.dart';
import '../models/category.dart';
import '../services/sync_service.dart';
class QuickTransactionScreen extends StatefulWidget {
const QuickTransactionScreen({
super.key,
required this.syncService,
required this.accounts,
required this.categories,
required this.onSaved,
});
final SyncService syncService;
final List<Account> accounts;
final List<Category> categories;
/// Important: this is async because the home screen must reload the local
/// SQLite snapshot before the quick form is closed.
final Future<void> Function() onSaved;
@override
State<QuickTransactionScreen> createState() => _QuickTransactionScreenState();
}
class _QuickTransactionScreenState extends State<QuickTransactionScreen> {
final _formKey = GlobalKey<FormState>();
final _amountController = TextEditingController();
final _labelController = TextEditingController();
final _noteController = TextEditingController();
String _type = 'expense';
int? _accountId;
int? _targetAccountId;
int? _categoryId;
bool _saving = false;
static const _quickAmounts = <double>[10, 20, 50, 100, 200, 500, 1000];
static const _expenseTemplates = <_QuickTemplate>[
_QuickTemplate(label: 'Courses', categoryHint: 'course'),
_QuickTemplate(label: 'Essence', categoryHint: 'essence'),
_QuickTemplate(label: 'Restaurant', categoryHint: 'restaurant'),
_QuickTemplate(label: 'Café', categoryHint: 'café'),
_QuickTemplate(label: 'Transport', categoryHint: 'transport'),
_QuickTemplate(label: 'Santé', categoryHint: 'santé'),
_QuickTemplate(label: 'Abonnement', categoryHint: 'abonnement'),
_QuickTemplate(label: 'Retrait cash', categoryHint: 'cash'),
];
static const _incomeTemplates = <_QuickTemplate>[
_QuickTemplate(label: 'Salaire', categoryHint: 'salaire'),
_QuickTemplate(label: 'Remboursement', categoryHint: 'remboursement'),
_QuickTemplate(label: 'Prime', categoryHint: 'prime'),
_QuickTemplate(label: 'Vente', categoryHint: 'vente'),
];
static const _transferTemplates = <_QuickTemplate>[
_QuickTemplate(label: 'Épargne'),
_QuickTemplate(label: 'Provision'),
_QuickTemplate(label: 'Banque → Cash'),
_QuickTemplate(label: 'Cash → Banque'),
];
@override
void initState() {
super.initState();
if (widget.accounts.isNotEmpty) {
_accountId = widget.accounts.first.id;
_targetAccountId = widget.accounts.length > 1 ? widget.accounts[1].id : null;
}
_setDefaultCategory();
}
@override
void dispose() {
_amountController.dispose();
_labelController.dispose();
_noteController.dispose();
super.dispose();
}
List<_QuickTemplate> get _templates {
if (_type == 'income') return _incomeTemplates;
if (_type == 'transfer') return _transferTemplates;
return _expenseTemplates;
}
void _setDefaultCategory({String? preferredHint}) {
final filtered = widget.categories.where((c) => c.type == _type).toList();
if (filtered.isEmpty) {
_categoryId = null;
return;
}
if (preferredHint != null && preferredHint.trim().isNotEmpty) {
final normalizedHint = _normalize(preferredHint);
for (final category in filtered) {
final normalizedCategory = _normalize(category.name);
if (normalizedCategory.contains(normalizedHint) ||
normalizedHint.contains(normalizedCategory)) {
_categoryId = category.id;
return;
}
}
}
_categoryId = filtered.first.id;
}
String _normalize(String value) {
return value
.toLowerCase()
.replaceAll('é', 'e')
.replaceAll('è', 'e')
.replaceAll('ê', 'e')
.replaceAll('à', 'a')
.replaceAll('â', 'a')
.replaceAll('ù', 'u')
.replaceAll('û', 'u')
.replaceAll('î', 'i')
.replaceAll('ï', 'i')
.replaceAll('ô', 'o')
.replaceAll('ç', 'c')
.trim();
}
List<Category> get _visibleCategories {
return widget.categories.where((c) => c.type == _type).toList();
}
void _applyTemplate(_QuickTemplate template) {
if (_saving) return;
setState(() {
_labelController.text = template.label;
if (template.amount != null) {
_amountController.text = _formatAmount(template.amount!);
}
if (_type != 'transfer') {
_setDefaultCategory(preferredHint: template.categoryHint ?? template.label);
}
});
}
void _applyAmount(double amount) {
if (_saving) return;
setState(() {
_amountController.text = _formatAmount(amount);
});
}
String _formatAmount(double amount) {
if (amount == amount.roundToDouble()) return amount.toInt().toString();
return amount.toStringAsFixed(2);
}
int? _firstOtherAccountId() {
for (final account in widget.accounts) {
if (account.id != _accountId) return account.id;
}
return null;
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
final accountId = _accountId;
if (accountId == null) {
_showSnack('Compte obligatoire.');
return;
}
final targetAccountId = _targetAccountId;
if (_type == 'transfer') {
if (targetAccountId == null) {
_showSnack('Compte cible obligatoire.');
return;
}
if (targetAccountId == accountId) {
_showSnack('Source et cible doivent être différents.');
return;
}
}
setState(() => _saving = true);
try {
final synced = await widget.syncService.createTransactionSmart(
label: _labelController.text.trim(),
amount: double.parse(_amountController.text.replaceAll(',', '.')),
type: _type,
accountId: accountId,
targetAccountId: _type == 'transfer' ? targetAccountId : null,
categoryId: _type == 'transfer' ? null : _categoryId,
note: _noteController.text.trim().isEmpty ? null : _noteController.text.trim(),
);
if (!mounted) return;
await widget.onSaved();
if (!mounted) return;
_showSnack(
synced
? 'Transaction synchronisée.'
: 'Backend absent : opération gardée localement.',
);
if (mounted) {
Navigator.of(context).pop();
}
} catch (e) {
if (!mounted) return;
_showSnack('Erreur : $e');
} finally {
if (mounted) setState(() => _saving = false);
}
}
void _showSnack(String text) {
final messenger = ScaffoldMessenger.maybeOf(context);
messenger?.clearSnackBars();
messenger?.showSnackBar(SnackBar(content: Text(text)));
}
Widget _buildSaveButton() {
return FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(_saving ? 'Enregistrement...' : 'Enregistrer'),
);
}
@override
Widget build(BuildContext context) {
final categories = _visibleCategories;
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
title: const Text('Ajout rapide'),
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: _buildSaveButton(),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
SegmentedButton<String>(
segments: const [
ButtonSegment(value: 'expense', label: Text('Dépense')),
ButtonSegment(value: 'income', label: Text('Recette')),
ButtonSegment(value: 'transfer', label: Text('Transfert')),
],
selected: {_type},
onSelectionChanged: _saving
? null
: (values) {
setState(() {
_type = values.first;
_setDefaultCategory();
if (_type == 'transfer' && _targetAccountId == _accountId) {
_targetAccountId = _firstOtherAccountId();
}
});
},
),
const SizedBox(height: 16),
_QuickChipsSection(
title: _type == 'transfer'
? 'Transferts rapides'
: _type == 'income'
? 'Recettes rapides'
: 'Dépenses rapides',
chips: _templates
.map((template) => ActionChip(
label: Text(template.label),
onPressed: _saving ? null : () => _applyTemplate(template),
))
.toList(),
),
const SizedBox(height: 16),
TextFormField(
controller: _amountController,
decoration: const InputDecoration(
labelText: 'Montant',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
final parsed = double.tryParse((value ?? '').replaceAll(',', '.'));
if (parsed == null || parsed <= 0) return 'Montant invalide';
return null;
},
),
const SizedBox(height: 8),
_QuickChipsSection(
title: 'Montants rapides',
chips: _quickAmounts
.map((amount) => ChoiceChip(
label: Text(_formatAmount(amount)),
selected: _amountController.text == _formatAmount(amount),
onSelected: _saving ? null : (_) => _applyAmount(amount),
))
.toList(),
dense: true,
),
const SizedBox(height: 12),
TextFormField(
controller: _labelController,
decoration: const InputDecoration(
labelText: 'Libellé',
border: OutlineInputBorder(),
),
validator: (value) {
if ((value ?? '').trim().isEmpty) return 'Libellé obligatoire';
return null;
},
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: _accountId,
decoration: const InputDecoration(
labelText: 'Compte source',
border: OutlineInputBorder(),
),
items: widget.accounts
.map((a) => DropdownMenuItem(value: a.id, child: Text(a.name)))
.toList(),
onChanged: _saving
? null
: (value) {
setState(() {
_accountId = value;
if (_type == 'transfer' && _targetAccountId == _accountId) {
_targetAccountId = _firstOtherAccountId();
}
});
},
validator: (value) => value == null ? 'Compte obligatoire' : null,
),
if (_type == 'transfer') ...[
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: _targetAccountId,
decoration: const InputDecoration(
labelText: 'Compte cible',
border: OutlineInputBorder(),
),
items: widget.accounts
.map((a) => DropdownMenuItem(value: a.id, child: Text(a.name)))
.toList(),
onChanged: _saving ? null : (value) => setState(() => _targetAccountId = value),
validator: (value) {
if (value == null) return 'Compte cible obligatoire';
if (value == _accountId) return 'Source et cible doivent être différents';
return null;
},
),
],
if (_type != 'transfer') ...[
const SizedBox(height: 12),
DropdownButtonFormField<int>(
value: _categoryId,
decoration: const InputDecoration(
labelText: 'Catégorie',
border: OutlineInputBorder(),
),
items: categories
.map((c) => DropdownMenuItem(value: c.id, child: Text(c.name)))
.toList(),
onChanged: _saving ? null : (value) => setState(() => _categoryId = value),
),
],
const SizedBox(height: 12),
TextFormField(
controller: _noteController,
decoration: const InputDecoration(
labelText: 'Note',
border: OutlineInputBorder(),
),
minLines: 2,
maxLines: 4,
),
const SizedBox(height: 120),
],
),
),
);
}
}
class _QuickTemplate {
const _QuickTemplate({
required this.label,
this.categoryHint,
this.amount,
});
final String label;
final String? categoryHint;
final double? amount;
}
class _QuickChipsSection extends StatelessWidget {
const _QuickChipsSection({
required this.title,
required this.chips,
this.dense = false,
});
final String title;
final List<Widget> chips;
final bool dense;
@override
Widget build(BuildContext context) {
if (chips.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: dense
? Theme.of(context).textTheme.bodyMedium
: Theme.of(context).textTheme.titleSmall,
),
SizedBox(height: dense ? 4 : 8),
Wrap(
spacing: 8,
runSpacing: dense ? 4 : 8,
children: chips,
),
],
);
}
}