Initial commit — app fidélité magasin (tablette)

App Flutter de programme de fidélité : login staff, clients (nom/prénom),
scan QR, factures (€→points), récompenses, réglages. Backend Supabase
(schéma SQL + RLS anti-triche dans supabase/). Icône incluse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 21:37:06 +02:00
commit 1d3ee69c6d
60 changed files with 4468 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "ad70ec4617166f1c38e5d2bfd388af71fda14f06"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
- platform: android
create_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
base_revision: ad70ec4617166f1c38e5d2bfd388af71fda14f06
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+91
View File
@@ -0,0 +1,91 @@
# Fidélité — App magasin (tablette)
App Android (Flutter) de **programme de fidélité** pour un magasin, conçue pour
une tablette en caisse. Chaque client a une **carte de fidélité = un QR code** :
on le scanne, on ajoute ses factures, il cumule des **points**, qu'il peut
échanger contre des **récompenses**.
**Backend : Supabase** (comptes + données partagées avec l'app client
[`app_fideliter_client`](../app_fideliter_client)). La tablette se connecte avec
un compte **staff** (personnel du magasin).
App sœur de [`gestion_prix_produit`](../gestion_prix_produit) : même stack et même
design (Material 3, noir & blanc + accent vert émeraude pour la distinguer sur la
même tablette).
## Fonctions
- **Connexion staff** : email + mot de passe (compte staff, voir SETUP).
- **Clients** : liste, recherche (nom / téléphone / code), création « au comptoir »
(code unique + QR générés côté serveur).
- **Scanner** : scan du QR d'un client → ouverture directe de sa fiche.
(Caméra de la tablette ; la scanette externe viendra ensuite.)
- **Fiche client** : solde de points, **ajout d'une facture** (montant € → points
selon le ratio), **utilisation d'une récompense** (débit), historique, correction
manuelle.
- **Récompenses** : catalogue (ex : « Café offert » = 10 pts), activer/désactiver.
- **Réglages** : **ratio** « euros pour 1 point », nom du magasin, déconnexion.
### Calcul des points
`points gagnés = montant de la facture ÷ (euros par point)`
Points **fractionnaires**. Ex : avec 1 pt = 10 €, une facture de 12 € rapporte
`1,2 pt`. Le solde est recalculé **côté base** (trigger) à chaque mouvement, donc
fiable même si plusieurs appareils écrivent.
## Configuration (à faire une fois)
Voir **[`supabase/SETUP.md`](supabase/SETUP.md)** : créer le projet Supabase,
exécuter [`supabase/schema.sql`](supabase/schema.sql), coller l'URL + la clé anon
dans [`lib/supabase_config.dart`](lib/supabase_config.dart), créer le compte staff.
## Techno
- Flutter (Material 3, thème noir & blanc + accent vert)
- `supabase_flutter` (auth staff + base de données partagée)
- `mobile_scanner` (scan du QR), `qr_flutter` (affichage du QR d'un client)
## Lancer l'app
```bash
flutter run
```
## Structure
```text
lib/
├── main.dart # init (locale fr, Supabase) + thème
├── config.dart # constantes par défaut
├── supabase_config.dart # URL + clé anon (À REMPLIR)
├── theme.dart
├── models/ # client, mouvement, recompense
├── services/
│ ├── reglages.dart # ratio €/point + nom magasin (Supabase)
│ ├── client_repository.dart # clients + factures + récompenses (Supabase)
│ └── recompense_repository.dart # catalogue (Supabase)
├── screens/
│ ├── auth_gate.dart # login staff ↔ app selon la session
│ ├── staff_login_screen.dart
│ ├── home_shell.dart # 4 onglets + navigation
│ ├── clients_screen.dart
│ ├── client_edit_screen.dart
│ ├── client_detail_screen.dart
│ ├── scan_screen.dart
│ ├── recompenses_screen.dart
│ └── reglages_screen.dart
├── widgets/ # pastille_points, qr_client
└── utils/format.dart
supabase/
├── schema.sql # tables + triggers + sécurité (RLS)
└── SETUP.md # guide de configuration
```
## Prochaines étapes (roadmap)
1. **Scanette externe** : le lecteur se comporte comme un clavier — un champ caché
reçoit le code et appelle `scan_screen.dart``_ouvrirParCode` (déjà factorisé).
2. **Temps réel** : abonnement Supabase Realtime pour mettre à jour les soldes
sans rafraîchir.
3. Impression du QR (carte physique), statistiques, bonus de bienvenue / paliers.
+28
View File
@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+53
View File
@@ -0,0 +1,53 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.magasin.app_fideliter"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.magasin.app_fideliter"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion // requis par mobile_scanner (scan QR / caméra)
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// R8/minification désactivée : évite que le scanner (ML Kit) casse en
// release en supprimant des classes nécessaires. Les règles proguard
// ci-dessous s'appliquent si on réactive la minification plus tard.
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
+11
View File
@@ -0,0 +1,11 @@
# Règles ProGuard/R8 conservent les classes nécessaires au scan de QR code.
# (mobile_scanner s'appuie sur Google ML Kit ; R8 pourrait sinon les supprimer.)
# Google ML Kit (détection de codes-barres / QR)
-keep class com.google.mlkit.** { *; }
-keep class com.google.android.gms.internal.mlkit_vision_** { *; }
-dontwarn com.google.mlkit.**
# mobile_scanner
-keep class dev.steenbakker.mobile_scanner.** { *; }
-dontwarn dev.steenbakker.mobile_scanner.**
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+56
View File
@@ -0,0 +1,56 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Scan du QR code client (caméra en attendant la scanette USB). -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Connexion au backend Supabase. -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Fidélité"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Télécharge le modèle ML Kit « barcode » à l'installation (requis
pour que le scan fonctionne de façon fiable en build release). -->
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.magasin.app_fideliter
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#0E7A55</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+13
View File
@@ -0,0 +1,13 @@
/// Constantes de configuration par défaut.
/// Les valeurs modifiables par l'utilisateur vivent dans [Reglages] (persistées).
class Config {
Config._();
/// Nombre d'euros à dépenser pour gagner 1 point (valeur par défaut au
/// premier lancement, ensuite modifiable dans l'onglet Réglages).
/// Ex : 10 → une facture de 25 € rapporte 2,5 points.
static const double eurosParPointParDefaut = 10;
/// Préfixe des codes clients encodés dans le QR code (ex : « FID-3F9K2A »).
static const String prefixeCode = 'FID-';
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'screens/auth_gate.dart';
import 'supabase_config.dart';
import 'theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Formatage des dates en français (« 2 juil. 2026 »).
await initializeDateFormatting('fr_FR', null);
// App verrouillée en portrait (tablette).
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Connexion au backend Supabase (partagé avec l'app client).
if (SupabaseConfig.estConfigure) {
await Supabase.initialize(
url: SupabaseConfig.url,
// Accepte la clé « anon public » (ou « publishable ») du projet.
publishableKey: SupabaseConfig.anonKey,
);
}
runApp(const MonApp());
}
class MonApp extends StatelessWidget {
const MonApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Fidélité — Magasin',
debugShowCheckedModeBanner: false,
theme: AppTheme.clair(),
darkTheme: AppTheme.sombre(),
themeMode: ThemeMode.light,
home: const AuthGate(),
);
}
}
+68
View File
@@ -0,0 +1,68 @@
/// Un client fidélité. Le [code] est ce qui est encodé dans son QR code :
/// scanner le QR permet de retrouver le compte.
class Client {
final String id; // uuid Supabase
/// Code unique du compte (encodé dans le QR), ex : « FID-3F9K2A ».
final String code;
final String nom;
final String? prenom;
final String? telephone;
/// Solde de points courant (peut être fractionnaire, ex : 12,48).
final double points;
const Client({
required this.id,
required this.code,
required this.nom,
this.prenom,
this.telephone,
this.points = 0,
});
/// Nom complet affiché : « Prénom Nom » (ou juste le nom si pas de prénom).
String get nomComplet {
final p = (prenom ?? '').trim();
return p.isEmpty ? nom : '$p $nom';
}
/// Initiales pour l'avatar (ex : « Jean Dupont » → « JD »).
String get initiales {
final p = (prenom ?? '').trim();
final n = nom.trim();
final a = p.isNotEmpty ? p[0] : (n.isNotEmpty ? n[0] : '?');
final b = n.isNotEmpty ? n[0] : '';
return (a + b).toUpperCase();
}
Client copyWith({
String? id,
String? code,
String? nom,
String? prenom,
String? telephone,
double? points,
}) {
return Client(
id: id ?? this.id,
code: code ?? this.code,
nom: nom ?? this.nom,
prenom: prenom ?? this.prenom,
telephone: telephone ?? this.telephone,
points: points ?? this.points,
);
}
factory Client.fromMap(Map<String, dynamic> map) {
return Client(
id: map['id'].toString(),
code: (map['code'] as String?) ?? '',
nom: (map['nom'] as String?) ?? '',
prenom: map['prenom'] as String?,
telephone: map['telephone'] as String?,
points: (map['points'] as num?)?.toDouble() ?? 0,
);
}
}
+53
View File
@@ -0,0 +1,53 @@
/// Type d'un mouvement de points dans l'historique d'un client.
class TypeMouvement {
static const facture = 'facture'; // gain de points suite à un achat
static const recompense = 'recompense'; // dépense de points sur une récompense
static const ajustement = 'ajustement'; // correction manuelle (+/-)
}
/// Une ligne d'historique : gain (facture) ou dépense (récompense) de points
/// pour un client donné.
class Mouvement {
final String id;
final String clientId;
/// Voir [TypeMouvement].
final String type;
/// Montant de la facture en euros (uniquement pour [TypeMouvement.facture]).
final double? montantEuros;
/// Points crédités (positif) ou débités (négatif).
final double points;
/// Libellé lisible, ex : « Facture 25,00 € » ou « Café offert ».
final String libelle;
/// Date du mouvement.
final DateTime date;
const Mouvement({
required this.id,
required this.clientId,
required this.type,
this.montantEuros,
required this.points,
required this.libelle,
required this.date,
});
bool get estGain => points >= 0;
factory Mouvement.fromMap(Map<String, dynamic> map) {
return Mouvement(
id: map['id'].toString(),
clientId: map['client_id'].toString(),
type: (map['type'] as String?) ?? TypeMouvement.ajustement,
montantEuros: (map['montant_euros'] as num?)?.toDouble(),
points: (map['points'] as num?)?.toDouble() ?? 0,
libelle: (map['libelle'] as String?) ?? '',
date: DateTime.tryParse(map['created_at']?.toString() ?? '')?.toLocal() ??
DateTime.fromMillisecondsSinceEpoch(0),
);
}
}
+42
View File
@@ -0,0 +1,42 @@
/// Une récompense du catalogue : ce qu'un client peut obtenir en échange de
/// ses points (ex : « Café offert » = 10 pts).
class Recompense {
final String id;
final String nom;
/// Coût en points pour obtenir la récompense.
final double coutPoints;
/// Récompense proposée (true) ou masquée sans être supprimée (false).
final bool actif;
const Recompense({
required this.id,
required this.nom,
required this.coutPoints,
this.actif = true,
});
Recompense copyWith({
String? id,
String? nom,
double? coutPoints,
bool? actif,
}) {
return Recompense(
id: id ?? this.id,
nom: nom ?? this.nom,
coutPoints: coutPoints ?? this.coutPoints,
actif: actif ?? this.actif,
);
}
factory Recompense.fromMap(Map<String, dynamic> map) {
return Recompense(
id: map['id'].toString(),
nom: (map['nom'] as String?) ?? '',
coutPoints: (map['cout_points'] as num?)?.toDouble() ?? 0,
actif: (map['actif'] as bool?) ?? true,
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../services/client_repository.dart';
import '../services/recompense_repository.dart';
import '../services/reglages.dart';
import '../supabase_config.dart';
import 'home_shell.dart';
import 'staff_login_screen.dart';
/// Aiguille entre l'écran de connexion (staff non connecté) et l'app.
/// Écoute l'état d'authentification Supabase en continu.
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context) {
if (!SupabaseConfig.estConfigure) return const _EcranNonConfigure();
return StreamBuilder<AuthState>(
stream: supabase.auth.onAuthStateChange,
builder: (context, snapshot) {
final session = supabase.auth.currentSession;
if (session == null) return const StaffLoginScreen();
return const _AppChargee();
},
);
}
}
/// Charge les données (réglages, clients, récompenses) après connexion, puis
/// affiche l'app. Recharge si le compte connecté change.
class _AppChargee extends StatefulWidget {
const _AppChargee();
@override
State<_AppChargee> createState() => _AppChargeeState();
}
class _AppChargeeState extends State<_AppChargee> {
late Future<void> _chargement;
@override
void initState() {
super.initState();
_chargement = _charger();
}
Future<void> _charger() async {
await Reglages.instance.charger();
await ClientRepository.instance.charger();
await RecompenseRepository.instance.charger();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: _chargement,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return const HomeShell();
},
);
}
}
class _EcranNonConfigure extends StatelessWidget {
const _EcranNonConfigure();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.cloud_off, size: 56, color: Colors.grey),
SizedBox(height: 16),
Text('Supabase non configuré',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
SizedBox(height: 8),
Text(
'Renseignez l\'URL et la clé anon dans lib/supabase_config.dart.\n'
'Voir supabase/SETUP.md',
textAlign: TextAlign.center,
style: TextStyle(color: Color(0xFF6B6B72)),
),
],
),
),
),
);
}
}
+623
View File
@@ -0,0 +1,623 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/client.dart';
import '../models/mouvement.dart';
import '../models/recompense.dart';
import '../services/client_repository.dart';
import '../services/recompense_repository.dart';
import '../services/reglages.dart';
import '../theme.dart';
import '../utils/format.dart';
import '../widgets/qr_client.dart';
import 'client_edit_screen.dart';
/// Fiche d'un client : solde de points, ajout de facture, utilisation de
/// récompenses, QR code et historique des mouvements.
class ClientDetailScreen extends StatefulWidget {
final String clientId;
const ClientDetailScreen({super.key, required this.clientId});
@override
State<ClientDetailScreen> createState() => _ClientDetailScreenState();
}
class _ClientDetailScreenState extends State<ClientDetailScreen> {
final _repo = ClientRepository.instance;
List<Mouvement> _mouvements = [];
bool _chargeMouvements = true;
@override
void initState() {
super.initState();
_rechargerMouvements();
}
Future<void> _rechargerMouvements() async {
final m = await _repo.mouvements(widget.clientId);
if (mounted) {
setState(() {
_mouvements = m;
_chargeMouvements = false;
});
}
}
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
Future<void> _ajouterFacture() async {
final montant = await _demanderMontant();
if (montant == null) return;
final gagnes =
await _repo.ajouterFacture(clientId: widget.clientId, montantEuros: montant);
await _rechargerMouvements();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Facture ${euro(montant)} • +${points(gagnes)}'),
backgroundColor: AppTheme.accent,
),
);
}
}
Future<double?> _demanderMontant({
double? initial,
String titre = 'Ajouter une facture',
String bouton = 'Valider',
}) {
final ctrl = TextEditingController(
text: initial == null
? ''
: (initial == initial.roundToDouble()
? initial.toInt().toString()
: initial.toString())
.replaceAll('.', ','),
);
return showDialog<double>(
context: context,
builder: (ctx) {
String? erreur;
return StatefulBuilder(
builder: (ctx, setDialog) {
double? apercu() {
final v = double.tryParse(ctrl.text.replaceAll(',', '.'));
return v == null ? null : Reglages.instance.pointsPour(v);
}
final pts = apercu();
return AlertDialog(
title: Text(titre),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: ctrl,
autofocus: true,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
],
onChanged: (_) => setDialog(() => erreur = null),
decoration: InputDecoration(
labelText: 'Montant dépensé',
suffixText: '',
errorText: erreur,
),
),
const SizedBox(height: 12),
Text(
pts != null && pts > 0
? 'Rapportera ${points(pts)}'
: 'Saisissez le montant de la facture',
style: const TextStyle(
color: AppTheme.accent, fontWeight: FontWeight.w600),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
final v = double.tryParse(ctrl.text.replaceAll(',', '.'));
if (v == null || v <= 0) {
setDialog(() => erreur = 'Montant invalide');
return;
}
Navigator.of(ctx).pop(v);
},
child: Text(bouton),
),
],
);
},
);
},
);
}
Future<void> _utiliserRecompense(Client client) async {
final actives = RecompenseRepository.instance.actives;
if (actives.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Aucune récompense. Créez-en dans l\'onglet Récompenses.')),
);
return;
}
final choisie = await showModalBottomSheet<Recompense>(
context: context,
showDragHandle: true,
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text('Utiliser une récompense',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: actives.length,
itemBuilder: (ctx, i) {
final r = actives[i];
final possible = client.points >= r.coutPoints;
return ListTile(
enabled: possible,
leading: CircleAvatar(
backgroundColor: possible
? AppTheme.accent.withValues(alpha: 0.14)
: Colors.grey.withValues(alpha: 0.14),
child: Icon(Icons.card_giftcard,
color: possible ? AppTheme.accent : Colors.grey),
),
title: Text(r.nom),
subtitle: Text(points(r.coutPoints)),
trailing: possible
? const Icon(Icons.chevron_right)
: Text('Manque ${points(r.coutPoints - client.points)}',
style: const TextStyle(
fontSize: 12, color: AppTheme.grisTexte)),
onTap:
possible ? () => Navigator.of(ctx).pop(r) : null,
);
},
),
),
const SizedBox(height: 8),
],
),
);
},
);
if (choisie == null) return;
final confirme = await _confirmer(
titre: 'Confirmer la récompense',
message:
'Utiliser « ${choisie.nom} » pour ${points(choisie.coutPoints)} ?',
libelleOk: 'Confirmer',
);
if (confirme != true) return;
try {
await _repo.utiliserRecompense(
clientId: widget.clientId, recompense: choisie);
await _rechargerMouvements();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${choisie.nom}${points(choisie.coutPoints)}'),
backgroundColor: AppTheme.accent,
),
);
}
} on SoldeInsuffisant catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
Future<void> _corrigerPoints(Client client) async {
final ctrl = TextEditingController();
final delta = await showDialog<double>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Corriger les points'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Ajoutez (+) ou retirez () des points manuellement.',
style: TextStyle(fontSize: 13, color: AppTheme.grisTexte),
),
const SizedBox(height: 12),
TextField(
controller: ctrl,
autofocus: true,
keyboardType: const TextInputType.numberWithOptions(
decimal: true, signed: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,-]')),
],
decoration: const InputDecoration(
labelText: 'Points (ex : 5 ou -2)',
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
final v = double.tryParse(ctrl.text.replaceAll(',', '.'));
if (v == null || v == 0) return;
Navigator.of(ctx).pop(v);
},
child: const Text('Appliquer'),
),
],
),
);
if (delta == null) return;
await _repo.ajuster(
clientId: widget.clientId,
points: delta,
libelle: 'Correction manuelle',
);
await _rechargerMouvements();
}
Future<void> _modifier(Client client) async {
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ClientEditScreen(client: client)),
);
if (mounted) setState(() {});
}
Future<void> _supprimer(Client client) async {
final ok = await _confirmer(
titre: 'Supprimer ce client ?',
message:
'« ${client.nomComplet} » et tout son historique seront supprimés définitivement.',
libelleOk: 'Supprimer',
danger: true,
);
if (ok != true) return;
await _repo.supprimer(widget.clientId);
if (mounted) Navigator.of(context).pop();
}
Future<bool?> _confirmer({
required String titre,
required String message,
required String libelleOk,
bool danger = false,
}) {
return showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(titre),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
style: danger
? FilledButton.styleFrom(backgroundColor: Colors.red)
: null,
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(libelleOk),
),
],
),
);
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _repo,
builder: (context, _) {
final client = _repo.parId(widget.clientId);
// Le client a pu être supprimé (pop en cours).
if (client == null) {
return const Scaffold(body: SizedBox.shrink());
}
return Scaffold(
appBar: AppBar(
title: Text(client.nomComplet),
actions: [
IconButton(
tooltip: 'QR code',
icon: const Icon(Icons.qr_code_2),
onPressed: () => afficherQrClient(context, client),
),
PopupMenuButton<String>(
onSelected: (v) {
switch (v) {
case 'modifier':
_modifier(client);
case 'corriger':
_corrigerPoints(client);
case 'supprimer':
_supprimer(client);
}
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'modifier', child: Text('Modifier')),
PopupMenuItem(
value: 'corriger', child: Text('Corriger les points')),
PopupMenuItem(value: 'supprimer', child: Text('Supprimer')),
],
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
_enteteSolde(client),
const SizedBox(height: 16),
_boutonsActions(client),
const SizedBox(height: 24),
const Text('Historique',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
_historique(),
],
),
);
},
);
}
Widget _enteteSolde(Client client) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
child: Column(
children: [
const Text('Solde de points',
style: TextStyle(color: AppTheme.grisTexte, fontSize: 14)),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
const Icon(Icons.stars_rounded,
color: AppTheme.accent, size: 34),
const SizedBox(width: 8),
Text(
pointsNombre(client.points),
style: const TextStyle(
fontSize: 44,
fontWeight: FontWeight.w800,
color: AppTheme.accent),
),
const SizedBox(width: 6),
const Padding(
padding: EdgeInsets.only(bottom: 6),
child: Text('pts',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppTheme.accent)),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.badge_outlined,
size: 15, color: AppTheme.grisTexte),
const SizedBox(width: 6),
Text(client.code,
style: const TextStyle(
color: AppTheme.grisTexte,
fontWeight: FontWeight.w600,
letterSpacing: 1)),
if (client.telephone?.isNotEmpty == true) ...[
const SizedBox(width: 12),
const Icon(Icons.phone,
size: 15, color: AppTheme.grisTexte),
const SizedBox(width: 6),
Text(client.telephone!,
style: const TextStyle(color: AppTheme.grisTexte)),
],
],
),
],
),
),
);
}
Widget _boutonsActions(Client client) {
return Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _ajouterFacture,
icon: const Icon(Icons.receipt_long),
label: const Text('Ajouter\nune facture', textAlign: TextAlign.center),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: AppTheme.accent,
foregroundColor: Colors.white,
),
onPressed: () => _utiliserRecompense(client),
icon: const Icon(Icons.card_giftcard),
label: const Text('Utiliser une\nrécompense',
textAlign: TextAlign.center),
),
),
],
);
}
Widget _historique() {
if (_chargeMouvements) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
if (_mouvements.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(
child: Text('Aucun mouvement pour l\'instant.',
style: TextStyle(color: AppTheme.grisTexte)),
),
);
}
return Column(
children: [
for (final m in _mouvements) _ligneMouvement(m),
],
);
}
Widget _ligneMouvement(Mouvement m) {
final gain = m.estGain;
final couleur = gain ? AppTheme.accent : Colors.red.shade600;
final icone = switch (m.type) {
TypeMouvement.facture => Icons.receipt_long,
TypeMouvement.recompense => Icons.card_giftcard,
_ => Icons.tune,
};
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _optionsMouvement(m),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
child: Row(
children: [
CircleAvatar(
radius: 20,
backgroundColor: couleur.withValues(alpha: 0.12),
child: Icon(icone, size: 20, color: couleur),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(m.libelle,
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text(dateHeure(m.date),
style: const TextStyle(
fontSize: 12, color: AppTheme.grisTexte)),
],
),
),
Text(
'${gain ? '+' : ''}${points(m.points.abs())}',
style: TextStyle(fontWeight: FontWeight.w700, color: couleur),
),
const SizedBox(width: 4),
const Icon(Icons.more_vert, size: 18, color: AppTheme.grisTexte),
],
),
),
);
}
/// Menu d'un mouvement : modifier (factures) ou supprimer.
Future<void> _optionsMouvement(Mouvement m) async {
final estFacture = m.type == TypeMouvement.facture;
final action = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: Text(m.libelle,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700)),
),
if (estFacture)
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Modifier le montant'),
onTap: () => Navigator.of(ctx).pop('modifier'),
),
ListTile(
leading: Icon(Icons.delete_outline, color: Colors.red.shade600),
title: Text('Supprimer',
style: TextStyle(color: Colors.red.shade600)),
onTap: () => Navigator.of(ctx).pop('supprimer'),
),
const SizedBox(height: 8),
],
),
),
);
if (action == 'modifier') {
final montant = await _demanderMontant(
initial: m.montantEuros,
titre: 'Modifier le montant',
bouton: 'Enregistrer',
);
if (montant == null) return;
await _repo.modifierFacture(mouvement: m, nouveauMontant: montant);
await _rechargerMouvements();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Facture modifiée')),
);
}
} else if (action == 'supprimer') {
final ok = await _confirmer(
titre: 'Supprimer ce mouvement ?',
message:
'« ${m.libelle} » sera supprimé et le solde du client réajusté.',
libelleOk: 'Supprimer',
danger: true,
);
if (ok != true) return;
await _repo.supprimerMouvement(m);
await _rechargerMouvements();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Mouvement supprimé')),
);
}
}
}
}
+138
View File
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import '../models/client.dart';
import '../services/client_repository.dart';
/// Création (client == null) ou édition d'une fiche client.
/// Retourne le [Client] créé/modifié via Navigator.pop, ou null si annulé.
class ClientEditScreen extends StatefulWidget {
final Client? client;
const ClientEditScreen({super.key, this.client});
@override
State<ClientEditScreen> createState() => _ClientEditScreenState();
}
class _ClientEditScreenState extends State<ClientEditScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl;
late final TextEditingController _telCtrl;
bool _enregistre = false;
bool get _edition => widget.client != null;
@override
void initState() {
super.initState();
_nomCtrl = TextEditingController(text: widget.client?.nom ?? '');
_prenomCtrl = TextEditingController(text: widget.client?.prenom ?? '');
_telCtrl = TextEditingController(text: widget.client?.telephone ?? '');
}
@override
void dispose() {
_nomCtrl.dispose();
_prenomCtrl.dispose();
_telCtrl.dispose();
super.dispose();
}
Future<void> _enregistrer() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _enregistre = true);
final repo = ClientRepository.instance;
try {
final Client resultat;
if (_edition) {
resultat = widget.client!.copyWith(
nom: _nomCtrl.text.trim(),
prenom: _prenomCtrl.text.trim(),
telephone: _telCtrl.text.trim(),
);
await repo.modifier(resultat);
} else {
resultat = await repo.creer(
nom: _nomCtrl.text.trim(),
prenom: _prenomCtrl.text.trim(),
telephone: _telCtrl.text.trim(),
);
}
if (mounted) Navigator.of(context).pop(resultat);
} catch (e) {
if (mounted) {
setState(() => _enregistre = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur : $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_edition ? 'Modifier le client' : 'Nouveau client'),
),
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _nomCtrl,
textCapitalization: TextCapitalization.words,
autofocus: !_edition,
decoration: const InputDecoration(
labelText: 'Nom *',
prefixIcon: Icon(Icons.person_outline),
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Nom obligatoire' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _prenomCtrl,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Prénom',
prefixIcon: Icon(Icons.badge_outlined),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _telCtrl,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Téléphone (facultatif)',
prefixIcon: Icon(Icons.phone_outlined),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _enregistre ? null : _enregistrer,
child: _enregistre
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: Text(_edition ? 'Enregistrer' : 'Créer le client'),
),
if (!_edition) ...[
const SizedBox(height: 16),
const Text(
'Un code de fidélité unique et son QR code seront générés automatiquement.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Color(0xFF6B6B72)),
),
],
],
),
),
);
}
}
+190
View File
@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
import '../models/client.dart';
import '../services/client_repository.dart';
import '../theme.dart';
import '../widgets/pastille_points.dart';
import 'client_detail_screen.dart';
import 'client_edit_screen.dart';
/// Onglet 1 : liste de tous les clients, avec recherche et ajout.
class ClientsScreen extends StatefulWidget {
final VoidCallback onAllerScanner;
const ClientsScreen({super.key, required this.onAllerScanner});
@override
State<ClientsScreen> createState() => _ClientsScreenState();
}
class _ClientsScreenState extends State<ClientsScreen> {
final _repo = ClientRepository.instance;
final _rechercheCtrl = TextEditingController();
String _requete = '';
@override
void dispose() {
_rechercheCtrl.dispose();
super.dispose();
}
Future<void> _ajouter() async {
final client = await Navigator.of(context).push<Client>(
MaterialPageRoute(builder: (_) => const ClientEditScreen()),
);
if (client != null && mounted) {
// On ouvre directement la fiche du nouveau client.
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ClientDetailScreen(clientId: client.id)),
);
}
}
void _ouvrir(Client c) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ClientDetailScreen(clientId: c.id)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Clients')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _ajouter,
icon: const Icon(Icons.person_add_alt_1),
label: const Text('Nouveau client'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: TextField(
controller: _rechercheCtrl,
onChanged: (v) => setState(() => _requete = v),
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Rechercher (nom, téléphone, code)',
prefixIcon: const Icon(Icons.search),
suffixIcon: _requete.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
_rechercheCtrl.clear();
setState(() => _requete = '');
},
),
),
),
),
Expanded(
child: AnimatedBuilder(
animation: _repo,
builder: (context, _) {
final liste = _repo.rechercher(_requete);
if (_repo.clients.isEmpty) return _vide();
if (liste.isEmpty) {
return const Center(
child: Text('Aucun client ne correspond.',
style: TextStyle(color: AppTheme.grisTexte)),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 96),
itemCount: liste.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) => _carte(liste[i]),
);
},
),
),
],
),
);
}
Widget _carte(Client c) {
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => _ouvrir(c),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: AppTheme.accent.withValues(alpha: 0.14),
child: Text(
c.initiales,
style: const TextStyle(
color: AppTheme.accent, fontWeight: FontWeight.w700),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.nomComplet,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
c.telephone?.isNotEmpty == true ? c.telephone! : c.code,
style: const TextStyle(
fontSize: 13, color: AppTheme.grisTexte),
),
],
),
),
const SizedBox(width: 8),
PastillePoints(c.points),
],
),
),
),
);
}
Widget _vide() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.people_outline, size: 64, color: AppTheme.grisTexte),
const SizedBox(height: 16),
const Text(
'Aucun client pour l\'instant',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
const Text(
'Créez une fiche client : un QR code de fidélité sera généré automatiquement.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.grisTexte),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _ajouter,
icon: const Icon(Icons.person_add_alt_1),
label: const Text('Créer le premier client'),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: widget.onAllerScanner,
icon: const Icon(Icons.qr_code_scanner),
label: const Text('Ou scanner un QR client'),
),
],
),
),
);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'clients_screen.dart';
import 'recompenses_screen.dart';
import 'reglages_screen.dart';
import 'scan_screen.dart';
/// Coquille principale : 4 onglets + barre de navigation.
class HomeShell extends StatefulWidget {
const HomeShell({super.key});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int _index = 0;
void _allerScanner() => setState(() => _index = 1);
@override
Widget build(BuildContext context) {
final pages = [
ClientsScreen(onAllerScanner: _allerScanner),
ScanScreen(active: _index == 1),
const RecompensesScreen(),
const ReglagesScreen(),
];
return Scaffold(
body: IndexedStack(index: _index, children: pages),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
height: 68,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
onDestinationSelected: (i) => setState(() => _index = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.people_alt_outlined),
selectedIcon: Icon(Icons.people_alt),
label: 'Clients',
),
NavigationDestination(
icon: Icon(Icons.qr_code_scanner_outlined),
selectedIcon: Icon(Icons.qr_code_scanner),
label: 'Scanner',
),
NavigationDestination(
icon: Icon(Icons.card_giftcard_outlined),
selectedIcon: Icon(Icons.card_giftcard),
label: 'Récompenses',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Réglages',
),
],
),
);
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/recompense.dart';
import '../services/recompense_repository.dart';
import '../theme.dart';
import '../utils/format.dart';
/// Onglet 3 : catalogue des récompenses (ce qu'un client peut obtenir avec ses
/// points). Créer / modifier / activer / supprimer.
class RecompensesScreen extends StatefulWidget {
const RecompensesScreen({super.key});
@override
State<RecompensesScreen> createState() => _RecompensesScreenState();
}
class _RecompensesScreenState extends State<RecompensesScreen> {
final _repo = RecompenseRepository.instance;
Future<void> _editer([Recompense? existante]) async {
final nomCtrl = TextEditingController(text: existante?.nom ?? '');
final coutCtrl = TextEditingController(
text: existante == null ? '' : points(existante.coutPoints)
.replaceAll(RegExp(r'\s?pts?$'), ''));
final formKey = GlobalKey<FormState>();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(existante == null ? 'Nouvelle récompense' : 'Modifier'),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: nomCtrl,
autofocus: true,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
labelText: 'Nom (ex : Café offert)',
),
validator: (v) =>
(v == null || v.trim().isEmpty) ? 'Nom obligatoire' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: coutCtrl,
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9.,]')),
],
decoration: const InputDecoration(
labelText: 'Coût en points',
suffixText: 'pts',
),
validator: (v) {
final n = double.tryParse((v ?? '').replaceAll(',', '.'));
if (n == null || n <= 0) return 'Coût invalide';
return null;
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
if (formKey.currentState!.validate()) Navigator.of(ctx).pop(true);
},
child: const Text('Enregistrer'),
),
],
),
);
if (ok != true) return;
final cout = double.parse(coutCtrl.text.replaceAll(',', '.'));
if (existante == null) {
await _repo.creer(nom: nomCtrl.text, coutPoints: cout);
} else {
await _repo.modifier(
existante.copyWith(nom: nomCtrl.text.trim(), coutPoints: cout));
}
}
Future<void> _supprimer(Recompense r) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Supprimer ?'),
content: Text('Supprimer la récompense « ${r.nom} » ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Supprimer'),
),
],
),
);
if (ok == true) await _repo.supprimer(r.id);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Récompenses')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _editer(),
icon: const Icon(Icons.add),
label: const Text('Récompense'),
),
body: AnimatedBuilder(
animation: _repo,
builder: (context, _) {
final liste = _repo.recompenses;
if (liste.isEmpty) return _vide();
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
itemCount: liste.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) => _carte(liste[i]),
);
},
),
);
}
Widget _carte(Recompense r) {
return Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 6, 6, 6),
child: Row(
children: [
CircleAvatar(
backgroundColor: r.actif
? AppTheme.accent.withValues(alpha: 0.14)
: Colors.grey.withValues(alpha: 0.14),
child: Icon(Icons.card_giftcard,
color: r.actif ? AppTheme.accent : Colors.grey),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(r.nom,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: r.actif ? null : AppTheme.grisTexte,
)),
const SizedBox(height: 2),
Text(points(r.coutPoints),
style: const TextStyle(
color: AppTheme.grisTexte, fontSize: 13)),
],
),
),
Switch(
value: r.actif,
onChanged: (v) => _repo.modifier(r.copyWith(actif: v)),
),
PopupMenuButton<String>(
onSelected: (v) {
if (v == 'modifier') _editer(r);
if (v == 'supprimer') _supprimer(r);
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'modifier', child: Text('Modifier')),
PopupMenuItem(value: 'supprimer', child: Text('Supprimer')),
],
),
],
),
),
);
}
Widget _vide() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.card_giftcard_outlined,
size: 64, color: AppTheme.grisTexte),
const SizedBox(height: 16),
const Text('Aucune récompense',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 8),
const Text(
'Créez des récompenses (ex : « Café offert » = 10 pts) que les clients pourront obtenir avec leurs points.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.grisTexte),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: () => _editer(),
icon: const Icon(Icons.add),
label: const Text('Créer une récompense'),
),
],
),
),
);
}
}
+268
View File
@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/reglages.dart';
import '../supabase_config.dart';
import '../theme.dart';
import '../utils/format.dart';
/// Onglet 4 : réglages du programme de fidélité.
/// Le réglage clé est le ratio « combien d'euros pour 1 point ».
class ReglagesScreen extends StatefulWidget {
const ReglagesScreen({super.key});
@override
State<ReglagesScreen> createState() => _ReglagesScreenState();
}
class _ReglagesScreenState extends State<ReglagesScreen> {
final _reglages = Reglages.instance;
late final TextEditingController _ratioCtrl;
late final TextEditingController _nomCtrl;
@override
void initState() {
super.initState();
_ratioCtrl = TextEditingController(
text: _formaterRatio(_reglages.eurosParPoint),
);
_nomCtrl = TextEditingController(text: _reglages.nomMagasin);
}
@override
void dispose() {
_ratioCtrl.dispose();
_nomCtrl.dispose();
super.dispose();
}
String _formaterRatio(double v) {
return (v == v.roundToDouble() ? v.toInt().toString() : v.toString())
.replaceAll('.', ',');
}
Future<void> _enregistrerRatio() async {
final v = double.tryParse(_ratioCtrl.text.replaceAll(',', '.'));
if (v == null || v <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Valeur invalide (doit être > 0)')),
);
_ratioCtrl.text = _formaterRatio(_reglages.eurosParPoint);
return;
}
await _reglages.definirEurosParPoint(v);
_ratioCtrl.text = _formaterRatio(v);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Réglage enregistré')),
);
}
}
Future<void> _enregistrerNom() async {
await _reglages.definirNomMagasin(_nomCtrl.text);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Réglages')),
body: AnimatedBuilder(
animation: _reglages,
builder: (context, _) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_titre('Programme de fidélité'),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Combien d\'euros dépensés pour gagner 1 point ?',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'1 point =',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _ratioCtrl,
textAlign: TextAlign.center,
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[0-9.,]')),
],
onSubmitted: (_) => _enregistrerRatio(),
decoration: const InputDecoration(
suffixText: '',
hintText: 'ex : 5',
),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _enregistrerRatio,
child: const Text('Enregistrer'),
),
),
const SizedBox(height: 16),
_apercus(),
],
),
),
),
const SizedBox(height: 24),
_titre('Magasin'),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Nom affiché sur le QR code du client',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
TextField(
controller: _nomCtrl,
textCapitalization: TextCapitalization.words,
onSubmitted: (_) => _enregistrerNom(),
onEditingComplete: _enregistrerNom,
decoration: const InputDecoration(
hintText: 'Ex : Boulangerie du Coin',
prefixIcon: Icon(Icons.storefront_outlined),
),
),
],
),
),
),
const SizedBox(height: 24),
_titre('À propos'),
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Données partagées via Supabase avec l\'app client. '
'Le scan se fait avec la caméra ; la scanette externe sera '
'branchée dans une prochaine version.',
style: TextStyle(color: AppTheme.grisTexte, height: 1.4),
),
),
),
const SizedBox(height: 24),
if (_emailConnecte() != null)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
'Connecté en tant que ${_emailConnecte()}',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13, color: AppTheme.grisTexte),
),
),
OutlinedButton.icon(
onPressed: _deconnexion,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade600,
minimumSize: const Size.fromHeight(52),
side: BorderSide(color: Colors.red.shade200),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14)),
),
icon: const Icon(Icons.logout),
label: const Text('Se déconnecter'),
),
],
);
},
),
);
}
String? _emailConnecte() => supabase.auth.currentUser?.email;
Future<void> _deconnexion() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Se déconnecter ?'),
content: const Text('Vous devrez ressaisir vos identifiants staff.'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Déconnexion'),
),
],
),
);
if (ok == true) await supabase.auth.signOut();
}
Widget _apercus() {
const exemples = [10.0, 20.0, 25.0, 50.0];
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.accent.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Aperçu',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppTheme.grisTexte)),
const SizedBox(height: 8),
for (final e in exemples)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Facture de ${euro(e)}'),
Text('${points(_reglages.pointsPour(e))}',
style: const TextStyle(
fontWeight: FontWeight.w700, color: AppTheme.accent)),
],
),
),
],
),
);
}
Widget _titre(String texte) {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 10),
child: Text(texte,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppTheme.grisTexte)),
);
}
}
+177
View File
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../services/client_repository.dart';
import '../theme.dart';
import 'client_detail_screen.dart';
/// Onglet 2 : scan du QR code d'un client → ouverture de sa fiche.
///
/// V1 : on utilise la caméra de la tablette. Quand la scanette (lecteur externe)
/// sera branchée, elle se comporte comme un clavier : il suffira d'ajouter un
/// champ caché qui reçoit le code et appelle [_ouvrirParCode]. La logique de
/// recherche du client est déjà factorisée pour ça.
class ScanScreen extends StatefulWidget {
final bool active;
const ScanScreen({super.key, required this.active});
@override
State<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
final _controller = MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates,
formats: const [BarcodeFormat.qrCode],
);
final _repo = ClientRepository.instance;
bool _traite = false;
@override
void didUpdateWidget(covariant ScanScreen old) {
super.didUpdateWidget(old);
// En revenant sur l'onglet Scanner, on réautorise la détection.
if (widget.active && !old.active) _traite = false;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _onDetect(BarcodeCapture capture) async {
if (_traite || !widget.active) return;
final code = capture.barcodes.firstOrNull?.rawValue;
if (code == null || code.isEmpty) return;
setState(() => _traite = true);
await _ouvrirParCode(code.trim());
}
/// Retrouve le client par son code et ouvre sa fiche (ou signale l'échec).
Future<void> _ouvrirParCode(String code) async {
final client = _repo.parCode(code);
if (!mounted) return;
if (client == null) {
await _popupInconnu(code);
if (mounted) setState(() => _traite = false);
return;
}
await Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ClientDetailScreen(clientId: client.id)),
);
if (mounted) setState(() => _traite = false);
}
Future<void> _popupInconnu(String code) async {
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('QR code non reconnu'),
content: Text(
'Aucun client ne correspond au code « $code ».\n\n'
'Vérifiez qu\'il s\'agit bien d\'une carte de fidélité de ce magasin.'),
actions: [
FilledButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
if (!widget.active) {
return const ColoredBox(color: Colors.black);
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
MobileScanner(
controller: _controller,
onDetect: _onDetect,
errorBuilder: (context, error) => _erreurCamera(error),
),
_cadre(),
_bandeauHaut(),
if (_traite)
Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
],
),
);
}
Widget _bandeauHaut() {
return SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Container(
margin: const EdgeInsets.only(top: 24),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(30),
),
child: const Text(
'Scannez le QR code de fidélité du client',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600),
),
),
),
);
}
Widget _cadre() {
return Center(
child: Container(
width: 240,
height: 240,
decoration: BoxDecoration(
border: Border.all(color: AppTheme.accent, width: 3),
borderRadius: BorderRadius.circular(20),
),
),
);
}
Widget _erreurCamera(MobileScannerException error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.no_photography_outlined,
color: Colors.white70, size: 56),
const SizedBox(height: 16),
const Text(
'Accès à la caméra impossible.\nAutorisez la caméra dans les réglages.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, fontSize: 15),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => _controller.start(),
child: const Text('Réessayer'),
),
],
),
),
);
}
}
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../supabase_config.dart';
import '../theme.dart';
/// Connexion du personnel du magasin (staff). Les comptes staff sont créés dans
/// Supabase (voir SETUP.md) pas d'inscription libre ici.
class StaffLoginScreen extends StatefulWidget {
const StaffLoginScreen({super.key});
@override
State<StaffLoginScreen> createState() => _StaffLoginScreenState();
}
class _StaffLoginScreenState extends State<StaffLoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailCtrl = TextEditingController();
final _mdpCtrl = TextEditingController();
bool _enCours = false;
bool _voirMdp = false;
String? _erreur;
@override
void dispose() {
_emailCtrl.dispose();
_mdpCtrl.dispose();
super.dispose();
}
Future<void> _connexion() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_enCours = true;
_erreur = null;
});
try {
await supabase.auth.signInWithPassword(
email: _emailCtrl.text.trim(),
password: _mdpCtrl.text,
);
// Vérifie que ce compte est bien autorisé (staff).
final estStaff = await supabase.rpc('est_staff') as bool? ?? false;
if (!estStaff) {
await supabase.auth.signOut();
if (mounted) {
setState(() => _erreur =
'Ce compte n\'est pas autorisé pour la tablette (staff).');
}
}
// Si staff : l'AuthGate bascule automatiquement vers l'app.
} on AuthException catch (e) {
if (mounted) setState(() => _erreur = e.message);
} catch (e) {
if (mounted) setState(() => _erreur = 'Erreur : $e');
} finally {
if (mounted) setState(() => _enCours = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppTheme.accent.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: const Icon(Icons.storefront,
size: 40, color: AppTheme.accent),
),
),
const SizedBox(height: 20),
const Text('Espace magasin',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24, fontWeight: FontWeight.w800)),
const SizedBox(height: 6),
const Text('Connexion du personnel',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.grisTexte)),
const SizedBox(height: 28),
TextFormField(
controller: _emailCtrl,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.mail_outline),
),
validator: (v) => (v == null || !v.contains('@'))
? 'Email invalide'
: null,
),
const SizedBox(height: 14),
TextFormField(
controller: _mdpCtrl,
obscureText: !_voirMdp,
decoration: InputDecoration(
labelText: 'Mot de passe',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(_voirMdp
? Icons.visibility_off
: Icons.visibility),
onPressed: () =>
setState(() => _voirMdp = !_voirMdp),
),
),
validator: (v) => (v == null || v.isEmpty)
? 'Mot de passe requis'
: null,
onFieldSubmitted: (_) => _connexion(),
),
if (_erreur != null) ...[
const SizedBox(height: 16),
Text(_erreur!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade600)),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _enCours ? null : _connexion,
child: _enCours
? const SizedBox(
height: 22,
width: 22,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('Se connecter'),
),
],
),
),
),
),
),
),
);
}
}
+223
View File
@@ -0,0 +1,223 @@
import 'package:flutter/foundation.dart';
import '../models/client.dart';
import '../models/mouvement.dart';
import '../models/recompense.dart';
import '../supabase_config.dart';
import '../utils/format.dart';
import 'reglages.dart';
/// Erreur levée quand un client n'a pas assez de points pour une récompense.
class SoldeInsuffisant implements Exception {
final double manquant;
SoldeInsuffisant(this.manquant);
@override
String toString() => 'Solde insuffisant (${points(manquant)} manquants)';
}
/// Source de vérité des clients (côté staff), adossée à Supabase.
/// Le solde de points est tenu à jour côté base (trigger) à chaque mouvement ;
/// on reflète le delta en mémoire pour un affichage immédiat.
class ClientRepository extends ChangeNotifier {
ClientRepository._();
static final ClientRepository instance = ClientRepository._();
final List<Client> _clients = [];
bool _charge = false;
bool _enCours = false;
String? _erreur;
List<Client> get clients => List.unmodifiable(_clients);
bool get charge => _charge;
bool get enCours => _enCours;
String? get erreur => _erreur;
Future<void> charger() async {
_enCours = true;
_erreur = null;
notifyListeners();
try {
final rows = await supabase.from('clients').select().order('nom');
_clients
..clear()
..addAll((rows as List).map((e) => Client.fromMap(e)));
_charge = true;
} catch (e) {
_erreur = e.toString();
} finally {
_enCours = false;
notifyListeners();
}
}
Client? parCode(String code) {
for (final c in _clients) {
if (c.code == code) return c;
}
return null;
}
Client? parId(String id) {
for (final c in _clients) {
if (c.id == id) return c;
}
return null;
}
/// Filtre par nom, téléphone ou code (recherche insensible à la casse).
List<Client> rechercher(String requete) {
final q = requete.trim().toLowerCase();
if (q.isEmpty) return clients;
return _clients
.where((c) =>
c.nom.toLowerCase().contains(q) ||
(c.prenom?.toLowerCase().contains(q) ?? false) ||
(c.telephone?.toLowerCase().contains(q) ?? false) ||
c.code.toLowerCase().contains(q))
.toList();
}
/// Crée un client « au comptoir » (sans compte de connexion). Le code unique
/// et le QR sont générés côté base.
Future<Client> creer({
required String nom,
String? prenom,
String? telephone,
}) async {
final row = await supabase
.from('clients')
.insert({
'nom': nom.trim(),
'prenom': _ouNull(prenom),
'telephone': _ouNull(telephone),
})
.select()
.single();
final cree = Client.fromMap(row);
_clients.add(cree);
_trier();
notifyListeners();
return cree;
}
/// Modifie nom / prénom / téléphone (pas le solde : il évolue via facture).
Future<void> modifier(Client c) async {
await supabase.from('clients').update({
'nom': c.nom.trim(),
'prenom': _ouNull(c.prenom),
'telephone': _ouNull(c.telephone),
}).eq('id', c.id);
final i = _clients.indexWhere((e) => e.id == c.id);
if (i != -1) {
_clients[i] =
_clients[i].copyWith(nom: c.nom, prenom: c.prenom, telephone: c.telephone);
}
_trier();
notifyListeners();
}
/// Renvoie null si la chaîne est vide/espaces, sinon la valeur nettoyée.
String? _ouNull(String? v) =>
(v == null || v.trim().isEmpty) ? null : v.trim();
Future<void> supprimer(String id) async {
await supabase.from('clients').delete().eq('id', id);
_clients.removeWhere((e) => e.id == id);
notifyListeners();
}
/// Ajoute une facture : crédite les points correspondant au montant dépensé.
/// Retourne le nombre de points gagnés.
Future<double> ajouterFacture({
required String clientId,
required double montantEuros,
}) async {
final gagnes = Reglages.instance.pointsPour(montantEuros);
await supabase.from('mouvements').insert({
'client_id': clientId,
'type': TypeMouvement.facture,
'montant_euros': montantEuros,
'points': gagnes,
'libelle': 'Facture ${euro(montantEuros)}',
});
_majSoldeMemoire(clientId, gagnes);
return gagnes;
}
/// Utilise une récompense : débite son coût. Lève [SoldeInsuffisant] si besoin.
Future<void> utiliserRecompense({
required String clientId,
required Recompense recompense,
}) async {
final client = parId(clientId);
if (client == null) throw Exception('Client introuvable');
if (client.points < recompense.coutPoints) {
throw SoldeInsuffisant(recompense.coutPoints - client.points);
}
await supabase.from('mouvements').insert({
'client_id': clientId,
'type': TypeMouvement.recompense,
'points': -recompense.coutPoints,
'libelle': recompense.nom,
});
_majSoldeMemoire(clientId, -recompense.coutPoints);
}
/// Correction manuelle du solde (ajout ou retrait de points).
Future<void> ajuster({
required String clientId,
required double points,
required String libelle,
}) async {
await supabase.from('mouvements').insert({
'client_id': clientId,
'type': TypeMouvement.ajustement,
'points': points,
'libelle': libelle,
});
_majSoldeMemoire(clientId, points);
}
/// Historique d'un client, du plus récent au plus ancien.
Future<List<Mouvement>> mouvements(String clientId) async {
final rows = await supabase
.from('mouvements')
.select()
.eq('client_id', clientId)
.order('created_at', ascending: false);
return (rows as List).map((e) => Mouvement.fromMap(e)).toList();
}
/// Corrige le montant d'une facture existante : recalcule les points au ratio
/// courant et réajuste le solde du client (via le trigger côté base).
Future<void> modifierFacture({
required Mouvement mouvement,
required double nouveauMontant,
}) async {
final nouveauxPoints = Reglages.instance.pointsPour(nouveauMontant);
await supabase.from('mouvements').update({
'montant_euros': nouveauMontant,
'points': nouveauxPoints,
'libelle': 'Facture ${euro(nouveauMontant)}',
}).eq('id', mouvement.id);
_majSoldeMemoire(mouvement.clientId, nouveauxPoints - mouvement.points);
}
/// Supprime un mouvement (facture, récompense ou ajustement). Le solde du
/// client est réajusté automatiquement (trigger côté base).
Future<void> supprimerMouvement(Mouvement mouvement) async {
await supabase.from('mouvements').delete().eq('id', mouvement.id);
_majSoldeMemoire(mouvement.clientId, -mouvement.points);
}
void _majSoldeMemoire(String clientId, double delta) {
final i = _clients.indexWhere((c) => c.id == clientId);
if (i != -1) {
_clients[i] = _clients[i].copyWith(points: _clients[i].points + delta);
}
notifyListeners();
}
void _trier() => _clients
.sort((a, b) => a.nom.toLowerCase().compareTo(b.nom.toLowerCase()));
}
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/foundation.dart';
import '../models/recompense.dart';
import '../supabase_config.dart';
/// Source de vérité du catalogue de récompenses (table `recompenses` Supabase).
class RecompenseRepository extends ChangeNotifier {
RecompenseRepository._();
static final RecompenseRepository instance = RecompenseRepository._();
final List<Recompense> _recompenses = [];
bool _charge = false;
List<Recompense> get recompenses => List.unmodifiable(_recompenses);
/// Uniquement les récompenses proposées (actives), triées par coût croissant.
List<Recompense> get actives {
final l = _recompenses.where((r) => r.actif).toList()
..sort((a, b) => a.coutPoints.compareTo(b.coutPoints));
return l;
}
bool get charge => _charge;
Future<void> charger() async {
try {
final rows =
await supabase.from('recompenses').select().order('cout_points');
_recompenses
..clear()
..addAll((rows as List).map((e) => Recompense.fromMap(e)));
_charge = true;
notifyListeners();
} catch (_) {
// Ignore si non connecté ; sera rechargé après connexion.
}
}
Future<Recompense> creer(
{required String nom, required double coutPoints}) async {
final row = await supabase
.from('recompenses')
.insert({'nom': nom.trim(), 'cout_points': coutPoints, 'actif': true})
.select()
.single();
final cree = Recompense.fromMap(row);
_recompenses.add(cree);
_trier();
notifyListeners();
return cree;
}
Future<void> modifier(Recompense r) async {
await supabase.from('recompenses').update({
'nom': r.nom.trim(),
'cout_points': r.coutPoints,
'actif': r.actif,
}).eq('id', r.id);
final i = _recompenses.indexWhere((e) => e.id == r.id);
if (i != -1) _recompenses[i] = r;
_trier();
notifyListeners();
}
Future<void> supprimer(String id) async {
await supabase.from('recompenses').delete().eq('id', id);
_recompenses.removeWhere((e) => e.id == id);
notifyListeners();
}
void _trier() =>
_recompenses.sort((a, b) => a.coutPoints.compareTo(b.coutPoints));
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/foundation.dart';
import '../config.dart';
import '../supabase_config.dart';
/// Réglages globaux du magasin (ligne unique `reglages` id=1 sur Supabase).
/// Étend [ChangeNotifier] pour que les écrans se rafraîchissent au changement.
class Reglages extends ChangeNotifier {
Reglages._();
static final Reglages instance = Reglages._();
double _eurosParPoint = Config.eurosParPointParDefaut;
String _nomMagasin = '';
/// Nombre d'euros à dépenser pour gagner 1 point.
double get eurosParPoint => _eurosParPoint;
/// Nom du magasin, affiché sur la carte / le QR du client.
String get nomMagasin => _nomMagasin;
Future<void> charger() async {
try {
final row = await supabase
.from('reglages')
.select('euros_par_point, nom_magasin')
.eq('id', 1)
.maybeSingle();
if (row != null) {
_eurosParPoint = (row['euros_par_point'] as num?)?.toDouble() ??
Config.eurosParPointParDefaut;
_nomMagasin = (row['nom_magasin'] as String?) ?? '';
notifyListeners();
}
} catch (_) {
// Réglages par défaut si non connecté / hors-ligne.
}
}
/// Points gagnés pour un montant donné, selon le ratio courant.
double pointsPour(double euros) =>
_eurosParPoint > 0 ? euros / _eurosParPoint : 0;
Future<void> definirEurosParPoint(double valeur) async {
if (valeur <= 0) return;
await supabase.from('reglages').update({'euros_par_point': valeur}).eq('id', 1);
_eurosParPoint = valeur;
notifyListeners();
}
Future<void> definirNomMagasin(String valeur) async {
final v = valeur.trim();
await supabase.from('reglages').update({'nom_magasin': v}).eq('id', 1);
_nomMagasin = v;
notifyListeners();
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'package:supabase_flutter/supabase_flutter.dart';
/// Configuration de connexion au backend Supabase (partagé avec l'app client).
///
/// À remplir avec les valeurs de VOTRE projet Supabase :
/// Project Settings API « Project URL » et « anon public ».
/// Voir le guide : supabase/SETUP.md
///
/// La clé « anon » est publique et peut vivre dans l'app : la sécurité est
/// assurée par les règles RLS définies dans supabase/schema.sql.
class SupabaseConfig {
SupabaseConfig._();
static const String url = 'https://zfhcnzbggpdvgvosrkgp.supabase.co';
static const String anonKey = 'sb_publishable_4R3-_q1Zy_usliTff01Ilg_jhFlIdPH';
static bool get estConfigure =>
!url.contains('VOTRE-PROJET') && !anonKey.contains('VOTRE_CLE');
}
/// Raccourci vers le client Supabase (une fois [Supabase.initialize] appelé).
SupabaseClient get supabase => Supabase.instance.client;
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
/// Thème noir & blanc épuré, avec une seule couleur d'accent.
/// Vert émeraude pour évoquer les points / gains (et se distinguer de l'app
/// sœur « Gestion Prix » qui utilise le bleu, sur la même tablette).
/// Change [accent] pour ajuster la touche de couleur de toute l'app.
class AppTheme {
static const Color accent = Color(0xFF12805C); // vert émeraude
static const Color noir = Color(0xFF111114);
static const Color grisTexte = Color(0xFF6B6B72);
static ThemeData clair() {
final scheme = ColorScheme.fromSeed(
seedColor: accent,
brightness: Brightness.light,
).copyWith(
primary: noir,
onPrimary: Colors.white,
secondary: accent,
surface: Colors.white,
onSurface: noir,
);
return _base(scheme, const Color(0xFFF6F6F7));
}
static ThemeData sombre() {
final scheme = ColorScheme.fromSeed(
seedColor: accent,
brightness: Brightness.dark,
).copyWith(
primary: Colors.white,
onPrimary: noir,
secondary: accent,
);
return _base(scheme, const Color(0xFF0E0E11));
}
static ThemeData _base(ColorScheme scheme, Color fond) {
final base = ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: fond,
fontFamily: 'Roboto',
);
return base.copyWith(
appBarTheme: AppBarTheme(
backgroundColor: fond,
foregroundColor: scheme.onSurface,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: scheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: scheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
margin: EdgeInsets.zero,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: scheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: scheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: accent, width: 2),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: scheme.surface,
indicatorColor: accent.withValues(alpha: 0.14),
elevation: 0,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? scheme.onSurface : grisTexte,
);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected);
return IconThemeData(
color: selected ? accent : grisTexte,
);
}),
),
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:intl/intl.dart';
final NumberFormat _euro = NumberFormat.currency(locale: 'fr_FR', symbol: '');
final DateFormat _dateHeure = DateFormat('d MMM y • HH:mm', 'fr_FR');
String euro(double v) => _euro.format(v);
/// Formate un nombre de points de façon lisible :
/// entier sans décimale (« 12 pts »), sinon jusqu'à 2 décimales sans zéros
/// inutiles (« 0,48 pt », « 2,5 pts »). Gère le singulier/pluriel.
String points(double v) {
final arrondi = (v * 100).round() / 100;
var s = arrondi == arrondi.roundToDouble()
? arrondi.toInt().toString()
: arrondi
.toStringAsFixed(2)
.replaceAll(RegExp(r'0+$'), '')
.replaceAll(RegExp(r'\.$'), '');
s = s.replaceAll('.', ',');
final abs = arrondi.abs();
final pluriel = abs >= 2 ? 'pts' : 'pt';
return '$s $pluriel';
}
/// Comme [points] mais sans le suffixe « pt/pts » (pour les gros affichages).
String pointsNombre(double v) {
final complet = points(v);
return complet.replaceAll(RegExp(r'\s?pts?$'), '');
}
String dateHeure(DateTime d) => _dateHeure.format(d);
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import '../theme.dart';
import '../utils/format.dart';
/// Petite pastille colorée affichant un solde de points (ex : « 12,5 pts »).
class PastillePoints extends StatelessWidget {
final double valeur;
final bool grande;
const PastillePoints(this.valeur, {super.key, this.grande = false});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: grande ? 16 : 12,
vertical: grande ? 8 : 5,
),
decoration: BoxDecoration(
color: AppTheme.accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(30),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.stars_rounded,
size: grande ? 22 : 16, color: AppTheme.accent),
const SizedBox(width: 6),
Text(
points(valeur),
style: TextStyle(
color: AppTheme.accent,
fontWeight: FontWeight.w700,
fontSize: grande ? 18 : 14,
),
),
],
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../models/client.dart';
import '../services/reglages.dart';
import '../theme.dart';
/// Affiche le QR code d'un client dans une boîte de dialogue. Le client peut le
/// prendre en photo (ou plus tard on l'imprimera) : c'est sa carte de fidélité.
Future<void> afficherQrClient(BuildContext context, Client client) {
final magasin = Reglages.instance.nomMagasin;
return showDialog<void>(
context: context,
builder: (ctx) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (magasin.isNotEmpty)
Text(
magasin,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppTheme.grisTexte),
),
const SizedBox(height: 4),
Text(
client.nomComplet,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE0E0E4)),
),
child: QrImageView(
data: client.code,
version: QrVersions.auto,
size: 220,
gapless: false,
eyeStyle: const QrEyeStyle(
eyeShape: QrEyeShape.square,
color: AppTheme.noir,
),
dataModuleStyle: const QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: AppTheme.noir,
),
),
),
const SizedBox(height: 14),
Text(
client.code,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 2,
),
),
const SizedBox(height: 8),
const Text(
'Présentez ce code en caisse pour cumuler des points.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: AppTheme.grisTexte),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Fermer'),
),
],
),
),
),
);
}
+754
View File
@@ -0,0 +1,754 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
app_links:
dependency: transitive
description:
name: app_links
sha256: "9d3c82f634c7f5b5c752f7ee46b67724246043f5e1d5fc1b433dd5b38d780dbe"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
app_links_linux:
dependency: transitive
description:
name: app_links_linux
sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81
url: "https://pub.dev"
source: hosted
version: "1.0.3"
app_links_platform_interface:
dependency: transitive
description:
name: app_links_platform_interface
sha256: "78a18580eecac98108d1eef52a7db668bc317714f5205e616973363326efe333"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
app_links_web:
dependency: transitive
description:
name: app_links_web
sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: transitive
description:
name: archive
sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev"
source: hosted
version: "4.0.9"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_jsonwebtoken:
dependency: transitive
description:
name: dart_jsonwebtoken
sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
url: "https://pub.dev"
source: hosted
version: "3.4.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
functions_client:
dependency: transitive
description:
name: functions_client
sha256: "5b17b8dcf5ae1cd6a6428e7a4ca8b569477297a7f158955b5407dda23baeaa5b"
url: "https://pub.dev"
source: hosted
version: "2.6.3"
gotrue:
dependency: transitive
description:
name: gotrue
sha256: "54e8abe49c8596234c503f4b64d07022c7913a6475c7a85c1f1eacc1a93af166"
url: "https://pub.dev"
source: hosted
version: "2.24.0"
gtk:
dependency: transitive
description:
name: gtk
sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
url: "https://pub.dev"
source: hosted
version: "4.9.1"
intl:
dependency: "direct main"
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
version: "0.20.3"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
jwt_decode:
dependency: transitive
description:
name: jwt_decode
sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb
url: "https://pub.dev"
source: hosted
version: "0.3.1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce
url: "https://pub.dev"
source: hosted
version: "7.2.0"
passkeys_platform_interface:
dependency: transitive
description:
name: passkeys_platform_interface
sha256: "9610bd136b3382500390912ddd8517ee99505228b1af7b507b9c907f7e99a47d"
url: "https://pub.dev"
source: hosted
version: "2.8.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
posix:
dependency: transitive
description:
name: posix
sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07"
url: "https://pub.dev"
source: hosted
version: "6.5.0"
postgrest:
dependency: transitive
description:
name: postgrest
sha256: "801f6659cffbb6372d38cb4deb0cd892cb0cb38d75e0686ff24f4a724e9f4999"
url: "https://pub.dev"
source: hosted
version: "2.7.4"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
realtime_client:
dependency: transitive
description:
name: realtime_client
sha256: "3261ba98af41fccfdbb6c66299b5bd6871907e98bb41bce9d55e0fb91287ed85"
url: "https://pub.dev"
source: hosted
version: "2.9.1"
retry:
dependency: transitive
description:
name: retry
sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: transitive
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
url: "https://pub.dev"
source: hosted
version: "2.4.26"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
storage_client:
dependency: transitive
description:
name: storage_client
sha256: "2cc5432c312c4a08687bcce795aff377d006e2892097c7f88d57512885bb4b10"
url: "https://pub.dev"
source: hosted
version: "2.5.9"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
supabase:
dependency: transitive
description:
name: supabase
sha256: d425df9330ce5f215a0ee708b084899d89031e8a8f3e096f46c38ea37df9fd7d
url: "https://pub.dev"
source: hosted
version: "2.13.3"
supabase_flutter:
dependency: "direct main"
description:
name: supabase_flutter
sha256: "262c7eda45997a308ac0311be82fe8e9134526a18125fe5e0cf0a58e9cd16cb6"
url: "https://pub.dev"
source: hosted
version: "2.15.3"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: transitive
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
source: hosted
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
yet_another_json_isolate:
dependency: transitive
description:
name: yet_another_json_isolate
sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9
url: "https://pub.dev"
source: hosted
version: "2.1.1"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.44.0"
+103
View File
@@ -0,0 +1,103 @@
name: app_fideliter
description: "Application de fidélité magasin (tablette) — scan QR client, points, récompenses."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.12.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
mobile_scanner: ^7.2.0 # scan du QR code client (caméra en attendant la scanette)
qr_flutter: ^4.1.0 # génération du QR code d'un client (affichage / impression)
supabase_flutter: ^2.8.0 # backend : auth staff + base de données partagée
intl: ^0.20.3 # formatage € et dates (locale fr_FR)
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.14.4
# Icône de l'app (générée par : dart run flutter_launcher_icons)
flutter_launcher_icons:
android: true
ios: false
image_path: "assets/icon/icon.png"
adaptive_icon_background: "#0E7A55"
adaptive_icon_foreground: "assets/icon/icon_foreground.png"
min_sdk_android: 21
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
+77
View File
@@ -0,0 +1,77 @@
# Configuration Supabase (à faire une seule fois)
Le backend est partagé par les **deux apps** : `app_fideliter` (tablette / staff)
et `app_fideliter_client` (client). Suivez ces étapes dans l'ordre.
## 1. Créer le projet Supabase
1. Aller sur https://supabase.com → **New project** (gratuit).
2. Choisir un nom, un mot de passe de base de données, une région proche
(ex : `Europe (Paris)`), puis **Create**.
## 2. Créer les tables
1. Dans le projet : menu **SQL Editor****New query**.
2. Copier-coller **tout** le contenu de [`schema.sql`](schema.sql) → **Run**.
3. Vérifier dans **Table Editor** que `clients`, `mouvements`, `recompenses`,
`reglages`, `staff` existent.
## 3. Récupérer les 2 clés à mettre dans les apps
Menu **Project Settings****API** :
- **Project URL** (ex : `https://abcd1234.supabase.co`)
- **anon public** key (une longue chaîne — c'est la clé *publique*, sans danger
dans une app ; la sécurité est assurée par la RLS du `schema.sql`).
Coller ces 2 valeurs dans **chaque** app, fichier `lib/supabase_config.dart` :
```dart
class SupabaseConfig {
static const String url = 'https://VOTRE-PROJET.supabase.co';
static const String anonKey = 'VOTRE_CLE_ANON';
}
```
> ⚠️ Ne jamais mettre la clé **service_role** dans une app : elle contourne
> toute la sécurité. Seule la clé **anon** va dans les apps.
## 4. Créer le compte du magasin (staff)
Le staff (la tablette) a besoin d'un compte avec droits d'écriture.
1. Menu **Authentication****Users****Add user****Create new user**.
Mettre un email + mot de passe (ce sera l'identifiant de la tablette).
Cocher « Auto Confirm User ».
2. Copier l'**UID** de cet utilisateur (colonne `UID`).
3. Menu **SQL Editor**, exécuter (en remplaçant l'UID) :
```sql
insert into public.staff (user_id) values ('COLLER_UID_ICI');
```
Ce compte peut maintenant se connecter sur l'app **tablette** et tout gérer.
Les **clients**, eux, créent leur compte tout seuls depuis l'app client (ils ne
sont pas staff → ils ne peuvent que consulter leurs propres points).
## 5. (Recommandé pour tester vite) désactiver la confirmation d'email
Pour que la création de compte client marche sans étape email pendant les tests :
**Authentication** → **Providers****Email** → désactiver
« Confirm email » (à réactiver plus tard en production si souhaité).
## 6. Lancer
```bash
# App tablette (magasin)
cd app_fideliter && flutter run
# App client
cd app_fideliter_client && flutter run
```
- Sur la **tablette** : se connecter avec le compte staff de l'étape 4.
- Sur l'**app client** : « Créer un compte » → un client + son QR sont générés.
- Scanner ce QR depuis la tablette → sa fiche s'ouvre, on ajoute une facture,
ses points se mettent à jour dans l'app client (au rafraîchissement / retour
dans l'app). 🎉
+226
View File
@@ -0,0 +1,226 @@
-- ============================================================================
-- FIDÉLITÉ — Schéma Supabase (Postgres)
-- À coller dans Supabase → SQL Editor → New query → Run.
-- Idempotent : on peut le relancer sans casser l'existant.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 1. Fonction de génération de code (utilisée en défaut de la table clients,
-- donc définie AVANT la table).
-- ----------------------------------------------------------------------------
-- Génère un code client unique du type « FID-3F9K2A » (sans I, O, 0, 1).
create or replace function public.generer_code_client()
returns text
language plpgsql
set search_path = public
as $$
declare
chars text := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
nouveau_code text;
i int;
begin
loop
nouveau_code := 'FID-';
for i in 1..6 loop
nouveau_code := nouveau_code ||
substr(chars, floor(random() * length(chars))::int + 1, 1);
end loop;
-- variable renommée pour éviter l'ambiguïté avec la colonne clients.code
exit when not exists
(select 1 from public.clients c where c.code = nouveau_code);
end loop;
return nouveau_code;
end;
$$;
-- ----------------------------------------------------------------------------
-- 2. Tables
-- ----------------------------------------------------------------------------
-- Personnel du magasin (droits d'écriture). On y ajoute l'ID d'un compte auth
-- pour qu'il devienne « staff » (voir SETUP.md).
create table if not exists public.staff (
user_id uuid primary key references auth.users(id) on delete cascade,
created_at timestamptz not null default now()
);
-- Un client fidélité. Lié à un compte de connexion (auth.users) quand le client
-- s'inscrit lui-même depuis l'app client. user_id peut être null si le staff
-- crée un client « au comptoir » sans compte.
create table if not exists public.clients (
id uuid primary key default gen_random_uuid(),
user_id uuid unique references auth.users(id) on delete set null,
code text unique not null default public.generer_code_client(),
nom text not null,
prenom text,
telephone text,
points numeric not null default 0,
created_at timestamptz not null default now()
);
-- Ajout de la colonne prénom si la table existait déjà (migration).
alter table public.clients add column if not exists prenom text;
-- Historique : gain (facture) / dépense (récompense) / ajustement.
create table if not exists public.mouvements (
id uuid primary key default gen_random_uuid(),
client_id uuid not null references public.clients(id) on delete cascade,
type text not null check (type in ('facture','recompense','ajustement')),
montant_euros numeric,
points numeric not null default 0,
libelle text not null default '',
created_at timestamptz not null default now()
);
create index if not exists idx_mouvements_client on public.mouvements(client_id);
-- Catalogue des récompenses.
create table if not exists public.recompenses (
id uuid primary key default gen_random_uuid(),
nom text not null,
cout_points numeric not null default 0,
actif boolean not null default true,
created_at timestamptz not null default now()
);
-- Réglages globaux du magasin (une seule ligne, id = 1).
create table if not exists public.reglages (
id int primary key default 1 check (id = 1),
euros_par_point numeric not null default 10,
nom_magasin text not null default ''
);
insert into public.reglages (id) values (1) on conflict (id) do nothing;
-- ----------------------------------------------------------------------------
-- 3. Fonctions & triggers de logique métier
-- ----------------------------------------------------------------------------
-- Vrai si l'utilisateur connecté fait partie du personnel.
-- SECURITY DEFINER pour pouvoir lire la table staff malgré la RLS.
create or replace function public.est_staff()
returns boolean
language sql
security definer
stable
set search_path = public
as $$
select exists (select 1 from public.staff s where s.user_id = auth.uid());
$$;
-- Tient à jour le solde de points du client à chaque mouvement, pour que le
-- solde reste fiable quelle que soit l'app qui écrit.
create or replace function public.maj_points()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
if (tg_op = 'INSERT') then
update public.clients set points = points + new.points where id = new.client_id;
elsif (tg_op = 'UPDATE') then
update public.clients set points = points - old.points + new.points
where id = new.client_id;
elsif (tg_op = 'DELETE') then
update public.clients set points = points - old.points where id = old.client_id;
end if;
return null;
end;
$$;
drop trigger if exists trg_maj_points on public.mouvements;
create trigger trg_maj_points
after insert or update or delete on public.mouvements
for each row execute function public.maj_points();
-- Empêche un client (non-staff) de modifier son solde, son code ou son user_id.
create or replace function public.proteger_client()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
if not public.est_staff() then
new.points := old.points;
new.code := old.code;
new.user_id := old.user_id;
end if;
return new;
end;
$$;
drop trigger if exists trg_proteger_client on public.clients;
create trigger trg_proteger_client
before update on public.clients
for each row execute function public.proteger_client();
-- ----------------------------------------------------------------------------
-- 4. Sécurité (Row Level Security)
-- Client = lit uniquement SES données. Staff = accès total.
-- ----------------------------------------------------------------------------
alter table public.staff enable row level security;
alter table public.clients enable row level security;
alter table public.mouvements enable row level security;
alter table public.recompenses enable row level security;
alter table public.reglages enable row level security;
-- staff : seul le staff peut lire la liste (personne ne s'auto-déclare staff).
drop policy if exists staff_select on public.staff;
create policy staff_select on public.staff
for select using (public.est_staff());
-- clients
drop policy if exists clients_select on public.clients;
create policy clients_select on public.clients
for select using (public.est_staff() or user_id = auth.uid());
drop policy if exists clients_insert on public.clients;
create policy clients_insert on public.clients
for insert with check (public.est_staff() or user_id = auth.uid());
drop policy if exists clients_update on public.clients;
create policy clients_update on public.clients
for update using (public.est_staff() or user_id = auth.uid());
drop policy if exists clients_delete on public.clients;
create policy clients_delete on public.clients
for delete using (public.est_staff());
-- mouvements : le client lit les siens ; seul le staff écrit (anti-triche).
drop policy if exists mouvements_select on public.mouvements;
create policy mouvements_select on public.mouvements
for select using (
public.est_staff()
or client_id in (select id from public.clients where user_id = auth.uid())
);
drop policy if exists mouvements_insert on public.mouvements;
create policy mouvements_insert on public.mouvements
for insert with check (public.est_staff());
drop policy if exists mouvements_update on public.mouvements;
create policy mouvements_update on public.mouvements
for update using (public.est_staff()) with check (public.est_staff());
drop policy if exists mouvements_delete on public.mouvements;
create policy mouvements_delete on public.mouvements
for delete using (public.est_staff());
-- recompenses : tout compte connecté lit ; seul le staff modifie.
drop policy if exists recompenses_select on public.recompenses;
create policy recompenses_select on public.recompenses
for select using (auth.role() = 'authenticated');
drop policy if exists recompenses_write on public.recompenses;
create policy recompenses_write on public.recompenses
for all using (public.est_staff()) with check (public.est_staff());
-- reglages : tout compte connecté lit ; seul le staff modifie.
drop policy if exists reglages_select on public.reglages;
create policy reglages_select on public.reglages
for select using (auth.role() = 'authenticated');
drop policy if exists reglages_update on public.reglages;
create policy reglages_update on public.reglages
for update using (public.est_staff()) with check (public.est_staff());
+23
View File
@@ -0,0 +1,23 @@
// Tests de base de l'app de fidélité.
//
// Le formatage des points est au cœur de l'affichage : on le vérifie ici.
import 'package:flutter_test/flutter_test.dart';
import 'package:app_fideliter/utils/format.dart';
void main() {
group('format points', () {
test('entier sans décimale, pluriel géré', () {
expect(points(0), '0 pt');
expect(points(1), '1 pt');
expect(points(12), '12 pts');
});
test('décimales sans zéros inutiles', () {
expect(points(0.48), '0,48 pt');
expect(points(2.5), '2,5 pts');
expect(points(1.5), '1,5 pt');
});
});
}