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>
@ -1,12 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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">
|
<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
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@mipmap/ic_launcher" />
|
||||||
</item> -->
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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">
|
<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
|
<bitmap
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:src="@mipmap/launch_image" />
|
android:src="@mipmap/ic_launcher" />
|
||||||
</item> -->
|
</item>
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@ -13,6 +13,6 @@
|
|||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<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>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
14
android/app/src/main/res/values-v31/styles.xml
Normal 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>
|
||||||
@ -1,4 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="colorPrimary">#FF8535</color>
|
<color name="colorPrimary">#FF8535</color>
|
||||||
</resources>
|
<!-- 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>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
<!-- 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
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
the Flutter engine draws its first frame -->
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
@ -12,7 +12,9 @@
|
|||||||
running.
|
running.
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<!-- `?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>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 1.4 KiB |
@ -19,7 +19,7 @@
|
|||||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</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>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
<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"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
|
|||||||
@ -1,14 +1,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mymuseum_visitapp/Components/GlassPill.dart';
|
||||||
import 'package:mymuseum_visitapp/app_context.dart';
|
import 'package:mymuseum_visitapp/app_context.dart';
|
||||||
import 'package:mymuseum_visitapp/constants.dart';
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
|
|
||||||
import 'ScannerDialog.dart';
|
import 'ScannerDialog.dart';
|
||||||
|
|
||||||
class ScannerBouton extends StatefulWidget {
|
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;
|
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
|
@override
|
||||||
State<ScannerBouton> createState() => _ScannerBoutonState();
|
State<ScannerBouton> createState() => _ScannerBoutonState();
|
||||||
}
|
}
|
||||||
@ -23,6 +37,12 @@ class _ScannerBoutonState extends State<ScannerBouton> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return InkWell(
|
||||||
onTap: _onItemTapped,
|
onTap: _onItemTapped,
|
||||||
child: Container(
|
child: Container(
|
||||||
@ -37,9 +57,9 @@ class _ScannerBoutonState extends State<ScannerBouton> {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
height: 85.0,
|
height: widget.size,
|
||||||
width: 85.0,
|
width: widget.size,
|
||||||
child: const Icon(Icons.qr_code_scanner, color: Colors.white, size: 40),
|
child: icon,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -235,7 +235,14 @@ class _ScannerDialogState extends State<ScannerDialog> {
|
|||||||
visitAppContext.configuration = other;
|
visitAppContext.configuration = other;
|
||||||
visitAppContext.sectionIds = null;
|
visitAppContext.sectionIds = null;
|
||||||
widget.appContext!.setContext(visitAppContext);
|
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(
|
SlideFromRightRoute(
|
||||||
page: ConfigurationPage(
|
page: ConfigurationPage(
|
||||||
configuration: other,
|
configuration: other,
|
||||||
@ -243,6 +250,7 @@ class _ScannerDialogState extends State<ScannerDialog> {
|
|||||||
openSectionId: sectionId,
|
openSectionId: sectionId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
(route) => route.isFirst,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Text(TranslationHelper.getFromLocale('open', visitAppContext)),
|
child: Text(TranslationHelper.getFromLocale('open', visitAppContext)),
|
||||||
|
|||||||
@ -5,6 +5,12 @@ class SlideFromRightRoute extends PageRouteBuilder {
|
|||||||
|
|
||||||
SlideFromRightRoute({required this.page})
|
SlideFromRightRoute({required this.page})
|
||||||
: super(
|
: 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,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
const begin = Offset(1.0, 0.0); // départ à droite (hors écran)
|
const begin = Offset(1.0, 0.0); // départ à droite (hors écran)
|
||||||
|
|||||||
@ -2,116 +2,135 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.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/Models/visitContext.dart';
|
||||||
import 'package:mymuseum_visitapp/app_context.dart';
|
import 'package:mymuseum_visitapp/app_context.dart';
|
||||||
import 'package:mymuseum_visitapp/constants.dart';
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class LoadingCommon extends StatefulWidget {
|
/// Le loader lui-même, sans rien savoir du contexte : le splash s'affiche avant
|
||||||
const LoadingCommon({Key? key}) : super(key: key);
|
/// 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
|
@override
|
||||||
State<LoadingCommon> createState() => _LoadingCommonState();
|
State<RotatingLoader> createState() => _RotatingLoaderState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoadingCommonState extends State<LoadingCommon> with TickerProviderStateMixin {
|
class _RotatingLoaderState extends State<RotatingLoader>
|
||||||
AnimationController? _controller;
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _controller = AnimationController(
|
||||||
@override
|
duration: const Duration(seconds: 3),
|
||||||
void initState() {
|
vsync: this,
|
||||||
_controller = AnimationController(
|
)..repeat();
|
||||||
duration: const Duration(milliseconds: 5000),
|
|
||||||
vsync: this,
|
|
||||||
)..repeat();
|
|
||||||
super.initState();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller!.dispose();
|
_controller.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final appContext = Provider.of<AppContext>(context);
|
final appContext = Provider.of<AppContext>(context);
|
||||||
VisitAppContext visitAppContext = appContext.getContext();
|
final VisitAppContext visitAppContext = appContext.getContext();
|
||||||
|
final configuration = visitAppContext.configuration;
|
||||||
|
|
||||||
Size size = MediaQuery.of(context).size;
|
final primaryColor = configuration?.primaryColor != null
|
||||||
_controller!.forward(from: 0.0);
|
? Color(int.parse(
|
||||||
_controller!.addListener(() {
|
configuration!.primaryColor!.split('(0x')[1].split(')')[0],
|
||||||
if (_controller!.isCompleted) {
|
radix: 16))
|
||||||
_controller!.reverse();
|
: kMainColor1;
|
||||||
}
|
|
||||||
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(
|
return Center(
|
||||||
child: SizedBox(
|
child: FutureBuilder<void>(
|
||||||
height: size.height * 0.1,
|
future: InstanceImages.restore(),
|
||||||
child: RotationTransition(
|
builder: (context, _) {
|
||||||
turns: Tween(begin: 0.0, end: 3.0).animate(_controller!),
|
final localConfigLoader = configuration?.loaderImageId != null
|
||||||
child: visitAppContext.configuration != null && visitAppContext.configuration!.loaderImageUrl != null ?
|
? _localResource(visitAppContext, configuration!.loaderImageId!)
|
||||||
//Image.network(tabletAppContext.configuration!.loaderImageUrl!)
|
: null;
|
||||||
// TODO Replace by CustomCacheResource..
|
|
||||||
FutureBuilder<File?>(
|
return RotatingLoader(
|
||||||
future: _checkIfLocalResourceExists(visitAppContext, visitAppContext.configuration!.loaderImageId!),
|
file: localConfigLoader ?? InstanceImages.loader,
|
||||||
builder: (context, snapshot) {
|
imageUrl: localConfigLoader == null ? configuration?.loaderImageUrl : null,
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
color: primaryColor,
|
||||||
// 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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
Directory? appDocumentsDirectory = Platform.isIOS ? await getApplicationDocumentsDirectory() : await getDownloadsDirectory();
|
if (visitAppContext.localPath == null || visitAppContext.configuration?.id == null) {
|
||||||
String localPath = appDocumentsDirectory!.path;
|
return null;
|
||||||
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) {
|
final directory =
|
||||||
print("ERROR _checkIfLocalResourceExists CachedCustomResource");
|
Directory('${visitAppContext.localPath}/${visitAppContext.configuration!.id}');
|
||||||
print(e);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,12 @@ class ImageCustomProvider {
|
|||||||
try {
|
try {
|
||||||
if(appContext.getContext().localPath != null && visitAppContext.configuration != null) {
|
if(appContext.getContext().localPath != null && visitAppContext.configuration != null) {
|
||||||
Directory configurationDirectory = Directory('${visitAppContext.localPath!}/${visitAppContext.configuration!.id!}');
|
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();
|
List<FileSystemEntity> fileList = configurationDirectory.listSync();
|
||||||
|
|
||||||
if(imageId != null && fileList.any((fileL) => fileL.uri.pathSegments.last.contains(imageId))) {
|
if(imageId != null && fileList.any((fileL) => fileL.uri.pathSegments.last.contains(imageId))) {
|
||||||
|
|||||||
105
lib/Helpers/instanceImages.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/Services/pushNotificationService.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/quizz_page.dart';
|
import 'package:mymuseum_visitapp/Screens/Sections/Quiz/quizz_page.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/section_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/app_context.dart';
|
||||||
import 'package:mymuseum_visitapp/constants.dart';
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
@ -301,10 +302,13 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
|
|||||||
//visitAppContext.isArticleCurrentlyShown = true;
|
//visitAppContext.isArticleCurrentlyShown = true;
|
||||||
lastTimePopUpWasClosed = DateTime.now();
|
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(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
SlideFromRightRoute(
|
||||||
builder: (context) => SectionPage(
|
page: SectionPage(
|
||||||
configuration: widget.configuration,
|
configuration: widget.configuration,
|
||||||
rawSection: null,
|
rawSection: null,
|
||||||
visitAppContextIn: visitAppContext,
|
visitAppContextIn: visitAppContext,
|
||||||
@ -364,8 +368,8 @@ class _ConfigurationPageState extends State<ConfigurationPage> with WidgetsBindi
|
|||||||
visitAppContext: visitAppContext,
|
visitAppContext: visitAppContext,
|
||||||
configurationId: widget.configuration.id,
|
configurationId: widget.configuration.id,
|
||||||
onNavigateToSection: (sectionId, sectionTitle) {
|
onNavigateToSection: (sectionId, sectionTitle) {
|
||||||
Navigator.push(context, MaterialPageRoute(
|
Navigator.push(context, SlideFromRightRoute(
|
||||||
builder: (_) => SectionPage(
|
page: SectionPage(
|
||||||
configuration: widget.configuration,
|
configuration: widget.configuration,
|
||||||
rawSection: null,
|
rawSection: null,
|
||||||
visitAppContextIn: visitAppContext,
|
visitAppContextIn: visitAppContext,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:auto_size_text/auto_size_text.dart';
|
import 'package:auto_size_text/auto_size_text.dart';
|
||||||
import 'package:flutter/services.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/Services/pushNotificationService.dart';
|
||||||
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
||||||
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.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/modelsHelper.dart';
|
||||||
import 'package:mymuseum_visitapp/Helpers/networkCheck.dart';
|
import 'package:mymuseum_visitapp/Helpers/networkCheck.dart';
|
||||||
import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.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
|
/// visite pour cacher `order`/`gridSpan`, donc « la ligne existe » ne veut
|
||||||
/// pas dire « le contenu est là ».
|
/// pas dire « le contenu est là ».
|
||||||
Set<String> downloadedConfigIds = {};
|
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 VisitAppContext visitAppContext;
|
||||||
|
|
||||||
late Future<List<_WeightedConfig>?> _futureConfigurations;
|
late Future<List<_WeightedConfig>?> _futureConfigurations;
|
||||||
@ -84,10 +91,39 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
void initState() {
|
void initState() {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
super.initState();
|
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);
|
final appContext = Provider.of<AppContext>(context, listen: false);
|
||||||
_futureConfigurations = getConfigurationsCall(appContext);
|
_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) {
|
Widget _buildCard(BuildContext context, ConfigurationDTO config) {
|
||||||
final lang = visitAppContext.language ?? "FR";
|
final lang = visitAppContext.language ?? "FR";
|
||||||
final titleEntry = config.title?.firstWhere(
|
final titleEntry = config.title?.firstWhere(
|
||||||
@ -103,6 +139,9 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
final isOffline = config.isOffline == true;
|
final isOffline = config.isOffline == true;
|
||||||
final needsDownload = isOffline && !downloadedConfigIds.contains(config.id);
|
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(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@ -124,10 +163,13 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
offset: Offset(0, 2),
|
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
|
image: config.imageSource != null
|
||||||
? DecorationImage(
|
? DecorationImage(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
image: NetworkImage(config.imageSource!),
|
image: CachedNetworkImageProvider(config.imageSource!),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
@ -152,26 +194,34 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
// Une visite hors ligne pas encore récupérée est volontairement
|
// 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.
|
// ternie : la tuile dit qu'elle n'est pas prête avant toute lecture.
|
||||||
if (needsDownload)
|
if (needsDownload || unreachable)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
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(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
child: GlassPill(
|
child: GlassPill(
|
||||||
size: 32,
|
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(
|
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,
|
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 {
|
Future<void> _openConfiguration(BuildContext context, ConfigurationDTO config) async {
|
||||||
final appCtx = Provider.of<AppContext>(context, listen: false);
|
final appCtx = Provider.of<AppContext>(context, listen: false);
|
||||||
final ctx = appCtx.getContext() as VisitAppContext;
|
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`
|
// 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.
|
// lit ses sections uniquement en base locale quand `isOffline` est vrai.
|
||||||
if (config.isOffline == true && !downloadedConfigIds.contains(config.id)) {
|
if (config.isOffline == true && !downloadedConfigIds.contains(config.id)) {
|
||||||
@ -537,19 +612,27 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
extendBody: true,
|
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(
|
body: FutureBuilder(
|
||||||
future: _futureConfigurations,
|
future: _futureConfigurations,
|
||||||
builder: (context, AsyncSnapshot<dynamic> snapshot) {
|
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
|
final mobileConfigIds = visitAppContext.applicationInstanceDTO
|
||||||
?.configurations
|
?.configurations
|
||||||
?.where((c) => c.isActive == true)
|
?.where((c) => c.isActive == true)
|
||||||
.map((c) => c.configurationId)
|
.map((c) => c.configurationId)
|
||||||
.toSet() ?? {};
|
.toSet() ?? {};
|
||||||
weightedConfigs = List<_WeightedConfig>.from(snapshot.data)
|
if (snapshot.hasData) {
|
||||||
.where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id))
|
weightedConfigs = List<_WeightedConfig>.from(snapshot.data)
|
||||||
.toList();
|
.where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id))
|
||||||
configurations = weightedConfigs.map((w) => w.configuration).toList();
|
.toList();
|
||||||
|
configurations = weightedConfigs.map((w) => w.configuration).toList();
|
||||||
|
}
|
||||||
|
|
||||||
final lang = visitAppContext.language ?? "FR";
|
final lang = visitAppContext.language ?? "FR";
|
||||||
final headerTitleEntry = configurations.isNotEmpty
|
final headerTitleEntry = configurations.isNotEmpty
|
||||||
@ -600,12 +683,36 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
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)
|
if (featuredEvent?.imageSource != null)
|
||||||
Image.network(featuredEvent!.imageSource!, fit: BoxFit.cover)
|
CachedNetworkImage(
|
||||||
else if (configurations.isNotEmpty && configurations[0].imageSource != null)
|
imageUrl: featuredEvent!.imageSource!,
|
||||||
Image.network(
|
|
||||||
configurations[0].imageSource!,
|
|
||||||
fit: BoxFit.cover,
|
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
|
// Bottom gradient for title readability
|
||||||
const DecoratedBox(
|
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
|
||||||
Positioned(
|
// en bas : c'est le geste le plus frequent, donc le plus a portee
|
||||||
bottom: 24,
|
// de pouce. Depuis que le scan d'un QR d'une autre visite bascule
|
||||||
right: 16,
|
// proprement dessus, il n'y a plus de raison de le reserver a
|
||||||
child: FloatingActionButton(
|
// l'interieur d'une visite.
|
||||||
heroTag: 'assistant_home',
|
Positioned(
|
||||||
backgroundColor: kMainColor1,
|
bottom: 20,
|
||||||
onPressed: () {
|
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(
|
AssistantChatSheet.show(
|
||||||
context,
|
context,
|
||||||
visitAppContext: visitAppContext,
|
visitAppContext: visitAppContext,
|
||||||
@ -878,24 +1031,8 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Icon(Icons.chat_bubble_outline, color: Colors.white),
|
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 {
|
Future<List<_WeightedConfig>?> _fetchConfigurations(AppContext appContext) async {
|
||||||
bool isOnline = await hasNetwork();
|
bool isOnline = await hasNetwork();
|
||||||
|
this.isOnline = isOnline;
|
||||||
VisitAppContext visitAppContext = appContext.getContext();
|
VisitAppContext visitAppContext = appContext.getContext();
|
||||||
|
|
||||||
List<ConfigurationDTO>? configurations;
|
List<ConfigurationDTO>? configurations;
|
||||||
@ -997,6 +1135,12 @@ class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
|
|||||||
final mobileInstance = instances?.where((e) => e.appType == AppType.Mobile).firstOrNull;
|
final mobileInstance = instances?.where((e) => e.appType == AppType.Mobile).firstOrNull;
|
||||||
if (mobileInstance != null) {
|
if (mobileInstance != null) {
|
||||||
visitAppContext.applicationInstanceDTO = mobileInstance;
|
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) {
|
if (mobileInstance.hasStats == true) {
|
||||||
visitAppContext.statisticsService = StatisticsService(
|
visitAppContext.statisticsService = StatisticsService(
|
||||||
clientAPI: visitAppContext.clientAPI,
|
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));
|
weightedConfigs.sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
|
||||||
|
|
||||||
// Best-effort : garde order/spans en cache local pour que le rendu
|
// Best-effort : garde la visite entière — titre, image, couleurs, spans —
|
||||||
// hors-ligne reste cohérent avec ce qui vient d'être vu en ligne.
|
// en cache local, et pas seulement order/spans comme avant. Hors ligne,
|
||||||
//
|
// la table `configurations` est la seule source de l'accueil : une visite
|
||||||
// `update` et non `insert` : ce dernier bascule sur un INSERT quand la
|
// jamais téléchargée en était absente et disparaissait purement de la
|
||||||
// ligne n'existe pas, et ces quatre colonnes ne suffisent pas à satisfaire
|
// grille. Elle y reste maintenant, ternie et non ouvrable, et l'image
|
||||||
// les NOT NULL de la table. Toute visite pas encore téléchargée levait donc
|
// suit via le cache disque de `CachedNetworkImageProvider`.
|
||||||
// « 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.
|
|
||||||
for (final w in weightedConfigs) {
|
for (final w in weightedConfigs) {
|
||||||
try {
|
try {
|
||||||
await DatabaseHelper.instance.update(
|
await DatabaseHelper.instance.insert(
|
||||||
DatabaseTableType.configurations,
|
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.columnConfigOrder: w.order,
|
||||||
DatabaseHelper.columnGridColSpan: w.colSpan,
|
DatabaseHelper.columnGridColSpan: w.colSpan,
|
||||||
DatabaseHelper.columnGridRowSpan: w.rowSpan,
|
DatabaseHelper.columnGridRowSpan: w.rowSpan,
|
||||||
},
|
},
|
||||||
w.configuration.id!,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("Could not cache order/spans for configuration ${w.configuration.id}: $e");
|
print("Could not cache configuration ${w.configuration.id}: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import 'package:mymuseum_visitapp/Components/GlassPill.dart';
|
|||||||
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
import 'package:mymuseum_visitapp/Components/loading_common.dart';
|
||||||
import 'package:mymuseum_visitapp/Components/SliderImages.dart';
|
import 'package:mymuseum_visitapp/Components/SliderImages.dart';
|
||||||
import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.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/Helpers/translationHelper.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/articleRead.dart';
|
import 'package:mymuseum_visitapp/Models/articleRead.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/resourceModel.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);
|
audioResourceModel = widget.resourcesModel.firstWhere((r) => r?.id == widget.mainAudioId, orElse: () => null);
|
||||||
resourcesModelToShow = widget.resourcesModel.where((r) => r?.id != widget.mainAudioId).toList();
|
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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
widget.visitAppContextIn.statisticsService?.track(
|
widget.visitAppContextIn.statisticsService?.track(
|
||||||
VisitEventType.articleRead,
|
VisitEventType.articleRead,
|
||||||
@ -102,10 +111,16 @@ class _ArticlePageState extends State<ArticlePage> {
|
|||||||
kMainColor2,
|
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(
|
image: widget.articleDTO.imageSource != null ? DecorationImage(
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
opacity: 0.65,
|
opacity: 0.65,
|
||||||
image: NetworkImage(
|
image: ImageCustomProvider.getImageProvider(
|
||||||
|
appContext,
|
||||||
|
widget.articleDTO.imageId,
|
||||||
widget.articleDTO.imageSource!,
|
widget.articleDTO.imageSource!,
|
||||||
),
|
),
|
||||||
): null,
|
): null,
|
||||||
@ -139,8 +154,11 @@ class _ArticlePageState extends State<ArticlePage> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
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(
|
child: HtmlWidget(
|
||||||
cleanedTitle,
|
'<div style="text-align:center">$cleanedTitle</div>',
|
||||||
// Blanc sur photo : sans ombre portée, un titre posé sur
|
// Blanc sur photo : sans ombre portée, un titre posé sur
|
||||||
// une zone claire de l'image devient illisible.
|
// une zone claire de l'image devient illisible.
|
||||||
textStyle: const TextStyle(
|
textStyle: const TextStyle(
|
||||||
@ -165,12 +183,14 @@ class _ArticlePageState extends State<ArticlePage> {
|
|||||||
appContext.setContext(visitAppContext);
|
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(
|
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)
|
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.
|
// s'étire vers la gauche par-dessus la page pour laisser régler l'écoute.
|
||||||
// En `floatingActionButton`/`miniEndFloat`, le bouton couvrait en
|
// En `floatingActionButton`/`miniEndFloat`, le bouton couvrait en
|
||||||
// permanence les dernières lignes du texte.
|
// permanence les dernières lignes du texte.
|
||||||
if (audioResourceModel != null && audioResourceModel!.source != null)
|
if (audioFile != null || audioResourceModel?.source != null)
|
||||||
AudioPlayerTab(
|
AudioPlayerTab(
|
||||||
file: audioFile,
|
file: audioFile,
|
||||||
resourceURl: audioResourceModel!.source!,
|
resourceURl: audioResourceModel?.source,
|
||||||
isAuto: widget.articleDTO.isReadAudioAuto!,
|
isAuto: widget.articleDTO.isReadAudioAuto!,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -14,7 +14,10 @@ class AudioPlayerTab extends StatefulWidget {
|
|||||||
const AudioPlayerTab({Key? key, required this.file, required this.resourceURl, required this.isAuto}) : super(key: key);
|
const AudioPlayerTab({Key? key, required this.file, required this.resourceURl, required this.isAuto}) : super(key: key);
|
||||||
|
|
||||||
final File? file;
|
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;
|
final bool isAuto;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -137,9 +140,9 @@ class _AudioPlayerTabState extends State<AudioPlayerTab>
|
|||||||
if(audiobytes != null) {
|
if(audiobytes != null) {
|
||||||
print("GOT AUDIOBYYYTES - LOCALLY SOSO");
|
print("GOT AUDIOBYYYTES - LOCALLY SOSO");
|
||||||
await player.setAudioSource(LoadedSource(audiobytes!));
|
await player.setAudioSource(LoadedSource(audiobytes!));
|
||||||
} else {
|
} else if (widget.resourceURl != null) {
|
||||||
print("GET SOUND BY URL");
|
print("GET SOUND BY URL");
|
||||||
await player.dynamicSet(url: widget.resourceURl);
|
await player.dynamicSet(url: widget.resourceURl!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(widget.isAuto) {
|
if(widget.isAuto) {
|
||||||
@ -150,6 +153,9 @@ class _AudioPlayerTabState extends State<AudioPlayerTab>
|
|||||||
isplaying = true;
|
isplaying = true;
|
||||||
audioplayed = 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();
|
super.initState();
|
||||||
@ -256,7 +262,10 @@ class _AudioPlayerTabState extends State<AudioPlayerTab>
|
|||||||
: IconButton(
|
: IconButton(
|
||||||
icon: Icon(isplaying ? Icons.pause : Icons.play_arrow,
|
icon: Icon(isplaying ? Icons.pause : Icons.play_arrow,
|
||||||
color: Colors.white),
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -57,6 +57,9 @@ class _SectionPageState extends State<SectionPage> {
|
|||||||
List<Map<String, dynamic>>? icons;
|
List<Map<String, dynamic>>? icons;
|
||||||
|
|
||||||
String? mainAudioId;
|
String? mainAudioId;
|
||||||
|
|
||||||
|
/// Memorise : sans lui, `future:` reconstruisait l'appel a chaque rebuild.
|
||||||
|
Future<dynamic>? _sectionDetail;
|
||||||
DateTime? _sectionOpenTime;
|
DateTime? _sectionOpenTime;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -96,20 +99,38 @@ class _SectionPageState extends State<SectionPage> {
|
|||||||
|
|
||||||
var test = SectionDTO.fromJson(jsonDecode(jsonEncode(widget.rawSection)));
|
var test = SectionDTO.fromJson(jsonDecode(jsonEncode(widget.rawSection)));
|
||||||
|
|
||||||
return Scaffold(
|
// Le `FutureBuilder` passe au-dessus du `Scaffold` : c'est le seul endroit
|
||||||
key: _scaffoldKey,
|
// d'où l'on sait si la section est prête, et c'est cette information qui
|
||||||
resizeToAvoidBottomInset: false,
|
// décide de la couleur du fond.
|
||||||
body: MediaQuery.removeViewInsets(
|
//
|
||||||
context: context,
|
// Le futur est aussi mémorisé ici. Il était construit dans `future:`, donc
|
||||||
removeBottom: true,
|
// relancé à chaque rebuild : chaque changement d'orientation, chaque
|
||||||
child: OrientationBuilder(
|
// `setState` refaisait l'appel réseau de la section.
|
||||||
builder: (context, orientation) {
|
return FutureBuilder(
|
||||||
return FutureBuilder(
|
future: _sectionDetail ??=
|
||||||
future: getSectionDetail(appContext, visitAppContext.clientAPI, widget.sectionId),
|
getSectionDetail(appContext, visitAppContext.clientAPI, widget.sectionId),
|
||||||
builder: (context, AsyncSnapshot<dynamic> snapshot) {
|
builder: (context, AsyncSnapshot<dynamic> snapshot) {
|
||||||
var sectionResult = snapshot.data;
|
var sectionResult = snapshot.data;
|
||||||
if(sectionDTO != null && sectionResult != null) {
|
final isReady = sectionDTO != null && sectionResult != null;
|
||||||
switch(sectionDTO!.type) {
|
|
||||||
|
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:
|
case SectionType.Agenda:
|
||||||
AgendaDTO agendaDTO = AgendaDTO.fromJson(sectionResult)!;
|
AgendaDTO agendaDTO = AgendaDTO.fromJson(sectionResult)!;
|
||||||
return AgendaPage(section: agendaDTO);
|
return AgendaPage(section: agendaDTO);
|
||||||
@ -166,14 +187,11 @@ class _SectionPageState extends State<SectionPage> {
|
|||||||
default:
|
default:
|
||||||
return const Center(child: Text("Unsupported type"));
|
return const Center(child: Text("Unsupported type"));
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
return const LoadingCommon();
|
),
|
||||||
}
|
),
|
||||||
}
|
);
|
||||||
);
|
},
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,65 +1,54 @@
|
|||||||
import 'package:flutter/material.dart';
|
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';
|
import 'package:mymuseum_visitapp/constants.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatelessWidget {
|
||||||
const SplashScreen({Key? key}) : super(key: key);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: kBackgroundColor,
|
backgroundColor: kSplashBackground,
|
||||||
body: Center(
|
// Le splash s'affiche pendant le boot, avant tout appel API : il ne peut
|
||||||
child: Column(
|
// lire que ce qu'un lancement précédent a mis en cache. Au tout premier
|
||||||
mainAxisSize: MainAxisSize.min,
|
// démarrage, le logo retombe donc sur l'image du flavor.
|
||||||
children: [
|
body: FutureBuilder<void>(
|
||||||
Image.asset(
|
future: InstanceImages.restore(),
|
||||||
kSplashLogoAsset,
|
builder: (context, _) {
|
||||||
width: 200,
|
return Center(
|
||||||
height: 200,
|
child: Column(
|
||||||
fit: BoxFit.contain,
|
mainAxisSize: MainAxisSize.min,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
children: [
|
||||||
Icons.museum_outlined,
|
ClipRRect(
|
||||||
size: 100,
|
borderRadius: BorderRadius.circular(32),
|
||||||
color: kMainColor1,
|
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -418,27 +418,26 @@ 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) {
|
Widget _label(String text) {
|
||||||
return Center(
|
return Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(8.0),
|
||||||
padding: const EdgeInsets.all(8.0),
|
child: Text(text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 20)),
|
||||||
child: Text(text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 20)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _message(IconData icon, Color color, String text) {
|
Widget _message(IconData icon, Color color, String text) {
|
||||||
return Center(
|
return Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(16.0),
|
||||||
padding: const EdgeInsets.all(16.0),
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
Icon(icon, size: 34, color: color),
|
||||||
Icon(icon, size: 34, color: color),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 10),
|
Text(text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)),
|
||||||
Text(text, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -149,18 +149,17 @@ const kAppTitle = _flavor == 'mdlf'
|
|||||||
? 'Fort Saint-Héribert'
|
? 'Fort Saint-Héribert'
|
||||||
: 'MyMuseum Dev';
|
: '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'
|
const kSplashLogoAsset = _flavor == 'mdlf'
|
||||||
? 'assets/splash/mdlf.png'
|
? 'assets/splash/mdlf.png'
|
||||||
: _flavor == 'fortsaintheribert'
|
: _flavor == 'fortsaintheribert'
|
||||||
? 'assets/splash/fortsaintheribert.png'
|
? 'assets/splash/fortsaintheribert.png'
|
||||||
: 'assets/splash/dev.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 kBackgroundLight = Color(0xfff3f3f3);
|
||||||
|
|
||||||
const kGreen = Color(0xFF15bd5b);
|
const kGreen = Color(0xFF15bd5b);
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import 'package:flutter/services.dart';
|
|||||||
// TODO // import 'package:flutter_beacon/flutter_beacon.dart';
|
// TODO // import 'package:flutter_beacon/flutter_beacon.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.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/Helpers/requirement_state_controller.dart';
|
||||||
import 'package:mymuseum_visitapp/Models/articleRead.dart';
|
import 'package:mymuseum_visitapp/Models/articleRead.dart';
|
||||||
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
|
import 'package:mymuseum_visitapp/Screens/Home/home_3.0.dart';
|
||||||
@ -135,6 +137,22 @@ class _AppBootstrapState extends State<AppBootstrap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
localContext.guideVoiceId = instanceDto?.guideVoiceId;
|
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) {
|
} catch (e) {
|
||||||
// Hors ligne : la voix retombe sur le repli du build, le reste de l'app tourne.
|
// Hors ligne : la voix retombe sur le repli du build, le reste de l'app tourne.
|
||||||
print('Could not load instance detail: $e');
|
print('Could not load instance detail: $e');
|
||||||
|
|||||||
@ -210,7 +210,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "8.9.2"
|
version: "8.9.2"
|
||||||
cached_network_image:
|
cached_network_image:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: cached_network_image
|
name: cached_network_image
|
||||||
sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f"
|
sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f"
|
||||||
|
|||||||
@ -38,6 +38,9 @@ dependencies:
|
|||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
fluttertoast:
|
fluttertoast:
|
||||||
carousel_slider: ^5.0.0
|
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
|
#flutter_svg_provider: ^1.0.3
|
||||||
photo_view: ^0.15.0
|
photo_view: ^0.15.0
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
@ -125,7 +128,6 @@ flutter:
|
|||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/splash/
|
- assets/splash/
|
||||||
- assets/loader/
|
|
||||||
- assets/icons/
|
- assets/icons/
|
||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/images/old/
|
- assets/images/old/
|
||||||
|
|||||||