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>
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
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é')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user