diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
index f74085f..cddc370 100644
--- a/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -1,12 +1,10 @@
-
-
+
-
-
+ android:src="@mipmap/ic_launcher" />
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
index 304732f..cddc370 100644
--- a/android/app/src/main/res/drawable/launch_background.xml
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -1,12 +1,10 @@
-
-
+
-
-
+ android:src="@mipmap/ic_launcher" />
+
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
index 06952be..5f1c793 100644
--- a/android/app/src/main/res/values-night/styles.xml
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -13,6 +13,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml
new file mode 100644
index 0000000..ba7289e
--- /dev/null
+++ b/android/app/src/main/res/values-v31/styles.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 9d8361e..5a7a063 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -1,4 +1,7 @@
#FF8535
-
\ No newline at end of file
+
+ #111111
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
index cb1ef88..7984f3b 100644
--- a/android/app/src/main/res/values/styles.xml
+++ b/android/app/src/main/res/values/styles.xml
@@ -1,7 +1,7 @@
-
diff --git a/assets/loader/dev.png b/assets/loader/dev.png
deleted file mode 100644
index 35a340b..0000000
Binary files a/assets/loader/dev.png and /dev/null differ
diff --git a/assets/loader/fortsaintheribert.png b/assets/loader/fortsaintheribert.png
deleted file mode 100644
index 35a340b..0000000
Binary files a/assets/loader/fortsaintheribert.png and /dev/null differ
diff --git a/assets/loader/mdlf.png b/assets/loader/mdlf.png
deleted file mode 100644
index 35a340b..0000000
Binary files a/assets/loader/mdlf.png and /dev/null differ
diff --git a/assets/splash/dev.png b/assets/splash/dev.png
index 35a340b..07009c7 100644
Binary files a/assets/splash/dev.png and b/assets/splash/dev.png differ
diff --git a/assets/splash/fortsaintheribert.png b/assets/splash/fortsaintheribert.png
index 35a340b..07009c7 100644
Binary files a/assets/splash/fortsaintheribert.png and b/assets/splash/fortsaintheribert.png differ
diff --git a/assets/splash/mdlf.png b/assets/splash/mdlf.png
index 35a340b..4d6372e 100644
Binary files a/assets/splash/mdlf.png and b/assets/splash/mdlf.png differ
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
index f2e259c..b62a40c 100644
--- a/ios/Runner/Base.lproj/LaunchScreen.storyboard
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -19,7 +19,7 @@
-
+
diff --git a/lib/Components/ScannerBouton.dart b/lib/Components/ScannerBouton.dart
index dee65ff..f7ee14f 100644
--- a/lib/Components/ScannerBouton.dart
+++ b/lib/Components/ScannerBouton.dart
@@ -1,14 +1,28 @@
import 'package:flutter/material.dart';
+import 'package:mymuseum_visitapp/Components/GlassPill.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'ScannerDialog.dart';
class ScannerBouton extends StatefulWidget {
- const ScannerBouton({Key? key, required this.appContext}) : super(key: key);
+ const ScannerBouton({
+ Key? key,
+ required this.appContext,
+ this.size = 85.0,
+ this.transparent = false,
+ }) : super(key: key);
final AppContext appContext;
+ /// Diametre du bouton. 85 dans une visite, plus petit sur l'accueil ou il
+ /// cohabite avec les autres pastilles.
+ final double size;
+
+ /// Pastille en verre depoli plutot que disque plein, pour se poser sur le
+ /// contenu de l'accueil sans le masquer.
+ final bool transparent;
+
@override
State createState() => _ScannerBoutonState();
}
@@ -23,6 +37,12 @@ class _ScannerBoutonState extends State {
@override
Widget build(BuildContext context) {
+ final icon = Icon(Icons.qr_code_scanner, color: Colors.white, size: widget.size * 0.47);
+
+ if (widget.transparent) {
+ return GlassPill(size: widget.size, onTap: _onItemTapped, child: icon);
+ }
+
return InkWell(
onTap: _onItemTapped,
child: Container(
@@ -37,9 +57,9 @@ class _ScannerBoutonState extends State {
)
],
),
- height: 85.0,
- width: 85.0,
- child: const Icon(Icons.qr_code_scanner, color: Colors.white, size: 40),
+ height: widget.size,
+ width: widget.size,
+ child: icon,
),
);
}
diff --git a/lib/Components/ScannerDialog.dart b/lib/Components/ScannerDialog.dart
index c744a64..fce876e 100644
--- a/lib/Components/ScannerDialog.dart
+++ b/lib/Components/ScannerDialog.dart
@@ -235,7 +235,14 @@ class _ScannerDialogState extends State {
visitAppContext.configuration = other;
visitAppContext.sectionIds = null;
widget.appContext!.setContext(visitAppContext);
- navigator.push(
+ // `pushAndRemoveUntil` jusqu'à l'accueil, et non `push` : une visite
+ // est une destination de premier niveau, pas quelque chose qui
+ // s'empile. En scannant des QR de trois visites différentes, le
+ // visiteur se retrouvait avec trois ConfigurationPage superposées et
+ // autant de retours à faire pour revenir à l'accueil — en gardant en
+ // mémoire des visites qu'il a quittées. Ici la pile reste
+ // « accueil → visite en cours ».
+ navigator.pushAndRemoveUntil(
SlideFromRightRoute(
page: ConfigurationPage(
configuration: other,
@@ -243,6 +250,7 @@ class _ScannerDialogState extends State {
openSectionId: sectionId,
),
),
+ (route) => route.isFirst,
);
},
child: Text(TranslationHelper.getFromLocale('open', visitAppContext)),
diff --git a/lib/Components/SlideFromRouteRight.dart b/lib/Components/SlideFromRouteRight.dart
index 79b3813..d5b06fa 100644
--- a/lib/Components/SlideFromRouteRight.dart
+++ b/lib/Components/SlideFromRouteRight.dart
@@ -5,6 +5,12 @@ class SlideFromRightRoute extends PageRouteBuilder {
SlideFromRightRoute({required this.page})
: super(
+ // Non opaque : l'écran d'où vient le visiteur continue d'être peint sous
+ // celui-ci. C'est ce qui permet à une page encore en chargement de se
+ // présenter comme un voile par-dessus ce qu'il regardait, au lieu d'une
+ // page blanche avec un loader au milieu. Dès que la page est prête, elle
+ // peint son propre fond et recouvre tout.
+ opaque: false,
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(1.0, 0.0); // départ à droite (hors écran)
diff --git a/lib/Components/loading_common.dart b/lib/Components/loading_common.dart
index d3315ea..7f65047 100644
--- a/lib/Components/loading_common.dart
+++ b/lib/Components/loading_common.dart
@@ -2,116 +2,135 @@ import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
-import 'package:manager_api_new/api.dart';
+import 'package:mymuseum_visitapp/Helpers/instanceImages.dart';
import 'package:mymuseum_visitapp/Models/visitContext.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart';
-import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
-class LoadingCommon extends StatefulWidget {
- const LoadingCommon({Key? key}) : super(key: key);
+/// Le loader lui-même, sans rien savoir du contexte : le splash s'affiche avant
+/// que le `Provider` n'existe et ne peut donc pas passer par `LoadingCommon`.
+///
+/// L'image vient du manager (Applications → Mobile). Tant qu'elle n'est pas
+/// connue, on tourne sur l'indicateur natif plutôt que sur `assets/loader/`, qui
+/// ne contient que des PNG 1x1 transparents — invisibles, et `errorBuilder` ne
+/// se déclenche pas puisque le fichier est valide.
+class RotatingLoader extends StatefulWidget {
+ const RotatingLoader({Key? key, this.file, this.imageUrl, this.size, this.color})
+ : super(key: key);
+
+ final File? file;
+ final String? imageUrl;
+ final double? size;
+ final Color? color;
@override
- State createState() => _LoadingCommonState();
+ State createState() => _RotatingLoaderState();
}
-class _LoadingCommonState extends State with TickerProviderStateMixin {
- AnimationController? _controller;
-
- @override
- void initState() {
- _controller = AnimationController(
- duration: const Duration(milliseconds: 5000),
- vsync: this,
- )..repeat();
- super.initState();
- }
+class _RotatingLoaderState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _controller = AnimationController(
+ duration: const Duration(seconds: 3),
+ vsync: this,
+ )..repeat();
@override
void dispose() {
- _controller!.dispose();
+ _controller.dispose();
super.dispose();
}
+ @override
+ Widget build(BuildContext context) {
+ final size = widget.size ?? MediaQuery.of(context).size.height * 0.1;
+ final color = widget.color ?? kMainColor1;
+
+ Widget? image;
+ if (widget.file != null) {
+ image = Image.file(widget.file!, width: size, height: size, fit: BoxFit.contain);
+ } else if (widget.imageUrl != null) {
+ image = CachedNetworkImage(
+ imageUrl: widget.imageUrl!,
+ width: size,
+ height: size,
+ fit: BoxFit.contain,
+ progressIndicatorBuilder: (context, url, progress) =>
+ CircularProgressIndicator(value: progress.progress, color: color),
+ errorWidget: (context, url, error) => CircularProgressIndicator(color: color),
+ );
+ }
+
+ if (image == null) {
+ return SizedBox(
+ width: size,
+ height: size,
+ child: Center(child: CircularProgressIndicator(color: color)),
+ );
+ }
+
+ return SizedBox(
+ width: size,
+ height: size,
+ child: RotationTransition(turns: _controller, child: image),
+ );
+ }
+}
+
+/// Loader des écrans de l'app. Ordre de préférence, celui de `visitapp-web` :
+/// l'image de la visite si elle en définit une, sinon celle de l'instance mise
+/// en cache au démarrage.
+class LoadingCommon extends StatelessWidget {
+ const LoadingCommon({Key? key}) : super(key: key);
+
@override
Widget build(BuildContext context) {
final appContext = Provider.of(context);
- VisitAppContext visitAppContext = appContext.getContext();
+ final VisitAppContext visitAppContext = appContext.getContext();
+ final configuration = visitAppContext.configuration;
- Size size = MediaQuery.of(context).size;
- _controller!.forward(from: 0.0);
- _controller!.addListener(() {
- if (_controller!.isCompleted) {
- _controller!.reverse();
- }
- if(_controller!.isDismissed){
- _controller!.forward();
- }
- });
+ final primaryColor = configuration?.primaryColor != null
+ ? Color(int.parse(
+ configuration!.primaryColor!.split('(0x')[1].split(')')[0],
+ radix: 16))
+ : kMainColor1;
- var primaryColor = visitAppContext.configuration != null ? visitAppContext.configuration!.primaryColor != null ? new Color(int.parse(visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16)) : kMainColor1 : kMainColor1;
return Center(
- child: SizedBox(
- height: size.height * 0.1,
- child: RotationTransition(
- turns: Tween(begin: 0.0, end: 3.0).animate(_controller!),
- child: visitAppContext.configuration != null && visitAppContext.configuration!.loaderImageUrl != null ?
- //Image.network(tabletAppContext.configuration!.loaderImageUrl!)
- // TODO Replace by CustomCacheResource..
- FutureBuilder(
- future: _checkIfLocalResourceExists(visitAppContext, visitAppContext.configuration!.loaderImageId!),
- builder: (context, snapshot) {
- if (snapshot.connectionState == ConnectionState.waiting) {
- // Loader ou indicateur de chargement pendant la vérification
- return const CircularProgressIndicator();
- } else if (snapshot.hasError || snapshot.data == null) {
- // Si la ressource locale n'existe pas ou s'il y a une erreur
- print("not a local loader succck");
- return CachedNetworkImage(
- imageUrl: visitAppContext.configuration!.loaderImageUrl!,
- progressIndicatorBuilder: (context, url, downloadProgress) => CircularProgressIndicator(value: downloadProgress.progress, color: primaryColor),
- errorWidget: (context, url, error) => const Icon(Icons.error),
- );
- } else {
- print("Its a local loaaader yeah");
- return Image.file(
- snapshot.data!,
- );
- }
- },
- )
- : 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),
- ),
- ),
+ child: FutureBuilder(
+ future: InstanceImages.restore(),
+ builder: (context, _) {
+ final localConfigLoader = configuration?.loaderImageId != null
+ ? _localResource(visitAppContext, configuration!.loaderImageId!)
+ : null;
+
+ return RotatingLoader(
+ file: localConfigLoader ?? InstanceImages.loader,
+ imageUrl: localConfigLoader == null ? configuration?.loaderImageUrl : null,
+ color: primaryColor,
+ );
+ },
),
);
}
}
-Future _checkIfLocalResourceExists(VisitAppContext visitAppContext, String resourceId) async {
+/// Fichier déjà téléchargé pour la visite en cours, s'il existe. Lecture
+/// synchrone : le répertoire est local et l'écran de chargement ne peut pas se
+/// permettre une frame vide pour aller le vérifier.
+File? _localResource(VisitAppContext visitAppContext, String resourceId) {
try {
- Directory? appDocumentsDirectory = Platform.isIOS ? await getApplicationDocumentsDirectory() : await getDownloadsDirectory();
- String localPath = appDocumentsDirectory!.path;
- Directory configurationDirectory = Directory('$localPath/${visitAppContext.configuration!.id}');
- List fileList = configurationDirectory.listSync();
-
- if(fileList.any((fileL) => fileL.uri.pathSegments.last.contains(resourceId))) {
- File file = File(fileList.firstWhere((fileL) => fileL.uri.pathSegments.last.contains(resourceId)).path);
- return file;
+ if (visitAppContext.localPath == null || visitAppContext.configuration?.id == null) {
+ return null;
}
- } catch(e) {
- print("ERROR _checkIfLocalResourceExists CachedCustomResource");
- print(e);
- }
+ final directory =
+ Directory('${visitAppContext.localPath}/${visitAppContext.configuration!.id}');
+ if (!directory.existsSync()) return null;
+ for (final entity in directory.listSync()) {
+ if (entity.uri.pathSegments.last.contains(resourceId)) return File(entity.path);
+ }
+ } catch (e) {
+ debugPrint('[loader] ressource locale illisible : $e');
+ }
return null;
}
-
-
diff --git a/lib/Helpers/ImageCustomProvider.dart b/lib/Helpers/ImageCustomProvider.dart
index 2d3c2bf..efcbeb4 100644
--- a/lib/Helpers/ImageCustomProvider.dart
+++ b/lib/Helpers/ImageCustomProvider.dart
@@ -11,6 +11,12 @@ class ImageCustomProvider {
try {
if(appContext.getContext().localPath != null && visitAppContext.configuration != null) {
Directory configurationDirectory = Directory('${visitAppContext.localPath!}/${visitAppContext.configuration!.id!}');
+ // Une visite jamais téléchargée n'a pas de répertoire : `listSync` levait
+ // alors une PathNotFoundException, rattrapée plus bas — à chaque
+ // construction de chaque image, pour finir de toute façon sur le réseau.
+ if (!configurationDirectory.existsSync()) {
+ return CachedNetworkImageProvider(imageSource);
+ }
List fileList = configurationDirectory.listSync();
if(imageId != null && fileList.any((fileL) => fileL.uri.pathSegments.last.contains(imageId))) {
diff --git a/lib/Helpers/instanceImages.dart b/lib/Helpers/instanceImages.dart
new file mode 100644
index 0000000..dd47f6c
--- /dev/null
+++ b/lib/Helpers/instanceImages.dart
@@ -0,0 +1,105 @@
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:http/http.dart' as http;
+import 'package:path_provider/path_provider.dart';
+
+/// Images définies dans le manager (Applications → Mobile) gardées sur le device.
+///
+/// Elles sont réclamées avant que quoi que ce soit d'autre existe : le splash
+/// s'affiche pendant le boot, donc avant le premier appel API, avant le contexte
+/// et avant le `Provider`. Et hors ligne, il n'y a plus d'URL à interroger du
+/// tout. Un cache fichier, relu à froid, est la seule chose qui réponde aux deux.
+///
+/// Volontairement à part du répertoire d'une visite (`localPath//`) :
+/// ces images appartiennent à l'instance, pas à une configuration, et doivent
+/// survivre à la suppression d'une visite téléchargée.
+class InstanceImages {
+ static const _loaderSlot = 'loader';
+ static const _mainSlot = 'main';
+
+ /// Loader tournant des écrans de chargement et du splash.
+ static File? loader;
+
+ /// Image principale de l'app, utilisée comme logo de démarrage.
+ static File? main;
+
+ static Future? _restoring;
+
+ /// Relit ce qu'un lancement précédent a mis en cache. Idempotent : le splash et
+ /// les écrans de chargement l'appellent sans avoir à se coordonner.
+ static Future restore() => _restoring ??= _restore();
+
+ static Future _restore() async {
+ loader = await _cachedFile(_loaderSlot);
+ main = await _cachedFile(_mainSlot);
+ }
+
+ /// Aligne le cache sur ce que le manager expose aujourd'hui. Best-effort : un
+ /// échec réseau laisse en place l'image du lancement précédent.
+ static Future refresh({String? loaderUrl, String? mainImageUrl}) async {
+ await restore();
+ loader = await _sync(_loaderSlot, loaderUrl);
+ main = await _sync(_mainSlot, mainImageUrl);
+ }
+
+ static Future _directory() async {
+ try {
+ final base = Platform.isIOS
+ ? await getApplicationDocumentsDirectory()
+ : await getDownloadsDirectory();
+ if (base == null) return null;
+ final dir = Directory('${base.path}/instance_images');
+ if (!dir.existsSync()) dir.createSync(recursive: true);
+ return dir;
+ } catch (e) {
+ debugPrint('[instanceImages] répertoire indisponible : $e');
+ return null;
+ }
+ }
+
+ static Future _cachedFile(String slot) async {
+ final dir = await _directory();
+ if (dir == null) return null;
+ final file = File('${dir.path}/$slot');
+ return file.existsSync() ? file : null;
+ }
+
+ /// L'URL source est écrite à côté du fichier : c'est elle qui dit si l'image a
+ /// changé dans le manager. Sans elle, il faudrait re-télécharger à chaque
+ /// démarrage — ou ne jamais voir un changement.
+ static Future _sync(String slot, String? url) async {
+ final dir = await _directory();
+ if (dir == null) return null;
+
+ final file = File('${dir.path}/$slot');
+ final sourceFile = File('${dir.path}/$slot.url');
+
+ if (url == null) {
+ // Image retirée côté manager : le device ne doit pas continuer à servir
+ // l'ancienne indéfiniment.
+ try {
+ if (file.existsSync()) file.deleteSync();
+ if (sourceFile.existsSync()) sourceFile.deleteSync();
+ } catch (_) {}
+ return null;
+ }
+
+ final knownSource = sourceFile.existsSync() ? sourceFile.readAsStringSync() : null;
+ if (file.existsSync() && knownSource == url) return file;
+
+ try {
+ final response = await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10));
+ if (response.statusCode != 200) {
+ debugPrint('[instanceImages] $slot : HTTP ${response.statusCode}');
+ return file.existsSync() ? file : null;
+ }
+ await file.writeAsBytes(response.bodyBytes);
+ await sourceFile.writeAsString(url);
+ return file;
+ } catch (e) {
+ debugPrint('[instanceImages] $slot non téléchargée : $e');
+ return file.existsSync() ? file : null;
+ }
+ }
+}
diff --git a/lib/Screens/ConfigurationPage/configuration_page.dart b/lib/Screens/ConfigurationPage/configuration_page.dart
index 7a398c8..f71c5db 100644
--- a/lib/Screens/ConfigurationPage/configuration_page.dart
+++ b/lib/Screens/ConfigurationPage/configuration_page.dart
@@ -19,6 +19,7 @@ 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/Components/SlideFromRouteRight.dart';
import 'package:mymuseum_visitapp/app_context.dart';
import 'package:mymuseum_visitapp/constants.dart';
import 'package:permission_handler/permission_handler.dart';
@@ -301,10 +302,13 @@ class _ConfigurationPageState extends State with WidgetsBindi
//visitAppContext.isArticleCurrentlyShown = true;
lastTimePopUpWasClosed = DateTime.now();
+ // `SlideFromRightRoute` et non `MaterialPageRoute` : elle seule
+ // est non opaque, donc seule elle laisse le voile de chargement
+ // de la section se poser sur l'ecran d'ou vient le visiteur.
Navigator.push(
context,
- MaterialPageRoute(
- builder: (context) => SectionPage(
+ SlideFromRightRoute(
+ page: SectionPage(
configuration: widget.configuration,
rawSection: null,
visitAppContextIn: visitAppContext,
@@ -364,8 +368,8 @@ class _ConfigurationPageState extends State with WidgetsBindi
visitAppContext: visitAppContext,
configurationId: widget.configuration.id,
onNavigateToSection: (sectionId, sectionTitle) {
- Navigator.push(context, MaterialPageRoute(
- builder: (_) => SectionPage(
+ Navigator.push(context, SlideFromRightRoute(
+ page: SectionPage(
configuration: widget.configuration,
rawSection: null,
visitAppContextIn: visitAppContext,
diff --git a/lib/Screens/Home/home_3.0.dart b/lib/Screens/Home/home_3.0.dart
index f0309f4..580cb1b 100644
--- a/lib/Screens/Home/home_3.0.dart
+++ b/lib/Screens/Home/home_3.0.dart
@@ -1,5 +1,6 @@
import 'dart:async';
+import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/services.dart';
@@ -17,6 +18,7 @@ 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/instanceImages.dart';
import 'package:mymuseum_visitapp/Helpers/modelsHelper.dart';
import 'package:mymuseum_visitapp/Helpers/networkCheck.dart';
import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart';
@@ -76,6 +78,11 @@ class _HomePage3State extends State with WidgetsBindingObserver {
/// visite pour cacher `order`/`gridSpan`, donc « la ligne existe » ne veut
/// pas dire « le contenu est là ».
Set downloadedConfigIds = {};
+
+ /// État réseau au dernier chargement de l'accueil. Une visite non téléchargée
+ /// n'a rien à montrer sans réseau : elle reste affichée — titre et image sont
+ /// en cache — mais ternie et non ouvrable, plutôt que disparue de la grille.
+ bool isOnline = true;
late VisitAppContext visitAppContext;
late Future?> _futureConfigurations;
@@ -84,10 +91,39 @@ class _HomePage3State extends State with WidgetsBindingObserver {
void initState() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.initState();
+ // `WidgetsBindingObserver` était déclaré mais jamais enregistré : aucun des
+ // rappels de cycle de vie n'arrivait.
+ WidgetsBinding.instance.addObserver(this);
final appContext = Provider.of(context, listen: false);
_futureConfigurations = getConfigurationsCall(appContext);
}
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ // Le visiteur qui coupe le mode avion ou rejoint le wifi du site revient dans
+ // l'app par là. Sans ce rappel, l'accueil gardait l'état réseau de son premier
+ // chargement : tuiles ternies et visites refusées alors que le réseau est revenu.
+ if (state == AppLifecycleState.resumed) _reloadIfNetworkChanged();
+ }
+
+ /// Reteste le réseau et, s'il a changé, relance le chargement de l'accueil —
+ /// images et titres compris, qui viennent du même appel.
+ Future _reloadIfNetworkChanged() async {
+ final online = await hasNetwork();
+ if (!mounted || online == isOnline) return;
+ setState(() {
+ isOnline = online;
+ _futureConfigurations = getConfigurationsCall(
+ Provider.of(context, listen: false));
+ });
+ }
+
Widget _buildCard(BuildContext context, ConfigurationDTO config) {
final lang = visitAppContext.language ?? "FR";
final titleEntry = config.title?.firstWhere(
@@ -103,6 +139,9 @@ class _HomePage3State extends State with WidgetsBindingObserver {
final isOffline = config.isOffline == true;
final needsDownload = isOffline && !downloadedConfigIds.contains(config.id);
+ // Sans réseau, seul ce qui est sur le device s'ouvre — y compris pour une
+ // visite qui n'est pas en mode hors ligne.
+ final unreachable = !isOnline && !downloadedConfigIds.contains(config.id);
return InkWell(
borderRadius: BorderRadius.circular(16),
@@ -124,10 +163,13 @@ class _HomePage3State extends State with WidgetsBindingObserver {
offset: Offset(0, 2),
),
],
+ // En cache disque : une fois la vignette vue en ligne, elle
+ // s'affiche encore sans réseau. `NetworkImage` la re-demandait à
+ // chaque ouverture et rendait une croix rouge hors ligne.
image: config.imageSource != null
? DecorationImage(
fit: BoxFit.cover,
- image: NetworkImage(config.imageSource!),
+ image: CachedNetworkImageProvider(config.imageSource!),
)
: null,
),
@@ -152,26 +194,34 @@ class _HomePage3State extends State with WidgetsBindingObserver {
),
// Une visite hors ligne pas encore récupérée est volontairement
// ternie : la tuile dit qu'elle n'est pas prête avant toute lecture.
- if (needsDownload)
+ if (needsDownload || unreachable)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
- color: Colors.black.withValues(alpha: 0.3),
+ color: Colors.black.withValues(alpha: unreachable ? 0.55 : 0.3),
),
),
),
- if (isOffline)
+ if (isOffline || unreachable)
Positioned(
top: 8,
right: 8,
child: GlassPill(
size: 32,
- onTap: () => _promptDownload(context, config),
+ // Une visite injoignable ne se télécharge pas non plus :
+ // proposer l'export sans réseau n'ouvrirait qu'un échec.
+ onTap: () => unreachable && !isOffline
+ ? _showOfflineNotice(context)
+ : _promptDownload(context, config),
child: Icon(
- needsDownload ? Icons.arrow_downward_rounded : Icons.check_rounded,
+ unreachable && !isOffline
+ ? Icons.wifi_off_rounded
+ : needsDownload
+ ? Icons.arrow_downward_rounded
+ : Icons.check_rounded,
size: 17,
- color: needsDownload ? Colors.white : Colors.greenAccent,
+ color: needsDownload || unreachable ? Colors.white : Colors.greenAccent,
),
),
),
@@ -219,10 +269,35 @@ class _HomePage3State extends State with WidgetsBindingObserver {
);
}
+ void _showOfflineNotice(BuildContext context) {
+ final ctx = Provider.of(context, listen: false).getContext();
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(TranslationHelper.getFromLocale("noInternet", ctx)),
+ backgroundColor: kMainColor2,
+ ),
+ );
+ }
+
Future _openConfiguration(BuildContext context, ConfigurationDTO config) async {
final appCtx = Provider.of(context, listen: false);
final ctx = appCtx.getContext() as VisitAppContext;
+ // Le détail lit l'API dès qu'il n'est pas servi par la base locale : sans
+ // réseau ni contenu téléchargé, la visite s'ouvrirait vide.
+ //
+ // Deuxième mesure du réseau avant de refuser : la première date du chargement
+ // de l'accueil, et refuser sur une mesure périmée est le pire des cas — le
+ // visiteur voit ses barres de réseau et l'app lui dit qu'il est hors ligne.
+ if (!isOnline && !downloadedConfigIds.contains(config.id)) {
+ await _reloadIfNetworkChanged();
+ if (!context.mounted) return;
+ if (!isOnline) {
+ _showOfflineNotice(context);
+ return;
+ }
+ }
+
// Une visite hors ligne non téléchargée ouvrait un détail vide : `body.dart`
// lit ses sections uniquement en base locale quand `isOffline` est vrai.
if (config.isOffline == true && !downloadedConfigIds.contains(config.id)) {
@@ -537,19 +612,27 @@ class _HomePage3State extends State with WidgetsBindingObserver {
return Scaffold(
extendBody: true,
+ // Le fond de l'accueil, pas le blanc du theme : pendant un rechargement,
+ // l'ecran virait au blanc le temps de la requete.
+ backgroundColor: _kBackground,
body: FutureBuilder(
future: _futureConfigurations,
builder: (context, AsyncSnapshot snapshot) {
- if (snapshot.connectionState == ConnectionState.done) {
+ // Un rechargement (retour du reseau) ne renvoie pas le visiteur sur un
+ // ecran vide : tant qu'on a les visites du chargement precedent, la
+ // grille reste affichee et se met a jour quand la reponse arrive.
+ if (snapshot.connectionState == ConnectionState.done || weightedConfigs.isNotEmpty) {
final mobileConfigIds = visitAppContext.applicationInstanceDTO
?.configurations
?.where((c) => c.isActive == true)
.map((c) => c.configurationId)
.toSet() ?? {};
- weightedConfigs = List<_WeightedConfig>.from(snapshot.data)
- .where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id))
- .toList();
- configurations = weightedConfigs.map((w) => w.configuration).toList();
+ if (snapshot.hasData) {
+ weightedConfigs = List<_WeightedConfig>.from(snapshot.data)
+ .where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id))
+ .toList();
+ configurations = weightedConfigs.map((w) => w.configuration).toList();
+ }
final lang = visitAppContext.language ?? "FR";
final headerTitleEntry = configurations.isNotEmpty
@@ -600,12 +683,36 @@ class _HomePage3State extends State with WidgetsBindingObserver {
child: Stack(
fit: StackFit.expand,
children: [
+ // Ordre : l'événement à la une s'il y en a un — il
+ // remplace tout l'en-tête, titre et bouton compris —
+ // puis l'image principale de l'app définie dans le
+ // manager (Applications → Mobile). L'image de la
+ // première visite ne reste qu'en dernier recours :
+ // prendre la vignette d'une visite pour bannière de
+ // l'app était un choix par défaut, pas une intention.
+ //
+ // Le fichier local d'abord : `InstanceImages` l'a mis
+ // en cache au démarrage, donc la bannière tient hors
+ // ligne. `Image.network` y laissait une croix rouge.
if (featuredEvent?.imageSource != null)
- Image.network(featuredEvent!.imageSource!, fit: BoxFit.cover)
- else if (configurations.isNotEmpty && configurations[0].imageSource != null)
- Image.network(
- configurations[0].imageSource!,
+ CachedNetworkImage(
+ imageUrl: featuredEvent!.imageSource!,
fit: BoxFit.cover,
+ errorWidget: (_, __, ___) => const SizedBox.shrink(),
+ )
+ else if (InstanceImages.main != null)
+ Image.file(InstanceImages.main!, fit: BoxFit.cover)
+ else if (visitAppContext.applicationInstanceDTO?.mainImageUrl != null)
+ CachedNetworkImage(
+ imageUrl: visitAppContext.applicationInstanceDTO!.mainImageUrl!,
+ fit: BoxFit.cover,
+ errorWidget: (_, __, ___) => const SizedBox.shrink(),
+ )
+ else if (configurations.isNotEmpty && configurations[0].imageSource != null)
+ CachedNetworkImage(
+ imageUrl: configurations[0].imageSource!,
+ fit: BoxFit.cover,
+ errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
// Bottom gradient for title readability
const DecoratedBox(
@@ -849,14 +956,60 @@ class _HomePage3State extends State with WidgetsBindingObserver {
],
),
),
- if (visitAppContext.applicationInstanceDTO?.isAssistant == true)
- Positioned(
- bottom: 24,
- right: 16,
- child: FloatingActionButton(
- heroTag: 'assistant_home',
- backgroundColor: kMainColor1,
- onPressed: () {
+ // Scanner et assistant empiles dans le meme coin. Le scanner est
+ // en bas : c'est le geste le plus frequent, donc le plus a portee
+ // de pouce. Depuis que le scan d'un QR d'une autre visite bascule
+ // proprement dessus, il n'y a plus de raison de le reserver a
+ // l'interieur d'une visite.
+ Positioned(
+ bottom: 20,
+ right: 14,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ if (visitAppContext.applicationInstanceDTO?.isAssistant == true) ...[
+ _assistantButton(context),
+ const SizedBox(height: 12),
+ ],
+ // Meme pastille de verre que le reglage de langue et le badge
+ // de telechargement d'une tuile : sur l'accueil, les actions
+ // se posent sur le contenu, elles ne s'y superposent pas en
+ // disque plein. Le `Padding` recentre la pastille de 46 sous
+ // le bouton d'assistant, plus large de 10.
+ Padding(
+ padding: const EdgeInsets.only(right: 5),
+ child: ScannerBouton(appContext: appContext, size: 46, transparent: true),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ } else if (snapshot.connectionState == ConnectionState.none) {
+ return Text(TranslationHelper.getFromLocale("noData", appContext.getContext()));
+ } else {
+ return Center(
+ child: SizedBox(
+ height: size.height * 0.15,
+ child: const LoadingCommon(),
+ ),
+ );
+ }
+ },
+ ),
+ );
+ }
+
+
+ /// Sorti du `build` pour que le coin bas-droit se lise comme ce qu'il est :
+ /// une pile de deux boutons.
+ Widget _assistantButton(BuildContext context) {
+ return FloatingActionButton(
+ heroTag: 'assistant_home',
+ backgroundColor: kMainColor1,
+ onPressed: () {
+
AssistantChatSheet.show(
context,
visitAppContext: visitAppContext,
@@ -878,24 +1031,8 @@ class _HomePage3State extends State with WidgetsBindingObserver {
}
},
);
- },
- child: const Icon(Icons.chat_bubble_outline, color: Colors.white),
- ),
- ),
- ],
- );
- } else if (snapshot.connectionState == ConnectionState.none) {
- return Text(TranslationHelper.getFromLocale("noData", appContext.getContext()));
- } else {
- return Center(
- child: SizedBox(
- height: size.height * 0.15,
- child: const LoadingCommon(),
- ),
- );
- }
- },
- ),
+ },
+ child: const Icon(Icons.chat_bubble_outline, color: Colors.white),
);
}
@@ -919,6 +1056,7 @@ class _HomePage3State extends State with WidgetsBindingObserver {
Future?> _fetchConfigurations(AppContext appContext) async {
bool isOnline = await hasNetwork();
+ this.isOnline = isOnline;
VisitAppContext visitAppContext = appContext.getContext();
List? configurations;
@@ -997,6 +1135,12 @@ class _HomePage3State extends State with WidgetsBindingObserver {
final mobileInstance = instances?.where((e) => e.appType == AppType.Mobile).firstOrNull;
if (mobileInstance != null) {
visitAppContext.applicationInstanceDTO = mobileInstance;
+ // Deuxième point d'alimentation du cache d'images, avec le boot : c'est
+ // ici que l'ApplicationInstance est lue de façon certaine.
+ InstanceImages.refresh(
+ loaderUrl: mobileInstance.loaderImageUrl,
+ mainImageUrl: mobileInstance.mainImageUrl,
+ );
if (mobileInstance.hasStats == true) {
visitAppContext.statisticsService = StatisticsService(
clientAPI: visitAppContext.clientAPI,
@@ -1031,29 +1175,28 @@ class _HomePage3State extends State with WidgetsBindingObserver {
[];
weightedConfigs.sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
- // Best-effort : garde order/spans en cache local pour que le rendu
- // hors-ligne reste cohérent avec ce qui vient d'être vu en ligne.
- //
- // `update` et non `insert` : ce dernier bascule sur un INSERT quand la
- // ligne n'existe pas, et ces quatre colonnes ne suffisent pas à satisfaire
- // les NOT NULL de la table. Toute visite pas encore téléchargée levait donc
- // « NOT NULL constraint failed: configurations.instanceId » à chaque
- // démarrage. Un UPDATE sans ligne cible ne touche rien, ce qui est la
- // bonne sémantique : ces spans complètent une visite en cache, ils ne la
- // créent pas.
+ // Best-effort : garde la visite entière — titre, image, couleurs, spans —
+ // en cache local, et pas seulement order/spans comme avant. Hors ligne,
+ // la table `configurations` est la seule source de l'accueil : une visite
+ // jamais téléchargée en était absente et disparaissait purement de la
+ // grille. Elle y reste maintenant, ternie et non ouvrable, et l'image
+ // suit via le cache disque de `CachedNetworkImageProvider`.
for (final w in weightedConfigs) {
try {
- await DatabaseHelper.instance.update(
+ await DatabaseHelper.instance.insert(
DatabaseTableType.configurations,
{
+ ...ModelsHelper.configurationToMap(w.configuration),
+ // Colonne NOT NULL : un `isOffline` absent du DTO ferait échouer
+ // l'insertion, donc perdre la visite pour l'accueil hors ligne.
+ DatabaseHelper.columnIsOffline: w.configuration.isOffline == true,
DatabaseHelper.columnConfigOrder: w.order,
DatabaseHelper.columnGridColSpan: w.colSpan,
DatabaseHelper.columnGridRowSpan: w.rowSpan,
},
- w.configuration.id!,
);
} catch (e) {
- print("Could not cache order/spans for configuration ${w.configuration.id}: $e");
+ print("Could not cache configuration ${w.configuration.id}: $e");
}
}
diff --git a/lib/Screens/Sections/Article/article_page.dart b/lib/Screens/Sections/Article/article_page.dart
index 78e53a6..4fd888f 100644
--- a/lib/Screens/Sections/Article/article_page.dart
+++ b/lib/Screens/Sections/Article/article_page.dart
@@ -10,6 +10,7 @@ import 'package:mymuseum_visitapp/Components/GlassPill.dart';
import 'package:mymuseum_visitapp/Components/loading_common.dart';
import 'package:mymuseum_visitapp/Components/SliderImages.dart';
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart';
+import 'package:mymuseum_visitapp/Helpers/ImageCustomProvider.dart';
import 'package:mymuseum_visitapp/Helpers/translationHelper.dart';
import 'package:mymuseum_visitapp/Models/articleRead.dart';
import 'package:mymuseum_visitapp/Models/resourceModel.dart';
@@ -51,6 +52,14 @@ class _ArticlePageState extends State {
audioResourceModel = widget.resourcesModel.firstWhere((r) => r?.id == widget.mainAudioId, orElse: () => null);
resourcesModelToShow = widget.resourcesModel.where((r) => r?.id != widget.mainAudioId).toList();
+ // Le fichier local n'était jamais rattaché : `audioFile` restait null et le
+ // lecteur retombait toujours sur l'URL, donc plus d'audio dès que le réseau
+ // manque — alors que le MP3 est bien sur le device depuis le téléchargement.
+ final audioPath = audioResourceModel?.path;
+ if (audioPath != null && audioPath.isNotEmpty && File(audioPath).existsSync()) {
+ audioFile = File(audioPath);
+ }
+
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.visitAppContextIn.statisticsService?.track(
VisitEventType.articleRead,
@@ -102,10 +111,16 @@ class _ArticlePageState extends State {
kMainColor2,
],
),
+ // `NetworkImage` ne connaît que l'URL : hors ligne, l'en-tête tombait
+ // sur une SocketException alors que l'image est sur le device.
+ // `ImageCustomProvider` sert le fichier local et ne repasse par le
+ // réseau (en cache) que s'il est absent.
image: widget.articleDTO.imageSource != null ? DecorationImage(
fit: BoxFit.cover,
opacity: 0.65,
- image: NetworkImage(
+ image: ImageCustomProvider.getImageProvider(
+ appContext,
+ widget.articleDTO.imageId,
widget.articleDTO.imageSource!,
),
): null,
@@ -139,8 +154,11 @@ class _ArticlePageState extends State {
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
+ // Le centrage passe par un `div` porteur et non par
+ // `customStylesBuilder` : un titre sans balise n'a pas
+ // d'élément à styler, il restait donc aligné à gauche.
child: HtmlWidget(
- cleanedTitle,
+ '$cleanedTitle
',
// Blanc sur photo : sans ombre portée, un titre posé sur
// une zone claire de l'image devient illisible.
textStyle: const TextStyle(
@@ -165,12 +183,14 @@ class _ArticlePageState extends State {
appContext.setContext(visitAppContext);
});
},
+ // 40 + 12 des deux côtés : le titre est centré sur l'écran,
+ // pas sur ce qui reste entre deux marges inégales.
child: SizedBox(
- width: 50,
+ width: 40,
child: visitAppContext.isMaximizeTextSize ? const Icon(Icons.text_fields, size: 30, color: Colors.white) : const Icon(Icons.format_size, size: 30, color: Colors.white)
),
),
- const SizedBox(width: 4),
+ const SizedBox(width: 12),
],
),
),
@@ -249,10 +269,10 @@ class _ArticlePageState extends State {
// s'étire vers la gauche par-dessus la page pour laisser régler l'écoute.
// En `floatingActionButton`/`miniEndFloat`, le bouton couvrait en
// permanence les dernières lignes du texte.
- if (audioResourceModel != null && audioResourceModel!.source != null)
+ if (audioFile != null || audioResourceModel?.source != null)
AudioPlayerTab(
file: audioFile,
- resourceURl: audioResourceModel!.source!,
+ resourceURl: audioResourceModel?.source,
isAuto: widget.articleDTO.isReadAudioAuto!,
),
],
diff --git a/lib/Screens/Sections/Article/audio_player_tab.dart b/lib/Screens/Sections/Article/audio_player_tab.dart
index 45d7ee4..5d72688 100644
--- a/lib/Screens/Sections/Article/audio_player_tab.dart
+++ b/lib/Screens/Sections/Article/audio_player_tab.dart
@@ -14,7 +14,10 @@ class AudioPlayerTab extends StatefulWidget {
const AudioPlayerTab({Key? key, required this.file, required this.resourceURl, required this.isAuto}) : super(key: key);
final File? file;
- final String resourceURl;
+
+ /// Nulle quand la ressource n'est connue que par son fichier local : une visite
+ /// hors ligne n'a pas toujours d'URL à jour, et le lecteur ne doit pas en dépendre.
+ final String? resourceURl;
final bool isAuto;
@override
@@ -137,9 +140,9 @@ class _AudioPlayerTabState extends State
if(audiobytes != null) {
print("GOT AUDIOBYYYTES - LOCALLY SOSO");
await player.setAudioSource(LoadedSource(audiobytes!));
- } else {
+ } else if (widget.resourceURl != null) {
print("GET SOUND BY URL");
- await player.dynamicSet(url: widget.resourceURl);
+ await player.dynamicSet(url: widget.resourceURl!);
}
if(widget.isAuto) {
@@ -150,6 +153,9 @@ class _AudioPlayerTabState extends State
isplaying = true;
audioplayed = true;
});
+ // La pastille reste repliée : en lecture auto le visiteur vient lire, pas
+ // régler l'écoute, et le panneau déplié couvrirait le texte d'entrée de
+ // jeu. Un appui l'ouvre quand il en a besoin.
}
});
super.initState();
@@ -256,7 +262,10 @@ class _AudioPlayerTabState extends State
: IconButton(
icon: Icon(isplaying ? Icons.pause : Icons.play_arrow,
color: Colors.white),
- onPressed: _toggleExpansion,
+ // Repliée, la pastille montre une icône « lecture » : le
+ // premier appui doit lancer le son, pas ouvrir un panneau.
+ // Une fois l'écoute commencée, elle donne accès aux réglages.
+ onPressed: isplaying || audioplayed ? _toggleExpansion : _togglePlay,
),
),
),
diff --git a/lib/Screens/section_page.dart b/lib/Screens/section_page.dart
index 5ef2681..9b64449 100644
--- a/lib/Screens/section_page.dart
+++ b/lib/Screens/section_page.dart
@@ -57,6 +57,9 @@ class _SectionPageState extends State {
List