From bdabfad92e1c89acc3342ac7928eaffb72cc9019 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Wed, 25 Mar 2026 17:47:10 +0100 Subject: [PATCH] added launch screen + update in ai chat + notifs (to be tested) + EventAgenda layout (to be tested) + home 3.0 update (event) + event mode layout (to be tested) + guided Path layout (to be tested) + map parcours (to be tested) + update readme --- .vscode/launch.json | 53 ++ CLAUDE.md | 58 ++ README.md | 141 +++- android/app/build.gradle | 34 +- android/app/src/dev/google-services.json | 105 +++ .../fortsaintheribert/google-services.json | 105 +++ .../res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 9303 bytes .../res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 4467 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 15579 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 34633 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 63361 bytes android/app/src/main/AndroidManifest.xml | 7 +- android/app/src/mdlf/google-services.json | 105 +++ .../src/mdlf/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/mdlf/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/mdlf/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../mdlf/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../mdlf/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes android/gradle.properties | 4 +- android/settings.gradle | 1 + assets/loader/dev.png | Bin 0 -> 68 bytes assets/loader/fortsaintheribert.png | Bin 0 -> 68 bytes assets/loader/mdlf.png | Bin 0 -> 68 bytes assets/splash/dev.png | Bin 0 -> 68 bytes assets/splash/fortsaintheribert.png | Bin 0 -> 68 bytes assets/splash/mdlf.png | Bin 0 -> 68 bytes devtools_options.yaml | 3 + ios/Runner/Info.plist | 4 +- .../GoogleService-Info.plist | 33 + ios/config/mdlf/GoogleService-Info.plist | 33 + ios/config/test/GoogleService-Info.plist | 33 + lib/Components/AssistantChatSheet.dart | 403 ++++++---- lib/Components/loading_common.dart | 9 +- lib/Helpers/DatabaseHelper.dart | 8 +- lib/Models/AssistantResponse.dart | 3 + lib/Models/agenda.dart | 56 ++ lib/Models/visitContext.dart | 4 +- .../ConfigurationPage/components/body.dart | 11 +- .../ConfigurationPage/configuration_page.dart | 62 +- lib/Screens/Home/home_3.0.dart | 323 ++++++-- lib/Screens/Sections/Agenda/agenda_page.dart | 33 +- lib/Screens/Sections/Agenda/event_popup.dart | 32 +- .../Sections/Article/article_page.dart | 10 +- .../Sections/Event/event_map_full_page.dart | 103 +++ lib/Screens/Sections/Event/event_page.dart | 758 +++++++++++++++++ .../guided_path_content_progression_page.dart | 444 ++++++++++ .../GuidedPath/guided_path_list_sheet.dart | 96 +++ .../guided_path_map_progression_page.dart | 760 ++++++++++++++++++ .../GuidedPath/guided_step_timer.dart | 108 +++ lib/Screens/Sections/Map/map_page.dart | 36 + lib/Screens/section_page.dart | 9 +- lib/Screens/splash_screen.dart | 66 ++ lib/Services/assistantService.dart | 70 +- lib/Services/pushNotificationService.dart | 93 +++ lib/client.dart | 16 + lib/constants.dart | 85 +- lib/main.dart | 150 ++-- pubspec.lock | 170 +++- pubspec.yaml | 10 + 59 files changed, 4253 insertions(+), 394 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 CLAUDE.md create mode 100644 android/app/src/dev/google-services.json create mode 100644 android/app/src/fortsaintheribert/google-services.json create mode 100644 android/app/src/fortsaintheribert/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/fortsaintheribert/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/fortsaintheribert/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/fortsaintheribert/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/fortsaintheribert/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/mdlf/google-services.json create mode 100644 android/app/src/mdlf/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/mdlf/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/mdlf/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/mdlf/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/mdlf/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 assets/loader/dev.png create mode 100644 assets/loader/fortsaintheribert.png create mode 100644 assets/loader/mdlf.png create mode 100644 assets/splash/dev.png create mode 100644 assets/splash/fortsaintheribert.png create mode 100644 assets/splash/mdlf.png create mode 100644 devtools_options.yaml create mode 100644 ios/config/fortsaintheribert/GoogleService-Info.plist create mode 100644 ios/config/mdlf/GoogleService-Info.plist create mode 100644 ios/config/test/GoogleService-Info.plist create mode 100644 lib/Screens/Sections/Event/event_map_full_page.dart create mode 100644 lib/Screens/Sections/Event/event_page.dart create mode 100644 lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart create mode 100644 lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart create mode 100644 lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart create mode 100644 lib/Screens/Sections/GuidedPath/guided_step_timer.dart create mode 100644 lib/Screens/splash_screen.dart create mode 100644 lib/Services/pushNotificationService.dart diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e249065 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,53 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Dev (flavor dev)", + "request": "launch", + "type": "dart", + "flutterMode": "debug", + "args": [ + "--flavor", + "dev", + "-t", + "lib/main.dart", + "--dart-define=FLAVOR=dev", + "--dart-define=INSTANCE_ID=63514fd67ed8c735aaa4b8f2", + "--dart-define=API_BASE_URL=http://192.168.31.228:5000", + "--dart-define=API_KEY=ak_WJDTunmXQSaRmZSulSdYNP64wnPsFMIIS9X5nAfSBfE" + ] + }, + { + "name": "Debug MDLF", + "request": "launch", + "type": "dart", + "flutterMode": "debug", + "args": [ + "--flavor", + "mdlf", + "-t", + "lib/main.dart", + "--dart-define=FLAVOR=mdlf", + "--dart-define=INSTANCE_ID=65ccc67265373befd15be511", + "--dart-define=API_BASE_URL=https://api.mymuseum.be", + "--dart-define=API_KEY=REPLACE_WITH_MDLF_KEY" + ] + }, + { + "name": "Debug Fort Saint-Héribert", + "request": "launch", + "type": "dart", + "flutterMode": "debug", + "args": [ + "--flavor", + "fortsaintheribert", + "-t", + "lib/main.dart", + "--dart-define=FLAVOR=fortsaintheribert", + "--dart-define=INSTANCE_ID=633ee379d9405f32f166f047", + "--dart-define=API_BASE_URL=https://api.mymuseum.be", + "--dart-define=API_KEY=REPLACE_WITH_FSH_KEY" + ] + } + ] +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fa01db6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# mymuseum-visitapp + +App Flutter pour les visiteurs finaux, installée sur leurs propres devices (smartphone/tablette). + +## Fonctionnement +- Le visiteur peut scanner un **QR code** pour afficher le détail d'un contenu +- Les **beacons** servent à suggérer l'affichage de contenu selon la position du visiteur +- Accès au contenu configuré dans `manager-app` pour son instance + +## Stack +- Flutter mobile (Android + iOS) +- State management : **Provider** + **GetX** (hybride) + - `ChangeNotifier` pour le contexte global (VisitContext) + - `Get.put()` pour les contrôleurs GetX (ex: `RequirementStateController`) +- Gestion des langues via `translations.dart` (Helpers) : les langues sont récupérées depuis le backend, pas depuis la locale du device — c'est pourquoi on n'utilise pas `flutter_localizations` pour le contenu +- SQLite (`sqflite`) pour le cache local +- Firebase Messaging + Local Notifications (push notifications) + +## Client API +Dépendance locale sur le client généré dans `manager-app` : +```yaml +manager_api_new: + path: ../manager-app/manager_api_new +``` +**Ne pas copier/dupliquer le client** — toujours pointer vers `manager-app/manager_api_new`. + +## Structure +``` +lib/ +├── api/ # Intégration OpenAPI +├── Components/ # AppBar, language selector, scanner, image/video viewers +├── Helpers/ # DatabaseHelper, networkCheck, translationHelper, modelsHelper +├── l10n/ # ARB files présents mais non utilisés pour le contenu (voir translationHelper) +├── Models/ # VisitContext, beaconSection, articleRead, agenda, weatherData +├── Screens/ +│ ├── Home/ # Liste des configurations, écran d'accueil +│ ├── ConfigurationPage/ # Setup avec détection beacon +│ └── Sections/ # Agenda, Article, Game, Map, Menu, PDF, +│ # Quiz, Slider, Video, Weather, Web +└── Services/ # apiService, assistantService, downloadConfiguration, + # pushNotificationService, statisticsService +``` + +## Particularités vs tablet-app +- Langues gérées via `translationHelper` + données backend (pas via ARB/flutter_localizations pour le contenu) +- Push notifications (Firebase Messaging) +- Scanner QR (`mobile_scanner`) + support beacons +- Speech-to-text (`speech_to_text`) +- Pas de MQTT +- GetX en plus de Provider pour certains contrôleurs + +## Commandes utiles +```bash +flutter run # Lancer sur device/émulateur connecté +flutter build apk # Build Android +flutter build ios # Build iOS +flutter gen-l10n # (peu utile, langues gérées via backend) +``` diff --git a/README.md b/README.md index 51d6b37..5b2804d 100644 --- a/README.md +++ b/README.md @@ -52,14 +52,135 @@ flutter build appbundle Faut pas oublier d'aller changer la version avant chaque upload de version (Puis mettre l'app bundle dans le dossier du PC, peut-être mettre sur le repos aussi ?) -# update app +# Clients existants -1) Mettre à jour l'instance Id -- MDLF : 65ccc67265373befd15be511 -- Fort : 633ee379d9405f32f166f047 -2) Mettre à jour l'icone (dans pubspec.yaml ref icon) --> https://easyappicon.com/ -3) Mettre à jour android manifest et info.plist (faire un CTRL MAJ F et replace..) -- be.unov.myinfomate.mdlf -- android : be.unov.mymuseum.fortsaintheribert - iod: be.unov.myvisit.mymuseumVisitapp -4) Mettre à jour les couleurs de l'app +| Flavor | Package Android | Bundle iOS | Instance ID | Nom app | +|---|---|---|---|---| +| `mdlf` | `be.unov.mymuseum.mdlf` | `be.unov.mymuseum.mdlf` | `65ccc67265373befd15be511` | MDLF | +| `fortsaintheribert` | `be.unov.mymuseum.fortsaintheribert` | `be.unov.mymuseum.fortsaintheribert` | `633ee379d9405f32f166f047` | Fort Saint-Héribert | +| `dev` | `be.unov.myinfomate.test` | `be.unov.myinfomate.test` | `63514fd67ed8c735aaa4b8f2` | MyMuseum Dev | + +# Builder pour un client + +Les paramètres à passer à chaque build : +- `FLAVOR` — nom du flavor (`dev`, `mdlf`, `fortsaintheribert`, ...) +- `INSTANCE_ID` — le GUID de l'instance dans le backend +- `API_BASE_URL` — l'URL de l'API backend +- `API_KEY` — la clé API de l'instance (générée via le Manager, endpoint `/api/apikey`) + +> La clé API est embarquée dans le binaire au moment du build. Elle est récupérable dans le Manager. + +## Android (App Bundle pour Play Store) +``` +flutter build appbundle --flavor mdlf --release -t lib/main.dart \ + --dart-define=FLAVOR=mdlf \ + --dart-define=INSTANCE_ID=65ccc67265373befd15be511 \ + --dart-define=API_BASE_URL=https://api.mymuseum.be \ + --dart-define=API_KEY=CLE_API_MDLF + +flutter build appbundle --flavor fortsaintheribert --release -t lib/main.dart \ + --dart-define=FLAVOR=fortsaintheribert \ + --dart-define=INSTANCE_ID=633ee379d9405f32f166f047 \ + --dart-define=API_BASE_URL=https://api.mymuseum.be \ + --dart-define=API_KEY=CLE_API_FORTSAINTHERIBERT +``` + +## iOS (fichier .ipa pour App Store) +> IPA = format de fichier d'une app iOS, équivalent de l'App Bundle Android. C'est ce qu'on upload sur l'App Store Connect. +``` +flutter build ipa --flavor mdlf --release -t lib/main.dart \ + --dart-define=FLAVOR=mdlf \ + --dart-define=INSTANCE_ID=65ccc67265373befd15be511 \ + --dart-define=API_BASE_URL=https://api.mymuseum.be \ + --dart-define=API_KEY=CLE_API_MDLF + +flutter build ipa --flavor fortsaintheribert --release -t lib/main.dart \ + --dart-define=FLAVOR=fortsaintheribert \ + --dart-define=INSTANCE_ID=633ee379d9405f32f166f047 \ + --dart-define=API_BASE_URL=https://api.mymuseum.be \ + --dart-define=API_KEY=CLE_API_FORTSAINTHERIBERT +``` + +## Dev local (flavor dev) +``` +flutter run --flavor dev -t lib/main.dart \ + --dart-define=FLAVOR=dev \ + --dart-define=INSTANCE_ID=63514fd67ed8c735aaa4b8f2 \ + --dart-define=API_BASE_URL=http://192.168.x.x:5000 \ + --dart-define=API_KEY=CLE_API_DEV +``` + +## Icône de l'app + +Pour mettre une icône spécifique par client, utilise https://easyappicon.com/ pour générer les assets, +puis place-les dans `android/app/src/{flavor}/res/mipmap-*/ic_launcher.png` +(ils remplaceront automatiquement l'icône par défaut au build). + +# Ajouter un nouveau client + +Exemple avec un client nommé `newclient` → package `be.unov.myinfomate.newclient` + +**1. Firebase Console** → projet `mymuseum-3b97f` +- Ajouter app Android (`be.unov.myinfomate.newclient`) → re-télécharger `google-services.json` → le copier dans `android/app/src/newclient/google-services.json` (et mettre à jour les autres flavors aussi) +- Ajouter app iOS (`be.unov.myinfomate.newclient`) → télécharger `GoogleService-Info.plist` → sauvegarder dans `ios/config/newclient/GoogleService-Info.plist` +- Uploader l'APNs Auth Key dans Firebase pour la nouvelle app iOS (même clé que les autres apps) + +**2. Apple Developer Portal** +- Créer App ID `be.unov.myinfomate.newclient` avec Push Notifications activé + +**3. `android/app/build.gradle`** : ajouter le flavor dans `productFlavors` +```groovy +newclient { + dimension "client" + applicationId "be.unov.myinfomate.newclient" + resValue "string", "app_name", "Nom de l'app" +} +``` + +**4. Xcode** : dupliquer les 3 configs (`Debug`, `Release`, `Profile`) pour le nouveau flavor, créer le scheme partagé +- Build Settings → `PRODUCT_BUNDLE_IDENTIFIER` = `be.unov.myinfomate.newclient` +- Build Settings → ajouter `FLUTTER_FLAVOR` = `newclient` + +**5. Mettre à jour le tableau "Clients existants"** en haut de ce README avec le nouveau client + +**6. Ajouter les couleurs dans `lib/constants.dart`** +Ajouter un nouveau cas dans chaque ternaire (`kMainColor0`, `kMainColor1`, `kMainColor2`) : +```dart +_flavor == 'newclient' ? 0xFFxxxxxx : // couleur principale du client +``` + +**7. Ajouter les assets splash et loader** + +- `assets/splash/newclient.png` — logo affiché au démarrage de l'app (fond transparent, ~400×400px) +- `assets/loader/newclient.png` — icône du loader in-app, affiché en rotation pendant les chargements (fond transparent, ~200×200px) + +Puis ajouter le nouveau flavor dans les deux constantes de `lib/constants.dart` : +```dart +const kSplashLogoAsset = _flavor == 'mdlf' + ? 'assets/splash/mdlf.png' + : _flavor == 'fortsaintheribert' + ? 'assets/splash/fortsaintheribert.png' + : _flavor == 'newclient' + ? 'assets/splash/newclient.png' + : 'assets/splash/dev.png'; + +const kLoaderAsset = _flavor == 'mdlf' + ? 'assets/loader/mdlf.png' + : _flavor == 'fortsaintheribert' + ? 'assets/loader/fortsaintheribert.png' + : _flavor == 'newclient' + ? 'assets/loader/newclient.png' + : 'assets/loader/dev.png'; +``` + +> Si tu ne fournis pas d'image, le fallback est automatique : `Icons.museum_outlined` pour le loader, `Icons.museum_outlined` pour le splash. Mais l'image doit quand même exister dans `assets/` (même un placeholder 1×1px) car Flutter vérifie les assets déclarés au build. + +**8. Tester** +``` +flutter run --flavor newclient -t lib/main.dart \ + --dart-define=FLAVOR=newclient \ + --dart-define=INSTANCE_ID=GUID_DU_CLIENT \ + --dart-define=API_BASE_URL=https://api.mymuseum.be +``` + +> ⚠️ Les fichiers `ios/config/*/GoogleService-Info.plist` marqués PLACEHOLDER doivent être remplacés par les vrais fichiers téléchargés depuis Firebase Console avant de builder en production iOS. diff --git a/android/app/build.gradle b/android/app/build.gradle index 9e138ae..7a1f8a1 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -3,6 +3,7 @@ plugins { id "kotlin-android" // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id "dev.flutter.flutter-gradle-plugin" + id "com.google.gms.google-services" } def localProperties = new Properties() @@ -46,6 +47,7 @@ android { compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true } packagingOptions { @@ -64,17 +66,31 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "be.unov.mymuseum.fortsaintheribert" // Update for mdlf and other clients -- "be.unov.mymuseum.fortsaintheribert" // be.unov.myinfomate.mdlf - minSdkVersion 24// flutter.minSdkVersion + minSdkVersion 24 targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true + } - /*ndk { - abiFilters "arm64-v8a" - }*/ + flavorDimensions "client" + + productFlavors { + dev { + dimension "client" + applicationId "be.unov.myinfomate.test" + resValue "string", "app_name", "MyMuseum Dev" + } + mdlf { + dimension "client" + applicationId "be.unov.mymuseum.mdlf" + resValue "string", "app_name", "MDLF" + } + fortsaintheribert { + dimension "client" + applicationId "be.unov.mymuseum.fortsaintheribert" + resValue "string", "app_name", "Fort Saint-Héribert" + } } signingConfigs { @@ -101,6 +117,6 @@ flutter { source '../..' } -/*dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -}*/ +dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' +} diff --git a/android/app/src/dev/google-services.json b/android/app/src/dev/google-services.json new file mode 100644 index 0000000..326d26a --- /dev/null +++ b/android/app/src/dev/google-services.json @@ -0,0 +1,105 @@ +{ + "project_info": { + "project_number": "1034665398515", + "project_id": "mymuseum-3b97f", + "storage_bucket": "mymuseum-3b97f.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:b9731ff810b0a2a7d6a786", + "android_client_info": { + "package_name": "be.unov.myinfomate.tablet" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:2911c4647a36e47cd6a786", + "android_client_info": { + "package_name": "be.unov.myinfomate.test" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:efe01f2db9674c78d6a786", + "android_client_info": { + "package_name": "be.unov.mymuseum.fortsaintheribert" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:23d9de7735f898e5d6a786", + "android_client_info": { + "package_name": "be.unov.mymuseum.mdlf" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:b7475582b41ed32dd6a786", + "android_client_info": { + "package_name": "be.unov.mymuseum.tablet_app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/fortsaintheribert/google-services.json b/android/app/src/fortsaintheribert/google-services.json new file mode 100644 index 0000000..326d26a --- /dev/null +++ b/android/app/src/fortsaintheribert/google-services.json @@ -0,0 +1,105 @@ +{ + "project_info": { + "project_number": "1034665398515", + "project_id": "mymuseum-3b97f", + "storage_bucket": "mymuseum-3b97f.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:b9731ff810b0a2a7d6a786", + "android_client_info": { + "package_name": "be.unov.myinfomate.tablet" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:2911c4647a36e47cd6a786", + "android_client_info": { + "package_name": "be.unov.myinfomate.test" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:efe01f2db9674c78d6a786", + "android_client_info": { + "package_name": "be.unov.mymuseum.fortsaintheribert" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:23d9de7735f898e5d6a786", + "android_client_info": { + "package_name": "be.unov.mymuseum.mdlf" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:b7475582b41ed32dd6a786", + "android_client_info": { + "package_name": "be.unov.mymuseum.tablet_app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/fortsaintheribert/res/mipmap-hdpi/ic_launcher.png b/android/app/src/fortsaintheribert/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..ddbb23620007d594368241a8f169c98ef7760c9c GIT binary patch literal 9303 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 literal 0 HcmV?d00001 diff --git a/android/app/src/fortsaintheribert/res/mipmap-mdpi/ic_launcher.png b/android/app/src/fortsaintheribert/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..2c0a0b396cab309d9dc9c40febf317c8f51e4fba GIT binary patch literal 4467 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 FV1g0BcXt2) literal 0 HcmV?d00001 diff --git a/android/app/src/fortsaintheribert/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/fortsaintheribert/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..dbba61eb0b8a8b7eb0b8d627f6646815ff10adf4 GIT binary patch literal 15579 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 V)!R@#o#Bo#zByk`m6cTh3q!F;isH~zON*9d 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 - + + @@ -20,7 +19,7 @@ --> diff --git a/android/app/src/mdlf/google-services.json b/android/app/src/mdlf/google-services.json new file mode 100644 index 0000000..326d26a --- /dev/null +++ b/android/app/src/mdlf/google-services.json @@ -0,0 +1,105 @@ +{ + "project_info": { + "project_number": "1034665398515", + "project_id": "mymuseum-3b97f", + "storage_bucket": "mymuseum-3b97f.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:b9731ff810b0a2a7d6a786", + "android_client_info": { + "package_name": "be.unov.myinfomate.tablet" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:2911c4647a36e47cd6a786", + "android_client_info": { + "package_name": "be.unov.myinfomate.test" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:efe01f2db9674c78d6a786", + "android_client_info": { + "package_name": "be.unov.mymuseum.fortsaintheribert" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:23d9de7735f898e5d6a786", + "android_client_info": { + "package_name": "be.unov.mymuseum.mdlf" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:1034665398515:android:b7475582b41ed32dd6a786", + "android_client_info": { + "package_name": "be.unov.mymuseum.tablet_app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBVCpwP5Uxh_nDUV2b6s4TybUqPJ-lvXm0" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/mdlf/res/mipmap-hdpi/ic_launcher.png b/android/app/src/mdlf/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/android/app/src/mdlf/res/mipmap-mdpi/ic_launcher.png b/android/app/src/mdlf/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/android/app/src/mdlf/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/mdlf/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/android/app/src/mdlf/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/mdlf/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/android/gradle.properties b/android/gradle.properties index e37eeb2..5156fa7 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,5 +1,5 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4096M android.useAndroidX=true -android.enableJetifier=true +android.enableJetifier=false android.experimental.enable16kApk=true android.useNewNativePlugin=true \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle index 79a6ff1..0613466 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -33,6 +33,7 @@ plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "8.9.0" apply false id "org.jetbrains.kotlin.android" version "2.1.0" apply false + id "com.google.gms.google-services" version "4.4.2" apply false } include ":app" \ No newline at end of file diff --git a/assets/loader/dev.png b/assets/loader/dev.png new file mode 100644 index 0000000000000000000000000000000000000000..35a340b6565bfc92ef9d53ef01e30267f65d4ec2 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blZci7-kcv6U2@FgO46F={RdK8B Pfx--)u6{1-oD!MNSLocationWhenInUseUsageDescription This app needs location to show content based on location NSMicrophoneUsageDescription - This app uses microphone only on qr code scanning (as it uses the camera) + Microphone access is used for voice input in the assistant chat + NSSpeechRecognitionUsageDescription + Speech recognition is used to convert your voice to text in the assistant chat UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/ios/config/fortsaintheribert/GoogleService-Info.plist b/ios/config/fortsaintheribert/GoogleService-Info.plist new file mode 100644 index 0000000..1246926 --- /dev/null +++ b/ios/config/fortsaintheribert/GoogleService-Info.plist @@ -0,0 +1,33 @@ + + + + + + + + API_KEY + PLACEHOLDER + GCM_SENDER_ID + 1034665398515 + PLIST_VERSION + 1 + BUNDLE_ID + be.unov.mymuseum.fortsaintheribert + PROJECT_ID + mymuseum-3b97f + STORAGE_BUCKET + mymuseum-3b97f.appspot.com + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + PLACEHOLDER + + diff --git a/ios/config/mdlf/GoogleService-Info.plist b/ios/config/mdlf/GoogleService-Info.plist new file mode 100644 index 0000000..3f43bee --- /dev/null +++ b/ios/config/mdlf/GoogleService-Info.plist @@ -0,0 +1,33 @@ + + + + + + + + API_KEY + PLACEHOLDER + GCM_SENDER_ID + 1034665398515 + PLIST_VERSION + 1 + BUNDLE_ID + be.unov.mymuseum.mdlf + PROJECT_ID + mymuseum-3b97f + STORAGE_BUCKET + mymuseum-3b97f.appspot.com + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + PLACEHOLDER + + diff --git a/ios/config/test/GoogleService-Info.plist b/ios/config/test/GoogleService-Info.plist new file mode 100644 index 0000000..a0f33c3 --- /dev/null +++ b/ios/config/test/GoogleService-Info.plist @@ -0,0 +1,33 @@ + + + + + + + + API_KEY + PLACEHOLDER + GCM_SENDER_ID + 1034665398515 + PLIST_VERSION + 1 + BUNDLE_ID + be.unov.myinfomate.test + PROJECT_ID + mymuseum-3b97f + STORAGE_BUCKET + mymuseum-3b97f.appspot.com + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + PLACEHOLDER + + diff --git a/lib/Components/AssistantChatSheet.dart b/lib/Components/AssistantChatSheet.dart index 96d3ca3..4d9e847 100644 --- a/lib/Components/AssistantChatSheet.dart +++ b/lib/Components/AssistantChatSheet.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:mymuseum_visitapp/Models/AssistantResponse.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Services/assistantService.dart'; import 'package:mymuseum_visitapp/constants.dart'; +import 'package:speech_to_text/speech_to_text.dart'; + +String _stripHtml(String html) => html.replaceAll(RegExp(r'<[^>]*>'), '').trim(); class AssistantChatSheet extends StatefulWidget { final VisitAppContext visitAppContext; - final String? configurationId; // null = scope instance, fourni = scope configuration + final String? configurationId; final void Function(String sectionId, String sectionTitle)? onNavigateToSection; const AssistantChatSheet({ @@ -16,6 +20,31 @@ class AssistantChatSheet extends StatefulWidget { this.onNavigateToSection, }) : super(key: key); + static void show( + BuildContext context, { + required VisitAppContext visitAppContext, + String? configurationId, + void Function(String sectionId, String sectionTitle)? onNavigateToSection, + }) { + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '', + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 280), + pageBuilder: (dialogContext, _, __) => AssistantChatSheet( + visitAppContext: visitAppContext, + configurationId: configurationId, + onNavigateToSection: onNavigateToSection, + ), + transitionBuilder: (_, animation, __, child) => SlideTransition( + position: Tween(begin: const Offset(0, 1), end: Offset.zero) + .animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)), + child: child, + ), + ); + } + @override State createState() => _AssistantChatSheetState(); } @@ -27,10 +56,46 @@ class _AssistantChatSheetState extends State { final List _bubbles = []; bool _isLoading = false; + final SpeechToText _speech = SpeechToText(); + bool _speechAvailable = false; + bool _isListening = false; + @override void initState() { super.initState(); _assistantService = AssistantService(visitAppContext: widget.visitAppContext); + _initSpeech(); + } + + Future _initSpeech() async { + final available = await _speech.initialize(); + if (mounted) setState(() => _speechAvailable = available); + } + + Future _toggleListening() async { + if (_isListening) { + await _speech.stop(); + setState(() => _isListening = false); + } else { + final locale = widget.visitAppContext.language?.toLowerCase() ?? 'fr'; + setState(() => _isListening = true); + await _speech.listen( + localeId: locale, + onResult: (result) { + setState(() => _controller.text = result.recognizedWords); + if (result.finalResult) { + setState(() => _isListening = false); + } + }, + listenOptions: SpeechListenOptions(partialResults: true), + ); + } + } + + @override + void dispose() { + _speech.cancel(); + super.dispose(); } Future _send() async { @@ -55,7 +120,8 @@ class _AssistantChatSheetState extends State { onNavigate: widget.onNavigateToSection, )); }); - } catch (_) { + } catch (e) { + print("AssistantChatSheet error: $e"); setState(() { _bubbles.add(_ChatBubble(text: "Une erreur est survenue, réessayez.", isUser: false)); }); @@ -79,117 +145,132 @@ class _AssistantChatSheetState extends State { @override Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.92, - expand: false, - builder: (context, scrollController) { - return Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), + final height = MediaQuery.of(context).size.height * 0.9; + return Align( + alignment: Alignment.bottomCenter, + child: Material( + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + clipBehavior: Clip.antiAlias, + child: SizedBox( + height: height, child: Column( - children: [ - // Handle - Container( - margin: const EdgeInsets.symmetric(vertical: 10), - width: 40, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Icon(Icons.chat_bubble_outline, color: kMainColor1), + const SizedBox(width: 8), + Text("Assistant", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: kSecondGrey)), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], ), - // Header + ), + const Divider(height: 1), + // Messages + Expanded( + child: _bubbles.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + "Bonjour ! Posez-moi vos questions sur cette visite.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[500], fontSize: 15), + ), + ), + ) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: _bubbles.length, + itemBuilder: (_, i) => _bubbles[i], + ), + ), + // Loading indicator + if (_isLoading) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( children: [ - Icon(Icons.chat_bubble_outline, color: kMainColor1), + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: kMainColor1), + ), const SizedBox(width: 8), - Text("Assistant", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: kSecondGrey)), + Text("...", style: TextStyle(color: Colors.grey[400])), ], ), ), - const Divider(height: 1), - // Messages - Expanded( - child: _bubbles.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - "Bonjour ! Posez-moi vos questions sur cette visite.", - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey[500], fontSize: 15), - ), + // Input + Padding( + padding: EdgeInsets.only( + left: 12, + right: 12, + bottom: MediaQuery.of(context).viewInsets.bottom + 8, + top: 8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: "Votre question...", + filled: true, + fillColor: Colors.grey[100], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, ), - ) - : ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - itemCount: _bubbles.length, - itemBuilder: (_, i) => _bubbles[i], + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), ), - ), - // Loading indicator - if (_isLoading) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Row( - children: [ - SizedBox( - width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: kMainColor1), - ), - const SizedBox(width: 8), - Text("...", style: TextStyle(color: Colors.grey[400])), - ], + onSubmitted: (_) => _send(), + ), ), - ), - // Input - SafeArea( - child: Padding( - padding: EdgeInsets.only( - left: 12, right: 12, bottom: MediaQuery.of(context).viewInsets.bottom + 8, top: 8), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - hintText: "Votre question...", - filled: true, - fillColor: Colors.grey[100], - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - ), - onSubmitted: (_) => _send(), - ), + const SizedBox(width: 8), + if (_speechAvailable) + AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: _isListening ? Colors.red : Colors.grey[200], + shape: BoxShape.circle, ), - const SizedBox(width: 8), - CircleAvatar( - backgroundColor: kMainColor1, - child: IconButton( - icon: const Icon(Icons.send, color: Colors.white, size: 18), - onPressed: _send, + child: IconButton( + icon: Icon( + _isListening ? Icons.mic : Icons.mic_none, + color: _isListening ? Colors.white : Colors.grey[600], + size: 20, ), + onPressed: _toggleListening, ), - ], + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: kMainColor1, + child: IconButton( + icon: const Icon(Icons.send, color: Colors.white, size: 18), + onPressed: _send, + ), ), - ), + ], ), - ], - ), - ); - }, + ), + ], + ), + ), + ), ); } } @@ -217,13 +298,15 @@ class _ChatBubble extends StatelessWidget { bottomRight: isUser ? const Radius.circular(4) : const Radius.circular(16), ), ), - child: Text( - text, - style: TextStyle( - color: isUser ? Colors.white : kSecondGrey, - fontSize: 14, - ), - ), + child: isUser + ? Text( + text, + style: const TextStyle(color: Colors.white, fontSize: 14), + ) + : HtmlWidget( + text, + textStyle: TextStyle(color: kSecondGrey, fontSize: 14), + ), ), ); } @@ -242,40 +325,86 @@ class _AssistantMessage extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Text bubble - _ChatBubble(text: response.reply, isUser: false), + if (response.reply.isNotEmpty) + _ChatBubble(text: response.reply, isUser: false), - // Cards if (response.cards != null && response.cards!.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 6, left: 4, right: 24), child: Column( - children: response.cards! - .map((card) => _AiCardWidget(card: card)) - .toList(), + children: response.cards!.map((card) => _AiCardWidget(card: card)).toList(), ), ), - // Navigation button if (response.navigation != null && onNavigate != null) - Padding( - padding: const EdgeInsets.only(top: 8, left: 4), - child: ElevatedButton.icon( - onPressed: () { - Navigator.pop(context); - onNavigate!( - response.navigation!.sectionId, - response.navigation!.sectionTitle, - ); - }, - icon: const Icon(Icons.arrow_forward, size: 16), - label: Text(response.navigation!.sectionTitle), - style: ElevatedButton.styleFrom( - backgroundColor: kMainColor1, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - textStyle: const TextStyle(fontSize: 13), + GestureDetector( + onTap: () { + Navigator.of(context).pop(); + onNavigate!( + response.navigation!.sectionId, + _stripHtml(response.navigation!.sectionTitle), + ); + }, + child: Container( + margin: const EdgeInsets.only(top: 8, left: 4, right: 24), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: kMainColor1.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: kMainColor1.withValues(alpha: 0.35)), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: response.navigation!.imageUrl != null + ? Image.network( + response.navigation!.imageUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: kMainColor1, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.place_outlined, color: Colors.white, size: 22), + ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: kMainColor1, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.place_outlined, color: Colors.white, size: 22), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _stripHtml(response.navigation!.sectionTitle), + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: kSecondGrey, + ), + ), + Text( + "Voir cette section", + style: TextStyle(fontSize: 11, color: Colors.grey[500]), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: kMainColor1, size: 20), + ], ), ), ), @@ -300,7 +429,10 @@ class _AiCardWidget extends StatelessWidget { borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.grey[200]!), boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 3, offset: const Offset(0, 1)), + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 3, + offset: const Offset(0, 1)), ], ), child: Row( @@ -313,15 +445,12 @@ class _AiCardWidget extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - card.title, - style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: kSecondGrey), - ), + Text(card.title, + style: TextStyle( + fontWeight: FontWeight.w600, fontSize: 13, color: kSecondGrey)), if (card.subtitle.isNotEmpty) - Text( - card.subtitle, - style: const TextStyle(fontSize: 12, color: Colors.grey), - ), + Text(card.subtitle, + style: const TextStyle(fontSize: 12, color: Colors.grey)), ], ), ), @@ -329,4 +458,4 @@ class _AiCardWidget extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/Components/loading_common.dart b/lib/Components/loading_common.dart index 9ee3739..d3315ea 100644 --- a/lib/Components/loading_common.dart +++ b/lib/Components/loading_common.dart @@ -81,7 +81,14 @@ class _LoadingCommonState extends State with TickerProviderStateM } }, ) - : Icon(Icons.museum_outlined, color: kMainColor2, size: size.height*0.1), + : Image.asset( + kLoaderAsset, + width: size.height * 0.1, + height: size.height * 0.1, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + Icon(Icons.museum_outlined, color: kMainColor2, size: size.height * 0.1), + ), ), ), ); diff --git a/lib/Helpers/DatabaseHelper.dart b/lib/Helpers/DatabaseHelper.dart index c342799..4dfa074 100644 --- a/lib/Helpers/DatabaseHelper.dart +++ b/lib/Helpers/DatabaseHelper.dart @@ -65,6 +65,7 @@ class DatabaseHelper { static const columnIsAdmin = 'isAdmin'; static const columnIsAllLanguages = 'isAllLanguages'; static const columnApiKey = 'apiKey'; + static const columnNotificationsEnabled = 'notificationsEnabled'; DatabaseHelper._privateConstructor(); @@ -159,7 +160,8 @@ class DatabaseHelper { $columnInstanceId TEXT NOT NULL, $columnIsAdmin BOOLEAN NOT NULL CHECK ($columnIsAdmin IN (0,1)), $columnIsAllLanguages BOOLEAN CHECK ($columnIsAllLanguages IN (0,1)), - $columnApiKey TEXT + $columnApiKey TEXT, + $columnNotificationsEnabled BOOLEAN CHECK ($columnNotificationsEnabled IN (0,1)) ) '''); break; @@ -257,6 +259,9 @@ class DatabaseHelper { if(test.where((e) => e.toString().contains(columnApiKey)).isEmpty) { await db.rawQuery("ALTER TABLE $nameOfTable ADD $columnApiKey TEXT"); } + if(test.where((e) => e.toString().contains(columnNotificationsEnabled)).isEmpty) { + await db.rawQuery("ALTER TABLE $nameOfTable ADD $columnNotificationsEnabled BOOLEAN CHECK ($columnNotificationsEnabled IN (0,1))"); + } DatabaseHelper.instance.insert(DatabaseTableType.main, visitAppContext.toMap()); } catch (e) { print("ERROR IN updateTableMain"); @@ -324,6 +329,7 @@ class DatabaseHelper { isAdmin: element["isAdmin"] == 1 ? true : false, isAllLanguages: element["isAllLanguages"] == 1 ? true : false, apiKey: element["apiKey"] as String?, + notificationsEnabled: element["notificationsEnabled"] == null || element["notificationsEnabled"] == 1, ); break; case DatabaseTableType.configurations: diff --git a/lib/Models/AssistantResponse.dart b/lib/Models/AssistantResponse.dart index 1d470f6..76cc6c5 100644 --- a/lib/Models/AssistantResponse.dart +++ b/lib/Models/AssistantResponse.dart @@ -16,11 +16,13 @@ class AssistantNavigationAction { final String sectionId; final String sectionTitle; final String sectionType; + final String? imageUrl; const AssistantNavigationAction({ required this.sectionId, required this.sectionTitle, required this.sectionType, + this.imageUrl, }); factory AssistantNavigationAction.fromJson(Map json) => @@ -28,6 +30,7 @@ class AssistantNavigationAction { sectionId: json['sectionId'] as String? ?? '', sectionTitle: json['sectionTitle'] as String? ?? '', sectionType: json['sectionType'] as String? ?? '', + imageUrl: json['imageUrl'] as String?, ); } diff --git a/lib/Models/agenda.dart b/lib/Models/agenda.dart index 0c2439e..fa92b1c 100644 --- a/lib/Models/agenda.dart +++ b/lib/Models/agenda.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:manager_api_new/api.dart'; class Agenda { List events; @@ -33,6 +34,8 @@ class EventAgenda { String? website; String? phone; String? idVideoYoutube; + String? videoLink; + String? videoResourceUrl; String? email; String? image; @@ -48,10 +51,63 @@ class EventAgenda { required this.website, required this.phone, required this.idVideoYoutube, + this.videoLink, + this.videoResourceUrl, required this.email, required this.image, }); + factory EventAgenda.fromDto(EventAgendaDTO dto, String language) { + String? pickTranslation(List? list) { + if (list == null || list.isEmpty) return null; + return (list.firstWhere( + (t) => t.language == language, + orElse: () => list.first, + )).value; + } + + EventAddress? address; + final a = dto.address; + if (a != null) { + final coords = a.geometry?.coordinates as List?; + address = EventAddress( + address: a.address, + lat: coords != null && coords.length >= 2 ? coords[1] : null, + lng: coords != null && coords.length >= 2 ? coords[0] : null, + zoom: a.zoom, + placeId: null, + name: null, + streetNumber: a.streetNumber, + streetName: a.streetName, + streetNameShort: null, + city: a.city, + state: a.state, + stateShort: null, + postCode: a.postCode, + country: a.country, + countryShort: null, + ); + } + + return EventAgenda( + name: pickTranslation(dto.label), + description: pickTranslation(dto.description), + type: dto.type, + dateAdded: dto.dateAdded, + dateFrom: dto.dateFrom, + dateTo: dto.dateTo, + dateHour: null, + address: address, + website: dto.website, + phone: dto.phone, + idVideoYoutube: dto.idVideoYoutube, + videoLink: dto.videoLink, + videoResourceUrl: dto.videoResource?.url, + email: dto.email, + image: dto.resource?.url, + ); + } + factory EventAgenda.fromJson(Map json) { return EventAgenda( name: json['name'], diff --git a/lib/Models/visitContext.dart b/lib/Models/visitContext.dart index d9f0fd9..1733554 100644 --- a/lib/Models/visitContext.dart +++ b/lib/Models/visitContext.dart @@ -44,10 +44,11 @@ class VisitAppContext with ChangeNotifier { bool? isAdmin = false; bool? isAllLanguages = false; + bool notificationsEnabled = true; String? localPath; - VisitAppContext({this.language, this.id, this.configuration, this.isAdmin, this.isAllLanguages, this.instanceId, this.apiKey}); + VisitAppContext({this.language, this.id, this.configuration, this.isAdmin, this.isAllLanguages, this.instanceId, this.apiKey, this.notificationsEnabled = true}); Map toMap() { return { @@ -57,6 +58,7 @@ class VisitAppContext with ChangeNotifier { 'isAdmin': isAdmin != null ? isAdmin! ? 1 : 0 : 0, 'isAllLanguages': isAllLanguages != null ? isAllLanguages! ? 1 : 0 : 0, 'apiKey': apiKey, + 'notificationsEnabled': notificationsEnabled ? 1 : 0, }; } diff --git a/lib/Screens/ConfigurationPage/components/body.dart b/lib/Screens/ConfigurationPage/components/body.dart index d499c21..b32b702 100644 --- a/lib/Screens/ConfigurationPage/components/body.dart +++ b/lib/Screens/ConfigurationPage/components/body.dart @@ -246,7 +246,8 @@ class _BodyState extends State { Future> getSections(AppContext appContext) async { VisitAppContext visitAppContext = appContext.getContext(); - if(widget.configuration.isOffline!) + sections = []; + if(widget.configuration.isOffline == true) { // OFFLINE sections = List.from(await DatabaseHelper.instance.getData(DatabaseTableType.sections)); @@ -260,14 +261,16 @@ class _BodyState extends State { List sectionList = rawToSection.whereType().toList(); visitAppContext.currentSections = rawSections; - //print(sectionsDownloaded); if(sectionList.isNotEmpty) { sections = sectionList.toList(); - //print(sections); } } - sections = sections.where((s) => s.configurationId == widget.configuration.id!).toList(); + // Only filter by configurationId if sections have it set + final id = widget.configuration.id; + if (id != null && sections.any((s) => s.configurationId != null)) { + sections = sections.where((s) => s.configurationId == id).toList(); + } sections.sort((a,b) => a.order!.compareTo(b.order!)); _allSections = sections; diff --git a/lib/Screens/ConfigurationPage/configuration_page.dart b/lib/Screens/ConfigurationPage/configuration_page.dart index c37aac5..d3a4c06 100644 --- a/lib/Screens/ConfigurationPage/configuration_page.dart +++ b/lib/Screens/ConfigurationPage/configuration_page.dart @@ -16,6 +16,7 @@ import 'package:mymuseum_visitapp/Models/beaconSection.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/ConfigurationPage/beaconArticleFound.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Article/article_page.dart'; +import 'package:mymuseum_visitapp/Services/pushNotificationService.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Quiz/quizz_page.dart'; import 'package:mymuseum_visitapp/Screens/section_page.dart'; import 'package:mymuseum_visitapp/app_context.dart'; @@ -53,6 +54,7 @@ class _ConfigurationPageState extends State with WidgetsBindi //bool _isArticleOpened = false; StreamSubscription? listener; //final List regions = []; + late final VoidCallback _notificationListener; @override void initState() { @@ -75,6 +77,33 @@ class _ConfigurationPageState extends State with WidgetsBindi listeningState(); } + _notificationListener = () { + final message = PushNotificationService.tappedMessage.value; + if (message == null) return; + if (!mounted) return; + final title = message.notification?.title ?? ''; + final body = message.notification?.body ?? ''; + ScaffoldMessenger.of(context).showMaterialBanner( + MaterialBanner( + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (title.isNotEmpty) Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + if (body.isNotEmpty) Text(body), + ], + ), + actions: [ + TextButton( + onPressed: () => ScaffoldMessenger.of(context).hideCurrentMaterialBanner(), + child: const Text('OK'), + ), + ], + ), + ); + }; + PushNotificationService.tappedMessage.addListener(_notificationListener); + WidgetsBinding.instance.addPostFrameCallback((_) { final appContext = Provider.of(context, listen: false); VisitAppContext visitAppContext = appContext.getContext(); @@ -323,6 +352,7 @@ class _ConfigurationPageState extends State with WidgetsBindi @override void dispose() { + PushNotificationService.tappedMessage.removeListener(_notificationListener); controller.pauseScanning(); super.dispose(); } @@ -362,24 +392,20 @@ class _ConfigurationPageState extends State with WidgetsBindi heroTag: 'assistant_config', backgroundColor: kMainColor1, onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => AssistantChatSheet( - visitAppContext: visitAppContext, - configurationId: widget.configuration.id, - onNavigateToSection: (sectionId, sectionTitle) { - Navigator.push(context, MaterialPageRoute( - builder: (_) => SectionPage( - configuration: widget.configuration, - rawSection: null, - visitAppContextIn: visitAppContext, - sectionId: sectionId, - ), - )); - }, - ), + AssistantChatSheet.show( + context, + visitAppContext: visitAppContext, + configurationId: widget.configuration.id, + onNavigateToSection: (sectionId, sectionTitle) { + Navigator.push(context, MaterialPageRoute( + builder: (_) => SectionPage( + configuration: widget.configuration, + rawSection: null, + visitAppContextIn: visitAppContext, + sectionId: sectionId, + ), + )); + }, ); }, child: const Icon(Icons.chat_bubble_outline, color: Colors.white), diff --git a/lib/Screens/Home/home_3.0.dart b/lib/Screens/Home/home_3.0.dart index 2fd3ab8..d1e2d2e 100644 --- a/lib/Screens/Home/home_3.0.dart +++ b/lib/Screens/Home/home_3.0.dart @@ -8,8 +8,9 @@ import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Components/AssistantChatSheet.dart'; import 'package:mymuseum_visitapp/Components/CustomAppBar.dart'; -import 'package:mymuseum_visitapp/Components/LanguageSelection.dart'; +import 'package:mymuseum_visitapp/Components/AdminPopup.dart'; import 'package:mymuseum_visitapp/Components/ScannerBouton.dart'; +import 'package:mymuseum_visitapp/Services/pushNotificationService.dart'; import 'package:mymuseum_visitapp/Components/loading_common.dart'; import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart'; import 'package:mymuseum_visitapp/Helpers/modelsHelper.dart'; @@ -19,6 +20,7 @@ import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/beaconSection.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/ConfigurationPage/configuration_page.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/Event/event_page.dart'; import 'package:mymuseum_visitapp/Services/apiService.dart'; import 'package:mymuseum_visitapp/Services/downloadConfiguration.dart'; import 'package:mymuseum_visitapp/Services/statisticsService.dart'; @@ -147,6 +149,99 @@ class _HomePage3State extends State with WidgetsBindingObserver { ); } + void _showSettingsSheet(BuildContext ctx, AppContext appCtx) { + final visitAppContext = appCtx.getContext() as VisitAppContext; + final configLanguages = visitAppContext.configuration?.languages ?? languages; + final hasNotifications = visitAppContext.instanceId != null && visitAppContext.instanceId!.isNotEmpty; + + showModalBottomSheet( + context: ctx, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetCtx) { + return StatefulBuilder(builder: (sheetCtx2, setLocal) { + final ctx2 = appCtx.getContext() as VisitAppContext; + return Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + const Text('Langue', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), + const SizedBox(height: 12), + Wrap( + spacing: 10, + children: configLanguages.map((lang) { + final isSelected = ctx2.language == lang; + return GestureDetector( + onTap: () async { + if (ctx2.language != lang) { + ctx2.language = lang; + appCtx.setContext(ctx2); + await DatabaseHelper.instance.insert(DatabaseTableType.main, ctx2.toMap()); + setLocal(() {}); + } + }, + child: Container( + width: 48, height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: isSelected ? Border.all(color: kMainColor, width: 2.5) : null, + image: DecorationImage( + fit: BoxFit.contain, + image: AssetImage('assets/images/old/${lang.toLowerCase()}.png'), + ), + boxShadow: const [BoxShadow(color: kSecondGrey, spreadRadius: 0.5, blurRadius: 5, offset: Offset(0, 1.5))], + ), + ), + ); + }).toList(), + ), + if (hasNotifications) ...[ + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Notifications push', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), + Switch( + value: ctx2.notificationsEnabled, + activeThumbColor: kMainColor, + onChanged: (value) async { + ctx2.notificationsEnabled = value; + appCtx.setContext(ctx2); + DatabaseHelper.instance.updateTableMain(DatabaseTableType.main, ctx2); + if (value) { + await PushNotificationService.subscribeToInstance(ctx2.instanceId!); + } else { + await PushNotificationService.unsubscribeFromInstance(ctx2.instanceId!); + } + setLocal(() {}); + }, + ), + ], + ), + ], + ], + ), + ); + }); + }, + ); + } + @override Widget build(BuildContext context) { Size size = MediaQuery.of(context).size; @@ -176,6 +271,7 @@ class _HomePage3State extends State with WidgetsBindingObserver { orElse: () => configurations[0].title!.first, ) : null; + final featuredEvent = visitAppContext.applicationInstanceDTO?.sectionEventDTO; return Stack( children: [ @@ -217,7 +313,9 @@ class _HomePage3State extends State with WidgetsBindingObserver { child: Stack( fit: StackFit.expand, children: [ - if (configurations.isNotEmpty && configurations[0].imageSource != null) + if (featuredEvent?.imageSource != null) + Image.network(featuredEvent!.imageSource!, fit: BoxFit.cover) + else if (configurations.isNotEmpty && configurations[0].imageSource != null) Image.network( configurations[0].imageSource!, fit: BoxFit.cover, @@ -233,41 +331,147 @@ class _HomePage3State extends State with WidgetsBindingObserver { ), ), ), + if (featuredEvent != null) + Positioned( + bottom: 16, + left: 14, + right: 60, + child: Builder(builder: (ctx) { + final titleEntry = featuredEvent.title?.firstWhere( + (t) => t.language == lang, + orElse: () => featuredEvent.title!.first, + ); + final start = featuredEvent.startDate; + final end = featuredEvent.endDate; + String dateLabel = ''; + if (start != null) { + dateLabel = '${start.day.toString().padLeft(2, '0')}/${start.month.toString().padLeft(2, '0')}'; + if (end != null && end.day != start.day) { + dateLabel += ' → ${end.day.toString().padLeft(2, '0')}/${end.month.toString().padLeft(2, '0')}'; + } + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (titleEntry != null) + Text( + titleEntry.value ?? '', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Row( + children: [ + if (dateLabel.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: kMainColor.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + dateLabel, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + if (dateLabel.isNotEmpty) const SizedBox(width: 8), + GestureDetector( + onTap: () { + final appCtx = Provider.of(ctx, listen: false); + final vCtx = appCtx.getContext() as VisitAppContext; + Navigator.of(ctx).push(MaterialPageRoute( + builder: (_) => EventPage( + section: featuredEvent, + visitAppContextIn: vCtx, + ), + )); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Découvrir', + style: TextStyle( + color: kMainColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ], + ); + }), + ), Positioned( top: 35, right: 10, - child: SizedBox( - width: 75, - height: 75, - child: LanguageSelection(), - ), + child: Builder(builder: (ctx) { + final appCtx = Provider.of(ctx, listen: false); + return GestureDetector( + onTap: () => _showSettingsSheet(ctx, appCtx), + onLongPress: () => showDialog( + context: ctx, + builder: (dialogCtx) => const AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10.0)), + ), + content: AdminPopup(), + contentPadding: EdgeInsets.zero, + ), + ), + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: const Icon(Icons.settings, color: Colors.white, size: 26), + ), + ); + }), ), ], ), ), ), - title: SizedBox( - width: size.width * 1.0, - height: 120, - child: Center( - child: headerTitleEntry != null - ? HtmlWidget( - headerTitleEntry.value!, - textStyle: const TextStyle( - color: Colors.white, - fontFamily: 'Roboto', - fontSize: 20, - fontWeight: FontWeight.bold, - ), - customStylesBuilder: (_) => { - 'text-align': 'center', - 'font-family': 'Roboto', - '-webkit-line-clamp': '2', - }, - ) - : const SizedBox(), - ), - ), + title: featuredEvent == null + ? SizedBox( + width: size.width * 1.0, + height: 120, + child: Center( + child: headerTitleEntry != null + ? HtmlWidget( + headerTitleEntry.value!, + textStyle: const TextStyle( + color: Colors.white, + fontFamily: 'Roboto', + fontSize: 20, + fontWeight: FontWeight.bold, + ), + customStylesBuilder: (_) => { + 'text-align': 'center', + 'font-family': 'Roboto', + '-webkit-line-clamp': '2', + }, + ) + : const SizedBox(), + ), + ) + : null, ), ), SliverPadding( @@ -309,30 +513,26 @@ class _HomePage3State extends State with WidgetsBindingObserver { heroTag: 'assistant_home', backgroundColor: kMainColor1, onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => AssistantChatSheet( - visitAppContext: visitAppContext, - onNavigateToSection: (configurationId, _) { - final config = configurations - .where((c) => c.id == configurationId) - .firstOrNull; - if (config != null) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ConfigurationPage( - configuration: config, - isAlreadyAllowed: - visitAppContext.isScanBeaconAlreadyAllowed, - ), + AssistantChatSheet.show( + context, + visitAppContext: visitAppContext, + onNavigateToSection: (configurationId, _) { + final config = configurations + .where((c) => c.id == configurationId) + .firstOrNull; + if (config != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ConfigurationPage( + configuration: config, + isAlreadyAllowed: + visitAppContext.isScanBeaconAlreadyAllowed, ), - ); - } - }, - ), + ), + ); + } + }, ); }, child: const Icon(Icons.chat_bubble_outline, color: Colors.white), @@ -440,6 +640,23 @@ class _HomePage3State extends State with WidgetsBindingObserver { } } - return await ApiService.getConfigurations(visitAppContext.clientAPI, visitAppContext); + // Fetch configurations via application instance links (mirrors manager-app "Applications / Mobile") + if (visitAppContext.applicationInstanceDTO?.id != null) { + try { + final links = await visitAppContext.clientAPI.applicationInstanceApi! + .applicationInstanceGetAllApplicationLinkFromApplicationInstance( + visitAppContext.applicationInstanceDTO!.id!); + final configs = links + ?.where((l) => l.configuration != null) + .map((l) => l.configuration!) + .toList() ?? + []; + return configs; + } catch (e) { + print("Could not load configurations from app instance links: $e"); + } + } + + return []; } } \ No newline at end of file diff --git a/lib/Screens/Sections/Agenda/agenda_page.dart b/lib/Screens/Sections/Agenda/agenda_page.dart index 0466d89..d6b3826 100644 --- a/lib/Screens/Sections/Agenda/agenda_page.dart +++ b/lib/Screens/Sections/Agenda/agenda_page.dart @@ -1,10 +1,6 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -//import 'dart:html'; import 'dart:ui' as ui; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:manager_api_new/api.dart'; @@ -49,27 +45,28 @@ class _AgendaPage extends State { Future getAndParseJsonInfo(VisitAppContext visitAppContext) async { try { - // Récupération du contenu JSON depuis l'URL - var httpClient = HttpClient(); + if (agendaDTO.id == null) return null; + final dtos = await visitAppContext.clientAPI.sectionAgendaApi! + .sectionAgendaGetUpcomingEvents(agendaDTO.id!); - // We need to get detail to get url from resourceId - var resourceIdForSelectedLanguage = agendaDTO.resourceIds!.where((ri) => ri.language == visitAppContext.language).first.value; - ResourceDTO? resourceDTO = await visitAppContext.clientAPI.resourceApi!.resourceGetDetail(resourceIdForSelectedLanguage!); + if (dtos == null) return null; - var request = await httpClient.getUrl(Uri.parse(resourceDTO!.url!)); - var response = await request.close(); - var jsonString = await response.transform(utf8.decoder).join(); + final events = dtos + .map((dto) => EventAgenda.fromDto(dto, visitAppContext.language)) + .toList(); + events.sort((a, b) { + if (a.dateFrom == null) return 1; + if (b.dateFrom == null) return -1; + return a.dateFrom!.compareTo(b.dateFrom!); + }); - agenda = Agenda.fromJson(jsonString); - agenda.events = agenda.events.where((a) => a.dateFrom != null && a.dateFrom!.isAfter(DateTime.now())).toList(); - agenda.events.sort((a, b) => a.dateFrom!.compareTo(b.dateFrom!)); - filteredAgenda.value = agenda.events; + agenda = Agenda(events: events); + filteredAgenda.value = events; mapIcon = await getByteIcon(); return agenda; - } catch(e) { - print("Erreur lors du parsing du json : ${e.toString()}"); + } catch (e) { return null; } } diff --git a/lib/Screens/Sections/Agenda/event_popup.dart b/lib/Screens/Sections/Agenda/event_popup.dart index 15eea8a..c64ee97 100644 --- a/lib/Screens/Sections/Agenda/event_popup.dart +++ b/lib/Screens/Sections/Agenda/event_popup.dart @@ -10,6 +10,7 @@ import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart' as mapBox; import 'package:manager_api_new/api.dart'; +import 'package:mymuseum_visitapp/Components/video_viewer.dart'; import 'package:mymuseum_visitapp/Components/video_viewer_youtube.dart'; import 'package:mymuseum_visitapp/Models/agenda.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; @@ -255,16 +256,33 @@ class _EventPopupState extends State { }, textStyle: const TextStyle(fontSize: 15.0), ), - widget.eventAgenda.idVideoYoutube != null && widget.eventAgenda.idVideoYoutube!.isNotEmpty ? - Padding( - padding: const EdgeInsets.all(8.0), - child: SizedBox( + if (widget.eventAgenda.idVideoYoutube != null && widget.eventAgenda.idVideoYoutube!.isNotEmpty) + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( height: 250, width: 350, - child: VideoViewerYoutube(videoUrl: "https://www.youtube.com/watch?v=${widget.eventAgenda.idVideoYoutube}", isAuto: false, webView: true) + child: VideoViewerYoutube(videoUrl: "https://www.youtube.com/watch?v=${widget.eventAgenda.idVideoYoutube}", isAuto: false, webView: true), + ), + ), + if (widget.eventAgenda.videoLink != null && widget.eventAgenda.videoLink!.isNotEmpty) + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 250, + width: 350, + child: VideoViewer(videoUrl: widget.eventAgenda.videoLink!, file: null), + ), + ), + if (widget.eventAgenda.videoResourceUrl != null && widget.eventAgenda.videoResourceUrl!.isNotEmpty) + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 250, + width: 350, + child: VideoViewer(videoUrl: widget.eventAgenda.videoResourceUrl!, file: null), + ), ), - ) : - SizedBox(), ], ), ), diff --git a/lib/Screens/Sections/Article/article_page.dart b/lib/Screens/Sections/Article/article_page.dart index a5bc046..a245175 100644 --- a/lib/Screens/Sections/Article/article_page.dart +++ b/lib/Screens/Sections/Article/article_page.dart @@ -24,12 +24,13 @@ import 'package:path_provider/path_provider.dart'; import 'audio_player_floating.dart'; class ArticlePage extends StatefulWidget { - const ArticlePage({Key? key, required this.visitAppContextIn, required this.articleDTO, required this.resourcesModel, this.mainAudioId}) : super(key: key); + const ArticlePage({Key? key, required this.visitAppContextIn, required this.articleDTO, required this.resourcesModel, this.mainAudioId, this.sectionId}) : super(key: key); final ArticleDTO articleDTO; final VisitAppContext visitAppContextIn; final List resourcesModel; final String? mainAudioId; + final String? sectionId; @override State createState() => _ArticlePageState(); @@ -49,6 +50,13 @@ class _ArticlePageState extends State { audioResourceModel = widget.resourcesModel.firstWhere((r) => r?.id == widget.mainAudioId, orElse: () => null); resourcesModelToShow = widget.resourcesModel.where((r) => r?.id != widget.mainAudioId).toList(); + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.visitAppContextIn.statisticsService?.track( + VisitEventType.articleRead, + sectionId: widget.sectionId, + ); + }); + super.initState(); } diff --git a/lib/Screens/Sections/Event/event_map_full_page.dart b/lib/Screens/Sections/Event/event_map_full_page.dart new file mode 100644 index 0000000..37cde9b --- /dev/null +++ b/lib/Screens/Sections/Event/event_map_full_page.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:manager_api_new/api.dart'; +import 'package:mymuseum_visitapp/Models/visitContext.dart'; +import 'package:mymuseum_visitapp/constants.dart'; + +class EventMapFullPage extends StatelessWidget { + const EventMapFullPage({ + Key? key, + required this.section, + required this.visitAppContextIn, + }) : super(key: key); + + final SectionEventDTO section; + final VisitAppContext visitAppContextIn; + + @override + Widget build(BuildContext context) { + final annotations = section.globalMapAnnotations ?? []; + final lat = double.tryParse(section.latitude ?? ''); + final lng = double.tryParse(section.longitude ?? ''); + final center = (lat != null && lng != null) ? LatLng(lat, lng) : const LatLng(50.85, 4.35); + + return Scaffold( + body: Stack( + children: [ + FlutterMap( + options: MapOptions( + initialCenter: center, + initialZoom: 14, + ), + children: [ + TileLayer( + urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', + userAgentPackageName: 'be.unov.myinfomate.visitapp', + ), + if (annotations.isNotEmpty) ...[ + MarkerLayer(markers: _buildMarkers(annotations)), + PolylineLayer(polylines: _buildPolylines(annotations)), + ], + ], + ), + Positioned( + top: MediaQuery.of(context).padding.top + 10, + left: 10, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 42, + height: 42, + decoration: const BoxDecoration(color: kMainColor, shape: BoxShape.circle), + child: const Icon(Icons.arrow_back, color: Colors.white, size: 20), + ), + ), + ), + ], + ), + ); + } + + List _buildMarkers(List annotations) { + final markers = []; + for (final ann in annotations) { + if (ann.geometryType?.value != 0) continue; + final coords = ann.geometry?.coordinates; + if (coords is! List || coords.length < 2) continue; + final lat = (coords[1] as num).toDouble(); + final lng = (coords[0] as num).toDouble(); + markers.add(Marker( + point: LatLng(lat, lng), + width: 32, + height: 32, + child: const Icon(Icons.place, color: kMainColor, size: 32), + )); + } + return markers; + } + + List _buildPolylines(List annotations) { + final lines = []; + for (final ann in annotations) { + if (ann.geometryType?.value != 1) continue; + final coords = ann.geometry?.coordinates; + if (coords is! List) continue; + final points = []; + for (final c in coords) { + if (c is List && c.length >= 2) { + points.add(LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble())); + } + } + if (points.length < 2) continue; + final color = ann.polyColor != null ? _hexColor(ann.polyColor!) : kMainColor; + lines.add(Polyline(points: points, color: color, strokeWidth: 4)); + } + return lines; + } + + Color _hexColor(String hex) { + final v = int.tryParse('FF${hex.replaceAll('#', '')}', radix: 16); + return v != null ? Color(v) : kMainColor; + } +} diff --git a/lib/Screens/Sections/Event/event_page.dart b/lib/Screens/Sections/Event/event_page.dart new file mode 100644 index 0000000..be7be04 --- /dev/null +++ b/lib/Screens/Sections/Event/event_page.dart @@ -0,0 +1,758 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:manager_api_new/api.dart'; +import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; +import 'package:mymuseum_visitapp/Models/visitContext.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/Event/event_map_full_page.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart'; +import 'package:mymuseum_visitapp/constants.dart'; + +class EventPage extends StatefulWidget { + const EventPage({ + Key? key, + required this.section, + required this.visitAppContextIn, + }) : super(key: key); + + final SectionEventDTO section; + final VisitAppContext visitAppContextIn; + + @override + State createState() => _EventPageState(); +} + +class _EventPageState extends State { + List _paths = []; + + @override + void initState() { + super.initState(); + _loadPaths(); + } + + Future _loadPaths() async { + if (widget.section.id == null) return; + if (widget.section.parcoursIds?.isEmpty ?? true) return; + try { + final paths = await widget.visitAppContextIn.clientAPI.sectionMapApi! + .sectionMapGetAllGuidedPathFromSection(widget.section.id!); + if (mounted) setState(() => _paths = paths ?? []); + } catch (_) {} + } + + ProgrammeBlock? get _activeBlock { + final now = DateTime.now(); + return widget.section.programme + ?.where((b) => + b.startTime != null && + b.endTime != null && + !now.isBefore(b.startTime!) && + !now.isAfter(b.endTime!)) + .firstOrNull; + } + + String _formatTime(DateTime? dt) { + if (dt == null) return ''; + return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; + } + + String _formatDate(DateTime dt) => + '${dt.day.toString().padLeft(2, '0')}/${dt.month.toString().padLeft(2, '0')}/${dt.year}'; + + String _formatDateRange() { + final start = widget.section.startDate; + final end = widget.section.endDate; + if (start == null && end == null) return ''; + if (start == null) return _formatDate(end!); + if (end == null) return _formatDate(start); + return '${_formatDate(start)} → ${_formatDate(end)}'; + } + + MapDTO _minimalMapDTO() => MapDTO( + latitude: widget.section.latitude, + longitude: widget.section.longitude, + zoom: 14, + ); + + @override + Widget build(BuildContext context) { + final hasProgramme = widget.section.programme?.isNotEmpty ?? false; + final annotations = widget.section.globalMapAnnotations ?? []; + final hasMapSection = annotations.isNotEmpty || + widget.section.baseSectionMapId != null || + widget.section.latitude != null || + _paths.isNotEmpty; + final hasDescription = widget.section.description?.isNotEmpty ?? false; + + return Scaffold( + backgroundColor: const Color(0xFF111111), + body: CustomScrollView( + slivers: [ + _buildHero(context), + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasProgramme) _buildProgrammeSection(), + if (hasMapSection) _buildMapParcoursSection(context, annotations), + if (hasDescription) _buildDescriptionSection(), + const SizedBox(height: 48), + ], + ), + ), + ], + ), + ); + } + + // ─── Hero ────────────────────────────────────────────────────────────────── + + Widget _buildHero(BuildContext context) { + final title = _safeTranslate(widget.section.title); + final dateRange = _formatDateRange(); + final expandedHeight = MediaQuery.of(context).size.height * 0.52; + + return SliverAppBar( + automaticallyImplyLeading: false, + expandedHeight: expandedHeight, + pinned: true, + backgroundColor: kMainColor, + title: Text( + title, + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + leading: Padding( + padding: const EdgeInsets.all(8), + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + decoration: const BoxDecoration(color: kMainColor, shape: BoxShape.circle), + child: const Icon(Icons.arrow_back, color: Colors.white), + ), + ), + ), + flexibleSpace: FlexibleSpaceBar( + collapseMode: CollapseMode.parallax, + background: Stack( + fit: StackFit.expand, + children: [ + if (widget.section.imageSource != null) + Image.network( + widget.section.imageSource!, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _gradientBackground(), + ) + else + _gradientBackground(), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], + stops: [0.35, 1.0], + ), + ), + ), + Positioned( + bottom: 20, + left: 16, + right: 16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.bold, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (dateRange.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: kMainColor.withOpacity(0.85), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + dateRange, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + + Widget _gradientBackground() => Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [kMainColor, kSecondColor], + ), + ), + ); + + // ─── Programme ───────────────────────────────────────────────────────────── + + Widget _buildProgrammeSection() { + final blocks = widget.section.programme!; + final active = _activeBlock; + + return Padding( + padding: const EdgeInsets.only(top: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionHeader(Icons.schedule, 'Programme'), + const SizedBox(height: 8), + ...blocks.asMap().entries.map( + (e) => _buildProgrammeBlock(e.value, e.value == active, e.key == blocks.length - 1), + ), + ], + ), + ); + } + + Widget _buildProgrammeBlock(ProgrammeBlock block, bool isActive, bool isLast) { + final title = _safeTranslate(block.title); + final startStr = _formatTime(block.startTime); + final endStr = _formatTime(block.endTime); + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 56, + child: Padding( + padding: const EdgeInsets.only(top: 14), + child: Text( + startStr, + style: TextStyle( + color: isActive ? kMainColor : Colors.grey[500], + fontSize: 12, + fontWeight: isActive ? FontWeight.bold : FontWeight.normal, + ), + textAlign: TextAlign.center, + ), + ), + ), + Column( + children: [ + const SizedBox(height: 16), + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: isActive ? kMainColor : Colors.grey[700], + shape: BoxShape.circle, + ), + ), + if (!isLast) + Expanded( + child: Container(width: 2, color: Colors.grey[800]), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: () => _showBlockDetail(block), + child: Container( + margin: EdgeInsets.only(bottom: isLast ? 0 : 12, right: 16, top: 4), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isActive ? kMainColor : const Color(0xFF1e1e1e), + borderRadius: BorderRadius.circular(12), + border: isActive ? null : Border.all(color: Colors.grey[800]!, width: 1), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: isActive ? Colors.white : Colors.white.withOpacity(0.9), + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + if (startStr.isNotEmpty && endStr.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + '$startStr – $endStr', + style: TextStyle( + color: isActive ? Colors.white70 : Colors.grey[600], + fontSize: 12, + ), + ), + ], + ], + ), + ), + if (isActive) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.25), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'En cours', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ) + else + Icon(Icons.chevron_right, color: Colors.grey[700], size: 18), + ], + ), + ), + ), + ), + ], + ), + ); + } + + void _showBlockDetail(ProgrammeBlock block) { + final title = _safeTranslate(block.title); + final desc = _safeTranslate(block.description); + final annotations = block.mapAnnotations ?? []; + + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1e1e1e), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + isScrollControlled: true, + builder: (_) => Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[700], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + if (desc.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + desc, + style: TextStyle(color: Colors.grey[400], fontSize: 14, height: 1.6), + ), + ], + if (annotations.isNotEmpty) ...[ + const SizedBox(height: 16), + Divider(color: Colors.grey[800]), + const SizedBox(height: 8), + ...annotations.map((a) { + final label = _safeTranslate(a.label); + if (label.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Icon(Icons.place, color: kMainColor, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 13), + ), + ), + ], + ), + ); + }), + ], + ], + ), + ), + ); + } + + // ─── Carte & Parcours ────────────────────────────────────────────────────── + + Widget _buildMapParcoursSection(BuildContext context, List annotations) { + final lat = double.tryParse(widget.section.latitude ?? ''); + final lng = double.tryParse(widget.section.longitude ?? ''); + final center = (lat != null && lng != null) ? LatLng(lat, lng) : const LatLng(50.85, 4.35); + + return Padding( + padding: const EdgeInsets.only(top: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionHeader(Icons.map_outlined, 'Carte & Parcours'), + const SizedBox(height: 12), + GestureDetector( + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => EventMapFullPage( + section: widget.section, + visitAppContextIn: widget.visitAppContextIn, + ), + )), + child: Container( + height: 220, + margin: const EdgeInsets.symmetric(horizontal: 16), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(16)), + child: Stack( + children: [ + FlutterMap( + options: MapOptions( + initialCenter: center, + initialZoom: 14, + interactionOptions: + const InteractionOptions(flags: InteractiveFlag.none), + ), + children: [ + TileLayer( + urlTemplate: + 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', + userAgentPackageName: 'be.unov.myinfomate.visitapp', + ), + if (annotations.isNotEmpty) ...[ + MarkerLayer(markers: _buildMarkers(annotations)), + PolylineLayer(polylines: _buildPolylines(annotations)), + ], + ], + ), + Positioned( + bottom: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.open_in_full, color: Colors.white, size: 12), + SizedBox(width: 4), + Text( + 'Agrandir', + style: TextStyle(color: Colors.white, fontSize: 11), + ), + ], + ), + ), + ), + ], + ), + ), + ), + if (_paths.isNotEmpty) ...[ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${_paths.length} parcours disponible${_paths.length > 1 ? 's' : ''}', + style: TextStyle(color: Colors.grey[400], fontSize: 13), + ), + GestureDetector( + onTap: () => _showParcoursList(context), + child: Text( + 'Voir tout', + style: TextStyle( + color: kMainColor, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + SizedBox( + height: 84, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), + scrollDirection: Axis.horizontal, + itemCount: _paths.length, + separatorBuilder: (_, __) => const SizedBox(width: 10), + itemBuilder: (_, i) => _buildParcoursChip(context, _paths[i]), + ), + ), + ], + ], + ), + ); + } + + List _buildMarkers(List annotations) { + final markers = []; + for (final ann in annotations) { + if (ann.geometryType?.value != 0) continue; + final coords = ann.geometry?.coordinates; + if (coords is! List || coords.length < 2) continue; + final lat = (coords[1] as num).toDouble(); + final lng = (coords[0] as num).toDouble(); + markers.add(Marker( + point: LatLng(lat, lng), + width: 30, + height: 30, + child: Icon(Icons.place, color: kMainColor, size: 30), + )); + } + return markers; + } + + List _buildPolylines(List annotations) { + final lines = []; + for (final ann in annotations) { + if (ann.geometryType?.value != 1) continue; + final coords = ann.geometry?.coordinates; + if (coords is! List) continue; + final points = []; + for (final c in coords) { + if (c is List && c.length >= 2) { + points.add(LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble())); + } + } + if (points.length < 2) continue; + final color = ann.polyColor != null ? _hexColor(ann.polyColor!) : kMainColor; + lines.add(Polyline(points: points, color: color, strokeWidth: 3.5)); + } + return lines; + } + + Color _hexColor(String hex) { + final v = int.tryParse('FF${hex.replaceAll('#', '')}', radix: 16); + return v != null ? Color(v) : kMainColor; + } + + Widget _buildParcoursChip(BuildContext context, GuidedPathDTO path) { + final title = _safeTranslate(path.title); + final stepCount = path.steps?.length ?? 0; + + return GestureDetector( + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => GuidedPathMapProgressionPage( + path: path, + mapDTO: _minimalMapDTO(), + visitAppContext: widget.visitAppContextIn, + ), + )), + child: Container( + width: 160, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF1e1e1e), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[800]!, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.route, color: kMainColor, size: 15), + const SizedBox(width: 5), + Text( + '$stepCount étape${stepCount > 1 ? 's' : ''}', + style: TextStyle(color: Colors.grey[500], fontSize: 11), + ), + ], + ), + const SizedBox(height: 6), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + void _showParcoursList(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1e1e1e), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[700], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Icon(Icons.route, size: 20, color: kMainColor), + const SizedBox(width: 8), + const Text( + 'Parcours disponibles', + style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + ), + ], + ), + const SizedBox(height: 8), + ..._paths.map((path) { + final title = _safeTranslate(path.title); + final desc = _safeTranslate(path.description); + final stepCount = path.steps?.length ?? 0; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: kMainColor.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: Icon(Icons.map_outlined, color: kMainColor, size: 20), + ), + title: Text( + title, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500), + ), + subtitle: Text( + desc.isNotEmpty ? desc : '$stepCount étape${stepCount > 1 ? 's' : ''}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.grey[500]), + ), + trailing: Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey[600]), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => GuidedPathMapProgressionPage( + path: path, + mapDTO: _minimalMapDTO(), + visitAppContext: widget.visitAppContextIn, + ), + )); + }, + ); + }), + ], + ), + ), + ); + } + + // ─── Description ─────────────────────────────────────────────────────────── + + Widget _buildDescriptionSection() { + final desc = _safeTranslate(widget.section.description); + if (desc.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 28, 16, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.info_outline, color: kMainColor, size: 20), + const SizedBox(width: 8), + const Text( + 'À propos', + style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 12), + Text(desc, style: TextStyle(color: Colors.grey[300], fontSize: 14, height: 1.6)), + ], + ), + ); + } + + // ─── Helpers ─────────────────────────────────────────────────────────────── + + Widget _sectionHeader(IconData icon, String label) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon(icon, color: kMainColor, size: 20), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold), + ), + ], + ), + ); + + String _safeTranslate(List? list) { + if (list == null || list.isEmpty) return ''; + try { + return TranslationHelper.get(list, widget.visitAppContextIn); + } catch (_) { + return list.first.value ?? ''; + } + } +} diff --git a/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart b/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart new file mode 100644 index 0000000..63b77fd --- /dev/null +++ b/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart @@ -0,0 +1,444 @@ +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:manager_api_new/api.dart'; +import 'dart:async'; + +import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; +import 'package:mymuseum_visitapp/Models/ResponseSubDTO.dart'; +import 'package:mymuseum_visitapp/Models/visitContext.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart'; + +/// Vue de progression centrée contenu (escape game, ou parcours sans carte). +class GuidedPathContentProgressionPage extends StatefulWidget { + final GuidedPathDTO path; + final VisitAppContext visitAppContext; + + const GuidedPathContentProgressionPage({ + Key? key, + required this.path, + required this.visitAppContext, + }) : super(key: key); + + @override + State createState() => + _GuidedPathContentProgressionPageState(); +} + +class _GuidedPathContentProgressionPageState + extends State { + late List _steps; + int _currentStepIndex = 0; + final Set _completedStepIds = {}; + + List _stepQuestions = []; + bool _quizCompleted = false; + bool _quizPassed = false; + + bool _inGeoZone = false; + StreamSubscription? _positionSub; + + @override + void initState() { + super.initState(); + _steps = [...(widget.path.steps ?? [])] + ..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); + _initStepState(); + _startLocationTracking(); + } + + @override + void dispose() { + _positionSub?.cancel(); + super.dispose(); + } + + // ─── State ──────────────────────────────────────────────────────────────── + + void _initStepState() { + final step = _currentStep; + if (step == null) return; + _quizCompleted = false; + _quizPassed = false; + _inGeoZone = false; + + if (step.quizQuestions?.isNotEmpty == true) { + _stepQuestions = step.quizQuestions! + .map((q) => QuestionSubDTO( + chosen: null, + label: q.label, + responsesSubDTO: ResponseSubDTO().fromJSON(q.responses), + resourceId: q.resourceId, + resourceUrl: q.resource?.url, + order: q.order, + )) + .toList(); + } else { + _stepQuestions = []; + } + } + + GuidedStepDTO? get _currentStep => + _steps.isEmpty ? null : _steps[_currentStepIndex]; + + String _translate(List? list) => + TranslationHelper.get(list, widget.visitAppContext); + + bool _hasGeoTrigger(GuidedStepDTO step) => + (step.geometry?.type == 'Point') || + (step.triggerGeoPointId != null && step.triggerGeoPoint != null); + + bool get _canAdvance { + final step = _currentStep; + if (step == null || step.isStepLocked == true) return false; + if (_hasGeoTrigger(step) && !_inGeoZone) return false; + if ((widget.path.requireSuccessToAdvance ?? false) && + step.quizQuestions?.isNotEmpty == true && + !_quizPassed) return false; + return true; + } + + void _advance() { + final step = _currentStep; + if (step == null || !_canAdvance) return; + if (step.id != null) _completedStepIds.add(step.id!); + if (_currentStepIndex < _steps.length - 1) { + setState(() { + _currentStepIndex++; + _initStepState(); + }); + } else { + _showCompletionDialog(); + } + } + + void _goBack() { + if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false)) { + setState(() { + _currentStepIndex--; + _initStepState(); + }); + } + } + + void _showCompletionDialog() { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Parcours terminé !'), + content: const Text('Vous avez complété toutes les étapes.'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + }, + child: const Text('Fermer'), + ), + ], + ), + ); + } + + // ─── Géoloc ─────────────────────────────────────────────────────────────── + + Future _startLocationTracking() async { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return; + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) return; + } + if (permission == LocationPermission.deniedForever) return; + + _positionSub = Geolocator.getPositionStream( + locationSettings: + const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5), + ).listen((pos) => _checkGeoZone(LatLng(pos.latitude, pos.longitude))); + } + + void _checkGeoZone(LatLng position) { + final step = _currentStep; + if (step == null || !_hasGeoTrigger(step)) return; + + LatLng? center; + double radius = step.zoneRadiusMeters ?? 30; + + if (step.geometry?.type == 'Point') { + final c = step.geometry!.coordinates as List; + center = LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble()); + } else if (step.triggerGeoPoint?.geometry?.type == 'Point') { + final c = step.triggerGeoPoint!.geometry!.coordinates as List; + center = LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble()); + } + + if (center == null) return; + final dist = const Distance().distance(position, center); + if ((dist <= radius) != _inGeoZone) { + setState(() => _inGeoZone = dist <= radius); + } + } + + // ─── Build ──────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final step = _currentStep; + if (step == null) return const Scaffold(body: Center(child: Text('Aucune étape'))); + + final title = _translate(step.title); + final desc = _translate(step.description); + final hasQuiz = step.quizQuestions?.isNotEmpty == true; + final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0; + final hasGeo = _hasGeoTrigger(step); + final isLocked = step.isStepLocked == true; + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + // ── Header ────────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Progress indicator + Text( + 'Étape ${_currentStepIndex + 1} / ${_steps.length}', + style: TextStyle(color: Colors.grey[600], fontSize: 13), + ), + ], + ), + ), + + // Dot progress + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_steps.length, (i) { + final done = _completedStepIds.contains(_steps[i].id); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: done + ? Colors.green + : i == _currentStepIndex + ? Colors.blue + : Colors.grey[300], + ), + ), + ); + }), + ), + ), + + // ── Contenu scrollable ─────────────────────────────────────── + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image + if (step.imageUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + step.imageUrl!, + height: 200, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + + if (step.imageUrl != null) const SizedBox(height: 16), + + // Locked + if (isLocked) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.lock, color: Colors.grey), + const SizedBox(width: 8), + Text('Étape verrouillée', style: TextStyle(color: Colors.grey[600])), + ], + ), + ), + + // Description + if (desc.isNotEmpty && !isLocked) + Text(desc, style: const TextStyle(fontSize: 15, height: 1.6)), + + if (desc.isNotEmpty) const SizedBox(height: 16), + + // Timer (barre) + if (hasTimer && !isLocked) + GuidedStepTimer( + key: ValueKey(step.id), + seconds: step.timerSeconds!, + expiredMessage: _translate(step.timerExpiredMessage), + showAsBar: true, + onExpired: () => setState(() {}), + ), + + if (hasTimer) const SizedBox(height: 16), + + // Géo + if (hasGeo && !isLocked) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _inGeoZone + ? Colors.green.withOpacity(0.1) + : Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _inGeoZone ? Colors.green : Colors.orange), + ), + child: Row( + children: [ + Icon( + _inGeoZone ? Icons.location_on : Icons.location_searching, + color: _inGeoZone ? Colors.green : Colors.orange, + size: 18, + ), + const SizedBox(width: 8), + Text( + _inGeoZone + ? 'Vous êtes dans la zone ✓' + : 'Approchez-vous du point indiqué', + style: TextStyle( + color: _inGeoZone ? Colors.green[700] : Colors.orange[700], + fontSize: 13, + ), + ), + ], + ), + ), + + if (hasGeo) const SizedBox(height: 16), + + // Quiz + if (hasQuiz && !isLocked && !_quizCompleted) + SizedBox( + height: 340, + child: QuestionsListWidget( + questionsSubDTO: _stepQuestions, + isShowResponse: false, + onShowResponse: () { + final good = _stepQuestions + .where((q) => + q.chosen != null && + q.chosen == + q.responsesSubDTO! + .indexWhere((r) => r.isGood == true)) + .length; + setState(() { + _quizCompleted = true; + _quizPassed = good == _stepQuestions.length; + }); + }, + orientation: MediaQuery.of(context).orientation, + ), + ), + + if (hasQuiz && _quizCompleted) + Row( + children: [ + Icon( + _quizPassed ? Icons.check_circle : Icons.cancel, + color: _quizPassed ? Colors.green : Colors.red, + ), + const SizedBox(width: 8), + Text( + _quizPassed ? 'Quiz réussi !' : 'Quiz échoué', + style: TextStyle( + color: _quizPassed ? Colors.green : Colors.red, + fontWeight: FontWeight.w500, + ), + ), + if (!_quizPassed) ...[ + const Spacer(), + TextButton( + onPressed: () => setState(() { + _quizCompleted = false; + _quizPassed = false; + _stepQuestions = _currentStep!.quizQuestions! + .map((q) => QuestionSubDTO( + chosen: null, + label: q.label, + responsesSubDTO: + ResponseSubDTO().fromJSON(q.responses), + resourceId: q.resourceId, + resourceUrl: q.resource?.url, + order: q.order, + )) + .toList(); + }), + child: const Text('Réessayer'), + ), + ], + ], + ), + ], + ), + ), + ), + + // ── Navigation ─────────────────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Row( + children: [ + if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false)) + OutlinedButton.icon( + onPressed: _goBack, + icon: const Icon(Icons.arrow_back, size: 16), + label: const Text('Précédent'), + ), + const Spacer(), + FilledButton.icon( + onPressed: _canAdvance ? _advance : null, + icon: Icon( + _currentStepIndex < _steps.length - 1 + ? Icons.arrow_forward + : Icons.flag, + size: 16, + ), + label: Text( + _currentStepIndex < _steps.length - 1 ? 'Suivant' : 'Terminer', + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart b/lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart new file mode 100644 index 0000000..4c6cc9f --- /dev/null +++ b/lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:manager_api_new/api.dart'; +import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; +import 'package:mymuseum_visitapp/Models/visitContext.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart'; + +class GuidedPathListSheet extends StatelessWidget { + final List paths; + final MapDTO mapDTO; + final VisitAppContext visitAppContext; + + const GuidedPathListSheet({ + Key? key, + required this.paths, + required this.mapDTO, + required this.visitAppContext, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final sorted = [...paths]..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); + + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: const [ + Icon(Icons.route, size: 20), + SizedBox(width: 8), + Text('Parcours disponibles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 8), + ...sorted.map((path) => _buildTile(context, path)), + ], + ), + ); + } + + Widget _buildTile(BuildContext context, GuidedPathDTO path) { + final title = TranslationHelper.get(path.title, visitAppContext); + final desc = TranslationHelper.get(path.description, visitAppContext); + final stepCount = path.steps?.length ?? 0; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon(Icons.map_outlined, color: Colors.blue, size: 20), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: desc.isNotEmpty + ? Text(desc, maxLines: 1, overflow: TextOverflow.ellipsis) + : Text('$stepCount étape${stepCount > 1 ? 's' : ''}', style: TextStyle(color: Colors.grey[600])), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$stepCount', style: TextStyle(color: Colors.grey[600], fontSize: 13)), + const SizedBox(width: 2), + Icon(Icons.flag_outlined, size: 14, color: Colors.grey[600]), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward_ios, size: 14), + ], + ), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => GuidedPathMapProgressionPage( + path: path, + mapDTO: mapDTO, + visitAppContext: visitAppContext, + ), + )); + }, + ); + } +} diff --git a/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart b/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart new file mode 100644 index 0000000..22703aa --- /dev/null +++ b/lib/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart @@ -0,0 +1,760 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:manager_api_new/api.dart'; +import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; +import 'package:mymuseum_visitapp/Models/ResponseSubDTO.dart'; +import 'package:mymuseum_visitapp/Models/visitContext.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart'; +import 'package:mymuseum_visitapp/constants.dart'; + +class GuidedPathMapProgressionPage extends StatefulWidget { + final GuidedPathDTO path; + final MapDTO mapDTO; + final VisitAppContext visitAppContext; + + const GuidedPathMapProgressionPage({ + Key? key, + required this.path, + required this.mapDTO, + required this.visitAppContext, + }) : super(key: key); + + @override + State createState() => _GuidedPathMapProgressionPageState(); +} + +class _GuidedPathMapProgressionPageState extends State + with SingleTickerProviderStateMixin { + late List _steps; + int _currentStepIndex = 0; + final Set _completedStepIds = {}; + + // Quiz state for current step + List _stepQuestions = []; + bool _quizCompleted = false; + bool _quizPassed = false; + + // Geo trigger state + LatLng? _userPosition; + bool _inGeoZone = false; + StreamSubscription? _positionSub; + + // Map + final MapController _mapController = MapController(); + + // Pulsing animation for current step marker + late AnimationController _pulseController; + late Animation _pulseAnimation; + + @override + void initState() { + super.initState(); + _steps = [...(widget.path.steps ?? [])] + ..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); + + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + _pulseAnimation = Tween(begin: 1.0, end: 1.4).animate( + CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), + ); + + _initStepState(); + _startLocationTracking(); + } + + @override + void dispose() { + _positionSub?.cancel(); + _pulseController.dispose(); + super.dispose(); + } + + // ─── Navigation ────────────────────────────────────────────────────────── + + void _initStepState() { + final step = _currentStep; + if (step == null) return; + + _quizCompleted = false; + _quizPassed = false; + _inGeoZone = false; + + if (step.quizQuestions?.isNotEmpty == true) { + _stepQuestions = step.quizQuestions!.map((q) => QuestionSubDTO( + chosen: null, + label: q.label, + responsesSubDTO: ResponseSubDTO().fromJSON(q.responses), + resourceId: q.resourceId, + resourceUrl: q.resource?.url, + order: q.order, + )).toList(); + } else { + _stepQuestions = []; + } + + // Re-check geo zone with current position + if (_userPosition != null) _checkGeoZone(_userPosition!); + + // Center map on step if it has geometry + _centerMapOnCurrentStep(); + } + + void _centerMapOnCurrentStep() { + final coords = _currentStep?.geometry?.type == 'Point' + ? _currentStep!.geometry!.coordinates as List? + : null; + if (coords != null && coords.length >= 2) { + final lat = (coords[1] as num).toDouble(); + final lon = (coords[0] as num).toDouble(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _mapController.move(LatLng(lat, lon), _mapController.camera.zoom); + }); + } + } + + bool get _canAdvance { + final step = _currentStep; + if (step == null) return false; + if (step.isStepLocked == true) return false; + + // Geo trigger required + if (_hasGeoTrigger(step) && !_inGeoZone) return false; + + // Quiz required to pass + if ((widget.path.requireSuccessToAdvance ?? false) && + step.quizQuestions?.isNotEmpty == true && + !_quizPassed) return false; + + return true; + } + + void _advance() { + final step = _currentStep; + if (step == null || !_canAdvance) return; + if (step.id != null) _completedStepIds.add(step.id!); + + if (_currentStepIndex < _steps.length - 1) { + setState(() { + _currentStepIndex++; + _initStepState(); + }); + } else { + _showCompletionDialog(); + } + } + + void _goBack() { + if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false)) { + setState(() { + _currentStepIndex--; + _initStepState(); + }); + } + } + + void _showCompletionDialog() { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Parcours terminé !'), + content: const Text('Vous avez complété toutes les étapes.'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + Navigator.of(context).pop(); + }, + child: const Text('Fermer'), + ), + ], + ), + ); + } + + // ─── Geo tracking ──────────────────────────────────────────────────────── + + Future _startLocationTracking() async { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) return; + } + if (permission == LocationPermission.deniedForever) return; + + _positionSub = Geolocator.getPositionStream( + locationSettings: const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5), + ).listen((pos) { + final newPos = LatLng(pos.latitude, pos.longitude); + setState(() => _userPosition = newPos); + _checkGeoZone(newPos); + }); + } + + void _checkGeoZone(LatLng position) { + final step = _currentStep; + if (step == null || !_hasGeoTrigger(step)) return; + + LatLng? triggerCenter; + double radius = step.zoneRadiusMeters ?? 30; + + if (step.geometry?.type == 'Point') { + final coords = step.geometry!.coordinates as List; + triggerCenter = LatLng((coords[1] as num).toDouble(), (coords[0] as num).toDouble()); + } else if (step.triggerGeoPoint?.geometry?.type == 'Point') { + final coords = step.triggerGeoPoint!.geometry!.coordinates as List; + triggerCenter = LatLng((coords[1] as num).toDouble(), (coords[0] as num).toDouble()); + } + + if (triggerCenter == null) return; + + final distMeters = const Distance().distance(position, triggerCenter); + final inZone = distMeters <= radius; + if (inZone != _inGeoZone) { + setState(() => _inGeoZone = inZone); + } + } + + bool _hasGeoTrigger(GuidedStepDTO step) => + (step.geometry?.type == 'Point') || + (step.triggerGeoPointId != null && step.triggerGeoPoint != null); + + // ─── Helpers ───────────────────────────────────────────────────────────── + + GuidedStepDTO? get _currentStep => + _steps.isEmpty ? null : _steps[_currentStepIndex]; + + String _translate(List? list) => + TranslationHelper.get(list, widget.visitAppContext); + + // ─── Map ───────────────────────────────────────────────────────────────── + + List _buildStepMarkers() { + final markers = []; + for (int i = 0; i < _steps.length; i++) { + final step = _steps[i]; + final isCompleted = _completedStepIds.contains(step.id); + final isCurrent = i == _currentStepIndex; + final hideNext = widget.path.hideNextStepsUntilComplete ?? false; + final isVisible = !hideNext || isCompleted || isCurrent; + if (!isVisible) continue; + + final coords = step.geometry?.type == 'Point' && step.geometry?.coordinates is List + ? step.geometry!.coordinates as List + : null; + if (coords == null || coords.length < 2) continue; + + final lat = (coords[1] as num).toDouble(); + final lon = (coords[0] as num).toDouble(); + + markers.add(Marker( + point: LatLng(lat, lon), + width: 44, + height: 44, + child: GestureDetector( + onTap: isCurrent ? null : null, // tapping non-current steps could scroll sheet + child: isCurrent + ? AnimatedBuilder( + animation: _pulseAnimation, + builder: (_, __) => Transform.scale( + scale: _pulseAnimation.value, + child: const _StepPin(state: _StepPinState.current), + ), + ) + : _StepPin( + state: isCompleted + ? _StepPinState.completed + : step.isStepLocked == true + ? _StepPinState.locked + : _StepPinState.upcoming, + ), + ), + )); + } + return markers; + } + + Marker? _buildUserMarker() { + if (_userPosition == null) return null; + return Marker( + point: _userPosition!, + width: 36, + height: 36, + child: Container( + decoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [BoxShadow(color: Colors.blue.withOpacity(0.4), blurRadius: 8, spreadRadius: 2)], + ), + child: const Icon(Icons.person, color: Colors.white, size: 18), + ), + ); + } + + // ─── Build ─────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final center = widget.mapDTO.latitude != null && widget.mapDTO.longitude != null + ? LatLng(double.tryParse(widget.mapDTO.latitude!)!, double.tryParse(widget.mapDTO.longitude!)!) + : const LatLng(50.465503, 4.865105); + final zoom = widget.mapDTO.zoom?.toDouble() ?? 15.0; + + final userMarker = _buildUserMarker(); + final stepMarkers = _buildStepMarkers(); + + return Scaffold( + body: Stack( + children: [ + // ── Carte plein écran ────────────────────────────────────────── + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: center, + initialZoom: zoom, + ), + children: [ + TileLayer( + urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', + userAgentPackageName: 'be.unov.myinfomate.visitapp', + ), + MarkerLayer( + markers: [ + ...stepMarkers, + if (userMarker != null) userMarker, + ], + ), + ], + ), + + // ── Bouton retour ────────────────────────────────────────────── + Positioned( + top: MediaQuery.of(context).padding.top + 10, + left: 10, + child: _CircleButton( + icon: Icons.arrow_back, + onTap: () => Navigator.of(context).pop(), + ), + ), + + // ── Badge progression ────────────────────────────────────────── + Positioned( + top: MediaQuery.of(context).padding.top + 10, + right: 10, + child: _ProgressBadge( + current: _currentStepIndex + 1, + total: _steps.length, + completedIds: _completedStepIds, + steps: _steps, + ), + ), + + // ── Bottom sheet drag ────────────────────────────────────────── + DraggableScrollableSheet( + initialChildSize: 0.22, + minChildSize: 0.12, + maxChildSize: 0.75, + snap: true, + snapSizes: const [0.22, 0.75], + builder: (_, scrollController) => _buildSheet(scrollController), + ), + ], + ), + ); + } + + Widget _buildSheet(ScrollController scrollController) { + final step = _currentStep; + if (step == null) return const SizedBox(); + + final title = _translate(step.title); + final desc = _translate(step.description); + final hasGeo = _hasGeoTrigger(step); + final hasQuiz = step.quizQuestions?.isNotEmpty == true; + final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0; + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10)], + ), + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Handle + Center( + child: Padding( + padding: const EdgeInsets.only(top: 10, bottom: 6), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + + // ── Peek row ──────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + if (hasGeo && !_inGeoZone) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.location_searching, size: 14, color: Colors.orange[700]), + const SizedBox(width: 4), + Text( + 'Zone requise', + style: TextStyle(fontSize: 12, color: Colors.orange[700]), + ), + ], + ), + if (hasTimer) + Padding( + padding: const EdgeInsets.only(left: 8), + child: GuidedStepTimer( + key: ValueKey(step.id), + seconds: step.timerSeconds!, + expiredMessage: _translate(step.timerExpiredMessage), + showAsBar: false, + ), + ), + ], + ), + ), + + const Divider(height: 20), + + // ── Détail étendu ─────────────────────────────────────── + + // Image + if (step.imageUrl != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + step.imageUrl!, + height: 160, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + ), + + // Locked + if (step.isStepLocked == true) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + const Icon(Icons.lock, color: Colors.grey), + const SizedBox(width: 8), + Text('Étape verrouillée', style: TextStyle(color: Colors.grey[600])), + ], + ), + ), + + // Description + if (desc.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text(desc, style: const TextStyle(fontSize: 14, height: 1.5)), + ), + + // Timer (mode barre, dans la version étendue) + if (hasTimer) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: GuidedStepTimer( + key: ValueKey('bar_${step.id}'), + seconds: step.timerSeconds!, + expiredMessage: _translate(step.timerExpiredMessage), + showAsBar: true, + ), + ), + + // Géo trigger + if (hasGeo) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: _GeoZoneIndicator(inZone: _inGeoZone), + ), + + // Quiz + if (hasQuiz && !_quizCompleted) + SizedBox( + height: 340, + child: QuestionsListWidget( + questionsSubDTO: _stepQuestions, + isShowResponse: false, + onShowResponse: () { + final goodCount = _stepQuestions.where((q) => + q.chosen != null && + q.chosen == q.responsesSubDTO!.indexWhere((r) => r.isGood == true), + ).length; + setState(() { + _quizCompleted = true; + _quizPassed = goodCount == _stepQuestions.length; + }); + }, + orientation: MediaQuery.of(context).orientation, + ), + ), + + if (hasQuiz && _quizCompleted) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Row( + children: [ + Icon( + _quizPassed ? Icons.check_circle : Icons.cancel, + color: _quizPassed ? Colors.green : Colors.red, + ), + const SizedBox(width: 8), + Text( + _quizPassed ? 'Quiz réussi !' : 'Quiz échoué', + style: TextStyle( + color: _quizPassed ? Colors.green : Colors.red, + fontWeight: FontWeight.w500, + ), + ), + if (!_quizPassed) ...[ + const Spacer(), + TextButton( + onPressed: () => setState(() { + _quizCompleted = false; + _quizPassed = false; + _stepQuestions = _currentStep!.quizQuestions!.map((q) => QuestionSubDTO( + chosen: null, + label: q.label, + responsesSubDTO: ResponseSubDTO().fromJSON(q.responses), + resourceId: q.resourceId, + resourceUrl: q.resource?.url, + order: q.order, + )).toList(); + }), + child: const Text('Réessayer'), + ), + ], + ], + ), + ), + + // ── Boutons navigation ─────────────────────────────────── + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), + child: Row( + children: [ + if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false)) + OutlinedButton.icon( + onPressed: _goBack, + icon: const Icon(Icons.arrow_back, size: 16), + label: const Text('Précédent'), + ), + const Spacer(), + FilledButton.icon( + onPressed: _canAdvance ? _advance : null, + icon: Icon( + _currentStepIndex < _steps.length - 1 + ? Icons.arrow_forward + : Icons.flag, + size: 16, + ), + label: Text( + _currentStepIndex < _steps.length - 1 ? 'Suivant' : 'Terminer', + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ─── Widgets auxiliaires ─────────────────────────────────────────────────── + +enum _StepPinState { current, completed, upcoming, locked } + +class _StepPin extends StatelessWidget { + final _StepPinState state; + const _StepPin({required this.state}); + + @override + Widget build(BuildContext context) { + switch (state) { + case _StepPinState.completed: + return const CircleAvatar( + backgroundColor: Colors.green, + radius: 18, + child: Icon(Icons.check, color: Colors.white, size: 18), + ); + case _StepPinState.current: + return const CircleAvatar( + backgroundColor: Colors.blue, + radius: 18, + child: Icon(Icons.location_on, color: Colors.white, size: 18), + ); + case _StepPinState.locked: + return CircleAvatar( + backgroundColor: Colors.grey[400], + radius: 18, + child: const Icon(Icons.lock, color: Colors.white, size: 16), + ); + case _StepPinState.upcoming: + return CircleAvatar( + backgroundColor: Colors.grey[300], + radius: 18, + child: Icon(Icons.location_on, color: Colors.grey[600], size: 18), + ); + } + } +} + +class _CircleButton extends StatelessWidget { + final IconData icon; + final VoidCallback onTap; + const _CircleButton({required this.icon, required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6)], + ), + child: Icon(icon, size: 22), + ), + ); + } +} + +class _ProgressBadge extends StatelessWidget { + final int current; + final int total; + final Set completedIds; + final List steps; + + const _ProgressBadge({ + required this.current, + required this.total, + required this.completedIds, + required this.steps, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6)], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$current / $total', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(total, (i) { + final isCompleted = steps[i].id != null && completedIds.contains(steps[i].id); + final isCurrent = i == current - 1; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isCompleted + ? Colors.green + : isCurrent + ? Colors.blue + : Colors.grey[300], + ), + ), + ); + }), + ), + ], + ), + ); + } +} + +class _GeoZoneIndicator extends StatelessWidget { + final bool inZone; + const _GeoZoneIndicator({required this.inZone}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: inZone ? Colors.green.withOpacity(0.1) : Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: inZone ? Colors.green : Colors.orange), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + inZone ? Icons.location_on : Icons.location_searching, + color: inZone ? Colors.green : Colors.orange, + size: 18, + ), + const SizedBox(width: 8), + Text( + inZone ? 'Vous êtes dans la zone ✓' : 'Approchez-vous du point indiqué', + style: TextStyle( + color: inZone ? Colors.green[700] : Colors.orange[700], + fontSize: 13, + ), + ), + ], + ), + ); + } +} diff --git a/lib/Screens/Sections/GuidedPath/guided_step_timer.dart b/lib/Screens/Sections/GuidedPath/guided_step_timer.dart new file mode 100644 index 0000000..433c1f7 --- /dev/null +++ b/lib/Screens/Sections/GuidedPath/guided_step_timer.dart @@ -0,0 +1,108 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +class GuidedStepTimer extends StatefulWidget { + final int seconds; + final String expiredMessage; + final VoidCallback? onExpired; + /// true = barre de progression + texte MM:SS (mode escape) + /// false = texte MM:SS seul (mode peek carte) + final bool showAsBar; + + const GuidedStepTimer({ + Key? key, + required this.seconds, + required this.expiredMessage, + this.onExpired, + this.showAsBar = false, + }) : super(key: key); + + @override + State createState() => _GuidedStepTimerState(); +} + +class _GuidedStepTimerState extends State { + late int _remaining; + Timer? _timer; + bool _expired = false; + + @override + void initState() { + super.initState(); + _remaining = widget.seconds; + _timer = Timer.periodic(const Duration(seconds: 1), _tick); + } + + void _tick(Timer t) { + if (!mounted) return; + if (_remaining <= 0) { + _timer?.cancel(); + setState(() => _expired = true); + widget.onExpired?.call(); + return; + } + setState(() => _remaining--); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_expired) { + return Text( + widget.expiredMessage, + style: const TextStyle(color: Colors.red, fontWeight: FontWeight.bold), + ); + } + + final timeText = _formatTime(_remaining); + + if (widget.showAsBar) { + final progress = widget.seconds > 0 ? _remaining / widget.seconds : 0.0; + final color = progress > 0.5 + ? Colors.green + : progress > 0.25 + ? Colors.orange + : Colors.red; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const Icon(Icons.timer, size: 18), + const SizedBox(width: 6), + Text(timeText, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + ], + ), + const SizedBox(height: 4), + LinearProgressIndicator( + value: progress, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation(color), + minHeight: 8, + borderRadius: BorderRadius.circular(4), + ), + ], + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.timer, size: 14), + const SizedBox(width: 4), + Text(timeText, style: const TextStyle(fontSize: 13)), + ], + ); + } + + String _formatTime(int s) { + final m = s ~/ 60; + final sec = s % 60; + return '${m.toString().padLeft(2, '0')}:${sec.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/Screens/Sections/Map/map_page.dart b/lib/Screens/Sections/Map/map_page.dart index d67d962..1e92a86 100644 --- a/lib/Screens/Sections/Map/map_page.dart +++ b/lib/Screens/Sections/Map/map_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Map/flutter_map_view.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_list_sheet.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Map/geo_point_filter.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Map/map_box_view.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Map/marker_view.dart'; @@ -145,6 +146,41 @@ class _MapPage extends State { ) ), ), + if (mapDTO?.guidedPaths?.isNotEmpty == true) + Positioned( + top: 35, + right: 10, + child: InkWell( + onTap: () { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => GuidedPathListSheet( + paths: mapDTO!.guidedPaths!, + mapDTO: mapDTO!, + visitAppContext: visitAppContext, + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: primaryColor, + borderRadius: BorderRadius.circular(25), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.route, size: 18, color: Colors.white), + SizedBox(width: 6), + Text('Parcours', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500)), + ], + ), + ), + ), + ), ] /*floatingActionButton: FloatingActionButton.extended( onPressed: _goToTheLake, diff --git a/lib/Screens/section_page.dart b/lib/Screens/section_page.dart index d796ae4..04ae67b 100644 --- a/lib/Screens/section_page.dart +++ b/lib/Screens/section_page.dart @@ -23,6 +23,7 @@ import 'package:mymuseum_visitapp/Screens/Sections/Quiz/quizz_page.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Slider/slider_page.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Video/video_page.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Weather/weather_page.dart'; +import 'package:mymuseum_visitapp/Screens/Sections/Event/event_page.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Web/web_page.dart'; import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/client.dart'; @@ -118,6 +119,7 @@ class _SectionPageState extends State { articleDTO: articleDTO, resourcesModel: resourcesModel, mainAudioId: mainAudioId, + sectionId: widget.sectionId, ); case SectionType.Map: MapDTO mapDTO = MapDTO.fromJson(sectionResult)!; @@ -154,6 +156,9 @@ class _SectionPageState extends State { case SectionType.Web: WebDTO webDTO = WebDTO.fromJson(sectionResult)!; return WebPage(section: webDTO); + case SectionType.Event: + SectionEventDTO eventDTO = SectionEventDTO.fromJson(sectionResult)!; + return EventPage(section: eventDTO, visitAppContextIn: visitAppContext); default: return const Center(child: Text("Unsupported type")); } @@ -170,7 +175,7 @@ class _SectionPageState extends State { Future getSectionDetail(AppContext appContext, Client client, String sectionId) async { try { - bool isConfigOffline = widget.configuration.isOffline!; + bool isConfigOffline = widget.configuration.isOffline == true; if(widget.rawSection == null) { if(isConfigOffline) { @@ -286,6 +291,8 @@ class _SectionPageState extends State { MapDTO mapDTO = MapDTO.fromJson(rawSectionData)!; icons = await getByteIcons(visitAppContext, mapDTO); break; + case SectionType.Event: + break; default: break; } diff --git a/lib/Screens/splash_screen.dart b/lib/Screens/splash_screen.dart new file mode 100644 index 0000000..25610d4 --- /dev/null +++ b/lib/Screens/splash_screen.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:mymuseum_visitapp/constants.dart'; + +class SplashScreen extends StatefulWidget { + const SplashScreen({Key? key}) : super(key: key); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + )..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + return Scaffold( + backgroundColor: kBackgroundColor, + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + kSplashLogoAsset, + width: 200, + height: 200, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Icon( + Icons.museum_outlined, + size: 100, + color: kMainColor1, + ), + ), + const SizedBox(height: 48), + RotationTransition( + turns: Tween(begin: 0.0, end: 3.0).animate(_controller), + child: Image.asset( + kLoaderAsset, + width: size.height * 0.07, + height: size.height * 0.07, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + CircularProgressIndicator(color: kMainColor1), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/Services/assistantService.dart b/lib/Services/assistantService.dart index ca7631d..7f698b8 100644 --- a/lib/Services/assistantService.dart +++ b/lib/Services/assistantService.dart @@ -1,16 +1,7 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Models/AssistantResponse.dart'; -class AiChatMessage { - final String role; - final String content; - AiChatMessage({required this.role, required this.content}); - Map toJson() => {'role': role, 'content': content}; -} - class AssistantService { final VisitAppContext visitAppContext; final List history = []; @@ -21,33 +12,46 @@ class AssistantService { required String message, String? configurationId, }) async { - final baseUrl = visitAppContext.clientAPI.apiApi?.basePath ?? 'https://api.mymuseum.be'; - - final body = { - 'message': message, - 'instanceId': visitAppContext.instanceId, - 'appType': 'Mobile', - 'configurationId': configurationId, - 'language': visitAppContext.language?.toUpperCase() ?? 'FR', - 'history': history.map((m) => m.toJson()).toList(), - }; - - final response = await http.post( - Uri.parse('$baseUrl/api/ai/chat'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(body), + final request = AiChatRequest( + message: message, + instanceId: visitAppContext.instanceId, + appType: AppType.Mobile, + configurationId: configurationId, + language: visitAppContext.language?.toUpperCase() ?? 'FR', + history: List.from(history), ); - if (response.statusCode == 200) { - final data = jsonDecode(response.body) as Map; - final result = AssistantResponse.fromJson(data); - history.add(AiChatMessage(role: 'user', content: message)); - history.add(AiChatMessage(role: 'assistant', content: result.reply)); - return result; - } else { - throw Exception('Erreur assistant: ${response.statusCode}'); + final response = await visitAppContext.clientAPI.aiApi!.aiChat(request); + + if (response == null) { + throw Exception('Empty response from assistant'); } + + print("AI raw response: reply='${response.reply}' navigation.sectionId='${response.navigation?.sectionId}' navigation.sectionTitle='${response.navigation?.sectionTitle}' navigation.sectionType='${response.navigation?.sectionType}' cards=${response.cards?.length}"); + + final result = AssistantResponse( + reply: response.reply ?? '', + cards: response.cards + ?.map((c) => AiCard( + title: c.title ?? '', + subtitle: c.subtitle ?? '', + icon: c.icon, + )) + .toList(), + navigation: response.navigation != null + ? AssistantNavigationAction( + sectionId: response.navigation!.sectionId ?? '', + sectionTitle: response.navigation!.sectionTitle ?? '', + sectionType: response.navigation!.sectionType ?? '', + imageUrl: response.navigation!.imageUrl, + ) + : null, + ); + + history.add(AiChatMessage(role: 'user', content: message)); + history.add(AiChatMessage(role: 'assistant', content: result.reply)); + return result; } void clearHistory() => history.clear(); -} +} \ No newline at end of file diff --git a/lib/Services/pushNotificationService.dart b/lib/Services/pushNotificationService.dart new file mode 100644 index 0000000..8f62a30 --- /dev/null +++ b/lib/Services/pushNotificationService.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +/// Handles background FCM messages (must be top-level function) +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // Firebase is already initialized in main — nothing to do here, + // the OS will display the notification automatically. +} + +class PushNotificationService { + static final FlutterLocalNotificationsPlugin _localNotifications = + FlutterLocalNotificationsPlugin(); + + /// Notifier set when the user taps a notification (background or terminated). + static final ValueNotifier tappedMessage = ValueNotifier(null); + + /// Call once after Firebase.initializeApp(), passing the instanceId. + static Future initialize(String instanceId) async { + // Register background handler + FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); + + // Request permission (iOS / Android 13+) + await FirebaseMessaging.instance.requestPermission( + alert: true, + badge: true, + sound: true, + ); + + // Configure local notifications for foreground display + const androidSettings = + AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosSettings = DarwinInitializationSettings(); + await _localNotifications.initialize( + const InitializationSettings(android: androidSettings, iOS: iosSettings), + ); + + // Subscribe to the instance topic + await subscribeToInstance(instanceId); + + // Show local notification when app is in foreground + FirebaseMessaging.onMessage.listen((RemoteMessage message) { + _showLocalNotification(message); + }); + + // App opened from background by tapping a notification + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { + tappedMessage.value = message; + }); + + // App launched from terminated state by tapping a notification + final initial = await FirebaseMessaging.instance.getInitialMessage(); + if (initial != null) { + Future.delayed(const Duration(milliseconds: 500), () { + tappedMessage.value = initial; + }); + } + } + + static Future subscribeToInstance(String instanceId) async { + await FirebaseMessaging.instance + .subscribeToTopic('instance_$instanceId'); + } + + static Future unsubscribeFromInstance(String instanceId) async { + await FirebaseMessaging.instance + .unsubscribeFromTopic('instance_$instanceId'); + } + + static Future _showLocalNotification(RemoteMessage message) async { + final notification = message.notification; + if (notification == null) return; + + const androidDetails = AndroidNotificationDetails( + 'push_notifications', + 'Push Notifications', + channelDescription: 'Notifications from the museum', + importance: Importance.high, + priority: Priority.high, + ); + const iosDetails = DarwinNotificationDetails(); + + await _localNotifications.show( + notification.hashCode, + notification.title, + notification.body, + const NotificationDetails(android: androidDetails, iOS: iosDetails), + ); + } +} diff --git a/lib/client.dart b/lib/client.dart index 9faa111..bc344c5 100644 --- a/lib/client.dart +++ b/lib/client.dart @@ -34,6 +34,18 @@ class Client { StatsApi? _statsApi; StatsApi? get statsApi => _statsApi; + AIApi? _aiApi; + AIApi? get aiApi => _aiApi; + + SectionAgendaApi? _sectionAgendaApi; + SectionAgendaApi? get sectionAgendaApi => _sectionAgendaApi; + + SectionMapApi? _sectionMapApi; + SectionMapApi? get sectionMapApi => _sectionMapApi; + + SectionEventApi? _sectionEventApi; + SectionEventApi? get sectionEventApi => _sectionEventApi; + Client(String path, {String? apiKey}) { _apiClient = ApiClient(basePath: path); if (apiKey != null) _apiClient!.addDefaultHeader('X-Api-Key', apiKey); @@ -46,5 +58,9 @@ class Client { _instanceApi = InstanceApi(_apiClient); _applicationInstanceApi = ApplicationInstanceApi(_apiClient); _statsApi = StatsApi(_apiClient); + _aiApi = AIApi(_apiClient); + _sectionAgendaApi = SectionAgendaApi(_apiClient); + _sectionMapApi = SectionMapApi(_apiClient); + _sectionEventApi = SectionEventApi(_apiClient); } } \ No newline at end of file diff --git a/lib/constants.dart b/lib/constants.dart index 20fa5df..5ae10b4 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -1,43 +1,90 @@ import 'package:flutter/material.dart'; -// API configuration -const kApiBaseUrl = 'http://192.168.31.228:5000'; // Replace with production URL -const kInstancePinCode = ''; // TODO: fill in the instance PIN code -const kInstanceId = '63514fd67ed8c735aaa4b8f2'; // TODO: fill in the instance ID +// API configuration — injectées au build via --dart-define +// Ex: flutter build appbundle --flavor mdlf --dart-define=INSTANCE_ID=65ccc67265373befd15be511 --dart-define=API_BASE_URL=https://api.mymuseum.be --dart-define=API_KEY=xxxx +const kApiBaseUrl = String.fromEnvironment('API_BASE_URL', + defaultValue: 'http://192.168.31.228:5000'); +const kApiKey = String.fromEnvironment('API_KEY', defaultValue: ''); +const kInstanceId = String.fromEnvironment('INSTANCE_ID', + defaultValue: '63514fd67ed8c735aaa4b8f2'); -// Colors - TO FILL WITH CORRECT COLOR +// Flavor injecté au build via --dart-define=FLAVOR=mdlf +const _flavor = String.fromEnvironment('FLAVOR', defaultValue: 'dev'); + +// Colors — définies par flavor +// Pour modifier les couleurs d'un client : changer les valeurs hex ci-dessous +// puis rebuilder avec --dart-define=FLAVOR=nomduclient +// Couleurs identiques pour tous les clients actuels const kBackgroundColor = Color(0xFFFFFFFF); const kMainColor = Color(0xFF306bac); const kSecondColor = Color(0xFF309cb0); const kConfigurationColor = Color(0xFF2F4858); -const kArticleTitleSize = 25.0; -const kArticleDescriptionSize = 12.5; +/*const kArticleTitleSize = 25.0; +const kArticleDescriptionSize = 12.5;*/ const kArticleContentSize = 16.0; const kArticleContentBiggerSize = 24.0; -const List languages = ["FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK"]; // hmmmm depends on config.. -const String defaultLanguage = "EN"; +const List languages = [ + "FR", + "NL", + "EN", + "DE", + "IT", + "ES", + "PL", + "CN", + "AR", + "UK" +]; // hmmmm depends on config.. -// Text Style -const kHeadingTextStyle = TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Color(0xFFFFFFFF) -); +const String defaultLanguage = "EN"; const kDefaultPadding = 20.0; const kMainGrey = Color(0xFF424242); const kSecondGrey = Color(0xFF555457); -const kSecondRed = Color(0xFF622727); +//const kSecondRed = Color(0xFF622727); const kTextRed = Color(0xFFba0505); const kBackgroundGrey = Color(0xFFb5b7b9); const kBackgroundSecondGrey = Color(0xFF5b5b63); -const kMainColor0 = Color(0xFF306bac); // const kBlue0 = Color(0xFF306bac); -const kMainColor1 = Color(0xFF308aae); // const kBlue1 = Color(0xFF308aae); -const kMainColor2 = Color(0xFF309cb0); // const kBlue2 = Color(0xFF309cb0); +const kMainColor0 = Color(_flavor == 'mdlf' + ? 0xFFe52122 + : // rouge + _flavor == 'fortsaintheribert' + ? 0xFF306bac + : // bleu + 0xFF306bac // test (défaut) + ); +const kMainColor1 = Color(_flavor == 'mdlf' + ? 0xFFed7082 + : // rouge clair + _flavor == 'fortsaintheribert' + ? 0xFF308aae + : // bleu + 0xFF308aae // test (défaut) + ); +const kMainColor2 = Color(_flavor == 'mdlf' + ? 0xFFed7082 + : // rouge clair + _flavor == 'fortsaintheribert' + ? 0xFF309cb0 + : // bleu + 0xFF309cb0 // test (défaut) + ); + +const kSplashLogoAsset = _flavor == 'mdlf' + ? 'assets/splash/mdlf.png' + : _flavor == 'fortsaintheribert' + ? 'assets/splash/fortsaintheribert.png' + : 'assets/splash/dev.png'; + +const kLoaderAsset = _flavor == 'mdlf' + ? 'assets/loader/mdlf.png' + : _flavor == 'fortsaintheribert' + ? 'assets/loader/fortsaintheribert.png' + : 'assets/loader/dev.png'; const kBackgroundLight = Color(0xfff3f3f3); diff --git a/lib/main.dart b/lib/main.dart index 42373d0..a931e87 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,8 @@ //import 'package:audioplayers/audioplayers.dart'; -import 'dart:convert'; import 'dart:io'; +import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; import 'package:flutter/services.dart'; // TODO // import 'package:flutter_beacon/flutter_beacon.dart'; import 'package:get/get.dart'; @@ -11,6 +10,8 @@ import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart'; import 'package:mymuseum_visitapp/Models/articleRead.dart'; import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart'; +import 'package:mymuseum_visitapp/Screens/splash_screen.dart'; +import 'package:mymuseum_visitapp/Services/pushNotificationService.dart'; import 'package:mymuseum_visitapp/l10n/app_localizations.dart'; import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; @@ -21,86 +22,89 @@ import 'client.dart'; import 'constants.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -void main() async { +void main() { WidgetsFlutterBinding.ensureInitialized(); - String initialRoute; - VisitAppContext? localContext = await DatabaseHelper.instance.getData(DatabaseTableType.main); + runApp(const AppBootstrap()); +} - Directory? appDocumentsDirectory = Platform.isIOS ? await getApplicationDocumentsDirectory() : await getDownloadsDirectory(); - String localPath = appDocumentsDirectory!.path; +class AppBootstrap extends StatefulWidget { + const AppBootstrap({Key? key}) : super(key: key); - MapboxOptions.setAccessToken("pk.eyJ1IjoidGZyYW5zb2xldCIsImEiOiJjbHRpcGNvZDYwYWhkMnFxdmF0ampveW10In0.7xHN0NGvUfQu5ThS3RGJRw"); // TODO put in json file or resource file + @override + _AppBootstrapState createState() => _AppBootstrapState(); +} - if(localContext != null) { - print("we've got an local db !"); - print(localContext); +class _AppBootstrapState extends State { + VisitAppContext? _ctx; - List articleReadTest = List.from(await DatabaseHelper.instance.getData(DatabaseTableType.articleRead)); - localContext.readSections = articleReadTest; - } else { - localContext = VisitAppContext(language: "FR", id: "UserId_Init", instanceId: kInstanceId, isAdmin: false, isAllLanguages: false); - DatabaseHelper.instance.insert(DatabaseTableType.main, localContext.toMap()); - - List articleReadTest = List.from(await DatabaseHelper.instance.getData(DatabaseTableType.articleRead)); - localContext.readSections = articleReadTest; - print(localContext.readSections); - - print("NO LOCAL DB !"); + @override + void initState() { + super.initState(); + _initApp(); } - // Bootstrap API key if not yet stored - if (localContext.apiKey == null && kInstancePinCode.isNotEmpty) { - try { - final resp = await http.get(Uri.parse( - '$kApiBaseUrl/api/instance/app-key?pinCode=$kInstancePinCode&appType=VisitApp')); - if (resp.statusCode == 200) { - localContext.apiKey = jsonDecode(resp.body)['key'] as String?; - DatabaseHelper.instance.updateTableMain(DatabaseTableType.main, localContext); - } - } catch (e) { - print('Bootstrap apiKey failed: $e'); + Future _initApp() async { + // Firebase init (requires google-services.json on Android, GoogleService-Info.plist on iOS) + if (!Platform.isWindows) { + await Firebase.initializeApp(); } + + VisitAppContext? localContext = await DatabaseHelper.instance.getData(DatabaseTableType.main); + + Directory? appDocumentsDirectory = Platform.isIOS ? await getApplicationDocumentsDirectory() : await getDownloadsDirectory(); + String localPath = appDocumentsDirectory!.path; + + MapboxOptions.setAccessToken("pk.eyJ1IjoidGZyYW5zb2xldCIsImEiOiJjbHRpcGNvZDYwYWhkMnFxdmF0ampveW10In0.7xHN0NGvUfQu5ThS3RGJRw"); // TODO put in json file or resource file + + if (localContext != null) { + print("we've got an local db !"); + print(localContext); + + List articleReadTest = List.from(await DatabaseHelper.instance.getData(DatabaseTableType.articleRead)); + localContext.readSections = articleReadTest; + } else { + localContext = VisitAppContext(language: "FR", id: "UserId_Init", instanceId: kInstanceId, isAdmin: false, isAllLanguages: false); + DatabaseHelper.instance.insert(DatabaseTableType.main, localContext.toMap()); + + List articleReadTest = List.from(await DatabaseHelper.instance.getData(DatabaseTableType.articleRead)); + localContext.readSections = articleReadTest; + print(localContext.readSections); + + print("NO LOCAL DB !"); + } + + localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey ?? (kApiKey.isNotEmpty ? kApiKey : null)); + + // Push notifications — subscribe to instance topic if enabled + if (!Platform.isWindows && localContext.instanceId != null) { + try { + await PushNotificationService.initialize(localContext.instanceId!); + } catch (e) { + print('PushNotification init failed: $e'); + } + } + + localContext.localPath = localPath; + print("Local path $localPath"); + + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + systemNavigationBarColor: Colors.transparent, + statusBarColor: Colors.transparent, + )); + + if (mounted) setState(() => _ctx = localContext); } - // Always initialize clientAPI with the current apiKey - localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey); - - localContext.localPath = localPath; - print("Local path $localPath"); - - initialRoute = '/home'; - - final MyApp myApp = MyApp( - initialRoute: initialRoute, - visitAppContext: localContext, - ); - - /*final AudioContext audioContext = AudioContext( - iOS: AudioContextIOS( - //defaultToSpeaker: true, - category: AVAudioSessionCategory.ambient, - options: [ - AVAudioSessionOptions.defaultToSpeaker, - AVAudioSessionOptions.mixWithOthers, - ], - ),*/ - /*android: AudioContextAndroid( - isSpeakerphoneOn: true, - stayAwake: true, - contentType: AndroidContentType.sonification, - usageType: AndroidUsageType.assistanceSonification, - audioFocus: AndroidAudioFocus.none, - ),*/ - //); - //AudioPlayer.global.setGlobalAudioContext(audioContext); - - WidgetsFlutterBinding.ensureInitialized(); - SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( - systemNavigationBarColor: Colors.transparent, // ← important - statusBarColor: Colors.transparent, - )); - - runApp(myApp); + @override + Widget build(BuildContext context) { + if (_ctx == null) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: SplashScreen(), + ); + } + return MyApp(visitAppContext: _ctx!, initialRoute: '/home'); + } } class MyApp extends StatefulWidget { @@ -150,4 +154,4 @@ class _MyAppState extends State { ), ); } -} \ No newline at end of file +} diff --git a/pubspec.lock b/pubspec.lock index 765cf3f..62cef3c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "67.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" analyzer: dependency: transitive description: @@ -313,6 +321,54 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + url: "https://pub.dev" + source: hosted + version: "15.2.10" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + url: "https://pub.dev" + source: hosted + version: "4.6.10" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + url: "https://pub.dev" + source: hosted + version: "3.10.10" fixnum: dependency: transitive description: @@ -406,6 +462,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" + url: "https://pub.dev" + source: hosted + version: "17.2.4" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af + url: "https://pub.dev" + source: hosted + version: "4.0.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66" + url: "https://pub.dev" + source: hosted + version: "7.2.0" flutter_localizations: dependency: "direct main" description: flutter @@ -541,6 +621,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.15.4" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" geotypes: dependency: transitive description: @@ -726,7 +854,7 @@ packages: source: hosted version: "0.4.13" latlong2: - dependency: transitive + dependency: "direct main" description: name: latlong2 sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" @@ -980,6 +1108,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + pedantic: + dependency: transitive + description: + name: pedantic + sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602" + url: "https://pub.dev" + source: hosted + version: "1.11.1" permission_handler: dependency: "direct main" description: @@ -1257,6 +1393,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + speech_to_text: + dependency: "direct main" + description: + name: speech_to_text + sha256: c07557664974afa061f221d0d4186935bea4220728ea9446702825e8b988db04 + url: "https://pub.dev" + source: hosted + version: "7.3.0" + speech_to_text_platform_interface: + dependency: transitive + description: + name: speech_to_text_platform_interface + sha256: a1935847704e41ee468aad83181ddd2423d0833abe55d769c59afca07adb5114 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + speech_to_text_windows: + dependency: transitive + description: + name: speech_to_text_windows + sha256: "2c9846d18253c7bbe059a276297ef9f27e8a2745dead32192525beb208195072" + url: "https://pub.dev" + source: hosted + version: "1.0.0+beta.8" sprintf: dependency: transitive description: @@ -1345,6 +1505,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + timezone: + dependency: transitive + description: + name: timezone + sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d" + url: "https://pub.dev" + source: hosted + version: "0.9.4" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 51cff9b..4ba571b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,6 +73,14 @@ dependencies: image: ^4.1.7 url_launcher: ^6.3.1 + speech_to_text: ^7.0.0 + geolocator: ^13.0.0 + latlong2: ^0.9.1 + + firebase_core: ^3.6.0 + firebase_messaging: ^15.1.3 + flutter_local_notifications: ^17.2.2 + manager_api_new: path: ../manager-app/manager_api_new # The following adds the Cupertino Icons font to your application. @@ -105,6 +113,8 @@ flutter: uses-material-design: true assets: + - assets/splash/ + - assets/loader/ - assets/icons/ - assets/images/ - assets/images/old/