Splash, loader et mode hors ligne : ce qui etait invisible le reste

Le fil rouge de ces corrections : des replis silencieux. Une exception
rattrapee, une image absente, un fichier local jamais rattache — rien ne
plantait, mais rien ne marchait non plus, et aucun message ne le disait.

Splash et loader
- assets/splash et assets/loader ne contenaient que des PNG 1x1
  transparents. errorBuilder ne se declenche pas : le fichier est valide,
  juste vide. Logo et loader etaient donc invisibles PARTOUT, splash comme
  ecrans de chargement. assets/loader est supprime, kLoaderAsset avec.
- Le splash natif etait blanc (launch_background en @android:color/white,
  theme Light) puis l'app basculait en sombre. Fond unifie sur #111111,
  cote natif comme Dart, avec values-v31 : Android 12+ ignore
  windowBackground et repart sur le theme sans ces attributs.
- Le loader et l'image principale viennent maintenant du manager
  (Applications -> Mobile). InstanceImages les garde sur le device : le
  splash s'affiche avant tout appel API, il ne peut lire qu'un cache.
  Alimente au boot depuis instanceGetDetail, qui porte deja les
  ApplicationInstances — aucun appel supplementaire.
- Ouvrir une section montrait une page blanche avec un loader au milieu.
  SlideFromRightRoute devient non opaque et SectionPage garde un fond
  transparent tant que la section n'est pas prete : le visiteur garde sous
  les yeux l'ecran d'ou il vient.

Hors ligne
- L'audio d'un article ne repartait jamais du MP3 local : audioFile
  n'etait jamais renseigne, le lecteur retombait toujours sur l'URL.
- L'image de titre d'un article passait par NetworkImage, donc une croix
  rouge des que le reseau manque, alors que le fichier est sur le device.
- L'accueil ne gardait en base que order/gridSpan : une visite jamais
  telechargee disparaissait de la grille hors ligne. La visite entiere est
  desormais mise en cache, et une visite injoignable reste affichee,
  ternie et non ouvrable, plutot qu'absente.
- L'etat reseau datait du premier chargement et n'etait jamais remesure.
  WidgetsBindingObserver etait declare mais jamais enregistre : aucun
  rappel n'arrivait. L'accueil se recharge au retour dans l'app et
  remesure avant de refuser l'ouverture d'une visite.

Divers
- Le futur de getSectionDetail etait construit dans future:, donc relance
  a chaque rebuild — chaque rotation d'ecran refaisait l'appel reseau.
- ImageCustomProvider listait le repertoire d'une visite non telechargee :
  exception a chaque construction de chaque image, pour finir de toute
  facon sur le reseau.
- Scanner un QR d'une autre visite empilait les ConfigurationPage.
  pushAndRemoveUntil : une visite est une destination de premier niveau.
- Le scanner arrive sur l'accueil, en pastille de verre comme les autres
  actions de cet ecran. ScannerBouton prend size et transparent, et garde
  son apparence d'origine ailleurs.
- Le titre d'un article n'etait pas centre : customStylesBuilder ne
  s'applique qu'aux elements, et un titre sans balise n'en a aucun.
- Le lecteur audio replie ne lancait pas la lecture au premier appui, il
  ouvrait le panneau. Il lit, puis ouvre. En lecture auto il reste replie.
- Le dialogue de telechargement faisait la hauteur de l'ecran : un Center
  prend toute la hauteur maximale que lui donne un AlertDialog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-09 16:47:27 +02:00
parent e15f874d20
commit 6709726208
30 changed files with 653 additions and 273 deletions

View File

@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<item android:drawable="@color/splash_background" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@mipmap/ic_launcher" />
</item>
</layer-list>

View File

@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<item android:drawable="@color/splash_background" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@mipmap/ic_launcher" />
</item>
</layer-list>

View File

@ -13,6 +13,6 @@
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<item name="android:windowBackground">@color/splash_background</item>
</style>
</resources>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12+ ignore windowBackground au lancement : c'est la SplashScreen
API qui decide, et sans ces attributs elle repart sur le fond du theme
(blanc) avec l'icone d'application. -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
</style>
</resources>

View File

@ -1,4 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#FF8535</color>
<!-- Meme fond que kSplashBackground / l'accueil : le lancement partait sur
du blanc avant que Flutter ne dessine, puis basculait en sombre. -->
<color name="splash_background">#111111</color>
</resources>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
@ -12,7 +12,9 @@
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- `?android:colorBackground` valait blanc en theme clair : la fenetre
clignotait en blanc entre le splash et la premiere frame Flutter. -->
<item name="android:windowBackground">@color/splash_background</item>
</style>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -19,7 +19,7 @@
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="backgroundColor" red="0.066666666666666666" green="0.066666666666666666" blue="0.066666666666666666" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>

View File

@ -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<ScannerBouton> createState() => _ScannerBoutonState();
}
@ -23,6 +37,12 @@ class _ScannerBoutonState extends State<ScannerBouton> {
@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<ScannerBouton> {
)
],
),
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,
),
);
}

