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>? icons; String? mainAudioId; + + /// Memorise : sans lui, `future:` reconstruisait l'appel a chaque rebuild. + Future? _sectionDetail; DateTime? _sectionOpenTime; @override @@ -96,20 +99,38 @@ class _SectionPageState extends State { var test = SectionDTO.fromJson(jsonDecode(jsonEncode(widget.rawSection))); - return Scaffold( - key: _scaffoldKey, - resizeToAvoidBottomInset: false, - body: MediaQuery.removeViewInsets( - context: context, - removeBottom: true, - child: OrientationBuilder( - builder: (context, orientation) { - return FutureBuilder( - future: getSectionDetail(appContext, visitAppContext.clientAPI, widget.sectionId), - builder: (context, AsyncSnapshot snapshot) { - var sectionResult = snapshot.data; - if(sectionDTO != null && sectionResult != null) { - switch(sectionDTO!.type) { + // Le `FutureBuilder` passe au-dessus du `Scaffold` : c'est le seul endroit + // d'où l'on sait si la section est prête, et c'est cette information qui + // décide de la couleur du fond. + // + // Le futur est aussi mémorisé ici. Il était construit dans `future:`, donc + // relancé à chaque rebuild : chaque changement d'orientation, chaque + // `setState` refaisait l'appel réseau de la section. + return FutureBuilder( + future: _sectionDetail ??= + getSectionDetail(appContext, visitAppContext.clientAPI, widget.sectionId), + builder: (context, AsyncSnapshot snapshot) { + var sectionResult = snapshot.data; + final isReady = sectionDTO != null && sectionResult != null; + + return Scaffold( + key: _scaffoldKey, + resizeToAvoidBottomInset: false, + // Transparent tant que la section n'est pas prête. Combiné à la route + // non opaque, le visiteur garde sous les yeux l'écran d'où il vient, + // assombri, avec le loader par-dessus — plutôt qu'une page blanche. + backgroundColor: isReady ? null : Colors.transparent, + body: MediaQuery.removeViewInsets( + context: context, + removeBottom: true, + child: OrientationBuilder( + builder: (context, orientation) { + // Rien d'autre que le loader : pas de voile. La page d'où vient + // le visiteur reste lisible telle quelle, le temps du chargement. + if (!isReady) { + return const Center(child: LoadingCommon()); + } + switch(sectionDTO!.type) { case SectionType.Agenda: AgendaDTO agendaDTO = AgendaDTO.fromJson(sectionResult)!; return AgendaPage(section: agendaDTO); @@ -166,14 +187,11 @@ class _SectionPageState extends State { default: return const Center(child: Text("Unsupported type")); } - } else { - return const LoadingCommon(); - } - } - ); - } - ), - ) + }, + ), + ), + ); + }, ); } diff --git a/lib/Screens/splash_screen.dart b/lib/Screens/splash_screen.dart index 25610d4..9707c20 100644 --- a/lib/Screens/splash_screen.dart +++ b/lib/Screens/splash_screen.dart @@ -1,65 +1,54 @@ import 'package:flutter/material.dart'; +import 'package:mymuseum_visitapp/Components/loading_common.dart'; +import 'package:mymuseum_visitapp/Helpers/instanceImages.dart'; import 'package:mymuseum_visitapp/constants.dart'; -class SplashScreen extends StatefulWidget { +class SplashScreen extends StatelessWidget { const SplashScreen({Key? key}) : super(key: key); - @override - State createState() => _SplashScreenState(); -} - -class _SplashScreenState extends State with SingleTickerProviderStateMixin { - late AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - )..repeat(reverse: true); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; return Scaffold( - backgroundColor: kBackgroundColor, - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Image.asset( - kSplashLogoAsset, - width: 200, - height: 200, - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => Icon( - Icons.museum_outlined, - size: 100, - color: kMainColor1, - ), + backgroundColor: kSplashBackground, + // Le splash s'affiche pendant le boot, avant tout appel API : il ne peut + // lire que ce qu'un lancement précédent a mis en cache. Au tout premier + // démarrage, le logo retombe donc sur l'image du flavor. + body: FutureBuilder( + future: InstanceImages.restore(), + builder: (context, _) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(32), + child: InstanceImages.main != null + ? Image.file(InstanceImages.main!, + width: 160, height: 160, fit: BoxFit.contain) + : Image.asset( + kSplashLogoAsset, + width: 160, + height: 160, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const Icon( + Icons.museum_outlined, + size: 100, + color: kMainColor1, + ), + ), + ), + const SizedBox(height: 24), + const Text( + kAppTitle, + style: TextStyle( + color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 40), + RotatingLoader(file: InstanceImages.loader, size: 64), + ], ), - const SizedBox(height: 48), - RotationTransition( - turns: Tween(begin: 0.0, end: 3.0).animate(_controller), - child: Image.asset( - kLoaderAsset, - width: size.height * 0.07, - height: size.height * 0.07, - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => - CircularProgressIndicator(color: kMainColor1), - ), - ), - ], - ), + ); + }, ), ); } diff --git a/lib/Services/downloadConfiguration.dart b/lib/Services/downloadConfiguration.dart index 56c794e..6e44e86 100644 --- a/lib/Services/downloadConfiguration.dart +++ b/lib/Services/downloadConfiguration.dart @@ -418,27 +418,26 @@ class _DownloadConfigurationWidgetState extends State { } localContext.guideVoiceId = instanceDto?.guideVoiceId; + + // Loader et image principale définis dans le manager (Applications → + // Mobile). `instanceGetDetail` porte déjà les ApplicationInstances : pas + // d'appel supplémentaire, et le cache est prêt pour le splash du + // lancement suivant. + final mobileInstance = instanceDto?.applicationInstanceDTOs + ?.where((a) => a.appType == AppType.Mobile) + .firstOrNull; + // Sans `await` : le boot n'a pas à attendre un téléchargement d'image. + // Le splash affiche ce qui est déjà en cache, et l'app le rattrape. + if (mobileInstance != null) { + InstanceImages.refresh( + loaderUrl: mobileInstance.loaderImageUrl, + mainImageUrl: mobileInstance.mainImageUrl, + ); + } } catch (e) { // Hors ligne : la voix retombe sur le repli du build, le reste de l'app tourne. print('Could not load instance detail: $e'); diff --git a/pubspec.lock b/pubspec.lock index 9dad4f0..766c8c6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -210,7 +210,7 @@ packages: source: hosted version: "8.9.2" cached_network_image: - dependency: transitive + dependency: "direct main" description: name: cached_network_image sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" diff --git a/pubspec.yaml b/pubspec.yaml index 2235dff..def69a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,9 @@ dependencies: provider: ^6.1.2 fluttertoast: carousel_slider: ^5.0.0 + # Utilisé directement (accueil, CachedCustomResource, ImageCustomProvider) : + # il n'était tiré que comme dépendance transitive. + cached_network_image: ^3.3.1 #flutter_svg_provider: ^1.0.3 photo_view: ^0.15.0 intl: ^0.20.2 @@ -125,7 +128,6 @@ flutter: assets: - assets/splash/ - - assets/loader/ - assets/icons/ - assets/images/ - assets/images/old/