Files
app_fideliter/lib/screens/client_detail_screen.dart
T
Mathew 1d3ee69c6d Initial commit — app fidélité magasin (tablette)
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>
2026-07-17 21:37:06 +02:00

624 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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é')),
);
}
}
}
}