View File

@ -235,7 +235,14 @@ class _ScannerDialogState extends State<ScannerDialog> {
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<ScannerDialog> {
openSectionId: sectionId,
),
),
(route) => route.isFirst,
);
},
child: Text(TranslationHelper.getFromLocale('open', visitAppContext)),

View File

@ -5,6 +5,12 @@ class SlideFromRightRoute extends PageRouteBuilder {
SlideFromRightRoute({required this.page})
: super(
// Non opaque : l'écran d' 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)

View File

@ -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<LoadingCommon> createState() => _LoadingCommonState();
State<RotatingLoader> createState() => _RotatingLoaderState();
}
class _LoadingCommonState extends State<LoadingCommon> with TickerProviderStateMixin {
AnimationController? _controller;
@override
void initState() {
_controller = AnimationController(
duration: const Duration(milliseconds: 5000),
class _RotatingLoaderState extends State<RotatingLoader>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
duration: const Duration(seconds: 3),
vsync: this,
)..repeat();
super.initState();
}
@override
void dispose() {
_controller!.dispose();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final appContext = Provider.of<AppContext>(context);
VisitAppContext visitAppContext = appContext.getContext();
final size = widget.size ?? MediaQuery.of(context).size.height * 0.1;
final color = widget.color ?? kMainColor1;
Size size = MediaQuery.of(context).size;
_controller!.forward(from: 0.0);
_controller!.addListener(() {
if (_controller!.isCompleted) {
_controller!.reverse();
}
if(_controller!.isDismissed){
_controller!.forward();
}
});
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<File?>(
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,
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,
errorBuilder: (_, __, ___) =>
Icon(Icons.museum_outlined, color: kMainColor2, size: size.height * 0.1),
),
),
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<AppContext>(context);
final VisitAppContext visitAppContext = appContext.getContext();
final configuration = visitAppContext.configuration;
final primaryColor = configuration?.primaryColor != null
? Color(int.parse(
configuration!.primaryColor!.split('(0x')[1].split(')')[0],
radix: 16))
: kMainColor1;
return Center(
child: FutureBuilder<void>(
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<File?> _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<FileSystemEntity> 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;
}
} catch(e) {
print("ERROR _checkIfLocalResourceExists CachedCustomResource");
print(e);
}
if (visitAppContext.localPath == null || visitAppContext.configuration?.id == null) {
return null;
}
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;
}

View File

@ -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<FileSystemEntity> fileList = configurationDirectory.listSync();
if(imageId != null && fileList.any((fileL) => fileL.uri.pathSegments.last.contains(imageId))) {

View File

@ -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/<configId>/`) :
/// 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<void>? _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<void> restore() => _restoring ??= _restore();
static Future<void> _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<void> refresh({String? loaderUrl, String? mainImageUrl}) async {
await restore();
loader = await _sync(_loaderSlot, loaderUrl);
main = await _sync(_mainSlot, mainImageUrl);
}
static Future<Directory?> _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<File?> _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<File?> _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;
}
}
}

View File

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

View File

@ -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<HomePage3> with WidgetsBindingObserver {
/// visite pour cacher `order`/`gridSpan`, donc « la ligne existe » ne veut
/// pas dire « le contenu est ».
Set<String> 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<List<_WeightedConfig>?> _futureConfigurations;
@ -84,10 +91,39 @@ class _HomePage3State extends State<HomePage3> 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<AppContext>(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<void> _reloadIfNetworkChanged() async {
final online = await hasNetwork();
if (!mounted || online == isOnline) return;
setState(() {
isOnline = online;
_futureConfigurations = getConfigurationsCall(
Provider.of<AppContext>(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<HomePage3> 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<HomePage3> 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<HomePage3> 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<HomePage3> with WidgetsBindingObserver {
);
}
void _showOfflineNotice(BuildContext context) {
final ctx = Provider.of<AppContext>(context, listen: false).getContext();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(TranslationHelper.getFromLocale("noInternet", ctx)),
backgroundColor: kMainColor2,
),
);
}
Future<void> _openConfiguration(BuildContext context, ConfigurationDTO config) async {
final appCtx = Provider.of<AppContext>(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<HomePage3> 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<dynamic> 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() ?? {};
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<HomePage3> 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<HomePage3> with WidgetsBindingObserver {
],
),
),
if (visitAppContext.applicationInstanceDTO?.isAssistant == true)
// 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: 24,
right: 16,
child: FloatingActionButton(
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,
@ -880,22 +1033,6 @@ class _HomePage3State extends State<HomePage3> 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(),
),
);
}
},
),
);
}
@ -919,6 +1056,7 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
Future<List<_WeightedConfig>?> _fetchConfigurations(AppContext appContext) async {
bool isOnline = await hasNetwork();
this.isOnline = isOnline;
VisitAppContext visitAppContext = appContext.getContext();
List<ConfigurationDTO>? configurations;
@ -997,6 +1135,12 @@ class _HomePage3State extends State<HomePage3> 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<HomePage3> 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");
}
}

View File

@ -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<ArticlePage> {
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<ArticlePage> {
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<ArticlePage> {
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,
'<div style="text-align:center">$cleanedTitle</div>',
// 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<ArticlePage> {
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<ArticlePage> {
// 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!,
),
],

View File

@ -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<AudioPlayerTab>
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<AudioPlayerTab>
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<AudioPlayerTab>
: 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,
),
),
),

View File

@ -57,6 +57,9 @@ class _SectionPageState extends State<SectionPage> {
List<Map<String, dynamic>>? icons;
String? mainAudioId;
/// Memorise : sans lui, `future:` reconstruisait l'appel a chaque rebuild.
Future<dynamic>? _sectionDetail;
DateTime? _sectionOpenTime;
@override
@ -96,19 +99,37 @@ class _SectionPageState extends State<SectionPage> {
var test = SectionDTO.fromJson(jsonDecode(jsonEncode(widget.rawSection)));
// 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<dynamic> 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' 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) {
return FutureBuilder(
future: getSectionDetail(appContext, visitAppContext.clientAPI, widget.sectionId),
builder: (context, AsyncSnapshot<dynamic> snapshot) {
var sectionResult = snapshot.data;
if(sectionDTO != null && sectionResult != null) {
// Rien d'autre que le loader : pas de voile. La page d' 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)!;
@ -166,14 +187,11 @@ class _SectionPageState extends State<SectionPage> {
default:
return const Center(child: Text("Unsupported type"));
}
} else {
return const LoadingCommon();
}
}
);
}
},
),
)
),
);
},
);
}

View File

@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Scaffold(
backgroundColor: kBackgroundColor,
body: Center(
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<void>(
future: InstanceImages.restore(),
builder: (context, _) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
ClipRRect(
borderRadius: BorderRadius.circular(32),
child: InstanceImages.main != null
? Image.file(InstanceImages.main!,
width: 160, height: 160, fit: BoxFit.contain)
: Image.asset(
kSplashLogoAsset,
width: 200,
height: 200,
width: 160,
height: 160,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
errorBuilder: (_, __, ___) => const Icon(
Icons.museum_outlined,
size: 100,
color: kMainColor1,
),
),
const SizedBox(height: 48),
RotationTransition(
turns: Tween(begin: 0.0, end: 3.0).animate(_controller),
child: Image.asset(
kLoaderAsset,
width: size.height * 0.07,
height: size.height * 0.07,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) =>
CircularProgressIndicator(color: kMainColor1),
),
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),
],
),
);
},
),
);
}

View File

@ -418,18 +418,18 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
);
}
/// Pas de `Center` autour : dans un `AlertDialog`, le contenu reçoit une hauteur
/// maximale très large, et un `Center` la prend toute d'où un dialogue haut
/// comme l'écran pour deux lignes de texte. Une `Column` `min` s'ajuste au texte.
Widget _label(String text) {
return Center(
child: Padding(
return Padding(
padding: const EdgeInsets.all(8.0),
child: Text(text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 20)),
),
);
}
Widget _message(IconData icon, Color color, String text) {
return Center(
child: Padding(
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
@ -439,7 +439,6 @@ class _DownloadConfigurationWidgetState extends State<DownloadConfigurationWidge
Text(text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)),
],
),
),
);
}
}

View File

@ -149,18 +149,17 @@ const kAppTitle = _flavor == 'mdlf'
? 'Fort Saint-Héribert'
: 'MyMuseum Dev';
/// Fond du démarrage : celui de l'accueil, pas `kBackgroundColor` (blanc). Le
/// splash natif et le splash Dart le partagent avec `@color/splash_background`
/// côté Android sinon le lancement enchaîne blanc, blanc, puis noir.
const kSplashBackground = Color(0xFF111111);
const kSplashLogoAsset = _flavor == 'mdlf'
? 'assets/splash/mdlf.png'
: _flavor == 'fortsaintheribert'
? 'assets/splash/fortsaintheribert.png'
: 'assets/splash/dev.png';
const kLoaderAsset = _flavor == 'mdlf'
? 'assets/loader/mdlf.png'
: _flavor == 'fortsaintheribert'
? 'assets/loader/fortsaintheribert.png'
: 'assets/loader/dev.png';
const kBackgroundLight = Color(0xfff3f3f3);
const kGreen = Color(0xFF15bd5b);

View File

@ -10,6 +10,8 @@ import 'package:flutter/services.dart';
// TODO // import 'package:flutter_beacon/flutter_beacon.dart';
import 'package:get/get.dart';
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
import 'package:manager_api_new/api.dart';
import 'package:mymuseum_visitapp/Helpers/instanceImages.dart';
import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart';
import 'package:mymuseum_visitapp/Models/articleRead.dart';
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
@ -135,6 +137,22 @@ class _AppBootstrapState extends State<AppBootstrap> {
}
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');

View File

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

View File

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