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,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-';
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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é')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user