Initial commit: app Fideliter client (Flutter)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.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();
|
||||
|
||||
await initializeDateFormatting('fr_FR', null);
|
||||
|
||||
if (SupabaseConfig.estConfigure) {
|
||||
await Supabase.initialize(
|
||||
url: SupabaseConfig.url,
|
||||
publishableKey: SupabaseConfig.anonKey,
|
||||
);
|
||||
}
|
||||
|
||||
runApp(const MonApp());
|
||||
}
|
||||
|
||||
class MonApp extends StatelessWidget {
|
||||
const MonApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Ma fidélité',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.clair(),
|
||||
darkTheme: AppTheme.sombre(),
|
||||
themeMode: ThemeMode.light,
|
||||
home: const AuthGate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// Le compte fidélité du client connecté. Le [code] est encodé dans son QR.
|
||||
class Client {
|
||||
final String id; // uuid Supabase
|
||||
final String code; // ex : « FID-3F9K2A »
|
||||
final String nom;
|
||||
final String? prenom;
|
||||
final String? telephone;
|
||||
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';
|
||||
}
|
||||
|
||||
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,39 @@
|
||||
/// Type d'un mouvement de points dans l'historique.
|
||||
class TypeMouvement {
|
||||
static const facture = 'facture';
|
||||
static const recompense = 'recompense';
|
||||
static const ajustement = 'ajustement';
|
||||
}
|
||||
|
||||
/// Une ligne d'historique : gain (facture) ou dépense (récompense).
|
||||
class Mouvement {
|
||||
final String id;
|
||||
final String type;
|
||||
final double? montantEuros;
|
||||
final double points; // signé
|
||||
final String libelle;
|
||||
final DateTime date;
|
||||
|
||||
const Mouvement({
|
||||
required this.id,
|
||||
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(),
|
||||
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,23 @@
|
||||
/// Une récompense du catalogue que le client peut viser avec ses points.
|
||||
class Recompense {
|
||||
final String id;
|
||||
final String nom;
|
||||
final double coutPoints;
|
||||
final bool actif;
|
||||
|
||||
const Recompense({
|
||||
required this.id,
|
||||
required this.nom,
|
||||
required this.coutPoints,
|
||||
this.actif = true,
|
||||
});
|
||||
|
||||
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,99 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../services/session_client.dart';
|
||||
import '../supabase_config.dart';
|
||||
import 'complete_profile_screen.dart';
|
||||
import 'home_client_screen.dart';
|
||||
import 'welcome_screen.dart';
|
||||
|
||||
/// Aiguille entre l'accueil non connecté, la finalisation de profil et l'app.
|
||||
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 WelcomeScreen();
|
||||
return _SessionChargee(key: ValueKey(session.user.id));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge les données du client connecté, puis affiche l'app (ou la
|
||||
/// finalisation de profil si le compte n'a pas encore de fiche fidélité).
|
||||
class _SessionChargee extends StatefulWidget {
|
||||
const _SessionChargee({super.key});
|
||||
|
||||
@override
|
||||
State<_SessionChargee> createState() => _SessionChargeeState();
|
||||
}
|
||||
|
||||
class _SessionChargeeState extends State<_SessionChargee> {
|
||||
late Future<void> _chargement;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_chargement = SessionClient.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 AnimatedBuilder(
|
||||
animation: SessionClient.instance,
|
||||
builder: (context, _) {
|
||||
final aProfil = SessionClient.instance.monClient != null;
|
||||
return aProfil
|
||||
? const HomeClientScreen()
|
||||
: const CompleteProfileScreen();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EcranNonConfigure extends StatelessWidget {
|
||||
const _EcranNonConfigure();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
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.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Color(0xFF6B6B72)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/session_client.dart';
|
||||
import '../supabase_config.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
/// Affiché quand le compte est connecté mais n'a pas encore de fiche fidélité
|
||||
/// (ex : après une inscription avec confirmation d'email). On crée le profil.
|
||||
class CompleteProfileScreen extends StatefulWidget {
|
||||
const CompleteProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CompleteProfileScreen> createState() => _CompleteProfileScreenState();
|
||||
}
|
||||
|
||||
class _CompleteProfileScreenState extends State<CompleteProfileScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nomCtrl = TextEditingController();
|
||||
final _prenomCtrl = TextEditingController();
|
||||
final _telCtrl = TextEditingController();
|
||||
bool _enCours = false;
|
||||
String? _erreur;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nomCtrl.dispose();
|
||||
_prenomCtrl.dispose();
|
||||
_telCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _valider() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() {
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
});
|
||||
try {
|
||||
await SessionClient.instance.creerProfil(
|
||||
nom: _nomCtrl.text,
|
||||
prenom: _prenomCtrl.text,
|
||||
telephone: _telCtrl.text,
|
||||
);
|
||||
// creerProfil recharge la session → l'AuthGate affiche l'accueil.
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_erreur = 'Erreur : $e';
|
||||
_enCours = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Finaliser mon profil'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Se déconnecter',
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () => supabase.auth.signOut(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'Encore une étape pour activer votre carte de fidélité.',
|
||||
style: TextStyle(color: AppTheme.grisTexte),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: _nomCtrl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nom',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Nom obligatoire' : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _prenomCtrl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Prénom',
|
||||
prefixIcon: Icon(Icons.badge_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _telCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Téléphone (facultatif)',
|
||||
prefixIcon: Icon(Icons.phone_outlined),
|
||||
),
|
||||
),
|
||||
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 : _valider,
|
||||
child: _enCours
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Text('Activer ma carte'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
|
||||
import '../models/mouvement.dart';
|
||||
import '../models/recompense.dart';
|
||||
import '../services/session_client.dart';
|
||||
import '../supabase_config.dart';
|
||||
import '../theme.dart';
|
||||
import '../utils/format.dart';
|
||||
|
||||
/// Écran principal du client : sa carte de fidélité (points + QR), les
|
||||
/// récompenses qu'il peut viser, et son historique.
|
||||
class HomeClientScreen extends StatefulWidget {
|
||||
const HomeClientScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeClientScreen> createState() => _HomeClientScreenState();
|
||||
}
|
||||
|
||||
class _HomeClientScreenState extends State<HomeClientScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _session = SessionClient.instance;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Rafraîchit le solde au retour dans l'app (après un passage en caisse).
|
||||
if (state == AppLifecycleState.resumed) _session.charger();
|
||||
}
|
||||
|
||||
Future<void> _deconnexion() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Se déconnecter ?'),
|
||||
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) {
|
||||
_session.vider();
|
||||
await supabase.auth.signOut();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_session.nomMagasin.isEmpty
|
||||
? 'Ma fidélité'
|
||||
: _session.nomMagasin),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'Rafraîchir',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => _session.charger(),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Se déconnecter',
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: _deconnexion,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: AnimatedBuilder(
|
||||
animation: _session,
|
||||
builder: (context, _) {
|
||||
final client = _session.monClient;
|
||||
if (client == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => _session.charger(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||||
children: [
|
||||
_carteFidelite(client.nomComplet, client.code, client.points),
|
||||
const SizedBox(height: 24),
|
||||
if (_session.recompenses.isNotEmpty) ...[
|
||||
_titre('Mes récompenses'),
|
||||
const SizedBox(height: 8),
|
||||
..._session.recompenses.map(
|
||||
(r) => _ligneRecompense(r, client.points),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
_titre('Historique'),
|
||||
const SizedBox(height: 8),
|
||||
_historique(),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _carteFidelite(String nom, String code, double pts) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(nom,
|
||||
style:
|
||||
const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 16),
|
||||
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(pts),
|
||||
style: const TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppTheme.accent),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: Text('pts',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.accent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
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: code,
|
||||
version: QrVersions.auto,
|
||||
size: 200,
|
||||
eyeStyle: const QrEyeStyle(
|
||||
eyeShape: QrEyeShape.square,
|
||||
color: AppTheme.noir,
|
||||
),
|
||||
dataModuleStyle: const QrDataModuleStyle(
|
||||
dataModuleShape: QrDataModuleShape.square,
|
||||
color: AppTheme.noir,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(code,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 2)),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'Présentez ce code en caisse pour cumuler vos points.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.grisTexte),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _ligneRecompense(Recompense r, double solde) {
|
||||
final atteignable = solde >= r.coutPoints;
|
||||
final progression =
|
||||
r.coutPoints <= 0 ? 1.0 : (solde / r.coutPoints).clamp(0.0, 1.0);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: atteignable
|
||||
? AppTheme.accent.withValues(alpha: 0.14)
|
||||
: Colors.grey.withValues(alpha: 0.12),
|
||||
child: Icon(Icons.card_giftcard,
|
||||
color: atteignable ? AppTheme.accent : Colors.grey),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(r.nom,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: progression,
|
||||
minHeight: 6,
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.18),
|
||||
color: AppTheme.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
atteignable
|
||||
? 'Disponible ! (${points(r.coutPoints)})'
|
||||
: 'Encore ${points(r.coutPoints - solde)} • coût ${points(r.coutPoints)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: atteignable
|
||||
? AppTheme.accent
|
||||
: AppTheme.grisTexte,
|
||||
fontWeight:
|
||||
atteignable ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _historique() {
|
||||
final mvts = _session.mouvements;
|
||||
if (mvts.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(
|
||||
child: Text('Aucun mouvement pour l\'instant.',
|
||||
style: TextStyle(color: AppTheme.grisTexte)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(children: mvts.map(_ligneMouvement).toList());
|
||||
}
|
||||
|
||||
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 Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _titre(String texte) => Text(texte,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../supabase_config.dart';
|
||||
|
||||
/// Connexion d'un client existant (email + mot de passe).
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
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,
|
||||
);
|
||||
// L'AuthGate prend le relais automatiquement (pop de cet écran).
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} 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(
|
||||
appBar: AppBar(title: const Text('Connexion')),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
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,192 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../services/session_client.dart';
|
||||
import '../supabase_config.dart';
|
||||
import '../theme.dart';
|
||||
|
||||
/// Création d'un compte client : email + mot de passe + profil (nom, téléphone).
|
||||
/// Le code de fidélité et le QR sont générés automatiquement côté serveur.
|
||||
class SignupScreen extends StatefulWidget {
|
||||
const SignupScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SignupScreen> createState() => _SignupScreenState();
|
||||
}
|
||||
|
||||
class _SignupScreenState extends State<SignupScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nomCtrl = TextEditingController();
|
||||
final _prenomCtrl = TextEditingController();
|
||||
final _telCtrl = TextEditingController();
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _mdpCtrl = TextEditingController();
|
||||
bool _enCours = false;
|
||||
bool _voirMdp = false;
|
||||
String? _erreur;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nomCtrl.dispose();
|
||||
_prenomCtrl.dispose();
|
||||
_telCtrl.dispose();
|
||||
_emailCtrl.dispose();
|
||||
_mdpCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _inscription() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
setState(() {
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
});
|
||||
try {
|
||||
final res = await supabase.auth.signUp(
|
||||
email: _emailCtrl.text.trim(),
|
||||
password: _mdpCtrl.text,
|
||||
);
|
||||
|
||||
if (res.session != null) {
|
||||
// Connecté directement (confirmation d'email désactivée) → on crée le
|
||||
// profil fidélité. L'AuthGate basculera ensuite vers l'accueil.
|
||||
await SessionClient.instance.creerProfil(
|
||||
nom: _nomCtrl.text,
|
||||
prenom: _prenomCtrl.text,
|
||||
telephone: _telCtrl.text,
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} else {
|
||||
// Confirmation d'email requise : le profil sera finalisé à la 1re
|
||||
// connexion (après validation du mail).
|
||||
if (mounted) await _popupVerifierEmail();
|
||||
}
|
||||
} on AuthException catch (e) {
|
||||
if (mounted) setState(() => _erreur = e.message);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _erreur = 'Erreur : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _enCours = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _popupVerifierEmail() async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Vérifiez votre email'),
|
||||
content: const Text(
|
||||
'Un email de confirmation vous a été envoyé. Validez-le puis '
|
||||
'connectez-vous pour finaliser votre carte de fidélité.'),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop(); // retour à l'accueil
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Créer un compte')),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nomCtrl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Nom',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (v) =>
|
||||
(v == null || v.trim().isEmpty) ? 'Nom obligatoire' : null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _prenomCtrl,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Prénom',
|
||||
prefixIcon: Icon(Icons.badge_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _telCtrl,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Téléphone (facultatif)',
|
||||
prefixIcon: Icon(Icons.phone_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
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.length < 6)
|
||||
? '6 caractères minimum'
|
||||
: null,
|
||||
onFieldSubmitted: (_) => _inscription(),
|
||||
),
|
||||
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 : _inscription,
|
||||
child: _enCours
|
||||
? const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: const Text('Créer mon compte'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Votre QR code de fidélité sera généré automatiquement.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.grisTexte),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme.dart';
|
||||
import 'login_screen.dart';
|
||||
import 'signup_screen.dart';
|
||||
|
||||
/// Premier écran : le client choisit de se connecter ou de créer un compte.
|
||||
class WelcomeScreen extends StatelessWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(flex: 2),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.accent.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.card_membership,
|
||||
size: 56, color: AppTheme.accent),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
const Text(
|
||||
'Votre carte de fidélité',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Cumulez des points à chaque achat et profitez de récompenses.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 15, color: AppTheme.grisTexte),
|
||||
),
|
||||
const Spacer(flex: 3),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const SignupScreen()),
|
||||
),
|
||||
child: const Text('Créer un compte'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: () => Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
child: const Text('J\'ai déjà un compte'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/client.dart';
|
||||
import '../models/mouvement.dart';
|
||||
import '../models/recompense.dart';
|
||||
import '../supabase_config.dart';
|
||||
|
||||
/// Données du client connecté (son compte, ses points, son historique) + le
|
||||
/// contexte magasin (nom, récompenses proposées). Source de vérité côté client.
|
||||
class SessionClient extends ChangeNotifier {
|
||||
SessionClient._();
|
||||
static final SessionClient instance = SessionClient._();
|
||||
|
||||
Client? _monClient;
|
||||
List<Mouvement> _mouvements = [];
|
||||
List<Recompense> _recompenses = [];
|
||||
String _nomMagasin = '';
|
||||
double _eurosParPoint = 0;
|
||||
bool _enCours = false;
|
||||
String? _erreur;
|
||||
|
||||
Client? get monClient => _monClient;
|
||||
List<Mouvement> get mouvements => List.unmodifiable(_mouvements);
|
||||
List<Recompense> get recompenses => List.unmodifiable(_recompenses);
|
||||
String get nomMagasin => _nomMagasin;
|
||||
double get eurosParPoint => _eurosParPoint;
|
||||
bool get enCours => _enCours;
|
||||
String? get erreur => _erreur;
|
||||
|
||||
/// Charge toutes les données du client connecté. [_monClient] reste null si
|
||||
/// le compte n'a pas encore de profil fidélité (→ écran « finaliser profil »).
|
||||
Future<void> charger() async {
|
||||
_enCours = true;
|
||||
_erreur = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final uid = supabase.auth.currentUser?.id;
|
||||
if (uid == null) {
|
||||
_monClient = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final row = await supabase
|
||||
.from('clients')
|
||||
.select()
|
||||
.eq('user_id', uid)
|
||||
.maybeSingle();
|
||||
_monClient = row == null ? null : Client.fromMap(row);
|
||||
|
||||
// Contexte magasin (lisible par tout compte connecté).
|
||||
final reglages = await supabase
|
||||
.from('reglages')
|
||||
.select('euros_par_point, nom_magasin')
|
||||
.eq('id', 1)
|
||||
.maybeSingle();
|
||||
if (reglages != null) {
|
||||
_eurosParPoint =
|
||||
(reglages['euros_par_point'] as num?)?.toDouble() ?? 0;
|
||||
_nomMagasin = (reglages['nom_magasin'] as String?) ?? '';
|
||||
}
|
||||
|
||||
final recs = await supabase
|
||||
.from('recompenses')
|
||||
.select()
|
||||
.eq('actif', true)
|
||||
.order('cout_points');
|
||||
_recompenses =
|
||||
(recs as List).map((e) => Recompense.fromMap(e)).toList();
|
||||
|
||||
if (_monClient != null) {
|
||||
final mvts = await supabase
|
||||
.from('mouvements')
|
||||
.select()
|
||||
.eq('client_id', _monClient!.id)
|
||||
.order('created_at', ascending: false);
|
||||
_mouvements =
|
||||
(mvts as List).map((e) => Mouvement.fromMap(e)).toList();
|
||||
} else {
|
||||
_mouvements = [];
|
||||
}
|
||||
} catch (e) {
|
||||
_erreur = e.toString();
|
||||
} finally {
|
||||
_enCours = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée le profil fidélité du compte connecté (nom + téléphone). Le code et le
|
||||
/// QR sont générés côté base. Utilisé à l'inscription / finalisation de profil.
|
||||
Future<void> creerProfil({
|
||||
required String nom,
|
||||
String? prenom,
|
||||
String? telephone,
|
||||
}) async {
|
||||
final uid = supabase.auth.currentUser?.id;
|
||||
if (uid == null) throw Exception('Non connecté');
|
||||
String? ouNull(String? v) =>
|
||||
(v == null || v.trim().isEmpty) ? null : v.trim();
|
||||
await supabase.from('clients').insert({
|
||||
'user_id': uid,
|
||||
'nom': nom.trim(),
|
||||
'prenom': ouNull(prenom),
|
||||
'telephone': ouNull(telephone),
|
||||
});
|
||||
await charger();
|
||||
}
|
||||
|
||||
void vider() {
|
||||
_monClient = null;
|
||||
_mouvements = [];
|
||||
_recompenses = [];
|
||||
_nomMagasin = '';
|
||||
_eurosParPoint = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
/// Configuration de connexion au backend Supabase (LE MÊME que l'app magasin).
|
||||
///
|
||||
/// ⚠️ Renseigner les valeurs de VOTRE projet Supabase :
|
||||
/// Project Settings → API → « Project URL » et « anon public ».
|
||||
/// Voir le guide : ../app_fideliter/supabase/SETUP.md
|
||||
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;
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Thème identique à l'app magasin (même marque de fidélité) : noir & blanc
|
||||
/// épuré + accent vert émeraude.
|
||||
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,28 @@
|
||||
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 pluriel = arrondi.abs() >= 2 ? 'pts' : 'pt';
|
||||
return '$s $pluriel';
|
||||
}
|
||||
|
||||
/// Comme [points] mais sans le suffixe « pt/pts » (pour les gros affichages).
|
||||
String pointsNombre(double v) =>
|
||||
points(v).replaceAll(RegExp(r'\s?pts?$'), '');
|
||||
|
||||
String dateHeure(DateTime d) => _dateHeure.format(d);
|
||||
Reference in New Issue
Block a user