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 0000000..ddbb236 Binary files /dev/null and b/android/app/src/fortsaintheribert/res/mipmap-hdpi/ic_launcher.png differ 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 0000000..2c0a0b3 Binary files /dev/null and b/android/app/src/fortsaintheribert/res/mipmap-mdpi/ic_launcher.png differ 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 0000000..dbba61e Binary files /dev/null and b/android/app/src/fortsaintheribert/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/fortsaintheribert/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/fortsaintheribert/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..3557440 Binary files /dev/null and b/android/app/src/fortsaintheribert/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/fortsaintheribert/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/fortsaintheribert/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..07009c7 Binary files /dev/null and b/android/app/src/fortsaintheribert/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5fee667..d80e6d6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ - - + + @@ -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 0000000..db77bb4 Binary files /dev/null and b/android/app/src/mdlf/res/mipmap-hdpi/ic_launcher.png differ 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 0000000..17987b7 Binary files /dev/null and b/android/app/src/mdlf/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/mdlf/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/mdlf/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/mdlf/res/mipmap-xhdpi/ic_launcher.png differ 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 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/mdlf/res/mipmap-xxhdpi/ic_launcher.png differ 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 0000000..4d6372e Binary files /dev/null and b/android/app/src/mdlf/res/mipmap-xxxhdpi/ic_launcher.png differ 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 0000000..35a340b Binary files /dev/null and b/assets/loader/dev.png differ diff --git a/assets/loader/fortsaintheribert.png b/assets/loader/fortsaintheribert.png new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/assets/loader/fortsaintheribert.png differ diff --git a/assets/loader/mdlf.png b/assets/loader/mdlf.png new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/assets/loader/mdlf.png differ diff --git a/assets/splash/dev.png b/assets/splash/dev.png new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/assets/splash/dev.png differ diff --git a/assets/splash/fortsaintheribert.png b/assets/splash/fortsaintheribert.png new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/assets/splash/fortsaintheribert.png differ diff --git a/assets/splash/mdlf.png b/assets/splash/mdlf.png new file mode 100644 index 0000000..35a340b Binary files /dev/null and b/assets/splash/mdlf.png differ diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 587ba80..146c2a1 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -39,7 +39,9 @@ NSLocationWhenInUseUsageDescription 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/