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:
2026-07-17 21:37:06 +02:00
commit 1d3ee69c6d
60 changed files with 4468 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
/// Constantes de configuration par défaut.
/// Les valeurs modifiables par l'utilisateur vivent dans [Reglages] (persistées).
class Config {
Config._();
/// Nombre d'euros à dépenser pour gagner 1 point (valeur par défaut au
/// premier lancement, ensuite modifiable dans l'onglet Réglages).
/// Ex : 10 → une facture de 25 € rapporte 2,5 points.
static const double eurosParPointParDefaut = 10;
/// Préfixe des codes clients encodés dans le QR code (ex : « FID-3F9K2A »).
static const String prefixeCode = 'FID-';
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'screens/auth_gate.dart';
import 'supabase_config.dart';
import 'theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Formatage des dates en français (« 2 juil. 2026 »).
await initializeDateFormatting('fr_FR', null);
// App verrouillée en portrait (tablette).
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Connexion au backend Supabase (partagé avec l'app client).
if (SupabaseConfig.estConfigure) {
await Supabase.initialize(
url: SupabaseConfig.url,
// Accepte la clé « anon public » (ou « publishable ») du projet.
publishableKey: SupabaseConfig.anonKey,
);
}
runApp(const MonApp());
}
class MonApp extends StatelessWidget {
const MonApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Fidélité — Magasin',
debugShowCheckedModeBanner: false,
theme: AppTheme.clair(),
darkTheme: AppTheme.sombre(),
themeMode: ThemeMode.light,
home: const AuthGate(),
);
}
}
+68
View File
@@ -0,0 +1,68 @@
/// Un client fidélité. Le [code] est ce qui est encodé dans son QR code :
/// scanner le QR permet de retrouver le compte.
class Client {
final String id; // uuid Supabase
/// Code unique du compte (encodé dans le QR), ex : « FID-3F9K2A ».
final String code;
final String nom;
final String? prenom;
final String? telephone;
/// Solde de points courant (peut être fractionnaire, ex : 12,48).
final double points;
const Client({
required this.id,
required this.code,
required this.nom,
this.prenom,
this.telephone,
this.points = 0,
});
/// Nom complet affiché : « Prénom Nom » (ou juste le nom si pas de prénom).
String get nomComplet {
final p = (prenom ?? '').trim();
return p.isEmpty ? nom : '$p $nom';
}
/// Initiales pour l'avatar (ex : « Jean Dupont » → « JD »).
String get initiales {
final p = (prenom ?? '').trim();
final n = nom.trim();
final a = p.isNotEmpty ? p[0] : (n.isNotEmpty ? n[0] : '?');
final b = n.isNotEmpty ? n[0] : '';
return (a + b).toUpperCase();
}
Client copyWith({
String? id,
String? code,
String? nom,
String? prenom,
String? telephone,
double? points,
}) {
return Client(
id: id ?? this.id,
code: code ?? this.code,
nom: nom ?? this.nom,
prenom: prenom ?? this.prenom,
telephone: telephone ?? this.telephone,
points: points ?? this.points,
);
}
factory Client.fromMap(Map<String, dynamic> map) {
return Client(
id: map['id'].toString(),
code: (map['code'] as String?) ?? '',
nom: (map['nom'] as String?) ?? '',
prenom: map['prenom'] as String?,
telephone: map['telephone'] as String?,
points: (map['points'] as num?)?.toDouble() ?? 0,
);
}
}
+53
View File
@@ -0,0 +1,53 @@
/// Type d'un mouvement de points dans l'historique d'un client.
class TypeMouvement {
static const facture = 'facture'; // gain de points suite à un achat
static const recompense = 'recompense'; // dépense de points sur une récompense
static const ajustement = 'ajustement'; // correction manuelle (+/-)
}
/// Une ligne d'historique : gain (facture) ou dépense (récompense) de points
/// pour un client donné.
class Mouvement {
final String id;
final String clientId;
/// Voir [TypeMouvement].
final String type;
/// Montant de la facture en euros (uniquement pour [TypeMouvement.facture]).
final double? montantEuros;
/// Points crédités (positif) ou débités (négatif).
final double points;
/// Libellé lisible, ex : « Facture 25,00 € » ou « Café offert ».
final String libelle;
/// Date du mouvement.
final DateTime date;
const Mouvement({
required this.id,
required this.clientId,
required this.type,
this.montantEuros,
required this.points,
required this.libelle,
required this.date,
});
bool get estGain => points >= 0;
factory Mouvement.fromMap(Map<String, dynamic> map) {
return Mouvement(
id: map['id'].toString(),
clientId: map['client_id'].toString(),
type: (map['type'] as String?) ?? TypeMouvement.ajustement,
montantEuros: (map['montant_euros'] as num?)?.toDouble(),
points: (map['points'] as num?)?.toDouble() ?? 0,
libelle: (map['libelle'] as String?) ?? '',
date: DateTime.tryParse(map['created_at']?.toString() ?? '')?.toLocal() ??
DateTime.fromMillisecondsSinceEpoch(0),
);
}
}
+42
View File
@@ -0,0 +1,42 @@
/// Une récompense du catalogue : ce qu'un client peut obtenir en échange de
/// ses points (ex : « Café offert » = 10 pts).
class Recompense {
final String id;
final String nom;
/// Coût en points pour obtenir la récompense.
final double coutPoints;
/// Récompense proposée (true) ou masquée sans être supprimée (false).
final bool actif;
const Recompense({
required this.id,
required this.nom,
required this.coutPoints,
this.actif = true,
});
Recompense copyWith({
String? id,
String? nom,
double? coutPoints,
bool? actif,
}) {
return Recompense(
id: id ?? this.id,
nom: nom ?? this.nom,
coutPoints: coutPoints ?? this.coutPoints,
actif: actif ?? this.actif,
);
}
factory Recompense.fromMap(Map<String, dynamic> map) {
return Recompense(
id: map['id'].toString(),
nom: (map['nom'] as String?) ?? '',
coutPoints: (map['cout_points'] as num?)?.toDouble() ?? 0,
actif: (map['actif'] as bool?) ?? true,
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../services/client_repository.dart';
import '../services/recompense_repository.dart';
import '../services/reglages.dart';
import '../supabase_config.dart';
import 'home_shell.dart';
import 'staff_login_screen.dart';
/// Aiguille entre l'écran de connexion (staff non connecté) et l'app.
/// Écoute l'état d'authentification Supabase en continu.
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context) {
if (!SupabaseConfig.estConfigure) return const _EcranNonConfigure();
return StreamBuilder<AuthState>(
stream: supabase.auth.onAuthStateChange,
builder: (context, snapshot) {
final session = supabase.auth.currentSession;
if (session == null) return const StaffLoginScreen();
return const _AppChargee();
},
);
}
}
/// Charge les données (réglages, clients, récompenses) après connexion, puis
/// affiche l'app. Recharge si le compte connecté change.
class _AppChargee extends StatefulWidget {
const _AppChargee();
@override
State<_AppChargee> createState() => _AppChargeeState();
}
class _AppChargeeState extends State<_AppChargee> {
late Future<void> _chargement;
@override
void initState() {
super.initState();
_chargement = _charger();
}
Future<void> _charger() async {
await Reglages.instance.charger();
await ClientRepository.instance.charger();
await RecompenseRepository.instance.charger();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _chargement,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return const HomeShell();
},
);
}
}
class _EcranNonConfigure extends StatelessWidget {
const _EcranNonConfigure();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.cloud_off, size: 56, color: Colors.grey),
SizedBox(height: 16),
Text('Supabase non configuré',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
SizedBox(height: 8),
Text(
'Renseignez l\'URL et la clé anon dans lib/supabase_config.dart.\n'
'Voir supabase/SETUP.md',
textAlign: TextAlign.center,
style: TextStyle(color: Color(0xFF6B6B72)),
),
],
),
),
),
);
}
}
+623
View File
@@ -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é')),
);
}
}
}
}
+138
View File
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import '../models/client.dart';
import '../services/client_repository.dart';
/// Création (client == null) ou édition d'une fiche client.
/// Retourne le [Client] créé/modifié via Navigator.pop, ou null si annulé.
class ClientEditScreen extends StatefulWidget {
final Client? client;
const ClientEditScreen({super.key, this.client});
@override
State<ClientEditScreen> createState() => _ClientEditScreenState();
}
class _ClientEditScreenState extends State<ClientEditScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl;
late final TextEditingController _telCtrl;
bool _enregistre = false;
bool get _edition => widget.client != null;
@override
void initState() {
super.initState();
_nomCtrl = TextEditingController(text: widget.client?.nom ?? '');
_prenomCtrl = TextEditingController(text: widget.client?.prenom ?? '');
_telCtrl = TextEditingController(text: widget.client?.telephone ?? '');
}
@override
void dispose() {
_nomCtrl.dispose();
_prenomCtrl.dispose();
_telCtrl.dispose();
super.dispose();
}
Future<void> _enregistrer() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _enregistre = true);
final repo = ClientRepository.instance;
try {
final Client resultat;
if (_edition) {
resultat = widget.client!.copyWith(
nom: _nomCtrl.text.trim(),
prenom: _prenomCtrl.text.trim(),
telephone: _telCtrl.text.trim(),
);
await repo.modifier(resultat);
} else {
resultat = await repo.creer(
nom: _nomCtrl.text.trim(),
prenom: _prenomCtrl.text.trim(),
telephone: _telCtrl.text.trim(),
);
}
if (mounted) Navigator.of(context).pop(resultat);
} catch (e) {
if (mounted) {
setState(() => _enregistre = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur : $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_edition ? 'Modifier le client' : 'Nouveau client'),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _nomCtrl,
textCapitalization: TextCapitalization.words,
autofocus: !_edition,
decoration: const InputDecoration(
labelText: 'Nom *',
prefixIcon: Icon(Icons.person_outline),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Nom obligatoire' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _prenomCtrl,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
prefixIcon: Icon(Icons.badge_outlined),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _telCtrl,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Téléphone (facultatif)',
prefixIcon: Icon(Icons.phone_outlined),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _enregistre ? null : _enregistrer,
child: _enregistre
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(_edition ? 'Enregistrer' : 'Créer le client'),
),
if (!_edition) ...[
const SizedBox(height: 16),
const Text(
'Un code de fidélité unique et son QR code seront générés automatiquement.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Color(0xFF6B6B72)),
),
],
],
),
),
);
}
}
+190
View File
@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import '../models/client.dart';
import '../services/client_repository.dart';
import '../theme.dart';
import '../widgets/pastille_points.dart';
import 'client_detail_screen.dart';
import 'client_edit_screen.dart';
/// Onglet 1 : liste de tous les clients, avec recherche et ajout.
class ClientsScreen extends StatefulWidget {
final VoidCallback onAllerScanner;
const ClientsScreen({super.key, required this.onAllerScanner});
@override
State<ClientsScreen> createState() => _ClientsScreenState();
}
class _ClientsScreenState extends State<ClientsScreen> {
final _repo = ClientRepository.instance;
final _rechercheCtrl = TextEditingController();
String _requete = '';
@override
void dispose() {
_rechercheCtrl.dispose();
super.dispose();
}
Future<void> _ajouter() async {
final client = await Navigator.of(context).push<Client>(
MaterialPageRoute(builder: (_) => const ClientEditScreen()),
);
if (client != null && mounted) {
// On ouvre directement la fiche du nouveau client.
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ClientDetailScreen(clientId: client.id)),
);
}
}
void _ouvrir(Client c) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ClientDetailScreen(clientId: c.id)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Clients')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _ajouter,
icon: const Icon(Icons.person_add_alt_1),
label: const Text('Nouveau client'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: TextField(
controller: _rechercheCtrl,
onChanged: (v) => setState(() => _requete = v),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Rechercher (nom, téléphone, code)',
prefixIcon: const Icon(Icons.search),
suffixIcon: _requete.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_rechercheCtrl.clear();
setState(() => _requete = '');
},
),
),
),
),
Expanded(
child: AnimatedBuilder(
animation: _repo,
builder: (context, _) {
final liste = _repo.rechercher(_requete);
if (_repo.clients.isEmpty) return _vide();
if (liste.isEmpty) {
return const Center(
child: Text('Aucun client ne correspond.',
style: TextStyle(color: AppTheme.grisTexte)),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 96),
itemCount: liste.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) => _carte(liste[i]),
);
},
),
),
],
),
);
}
Widget _carte(Client c) {
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => _ouvrir(c),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: AppTheme.accent.withValues(alpha: 0.14),
child: Text(
c.initiales,
style: const TextStyle(
color: AppTheme.accent, fontWeight: FontWeight.w700),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.nomComplet,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
c.telephone?.isNotEmpty == true ? c.telephone! : c.code,
style: const TextStyle(
fontSize: 13, color: AppTheme.grisTexte),
),
],
),
),
const SizedBox(width: 8),
PastillePoints(c.points),
],
),
),
),
);
}
Widget _vide() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.people_outline, size: 64, color: AppTheme.grisTexte),
const SizedBox(height: 16),
const Text(
'Aucun client pour l\'instant',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
const Text(
'Créez une fiche client : un QR code de fidélité sera généré automatiquement.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.grisTexte),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _ajouter,
icon: const Icon(Icons.person_add_alt_1),
label: const Text('Créer le premier client'),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: widget.onAllerScanner,
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Ou scanner un QR client'),
),
],
),
),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'clients_screen.dart';
import 'recompenses_screen.dart';
import 'reglages_screen.dart';
import 'scan_screen.dart';
/// Coquille principale : 4 onglets + barre de navigation.
class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int _index = 0;
void _allerScanner() => setState(() => _index = 1);
@override
Widget build(BuildContext context) {
final pages = [
ClientsScreen(onAllerScanner: _allerScanner),
ScanScreen(active: _index == 1),
const RecompensesScreen(),
const ReglagesScreen(),
];
return Scaffold(
body: IndexedStack(index: _index, children: pages),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
height: 68,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
onDestinationSelected: (i) => setState(() => _index = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.people_alt_outlined),
selectedIcon: Icon(Icons.people_alt),
label: 'Clients',
),
NavigationDestination(
icon: Icon(Icons.qr_code_scanner_outlined),
selectedIcon: Icon(Icons.qr_code_scanner),
label: 'Scanner',
),
NavigationDestination(
icon: Icon(Icons.card_giftcard_outlined),
selectedIcon: Icon(Icons.card_giftcard),
label: 'Récompenses',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Réglages',
),
],
),
);
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/recompense.dart';
import '../services/recompense_repository.dart';
import '../theme.dart';
import '../utils/format.dart';
/// Onglet 3 : catalogue des récompenses (ce qu'un client peut obtenir avec ses
/// points). Créer / modifier / activer / supprimer.
class RecompensesScreen extends StatefulWidget {
const RecompensesScreen({super.key});
@override
State<RecompensesScreen> createState() => _RecompensesScreenState();
}
class _RecompensesScreenState extends State<RecompensesScreen> {
final _repo = RecompenseRepository.instance;
Future<void> _editer([Recompense? existante]) async {
final nomCtrl = TextEditingController(text: existante?.nom ?? '');
final coutCtrl = TextEditingController(
text: existante == null ? '' : points(existante.coutPoints)
.replaceAll(RegExp(r'\s?pts?$'), ''));
final formKey = GlobalKey<FormState>();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(existante == null ? 'Nouvelle récompense' : 'Modifier'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nomCtrl,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Nom (ex : Café offert)',
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Nom obligatoire' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: coutCtrl,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
],
decoration: const InputDecoration(
labelText: 'Coût en points',
suffixText: 'pts',
),
validator: (v) {
final n = double.tryParse((v ?? '').replaceAll(',', '.'));
if (n == null || n <= 0) return 'Coût invalide';
return null;
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
if (formKey.currentState!.validate()) Navigator.of(ctx).pop(true);
},
child: const Text('Enregistrer'),
),
],
),
);
if (ok != true) return;
final cout = double.parse(coutCtrl.text.replaceAll(',', '.'));
if (existante == null) {
await _repo.creer(nom: nomCtrl.text, coutPoints: cout);
} else {
await _repo.modifier(
existante.copyWith(nom: nomCtrl.text.trim(), coutPoints: cout));
}
}
Future<void> _supprimer(Recompense r) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Supprimer ?'),
content: Text('Supprimer la récompense « ${r.nom} » ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Supprimer'),
),
],
),
);
if (ok == true) await _repo.supprimer(r.id);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Récompenses')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _editer(),
icon: const Icon(Icons.add),
label: const Text('Récompense'),
),
body: AnimatedBuilder(
animation: _repo,
builder: (context, _) {
final liste = _repo.recompenses;
if (liste.isEmpty) return _vide();
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
itemCount: liste.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) => _carte(liste[i]),
);
},
),
);
}
Widget _carte(Recompense r) {
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 6, 6),
child: Row(
children: [
CircleAvatar(
backgroundColor: r.actif
? AppTheme.accent.withValues(alpha: 0.14)
: Colors.grey.withValues(alpha: 0.14),
child: Icon(Icons.card_giftcard,
color: r.actif ? AppTheme.accent : Colors.grey),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(r.nom,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: r.actif ? null : AppTheme.grisTexte,
)),
const SizedBox(height: 2),
Text(points(r.coutPoints),
style: const TextStyle(
color: AppTheme.grisTexte, fontSize: 13)),
],
),
),
Switch(
value: r.actif,
onChanged: (v) => _repo.modifier(r.copyWith(actif: v)),
),
PopupMenuButton<String>(
onSelected: (v) {
if (v == 'modifier') _editer(r);
if (v == 'supprimer') _supprimer(r);
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'modifier', child: Text('Modifier')),
PopupMenuItem(value: 'supprimer', child: Text('Supprimer')),
],
),
],
),
),
);
}
Widget _vide() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.card_giftcard_outlined,
size: 64, color: AppTheme.grisTexte),
const SizedBox(height: 16),
const Text('Aucune récompense',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
const Text(
'Créez des récompenses (ex : « Café offert » = 10 pts) que les clients pourront obtenir avec leurs points.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.grisTexte),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => _editer(),
icon: const Icon(Icons.add),
label: const Text('Créer une récompense'),
),
],
),
),
);
}
}
+268
View File
@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/reglages.dart';
import '../supabase_config.dart';
import '../theme.dart';
import '../utils/format.dart';
/// Onglet 4 : réglages du programme de fidélité.
/// Le réglage clé est le ratio « combien d'euros pour 1 point ».
class ReglagesScreen extends StatefulWidget {
const ReglagesScreen({super.key});
@override
State<ReglagesScreen> createState() => _ReglagesScreenState();
}
class _ReglagesScreenState extends State<ReglagesScreen> {
final _reglages = Reglages.instance;
late final TextEditingController _ratioCtrl;
late final TextEditingController _nomCtrl;
@override
void initState() {
super.initState();
_ratioCtrl = TextEditingController(
text: _formaterRatio(_reglages.eurosParPoint),
);
_nomCtrl = TextEditingController(text: _reglages.nomMagasin);
}
@override
void dispose() {
_ratioCtrl.dispose();
_nomCtrl.dispose();
super.dispose();
}
String _formaterRatio(double v) {
return (v == v.roundToDouble() ? v.toInt().toString() : v.toString())
.replaceAll('.', ',');
}
Future<void> _enregistrerRatio() async {
final v = double.tryParse(_ratioCtrl.text.replaceAll(',', '.'));
if (v == null || v <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Valeur invalide (doit être > 0)')),
);
_ratioCtrl.text = _formaterRatio(_reglages.eurosParPoint);
return;
}
await _reglages.definirEurosParPoint(v);
_ratioCtrl.text = _formaterRatio(v);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Réglage enregistré')),
);
}
}
Future<void> _enregistrerNom() async {
await _reglages.definirNomMagasin(_nomCtrl.text);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Réglages')),
body: AnimatedBuilder(
animation: _reglages,
builder: (context, _) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_titre('Programme de fidélité'),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Combien d\'euros dépensés pour gagner 1 point ?',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'1 point =',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _ratioCtrl,
textAlign: TextAlign.center,
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[0-9.,]')),
],
onSubmitted: (_) => _enregistrerRatio(),
decoration: const InputDecoration(
suffixText: '',
hintText: 'ex : 5',
),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _enregistrerRatio,
child: const Text('Enregistrer'),
),
),
const SizedBox(height: 16),
_apercus(),
],
),
),
),
const SizedBox(height: 24),
_titre('Magasin'),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Nom affiché sur le QR code du client',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
TextField(
controller: _nomCtrl,
textCapitalization: TextCapitalization.words,
onSubmitted: (_) => _enregistrerNom(),
onEditingComplete: _enregistrerNom,
decoration: const InputDecoration(
hintText: 'Ex : Boulangerie du Coin',
prefixIcon: Icon(Icons.storefront_outlined),
),
),
],
),
),
),
const SizedBox(height: 24),
_titre('À propos'),
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Données partagées via Supabase avec l\'app client. '
'Le scan se fait avec la caméra ; la scanette externe sera '
'branchée dans une prochaine version.',
style: TextStyle(color: AppTheme.grisTexte, height: 1.4),
),
),
),
const SizedBox(height: 24),
if (_emailConnecte() != null)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
'Connecté en tant que ${_emailConnecte()}',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13, color: AppTheme.grisTexte),
),
),
OutlinedButton.icon(
onPressed: _deconnexion,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade600,
minimumSize: const Size.fromHeight(52),
side: BorderSide(color: Colors.red.shade200),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
),
icon: const Icon(Icons.logout),
label: const Text('Se déconnecter'),
),
],
);
},
),
);
}
String? _emailConnecte() => supabase.auth.currentUser?.email;
Future<void> _deconnexion() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Se déconnecter ?'),
content: const Text('Vous devrez ressaisir vos identifiants staff.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Déconnexion'),
),
],
),
);
if (ok == true) await supabase.auth.signOut();
}
Widget _apercus() {
const exemples = [10.0, 20.0, 25.0, 50.0];
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.accent.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Aperçu',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppTheme.grisTexte)),
const SizedBox(height: 8),
for (final e in exemples)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Facture de ${euro(e)}'),
Text('${points(_reglages.pointsPour(e))}',
style: const TextStyle(
fontWeight: FontWeight.w700, color: AppTheme.accent)),
],
),
),
],
),
);
}
Widget _titre(String texte) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 10),
child: Text(texte,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppTheme.grisTexte)),
);
}
}
+177
View File
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../services/client_repository.dart';
import '../theme.dart';
import 'client_detail_screen.dart';
/// Onglet 2 : scan du QR code d'un client → ouverture de sa fiche.
///
/// V1 : on utilise la caméra de la tablette. Quand la scanette (lecteur externe)
/// sera branchée, elle se comporte comme un clavier : il suffira d'ajouter un
/// champ caché qui reçoit le code et appelle [_ouvrirParCode]. La logique de
/// recherche du client est déjà factorisée pour ça.
class ScanScreen extends StatefulWidget {
final bool active;
const ScanScreen({super.key, required this.active});
@override
State<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
final _controller = MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates,
formats: const [BarcodeFormat.qrCode],
);
final _repo = ClientRepository.instance;
bool _traite = false;
@override
void didUpdateWidget(covariant ScanScreen old) {
super.didUpdateWidget(old);
// En revenant sur l'onglet Scanner, on réautorise la détection.
if (widget.active && !old.active) _traite = false;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _onDetect(BarcodeCapture capture) async {
if (_traite || !widget.active) return;
final code = capture.barcodes.firstOrNull?.rawValue;
if (code == null || code.isEmpty) return;
setState(() => _traite = true);
await _ouvrirParCode(code.trim());
}
/// Retrouve le client par son code et ouvre sa fiche (ou signale l'échec).
Future<void> _ouvrirParCode(String code) async {
final client = _repo.parCode(code);
if (!mounted) return;
if (client == null) {
await _popupInconnu(code);
if (mounted) setState(() => _traite = false);
return;
}
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ClientDetailScreen(clientId: client.id)),
);
if (mounted) setState(() => _traite = false);
}
Future<void> _popupInconnu(String code) async {
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('QR code non reconnu'),
content: Text(
'Aucun client ne correspond au code « $code ».\n\n'
'Vérifiez qu\'il s\'agit bien d\'une carte de fidélité de ce magasin.'),
actions: [
FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (!widget.active) {
return const ColoredBox(color: Colors.black);
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
MobileScanner(
controller: _controller,
onDetect: _onDetect,
errorBuilder: (context, error) => _erreurCamera(error),
),
_cadre(),
_bandeauHaut(),
if (_traite)
Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
],
),
);
}
Widget _bandeauHaut() {
return SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Container(
margin: const EdgeInsets.only(top: 24),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(30),
),
child: const Text(
'Scannez le QR code de fidélité du client',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600),
),
),
),
);
}
Widget _cadre() {
return Center(
child: Container(
width: 240,
height: 240,
decoration: BoxDecoration(
border: Border.all(color: AppTheme.accent, width: 3),
borderRadius: BorderRadius.circular(20),
),
),
);
}
Widget _erreurCamera(MobileScannerException error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.no_photography_outlined,
color: Colors.white70, size: 56),
const SizedBox(height: 16),
const Text(
'Accès à la caméra impossible.\nAutorisez la caméra dans les réglages.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, fontSize: 15),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => _controller.start(),
child: const Text('Réessayer'),
),
],
),
),
);
}
}
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../supabase_config.dart';
import '../theme.dart';
/// Connexion du personnel du magasin (staff). Les comptes staff sont créés dans
/// Supabase (voir SETUP.md) — pas d'inscription libre ici.
class StaffLoginScreen extends StatefulWidget {
const StaffLoginScreen({super.key});
@override
State<StaffLoginScreen> createState() => _StaffLoginScreenState();
}
class _StaffLoginScreenState extends State<StaffLoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _mdpCtrl = TextEditingController();
bool _enCours = false;
bool _voirMdp = false;
String? _erreur;
@override
void dispose() {
_emailCtrl.dispose();
_mdpCtrl.dispose();
super.dispose();
}
Future<void> _connexion() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_enCours = true;
_erreur = null;
});
try {
await supabase.auth.signInWithPassword(
email: _emailCtrl.text.trim(),
password: _mdpCtrl.text,
);
// Vérifie que ce compte est bien autorisé (staff).
final estStaff = await supabase.rpc('est_staff') as bool? ?? false;
if (!estStaff) {
await supabase.auth.signOut();
if (mounted) {
setState(() => _erreur =
'Ce compte n\'est pas autorisé pour la tablette (staff).');
}
}
// Si staff : l'AuthGate bascule automatiquement vers l'app.
} on AuthException catch (e) {
if (mounted) setState(() => _erreur = e.message);
} catch (e) {
if (mounted) setState(() => _erreur = 'Erreur : $e');
} finally {
if (mounted) setState(() => _enCours = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppTheme.accent.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(Icons.storefront,
size: 40, color: AppTheme.accent),
),
),
const SizedBox(height: 20),
const Text('Espace magasin',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.w800)),
const SizedBox(height: 6),
const Text('Connexion du personnel',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.grisTexte)),
const SizedBox(height: 28),
TextFormField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.mail_outline),
),
validator: (v) => (v == null || !v.contains('@'))
? 'Email invalide'
: null,
),
const SizedBox(height: 14),
TextFormField(
controller: _mdpCtrl,
obscureText: !_voirMdp,
decoration: InputDecoration(
labelText: 'Mot de passe',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_voirMdp
? Icons.visibility_off
: Icons.visibility),
onPressed: () =>
setState(() => _voirMdp = !_voirMdp),
),
),
validator: (v) => (v == null || v.isEmpty)
? 'Mot de passe requis'
: null,
onFieldSubmitted: (_) => _connexion(),
),
if (_erreur != null) ...[
const SizedBox(height: 16),
Text(_erreur!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade600)),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _enCours ? null : _connexion,
child: _enCours
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('Se connecter'),
),
],
),
),
),
),
),
),
);
}
}
+223
View File
@@ -0,0 +1,223 @@
import 'package:flutter/foundation.dart';
import '../models/client.dart';
import '../models/mouvement.dart';
import '../models/recompense.dart';
import '../supabase_config.dart';
import '../utils/format.dart';
import 'reglages.dart';
/// Erreur levée quand un client n'a pas assez de points pour une récompense.
class SoldeInsuffisant implements Exception {
final double manquant;
SoldeInsuffisant(this.manquant);
@override
String toString() => 'Solde insuffisant (${points(manquant)} manquants)';
}
/// Source de vérité des clients (côté staff), adossée à Supabase.
/// Le solde de points est tenu à jour côté base (trigger) à chaque mouvement ;
/// on reflète le delta en mémoire pour un affichage immédiat.
class ClientRepository extends ChangeNotifier {
ClientRepository._();
static final ClientRepository instance = ClientRepository._();
final List<Client> _clients = [];
bool _charge = false;
bool _enCours = false;
String? _erreur;
List<Client> get clients => List.unmodifiable(_clients);
bool get charge => _charge;
bool get enCours => _enCours;
String? get erreur => _erreur;
Future<void> charger() async {
_enCours = true;
_erreur = null;
notifyListeners();
try {
final rows = await supabase.from('clients').select().order('nom');
_clients
..clear()
..addAll((rows as List).map((e) => Client.fromMap(e)));
_charge = true;
} catch (e) {
_erreur = e.toString();
} finally {
_enCours = false;
notifyListeners();
}
}
Client? parCode(String code) {
for (final c in _clients) {
if (c.code == code) return c;
}
return null;
}
Client? parId(String id) {
for (final c in _clients) {
if (c.id == id) return c;
}
return null;
}
/// Filtre par nom, téléphone ou code (recherche insensible à la casse).
List<Client> rechercher(String requete) {
final q = requete.trim().toLowerCase();
if (q.isEmpty) return clients;
return _clients
.where((c) =>
c.nom.toLowerCase().contains(q) ||
(c.prenom?.toLowerCase().contains(q) ?? false) ||
(c.telephone?.toLowerCase().contains(q) ?? false) ||
c.code.toLowerCase().contains(q))
.toList();
}
/// Crée un client « au comptoir » (sans compte de connexion). Le code unique
/// et le QR sont générés côté base.
Future<Client> creer({
required String nom,
String? prenom,
String? telephone,
}) async {
final row = await supabase
.from('clients')
.insert({
'nom': nom.trim(),
'prenom': _ouNull(prenom),
'telephone': _ouNull(telephone),
})
.select()
.single();
final cree = Client.fromMap(row);
_clients.add(cree);
_trier();
notifyListeners();
return cree;
}
/// Modifie nom / prénom / téléphone (pas le solde : il évolue via facture).
Future<void> modifier(Client c) async {
await supabase.from('clients').update({
'nom': c.nom.trim(),
'prenom': _ouNull(c.prenom),
'telephone': _ouNull(c.telephone),
}).eq('id', c.id);
final i = _clients.indexWhere((e) => e.id == c.id);
if (i != -1) {
_clients[i] =
_clients[i].copyWith(nom: c.nom, prenom: c.prenom, telephone: c.telephone);
}
_trier();
notifyListeners();
}
/// Renvoie null si la chaîne est vide/espaces, sinon la valeur nettoyée.
String? _ouNull(String? v) =>
(v == null || v.trim().isEmpty) ? null : v.trim();
Future<void> supprimer(String id) async {
await supabase.from('clients').delete().eq('id', id);
_clients.removeWhere((e) => e.id == id);
notifyListeners();
}
/// Ajoute une facture : crédite les points correspondant au montant dépensé.
/// Retourne le nombre de points gagnés.
Future<double> ajouterFacture({
required String clientId,
required double montantEuros,
}) async {
final gagnes = Reglages.instance.pointsPour(montantEuros);
await supabase.from('mouvements').insert({
'client_id': clientId,
'type': TypeMouvement.facture,
'montant_euros': montantEuros,
'points': gagnes,
'libelle': 'Facture ${euro(montantEuros)}',
});
_majSoldeMemoire(clientId, gagnes);
return gagnes;
}
/// Utilise une récompense : débite son coût. Lève [SoldeInsuffisant] si besoin.
Future<void> utiliserRecompense({
required String clientId,
required Recompense recompense,
}) async {
final client = parId(clientId);
if (client == null) throw Exception('Client introuvable');
if (client.points < recompense.coutPoints) {
throw SoldeInsuffisant(recompense.coutPoints - client.points);
}
await supabase.from('mouvements').insert({
'client_id': clientId,
'type': TypeMouvement.recompense,
'points': -recompense.coutPoints,
'libelle': recompense.nom,
});
_majSoldeMemoire(clientId, -recompense.coutPoints);
}
/// Correction manuelle du solde (ajout ou retrait de points).
Future<void> ajuster({
required String clientId,
required double points,
required String libelle,
}) async {
await supabase.from('mouvements').insert({
'client_id': clientId,
'type': TypeMouvement.ajustement,
'points': points,
'libelle': libelle,
});
_majSoldeMemoire(clientId, points);
}
/// Historique d'un client, du plus récent au plus ancien.
Future<List<Mouvement>> mouvements(String clientId) async {
final rows = await supabase
.from('mouvements')
.select()
.eq('client_id', clientId)
.order('created_at', ascending: false);
return (rows as List).map((e) => Mouvement.fromMap(e)).toList();
}
/// Corrige le montant d'une facture existante : recalcule les points au ratio
/// courant et réajuste le solde du client (via le trigger côté base).
Future<void> modifierFacture({
required Mouvement mouvement,
required double nouveauMontant,
}) async {
final nouveauxPoints = Reglages.instance.pointsPour(nouveauMontant);
await supabase.from('mouvements').update({
'montant_euros': nouveauMontant,
'points': nouveauxPoints,
'libelle': 'Facture ${euro(nouveauMontant)}',
}).eq('id', mouvement.id);
_majSoldeMemoire(mouvement.clientId, nouveauxPoints - mouvement.points);
}
/// Supprime un mouvement (facture, récompense ou ajustement). Le solde du
/// client est réajusté automatiquement (trigger côté base).
Future<void> supprimerMouvement(Mouvement mouvement) async {
await supabase.from('mouvements').delete().eq('id', mouvement.id);
_majSoldeMemoire(mouvement.clientId, -mouvement.points);
}
void _majSoldeMemoire(String clientId, double delta) {
final i = _clients.indexWhere((c) => c.id == clientId);
if (i != -1) {
_clients[i] = _clients[i].copyWith(points: _clients[i].points + delta);
}
notifyListeners();
}
void _trier() => _clients
.sort((a, b) => a.nom.toLowerCase().compareTo(b.nom.toLowerCase()));
}
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/foundation.dart';
import '../models/recompense.dart';
import '../supabase_config.dart';
/// Source de vérité du catalogue de récompenses (table `recompenses` Supabase).
class RecompenseRepository extends ChangeNotifier {
RecompenseRepository._();
static final RecompenseRepository instance = RecompenseRepository._();
final List<Recompense> _recompenses = [];
bool _charge = false;
List<Recompense> get recompenses => List.unmodifiable(_recompenses);
/// Uniquement les récompenses proposées (actives), triées par coût croissant.
List<Recompense> get actives {
final l = _recompenses.where((r) => r.actif).toList()
..sort((a, b) => a.coutPoints.compareTo(b.coutPoints));
return l;
}
bool get charge => _charge;
Future<void> charger() async {
try {
final rows =
await supabase.from('recompenses').select().order('cout_points');
_recompenses
..clear()
..addAll((rows as List).map((e) => Recompense.fromMap(e)));
_charge = true;
notifyListeners();
} catch (_) {
// Ignore si non connecté ; sera rechargé après connexion.
}
}
Future<Recompense> creer(
{required String nom, required double coutPoints}) async {
final row = await supabase
.from('recompenses')
.insert({'nom': nom.trim(), 'cout_points': coutPoints, 'actif': true})
.select()
.single();
final cree = Recompense.fromMap(row);
_recompenses.add(cree);
_trier();
notifyListeners();
return cree;
}
Future<void> modifier(Recompense r) async {
await supabase.from('recompenses').update({
'nom': r.nom.trim(),
'cout_points': r.coutPoints,
'actif': r.actif,
}).eq('id', r.id);
final i = _recompenses.indexWhere((e) => e.id == r.id);
if (i != -1) _recompenses[i] = r;
_trier();
notifyListeners();
}
Future<void> supprimer(String id) async {
await supabase.from('recompenses').delete().eq('id', id);
_recompenses.removeWhere((e) => e.id == id);
notifyListeners();
}
void _trier() =>
_recompenses.sort((a, b) => a.coutPoints.compareTo(b.coutPoints));
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/foundation.dart';
import '../config.dart';
import '../supabase_config.dart';
/// Réglages globaux du magasin (ligne unique `reglages` id=1 sur Supabase).
/// Étend [ChangeNotifier] pour que les écrans se rafraîchissent au changement.
class Reglages extends ChangeNotifier {
Reglages._();
static final Reglages instance = Reglages._();
double _eurosParPoint = Config.eurosParPointParDefaut;
String _nomMagasin = '';
/// Nombre d'euros à dépenser pour gagner 1 point.
double get eurosParPoint => _eurosParPoint;
/// Nom du magasin, affiché sur la carte / le QR du client.
String get nomMagasin => _nomMagasin;
Future<void> charger() async {
try {
final row = await supabase
.from('reglages')
.select('euros_par_point, nom_magasin')
.eq('id', 1)
.maybeSingle();
if (row != null) {
_eurosParPoint = (row['euros_par_point'] as num?)?.toDouble() ??
Config.eurosParPointParDefaut;
_nomMagasin = (row['nom_magasin'] as String?) ?? '';
notifyListeners();
}
} catch (_) {
// Réglages par défaut si non connecté / hors-ligne.
}
}
/// Points gagnés pour un montant donné, selon le ratio courant.
double pointsPour(double euros) =>
_eurosParPoint > 0 ? euros / _eurosParPoint : 0;
Future<void> definirEurosParPoint(double valeur) async {
if (valeur <= 0) return;
await supabase.from('reglages').update({'euros_par_point': valeur}).eq('id', 1);
_eurosParPoint = valeur;
notifyListeners();
}
Future<void> definirNomMagasin(String valeur) async {
final v = valeur.trim();
await supabase.from('reglages').update({'nom_magasin': v}).eq('id', 1);
_nomMagasin = v;
notifyListeners();
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:supabase_flutter/supabase_flutter.dart';
/// Configuration de connexion au backend Supabase (partagé avec l'app client).
///
/// ⚠️ À remplir avec les valeurs de VOTRE projet Supabase :
/// Project Settings → API → « Project URL » et « anon public ».
/// Voir le guide : supabase/SETUP.md
///
/// La clé « anon » est publique et peut vivre dans l'app : la sécurité est
/// assurée par les règles RLS définies dans supabase/schema.sql.
class SupabaseConfig {
SupabaseConfig._();
static const String url = 'https://zfhcnzbggpdvgvosrkgp.supabase.co';
static const String anonKey = 'sb_publishable_4R3-_q1Zy_usliTff01Ilg_jhFlIdPH';
static bool get estConfigure =>
!url.contains('VOTRE-PROJET') && !anonKey.contains('VOTRE_CLE');
}
/// Raccourci vers le client Supabase (une fois [Supabase.initialize] appelé).
SupabaseClient get supabase => Supabase.instance.client;
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
/// Thème noir & blanc épuré, avec une seule couleur d'accent.
/// Vert émeraude pour évoquer les points / gains (et se distinguer de l'app
/// sœur « Gestion Prix » qui utilise le bleu, sur la même tablette).
/// Change [accent] pour ajuster la touche de couleur de toute l'app.
class AppTheme {
static const Color accent = Color(0xFF12805C); // vert émeraude
static const Color noir = Color(0xFF111114);
static const Color grisTexte = Color(0xFF6B6B72);
static ThemeData clair() {
final scheme = ColorScheme.fromSeed(
seedColor: accent,
brightness: Brightness.light,
).copyWith(
primary: noir,
onPrimary: Colors.white,
secondary: accent,
surface: Colors.white,
onSurface: noir,
);
return _base(scheme, const Color(0xFFF6F6F7));
}
static ThemeData sombre() {
final scheme = ColorScheme.fromSeed(
seedColor: accent,
brightness: Brightness.dark,
).copyWith(
primary: Colors.white,
onPrimary: noir,
secondary: accent,
);
return _base(scheme, const Color(0xFF0E0E11));
}
static ThemeData _base(ColorScheme scheme, Color fond) {
final base = ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: fond,
fontFamily: 'Roboto',
);
return base.copyWith(
appBarTheme: AppBarTheme(
backgroundColor: fond,
foregroundColor: scheme.onSurface,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: scheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: scheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
margin: EdgeInsets.zero,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: accent, width: 2),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surface,
indicatorColor: accent.withValues(alpha: 0.14),
elevation: 0,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? scheme.onSurface : grisTexte,
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return IconThemeData(
color: selected ? accent : grisTexte,
);
}),
),
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:intl/intl.dart';
final NumberFormat _euro = NumberFormat.currency(locale: 'fr_FR', symbol: '');
final DateFormat _dateHeure = DateFormat('d MMM y • HH:mm', 'fr_FR');
String euro(double v) => _euro.format(v);
/// Formate un nombre de points de façon lisible :
/// entier sans décimale (« 12 pts »), sinon jusqu'à 2 décimales sans zéros
/// inutiles (« 0,48 pt », « 2,5 pts »). Gère le singulier/pluriel.
String points(double v) {
final arrondi = (v * 100).round() / 100;
var s = arrondi == arrondi.roundToDouble()
? arrondi.toInt().toString()
: arrondi
.toStringAsFixed(2)
.replaceAll(RegExp(r'0+$'), '')
.replaceAll(RegExp(r'\.$'), '');
s = s.replaceAll('.', ',');
final abs = arrondi.abs();
final pluriel = abs >= 2 ? 'pts' : 'pt';
return '$s $pluriel';
}
/// Comme [points] mais sans le suffixe « pt/pts » (pour les gros affichages).
String pointsNombre(double v) {
final complet = points(v);
return complet.replaceAll(RegExp(r'\s?pts?$'), '');
}
String dateHeure(DateTime d) => _dateHeure.format(d);
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import '../utils/format.dart';
/// Petite pastille colorée affichant un solde de points (ex : « 12,5 pts »).
class PastillePoints extends StatelessWidget {
final double valeur;
final bool grande;
const PastillePoints(this.valeur, {super.key, this.grande = false});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: grande ? 16 : 12,
vertical: grande ? 8 : 5,
),
decoration: BoxDecoration(
color: AppTheme.accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(30),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.stars_rounded,
size: grande ? 22 : 16, color: AppTheme.accent),
const SizedBox(width: 6),
Text(
points(valeur),
style: TextStyle(
color: AppTheme.accent,
fontWeight: FontWeight.w700,
fontSize: grande ? 18 : 14,
),
),
],
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../models/client.dart';
import '../services/reglages.dart';
import '../theme.dart';
/// Affiche le QR code d'un client dans une boîte de dialogue. Le client peut le
/// prendre en photo (ou plus tard on l'imprimera) : c'est sa carte de fidélité.
Future<void> afficherQrClient(BuildContext context, Client client) {
final magasin = Reglages.instance.nomMagasin;
return showDialog<void>(
context: context,
builder: (ctx) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (magasin.isNotEmpty)
Text(
magasin,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.grisTexte),
),
const SizedBox(height: 4),
Text(
client.nomComplet,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE0E0E4)),
),
child: QrImageView(
data: client.code,
version: QrVersions.auto,
size: 220,
gapless: false,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: AppTheme.noir,
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: AppTheme.noir,
),
),
),
const SizedBox(height: 14),
Text(
client.code,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 2,
),
),
const SizedBox(height: 8),
const Text(
'Présentez ce code en caisse pour cumuler des points.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: AppTheme.grisTexte),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Fermer'),
),
],
),
),
),
);
}