import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../models/client.dart'; import '../models/mouvement.dart'; import '../models/recompense.dart'; import '../services/client_repository.dart'; import '../services/recompense_repository.dart'; import '../services/reglages.dart'; import '../theme.dart'; import '../utils/format.dart'; import '../widgets/qr_client.dart'; import 'client_edit_screen.dart'; /// Fiche d'un client : solde de points, ajout de facture, utilisation de /// récompenses, QR code et historique des mouvements. class ClientDetailScreen extends StatefulWidget { final String clientId; const ClientDetailScreen({super.key, required this.clientId}); @override State createState() => _ClientDetailScreenState(); } class _ClientDetailScreenState extends State { final _repo = ClientRepository.instance; List _mouvements = []; bool _chargeMouvements = true; @override void initState() { super.initState(); _rechargerMouvements(); } Future _rechargerMouvements() async { final m = await _repo.mouvements(widget.clientId); if (mounted) { setState(() { _mouvements = m; _chargeMouvements = false; }); } } // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- Future _ajouterFacture() async { final montant = await _demanderMontant(); if (montant == null) return; final gagnes = await _repo.ajouterFacture(clientId: widget.clientId, montantEuros: montant); await _rechargerMouvements(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Facture ${euro(montant)} • +${points(gagnes)}'), backgroundColor: AppTheme.accent, ), ); } } Future _demanderMontant({ double? initial, String titre = 'Ajouter une facture', String bouton = 'Valider', }) { final ctrl = TextEditingController( text: initial == null ? '' : (initial == initial.roundToDouble() ? initial.toInt().toString() : initial.toString()) .replaceAll('.', ','), ); return showDialog( context: context, builder: (ctx) { String? erreur; return StatefulBuilder( builder: (ctx, setDialog) { double? apercu() { final v = double.tryParse(ctrl.text.replaceAll(',', '.')); return v == null ? null : Reglages.instance.pointsPour(v); } final pts = apercu(); return AlertDialog( title: Text(titre), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: ctrl, autofocus: true, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')), ], onChanged: (_) => setDialog(() => erreur = null), decoration: InputDecoration( labelText: 'Montant dépensé', suffixText: '€', errorText: erreur, ), ), const SizedBox(height: 12), Text( pts != null && pts > 0 ? 'Rapportera ${points(pts)}' : 'Saisissez le montant de la facture', style: const TextStyle( color: AppTheme.accent, fontWeight: FontWeight.w600), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: const Text('Annuler'), ), FilledButton( onPressed: () { final v = double.tryParse(ctrl.text.replaceAll(',', '.')); if (v == null || v <= 0) { setDialog(() => erreur = 'Montant invalide'); return; } Navigator.of(ctx).pop(v); }, child: Text(bouton), ), ], ); }, ); }, ); } Future _utiliserRecompense(Client client) async { final actives = RecompenseRepository.instance.actives; if (actives.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Aucune récompense. Créez-en dans l\'onglet Récompenses.')), ); return; } final choisie = await showModalBottomSheet( context: context, showDragHandle: true, builder: (ctx) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Padding( padding: EdgeInsets.symmetric(vertical: 8), child: Text('Utiliser une récompense', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), ), Flexible( child: ListView.builder( shrinkWrap: true, itemCount: actives.length, itemBuilder: (ctx, i) { final r = actives[i]; final possible = client.points >= r.coutPoints; return ListTile( enabled: possible, leading: CircleAvatar( backgroundColor: possible ? AppTheme.accent.withValues(alpha: 0.14) : Colors.grey.withValues(alpha: 0.14), child: Icon(Icons.card_giftcard, color: possible ? AppTheme.accent : Colors.grey), ), title: Text(r.nom), subtitle: Text(points(r.coutPoints)), trailing: possible ? const Icon(Icons.chevron_right) : Text('Manque ${points(r.coutPoints - client.points)}', style: const TextStyle( fontSize: 12, color: AppTheme.grisTexte)), onTap: possible ? () => Navigator.of(ctx).pop(r) : null, ); }, ), ), const SizedBox(height: 8), ], ), ); }, ); if (choisie == null) return; final confirme = await _confirmer( titre: 'Confirmer la récompense', message: 'Utiliser « ${choisie.nom} » pour ${points(choisie.coutPoints)} ?', libelleOk: 'Confirmer', ); if (confirme != true) return; try { await _repo.utiliserRecompense( clientId: widget.clientId, recompense: choisie); await _rechargerMouvements(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${choisie.nom} • −${points(choisie.coutPoints)}'), backgroundColor: AppTheme.accent, ), ); } } on SoldeInsuffisant catch (e) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(e.toString()))); } } } Future _corrigerPoints(Client client) async { final ctrl = TextEditingController(); final delta = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Corriger les points'), content: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( 'Ajoutez (+) ou retirez (−) des points manuellement.', style: TextStyle(fontSize: 13, color: AppTheme.grisTexte), ), const SizedBox(height: 12), TextField( controller: ctrl, autofocus: true, keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true), inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'[0-9.,-]')), ], decoration: const InputDecoration( labelText: 'Points (ex : 5 ou -2)', ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: const Text('Annuler'), ), FilledButton( onPressed: () { final v = double.tryParse(ctrl.text.replaceAll(',', '.')); if (v == null || v == 0) return; Navigator.of(ctx).pop(v); }, child: const Text('Appliquer'), ), ], ), ); if (delta == null) return; await _repo.ajuster( clientId: widget.clientId, points: delta, libelle: 'Correction manuelle', ); await _rechargerMouvements(); } Future _modifier(Client client) async { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => ClientEditScreen(client: client)), ); if (mounted) setState(() {}); } Future _supprimer(Client client) async { final ok = await _confirmer( titre: 'Supprimer ce client ?', message: '« ${client.nomComplet} » et tout son historique seront supprimés définitivement.', libelleOk: 'Supprimer', danger: true, ); if (ok != true) return; await _repo.supprimer(widget.clientId); if (mounted) Navigator.of(context).pop(); } Future _confirmer({ required String titre, required String message, required String libelleOk, bool danger = false, }) { return showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(titre), content: Text(message), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Annuler'), ), FilledButton( style: danger ? FilledButton.styleFrom(backgroundColor: Colors.red) : null, onPressed: () => Navigator.of(ctx).pop(true), child: Text(libelleOk), ), ], ), ); } // --------------------------------------------------------------------------- // UI // --------------------------------------------------------------------------- @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _repo, builder: (context, _) { final client = _repo.parId(widget.clientId); // Le client a pu être supprimé (pop en cours). if (client == null) { return const Scaffold(body: SizedBox.shrink()); } return Scaffold( appBar: AppBar( title: Text(client.nomComplet), actions: [ IconButton( tooltip: 'QR code', icon: const Icon(Icons.qr_code_2), onPressed: () => afficherQrClient(context, client), ), PopupMenuButton( onSelected: (v) { switch (v) { case 'modifier': _modifier(client); case 'corriger': _corrigerPoints(client); case 'supprimer': _supprimer(client); } }, itemBuilder: (_) => const [ PopupMenuItem(value: 'modifier', child: Text('Modifier')), PopupMenuItem( value: 'corriger', child: Text('Corriger les points')), PopupMenuItem(value: 'supprimer', child: Text('Supprimer')), ], ), ], ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ _enteteSolde(client), const SizedBox(height: 16), _boutonsActions(client), const SizedBox(height: 24), const Text('Historique', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), const SizedBox(height: 8), _historique(), ], ), ); }, ); } Widget _enteteSolde(Client client) { return Card( child: Padding( padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), child: Column( children: [ const Text('Solde de points', style: TextStyle(color: AppTheme.grisTexte, fontSize: 14)), const SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ const Icon(Icons.stars_rounded, color: AppTheme.accent, size: 34), const SizedBox(width: 8), Text( pointsNombre(client.points), style: const TextStyle( fontSize: 44, fontWeight: FontWeight.w800, color: AppTheme.accent), ), const SizedBox(width: 6), const Padding( padding: EdgeInsets.only(bottom: 6), child: Text('pts', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: AppTheme.accent)), ), ], ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.badge_outlined, size: 15, color: AppTheme.grisTexte), const SizedBox(width: 6), Text(client.code, style: const TextStyle( color: AppTheme.grisTexte, fontWeight: FontWeight.w600, letterSpacing: 1)), if (client.telephone?.isNotEmpty == true) ...[ const SizedBox(width: 12), const Icon(Icons.phone, size: 15, color: AppTheme.grisTexte), const SizedBox(width: 6), Text(client.telephone!, style: const TextStyle(color: AppTheme.grisTexte)), ], ], ), ], ), ), ); } Widget _boutonsActions(Client client) { return Row( children: [ Expanded( child: FilledButton.icon( onPressed: _ajouterFacture, icon: const Icon(Icons.receipt_long), label: const Text('Ajouter\nune facture', textAlign: TextAlign.center), ), ), const SizedBox(width: 12), Expanded( child: FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: AppTheme.accent, foregroundColor: Colors.white, ), onPressed: () => _utiliserRecompense(client), icon: const Icon(Icons.card_giftcard), label: const Text('Utiliser une\nrécompense', textAlign: TextAlign.center), ), ), ], ); } Widget _historique() { if (_chargeMouvements) { return const Padding( padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()), ); } if (_mouvements.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center( child: Text('Aucun mouvement pour l\'instant.', style: TextStyle(color: AppTheme.grisTexte)), ), ); } return Column( children: [ for (final m in _mouvements) _ligneMouvement(m), ], ); } Widget _ligneMouvement(Mouvement m) { final gain = m.estGain; final couleur = gain ? AppTheme.accent : Colors.red.shade600; final icone = switch (m.type) { TypeMouvement.facture => Icons.receipt_long, TypeMouvement.recompense => Icons.card_giftcard, _ => Icons.tune, }; return InkWell( borderRadius: BorderRadius.circular(12), onTap: () => _optionsMouvement(m), child: Padding( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), child: Row( children: [ CircleAvatar( radius: 20, backgroundColor: couleur.withValues(alpha: 0.12), child: Icon(icone, size: 20, color: couleur), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(m.libelle, style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 2), Text(dateHeure(m.date), style: const TextStyle( fontSize: 12, color: AppTheme.grisTexte)), ], ), ), Text( '${gain ? '+' : '−'}${points(m.points.abs())}', style: TextStyle(fontWeight: FontWeight.w700, color: couleur), ), const SizedBox(width: 4), const Icon(Icons.more_vert, size: 18, color: AppTheme.grisTexte), ], ), ), ); } /// Menu d'un mouvement : modifier (factures) ou supprimer. Future _optionsMouvement(Mouvement m) async { final estFacture = m.type == TypeMouvement.facture; final action = await showModalBottomSheet( context: context, showDragHandle: true, builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), child: Text(m.libelle, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700)), ), if (estFacture) ListTile( leading: const Icon(Icons.edit_outlined), title: const Text('Modifier le montant'), onTap: () => Navigator.of(ctx).pop('modifier'), ), ListTile( leading: Icon(Icons.delete_outline, color: Colors.red.shade600), title: Text('Supprimer', style: TextStyle(color: Colors.red.shade600)), onTap: () => Navigator.of(ctx).pop('supprimer'), ), const SizedBox(height: 8), ], ), ), ); if (action == 'modifier') { final montant = await _demanderMontant( initial: m.montantEuros, titre: 'Modifier le montant', bouton: 'Enregistrer', ); if (montant == null) return; await _repo.modifierFacture(mouvement: m, nouveauMontant: montant); await _rechargerMouvements(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Facture modifiée')), ); } } else if (action == 'supprimer') { final ok = await _confirmer( titre: 'Supprimer ce mouvement ?', message: '« ${m.libelle} » sera supprimé et le solde du client réajusté.', libelleOk: 'Supprimer', danger: true, ); if (ok != true) return; await _repo.supprimerMouvement(m); await _rechargerMouvements(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Mouvement supprimé')), ); } } } }