added launch screen + update in ai chat + notifs (to be tested) + EventAgenda layout (to be tested) + home 3.0 update (event) + event mode layout (to be tested) + guided Path layout (to be tested) + map parcours (to be tested) + update readme

This commit is contained in:
Thomas Fransolet 2026-03-25 17:47:10 +01:00
parent e8ef78d0e2
commit bdabfad92e
59 changed files with 4253 additions and 394 deletions

53
.vscode/launch.json vendored Normal file
View File

@ -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"
]
}
]
}

58
CLAUDE.md Normal file
View File

@ -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)
```

141
README.md
View File

@ -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.

View File

@ -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'
}

View File

@ -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"
}

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -1,7 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="be.unov.mymuseum.fortsaintheribert">
<!-- package="be.unov.myinfomate.mdlf"> -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
@ -20,7 +19,7 @@
<uses-feature android:name="android.hardware.camera" />-->
<!-- android:label="Fort Saint Héribert" "Musée de la fraise" -->
<application
android:label="Fort Saint Héribert"
android:label="@string/app_name"
android:name="${applicationName}"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher">

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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

View File

@ -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"

BIN
assets/loader/dev.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

BIN
assets/loader/mdlf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

BIN
assets/splash/dev.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

BIN
assets/splash/mdlf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

3
devtools_options.yaml Normal file
View File

@ -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:

View File

@ -39,7 +39,9 @@
<key>NSLocationWhenInUseUsageDescription</key>
<string>This app needs location to show content based on location</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app uses microphone only on qr code scanning (as it uses the camera)</string>
<string>Microphone access is used for voice input in the assistant chat</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Speech recognition is used to convert your voice to text in the assistant chat</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- PLACEHOLDER — Télécharger le vrai fichier depuis Firebase Console -->
<!-- Projet Firebase : mymuseum-3b97f -->
<!-- App iOS à enregistrer : be.unov.mymuseum.fortsaintheribert -->
<key>API_KEY</key>
<string>PLACEHOLDER</string>
<key>GCM_SENDER_ID</key>
<string>1034665398515</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>be.unov.mymuseum.fortsaintheribert</string>
<key>PROJECT_ID</key>
<string>mymuseum-3b97f</string>
<key>STORAGE_BUCKET</key>
<string>mymuseum-3b97f.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>PLACEHOLDER</string>
</dict>
</plist>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- PLACEHOLDER — Télécharger le vrai fichier depuis Firebase Console -->
<!-- Projet Firebase : mymuseum-3b97f -->
<!-- App iOS à enregistrer : be.unov.mymuseum.mdlf -->
<key>API_KEY</key>
<string>PLACEHOLDER</string>
<key>GCM_SENDER_ID</key>
<string>1034665398515</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>be.unov.mymuseum.mdlf</string>
<key>PROJECT_ID</key>
<string>mymuseum-3b97f</string>
<key>STORAGE_BUCKET</key>
<string>mymuseum-3b97f.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>PLACEHOLDER</string>
</dict>
</plist>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- PLACEHOLDER — Télécharger le vrai fichier depuis Firebase Console -->
<!-- Projet Firebase : mymuseum-3b97f -->
<!-- App iOS à enregistrer : be.unov.myinfomate.test -->
<key>API_KEY</key>
<string>PLACEHOLDER</string>
<key>GCM_SENDER_ID</key>
<string>1034665398515</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>be.unov.myinfomate.test</string>
<key>PROJECT_ID</key>
<string>mymuseum-3b97f</string>
<key>STORAGE_BUCKET</key>
<string>mymuseum-3b97f.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>PLACEHOLDER</string>
</dict>
</plist>

View File

@ -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<Offset>(begin: const Offset(0, 1), end: Offset.zero)
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
child: child,
),
);
}
@override
State<AssistantChatSheet> createState() => _AssistantChatSheetState();
}
@ -27,10 +56,46 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
final List<Widget> _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<void> _initSpeech() async {
final available = await _speech.initialize();
if (mounted) setState(() => _speechAvailable = available);
}
Future<void> _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<void> _send() async {
@ -55,7 +120,8 @@ class _AssistantChatSheetState extends State<AssistantChatSheet> {
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<AssistantChatSheet> {
@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)),
],
),
),

View File

@ -81,7 +81,14 @@ class _LoadingCommonState extends State<LoadingCommon> 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),
),
),
),
);

View File

@ -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:

View File

@ -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<String, dynamic> 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?,
);
}

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'package:manager_api_new/api.dart';
class Agenda {
List<EventAgenda> 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<TranslationDTO>? 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<String, dynamic> json) {
return EventAgenda(
name: json['name'],

View File

@ -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<String, dynamic> 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,
};
}

View File

@ -246,7 +246,8 @@ class _BodyState extends State<Body> {
Future<List<SectionDTO>> getSections(AppContext appContext) async {
VisitAppContext visitAppContext = appContext.getContext();
if(widget.configuration.isOffline!)
sections = [];
if(widget.configuration.isOffline == true)
{
// OFFLINE
sections = List<SectionDTO>.from(await DatabaseHelper.instance.getData(DatabaseTableType.sections));
@ -260,14 +261,16 @@ class _BodyState extends State<Body> {
List<SectionDTO> sectionList = rawToSection.whereType<SectionDTO>().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;

View File

@ -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<ConfigurationPage> with WidgetsBindi
//bool _isArticleOpened = false;
StreamSubscription? listener;
//final List<Region> regions = <Region>[];
late final VoidCallback _notificationListener;
@override
void initState() {
@ -75,6 +77,33 @@ class _ConfigurationPageState extends State<ConfigurationPage> 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<AppContext>(context, listen: false);
VisitAppContext visitAppContext = appContext.getContext();
@ -323,6 +352,7 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
@override
void dispose() {
PushNotificationService.tappedMessage.removeListener(_notificationListener);
controller.pauseScanning();
super.dispose();
}
@ -362,24 +392,20 @@ class _ConfigurationPageState extends State<ConfigurationPage> 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),

View File

@ -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<HomePage3> 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<HomePage3> with WidgetsBindingObserver {
orElse: () => configurations[0].title!.first,
)
: null;
final featuredEvent = visitAppContext.applicationInstanceDTO?.sectionEventDTO;
return Stack(
children: [
@ -217,7 +313,9 @@ class _HomePage3State extends State<HomePage3> 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<HomePage3> 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<AppContext>(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<AppContext>(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<HomePage3> 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<HomePage3> 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 [];
}
}

View File

@ -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<AgendaPage> {
Future<Agenda?> 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;
}
}

View File

@ -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<EventPopup> {
},
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(),
],
),
),

View File

@ -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<ResourceModel?> resourcesModel;
final String? mainAudioId;
final String? sectionId;
@override
State<ArticlePage> createState() => _ArticlePageState();
@ -49,6 +50,13 @@ class _ArticlePageState extends State<ArticlePage> {
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();
}

View File

@ -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<Marker> _buildMarkers(List<MapAnnotationDTO> annotations) {
final markers = <Marker>[];
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<Polyline> _buildPolylines(List<MapAnnotationDTO> annotations) {
final lines = <Polyline>[];
for (final ann in annotations) {
if (ann.geometryType?.value != 1) continue;
final coords = ann.geometry?.coordinates;
if (coords is! List) continue;
final points = <LatLng>[];
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;
}
}

View File

@ -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<EventPage> createState() => _EventPageState();
}
class _EventPageState extends State<EventPage> {
List<GuidedPathDTO> _paths = [];
@override
void initState() {
super.initState();
_loadPaths();
}
Future<void> _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<MapAnnotationDTO> 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<Marker> _buildMarkers(List<MapAnnotationDTO> annotations) {
final markers = <Marker>[];
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<Polyline> _buildPolylines(List<MapAnnotationDTO> annotations) {
final lines = <Polyline>[];
for (final ann in annotations) {
if (ann.geometryType?.value != 1) continue;
final coords = ann.geometry?.coordinates;
if (coords is! List) continue;
final points = <LatLng>[];
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<TranslationDTO>? list) {
if (list == null || list.isEmpty) return '';
try {
return TranslationHelper.get(list, widget.visitAppContextIn);
} catch (_) {
return list.first.value ?? '';
}
}
}

View File

@ -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<GuidedPathContentProgressionPage> createState() =>
_GuidedPathContentProgressionPageState();
}
class _GuidedPathContentProgressionPageState
extends State<GuidedPathContentProgressionPage> {
late List<GuidedStepDTO> _steps;
int _currentStepIndex = 0;
final Set<String> _completedStepIds = {};
List<QuestionSubDTO> _stepQuestions = [];
bool _quizCompleted = false;
bool _quizPassed = false;
bool _inGeoZone = false;
StreamSubscription<Position>? _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<TranslationDTO>? 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<void> _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',
),
),
],
),
),
],
),
),
);
}
}

View File

@ -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<GuidedPathDTO> 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,
),
));
},
);
}
}

View File

@ -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<GuidedPathMapProgressionPage> createState() => _GuidedPathMapProgressionPageState();
}
class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionPage>
with SingleTickerProviderStateMixin {
late List<GuidedStepDTO> _steps;
int _currentStepIndex = 0;
final Set<String> _completedStepIds = {};
// Quiz state for current step
List<QuestionSubDTO> _stepQuestions = [];
bool _quizCompleted = false;
bool _quizPassed = false;
// Geo trigger state
LatLng? _userPosition;
bool _inGeoZone = false;
StreamSubscription<Position>? _positionSub;
// Map
final MapController _mapController = MapController();
// Pulsing animation for current step marker
late AnimationController _pulseController;
late Animation<double> _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<double>(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<void> _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<TranslationDTO>? list) =>
TranslationHelper.get(list, widget.visitAppContext);
// Map
List<Marker> _buildStepMarkers() {
final markers = <Marker>[];
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<String> completedIds;
final List<GuidedStepDTO> 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,
),
),
],
),
);
}
}

View File

@ -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<GuidedStepTimer> createState() => _GuidedStepTimerState();
}
class _GuidedStepTimerState extends State<GuidedStepTimer> {
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>(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')}';
}
}

View File

@ -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<MapPage> {
)
),
),
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,

View File

@ -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<SectionPage> {
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<SectionPage> {
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<SectionPage> {
Future<dynamic> 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<SectionPage> {
MapDTO mapDTO = MapDTO.fromJson(rawSectionData)!;
icons = await getByteIcons(visitAppContext, mapDTO);
break;
case SectionType.Event:
break;
default:
break;
}

View File

@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> 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),
),
),
],
),
),
);
}
}

View File

@ -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<String, dynamic> toJson() => {'role': role, 'content': content};
}
class AssistantService {
final VisitAppContext visitAppContext;
final List<AiChatMessage> history = [];
@ -21,32 +12,45 @@ 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<String, dynamic>;
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();

View File

@ -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<void> 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<RemoteMessage?> tappedMessage = ValueNotifier(null);
/// Call once after Firebase.initializeApp(), passing the instanceId.
static Future<void> 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<void> subscribeToInstance(String instanceId) async {
await FirebaseMessaging.instance
.subscribeToTopic('instance_$instanceId');
}
static Future<void> unsubscribeFromInstance(String instanceId) async {
await FirebaseMessaging.instance
.unsubscribeFromTopic('instance_$instanceId');
}
static Future<void> _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),
);
}
}

View File

@ -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);
}
}

View File

@ -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<String> languages = ["FR", "NL", "EN", "DE", "IT", "ES", "PL", "CN", "AR", "UK"]; // hmmmm depends on config..
const String defaultLanguage = "EN";
const List<String> 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);

View File

@ -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<AppBootstrap> {
VisitAppContext? _ctx;
List<SectionRead> articleReadTest = List<SectionRead>.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<SectionRead> articleReadTest = List<SectionRead>.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<void> _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<SectionRead> articleReadTest = List<SectionRead>.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<SectionRead> articleReadTest = List<SectionRead>.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 {

View File

@ -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:

View File

@ -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/