Thomas Fransolet 6709726208 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>
2026-09-09 16:47:27 +02:00

288 lines
11 KiB
Dart

//import 'package:audioplayers/audioplayers.dart';
import 'dart:async';
import 'dart:io';
import 'package:app_links/app_links.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
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';
import 'package:mymuseum_visitapp/Screens/splash_screen.dart';
import 'package:mymuseum_visitapp/Services/Glasses/voice_orchestrator.dart';
import 'package:mymuseum_visitapp/Services/meta_glasses_service.dart';
import 'package:mymuseum_visitapp/Services/voice_controller.dart';
import 'package:mymuseum_visitapp/Services/pushNotificationService.dart';
import 'package:mymuseum_visitapp/l10n/app_localizations.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'Helpers/DatabaseHelper.dart';
import 'Models/visitContext.dart';
import 'app_context.dart';
import 'client.dart';
import 'constants.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
/// Plafond des appels réseau du démarrage. Aucun d'eux n'est nécessaire pour
/// afficher l'accueil : ils enrichissent le contexte, ils ne le conditionnent pas.
const _bootNetworkTimeout = Duration(seconds: 8);
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const AppBootstrap());
}
class AppBootstrap extends StatefulWidget {
const AppBootstrap({Key? key}) : super(key: key);
@override
_AppBootstrapState createState() => _AppBootstrapState();
}
class _AppBootstrapState extends State<AppBootstrap> {
VisitAppContext? _ctx;
StreamSubscription<Uri>? _deepLinkSub;
@override
void initState() {
super.initState();
_listenGlassesDeepLinks();
_initApp();
}
@override
void dispose() {
_deepLinkSub?.cancel();
super.dispose();
}
/// L'app Meta AI renvoie vers notre scheme pour terminer la registration DAT.
void _listenGlassesDeepLinks() {
if (!kEnableGlasses || !Platform.isAndroid) return;
_deepLinkSub = AppLinks().uriLinkStream.listen(
(uri) => MetaGlassesService.instance.handleDeepLink(uri.toString()),
onError: (e) => print('Glasses deep link error: $e'),
);
}
Future<void> _initApp() async {
// Firebase init (requires google-services.json on Android, GoogleService-Info.plist on iOS)
if (!Platform.isWindows) {
await Firebase.initializeApp();
}
VisitAppContext? localContext = await DatabaseHelper.instance.getData(DatabaseTableType.main);
Directory? appDocumentsDirectory = Platform.isIOS ? await getApplicationDocumentsDirectory() : await getDownloadsDirectory();
String localPath = appDocumentsDirectory!.path;
MapboxOptions.setAccessToken("pk.eyJ1IjoidGZyYW5zb2xldCIsImEiOiJjbHRpcGNvZDYwYWhkMnFxdmF0ampveW10In0.7xHN0NGvUfQu5ThS3RGJRw"); // TODO put in json file or resource file
if (localContext != null) {
print("we've got an local db !");
print(localContext);
List<SectionRead> articleReadTest = List<SectionRead>.from(await DatabaseHelper.instance.getData(DatabaseTableType.articleRead));
localContext.readSections = articleReadTest;
// L'instance vient du build, jamais du visiteur — la base locale ne fait que
// la mémoriser. Elle gagnait quand même : une app cliente lancée une fois sans
// `--dart-define=INSTANCE_ID` écrivait l'instance de dev en base, et continuait
// ensuite de l'interroger même relancée avec la bonne configuration.
if (localContext.instanceId != kInstanceId) {
print('instanceId local (${localContext.instanceId}) remplacé par celui du build ($kInstanceId)');
localContext.instanceId = kInstanceId;
localContext.apiKey = null;
DatabaseHelper.instance.updateTableMain(DatabaseTableType.main, localContext);
}
} else {
localContext = VisitAppContext(language: "FR", id: "UserId_Init", instanceId: kInstanceId, isAdmin: false, isAllLanguages: false);
DatabaseHelper.instance.insert(DatabaseTableType.main, localContext.toMap());
List<SectionRead> articleReadTest = List<SectionRead>.from(await DatabaseHelper.instance.getData(DatabaseTableType.articleRead));
localContext.readSections = articleReadTest;
print(localContext.readSections);
print("NO LOCAL DB !");
}
localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey ?? (kApiKey.isNotEmpty ? kApiKey : null));
// Récupère publicApiKey (si pas encore en mémoire) et la voix du guide choisie par
// le client. La condition portait sur `apiKey == null` : une fois la clé en base, la
// réponse n'était plus jamais lue, et `guideVoiceId` avec elle. Elle porte désormais
// sur l'instance — un appel au démarrage, déjà non bloquant en cas d'échec réseau.
//
// Le `timeout` n'est pas une précaution : un socket qui ne répond pas (serveur
// éteint, port filtré) ne rend la main qu'au bout de deux minutes, et le boot
// attend ici. L'app restait sur son splash tout ce temps.
if (localContext.instanceId != null) {
try {
final instanceDto = await localContext.clientAPI.instanceApi!
.instanceGetDetail(localContext.instanceId!)
.timeout(_bootNetworkTimeout);
if (localContext.apiKey == null && instanceDto?.publicApiKey != null) {
localContext.apiKey = instanceDto!.publicApiKey;
localContext.clientAPI = Client(kApiBaseUrl, apiKey: localContext.apiKey);
DatabaseHelper.instance.updateTableMain(DatabaseTableType.main, localContext);
print('publicApiKey loaded and saved');
}
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');
}
}
// Push notifications — subscribe to instance topic if enabled
if (!Platform.isWindows && localContext.instanceId != null) {
try {
await PushNotificationService.initialize(localContext.instanceId!)
.timeout(_bootNetworkTimeout);
} catch (e) {
print('PushNotification init failed: $e');
}
}
localContext.localPath = localPath;
print("Local path $localPath");
// Glasses SDK init — toujours initialisé pour détecter la connexion BT.
// L'orchestrateur vocal ne démarre PAS automatiquement — c'est VoiceController qui gère ça.
// ⚠️ Historique : avec meta_wearables_dat 0.1.3 (SDK DAT 0.3.0), le SDK tuait
// le processus au chargement de ses libs natives sur certains telephones :
// SIGABRT dans facebook::jni::initialize, « __cxa_guard_acquire detected
// recursive initialization », non rattrapable en Dart. Constate le 2026-09-07
// sur Xiaomi klimt / Android 16. Le passage a flutter_meta_wearables_dat
// (SDK DAT 0.9.0, qui ne lie plus mwdat-mockdevice) est cense le corriger :
// A REVALIDER SUR CE TELEPHONE avant de retirer le drapeau, desactive par defaut :
// --dart-define=ENABLE_GLASSES=true
if (kEnableGlasses && !Platform.isWindows && Platform.isAndroid) {
await MetaGlassesService.instance.initialize();
}
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent,
statusBarColor: Colors.transparent,
));
if (mounted) setState(() => _ctx = localContext);
}
@override
Widget build(BuildContext context) {
if (_ctx == null) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
title: kAppTitle,
home: SplashScreen(),
);
}
return MyApp(visitAppContext: _ctx!, initialRoute: '/home');
}
}
class MyApp extends StatefulWidget {
String initialRoute = "";
VisitAppContext visitAppContext;
MyApp({Key? key, required this.initialRoute, required this.visitAppContext}) : super(key: key);
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
// Le mode vocal ne démarre plus automatiquement — l'utilisateur choisit via VoiceModeSheet.
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
final o = activeVoiceOrchestrator;
if (o != null && o.isRunning && !o.isInConversation) {
o.restartWakeWord();
}
}
}
@override
Widget build(BuildContext context) {
Get.put(RequirementStateController());
return MultiProvider(
providers: [
ChangeNotifierProvider<AppContext>(
create: (_) => AppContext(widget.visitAppContext),
),
ChangeNotifierProvider<VoiceController>(
create: (_) => VoiceController(),
),
],
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: kAppTitle,
initialRoute: widget.initialRoute,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const[
Locale('en', ''),
Locale('fr', ''),
],
theme: ThemeData(
primarySwatch: Colors.blue,
scaffoldBackgroundColor: kBackgroundColor,
//fontFamily: "Vollkorn",
textTheme: TextTheme(bodyLarge: TextStyle(color: widget.visitAppContext.configuration != null ? widget.visitAppContext.configuration!.primaryColor != null ? Color(int.parse(widget.visitAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16)): kMainColor1 : kMainColor1)),
visualDensity: VisualDensity.adaptivePlatformDensity,
appBarTheme: const AppBarTheme(
iconTheme: IconThemeData(color: Colors.white), // Change the color here
),
),
routes: {
'/home': (context) => const HomePage3(),
}
),
);
}
}