Merge branch 'release/0.0.1'

This commit is contained in:
bastien
2026-07-05 00:42:50 +02:00
123 changed files with 9764 additions and 1 deletions
+39
View File
@@ -0,0 +1,39 @@
# Application mobile Laverie — Android + iOS uniquement (pas de web Flutter).
# Plateformes générées par Flutter (ne pas versionner entièrement si préféré)
/android/.gradle/
/android/app/debug/
/android/app/profile/
/android/app/release/
/android/local.properties
/android/**/GeneratedPluginRegistrant.java
/android/**/GeneratedPluginRegistrant.kt
/ios/Pods/
/ios/.symlinks/
/ios/Flutter/Flutter.framework
/ios/Flutter/Flutter.podspec
/ios/Flutter/Generated.xcconfig
/ios/Flutter/ephemeral/
/ios/Runner/GeneratedPluginRegistrant.*
# Build
/build/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
*.iml
# IDE
.idea/
.vscode/
*.swp
# macOS (non ciblé)
/macos/
/web/
/linux/
/windows/
+33
View File
@@ -0,0 +1,33 @@
# 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
- platform: ios
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'
Vendored
+1
View File
@@ -0,0 +1 @@
flutterAndroidPipeline(name: 'laundry', api: '36')
+116 -1
View File
@@ -1,2 +1,117 @@
# mobile
# Laverie Mobile
Application **native** Android et iOS pour Laverie Connectée — consultation des laveries, portefeuille, réservations et lavages.
> **Pas de cible web Flutter.** Le back-office web est géré séparément (Laravel + Inertia). Cette app est mobile-first : **Android en priorité**, **iOS prévu** dès le départ (même codebase).
## Prérequis
| Outil | Android | iOS (plus tard) |
|-------|---------|-----------------|
| Flutter SDK 3.2+ | Oui | Oui (sur macOS) |
| Android Studio + SDK | Oui | — |
| Xcode | — | Oui (macOS uniquement) |
| Backend API | [../README.md](../README.md) | idem |
## Installation (première fois)
Le dépôt contient le code Dart (`lib/`) mais pas les dossiers natifs. Générez **Android + iOS uniquement** :
### Windows (PowerShell)
```powershell
cd mobile
.\scripts\bootstrap_platforms.ps1
```
### macOS / Linux
```bash
cd mobile
chmod +x scripts/bootstrap_platforms.sh
./scripts/bootstrap_platforms.sh
```
Équivalent manuel :
```bash
flutter create . --org fr.laverie --project-name laverie_mobile --platforms=android,ios
flutter pub get
```
Ne pas ajouter `--platforms web` : le web Flutter est hors périmètre.
## Lancement Android (priorité)
```bash
# Émulateur Android (API par défaut : 10.0.2.2 = localhost hôte)
flutter run -d android
# Appareil physique — remplacer par l'IP de votre PC
flutter run -d android --dart-define=API_BASE_URL=http://192.168.1.10:8000/api/v1
```
### HTTP en développement
L'émulateur Android autorise le trafic HTTP clair vers `10.0.2.2` via la config réseau de debug. En production, l'API doit être en **HTTPS**.
## iOS (préparation)
Le même projet inclut le dossier `ios/` pour une future build App Store. Nécessite **macOS + Xcode**.
```bash
# Simulateur iOS (localhost)
flutter run -d ios
# Appareil physique
flutter run -d ios --dart-define=API_BASE_URL=http://192.168.1.10:8000/api/v1
```
Points déjà prévus côté Dart :
- `flutter_secure_storage` avec options Keychain iOS
- URL API par défaut `127.0.0.1` sur iOS simulateur
- Détection plateforme dans `lib/core/platform/app_platform.dart`
## Configuration API
| Contexte | URL API |
|----------|---------|
| Android émulateur | `http://10.0.2.2:8000/api/v1` (défaut) |
| iOS simulateur | `http://127.0.0.1:8000/api/v1` (défaut) |
| Appareil physique | `--dart-define=API_BASE_URL=http://IP:8000/api/v1` |
## Compte de démonstration
| Email | Mot de passe |
|-------|--------------|
| marie.dupont@demo.local | password |
## Architecture
```
lib/
├── main.dart # Garde anti-web / anti-desktop
├── core/
│ ├── platform/ # Android / iOS uniquement
│ ├── config/ # API URL, secure storage
│ ├── api/
│ ├── auth/
│ ├── router/
│ └── theme/
└── features/
├── auth/
├── home/
├── establishments/
├── wallet/
├── booking/
├── wash/
└── profile/
```
## Prochaines étapes (Android)
1. Scan QR machine (`mobile_scanner`) → `POST /washes/start`
2. Rechargement wallet simulé
3. Notifications push (FCM Android, APNs iOS plus tard)
4. Tests `integration_test` sur émulateur Android
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
prefer_const_declarations: true
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
@@ -0,0 +1,3 @@
kotlin version: 2.3.20
error message: Daemon compilation failed
+45
View File
@@ -0,0 +1,45 @@
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 = "fr.laverie.laverie_mobile"
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 = "fr.laverie.laverie_mobile"
// 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
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Dev local : API Laravel sur http://10.0.2.2:8000 -->
<application android:usesCleartextTraffic="true" />
</manifest>
+49
View File
@@ -0,0 +1,49 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<!-- Requis par Google Play Services / Stripe Payment Sheet sur Android -->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<application
android:label="laverie_mobile"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package fr.laverie.laverie_mobile
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity : FlutterFragmentActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
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
# Compatibilité plugins Flutter pas encore migrés vers le Kotlin intégré AGP 9
android.builtInKotlin=false
# Évite les erreurs de cache Kotlin cross-disques (C: pub cache / D: projet) sous Windows
kotlin.incremental=false
kotlin.compiler.execution.strategy=in-process
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
+447
View File
@@ -0,0 +1,447 @@
# Cahier des Charges — Application Utilisateur Mobile & Web
## Projet : Laverie Connectée — App Flutter
**Version :** 1.0
**Date :** 2026-06-27
**Stack principale :** Flutter 3.x (Android / iOS / Web)
---
## 1. Contexte & Objectifs
### 1.1 Contexte
Application grand public permettant aux utilisateurs de :
- Localiser les laveries disponibles
- Gérer leur porte-monnaie électronique
- Réserver un créneau machine
- Déclencher un lavage par QR code
- Suivre leurs lavages et consulter leur historique
### 1.2 Cibles plateformes
| Plateforme | Version minimale | Distribution |
|------------|-----------------|--------------|
| Android | 8.0 (API 26) | Google Play Store |
| iOS | 15.0 | Apple App Store |
| Web | Chrome 100+, Safari 15+, Firefox 100+ | PWA hébergée |
### 1.3 Contraintes
- L'app doit rester **utilisable hors-ligne** pour les fonctions de consultation (solde en cache, historique)
- Respect strict du **RGPD** : collecte minimale, consentements explicites, droit à l'effacement
- L'application doit rester **connectée en arrière-plan** pour recevoir les notifications push
---
## 2. Stack Technique
| Composant | Technologie | Version | Justification |
|-----------|-------------|---------|---------------|
| Framework UI | Flutter | 3.x (stable) | Codebase unique Android/iOS/Web |
| Langage | Dart | 3.x | Null-safety, typage fort |
| State management | Riverpod | 2.x | Réactivité, testabilité, pas de BuildContext dependency |
| Navigation | GoRouter | 13.x | Deep links, routing déclaratif |
| HTTP Client | Dio | 5.x | Intercepteurs JWT, retry automatique |
| Cache local | Hive | 2.x | Clé-valeur rapide, offline-first |
| Auth persistence | flutter_secure_storage | 9.x | Stockage sécurisé tokens (Keychain / Keystore) |
| Notifications push | firebase_messaging | 15.x | FCM Android/Web + APNs iOS |
| QR Code scan | mobile_scanner | 5.x | Caméra native, performant |
| Paiement in-app | flutter_stripe | 10.x | Stripe SDK officiel |
| Géolocalisation | geolocator | 13.x | Localisation établissements |
| Cartes | flutter_map + OpenStreetMap | — | Affichage carte sans coût API Google |
| Internationalisation | flutter_localizations | (Flutter) | fr / en au minimum |
| Tests | flutter_test + Mockito | — | Unit + widget + integration tests |
| CI/CD | GitHub Actions + Fastlane | — | Build, test, déploiement stores |
| Monitoring | Firebase Crashlytics | — | Crashes prod |
| Analytics | Firebase Analytics | — | Entonnoirs, rétention |
---
## 3. Architecture de l'Application
### 3.1 Pattern architectural : Clean Architecture + Feature-first
```
lib/
├── core/
│ ├── api/
│ │ ├── api_client.dart # Instance Dio configurée
│ │ ├── interceptors/
│ │ │ ├── auth_interceptor.dart # Injection Bearer token
│ │ │ └── retry_interceptor.dart # Retry sur 401 avec refresh
│ │ └── api_exception.dart
│ ├── cache/
│ │ └── hive_service.dart # Abstraction cache local
│ ├── router/
│ │ └── app_router.dart # GoRouter centralisé
│ ├── theme/
│ │ ├── app_theme.dart
│ │ └── app_colors.dart
│ └── utils/
│ ├── currency_formatter.dart
│ └── date_formatter.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── auth_repository.dart
│ │ │ └── models/auth_token.dart
│ │ ├── domain/
│ │ │ └── providers/auth_provider.dart
│ │ └── presentation/
│ │ ├── login_screen.dart
│ │ └── register_screen.dart
│ │
│ ├── wallet/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ │ ├── wallet_screen.dart
│ │ ├── transaction_history_screen.dart
│ │ └── top_up_screen.dart
│ │
│ ├── establishments/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ │ ├── map_screen.dart
│ │ ├── establishment_list_screen.dart
│ │ └── establishment_detail_screen.dart
│ │
│ ├── machines/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ │ ├── machine_card_widget.dart
│ │ └── machine_detail_screen.dart
│ │
│ ├── booking/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ │ ├── slot_picker_screen.dart
│ │ ├── booking_confirm_screen.dart
│ │ └── my_bookings_screen.dart
│ │
│ ├── wash/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ │ ├── qr_scanner_screen.dart
│ │ ├── wash_confirm_screen.dart
│ │ └── wash_active_screen.dart
│ │
│ ├── notifications/
│ │ └── notification_service.dart
│ │
│ └── profile/
│ └── presentation/
│ ├── profile_screen.dart
│ └── gdpr_settings_screen.dart
└── main.dart
```
### 3.2 Flux d'authentification
```
App start
└── AuthProvider.check()
├── Token valide → HomeScreen
├── Token expiré → RefreshToken()
│ ├── Succès → HomeScreen
│ └── Échec → LoginScreen
└── Pas de token → OnboardingScreen
```
### 3.3 Gestion du token JWT
- `AuthInterceptor` (Dio) : inject `Authorization: Bearer <token>` sur chaque requête
- Sur 401 : appel automatique à `/auth/refresh` → retry de la requête originale
- Tokens stockés dans `flutter_secure_storage` (Keychain iOS / Keystore Android)
- Sur Web : stockage en `sessionStorage` chiffré (pas de `localStorage` pour les tokens)
---
## 4. Écrans & Parcours Utilisateur
### 4.1 Navigation principale (Bottom Navigation Bar)
```
┌─────────────────────────────────────────────┐
│ 🗺 Carte │ 💰 Wallet │ 📅 Réservations │ 👤 Profil │
└─────────────────────────────────────────────┘
```
### 4.2 Onboarding & Inscription
**Écran 1 — Bienvenue**
- Logo + baseline
- CTA : "Se connecter" / "Créer un compte"
**Écran 2 — Inscription (multi-étapes)**
- Étape 1 : Email + mot de passe (+ confirmation)
- Étape 2 : Prénom, nom, téléphone, date de naissance
- Étape 3 : Consentements RGPD (cases à cocher individuelles, non pré-cochées)
- Traitement des données (obligatoire)
- Communications marketing (optionnel)
- Analyse d'utilisation (optionnel)
- Étape 4 : Code parrainage (optionnel)
- Confirmation email requise avant accès complet
**Règles de validation :**
- Email : format RFC 5322
- Mot de passe : 8 caractères minimum, 1 majuscule, 1 chiffre
- Téléphone : format E.164 (`+33XXXXXXXXX`)
- Date de naissance : 16 ans minimum
### 4.3 Carte & Établissements
**Écran Carte (`MapScreen`)**
- Carte OpenStreetMap centrée sur la position utilisateur
- Marqueurs pour chaque établissement (couleur selon disponibilité)
- 🟢 Machines disponibles
- 🟡 Toutes occupées, créneaux libres
- 🔴 Toutes occupées, aucun créneau proche
- Tap sur marqueur → sheet inférieure avec résumé
- Bouton liste (switch vue liste / carte)
**Écran Détail Établissement**
- Nom, adresse, horaires
- Liste des machines avec statut temps réel (polling 30s ou WebSocket si dispo)
- Pour chaque machine : type, statut, prochain créneau libre, tarif actuel
- CTA : "Réserver" ou "Scanner & Lancer"
### 4.4 Porte-monnaie
**Écran Wallet**
- Solde affiché en grand (avec animation de mise à jour)
- Bouton "Recharger" bien visible
- Historique des 10 dernières transactions
- Lien "Voir tout"
**Écran Historique**
- Liste paginée (infinite scroll)
- Filtres : Type (débit/crédit), Période
- Chaque entrée : icône type, montant coloré, source, date/heure, solde après
- Export CSV (optionnel, v2)
**Parcours Rechargement**
1. Saisie du montant (suggestions : 10€, 20€, 50€)
2. Sélection du fournisseur de paiement (si plusieurs disponibles)
3. Redirection vers Stripe Checkout (WebView sécurisée) ou SDK natif
4. Retour sur l'app via deep link `laverie://wallet/topup-result`
5. Confirmation animée + mise à jour du solde
6. Notification push de confirmation
**Contraintes :**
- Montant minimum : 5€, maximum : 150€ par rechargement
- Affichage du solde en cache si hors-ligne
### 4.5 Réservation d'un Créneau
**Écran Sélection de Créneau (`SlotPickerScreen`)**
- Sélecteur de date (calendrier scrollable, 14 jours maximum)
- Pour la date sélectionnée : grille horaire avec créneaux libres/occupés
- Mise en évidence du prix par créneau (heure creuse / heure pleine)
- Information : "Supplément réservation : +1.00€"
**Écran Confirmation Réservation**
- Récapitulatif : machine, date/heure, durée, prix total
- Solde actuel et solde après débit
- Mention pénalité no-show
- Bouton "Confirmer et débiter X.XX€"
**Écran Mes Réservations**
- Onglets : À venir / Passées
- Chaque réservation : statut badge, machine, créneau, montant
- Actions possibles : "Annuler", "Déplacer" (si > 2h)
- Countdown pour les réservations imminentes
**Règles métier UI :**
- Créneau non réservable si solde insuffisant → CTA "Recharger d'abord"
- Annulation : popup de confirmation avec montant remboursé affiché
- Déplacement : ouvre SlotPickerScreen avec le même contexte machine
### 4.6 Déclenchement d'un Lavage par QR Code
**Écran Scanner QR (`QrScannerScreen`)**
- Vue caméra plein écran avec cadre de scan
- Feedback visuel à la détection (vibration + flash vert)
- Fallback : saisie manuelle du code machine
**Flux post-scan :**
1. Identification de la machine via le QR
2. Appel API → vérification statut + tarif
3. Affichage récap : machine, programme, prix estimé, solde après
4. Bouton "Lancer le lavage"
5. Animation de confirmation (spinner → ✓)
6. Écran "Lavage en cours" avec countdown
**Écran Lavage Actif (`WashActiveScreen`)**
- Machine + établissement
- Barre de progression avec temps restant (mise à jour polling API 30s)
- Montant débité
- Notification push à la fin du cycle
### 4.7 Notifications Push
**Configuration :**
- Demande de permission notifications à l'inscription (après onboarding)
- Sur iOS : `UNUserNotificationCenter.requestAuthorization`
- Sur Android 13+ : `POST_NOTIFICATIONS` permission
- Token FCM/APNs envoyé à l'API à chaque démarrage de l'app (mise à jour si changé)
**Gestion en foreground :**
- Affichage d'une bannière in-app (snackbar stylisée)
- Tap → navigation vers l'écran pertinent
**Deep links depuis notification :**
| Notification | Deep link |
|---|---|
| Rechargement confirmé | `laverie://wallet` |
| Réservation confirmée | `laverie://bookings/{uuid}` |
| Rappel créneau | `laverie://bookings/{uuid}` |
| Lavage terminé | `laverie://washes/{uuid}` |
| Promo disponible | `laverie://establishments/{uuid}` |
### 4.8 Profil & Paramètres
**Écran Profil**
- Informations personnelles (modifiables)
- Mes statistiques : nombre de lavages, total dépensé, économies promos
- Code parrainage personnel (copiable, partageable)
- Gestion notifications (toggle par type)
- Paramètres RGPD
- Déconnexion
**Écran Paramètres RGPD**
- Visualisation des consentements donnés (avec date)
- Toggle individuel pour chaque consentement
- Bouton "Télécharger mes données" (export JSON, v2)
- Bouton "Supprimer mon compte" (confirmation en deux étapes + délai 30 jours)
---
## 5. Gestion de l'État (Riverpod)
### 5.1 Providers principaux
```dart
// Auth
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>
// Wallet
final walletProvider = FutureProvider<WalletData>
final transactionsProvider = StateNotifierProvider<TransactionsNotifier, TransactionsState>
// Establishments
final establishmentsProvider = FutureProvider.family<Establishment, String> // by uuid
final nearbyEstablishmentsProvider = FutureProvider<List<Establishment>>
// Machines
final machineStatusProvider = StreamProvider.family<Machine, String> // polling 30s
// Bookings
final bookingsProvider = StateNotifierProvider<BookingsNotifier, BookingsState>
final slotAvailabilityProvider = FutureProvider.family<List<TimeSlot>, SlotQuery>
// Active wash
final activeWashProvider = StreamProvider<Wash?> // null si aucun lavage en cours
```
### 5.2 Stratégie offline-first (Hive)
| Donnée | Cache | TTL |
|--------|-------|-----|
| Solde wallet | Oui | Jusqu'au prochain refresh |
| Historique transactions | Oui (50 dernières) | 24h |
| Établissements (liste) | Oui | 1h |
| Disponibilités machines | Non | — |
| Réservations | Oui | 30 min |
En cas d'absence de réseau : affichage des données en cache avec badge "Données hors-ligne".
---
## 6. Sécurité
### 6.1 Stockage
- Tokens JWT : `flutter_secure_storage` (AES-256 sur Android, Keychain sur iOS)
- Aucune donnée sensible dans `SharedPreferences`
- Sur Web : pas de localStorage, session uniquement
### 6.2 Réseau
- Certificate pinning en production (via `dio` custom `HttpClient`)
- Timeout : 10s connexion, 30s réception
- Toutes les requêtes en HTTPS (TLS 1.3)
### 6.3 QR Code
- Vérification côté serveur (le QR code seul ne suffit pas à déclencher un lavage)
- Rate limiting API sur `/washes/start` : 3 req/min/user
---
## 7. Performance & UX
- **Splash screen** natif (Android 12 Splash API, iOS UILaunchScreen)
- **Skeleton loaders** sur toutes les listes et cartes pendant le chargement
- **Optimistic UI** : affichage anticipé des mises à jour (ex: annulation réservation)
- **Pull-to-refresh** sur toutes les listes
- **Pagination infinie** sur l'historique des transactions
- **Animations** : transitions de pages fluides (Hero, Fade), animation solde wallet
---
## 8. Tests
| Type | Outil | Portée |
|------|-------|--------|
| Tests unitaires | `flutter_test` | Providers Riverpod, formatters, validators |
| Tests widgets | `flutter_test` | Tous les écrans principaux |
| Tests d'intégration | `integration_test` | Parcours critiques (inscription, rechargement, scan QR) |
| Tests golden | `golden_toolkit` | Composants UI clés (cohérence visuelle) |
**Parcours critiques à couvrir en intégration :**
1. Inscription complète → vérification email → connexion
2. Rechargement porte-monnaie (mock Stripe)
3. Réservation créneau → annulation → vérification remboursement
4. Scan QR → lancement lavage → fin de cycle
---
## 9. CI/CD
### 9.1 Pipeline GitHub Actions
```yaml
# Sur chaque PR :
- flutter analyze # Linting strict
- flutter test # Tests unitaires + widgets
- flutter build apk --release # Vérification build Android
- flutter build ios --release # Vérification build iOS (macOS runner)
- flutter build web # Vérification build Web
# Sur merge main :
- Fastlane → TestFlight (iOS)
- Fastlane → Play Store Internal Track (Android)
- Deploy Web → hébergement (Firebase Hosting ou VPS)
```
### 9.2 Flavors
| Flavor | API URL | Firebase projet | Build suffix |
|--------|---------|-----------------|--------------|
| dev | `http://localhost:8000` | laverie-dev | `.dev` |
| staging | `https://api-staging.laverie.app` | laverie-staging | `.staging` |
| production | `https://api.laverie.app` | laverie-prod | — |
---
## 10. Livrables attendus
- [ ] Code source Flutter (GitHub, repository dédié)
- [ ] Flavors configurés (dev / staging / prod)
- [ ] `README.md` avec instructions setup complet
- [ ] Fichiers de configuration Firebase (`google-services.json`, `GoogleService-Info.plist`)
- [ ] Fichiers de signature Android (`.keystore`) documentés
- [ ] Screenshots pour les stores (5 par plateforme minimum)
- [ ] Suite de tests (couverture > 70% sur la logique métier)
- [ ] Build release Android (`.aab`) + iOS (`.ipa`) prêts à soumettre
+430
View File
@@ -0,0 +1,430 @@
# Cahier des Charges — Application Utilisateur Mobile & Web
## Projet : Laverie Connectée — App Flutter
**Version :** 2.0
**Stack principale :** Flutter 3.x (Android / iOS / Web)
**Positionnement :** V1 démontrable, centrée sur les parcours critiques
---
## 1. Contexte & Objectifs
### 1.1 Objectif produit
Application grand public permettant aux utilisateurs de :
- localiser les laveries disponibles,
- consulter l'état des machines,
- recharger leur porte-monnaie,
- réserver un créneau,
- lancer un lavage,
- suivre l'historique de leurs opérations.
### 1.2 Objectif projet V1
La V1 doit être :
- démontrable rapidement,
- cohérente côté métier,
- simple à maintenir,
- compatible avec une future montée en version sans refonte majeure.
### 1.3 Contraintes
- support Android / iOS en priorité,
- support Web utilitaire possible, sans promesse d'expérience équivalente à une app web dédiée,
- mode hors-ligne limité aux consultations simples,
- conformité RGPD minimale dès la V1,
- intégration machine pouvant être simulée pour la démo.
---
## 2. Périmètre fonctionnel
### 2.1 MVP V1
- inscription / connexion,
- maintien de session,
- liste des laveries,
- détail d'une laverie,
- consultation des machines et de leur statut,
- wallet avec solde et historique simple,
- rechargement wallet,
- réservation d'un créneau,
- affichage des réservations,
- lancement d'un lavage,
- écran de lavage en cours,
- notifications transactionnelles,
- profil utilisateur,
- gestion simple des consentements.
### 2.2 V2 prévue
- fidélité,
- parrainage,
- abonnements,
- promotions marketing avancées,
- export de données,
- statistiques utilisateur plus poussées,
- expérience offline enrichie,
- recommandations de cycles.
### 2.3 Hors périmètre V1
- chat,
- avis,
- FAQ dynamique,
- IA / photo d'étiquette,
- moteur prédictif,
- marketing automation complexe.
---
## 3. Stack Technique
| Composant | Technologie | Version | Commentaire |
|-----------|-------------|---------|-------------|
| Framework UI | Flutter | 3.x | Base unique mobile + web |
| Langage | Dart | 3.x | Null-safety |
| State management | Riverpod | 2.x | Testable, clair |
| Navigation | GoRouter | 13.x | Routing déclaratif |
| HTTP | Dio | 5.x | Intercepteurs auth |
| Stockage sécurisé | flutter_secure_storage | 9.x | Tokens |
| Cache local | Hive | 2.x | Suffisant pour V1 |
| Push | firebase_messaging | 15.x | Android / iOS / Web |
| QR scan | mobile_scanner | 5.x | Scan machine |
| Paiement | flutter_stripe | 10.x | Si Stripe retenu en V1 |
| Géolocalisation | geolocator | 13.x | Recherche proximité |
| Cartographie | flutter_map + OSM | — | Coût faible |
| Crash reporting | Firebase Crashlytics | — | Monitoring mobile |
| Tests | flutter_test + integration_test | — | Couverture critique |
### 3.1 Remarque Web
Le support Flutter Web est accepté comme **surface utilitaire** pour V1. Il ne doit pas être présenté comme un site web marketing riche ou fortement orienté SEO.
---
## 4. Architecture applicative
### 4.1 Organisation recommandée
```
lib/
├── core/
│ ├── api/
│ ├── auth/
│ ├── cache/
│ ├── router/
│ ├── theme/
│ └── utils/
├── features/
│ ├── auth/
│ ├── establishments/
│ ├── machines/
│ ├── wallet/
│ ├── booking/
│ ├── wash/
│ ├── notifications/
│ └── profile/
└── main.dart
```
### 4.2 Principes
- séparation claire data / state / presentation,
- providers Riverpod par domaine,
- toute logique réseau centralisée,
- aucune règle métier critique uniquement côté client,
- compatibilité avec un mode démonstration si l'intégration machine n'est pas prête.
---
## 5. Authentification
### 5.1 Flux attendu
```text
Lancement app
→ lecture des tokens
→ si access token valide : accès direct
→ sinon tentative refresh
→ sinon retour login
```
### 5.2 Règles
- access token court,
- refresh token persistant,
- déconnexion complète si refresh invalide,
- aucun stockage de token sensible dans SharedPreferences.
### 5.3 Web
Sur Web, éviter `localStorage` pour les tokens. Si le backend le permet, privilégier une stratégie plus sûre à long terme. Pour V1, une stratégie simple et limitée peut être tolérée, mais elle doit être documentée comme compromis technique.
---
## 6. Parcours utilisateur V1
## 6.1 Onboarding / inscription
### Écrans
- Bienvenue
- Connexion
- Inscription
- Vérification email si activée en V1
### Champs d'inscription
- prénom
- nom
- email
- téléphone
- mot de passe
- date de naissance
- consentements RGPD
### Règles
- consentement traitement données obligatoire,
- consentements marketing et analytics séparés,
- validations simples mais strictes.
---
## 6.2 Laveries et machines
### Écran liste / carte
- liste des établissements,
- carte facultative si le délai est tendu,
- indicateur de disponibilité globale.
### Écran détail établissement
- nom, adresse, horaires,
- liste des machines,
- statut de chaque machine,
- prochain créneau disponible,
- CTA réservation,
- CTA lancement lavage.
### Statuts V1 affichés
- disponible,
- réservée,
- en cours,
- maintenance,
- hors ligne,
- erreur.
---
## 6.3 Wallet
### Écran wallet
- solde actuel,
- bouton recharger,
- dernières transactions,
- message si données en cache.
### Historique
- liste simple paginée,
- type,
- montant,
- date,
- libellé métier.
### Rechargement V1
Parcours recommandé :
1. saisie montant,
2. lancement du paiement,
3. retour app,
4. confirmation backend,
5. mise à jour solde.
### Contraintes V1
- montant min et max configurables,
- aucune confiance dans le seul retour client,
- affichage clair des états : en cours / confirmé / échoué.
---
## 6.4 Réservation
### Écran sélection de créneau
- choix date,
- créneaux disponibles,
- prix total,
- supplément réservation si applicable,
- indication pénalité no-show.
### Écran confirmation
- récapitulatif machine,
- créneau,
- prix,
- solde avant / après,
- bouton de confirmation.
### Écran mes réservations
- onglets à venir / passées,
- statut,
- actions : annuler, déplacer si autorisé.
### Règles UI
- si solde insuffisant, proposer rechargement,
- si réservation non déplaçable, expliquer pourquoi,
- afficher clairement les règles d'annulation.
---
## 6.5 Lavage
### Démarrage
Deux modes possibles selon intégration :
- scan QR,
- démarrage depuis une réservation active.
### Flux V1
1. identification machine,
2. vérification disponibilité,
3. affichage prix / programme si nécessaire,
4. confirmation utilisateur,
5. demande de démarrage,
6. affichage de l'état `en cours`.
### Écran lavage actif
- machine,
- laverie,
- heure de début,
- temps estimé restant si disponible,
- statut actualisé.
### Mode démo
Si aucune vraie intégration machine n'est disponible, un mode simulation doit permettre d'afficher un cycle complet crédible.
---
## 6.6 Notifications
### Notifications V1
- rechargement confirmé,
- réservation confirmée,
- rappel de créneau,
- lavage terminé,
- no-show détecté si applicable.
### Deep links
Les notifications doivent pouvoir ouvrir l'écran concerné :
- wallet,
- réservation,
- lavage,
- établissement.
---
## 6.7 Profil
### Écran profil
- informations personnelles,
- préférences de notification,
- consentements,
- déconnexion,
- suppression de compte si disponible en V1, sinon message préparant la V2.
---
## 7. Gestion d'état
### Providers principaux
```dart
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(...);
final establishmentsProvider = FutureProvider<List<Establishment>>(...);
final establishmentDetailProvider = FutureProvider.family<EstablishmentDetail, String>(...);
final walletProvider = FutureProvider<WalletView>(...);
final bookingsProvider = StateNotifierProvider<BookingsNotifier, BookingsState>(...);
final washProvider = StateNotifierProvider<WashNotifier, WashState>(...);
```
### Stratégie V1
- refresh manuel sur les écrans critiques,
- polling simple si nécessaire pour le lavage actif,
- éviter une architecture temps réel trop complexe pour la V1 si elle met en risque le planning.
---
## 8. Offline-first
### Ce qui est caché localement en V1
| Donnée | Cache |
|---|---|
| Solde wallet | Oui |
| Dernières transactions | Oui |
| Liste établissements | Oui |
| Réservations récentes | Oui |
| Disponibilité machine temps réel | Non |
### Règle
Le hors-ligne V1 est **consultatif**, pas transactionnel.
---
## 9. Sécurité
### 9.1 Stockage
- tokens en stockage sécurisé,
- aucune donnée sensible en clair,
- purge des tokens en déconnexion.
### 9.2 Réseau
- HTTPS obligatoire,
- timeouts définis,
- retry mesuré,
- certificate pinning seulement si tu es sûr de pouvoir l'opérer correctement, sinon à repousser plutôt que mal implémenter.
### 9.3 QR code
- le QR seul ne déclenche jamais directement un lavage,
- validation serveur obligatoire,
- contrôle de cohérence utilisateur / machine / statut.
---
## 10. UX / performance
### Priorités V1
- écrans rapides,
- états de chargement propres,
- erreurs compréhensibles,
- navigation simple,
- pas d'animations complexes non essentielles.
### À éviter en V1 si délai serré
- sur-optimisation visuelle,
- offline avancé,
- temps réel sophistiqué,
- effets UI coûteux sans valeur métier.
---
## 11. Mode démonstration
L'application doit pouvoir être connectée à un environnement de démonstration permettant :
- données multi-laveries fictives,
- wallet de test,
- réservations simulées,
- cycles machine simulés,
- notifications de test.
Ce mode doit être suffisamment crédible pour une démonstration client de fin de mois.
---
## 12. Tests
| Type | Outil | Portée |
|------|-------|--------|
| Unitaires | flutter_test | Providers, validateurs, formatters |
| Widgets | flutter_test | Écrans clés |
| Intégration | integration_test | Connexion, wallet, réservation, lavage |
### Parcours critiques à couvrir
1. connexion utilisateur,
2. affichage des laveries,
3. rechargement wallet,
4. réservation d'un créneau,
5. démarrage d'un lavage en mode simulation,
6. réception d'une notification.
---
## 13. Livrables attendus
- code source Flutter,
- configuration dev / staging / prod,
- README d'installation,
- configuration push,
- build de démonstration,
- dataset ou compte de démo,
- couverture de tests sur les flux critiques.
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+644
View File
@@ -0,0 +1,644 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = fr.laverie.laverieMobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fr.laverie.laverieMobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fr.laverie.laverieMobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = fr.laverie.laverieMobile.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = fr.laverie.laverieMobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = fr.laverie.laverieMobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+72
View File
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Laverie Mobile</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>laverie_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>NSCameraUsageDescription</key>
<string>Scanner les QR codes des machines de laverie.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
+120
View File
@@ -0,0 +1,120 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/app_config.dart';
import '../auth/auth_provider.dart';
/// Client HTTP Dio configuré pour l'API Laverie.
class ApiClient {
ApiClient({
required this.baseUrl,
this.getAccessToken,
this.onRefreshToken,
this.onSessionExpired,
}) : _dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
),
) {
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await getAccessToken?.call();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
final response = error.response;
final alreadyRetried = error.requestOptions.extra['auth_retried'] == true;
if (response?.statusCode == 401 && !alreadyRetried && onRefreshToken != null) {
final newToken = await onRefreshToken!();
if (newToken != null && newToken.isNotEmpty) {
final request = error.requestOptions;
request.extra['auth_retried'] = true;
request.headers['Authorization'] = 'Bearer $newToken';
try {
final retryResponse = await _dio.fetch(request);
return handler.resolve(retryResponse);
} on DioException catch (retryError) {
return handler.next(retryError);
}
}
await onSessionExpired?.call();
}
handler.next(error);
},
),
);
}
final String baseUrl;
final Future<String?> Function()? getAccessToken;
final Future<String?> Function()? onRefreshToken;
final Future<void> Function()? onSessionExpired;
final Dio _dio;
Dio get dio => _dio;
Future<Response<T>> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
}) {
return _dio.get<T>(path, queryParameters: queryParameters);
}
Future<Response<T>> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
}) {
return _dio.post<T>(path, data: data, queryParameters: queryParameters);
}
Future<Response<T>> patch<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
}) {
return _dio.patch<T>(path, data: data, queryParameters: queryParameters);
}
Future<Response<T>> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
}) {
return _dio.put<T>(path, data: data, queryParameters: queryParameters);
}
Future<Response<T>> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
}) {
return _dio.delete<T>(path, data: data, queryParameters: queryParameters);
}
}
/// Fournisseur Riverpod du client API (injecte le token depuis l'état auth).
final apiClientProvider = Provider<ApiClient>((ref) {
final authNotifier = ref.read(authProvider.notifier);
return ApiClient(
baseUrl: kApiBaseUrl,
getAccessToken: () async => ref.read(authProvider).accessToken,
onRefreshToken: () => authNotifier.refreshAccessToken(),
onSessionExpired: () => authNotifier.sessionExpired(),
);
});
+38
View File
@@ -0,0 +1,38 @@
/// Chemins des endpoints REST de l'API Laverie v1.
abstract final class ApiEndpoints {
// Santé / monitoring
static const health = '/health';
static const healthDb = '/health/db';
// Authentification utilisateur
static const authRegister = '/auth/register';
static const authLogin = '/auth/login';
static const authRefresh = '/auth/refresh';
static const authLogout = '/auth/logout';
static const authMe = '/auth/me';
// Portefeuille
static const wallet = '/wallet';
static const walletTransactions = '/wallet/transactions';
static const walletTopUpInitiate = '/wallet/top-up/initiate';
static const walletTopUpConfirm = '/wallet/top-up/confirm';
// Établissements & machines
static const establishments = '/establishments';
static String establishment(String uuid) => '/establishments/$uuid';
static String machine(String uuid) => '/machines/$uuid';
static const machineLookup = '/machines/lookup';
static String machineAvailability(String uuid) => '/machines/$uuid/availability';
static String machinePricing(String uuid) => '/machines/$uuid/pricing';
// Réservations
static const bookings = '/bookings';
static String booking(String uuid) => '/bookings/$uuid';
static String bookingCancel(String uuid) => '/bookings/$uuid/cancel';
static String bookingMove(String uuid) => '/bookings/$uuid/move';
// Lavages
static const washes = '/washes';
static const washesStart = '/washes/start';
static String wash(String uuid) => '/washes/$uuid';
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:dio/dio.dart';
/// Helpers pour parser les réponses JSON de l'API Laverie (`success` + `data`).
abstract final class ApiResponse { static Map<String, dynamic> payload(dynamic data) {
if (data is Map<String, dynamic>) {
if (data['data'] is Map<String, dynamic>) {
return data['data'] as Map<String, dynamic>;
}
return data;
}
throw StateError('Réponse API inattendue');
}
static List<dynamic> list(dynamic data, String key) {
final value = payload(data)[key];
if (value is List<dynamic>) {
return value;
}
return [];
}
static Map<String, dynamic> object(dynamic data, String key) {
final value = payload(data)[key];
if (value is Map<String, dynamic>) {
return value;
}
throw StateError('Champ API "$key" introuvable ou invalide');
}
static String errorMessage(DioException error, {String fallback = 'Erreur réseau'}) {
final response = error.response;
if (response?.data is Map<String, dynamic>) {
final data = response!.data as Map<String, dynamic>;
final message = data['message'];
if (message is String && message.isNotEmpty) {
return _sanitize(message);
}
}
return switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'Délai d\'attente dépassé',
DioExceptionType.connectionError =>
'Connexion impossible',
_ => fallback,
};
}
/// Masque les détails SQL techniques pour l'utilisateur.
static String _sanitize(String message) {
if (message.contains('SQLSTATE') || message.contains('SQL:') || message.contains('must be of type')) {
return 'Erreur serveur — réessayez dans un instant';
}
return message;
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/api_client.dart';
import '../config/app_config.dart';
import '../config/secure_storage_config.dart';
import '../../features/auth/domain/auth_user.dart';
import 'auth_repository.dart';
/// État d'authentification de l'application.
class AuthState {
const AuthState({
this.user,
this.accessToken,
this.refreshToken,
this.isLoading = false,
this.error,
});
final AuthUser? user;
final String? accessToken;
final String? refreshToken;
final bool isLoading;
final String? error;
bool get isAuthenticated => accessToken != null && accessToken!.isNotEmpty;
AuthState copyWith({
AuthUser? user,
String? accessToken,
String? refreshToken,
bool? isLoading,
String? error,
bool clearError = false,
bool clearUser = false,
}) {
return AuthState(
user: clearUser ? null : (user ?? this.user),
accessToken: accessToken ?? this.accessToken,
refreshToken: refreshToken ?? this.refreshToken,
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
);
}
}
/// Gestionnaire d'état Riverpod pour l'authentification.
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier(this._repository) : super(const AuthState(isLoading: true)) {
_restoreSession();
}
final AuthRepository _repository;
/// Tente de renouveler le token d'accès (appelé par le client API sur 401).
Future<String?> refreshAccessToken() async {
final refreshed = await _repository.refresh();
if (refreshed == null) {
return null;
}
state = state.copyWith(
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
user: refreshed.user ?? state.user,
);
return refreshed.accessToken;
}
/// Session expirée après échec du refresh.
Future<void> sessionExpired() async {
await logout();
}
Future<void> _restoreSession() async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final stored = await _repository.loadStoredTokens();
if (stored == null) {
state = state.copyWith(isLoading: false);
return;
}
state = state.copyWith(
accessToken: stored.accessToken,
refreshToken: stored.refreshToken,
);
final user = await _repository.fetchCurrentUser();
state = state.copyWith(user: user, isLoading: false);
} catch (_) {
final refreshed = await _repository.refresh();
if (refreshed != null) {
final user = await _repository.fetchCurrentUser();
state = state.copyWith(
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
user: user,
isLoading: false,
);
} else {
await _repository.clearStoredTokens();
state = const AuthState(isLoading: false);
}
}
}
Future<bool> login(String email, String password) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final tokens = await _repository.login(email: email, password: password);
final user = tokens.user ?? await _repository.fetchCurrentUser();
state = state.copyWith(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
user: user,
isLoading: false,
);
return true;
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Connexion impossible. Vérifiez vos identifiants.',
);
return false;
}
}
Future<bool> register({
required String firstName,
required String lastName,
required String email,
required String password,
String? phone,
}) async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final tokens = await _repository.register(
firstName: firstName,
lastName: lastName,
email: email,
password: password,
phone: phone,
);
final user = tokens.user ?? await _repository.fetchCurrentUser();
state = state.copyWith(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
user: user,
isLoading: false,
);
return true;
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Inscription impossible. L\'email est peut-être déjà utilisé.',
);
return false;
}
}
Future<void> logout() async {
await _repository.logout();
state = const AuthState();
}
}
final authRepositoryProvider = Provider<AuthRepository>((ref) {
const storage = laverieSecureStorage;
// Token lu depuis le stockage sécurisé (évite la dépendance circulaire avec authProvider).
return AuthRepository(
apiClient: ApiClient(
baseUrl: kApiBaseUrl,
getAccessToken: () => storage.read(key: AuthStorageKeys.accessToken),
),
secureStorage: storage,
);
});
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(ref.watch(authRepositoryProvider));
});
+124
View File
@@ -0,0 +1,124 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../api/api_client.dart';
import '../api/api_endpoints.dart';
import '../api/api_response.dart';
import '../config/app_config.dart';
import '../config/secure_storage_config.dart';
import '../../features/auth/domain/auth_user.dart';
/// Dépôt d'authentification — login, inscription, refresh et logout.
class AuthRepository {
AuthRepository({
required ApiClient apiClient,
FlutterSecureStorage? secureStorage,
}) : _apiClient = apiClient,
_secureStorage = secureStorage ?? laverieSecureStorage;
final ApiClient _apiClient;
final FlutterSecureStorage _secureStorage;
Future<AuthTokens> login({
required String email,
required String password,
}) async {
final response = await _apiClient.post(
ApiEndpoints.authLogin,
data: {
'email': email,
'password': password,
},
);
final tokens = AuthTokens.fromJson(ApiResponse.payload(response.data));
await _persistTokens(tokens);
return tokens;
}
Future<AuthTokens> register({
required String firstName,
required String lastName,
required String email,
required String password,
String? phone,
}) async {
final response = await _apiClient.post(
ApiEndpoints.authRegister,
data: {
'first_name': firstName,
'last_name': lastName,
'email': email,
'password': password,
if (phone != null) 'phone': phone,
},
);
final tokens = AuthTokens.fromJson(ApiResponse.payload(response.data));
await _persistTokens(tokens);
return tokens;
}
Future<AuthTokens?> refresh() async {
final refreshToken = await _secureStorage.read(key: AuthStorageKeys.refreshToken);
if (refreshToken == null || refreshToken.isEmpty) {
return null;
}
final response = await _apiClient.post(
ApiEndpoints.authRefresh,
data: {'refresh_token': refreshToken},
);
final tokens = AuthTokens.fromJson(ApiResponse.payload(response.data));
await _persistTokens(tokens);
return tokens;
}
Future<AuthUser?> fetchCurrentUser() async {
final response = await _apiClient.get(ApiEndpoints.authMe);
final payload = ApiResponse.payload(response.data);
final userJson = payload['user'] as Map<String, dynamic>?;
if (userJson != null) {
return AuthUser.fromJson(userJson);
}
return AuthUser.fromJson(payload);
}
Future<void> logout() async {
try {
await _apiClient.post(ApiEndpoints.authLogout);
} catch (_) {
// Déconnexion locale même si l'API est injoignable.
}
await clearStoredTokens();
}
Future<AuthTokens?> loadStoredTokens() async {
final accessToken = await _secureStorage.read(key: AuthStorageKeys.accessToken);
final refreshToken = await _secureStorage.read(key: AuthStorageKeys.refreshToken);
if (accessToken == null || refreshToken == null) {
return null;
}
return AuthTokens(accessToken: accessToken, refreshToken: refreshToken);
}
Future<void> clearStoredTokens() async {
await _secureStorage.delete(key: AuthStorageKeys.accessToken);
await _secureStorage.delete(key: AuthStorageKeys.refreshToken);
}
Future<void> _persistTokens(AuthTokens tokens) async {
await _secureStorage.write(
key: AuthStorageKeys.accessToken,
value: tokens.accessToken,
);
await _secureStorage.write(
key: AuthStorageKeys.refreshToken,
value: tokens.refreshToken,
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
/// Surcharge explicite via `--dart-define=API_BASE_URL=...`
const String _envApiBaseUrl = String.fromEnvironment('API_BASE_URL');
/// URL de base de l'API Laverie.
///
/// Priorité :
/// 1. `API_BASE_URL` passé en `--dart-define`
/// 2. Valeur par défaut selon la plateforme :
/// - Android émulateur : `10.0.2.2` (localhost de l'hôte)
/// - iOS simulateur : `127.0.0.1`
/// 3. Appareil physique : toujours passer `--dart-define=API_BASE_URL=http://IP:8000/api/v1`
String get kApiBaseUrl {
if (_envApiBaseUrl.isNotEmpty) {
return _envApiBaseUrl;
}
if (kIsWeb) {
throw UnsupportedError('Le web Flutter n\'est pas supporté.');
}
if (Platform.isAndroid) {
return 'http://10.0.2.2:8000/api/v1';
}
if (Platform.isIOS) {
return 'http://127.0.0.1:8000/api/v1';
}
throw UnsupportedError('Plateforme non supportée.');
}
/// Clés de stockage sécurisé pour les jetons d'authentification.
abstract final class AuthStorageKeys {
static const accessToken = 'laverie_access_token';
static const refreshToken = 'laverie_refresh_token';
}
/// Clé publique Stripe (test) — surcharge via `--dart-define=STRIPE_PUBLISHABLE_KEY=...`
const String _envStripePublishableKey = String.fromEnvironment('STRIPE_PUBLISHABLE_KEY');
final String kStripePublishableKey = _envStripePublishableKey.isNotEmpty
? _envStripePublishableKey
: 'pk_test_51TpANRJRUgjTIwfBR9PoU4Lu201yD5R0JzvOv8Nmyva7ISX3GJPJ3IX4lSqnkg13siYwi3B9Qq0tIpEj6VCzeVFB00PSt9o0OA';
@@ -0,0 +1,14 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Configuration du stockage sécurisé adaptée Android / iOS.
///
/// Android : SharedPreferences chiffrées (Keystore).
/// iOS : Keychain avec accessibilité au premier déverrouillage.
const FlutterSecureStorage laverieSecureStorage = FlutterSecureStorage(
aOptions: AndroidOptions(
encryptedSharedPreferences: true,
),
iOptions: IOSOptions(
accessibility: KeychainAccessibility.first_unlock,
),
);
+39
View File
@@ -0,0 +1,39 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
/// Plateformes supportées par l'application (mobile natif uniquement, pas de web).
enum AppPlatform {
android,
ios,
}
/// Indique si la plateforme courante est supportée (Android ou iOS).
bool get isMobilePlatformSupported {
if (kIsWeb) {
return false;
}
return Platform.isAndroid || Platform.isIOS;
}
/// Plateforme courante. Lance une exception si web ou desktop.
AppPlatform get currentAppPlatform {
if (kIsWeb) {
throw UnsupportedError(
'Laverie Mobile ne cible pas le web. Utilisez Android ou iOS.',
);
}
if (Platform.isAndroid) {
return AppPlatform.android;
}
if (Platform.isIOS) {
return AppPlatform.ios;
}
throw UnsupportedError(
'Plateforme non supportée. Cible : Android (prioritaire) et iOS.',
);
}
bool get isAndroid => !kIsWeb && Platform.isAndroid;
bool get isIos => !kIsWeb && Platform.isIOS;
+159
View File
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
import '../../features/auth/presentation/login_screen.dart';
import '../../features/auth/presentation/register_screen.dart';
import '../../features/auth/presentation/splash_screen.dart';
import '../../features/home/presentation/home_screen.dart';
import '../../features/establishments/presentation/establishment_detail_screen.dart';
import '../../features/wallet/presentation/wallet_screen.dart';
import '../../features/wallet/presentation/wallet_top_up_screen.dart';
import '../../features/booking/presentation/booking_modify_screen.dart';
import '../../features/booking/presentation/bookings_screen.dart';
import '../../features/machines/presentation/machine_action_screen.dart';
import '../../features/machines/presentation/machine_booking_screen.dart';
import '../../features/wash/presentation/qr_scanner_screen.dart';
import '../../features/wash/presentation/wash_screen.dart';
import '../../features/profile/presentation/profile_screen.dart';
import '../widgets/main_shell.dart';
/// Routes nommées de l'application.
abstract final class AppRoutes {
static const splash = '/splash';
static const login = '/login';
static const register = '/register';
static const home = '/';
static const wallet = '/wallet';
static const walletTopUp = '/wallet/top-up';
static const bookings = '/bookings';
static const washes = '/washes';
static const washScan = '/washes/scan';
static const profile = '/profile';
static const establishment = '/establishments/:uuid';
static String machineAction(String uuid) => '/machines/$uuid/action';
static String machineBooking(String uuid) => '/machines/$uuid/book';
static String bookingModify(String uuid) => '/bookings/$uuid/edit';
}
/// Configuration GoRouter avec redirection selon l'état d'authentification.
final appRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authProvider);
return GoRouter(
initialLocation: AppRoutes.splash,
refreshListenable: GoRouterRefreshStream(ref),
redirect: (context, state) {
final location = state.matchedLocation;
final onSplash = location == AppRoutes.splash;
final isAuthRoute = location == AppRoutes.login || location == AppRoutes.register;
// Restauration de session en cours → écran de chargement.
if (authState.isLoading) {
return onSplash ? null : AppRoutes.splash;
}
// Session restaurée → quitter le splash.
if (onSplash) {
return authState.isAuthenticated ? AppRoutes.home : AppRoutes.login;
}
final isAuthenticated = authState.isAuthenticated;
if (!isAuthenticated && !isAuthRoute) {
return AppRoutes.login;
}
if (isAuthenticated && isAuthRoute) {
return AppRoutes.home;
}
return null;
},
routes: [
GoRoute(
path: AppRoutes.splash,
builder: (context, state) => const SplashScreen(),
),
GoRoute(
path: AppRoutes.login,
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: AppRoutes.register,
builder: (context, state) => const RegisterScreen(),
),
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
GoRoute(
path: AppRoutes.home,
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: AppRoutes.bookings,
builder: (context, state) => const BookingsScreen(),
),
GoRoute(
path: AppRoutes.washes,
builder: (context, state) => const WashScreen(),
),
GoRoute(
path: AppRoutes.wallet,
builder: (context, state) => const WalletScreen(),
),
GoRoute(
path: AppRoutes.profile,
builder: (context, state) => const ProfileScreen(),
),
],
),
GoRoute(
path: AppRoutes.establishment,
builder: (context, state) {
final uuid = state.pathParameters['uuid']!;
return EstablishmentDetailScreen(establishmentUuid: uuid);
},
),
GoRoute(
path: AppRoutes.washScan,
builder: (context, state) => const QrScannerScreen(),
),
GoRoute(
path: AppRoutes.walletTopUp,
builder: (context, state) => const WalletTopUpScreen(),
),
GoRoute(
path: '/machines/:uuid/action',
builder: (context, state) {
final uuid = state.pathParameters['uuid']!;
return MachineActionScreen(machineUuid: uuid);
},
),
GoRoute(
path: '/machines/:uuid/book',
builder: (context, state) {
final uuid = state.pathParameters['uuid']!;
return MachineBookingScreen(machineUuid: uuid);
},
),
GoRoute(
path: '/bookings/:uuid/edit',
builder: (context, state) {
final uuid = state.pathParameters['uuid']!;
return BookingModifyScreen(bookingUuid: uuid);
},
),
],
);
});
/// Écoute les changements Riverpod pour rafraîchir les redirections GoRouter.
class GoRouterRefreshStream extends ChangeNotifier {
GoRouterRefreshStream(this._ref) {
_ref.listen<AuthState>(authProvider, (_, __) => notifyListeners());
}
final Ref _ref;
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
/// Palette équilibrée — moderne sans excès « web app ».
abstract final class AppColors {
static const primary = Color(0xFF2563EB);
static const primaryDark = Color(0xFF1D4ED8);
static const secondary = Color(0xFF0D9488);
static const background = Color(0xFFF1F5F9);
static const surface = Color(0xFFFFFFFF);
static const surfaceVariant = Color(0xFFE2E8F0);
static const divider = Color(0xFFE2E8F0);
static const textPrimary = Color(0xFF0F172A);
static const textSecondary = Color(0xFF64748B);
static const success = Color(0xFF059669);
static const warning = Color(0xFFD97706);
static const error = Color(0xFFDC2626);
/// Statuts machines — code couleur type WashOnline.
static const machineAvailable = Color(0xFF16A34A);
static const machineRunning = Color(0xFF2563EB);
static const machineReserved = Color(0xFFEA580C);
static const machineOffline = Color(0xFF94A3B8);
static const gradientPrimary = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF2563EB), Color(0xFF1D4ED8)],
);
static const gradientAccent = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF2563EB), Color(0xFF0D9488)],
);
static const gradientSoft = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFEFF6FF), Color(0xFFF1F5F9)],
);
/// Deux accents alternés pour les cartes (pas d'arc-en-ciel).
static Color cardAccent(int index) => index.isEven ? primary : secondary;
}
+135
View File
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app_colors.dart';
/// Thème visuel de l'application Laverie.
class AppTheme {
AppTheme._();
static ThemeData get light {
const colorScheme = ColorScheme.light(
primary: AppColors.primary,
onPrimary: Colors.white,
primaryContainer: Color(0xFFDBEAFE),
onPrimaryContainer: AppColors.primaryDark,
secondary: AppColors.secondary,
onSecondary: Colors.white,
error: AppColors.error,
onError: Colors.white,
surface: AppColors.surface,
onSurface: AppColors.textPrimary,
onSurfaceVariant: AppColors.textSecondary,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: AppColors.background,
appBarTheme: const AppBarTheme(
centerTitle: false,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimary,
surfaceTintColor: Colors.transparent,
systemOverlayStyle: SystemUiOverlayStyle.dark,
titleTextStyle: TextStyle(
color: AppColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
cardTheme: CardThemeData(
elevation: 1,
shadowColor: Colors.black.withValues(alpha: 0.06),
color: AppColors.surface,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
margin: EdgeInsets.zero,
),
dividerTheme: const DividerThemeData(color: AppColors.divider, thickness: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.surface,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.divider),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
prefixIconColor: AppColors.textSecondary,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
elevation: 0,
backgroundColor: AppColors.success,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.success.withValues(alpha: 0.4),
disabledForegroundColor: Colors.white70,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
backgroundColor: AppColors.success,
foregroundColor: Colors.white,
disabledBackgroundColor: AppColors.success.withValues(alpha: 0.5),
disabledForegroundColor: Colors.white.withValues(alpha: 0.8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.divider),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
elevation: 3,
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
),
navigationBarTheme: NavigationBarThemeData(
elevation: 0,
height: 64,
backgroundColor: AppColors.surface,
indicatorColor: AppColors.primary.withValues(alpha: 0.12),
surfaceTintColor: Colors.transparent,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.primary);
}
return const TextStyle(fontSize: 12, color: AppColors.textSecondary);
}),
iconTheme: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return const IconThemeData(color: AppColors.primary, size: 24);
}
return const IconThemeData(color: AppColors.textSecondary, size: 24);
}),
),
listTileTheme: const ListTileThemeData(
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
),
textTheme: const TextTheme(
headlineMedium: TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary),
bodyLarge: TextStyle(fontSize: 16, color: AppColors.textPrimary),
bodyMedium: TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// Couleurs et libellés des statuts machine (inspiré WashOnline, plus lisible).
abstract final class MachineStatusTheme {
static Color color(String status) => switch (status) {
'available' => AppColors.machineAvailable,
'running' => AppColors.machineRunning,
'reserved' => AppColors.machineReserved,
'maintenance' || 'offline' || 'error' => AppColors.machineOffline,
_ => AppColors.textSecondary,
};
static String label(String status) => switch (status) {
'available' => 'Libre',
'running' => 'En cours',
'reserved' => 'Réservée',
'maintenance' => 'Maintenance',
'offline' => 'Hors ligne',
'error' => 'Erreur',
_ => status,
};
static IconData iconForType(String type) => type.startsWith('dryer')
? Icons.air_outlined
: Icons.water_drop_outlined;
static bool canStart(String status) => status == 'available';
}
+71
View File
@@ -0,0 +1,71 @@
import 'package:intl/intl.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
/// Heure métier alignée sur le fuseau du serveur API (pas l'UTC ni le téléphone).
abstract final class ServerTime {
static const defaultTimezone = 'Europe/Paris';
static bool _initialized = false;
static tz.Location _location = tz.UTC;
static Future<void> initialize({String timezone = defaultTimezone}) async {
if (!_initialized) {
tz_data.initializeTimeZones();
_initialized = true;
}
setTimezone(timezone);
}
static void setTimezone(String timezone) {
if (!_initialized) {
tz_data.initializeTimeZones();
_initialized = true;
}
_location = tz.getLocation(timezone);
}
static String get timezone => _location.name;
static tz.TZDateTime now() => tz.TZDateTime.now(_location);
static DateTime startOfToday() {
final current = now();
return DateTime(current.year, current.month, current.day);
}
static DateTime? parse(String? iso) {
if (iso == null || iso.isEmpty) {
return null;
}
return DateTime.tryParse(iso);
}
static tz.TZDateTime toServerTime(DateTime dateTime) {
return tz.TZDateTime.from(dateTime.toUtc(), _location);
}
static String format(
DateTime? dateTime, {
required String pattern,
String locale = 'fr_FR',
}) {
if (dateTime == null) {
return '';
}
final server = toServerTime(dateTime);
return DateFormat(pattern, locale).format(
DateTime(
server.year,
server.month,
server.day,
server.hour,
server.minute,
server.second,
server.millisecond,
server.microsecond,
),
);
}
}
+29
View File
@@ -0,0 +1,29 @@
import 'package:dio/dio.dart';
import '../api/api_endpoints.dart';
import '../api/api_response.dart';
import '../config/app_config.dart';
import 'server_time.dart';
/// Synchronise le fuseau horaire applicatif avec l'API (`/health`).
Future<void> syncServerTimezone() async {
try {
final client = Dio(
BaseOptions(
baseUrl: kApiBaseUrl,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
headers: {'Accept': 'application/json'},
),
);
final response = await client.get(ApiEndpoints.health);
final timezone = ApiResponse.payload(response.data)['timezone'];
if (timezone is String && timezone.isNotEmpty) {
ServerTime.setTimezone(timezone);
}
} catch (_) {
// Conserve le fuseau par défaut (Europe/Paris).
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
/// État vide avec icône dans un cercle coloré.
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
required this.subtitle,
this.iconColor,
this.actionLabel,
this.onAction,
});
final IconData icon;
final String title;
final String subtitle;
final Color? iconColor;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final color = iconColor ?? AppColors.primary;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 32, color: color),
),
const SizedBox(height: 16),
Text(title, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 6),
Text(subtitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium),
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 20),
OutlinedButton(onPressed: onAction, child: Text(actionLabel!)),
],
],
),
),
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
/// Grille responsive pour les cartes machines (téléphone → tablette).
abstract final class MachineGridLayout {
static const _minTileWidth = 155.0;
static const _maxTileWidth = 190.0;
static SliverGridDelegate delegate(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
const padding = 32.0;
final available = width - padding;
if (available > _maxTileWidth * 3) {
return const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _maxTileWidth,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 0.88,
);
}
final count = (available / _minTileWidth).floor().clamp(2, 3);
return SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: count,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: count >= 3 ? 0.85 : 0.92,
);
}
}
+450
View File
@@ -0,0 +1,450 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../router/app_router.dart';
import '../theme/app_colors.dart';
import '../theme/machine_status_theme.dart';
import '../../features/establishments/domain/establishment.dart';
/// Légende des statuts machines.
class MachineStatusLegend extends StatelessWidget {
const MachineStatusLegend({super.key});
@override
Widget build(BuildContext context) {
return const Wrap(
spacing: 12,
runSpacing: 6,
children: [
_LegendDot(color: AppColors.machineAvailable, label: 'Libre'),
_LegendDot(color: AppColors.machineRunning, label: 'En cours'),
_LegendDot(color: AppColors.machineReserved, label: 'Réservée'),
_LegendDot(color: AppColors.machineOffline, label: 'Indisponible'),
],
);
}
}
class _LegendDot extends StatelessWidget {
const _LegendDot({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 12)),
],
);
}
}
/// Carte machine en grille — vue d'ensemble type WashOnline.
class MachineGridCard extends StatelessWidget {
const MachineGridCard({
super.key,
required this.machine,
required this.onTap,
});
final Machine machine;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final statusColor = MachineStatusTheme.color(machine.status);
final canStart = MachineStatusTheme.canStart(machine.status);
return Material(
color: AppColors.surface,
elevation: canStart ? 2 : 0,
shadowColor: statusColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: canStart ? statusColor.withValues(alpha: 0.35) : AppColors.divider,
width: canStart ? 1.5 : 1,
),
),
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
MachineStatusTheme.iconForType(machine.type),
color: statusColor,
size: 20,
),
),
const Spacer(),
_StatusPill(status: machine.status),
],
),
const SizedBox(height: 10),
Text(
machine.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontSize: 14),
),
const SizedBox(height: 2),
Text(
machine.typeLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 11),
),
if (canStart) ...[
const Spacer(),
Text(
'Démarrer',
style: TextStyle(
color: statusColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
),
);
}
}
class _StatusPill extends StatelessWidget {
const _StatusPill({required this.status});
final String status;
@override
Widget build(BuildContext context) {
final color = MachineStatusTheme.color(status);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
MachineStatusTheme.label(status),
style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w700),
),
);
}
}
/// Statistiques rapides du parc machines.
class MachineStatsRow extends StatelessWidget {
const MachineStatsRow({super.key, required this.machines});
final List<Machine> machines;
@override
Widget build(BuildContext context) {
final available = machines.where((m) => m.status == 'available').length;
final running = machines.where((m) => m.status == 'running').length;
final reserved = machines.where((m) => m.status == 'reserved').length;
return Row(
children: [
Expanded(child: _StatBox(value: '$available', label: 'Libres', color: AppColors.machineAvailable)),
const SizedBox(width: 8),
Expanded(child: _StatBox(value: '$running', label: 'En cours', color: AppColors.machineRunning)),
const SizedBox(width: 8),
Expanded(child: _StatBox(value: '$reserved', label: 'Réservées', color: AppColors.machineReserved)),
],
);
}
}
class _StatBox extends StatelessWidget {
const _StatBox({required this.value, required this.label, required this.color});
final String value;
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Text(value, style: TextStyle(color: color, fontSize: 20, fontWeight: FontWeight.w700)),
Text(label, style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 11)),
],
),
);
}
}
/// Actions rapides — démarrage en quelques clics.
class QuickActionsRow extends StatelessWidget {
const QuickActionsRow({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _QuickAction(
icon: Icons.qr_code_scanner_rounded,
label: 'Scanner',
color: AppColors.primary,
onTap: () => context.push(AppRoutes.washScan),
),
),
const SizedBox(width: 10),
Expanded(
child: _QuickAction(
icon: Icons.event_available_outlined,
label: 'Réserver',
color: AppColors.machineReserved,
onTap: () => context.go(AppRoutes.bookings),
),
),
const SizedBox(width: 10),
Expanded(
child: _QuickAction(
icon: Icons.add_card_outlined,
label: 'Recharger',
color: AppColors.secondary,
onTap: () => context.go(AppRoutes.wallet),
),
),
],
);
}
}
class _QuickAction extends StatelessWidget {
const _QuickAction({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
final IconData icon;
final String label;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.surface,
elevation: 1,
shadowColor: Colors.black.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(icon, color: color, size: 26),
const SizedBox(height: 6),
Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600)),
],
),
),
),
);
}
}
/// Bannière lavage en cours.
class ActiveWashBanner extends StatelessWidget {
const ActiveWashBanner({
super.key,
required this.machineName,
required this.onTap,
});
final String machineName;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.primary,
borderRadius: BorderRadius.circular(14),
elevation: 2,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.local_laundry_service, color: Colors.white),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Lavage en cours',
style: TextStyle(color: Colors.white70, fontSize: 12),
),
Text(
machineName,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
],
),
),
const Icon(Icons.chevron_right, color: Colors.white),
],
),
),
),
);
}
}
/// Bottom sheet détail machine + actions.
class MachineActionSheet {
static Future<void> show(
BuildContext context, {
required Machine machine,
required VoidCallback onStart,
VoidCallback? onReserve,
}) {
final statusColor = MachineStatusTheme.color(machine.status);
final canStart = MachineStatusTheme.canStart(machine.status);
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 20 + MediaQuery.of(context).padding.bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.divider,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 20),
Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
MachineStatusTheme.iconForType(machine.type),
color: statusColor,
size: 28,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(machine.name, style: Theme.of(context).textTheme.titleMedium),
Text(machine.typeLabel, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
_StatusPill(status: machine.status),
],
),
const SizedBox(height: 20),
if (canStart) ...[
ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
onStart();
},
icon: const Icon(Icons.play_arrow_rounded),
label: const Text('Démarrer maintenant'),
),
const SizedBox(height: 10),
OutlinedButton.icon(
onPressed: () {
Navigator.pop(context);
onReserve?.call();
},
icon: const Icon(Icons.event_outlined),
label: const Text('Réserver un créneau'),
),
] else
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Text(
machine.status == 'running'
? 'Cette machine est en cours d\'utilisation.'
: machine.status == 'reserved'
? 'Machine réservée — elle sera disponible sur votre créneau.'
: 'Machine indisponible pour le moment.',
textAlign: TextAlign.center,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
),
),
],
),
),
);
}
}
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../router/app_router.dart';
import '../theme/app_colors.dart';
import 'wallet_chip.dart';
/// Coque principale — navigation + solde visible (style WashOnline amélioré).
class MainShell extends ConsumerWidget {
const MainShell({super.key, required this.child});
final Widget child;
static int _indexFromLocation(String location) {
if (location.startsWith(AppRoutes.bookings)) return 1;
if (location.startsWith(AppRoutes.washes)) return 2;
if (location.startsWith(AppRoutes.wallet)) return 3;
if (location.startsWith(AppRoutes.profile)) return 4;
return 0;
}
static String _titleFromLocation(String location) {
if (location.startsWith(AppRoutes.bookings)) return 'Réservations';
if (location.startsWith(AppRoutes.washes)) return 'Mes lavages';
if (location.startsWith(AppRoutes.wallet)) return 'Portefeuille';
if (location.startsWith(AppRoutes.profile)) return 'Mon profil';
return 'Accueil';
}
static bool _showScanFab(String location) {
return location.startsWith(AppRoutes.bookings) ||
location.startsWith(AppRoutes.washes);
}
void _onTabTap(BuildContext context, int index) {
switch (index) {
case 0:
context.go(AppRoutes.home);
case 1:
context.go(AppRoutes.bookings);
case 2:
context.go(AppRoutes.washes);
case 3:
context.go(AppRoutes.wallet);
case 4:
context.go(AppRoutes.profile);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final location = GoRouterState.of(context).uri.toString();
final selectedIndex = _indexFromLocation(location);
final showScanFab = _showScanFab(location);
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: Text(_titleFromLocation(location)),
actions: const [WalletChip()],
),
body: child,
floatingActionButton: showScanFab
? FloatingActionButton.extended(
onPressed: () => context.push(AppRoutes.washScan),
icon: const Icon(Icons.qr_code_scanner_rounded),
label: const Text('Scanner'),
)
: null,
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (index) => _onTabTap(context, index),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home_rounded),
label: 'Accueil',
),
NavigationDestination(
icon: Icon(Icons.event_outlined),
selectedIcon: Icon(Icons.event),
label: 'Résa',
),
NavigationDestination(
icon: Icon(Icons.local_laundry_service_outlined),
selectedIcon: Icon(Icons.local_laundry_service),
label: 'Lavages',
),
NavigationDestination(
icon: Icon(Icons.account_balance_wallet_outlined),
selectedIcon: Icon(Icons.account_balance_wallet),
label: 'Wallet',
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'Profil',
),
],
),
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
/// Badge de statut discret.
class StatusBadge extends StatelessWidget {
const StatusBadge({super.key, required this.label, required this.color});
final String label;
final Color color;
factory StatusBadge.fromStatus(String status) {
final (label, color) = switch (status) {
'available' || 'confirmed' || 'completed' => ('Disponible', AppColors.machineAvailable),
'running' || 'active' || 'pending_start' => ('En cours', AppColors.machineRunning),
'reserved' || 'pending' => ('Réservé', AppColors.machineReserved),
'cancelled' => ('Annulé', AppColors.textSecondary),
_ => (status, AppColors.textSecondary),
};
return StatusBadge(label: label, color: color);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
label,
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600),
),
);
}
}
/// Bandeau d'en-tête discret pour les écrans principaux.
class ScreenHeader extends StatelessWidget {
const ScreenHeader({
super.key,
required this.title,
this.subtitle,
});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: AppColors.gradientAccent,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 13,
),
),
],
],
),
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../router/app_router.dart';
import '../theme/app_colors.dart';
import '../../features/wallet/data/wallet_repository.dart';
/// Solde portefeuille compact — toujours visible (comme WashOnline).
class WalletChip extends ConsumerWidget {
const WalletChip({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final walletAsync = ref.watch(walletProvider);
final format = NumberFormat.currency(locale: 'fr_FR', symbol: '', decimalDigits: 2);
return walletAsync.when(
loading: () => const Padding(
padding: EdgeInsets.only(right: 12),
child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (_, __) => _ChipButton(
label: '— €',
onTap: () => context.go(AppRoutes.wallet),
),
data: (wallet) => _ChipButton(
label: format.format(wallet.currentBalance),
onTap: () => context.go(AppRoutes.wallet),
),
);
}
}
class _ChipButton extends StatelessWidget {
const _ChipButton({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Material(
color: AppColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(20),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.account_balance_wallet_outlined, size: 16, color: AppColors.primary),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
],
),
),
),
),
);
}
}
+53
View File
@@ -0,0 +1,53 @@
/// Modèle utilisateur authentifié.
class AuthUser {
const AuthUser({
required this.uuid,
required this.firstName,
required this.lastName,
required this.email,
this.phone,
this.locale = 'fr',
});
final String uuid;
final String firstName;
final String lastName;
final String email;
final String? phone;
final String locale;
String get fullName => '$firstName $lastName'.trim();
factory AuthUser.fromJson(Map<String, dynamic> json) {
return AuthUser(
uuid: json['uuid'] as String,
firstName: json['first_name'] as String? ?? '',
lastName: json['last_name'] as String? ?? '',
email: json['email'] as String,
phone: json['phone'] as String?,
locale: json['locale'] as String? ?? 'fr',
);
}
}
/// Réponse d'authentification (login / register / refresh).
class AuthTokens {
const AuthTokens({
required this.accessToken,
required this.refreshToken,
this.user,
});
final String accessToken;
final String refreshToken;
final AuthUser? user;
factory AuthTokens.fromJson(Map<String, dynamic> json) {
final userJson = json['user'] as Map<String, dynamic>?;
return AuthTokens(
accessToken: json['access_token'] as String? ?? json['token'] as String,
refreshToken: json['refresh_token'] as String,
user: userJson != null ? AuthUser.fromJson(userJson) : null,
);
}
}
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// Fond et carte pour les écrans d'authentification.␍
class AuthScaffold extends StatelessWidget {
const AuthScaffold({
super.key,
required this.child,
this.showBackButton = false,
});
final Widget child;
final bool showBackButton;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.gradientSoft),
child: SafeArea(
child: Column(
children: [
if (showBackButton)
Align(
alignment: Alignment.centerLeft,
child: IconButton(
onPressed: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.arrow_back),
),
),
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: child,
),
),
),
],
),
),
),
);
}
}
/// Carte blanche pour formulaires auth.␍
class AuthFormCard extends StatelessWidget {
const AuthFormCard({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Card(
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.08),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(24),
child: child,
),
),
);
}
}
/// Logo et titre auth.␍
class AuthHeader extends StatelessWidget {
const AuthHeader({super.key, required this.subtitle});
final String subtitle;
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: AppColors.gradientPrimary,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.local_laundry_service_outlined, size: 32, color: Colors.white),
),
const SizedBox(height: 16),
Text(
'Laverie Connectée',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(subtitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 24),
],
);
}
}
@@ -0,0 +1,252 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../../../core/auth/auth_provider.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../booking/data/booking_repository.dart';
import '../../wallet/data/wallet_repository.dart';
import '../../wash/data/wash_repository.dart';
import 'auth_widgets.dart';
/// Écran de connexion utilisateur.
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController(text: 'marie.dupont@demo.local');
final _passwordController = TextEditingController(text: 'password');
bool _isCheckingHealth = false;
String? _healthResult;
bool _healthHasError = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _checkHealth() async {
setState(() {
_isCheckingHealth = true;
_healthResult = null;
_healthHasError = false;
});
final apiClient = ref.read(apiClientProvider);
final lines = <String>[
'URL de base : ${apiClient.baseUrl}',
'',
];
var hasError = false;
for (final entry in [
('API', ApiEndpoints.health),
('Base de données', ApiEndpoints.healthDb),
]) {
final label = entry.$1;
final path = entry.$2;
final url = '${apiClient.baseUrl}$path';
try {
final response = await apiClient.get(path);
lines.add('$label : OK (${response.statusCode})');
lines.add('URL : $url');
lines.add(_formatResponse(response.data));
} on DioException catch (error) {
hasError = true;
lines.add('$label : Erreur');
lines.add('URL : $url');
lines.add(_formatDioError(error));
}
lines.add('');
}
if (mounted) {
setState(() {
_isCheckingHealth = false;
_healthHasError = hasError;
_healthResult = lines.join('\n').trim();
});
}
}
String _formatResponse(dynamic data) {
if (data is Map || data is List) {
return const JsonEncoder.withIndent(' ').convert(data);
}
return data?.toString() ?? '';
}
String _formatDioError(DioException error) {
final response = error.response;
if (response != null) {
final body = response.data;
if (body is Map || body is List) {
return 'HTTP ${response.statusCode}\n${_formatResponse(body)}';
}
return 'HTTP ${response.statusCode}: $body';
}
return switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'Délai d\'attente dépassé',
DioExceptionType.connectionError =>
'Connexion impossible (${error.message ?? 'réseau injoignable'})',
_ => error.message ?? 'Erreur réseau',
};
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final success = await ref.read(authProvider.notifier).login(
_emailController.text.trim(),
_passwordController.text,
);
if (success && mounted) {
ref.invalidate(washesProvider);
ref.invalidate(bookingsProvider);
ref.invalidate(walletProvider);
ref.invalidate(walletTransactionsProvider);
context.go(AppRoutes.home);
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return AuthScaffold(
child: AuthFormCard(
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AuthHeader(subtitle: 'Connectez-vous pour réserver et laver'),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email requis';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Mot de passe requis';
}
return null;
},
),
if (authState.error != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.error.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline_rounded, color: AppColors.error, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
authState.error!,
style: const TextStyle(color: AppColors.error),
),
),
],
),
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: authState.isLoading ? null : _submit,
child: authState.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('Se connecter'),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => context.push(AppRoutes.register),
child: const Text('Créer un compte'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _isCheckingHealth ? null : _checkHealth,
icon: _isCheckingHealth
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.monitor_heart_outlined, size: 18),
label: const Text('Vérifier l\'API'),
),
if (_healthResult != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: (_healthHasError ? AppColors.error : AppColors.primary)
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: (_healthHasError ? AppColors.error : AppColors.primary)
.withValues(alpha: 0.3),
),
),
child: SelectableText(
_healthResult!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: _healthHasError ? AppColors.error : AppColors.primaryDark,
),
),
),
],
],
),
),
),
);
}
}
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/auth/auth_provider.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import 'auth_widgets.dart';
/// Écran d'inscription utilisateur.
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_firstNameController.dispose();
_lastNameController.dispose();
_emailController.dispose();
_phoneController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final success = await ref.read(authProvider.notifier).register(
firstName: _firstNameController.text.trim(),
lastName: _lastNameController.text.trim(),
email: _emailController.text.trim(),
password: _passwordController.text,
phone: _phoneController.text.trim().isEmpty
? null
: _phoneController.text.trim(),
);
if (success && mounted) {
context.go(AppRoutes.home);
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return AuthScaffold(
showBackButton: true,
child: AuthFormCard(
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AuthHeader(subtitle: 'Créez votre compte en quelques secondes'),
TextFormField(
controller: _firstNameController,
decoration: const InputDecoration(
labelText: 'Prénom',
prefixIcon: Icon(Icons.person_outline_rounded),
),
validator: (v) => v == null || v.isEmpty ? 'Prénom requis' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _lastNameController,
decoration: const InputDecoration(
labelText: 'Nom',
prefixIcon: Icon(Icons.badge_outlined),
),
validator: (v) => v == null || v.isEmpty ? 'Nom requis' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (v) => v == null || v.isEmpty ? 'Email requis' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Téléphone (optionnel)',
prefixIcon: Icon(Icons.phone_outlined),
),
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline),
),
validator: (v) {
if (v == null || v.length < 8) {
return 'Minimum 8 caractères';
}
return null;
},
),
if (authState.error != null) ...[
const SizedBox(height: 12),
Text(authState.error!, style: const TextStyle(color: AppColors.error)),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: authState.isLoading ? null : _submit,
child: authState.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('S\'inscrire'),
),
],
),
),
),
);
}
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// Écran affiché pendant la restauration de session au démarrage.
class SplashScreen extends StatelessWidget {
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
width: double.infinity,
decoration: const BoxDecoration(gradient: AppColors.gradientSoft),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: AppColors.gradientPrimary,
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.local_laundry_service_outlined, size: 36, color: Colors.white),
),
const SizedBox(height: 20),
Text(
'Laverie Connectée',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
const CircularProgressIndicator(),
],
),
),
),
);
}
}
@@ -0,0 +1,102 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../../../core/api/api_response.dart';
import '../../../core/auth/auth_provider.dart';
import '../../../core/time/server_time.dart';
import '../domain/booking.dart';
/// Dépôt de données pour les réservations.
class BookingRepository {
BookingRepository(this._apiClient);
final ApiClient _apiClient;
Future<List<Booking>> fetchBookings() async {
final response = await _apiClient.get(ApiEndpoints.bookings);
final list = ApiResponse.list(response.data, 'bookings');
return list.map((json) => Booking.fromJson(json as Map<String, dynamic>)).toList();
}
Future<Booking> fetchBooking(String uuid) async {
final response = await _apiClient.get(ApiEndpoints.booking(uuid));
final json = ApiResponse.object(response.data, 'booking');
return Booking.fromJson(json);
}
Future<Booking> createBooking({
required String machineUuid,
required DateTime slotStart,
required DateTime slotEnd,
}) async {
try {
final response = await _apiClient.post(
ApiEndpoints.bookings,
data: {
'machine_uuid': machineUuid,
'slot_start': ServerTime.toServerTime(slotStart).toIso8601String(),
'slot_end': ServerTime.toServerTime(slotEnd).toIso8601String(),
},
);
final json = ApiResponse.object(response.data, 'booking');
return Booking.fromJson(json);
} on DioException catch (error) {
throw BookingException(ApiResponse.errorMessage(error, fallback: 'Impossible de réserver'));
}
}
Future<Booking> cancelBooking(String uuid) async {
try {
final response = await _apiClient.patch(ApiEndpoints.bookingCancel(uuid));
final json = ApiResponse.object(response.data, 'booking');
return Booking.fromJson(json);
} on DioException catch (error) {
throw BookingException(ApiResponse.errorMessage(error, fallback: 'Impossible d\'annuler'));
}
}
Future<Booking> moveBooking({
required String uuid,
required DateTime slotStart,
required DateTime slotEnd,
}) async {
try {
final response = await _apiClient.patch(
ApiEndpoints.bookingMove(uuid),
data: {
'slot_start': ServerTime.toServerTime(slotStart).toIso8601String(),
'slot_end': ServerTime.toServerTime(slotEnd).toIso8601String(),
},
);
final json = ApiResponse.object(response.data, 'booking');
return Booking.fromJson(json);
} on DioException catch (error) {
throw BookingException(ApiResponse.errorMessage(error, fallback: 'Impossible de modifier'));
}
}
}
class BookingException implements Exception {
BookingException(this.message);
final String message;
@override
String toString() => message;
}
final bookingRepositoryProvider = Provider<BookingRepository>((ref) {
return BookingRepository(ref.watch(apiClientProvider));
});
final bookingsProvider = FutureProvider<List<Booking>>((ref) async {
final token = ref.watch(authProvider.select((state) => state.accessToken));
if (token == null || token.isEmpty) {
throw StateError('Non authentifié');
}
return ref.watch(bookingRepositoryProvider).fetchBookings();
});
final bookingDetailProvider = FutureProvider.family<Booking, String>((ref, uuid) async {
return ref.watch(bookingRepositoryProvider).fetchBooking(uuid);
});
+47
View File
@@ -0,0 +1,47 @@
import '../../../core/time/server_time.dart';
/// Modèle réservation de créneau machine.
class Booking {
const Booking({
required this.uuid,
required this.machineUuid,
required this.machineName,
required this.slotStart,
required this.slotEnd,
required this.status,
this.bookingFee = 0,
});
final String uuid;
final String machineUuid;
final String machineName;
final DateTime? slotStart;
final DateTime? slotEnd;
final String status;
final double bookingFee;
bool get canCancel =>
(status == 'confirmed' || status == 'pending') &&
slotStart != null &&
slotStart!.isAfter(ServerTime.now());
bool get canModify => canCancel;
factory Booking.fromJson(Map<String, dynamic> json) {
final machine = json['machine'] as Map<String, dynamic>?;
return Booking(
uuid: json['uuid'] as String,
machineUuid: machine?['uuid'] as String? ?? json['machine_uuid'] as String? ?? '',
machineName: machine?['name'] as String? ?? 'Machine',
slotStart: json['slot_start'] != null
? DateTime.tryParse(json['slot_start'] as String)
: null,
slotEnd: json['slot_end'] != null
? DateTime.tryParse(json['slot_end'] as String)
: null,
status: json['status'] as String? ?? 'pending',
bookingFee: (json['booking_fee'] as num?)?.toDouble() ?? 0,
);
}
}
@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/time/server_time.dart';
import '../../machines/data/machine_repository.dart';
import '../../machines/domain/machine_detail.dart';
import '../data/booking_repository.dart';
import '../domain/booking.dart';
/// Modification du créneau d'une réservation existante.
class BookingModifyScreen extends ConsumerStatefulWidget {
const BookingModifyScreen({super.key, required this.bookingUuid});
final String bookingUuid;
@override
ConsumerState<BookingModifyScreen> createState() => _BookingModifyScreenState();
}
class _BookingModifyScreenState extends ConsumerState<BookingModifyScreen> {
DateTime? _selectedDate;
TimeSlot? _selectedSlot;
bool _isSaving = false;
Future<void> _confirmMove(Booking booking) async {
final slot = _selectedSlot;
if (slot == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélectionnez un nouveau créneau')),
);
return;
}
setState(() => _isSaving = true);
try {
await ref.read(bookingRepositoryProvider).moveBooking(
uuid: booking.uuid,
slotStart: slot.start,
slotEnd: slot.end,
);
ref.invalidate(bookingsProvider);
ref.invalidate(bookingDetailProvider(booking.uuid));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Créneau modifié avec succès'),
backgroundColor: AppColors.success,
),
);
context.go(AppRoutes.bookings);
}
} on BookingException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: AppColors.error),
);
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
@override
Widget build(BuildContext context) {
final bookingAsync = ref.watch(bookingDetailProvider(widget.bookingUuid));
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(title: const Text('Modifier le créneau')),
body: bookingAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('Réservation introuvable')),
data: (booking) {
if (!booking.canModify) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'Cette réservation ne peut plus être modifiée.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
),
);
}
final selectedDate = _selectedDate ??
DateTime(
booking.slotStart!.year,
booking.slotStart!.month,
booking.slotStart!.day,
);
final availabilityAsync = ref.watch(
machineAvailabilityProvider((uuid: booking.machineUuid, date: selectedDate)),
);
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(booking.machineName, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Créneau actuel',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 12),
),
Text(
booking.slotStart != null && booking.slotEnd != null
? '${ServerTime.format(booking.slotStart, pattern: 'EEEE dd MMM · HH:mm')} ${ServerTime.format(booking.slotEnd, pattern: 'HH:mm')}'
: '',
style: const TextStyle(fontWeight: FontWeight.w600),
),
],
),
),
),
const SizedBox(height: 20),
Text('Nouveau jour', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 10),
SizedBox(
height: 44,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: 6,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final date = ServerTime.startOfToday().add(Duration(days: index));
final normalized = DateTime(date.year, date.month, date.day);
final isSelected = normalized.year == selectedDate.year &&
normalized.month == selectedDate.month &&
normalized.day == selectedDate.day;
return ChoiceChip(
label: Text(ServerTime.format(normalized, pattern: 'EEE dd/MM')),
selected: isSelected,
onSelected: (_) => setState(() {
_selectedDate = normalized;
_selectedSlot = null;
}),
selectedColor: AppColors.primary.withValues(alpha: 0.15),
);
},
),
),
const SizedBox(height: 20),
Text('Nouveau créneau', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
availabilityAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Text('Impossible de charger les créneaux.'),
data: (slots) {
if (slots.isEmpty) {
return Text(
'Aucun créneau disponible ce jour.',
style: Theme.of(context).textTheme.bodyMedium,
);
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: slots.map((slot) {
final label =
'${ServerTime.format(slot.start, pattern: 'HH:mm')} ${ServerTime.format(slot.end, pattern: 'HH:mm')}';
final isSelected = _selectedSlot?.start == slot.start;
return FilterChip(
label: Text(label),
selected: isSelected,
onSelected: (_) => setState(() => _selectedSlot = slot),
selectedColor: AppColors.primary.withValues(alpha: 0.15),
checkmarkColor: AppColors.primary,
);
}).toList(),
);
},
),
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _isSaving ? null : () => _confirmMove(booking),
icon: _isSaving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.check_rounded, size: 24),
label: Text(_isSaving ? 'Enregistrement…' : 'Confirmer le changement'),
),
),
),
),
],
);
},
),
);
}
}
@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/time/server_time.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/screen_header.dart';
import '../data/booking_repository.dart';
import '../domain/booking.dart';
/// Écran listant les réservations avec annulation et modification.
class BookingsScreen extends ConsumerWidget {
const BookingsScreen({super.key});
Future<void> _cancelBooking(BuildContext context, WidgetRef ref, Booking booking) async {
final slotLabel = booking.slotStart != null
? ServerTime.format(booking.slotStart, pattern: 'EEEE dd MMM à HH:mm')
: 'ce créneau';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Annuler la réservation ?'),
content: Text(
'Le créneau du $slotLabel sera libéré.\n\n'
'Annulation gratuite plus de 2 h avant le créneau, sinon des frais peuvent s\'appliquer.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Non')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('Oui, annuler'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
try {
await ref.read(bookingRepositoryProvider).cancelBooking(booking.uuid);
ref.invalidate(bookingsProvider);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Réservation annulée'),
backgroundColor: AppColors.success,
),
);
}
} on BookingException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: AppColors.error),
);
}
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final bookingsAsync = ref.watch(bookingsProvider);
return bookingsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => EmptyState(
icon: Icons.event_busy_outlined,
title: 'Erreur',
subtitle: 'Impossible de charger vos réservations.',
actionLabel: 'Réessayer',
onAction: () => ref.invalidate(bookingsProvider),
),
data: (bookings) {
if (bookings.isEmpty) {
return EmptyState(
icon: Icons.event_outlined,
title: 'Aucune réservation',
subtitle: 'Réservez un créneau jusqu\'à 6 jours\nà l\'avance depuis une laverie.',
actionLabel: 'Voir les laveries',
onAction: () => context.go(AppRoutes.home),
);
}
final upcoming = bookings
.where((b) => b.status != 'cancelled' && b.status != 'completed' && b.status != 'no_show')
.toList();
final past = bookings
.where((b) => b.status == 'cancelled' || b.status == 'completed' || b.status == 'no_show')
.toList();
return RefreshIndicator(
onRefresh: () async => ref.invalidate(bookingsProvider),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
children: [
const ScreenHeader(
title: 'Mes créneaux',
subtitle: '30 min pour démarrer après notification',
),
if (upcoming.isNotEmpty) ...[
Text('À venir', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...upcoming.map(
(b) => _BookingCard(
booking: b,
highlight: true,
onModify: b.canModify
? () => context.push(AppRoutes.bookingModify(b.uuid))
: null,
onCancel: b.canCancel ? () => _cancelBooking(context, ref, b) : null,
),
),
const SizedBox(height: 16),
],
if (past.isNotEmpty) ...[
Text('Passées', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...past.map((b) => _BookingCard(booking: b, highlight: false)),
],
],
),
);
},
);
}
}
class _BookingCard extends StatelessWidget {
const _BookingCard({
required this.booking,
required this.highlight,
this.onModify,
this.onCancel,
});
final Booking booking;
final bool highlight;
final VoidCallback? onModify;
final VoidCallback? onCancel;
@override
Widget build(BuildContext context) {
final slotText = booking.slotStart != null
? ServerTime.format(booking.slotStart, pattern: 'EEEE dd MMM · HH:mm')
: 'Créneau à confirmer';
return Card(
margin: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: highlight
? BorderSide(color: AppColors.machineReserved.withValues(alpha: 0.4))
: BorderSide.none,
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: (highlight ? AppColors.machineReserved : AppColors.textSecondary)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.event,
color: highlight ? AppColors.machineReserved : AppColors.textSecondary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(booking.machineName, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 2),
Text(slotText, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
StatusBadge.fromStatus(booking.status),
],
),
if (onModify != null || onCancel != null) ...[
const SizedBox(height: 12),
Row(
children: [
if (onModify != null)
Expanded(
child: OutlinedButton.icon(
onPressed: onModify,
icon: const Icon(Icons.edit_calendar_outlined, size: 18),
label: const Text('Modifier'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(vertical: 10),
),
),
),
if (onModify != null && onCancel != null) const SizedBox(width: 8),
if (onCancel != null)
Expanded(
child: OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.cancel_outlined, size: 18),
label: const Text('Annuler'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
padding: const EdgeInsets.symmetric(vertical: 10),
),
),
),
],
),
],
],
),
),
);
}
}
@@ -0,0 +1,41 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_client.dart';
import '../../../core/api/api_endpoints.dart';
import '../../../core/api/api_response.dart';
import '../domain/establishment.dart';
/// Dépôt de données pour les établissements et machines.
class EstablishmentRepository {
EstablishmentRepository(this._apiClient);
final ApiClient _apiClient;
Future<List<Establishment>> fetchEstablishments() async {
final response = await _apiClient.get(ApiEndpoints.establishments);
final data = ApiResponse.list(response.data, 'establishments');
return data
.map((json) => Establishment.fromJson(json as Map<String, dynamic>))
.toList();
}
Future<Establishment> fetchEstablishment(String uuid) async {
final response = await _apiClient.get(ApiEndpoints.establishment(uuid));
final json = ApiResponse.object(response.data, 'establishment');
return Establishment.fromJson(json);
}
}
final establishmentRepositoryProvider = Provider<EstablishmentRepository>((ref) {
return EstablishmentRepository(ref.watch(apiClientProvider));
});
final establishmentsProvider = FutureProvider<List<Establishment>>((ref) async {
return ref.watch(establishmentRepositoryProvider).fetchEstablishments();
});
final establishmentDetailProvider =
FutureProvider.family<Establishment, String>((ref, uuid) async {
return ref.watch(establishmentRepositoryProvider).fetchEstablishment(uuid);
});
@@ -0,0 +1,110 @@
/// Modèle établissement (laverie).
class Establishment {
const Establishment({
required this.uuid,
required this.name,
required this.address,
this.city,
this.zipCode,
this.latitude,
this.longitude,
this.isActive = true,
this.machines = const [],
});
final String uuid;
final String name;
final String address;
final String? city;
final String? zipCode;
final double? latitude;
final double? longitude;
final bool isActive;
final List<Machine> machines;
String get fullAddress {
final parts = [address, zipCode, city].where((p) => p != null && p.isNotEmpty);
return parts.join(', ');
}
factory Establishment.fromJson(Map<String, dynamic> json) {
final machinesJson = json['machines'] as List<dynamic>? ?? [];
return Establishment(
uuid: json['uuid'] as String,
name: json['name'] as String,
address: json['address'] as String? ?? '',
city: json['city'] as String?,
zipCode: json['zip_code'] as String?,
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
isActive: json['is_active'] as bool? ?? true,
machines: machinesJson
.map((m) => Machine.fromJson(m as Map<String, dynamic>))
.toList(),
);
}
}
/// Modèle machine (lave-linge / sèche-linge).
class Machine {
const Machine({
required this.uuid,
required this.name,
required this.type,
required this.status,
this.qrCode,
});
final String uuid;
final String name;
final String type;
final String status;
final String? qrCode;
bool get isAvailable => status == 'available';
String get typeLabel {
switch (type) {
case 'washer_small':
return 'Lave-linge petit';
case 'washer_large':
return 'Lave-linge grand';
case 'dryer_small':
return 'Sèche-linge petit';
case 'dryer_large':
return 'Sèche-linge grand';
default:
return type;
}
}
String get statusLabel {
switch (status) {
case 'available':
return 'Disponible';
case 'reserved':
return 'Réservée';
case 'running':
return 'En cours';
case 'maintenance':
return 'Maintenance';
case 'offline':
return 'Hors ligne';
case 'error':
return 'Erreur';
default:
return status;
}
}
factory Machine.fromJson(Map<String, dynamic> json) {
return Machine(
uuid: json['uuid'] as String,
name: json['name'] as String,
type: json['type'] as String,
status: json['status'] as String,
qrCode: json['qr_code'] as String?,
);
}
}
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../core/widgets/machine_grid_layout.dart';
import '../../../core/widgets/machine_widgets.dart';
import '../../establishments/data/establishment_repository.dart';
import '../../establishments/domain/establishment.dart';
/// Écran laverie — grille machines.
class EstablishmentDetailScreen extends ConsumerStatefulWidget {
const EstablishmentDetailScreen({
super.key,
required this.establishmentUuid,
});
final String establishmentUuid;
@override
ConsumerState<EstablishmentDetailScreen> createState() => _EstablishmentDetailScreenState();
}
class _EstablishmentDetailScreenState extends ConsumerState<EstablishmentDetailScreen> {
String _filter = 'all';
List<Machine> _filterMachines(List<Machine> machines) => switch (_filter) {
'washer' => machines.where((m) => m.type.startsWith('washer')).toList(),
'dryer' => machines.where((m) => m.type.startsWith('dryer')).toList(),
_ => machines,
};
@override
Widget build(BuildContext context) {
final establishmentAsync =
ref.watch(establishmentDetailProvider(widget.establishmentUuid));
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: establishmentAsync.maybeWhen(
data: (e) => Text(e.name, overflow: TextOverflow.ellipsis),
orElse: () => const Text('Ma laverie'),
),
),
body: establishmentAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => EmptyState(
icon: Icons.error_outline,
title: 'Erreur',
subtitle: '$error',
actionLabel: 'Réessayer',
onAction: () => ref.invalidate(establishmentDetailProvider(widget.establishmentUuid)),
),
data: (establishment) => _EstablishmentBody(
establishment: establishment,
filter: _filter,
onFilterChanged: (value) => setState(() => _filter = value),
onRefresh: () async {
ref.invalidate(establishmentDetailProvider(widget.establishmentUuid));
},
machines: _filterMachines(establishment.machines),
onMachineTap: (machine) => context.push(AppRoutes.machineAction(machine.uuid)),
),
),
);
}
}
class _EstablishmentBody extends StatelessWidget {
const _EstablishmentBody({
required this.establishment,
required this.filter,
required this.onFilterChanged,
required this.onRefresh,
required this.machines,
required this.onMachineTap,
});
final Establishment establishment;
final String filter;
final ValueChanged<String> onFilterChanged;
final Future<void> Function() onRefresh;
final List<Machine> machines;
final ValueChanged<Machine> onMachineTap;
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: AppColors.gradientAccent,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
establishment.name,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
establishment.fullAddress,
style: TextStyle(color: Colors.white.withValues(alpha: 0.9), fontSize: 13),
),
],
),
),
const SizedBox(height: 16),
if (establishment.machines.isNotEmpty) ...[
MachineStatsRow(machines: establishment.machines),
const SizedBox(height: 12),
const MachineStatusLegend(),
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_FilterChip(
label: 'Toutes',
selected: filter == 'all',
onTap: () => onFilterChanged('all'),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Lave-linge',
selected: filter == 'washer',
onTap: () => onFilterChanged('washer'),
),
const SizedBox(width: 8),
_FilterChip(
label: 'Sèche-linge',
selected: filter == 'dryer',
onTap: () => onFilterChanged('dryer'),
),
],
),
),
const SizedBox(height: 16),
],
if (establishment.machines.isEmpty)
const EmptyState(
icon: Icons.local_laundry_service_outlined,
title: 'Aucune machine',
subtitle: 'Cette laverie n\'a pas encore de machines.',
)
else if (machines.isEmpty)
const EmptyState(
icon: Icons.filter_alt_outlined,
title: 'Aucun résultat',
subtitle: 'Aucune machine dans cette catégorie.',
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: MachineGridLayout.delegate(context),
itemCount: machines.length,
itemBuilder: (context, index) => MachineGridCard(
machine: machines[index],
onTap: () => onMachineTap(machines[index]),
),
),
],
),
);
}
}
class _FilterChip extends StatelessWidget {
const _FilterChip({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FilterChip(
label: Text(label),
selected: selected,
onSelected: (_) => onTap(),
selectedColor: AppColors.primary.withValues(alpha: 0.15),
checkmarkColor: AppColors.primary,
labelStyle: TextStyle(
color: selected ? AppColors.primary : AppColors.textSecondary,
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More