1d3ee69c6d
App Flutter de programme de fidélité : login staff, clients (nom/prénom), scan QR, factures (€→points), récompenses, réglages. Backend Supabase (schéma SQL + RLS anti-triche dans supabase/). Icône incluse. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
624 lines
20 KiB
Dart
624 lines
20 KiB
Dart
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<ClientDetailScreen> createState() => _ClientDetailScreenState();
|
||
}
|
||
|
||
class _ClientDetailScreenState extends State<ClientDetailScreen> {
|
||
final _repo = ClientRepository.instance;
|
||
List<Mouvement> _mouvements = [];
|
||
bool _chargeMouvements = true;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_rechargerMouvements();
|
||
}
|
||
|
||
Future<void> _rechargerMouvements() async {
|
||
final m = await _repo.mouvements(widget.clientId);
|
||
if (mounted) {
|
||
setState(() {
|
||
_mouvements = m;
|
||
_chargeMouvements = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Actions
|
||
// ---------------------------------------------------------------------------
|
||
|
||
Future<void> _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<double?> _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<double>(
|
||
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<void> _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<Recompense>(
|
||
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<void> _corrigerPoints(Client client) async {
|
||
final ctrl = TextEditingController();
|
||
final delta = await showDialog<double>(
|
||
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<void> _modifier(Client client) async {
|
||
await Navigator.of(context).push(
|
||
MaterialPageRoute(builder: (_) => ClientEditScreen(client: client)),
|
||
);
|
||
if (mounted) setState(() {});
|
||
}
|
||
|
||
Future<void> _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<bool?> _confirmer({
|
||
required String titre,
|
||
required String message,
|
||
required String libelleOk,
|
||
bool danger = false,
|
||
}) {
|
||
return showDialog<bool>(
|
||
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<String>(
|
||
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<void> _optionsMouvement(Mouvement m) async {
|
||
final estFacture = m.type == TypeMouvement.facture;
|
||
final action = await showModalBottomSheet<String>(
|
||
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é')),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|