From 1d3ee69c6d2053d25226c800c0cf3b7eb56aafbc Mon Sep 17 00:00:00 2001 From: Mathew Date: Fri, 17 Jul 2026 21:37:06 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20app=20fid=C3=A9l?= =?UTF-8?q?it=C3=A9=20magasin=20(tablette)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 45 ++ .metadata | 30 + README.md | 91 +++ analysis_options.yaml | 28 + android/.gitignore | 14 + android/app/build.gradle.kts | 53 ++ android/app/proguard-rules.pro | 11 + android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 56 ++ .../com/magasin/app_fideliter/MainActivity.kt | 5 + .../drawable-hdpi/ic_launcher_foreground.png | Bin 0 -> 3700 bytes .../drawable-mdpi/ic_launcher_foreground.png | Bin 0 -> 2176 bytes .../res/drawable-v21/launch_background.xml | 12 + .../drawable-xhdpi/ic_launcher_foreground.png | Bin 0 -> 4445 bytes .../ic_launcher_foreground.png | Bin 0 -> 6528 bytes .../ic_launcher_foreground.png | Bin 0 -> 8790 bytes .../main/res/drawable/launch_background.xml | 12 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 9 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3672 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2098 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4161 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7179 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 8464 bytes .../app/src/main/res/values-night/styles.xml | 18 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/styles.xml | 18 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle.kts | 24 + android/gradle.properties | 6 + .../gradle/wrapper/gradle-wrapper.properties | 5 + android/settings.gradle.kts | 26 + assets/icon/icon.png | Bin 0 -> 53618 bytes assets/icon/icon_foreground.png | Bin 0 -> 15754 bytes lib/config.dart | 13 + lib/main.dart | 48 ++ lib/models/client.dart | 68 ++ lib/models/mouvement.dart | 53 ++ lib/models/recompense.dart | 42 + lib/screens/auth_gate.dart | 100 +++ lib/screens/client_detail_screen.dart | 623 +++++++++++++++ lib/screens/client_edit_screen.dart | 138 ++++ lib/screens/clients_screen.dart | 190 +++++ lib/screens/home_shell.dart | 61 ++ lib/screens/recompenses_screen.dart | 220 +++++ lib/screens/reglages_screen.dart | 268 +++++++ lib/screens/scan_screen.dart | 177 ++++ lib/screens/staff_login_screen.dart | 155 ++++ lib/services/client_repository.dart | 223 ++++++ lib/services/recompense_repository.dart | 73 ++ lib/services/reglages.dart | 56 ++ lib/supabase_config.dart | 22 + lib/theme.dart | 118 +++ lib/utils/format.dart | 31 + lib/widgets/pastille_points.dart | 42 + lib/widgets/qr_client.dart | 83 ++ pubspec.lock | 754 ++++++++++++++++++ pubspec.yaml | 103 +++ supabase/SETUP.md | 77 ++ supabase/schema.sql | 226 ++++++ test/widget_test.dart | 23 + 60 files changed, 4468 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 README.md create mode 100644 analysis_options.yaml create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/magasin/app_fideliter/MainActivity.kt create mode 100644 android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-night/styles.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle.kts create mode 100644 assets/icon/icon.png create mode 100644 assets/icon/icon_foreground.png create mode 100644 lib/config.dart create mode 100644 lib/main.dart create mode 100644 lib/models/client.dart create mode 100644 lib/models/mouvement.dart create mode 100644 lib/models/recompense.dart create mode 100644 lib/screens/auth_gate.dart create mode 100644 lib/screens/client_detail_screen.dart create mode 100644 lib/screens/client_edit_screen.dart create mode 100644 lib/screens/clients_screen.dart create mode 100644 lib/screens/home_shell.dart create mode 100644 lib/screens/recompenses_screen.dart create mode 100644 lib/screens/reglages_screen.dart create mode 100644 lib/screens/scan_screen.dart create mode 100644 lib/screens/staff_login_screen.dart create mode 100644 lib/services/client_repository.dart create mode 100644 lib/services/recompense_repository.dart create mode 100644 lib/services/reglages.dart create mode 100644 lib/supabase_config.dart create mode 100644 lib/theme.dart create mode 100644 lib/utils/format.dart create mode 100644 lib/widgets/pastille_points.dart create mode 100644 lib/widgets/qr_client.dart create mode 100644 pubspec.lock create mode 100644 pubspec.yaml create mode 100644 supabase/SETUP.md create mode 100644 supabase/schema.sql create mode 100644 test/widget_test.dart diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..aa8e03d --- /dev/null +++ b/.metadata @@ -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' diff --git a/README.md b/README.md new file mode 100644 index 0000000..b338c55 --- /dev/null +++ b/README.md @@ -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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -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 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..0acdd0c --- /dev/null +++ b/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..6398d5a --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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.** diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1488d01 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/magasin/app_fideliter/MainActivity.kt b/android/app/src/main/kotlin/com/magasin/app_fideliter/MainActivity.kt new file mode 100644 index 0000000..1a01a19 --- /dev/null +++ b/android/app/src/main/kotlin/com/magasin/app_fideliter/MainActivity.kt @@ -0,0 +1,5 @@ +package com.magasin.app_fideliter + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..b592a6b0286ce6c963d9d994810e16f1bfc0ca67 GIT binary patch literal 3700 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 v84ash^esbG40Xr + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..4f88a4173326c7e7cb2a45c07dbb196b9f30bfc1 GIT binary patch literal 4445 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..4a787685da987ac554be1f5c84a1c319e0765ff1 GIT binary patch literal 6528 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c79c58a --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..88289b6ea541d60771d888d485e8b2dc757dd876 GIT binary patch literal 3672 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..50ce11f50a271b474d6b68c9084395ccf94ee8ab GIT binary patch literal 4161 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 N002ovPDHLkV1fq}!sY+~ literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..73036c84d006a7acf6a7e18001b9a49e35076565 GIT binary patch literal 8464 zcmV+rA@AOaP) z33wD$w)jtVSMS}~7f2vsO9BBBAnb?=hzJTQ3WMuBMVxV5N8Y@7^JdtOtef6ccsygYu z=iYP9J@;H5D1oyYdEKC1!}#2kQCgENPuD+fkR~NATVpk)f+!{cfQbW+2M#!;Wo2}Z zD2f6AxB#Fj;B;39YMRao7wgaXs~XCL3zzr%&Q_NME;qZBmJ{KXx-qBcL6@76Zx}uB zPW|w#Y1*XtaU6j4dmj|Zr>}d?pc&Fp0od@+noXJ7Y|}_xcKVg~MG$>rqjR74=(#QK z;?tYFr5ARKPLE#+S)-}|oY|l?jPEzaFl^9V!_cfboSx6T`YvnPJp~Rx$Hki`8Yd3M z^k?svbAEZ+ZiocqM`tr^dy_*w!&wOHx7GRqk;4gPGR&QIGr}l zFf{95@UT5Fxz4?_>@DY4$NnUos;H3r-m)ryHZ?BZa@V+@8z*ZpKs0MhVh6+LO3IQThE$ET7PnT5j$0}eQyp|Jmx z`ojJ{^d3C@_vY2Re&;_^cS-8|NUi|pnWN@g@13}m%#bAVrj!(1BoA`mgF#C?CH#RN|G&&Y(+;Uv&Ft^)G!qAy8+p5B0o|6@ZI3>l2=z zvr?O6D`Xu%gGRLRmb}DY+`F~$^>w!j<<(W8oD-S?=y@*wxf%Z;Ha~+wG1>%6LHrZ9 zZLEL!<4J)=r=zRShok^;Kf3u>`aT)+C6SQ9kQp@5Z4cf2a^vgkp6TkdT`GX_reU*; z!*gB$Hp9hG5&D9DPg-Ud?Q*OxUf$W~I#mEnZ?Mgq_!gVwVyF!cKx4jR?AyN6)rY-D z&mZmRbDb)H?Z-3!htu;JvPj5a$PCAErnrUE-mm@D2cyC1ZFj|4hYDbxF><~kJLO>( z@nNVLjm12|di&^S?H}!YwY~S-RRB$jEx|f>+)|d+$%qBa(+Yp>+J5X^e`Q16_3yQ- z0G1h}o&;d|{EQfZgLvEgNxyA)ZSB+7zx6!@(4@r08zXMq#rS?AA)Q8?4M+#mZ6mp>bP;fexqW<775l>Zbn zeHgKfe&oOhILr2b6&-FLv{eB8n0`|@juow9#5NoU$;K%;vt4USRzh0^Fbo+mS5e6r zj3{9oIpoo66oAw5pdZp_HZy-1JpsOV;tW13B~3V2TTK*zAJl6or_*M#SzksEf!F8v zogtj7{RdG1!^r-3DlJj0fZG@F8VzVU4QMqQ(BNN=0}Z~zwH_U8av*{T0TJu#2OvNm zi{KXn;12|@{0*>9N)il({qJ;cIMAX1y1`kuu?OBD)V!k2X7Q2(K|%bCni^Hqf-ehL63yTKD8bxlwWPK%c?x^AIzM zaTSf;+>ht8QU)n42rXb^G3yM(p^YVQBug-ui6XcJFFEKO!=ek{J28vbB-*l-7DQ># zwzuhwQFmyAql*}&=7@@5)0@DeGmsGh6`?1ii8p8P8jB&dr*;PzQL*WbVBmF%+<$bh z0i-c*J%22!_r@!=BfwH;~8)M(4xbzRlsfnFA;#fEm|1Btq6wNiaRwvl1(hVlWP@9> zhl5V5g$WrsuH&!f3x(izBerT^z244yz5 zKk`uiWY}4ui~?MRBmMzKz?!_So-&7^J~) z2B#aU6_pD?e<(?-CB6a9EXOt+$H#gGsEId_2G^qIbo{T_`^OuuTApZz%*z`O|9{VC zipmuPllL%e>hO7pZxGAyl(Y$1uBd14h7-y{oB7Zo}`)O6Ei~ z(CBv3^7ToMEIY+34d%yS4~sLov@tbn7^GX`zxR5w$p&}jjDe5K_9!A(M_|w((P)7N zk25Cy0{U7=H^Ih_KNwGmy?;IP2aoUYy@dr+VdaUv)HO7nmWO1M73$qiGMgMr#FPo7 z(X=*xPD_o+)0RwIB8<)GIP;`$Try0_8VX;WJrY&9LO}x>^4-?E9U#O|a$U?g!Pw{% z3>MJLNx~b+k zxI1@Dm!BJ!+80Kq_lE;jXQCokxCBki`WR6H)hj^o2Z?qfTe#epKZ)4Vp)e<>h*|}J z*05~?DuK%mf=EZyDpFMK1CYjVpx5RPf(dU6hD;3k^D_sGgg@+AOJ-zg1sD0F$yw*N zQ|GjYgQ`aW40X~P?P<%bfg>T#5PHfL&R;ATIti8?*dnW3-9@Wuag6I+j;N({OO0xs zU~D9Ml1oFs{Bv?fhkXu?7=JtTCG}xP%qWdFnuuepMpS76VH&fUH>jcQ6Q_1WdcUyG zOS8no+}yFS;`lCEa+1lHum;p%I^-X-k4}xK^OzgrsuG0Y}nLOmNwy zE~i*kCJ^SU(;o3@%c2p}x>YF-nKxtt?7Bd|0zm6*alGD4<_Oe+YMfvLso0=Xh0_bI z$!&M$bv^Ob6;u0R@?0ELIT|IAOKvbQfxnZ}r;e3nyc(JluHm&Aj4EmREzBA;N|F#Q zUSq+~8{m(7*GeK+G%)Lo;PUxYQOH*nCJ-)Q$Kh2CvO>!)ByjJL36foJLH;Co=g?Oq zFJBF4NQY%og}l@|!DvHc*i%hR?wbz-6Q$d23LDe?LvMhk#hWC%rrcnWF^^UUZpy3C zQLg|t6-9l5JX~<2?23WFdwe0tj5)5TC z1qcEBfoZJg(;4XUE-IQHwfoJ-#WKZPpqb(oW7pHNo2HJsU23TF0dS~P+qOhE^&1L< zQ!=A=Ka8*6ky`}Ik9-rgdrAS00=R)6O;FXY-Uc>?>#&%3#RlN1_vw8kmZOpHL8xl-`1$pfs3Mf0jf+O z+;N=-c`yQ%Vu^!ZmIO$%#*>;p>DC13WlbQD(<}+(xssz5PPV;1>oM{ew?VCOHbRY~ z39229P-$-<&#UYWB#jzh$Hz2meqqw;z~!Y$0Vo2)pK^nhByhbgHef7L8*J$KJk=6U z-orYWYRW&tjW#lDN#y7V+(a8|{?|I2pvuwEq9l%%$JO>ms3r>22$!8rvi3n(8wo?k zeoE>IQKJBn?!b;;W}?Xg$)-3+Hd`UlWF^}VC7a{O>%qrKCM$VA8cIeZWXYn@Opbv` zy*m7yKM)`$6)MVQXEUk$bJ=MpiqZ18nH=?QJJdRxBU%HDBd3EBGl-{Ub`C>ioymfw z4}UOhQi~~KNgy$IG)0olwulsm(LNZi#P1Z3uE0^H(c^42y{?WbpOx-~@=nq!Kj&huqqokvSeS2`3Rg500-h8lQylsfI9*o>kF5F= ziTXqW{^scQDP2WQ@ezRU<^mFz&kIkjUIu@d^(fpjuuw9&7#(xq(rNg~>NmSx_5?=( zrE7^Qt^iVmG;vmO@%ne*=ac8ceFc+MHH)h@)}1JZ|JeM#Bn=l!t9unLa-qBeNVJ3y z_p5qw+edJ`_5!>xWdWNjkb!?5+6FIe`$V$%y_Nz$K=CjrSwkzK44)j?1!6#i|98v% zY$T8fobmm`&eibOZ#PSNT`dLG{uG}HmSbDfmB)5LovRsMn*I!7s=UI~1&j_75XEQvu>d#PGb3wLy(6IJN@ex4o^}lE9 zt6~1~-^1@`{tJxl6aAH2YUCVFaDDzJd{wWOIM+-p7xd6p|08JFS0Zi3h_{nRq zWY(i_YtBen@1=H%D^8Kd-{gr-Y4euuPe}!!AQ>(iGT>an&(^;~9y29qpIzrl;U}NH z5!K2KN}EB7n?U5Bk5aS}BN{(l^ENy_b_P5(;dVAx(1y>C?}Zn(e4s$t5@kcxif@r5 zMmbiGlQ?he-9Q$Jzj*6|J$^MA#`u?f^C`UjtunjPiwgU}%9}u96o-H|tU9`z_w1fO;AP%g}^-i z-)>nBt4sGODu*a4I|eA;_2CzwWXi&!OK0HW6@P@k&wq|AxbF#IRfmNizXoNs6-tYu z__|=l6#%!=RN56SZ>Wa*mcIha<~{|3S{HZ6Dp*`#(aP7UX#7CMdH|&@pn!5FP+KlF z)x&)syaIoi{TSTPPrhAVl;lWdIXu7SZDRJ(KtN&pAE+1sXemsDxi4Nf{AATq`2DO$ z;I_O`N{b^YJI)@5pM1Jh@dYSt2~bcSPH`u=q4XsI(a0L!jWdS6%@qumjtppjUsTMUV{qU+7c-<#)YT z-URY1KMb}<>T-BQp9^Q@T$o^3$`R zw3;3TAfpilZI6l(02KfXN=tE-qRiMae}W86&w`aDU6(G87PvNp2B=T~G=b0_q@hXx zkg#FwM1X?irbcfc<4~Z*AK^txQV~2p9~gDY4+FSBW<!xIb>>yMr z0H#HFh%>Cr7O0u=$&g}+-5R3Uv7i~W>BIp=?vExeO$(raCLbV()9X=e58zmw0K1-| zJ~^h(jL^AK+qZu67P(;@F0Th{QJj$--aA|NTqN{wUfsG1{#pFxHEp4?xgH*0@h6x)bR7KalzSlECU1eR zXe$8zmb9eYTo1hp;PZf4Z;aYHfg%|<&%g!g%lB`EzwOxswViA%z53`L*j#o19vL?s z9veRcEPA6X@(k^j4WAs|Aiid2e!lWH{T6+7fh7Af^~kctf-kIP7l4kKhWv~2O*KWddLX zxf>?h{!X8ps+r&Gq1FTn(&TnPqS+?v>~R0&bm9a{L6h4F|NQn#V(v71T;0Cj&a=m1 z)|>wca|}dJZ-X^Q_QG>F-vtXs-PG*}8TvfOE9FxFtU~AV(iiu? z9_mK`AijW`%nr*n3go5s>DKvYE?2_Kn^(Zs-<3!vQ+wdPy)SND4l53P4KLpQ!){I7 z6=e1Y_*PQ!u7JHhj)ZDJ@hy@F#$kU1H z3b^mx|Aslk$HU_jXAu)FT#EY27xluZ4=q(6x`GGFZL}a5OV(?7Fv*Z7M4}>Lfcfn2 z?b`x>*}j_0FIxgsHq}cimoMN4yVpgt z-+K+JFoA+JxtyT4@RH6AV00xRj2fV2RTtn_Yu|;Ei_x1%>I__4`{s^yu<>{?{9^hK z;HI2{kk2j57yy+`kwvN615KYs^nX;+_(5$7fVm`STVkG&RQ^DyrU1tAU)%O6tUtDo zx|CkzY+V)n=zm{>n+6rY;;C~XH?3>GrYI{X;xO&?kjC$JQc>?S98{$M2qtl%jVV>w zvMU9^2o&b(EZeb;skel~3$B$_)O-K6vbaL&d}VYX*iP#*?u z545{63@AkgC5eV~z>d?$;MZ&4g9=Kv6bcm_DOVoa0o%Vj0>8ZVKA172sNE40M+x++ zy1Q!(`b5p{?$3EB0o60XozdvBgGOT`KVYO_`IDS>sz4mQR1QnOSq)#59Z>1F(?X5C z0iIjAl!QwcPrVn$^&5Q6`&ckHFTD?xTs#x;J<#N-ciO2;bq)p9nLt7C>uzw`A=wfa zu~R&v&%kTWMbqk)Eg!*$`?pFcsq!4Ycm{s>{wpvme+)5;ddDYUc@GogB2@sa@Lcb( zQ@h7QD5%l|3WClk{s2i9TSN(Cg{>9f(1H8BWH&7N;$x_`H^?9<9jrUL5578a2%evM z4?Hkt8u`xh&{>Y44PcMo;Brz^;Vn{BI|2m3A-K-b3`u4yXu=!`&K?YWD9oE{Bf~Y=<9Dz5|AYIR^r5!ku0f1O>W6wLU;wgn$6`E;~_zaKfX@cCLe+ zr;o}i8?BU8pNF4*vJ?t4!>bj95n#0WY1?r!QdDgMwZ$h0Q0K6duwSU=(9Z9AX5(+m zp$p%I{8==6Vx{K~qIwgkEm%#W&d~%3W^2rCaG^Bl@HV)eG1d4X))W9*x87;-32d@2 zr0FS;P^ZeHJslAC8=4hb?i?XA1I#~v}^1h4Ab=z4Gw3EHjkCJR+NQPNB>v5lsUEaZY0QN3|hcnst5 z3#1$gragy~Gv5=zydGTAskW@nh!8~)_XzWIz{Py4K)(Q;W3%2swhN+VLq1v%TdTJz zwZKOSaF2Fu_?X6LQ(Bm4fz{b?HZA_|+^;q8kM@4jUU6gHF5k>60=<-%`CVlK~i_!_`{7?q}_T%m`I zhP2o1krUA?)bV1I^BkD;Bb63LNt~^XA=VWEdweV|fOdW?hc-h+Y<6Ab{dLV}wCVCz zBUTHogyzZQLKLv%%d38x9^gO@fMG(&-i1x+xqmj*pEV z9a|c!SI~r5Z$Hlas~eAqqUhr|jwQ`>0QROqKLO1mEYoPz=}5*Y4I=|KdaTIc_W63o z1eQej>zYe>vDxJk?Vi1y)i_CMfz*KC9{`8H#YDo!kBjngaXH`mS1a=b8%A)jkz>(d zFWFGgAG3*Saw3A*56p2=XS9FgN1yob*&4?e%50HVw5@}0W$mM$;z-xZUN!H^l@bmd2)({p7L zw`-;~eI@5!=W{cj=Qxff$}?gT-*;7iZ~Ocj1@KqaRS0LR-_-R_e>&=tF&HuvXmsol zPFCz_``Y&sTf94uzrbgwJ;HHZ==%mS7_=jbqG!*^#o)ho*8F=45U6+53FX!A>iVTW z6;1gV3_0=F+dmUdRP4F_^>$Mmy*o=^;`^mN!~w)dMLGsUMnq9;c5gZOYGGyPO;d$u@5$$k z;|DMA>~jnTjd%~8T`Ziq_)TY@>2iIa@9>!q`K;92`HZ9oC6$iBkP&}%;~MXt6MyOI zb6qcR^^4%zd~lI@W>JbZE&evC1Y|JeBH(bBxxYNT5d2*)4-IXFx6|WxZ7jaqJa^&_ zjoCO#0vQ<$Sr7&P1=prSw*>0#)uDVo-Q#wxE56r!=lI>6PMg*(DH#kI5Cwmo zYt6ptfy$O#|4<04#z&yG>5TKU-8Y(M7JaI*7zc-N3WE_9NaNqIf40A}zBHWk!Y*E3wYS>5OXy9}dp{stUp>dy5TjF1pTkN0r-V(-3_e-0(o z*%^`)y7YZ}`olnF(_zDu;qPlqh8|sNogpux&tKvB=Ewr!gDkba0}YNk_xk4SBHdOg8srQXl++7vnO z$xt_<-{0WBaQS23>8khqr!Q@XfV`z7qq5sWpuDQoQ(pC~*PvUh&B>Up%gcO3lVqLA z^?;q~8C@e{%B9`4O*mQkq3`Iq4@B41x;=6e)dnbfe1dSKd=(yCsx6hzOqs!Fr{ASX zwN2%KON^#03}q2PYz{Qow+Ux!Hu=xjej2E1IIn~piuJ%^O>>Q}rulu}!87lJUdL$? zEdw=4wgOGOIiE8d`e_m^S)4(a!I=!G7;yk#O%iu@n_Nt5ZOO(_fj0000 + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..8ce74b1 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #0E7A55 + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/android/gradle.properties @@ -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 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/android/settings.gradle.kts @@ -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") diff --git a/assets/icon/icon.png b/assets/icon/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..541118cc2be258fb66df55b2a6f0e1489f877467 GIT binary patch literal 53618 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Ⓢ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 literal 0 HcmV?d00001 diff --git a/assets/icon/icon_foreground.png b/assets/icon/icon_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..e7321cc6508fe1508a613af068a8364767ec3c5e GIT binary patch literal 15754 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">v>?f$g*VrEivS$_w0I>kW4YJ^taBL*RQWGE7 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 literal 0 HcmV?d00001 diff --git a/lib/config.dart b/lib/config.dart new file mode 100644 index 0000000..f98bd43 --- /dev/null +++ b/lib/config.dart @@ -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-'; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..a44b79f --- /dev/null +++ b/lib/main.dart @@ -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 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(), + ); + } +} diff --git a/lib/models/client.dart b/lib/models/client.dart new file mode 100644 index 0000000..bec8f6a --- /dev/null +++ b/lib/models/client.dart @@ -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 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, + ); + } +} diff --git a/lib/models/mouvement.dart b/lib/models/mouvement.dart new file mode 100644 index 0000000..55e044c --- /dev/null +++ b/lib/models/mouvement.dart @@ -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 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), + ); + } +} diff --git a/lib/models/recompense.dart b/lib/models/recompense.dart new file mode 100644 index 0000000..97417c4 --- /dev/null +++ b/lib/models/recompense.dart @@ -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 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, + ); + } +} diff --git a/lib/screens/auth_gate.dart b/lib/screens/auth_gate.dart new file mode 100644 index 0000000..50454b9 --- /dev/null +++ b/lib/screens/auth_gate.dart @@ -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( + 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 _chargement; + + @override + void initState() { + super.initState(); + _chargement = _charger(); + } + + Future _charger() async { + await Reglages.instance.charger(); + await ClientRepository.instance.charger(); + await RecompenseRepository.instance.charger(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + 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)), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/client_detail_screen.dart b/lib/screens/client_detail_screen.dart new file mode 100644 index 0000000..f92c9a5 --- /dev/null +++ b/lib/screens/client_detail_screen.dart @@ -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 createState() => _ClientDetailScreenState(); +} + +class _ClientDetailScreenState extends State { + final _repo = ClientRepository.instance; + List _mouvements = []; + bool _chargeMouvements = true; + + @override + void initState() { + super.initState(); + _rechargerMouvements(); + } + + Future _rechargerMouvements() async { + final m = await _repo.mouvements(widget.clientId); + if (mounted) { + setState(() { + _mouvements = m; + _chargeMouvements = false; + }); + } + } + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + Future _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 _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( + 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 _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( + 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 _corrigerPoints(Client client) async { + final ctrl = TextEditingController(); + final delta = await showDialog( + 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 _modifier(Client client) async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => ClientEditScreen(client: client)), + ); + if (mounted) setState(() {}); + } + + Future _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 _confirmer({ + required String titre, + required String message, + required String libelleOk, + bool danger = false, + }) { + return showDialog( + 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( + 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 _optionsMouvement(Mouvement m) async { + final estFacture = m.type == TypeMouvement.facture; + final action = await showModalBottomSheet( + 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é')), + ); + } + } + } +} diff --git a/lib/screens/client_edit_screen.dart b/lib/screens/client_edit_screen.dart new file mode 100644 index 0000000..cbc1503 --- /dev/null +++ b/lib/screens/client_edit_screen.dart @@ -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 createState() => _ClientEditScreenState(); +} + +class _ClientEditScreenState extends State { + final _formKey = GlobalKey(); + 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 _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)), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/screens/clients_screen.dart b/lib/screens/clients_screen.dart new file mode 100644 index 0000000..1392a13 --- /dev/null +++ b/lib/screens/clients_screen.dart @@ -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 createState() => _ClientsScreenState(); +} + +class _ClientsScreenState extends State { + final _repo = ClientRepository.instance; + final _rechercheCtrl = TextEditingController(); + String _requete = ''; + + @override + void dispose() { + _rechercheCtrl.dispose(); + super.dispose(); + } + + Future _ajouter() async { + final client = await Navigator.of(context).push( + 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'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/home_shell.dart b/lib/screens/home_shell.dart new file mode 100644 index 0000000..859a6ae --- /dev/null +++ b/lib/screens/home_shell.dart @@ -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 createState() => _HomeShellState(); +} + +class _HomeShellState extends State { + 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', + ), + ], + ), + ); + } +} diff --git a/lib/screens/recompenses_screen.dart b/lib/screens/recompenses_screen.dart new file mode 100644 index 0000000..e7f3f55 --- /dev/null +++ b/lib/screens/recompenses_screen.dart @@ -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 createState() => _RecompensesScreenState(); +} + +class _RecompensesScreenState extends State { + final _repo = RecompenseRepository.instance; + + Future _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(); + + final ok = await showDialog( + 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 _supprimer(Recompense r) async { + final ok = await showDialog( + 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( + 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'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/reglages_screen.dart b/lib/screens/reglages_screen.dart new file mode 100644 index 0000000..7319875 --- /dev/null +++ b/lib/screens/reglages_screen.dart @@ -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 createState() => _ReglagesScreenState(); +} + +class _ReglagesScreenState extends State { + 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 _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 _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 _deconnexion() async { + final ok = await showDialog( + 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)), + ); + } +} diff --git a/lib/screens/scan_screen.dart b/lib/screens/scan_screen.dart new file mode 100644 index 0000000..2174f03 --- /dev/null +++ b/lib/screens/scan_screen.dart @@ -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 createState() => _ScanScreenState(); +} + +class _ScanScreenState extends State { + 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 _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 _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 _popupInconnu(String code) async { + await showDialog( + 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'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/staff_login_screen.dart b/lib/screens/staff_login_screen.dart new file mode 100644 index 0000000..ea737ef --- /dev/null +++ b/lib/screens/staff_login_screen.dart @@ -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 createState() => _StaffLoginScreenState(); +} + +class _StaffLoginScreenState extends State { + final _formKey = GlobalKey(); + final _emailCtrl = TextEditingController(); + final _mdpCtrl = TextEditingController(); + bool _enCours = false; + bool _voirMdp = false; + String? _erreur; + + @override + void dispose() { + _emailCtrl.dispose(); + _mdpCtrl.dispose(); + super.dispose(); + } + + Future _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'), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/client_repository.dart b/lib/services/client_repository.dart new file mode 100644 index 0000000..8337806 --- /dev/null +++ b/lib/services/client_repository.dart @@ -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 _clients = []; + bool _charge = false; + bool _enCours = false; + String? _erreur; + + List get clients => List.unmodifiable(_clients); + bool get charge => _charge; + bool get enCours => _enCours; + String? get erreur => _erreur; + + Future 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 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 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 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 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 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 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 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> 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 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 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())); +} diff --git a/lib/services/recompense_repository.dart b/lib/services/recompense_repository.dart new file mode 100644 index 0000000..9deffce --- /dev/null +++ b/lib/services/recompense_repository.dart @@ -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 _recompenses = []; + bool _charge = false; + + List get recompenses => List.unmodifiable(_recompenses); + + /// Uniquement les récompenses proposées (actives), triées par coût croissant. + List 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 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 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 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 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)); +} diff --git a/lib/services/reglages.dart b/lib/services/reglages.dart new file mode 100644 index 0000000..69d2f40 --- /dev/null +++ b/lib/services/reglages.dart @@ -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 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 definirEurosParPoint(double valeur) async { + if (valeur <= 0) return; + await supabase.from('reglages').update({'euros_par_point': valeur}).eq('id', 1); + _eurosParPoint = valeur; + notifyListeners(); + } + + Future definirNomMagasin(String valeur) async { + final v = valeur.trim(); + await supabase.from('reglages').update({'nom_magasin': v}).eq('id', 1); + _nomMagasin = v; + notifyListeners(); + } +} diff --git a/lib/supabase_config.dart b/lib/supabase_config.dart new file mode 100644 index 0000000..e0473c6 --- /dev/null +++ b/lib/supabase_config.dart @@ -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; diff --git a/lib/theme.dart b/lib/theme.dart new file mode 100644 index 0000000..49e32c0 --- /dev/null +++ b/lib/theme.dart @@ -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, + ); + }), + ), + ); + } +} diff --git a/lib/utils/format.dart b/lib/utils/format.dart new file mode 100644 index 0000000..975a38b --- /dev/null +++ b/lib/utils/format.dart @@ -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); diff --git a/lib/widgets/pastille_points.dart b/lib/widgets/pastille_points.dart new file mode 100644 index 0000000..adc186d --- /dev/null +++ b/lib/widgets/pastille_points.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/qr_client.dart b/lib/widgets/qr_client.dart new file mode 100644 index 0000000..29def36 --- /dev/null +++ b/lib/widgets/qr_client.dart @@ -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 afficherQrClient(BuildContext context, Client client) { + final magasin = Reglages.instance.nomMagasin; + return showDialog( + 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'), + ), + ], + ), + ), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..9e27147 --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..20c0bbd --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/supabase/SETUP.md b/supabase/SETUP.md new file mode 100644 index 0000000..7b0a84d --- /dev/null +++ b/supabase/SETUP.md @@ -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). 🎉 diff --git a/supabase/schema.sql b/supabase/schema.sql new file mode 100644 index 0000000..372aa2a --- /dev/null +++ b/supabase/schema.sql @@ -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()); diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..2611ad0 --- /dev/null +++ b/test/widget_test.dart @@ -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'); + }); + }); +}