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
53
.vscode/launch.json
vendored
Normal 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
@ -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
@ -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.
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
105
android/app/src/dev/google-services.json
Normal 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"
|
||||
}
|
||||
105
android/app/src/fortsaintheribert/google-services.json
Normal 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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 62 KiB |
@ -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">
|
||||
|
||||
105
android/app/src/mdlf/google-services.json
Normal 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"
|
||||
}
|
||||
BIN
android/app/src/mdlf/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/mdlf/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/mdlf/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/mdlf/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/mdlf/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
@ -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
|
||||
@ -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
|
After Width: | Height: | Size: 68 B |
BIN
assets/loader/fortsaintheribert.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
assets/loader/mdlf.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
assets/splash/dev.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
assets/splash/fortsaintheribert.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
assets/splash/mdlf.png
Normal file
|
After Width: | Height: | Size: 68 B |
3
devtools_options.yaml
Normal 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:
|
||||
@ -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>
|
||||
|
||||
33
ios/config/fortsaintheribert/GoogleService-Info.plist
Normal 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>
|
||||
33
ios/config/mdlf/GoogleService-Info.plist
Normal 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>
|
||||
33
ios/config/test/GoogleService-Info.plist
Normal 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>
|
||||
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -329,4 +458,4 @@ class _AiCardWidget extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
103
lib/Screens/Sections/Event/event_map_full_page.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
758
lib/Screens/Sections/Event/event_page.dart
Normal 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 ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/Screens/Sections/GuidedPath/guided_path_list_sheet.dart
Normal 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,
|
||||
),
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
108
lib/Screens/Sections/GuidedPath/guided_step_timer.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
66
lib/Screens/splash_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,33 +12,46 @@ class AssistantService {
|
||||
required String message,
|
||||
String? configurationId,
|
||||
}) async {
|
||||
final baseUrl = visitAppContext.clientAPI.apiApi?.basePath ?? 'https://api.mymuseum.be';
|
||||
|
||||
final body = {
|
||||
'message': message,
|
||||
'instanceId': visitAppContext.instanceId,
|
||||
'appType': 'Mobile',
|
||||
'configurationId': configurationId,
|
||||
'language': visitAppContext.language?.toUpperCase() ?? 'FR',
|
||||
'history': history.map((m) => m.toJson()).toList(),
|
||||
};
|
||||
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/ai/chat'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
final request = AiChatRequest(
|
||||
message: message,
|
||||
instanceId: visitAppContext.instanceId,
|
||||
appType: AppType.Mobile,
|
||||
configurationId: configurationId,
|
||||
language: visitAppContext.language?.toUpperCase() ?? 'FR',
|
||||
history: List.from(history),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<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();
|
||||
}
|
||||
}
|
||||
93
lib/Services/pushNotificationService.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
150
lib/main.dart
@ -1,9 +1,8 @@
|
||||
//import 'package:audioplayers/audioplayers.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter/services.dart';
|
||||
// TODO // import 'package:flutter_beacon/flutter_beacon.dart';
|
||||
import 'package:get/get.dart';
|
||||
@ -11,6 +10,8 @@ import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
|
||||
import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart';
|
||||
import 'package:mymuseum_visitapp/Models/articleRead.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
|
||||
import 'package:mymuseum_visitapp/Screens/splash_screen.dart';
|
||||
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
|
||||
import 'package:mymuseum_visitapp/l10n/app_localizations.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@ -21,86 +22,89 @@ import 'client.dart';
|
||||
import 'constants.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
void main() async {
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
String initialRoute;
|
||||
VisitAppContext? localContext = await DatabaseHelper.instance.getData(DatabaseTableType.main);
|
||||
runApp(const AppBootstrap());
|
||||
}
|
||||
|
||||
Directory? appDocumentsDirectory = Platform.isIOS ? await getApplicationDocumentsDirectory() : await getDownloadsDirectory();
|
||||
String localPath = appDocumentsDirectory!.path;
|
||||
class AppBootstrap extends StatefulWidget {
|
||||
const AppBootstrap({Key? key}) : super(key: key);
|
||||
|
||||
MapboxOptions.setAccessToken("pk.eyJ1IjoidGZyYW5zb2xldCIsImEiOiJjbHRpcGNvZDYwYWhkMnFxdmF0ampveW10In0.7xHN0NGvUfQu5ThS3RGJRw"); // TODO put in json file or resource file
|
||||
@override
|
||||
_AppBootstrapState createState() => _AppBootstrapState();
|
||||
}
|
||||
|
||||
if(localContext != null) {
|
||||
print("we've got an local db !");
|
||||
print(localContext);
|
||||
class _AppBootstrapState extends State<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 {
|
||||
@ -150,4 +154,4 @@ class _MyAppState extends State<MyApp> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
170
pubspec.lock
@ -9,6 +9,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "67.0.0"
|
||||
_flutterfire_internals:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _flutterfire_internals
|
||||
sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.59"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -313,6 +321,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
firebase_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.15.2"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.24.1"
|
||||
firebase_messaging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_messaging
|
||||
sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.10"
|
||||
firebase_messaging_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_platform_interface
|
||||
sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.10"
|
||||
firebase_messaging_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_messaging_web
|
||||
sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.10.10"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -406,6 +462,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
flutter_local_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.4"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_linux
|
||||
sha256: c49bd06165cad9beeb79090b18cd1eb0296f4bf4b23b84426e37dd7c027fc3af
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
flutter_local_notifications_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_local_notifications_platform_interface
|
||||
sha256: "85f8d07fe708c1bdcf45037f2c0109753b26ae077e9d9e899d55971711a4ea66"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -541,6 +621,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.4"
|
||||
geolocator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: geolocator
|
||||
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.0.4"
|
||||
geolocator_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_android
|
||||
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.2"
|
||||
geolocator_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_apple
|
||||
sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.13"
|
||||
geolocator_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_platform_interface
|
||||
sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.6"
|
||||
geolocator_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_web
|
||||
sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.3"
|
||||
geolocator_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_windows
|
||||
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
geotypes:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -726,7 +854,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.4.13"
|
||||
latlong2:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: latlong2
|
||||
sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe"
|
||||
@ -980,6 +1108,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
pedantic:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pedantic
|
||||
sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.11.1"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1257,6 +1393,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
speech_to_text:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: speech_to_text
|
||||
sha256: c07557664974afa061f221d0d4186935bea4220728ea9446702825e8b988db04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.0"
|
||||
speech_to_text_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: speech_to_text_platform_interface
|
||||
sha256: a1935847704e41ee468aad83181ddd2423d0833abe55d769c59afca07adb5114
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
speech_to_text_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: speech_to_text_windows
|
||||
sha256: "2c9846d18253c7bbe059a276297ef9f27e8a2745dead32192525beb208195072"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0+beta.8"
|
||||
sprintf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1345,6 +1505,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timezone
|
||||
sha256: "2236ec079a174ce07434e89fcd3fcda430025eb7692244139a9cf54fdcf1fc7d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
10
pubspec.yaml
@ -73,6 +73,14 @@ dependencies:
|
||||
image: ^4.1.7
|
||||
url_launcher: ^6.3.1
|
||||
|
||||
speech_to_text: ^7.0.0
|
||||
geolocator: ^13.0.0
|
||||
latlong2: ^0.9.1
|
||||
|
||||
firebase_core: ^3.6.0
|
||||
firebase_messaging: ^15.1.3
|
||||
flutter_local_notifications: ^17.2.2
|
||||
|
||||
manager_api_new:
|
||||
path: ../manager-app/manager_api_new
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
@ -105,6 +113,8 @@ flutter:
|
||||
uses-material-design: true
|
||||
|
||||
assets:
|
||||
- assets/splash/
|
||||
- assets/loader/
|
||||
- assets/icons/
|
||||
- assets/images/
|
||||
- assets/images/old/
|
||||
|
||||