import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:flutter_widget_from_html/flutter_widget_from_html.dart'; import 'package:manager_api_new/api.dart'; import 'package:myinfomate_layout/myinfomate_layout.dart'; import 'package:mymuseum_visitapp/Components/AssistantChatSheet.dart'; import 'package:mymuseum_visitapp/Components/CustomAppBar.dart'; import 'package:mymuseum_visitapp/Components/GlassPill.dart'; import 'package:mymuseum_visitapp/Components/GlassesStatusWidget.dart'; import 'package:mymuseum_visitapp/Components/VoiceModeSheet.dart'; import 'package:mymuseum_visitapp/Components/AdminPopup.dart'; import 'package:mymuseum_visitapp/Components/ScannerBouton.dart'; import 'package:mymuseum_visitapp/Services/pushNotificationService.dart'; import 'package:mymuseum_visitapp/Components/loading_common.dart'; import 'package:mymuseum_visitapp/Helpers/DatabaseHelper.dart'; import 'package:mymuseum_visitapp/Helpers/instanceImages.dart'; import 'package:mymuseum_visitapp/Helpers/modelsHelper.dart'; import 'package:mymuseum_visitapp/Helpers/networkCheck.dart'; import 'package:mymuseum_visitapp/Helpers/requirement_state_controller.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/beaconSection.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/ConfigurationPage/configuration_page.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Event/event_page.dart'; import 'package:mymuseum_visitapp/Services/apiService.dart'; import 'package:mymuseum_visitapp/Services/downloadConfiguration.dart'; import 'package:mymuseum_visitapp/Services/statisticsService.dart'; import 'package:mymuseum_visitapp/app_context.dart'; import 'package:mymuseum_visitapp/client.dart'; import 'package:mymuseum_visitapp/constants.dart'; import 'package:provider/provider.dart'; /// Fond de l'accueil et des surfaces qui s'ouvrent par-dessus (feuille de /// réglages, dialogues de téléchargement) : une feuille blanche dans une app /// noire était le contraste le plus violent de l'écran. const Color _kSurface = Color(0xFF1A1A1A); const Color _kBackground = Color(0xFF111111); class _WeightedConfig { final ConfigurationDTO configuration; final int colSpan; final int rowSpan; final int? order; const _WeightedConfig({required this.configuration, this.colSpan = 1, this.rowSpan = 1, this.order}); } Color? _parseStoredColor(String? raw) { if (raw == null) return null; try { return Color(int.parse(raw.split('(0x')[1].split(')')[0], radix: 16)); } catch (_) { return null; } } class HomePage3 extends StatefulWidget { const HomePage3({Key? key}) : super(key: key); @override State createState() => _HomePage3State(); } class _HomePage3State extends State with WidgetsBindingObserver { int currentIndex = 0; late List configurations = []; List<_WeightedConfig> weightedConfigs = []; /// Visites hors ligne dont le contenu est réellement présent sur le device. /// /// Délibérément déduit des sections en base locale, et non des lignes de la /// table `configurations` : `_fetchConfigurations` y écrit une ligne par /// visite pour cacher `order`/`gridSpan`, donc « la ligne existe » ne veut /// pas dire « le contenu est là ». Set downloadedConfigIds = {}; /// État réseau au dernier chargement de l'accueil. Une visite non téléchargée /// n'a rien à montrer sans réseau : elle reste affichée — titre et image sont /// en cache — mais ternie et non ouvrable, plutôt que disparue de la grille. bool isOnline = true; late VisitAppContext visitAppContext; late Future?> _futureConfigurations; @override void initState() { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.initState(); // `WidgetsBindingObserver` était déclaré mais jamais enregistré : aucun des // rappels de cycle de vie n'arrivait. WidgetsBinding.instance.addObserver(this); final appContext = Provider.of(context, listen: false); _futureConfigurations = getConfigurationsCall(appContext); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // Le visiteur qui coupe le mode avion ou rejoint le wifi du site revient dans // l'app par là. Sans ce rappel, l'accueil gardait l'état réseau de son premier // chargement : tuiles ternies et visites refusées alors que le réseau est revenu. if (state == AppLifecycleState.resumed) _reloadIfNetworkChanged(); } /// Reteste le réseau et, s'il a changé, relance le chargement de l'accueil — /// images et titres compris, qui viennent du même appel. Future _reloadIfNetworkChanged() async { final online = await hasNetwork(); if (!mounted || online == isOnline) return; setState(() { isOnline = online; _futureConfigurations = getConfigurationsCall( Provider.of(context, listen: false)); }); } Widget _buildCard(BuildContext context, ConfigurationDTO config) { final lang = visitAppContext.language ?? "FR"; final titleEntry = config.title?.firstWhere( (t) => t.language == lang, orElse: () => config.title!.first, ); final cleanedTitle = (titleEntry?.value ?? '').replaceAll('\n', ' ').replaceAll('
', ' '); final fallbackBaseColor = _parseStoredColor(config.primaryColor) ?? _parseStoredColor(visitAppContext.applicationInstanceDTO?.primaryColor) ?? kMainColor1; final fallbackColor = Color.lerp(fallbackBaseColor, Colors.white, 0.35)!; final isOffline = config.isOffline == true; final needsDownload = isOffline && !downloadedConfigIds.contains(config.id); // Sans réseau, seul ce qui est sur le device s'ouvre — y compris pour une // visite qui n'est pas en mode hors ligne. final unreachable = !isOnline && !downloadedConfigIds.contains(config.id); return InkWell( borderRadius: BorderRadius.circular(16), onTap: () => _openConfiguration(context, config), child: Hero( tag: config.id!, child: Material( type: MaterialType.transparency, child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Container( decoration: BoxDecoration( color: fallbackColor, borderRadius: BorderRadius.circular(16), boxShadow: const [ BoxShadow( color: Colors.black38, blurRadius: 6, offset: Offset(0, 2), ), ], // En cache disque : une fois la vignette vue en ligne, elle // s'affiche encore sans réseau. `NetworkImage` la re-demandait à // chaque ouverture et rendait une croix rouge hors ligne. image: config.imageSource != null ? DecorationImage( fit: BoxFit.cover, image: CachedNetworkImageProvider(config.imageSource!), ) : null, ), child: Stack( children: [ // Gradient overlay for text readability Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withValues(alpha: 0.72), ], stops: const [0.4, 1.0], ), ), ), ), // Une visite hors ligne pas encore récupérée est volontairement // ternie : la tuile dit qu'elle n'est pas prête avant toute lecture. if (needsDownload || unreachable) Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: Colors.black.withValues(alpha: unreachable ? 0.55 : 0.3), ), ), ), if (isOffline || unreachable) Positioned( top: 8, right: 8, child: GlassPill( size: 32, // 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( unreachable && !isOffline ? Icons.wifi_off_rounded : needsDownload ? Icons.arrow_downward_rounded : Icons.check_rounded, size: 17, color: needsDownload || unreachable ? Colors.white : Colors.greenAccent, ), ), ), // Titre + chevron sur une même ligne : le chevron reste centré // verticalement sur le bloc de titre, quel que soit son nombre de lignes. Positioned( bottom: 10, left: 10, right: 8, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: ClipRect( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 40), child: HtmlWidget( cleanedTitle, textStyle: const TextStyle( color: Colors.white, fontFamily: 'Roboto', fontSize: 14, fontWeight: FontWeight.w600, ), customStylesBuilder: (_) => { 'font-family': 'Roboto', 'margin': '0', '-webkit-line-clamp': '2', }, ), ), ), ), const SizedBox(width: 4), const Icon(Icons.chevron_right, size: 18, color: Colors.white), ], ), ), ], ), ), ), ), ), ); } void _showOfflineNotice(BuildContext context) { final ctx = Provider.of(context, listen: false).getContext(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(TranslationHelper.getFromLocale("noInternet", ctx)), backgroundColor: kMainColor2, ), ); } Future _openConfiguration(BuildContext context, ConfigurationDTO config) async { final appCtx = Provider.of(context, listen: false); final ctx = appCtx.getContext() as VisitAppContext; // Le détail lit l'API dès qu'il n'est pas servi par la base locale : sans // réseau ni contenu téléchargé, la visite s'ouvrirait vide. // // Deuxième mesure du réseau avant de refuser : la première date du chargement // de l'accueil, et refuser sur une mesure périmée est le pire des cas — le // visiteur voit ses barres de réseau et l'app lui dit qu'il est hors ligne. if (!isOnline && !downloadedConfigIds.contains(config.id)) { await _reloadIfNetworkChanged(); if (!context.mounted) return; if (!isOnline) { _showOfflineNotice(context); return; } } // Une visite hors ligne non téléchargée ouvrait un détail vide : `body.dart` // lit ses sections uniquement en base locale quand `isOffline` est vrai. if (config.isOffline == true && !downloadedConfigIds.contains(config.id)) { await _promptDownload(context, config); return; } if (config.languages != null && !config.languages!.contains(ctx.language)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(TranslationHelper.getFromLocale("languageNotSupported", ctx)), backgroundColor: kMainColor2, ), ); return; } ctx.configuration = config; ctx.sectionIds = config.sectionIds; appCtx.setContext(ctx); if (!context.mounted) return; Navigator.of(context).push(MaterialPageRoute( builder: (context) => ConfigurationPage( configuration: config, isAlreadyAllowed: ctx.isScanBeaconAlreadyAllowed, ), )); } /// Choix de la langue puis téléchargement. La langue est écrite dans le /// contexte avant de lancer l'export : `DownloadConfigurationWidget` ne /// récupère que les ressources de `visitAppContext.language`. Future _promptDownload(BuildContext context, ConfigurationDTO config) async { final appCtx = Provider.of(context, listen: false); final isUpdate = downloadedConfigIds.contains(config.id); final configLanguages = config.languages ?? languages; final confirmed = await showDialog( context: context, builder: (dialogCtx) { return StatefulBuilder(builder: (dialogCtx2, setLocal) { final ctx = appCtx.getContext() as VisitAppContext; return AlertDialog( backgroundColor: _kSurface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(20)), ), contentPadding: const EdgeInsets.fromLTRB(24, 28, 24, 8), content: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.cloud_download_outlined, size: 34, color: kMainColor1), const SizedBox(height: 16), Text( TranslationHelper.getFromLocale( isUpdate ? "downloadPromptUpdate" : "downloadPrompt", ctx), textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.4), ), const SizedBox(height: 24), Text( TranslationHelper.getFromLocale("downloadLanguage", ctx), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600), ), const SizedBox(height: 14), _languageFlags( languagesEnabled: configLanguages, current: ctx.language, size: 44, onSelected: (lang) async { ctx.language = lang; appCtx.setContext(ctx); await DatabaseHelper.instance.insert(DatabaseTableType.main, ctx.toMap()); setLocal(() {}); }, ), ], ), actionsAlignment: MainAxisAlignment.spaceBetween, actions: [ TextButton( onPressed: () => Navigator.of(dialogCtx).pop(false), child: Text( TranslationHelper.getFromLocale("close", ctx), style: const TextStyle(color: Colors.white54), ), ), FilledButton( style: FilledButton.styleFrom(backgroundColor: kMainColor), onPressed: () => Navigator.of(dialogCtx).pop(true), child: Text( TranslationHelper.getFromLocale("download", ctx), style: const TextStyle(color: Colors.white), ), ), ], ); }); }, ); if (confirmed != true || !context.mounted) return; await showDialog( context: context, builder: (dialogCtx) => AlertDialog( backgroundColor: _kSurface, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(20)), ), content: DefaultTextStyle.merge( style: const TextStyle(color: Colors.white), // Hauteur libre : la boîte de 125px coupait le message d'échec, plus long // que « Téléchargement en cours ». child: SizedBox( width: 350, child: DownloadConfigurationWidget(configuration: config), ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogCtx).pop(), child: Text( TranslationHelper.getFromLocale("close", appCtx.getContext()), style: const TextStyle(color: Colors.white54), ), ), ], ), ); await _refreshDownloadedState(weightedConfigs); if (mounted) setState(() {}); } /// Grille de drapeaux, partagée par la feuille de réglages et le dialogue de /// téléchargement pour que le choix de langue se présente pareil aux deux /// endroits. Widget _languageFlags({ required List languagesEnabled, required String? current, required Future Function(String lang) onSelected, double size = 48, }) { return Wrap( spacing: 16, runSpacing: 16, alignment: WrapAlignment.center, children: languagesEnabled.map((lang) { final isSelected = current == lang; return GestureDetector( onTap: () async { if (current != lang) await onSelected(lang); }, child: Opacity( opacity: isSelected ? 1 : 0.55, child: Container( width: size, height: size, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: isSelected ? kMainColor : Colors.white24, width: isSelected ? 2.5 : 1, ), image: DecorationImage( fit: BoxFit.cover, image: AssetImage('assets/images/old/${lang.toLowerCase()}.png'), ), ), ), ), ); }).toList(), ); } void _showSettingsSheet(BuildContext ctx, AppContext appCtx) { final visitAppContext = appCtx.getContext() as VisitAppContext; final configLanguages = visitAppContext.configuration?.languages ?? languages; final hasNotifications = visitAppContext.instanceId != null && visitAppContext.instanceId!.isNotEmpty; showModalBottomSheet( context: ctx, backgroundColor: _kSurface, useSafeArea: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (sheetCtx) { return StatefulBuilder(builder: (sheetCtx2, setLocal) { final ctx2 = appCtx.getContext() as VisitAppContext; return Padding( padding: const EdgeInsets.fromLTRB(24, 20, 24, 32), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 40, height: 4, decoration: BoxDecoration( color: Colors.white24, borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 20), // Entrée assistant vocal (si feature disponible) if (visitAppContext.applicationInstanceDTO?.isAssistant == true) ...[ Material( type: MaterialType.transparency, child: ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.mic, color: kMainColor1), title: Text( TranslationHelper.getFromLocale('settings.voiceAssistant', ctx2), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600), ), subtitle: Text( TranslationHelper.getFromLocale('settings.voiceAssistantSubtitle', ctx2), style: const TextStyle(color: Colors.white54), ), trailing: const Icon(Icons.chevron_right, color: Colors.white38), onTap: () { Navigator.pop(ctx); VoiceModeSheet.show(ctx, visitAppContext: visitAppContext); }, ), ), const Divider(color: Colors.white12), ], Text( TranslationHelper.getFromLocale('settings.language', ctx2), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 16), ), const SizedBox(height: 12), _languageFlags( languagesEnabled: configLanguages, current: ctx2.language, onSelected: (lang) async { ctx2.language = lang; appCtx.setContext(ctx2); await DatabaseHelper.instance.insert(DatabaseTableType.main, ctx2.toMap()); setLocal(() {}); }, ), if (hasNotifications) ...[ const SizedBox(height: 24), const Divider(color: Colors.white12), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( TranslationHelper.getFromLocale('settings.notifications', ctx2), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 16), ), Switch( value: ctx2.notificationsEnabled, activeThumbColor: kMainColor, inactiveThumbColor: Colors.white38, inactiveTrackColor: Colors.white12, onChanged: (value) async { ctx2.notificationsEnabled = value; appCtx.setContext(ctx2); DatabaseHelper.instance.updateTableMain(DatabaseTableType.main, ctx2); if (value) { await PushNotificationService.subscribeToInstance(ctx2.instanceId!); } else { await PushNotificationService.unsubscribeFromInstance(ctx2.instanceId!); } setLocal(() {}); }, ), ], ), ], const SizedBox(height: 24), Center(child: _buildBadge()), ], ), ); }); }, ); } /// Identifie le build installe : flavor, version du pubspec et commit. /// Sans ca, un APK pose sur une tablette du terrain n'etait rattachable a /// aucun commit precis. Widget _buildBadge() { return FutureBuilder( future: PackageInfo.fromPlatform(), builder: (context, snapshot) { final info = snapshot.data; if (info == null) return const SizedBox.shrink(); return Text( '$kFlavor \u00b7 v${info.version}+${info.buildNumber} \u00b7 $kGitSha', style: const TextStyle(color: Colors.white30, fontSize: 11), ); }, ); } @override Widget build(BuildContext context) { Size size = MediaQuery.of(context).size; final appContext = Provider.of(context); visitAppContext = appContext.getContext(); return Scaffold( extendBody: true, // Le fond de l'accueil, pas le blanc du theme : pendant un rechargement, // l'ecran virait au blanc le temps de la requete. backgroundColor: _kBackground, body: FutureBuilder( future: _futureConfigurations, builder: (context, AsyncSnapshot snapshot) { // Un rechargement (retour du reseau) ne renvoie pas le visiteur sur un // ecran vide : tant qu'on a les visites du chargement precedent, la // grille reste affichee et se met a jour quand la reponse arrive. if (snapshot.connectionState == ConnectionState.done || weightedConfigs.isNotEmpty) { final mobileConfigIds = visitAppContext.applicationInstanceDTO ?.configurations ?.where((c) => c.isActive == true) .map((c) => c.configurationId) .toSet() ?? {}; if (snapshot.hasData) { weightedConfigs = List<_WeightedConfig>.from(snapshot.data) .where((w) => mobileConfigIds.isEmpty || mobileConfigIds.contains(w.configuration.id)) .toList(); configurations = weightedConfigs.map((w) => w.configuration).toList(); } final lang = visitAppContext.language ?? "FR"; final headerTitleEntry = configurations.isNotEmpty ? configurations[0].title?.firstWhere( (t) => t.language == lang, orElse: () => configurations[0].title!.first, ) : null; final featuredEvent = visitAppContext.applicationInstanceDTO?.sectionEventDTO; return Stack( children: [ // Dark background const ColoredBox(color: _kBackground, child: SizedBox.expand()), SafeArea( top: false, bottom: false, child: CustomScrollView( slivers: [ SliverAppBar( backgroundColor: Colors.transparent, pinned: false, expandedHeight: 235.0, flexibleSpace: FlexibleSpaceBar( collapseMode: CollapseMode.pin, centerTitle: true, background: Container( padding: const EdgeInsets.only(bottom: 25.0), decoration: const BoxDecoration( borderRadius: BorderRadius.only( bottomLeft: Radius.circular(25), bottomRight: Radius.circular(25), ), boxShadow: [ BoxShadow( color: Colors.black38, spreadRadius: 0.5, blurRadius: 8, offset: Offset(0, 4), ), ], ), child: ClipRRect( borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(25), bottomRight: Radius.circular(25), ), child: Stack( fit: StackFit.expand, children: [ // Ordre : l'événement à la une s'il y en a un — il // remplace tout l'en-tête, titre et bouton compris — // puis l'image principale de l'app définie dans le // manager (Applications → Mobile). L'image de la // première visite ne reste qu'en dernier recours : // prendre la vignette d'une visite pour bannière de // l'app était un choix par défaut, pas une intention. // // Le fichier local d'abord : `InstanceImages` l'a mis // en cache au démarrage, donc la bannière tient hors // ligne. `Image.network` y laissait une croix rouge. if (featuredEvent?.imageSource != null) CachedNetworkImage( imageUrl: featuredEvent!.imageSource!, fit: BoxFit.cover, errorWidget: (_, __, ___) => const SizedBox.shrink(), ) else if (InstanceImages.main != null) Image.file(InstanceImages.main!, fit: BoxFit.cover) else if (visitAppContext.applicationInstanceDTO?.mainImageUrl != null) CachedNetworkImage( imageUrl: visitAppContext.applicationInstanceDTO!.mainImageUrl!, fit: BoxFit.cover, errorWidget: (_, __, ___) => const SizedBox.shrink(), ) else if (configurations.isNotEmpty && configurations[0].imageSource != null) CachedNetworkImage( imageUrl: configurations[0].imageSource!, fit: BoxFit.cover, errorWidget: (_, __, ___) => const SizedBox.shrink(), ), // Bottom gradient for title readability const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black54], stops: [0.5, 1.0], ), ), ), if (featuredEvent != null) Positioned( bottom: 16, left: 14, right: 60, child: Builder(builder: (ctx) { final titleEntry = featuredEvent.title?.firstWhere( (t) => t.language == lang, orElse: () => featuredEvent.title!.first, ); final start = featuredEvent.startDate; final end = featuredEvent.endDate; String dateLabel = ''; if (start != null) { dateLabel = '${start.day.toString().padLeft(2, '0')}/${start.month.toString().padLeft(2, '0')}'; if (end != null && end.day != start.day) { dateLabel += ' → ${end.day.toString().padLeft(2, '0')}/${end.month.toString().padLeft(2, '0')}'; } } return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (titleEntry != null) ClipRect( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 52), child: HtmlWidget( (titleEntry.value ?? '') .replaceAll('\n', ' ') .replaceAll('
', ' '), textStyle: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, height: 1.2, ), customStylesBuilder: (_) => { 'margin': '0', '-webkit-line-clamp': '2', }, ), ), ), const SizedBox(height: 8), Row( children: [ if (dateLabel.isNotEmpty) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: kMainColor.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(12), ), child: Text( dateLabel, style: const TextStyle(color: Colors.white, fontSize: 12), ), ), if (dateLabel.isNotEmpty) const SizedBox(width: 8), GestureDetector( onTap: () { final appCtx = Provider.of(ctx, listen: false); final vCtx = appCtx.getContext() as VisitAppContext; Navigator.of(ctx).push(MaterialPageRoute( builder: (_) => EventPage( section: featuredEvent, visitAppContextIn: vCtx, ), )); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), ), child: Text( TranslationHelper.getFromLocale('event.discover', visitAppContext), style: TextStyle( color: kMainColor, fontSize: 12, fontWeight: FontWeight.w700, ), ), ), ), ], ), ], ); }), ), // Icône lunettes / mode vocal — à gauche du bouton settings Positioned( top: 35, right: 64, child: GlassesStatusWidget(visitAppContext: visitAppContext), ), Positioned( top: 35, right: 10, child: Builder(builder: (ctx) { final appCtx = Provider.of(ctx, listen: false); final lang = (visitAppContext.language ?? defaultLanguage).toLowerCase(); // Le drapeau en pastille dit ce que la feuille contient // d'abord : un engrenage ne rendait pas le choix de // langue découvrable. return GlassPill( size: 46, onTap: () => _showSettingsSheet(ctx, appCtx), onLongPress: () => showDialog( context: ctx, builder: (dialogCtx) => const AlertDialog( shape: RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(10.0)), ), content: AdminPopup(), contentPadding: EdgeInsets.zero, ), ), child: SizedBox( width: 46, height: 46, child: Stack( alignment: Alignment.center, children: [ const Icon(Icons.tune_rounded, color: Colors.white, size: 22), Positioned( bottom: 5, right: 5, child: Container( width: 16, height: 16, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: Colors.black54, width: 1), image: DecorationImage( fit: BoxFit.cover, image: AssetImage('assets/images/old/$lang.png'), ), ), ), ), ], ), ), ); }), ), ], ), ), ), title: featuredEvent == null ? SizedBox( width: size.width * 1.0, height: 120, child: Center( child: headerTitleEntry != null ? HtmlWidget( headerTitleEntry.value!, textStyle: const TextStyle( color: Colors.white, fontFamily: 'Roboto', fontSize: 20, fontWeight: FontWeight.bold, ), customStylesBuilder: (_) => { 'text-align': 'center', 'font-family': 'Roboto', '-webkit-line-clamp': '2', }, ) : const SizedBox(), ), ) : null, ), ), SliverPadding( padding: const EdgeInsets.only( left: 8.0, right: 8.0, top: 8.0, bottom: 20.0), sliver: SliverToBoxAdapter( child: Builder(builder: (context) { const columns = 2; // grille mobile const gap = 12.0; final gridWidth = size.width - 16.0; final cellWidth = (gridWidth - (columns - 1) * gap) / columns; final cellHeight = cellWidth * 0.9; final result = weightedConfigs.isNotEmpty ? bentoLayout( weightedConfigs .map((w) => BentoItem( id: w.configuration.id!, colSpan: w.colSpan.clamp(1, columns), rowSpan: w.rowSpan.clamp(1, 2), )) .toList(), columns, ) : const BentoResult(placements: [], rowCount: 0); final totalHeight = result.rowCount <= 0 ? 0.0 : result.rowCount * cellHeight + (result.rowCount - 1) * gap; final configById = { for (final w in weightedConfigs) w.configuration.id!: w.configuration, }; return SizedBox( height: totalHeight, child: Stack( children: result.placements.map((p) { final config = configById[p.id]!; return Positioned( left: p.col * (cellWidth + gap), top: p.row * (cellHeight + gap), width: p.colSpan * cellWidth + (p.colSpan - 1) * gap, height: p.rowSpan * cellHeight + (p.rowSpan - 1) * gap, child: _buildCard(context, config), ); }).toList(), ), ); }), ), ), ], ), ), // Scanner et assistant empiles dans le meme coin. Le scanner est // en bas : c'est le geste le plus frequent, donc le plus a portee // de pouce. Depuis que le scan d'un QR d'une autre visite bascule // proprement dessus, il n'y a plus de raison de le reserver a // l'interieur d'une visite. Positioned( bottom: 20, right: 14, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (visitAppContext.applicationInstanceDTO?.isAssistant == true) ...[ _assistantButton(context), const SizedBox(height: 12), ], // Meme pastille de verre que le reglage de langue et le badge // de telechargement d'une tuile : sur l'accueil, les actions // se posent sur le contenu, elles ne s'y superposent pas en // disque plein. Le `Padding` recentre la pastille de 46 sous // le bouton d'assistant, plus large de 10. Padding( padding: const EdgeInsets.only(right: 5), child: ScannerBouton(appContext: appContext, size: 46, transparent: true), ), ], ), ), ], ); } else if (snapshot.connectionState == ConnectionState.none) { return Text(TranslationHelper.getFromLocale("noData", appContext.getContext())); } else { return Center( child: SizedBox( height: size.height * 0.15, child: const LoadingCommon(), ), ); } }, ), ); } /// Sorti du `build` pour que le coin bas-droit se lise comme ce qu'il est : /// une pile de deux boutons. Widget _assistantButton(BuildContext context) { return FloatingActionButton( heroTag: 'assistant_home', backgroundColor: kMainColor1, onPressed: () { AssistantChatSheet.show( context, visitAppContext: visitAppContext, onNavigateToSection: (configurationId, _) { final config = configurations .where((c) => c.id == configurationId) .firstOrNull; if (config != null) { Navigator.push( context, MaterialPageRoute( builder: (_) => ConfigurationPage( configuration: config, isAlreadyAllowed: visitAppContext.isScanBeaconAlreadyAllowed, ), ), ); } }, ); }, child: const Icon(Icons.chat_bubble_outline, color: Colors.white), ); } Future?> getConfigurationsCall(AppContext appContext) async { final configs = await _fetchConfigurations(appContext); await _refreshDownloadedState(configs ?? []); return configs; } Future _refreshDownloadedState(List<_WeightedConfig> configs) async { final downloaded = {}; for (final w in configs) { final config = w.configuration; if (config.isOffline != true || config.id == null) continue; final sections = await DatabaseHelper.instance .queryWithConfigurationId(DatabaseTableType.sections, config.id!); if (sections.isNotEmpty) downloaded.add(config.id!); } downloadedConfigIds = downloaded; } Future?> _fetchConfigurations(AppContext appContext) async { bool isOnline = await hasNetwork(); this.isOnline = isOnline; VisitAppContext visitAppContext = appContext.getContext(); List? configurations; configurations = List.from(await DatabaseHelper.instance.getData(DatabaseTableType.configurations)); print("GOT configurations from LOCAL"); print(configurations.length); print(configurations); if(!isOnline) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(TranslationHelper.getFromLocale("noInternet", appContext.getContext())), backgroundColor: kMainColor2), ); // GET ALL SECTIONIDS FOR ALL CONFIGURATION (OFFLINE) for(var configuration in configurations) { var sections = List.from(await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, configuration.id!)); configuration.sectionIds = sections.map((e) => e.id!).toList(); } // GET BEACONS FROM LOCAL List beaconSections = List.from(await DatabaseHelper.instance.getData(DatabaseTableType.beaconSection)); print("GOT beaconSection from LOCAL"); print(beaconSections); visitAppContext.beaconSections = beaconSections; //appContext.setContext(visitAppContext); // Récupère order/spans en brut (pas exposés sur ConfigurationDTO, qui // vivent sur le lien section↔instance) pour que le layout hors-ligne // corresponde à ce que le visiteur verrait en ligne. final rawRows = await DatabaseHelper.instance.queryAllRows(DatabaseTableType.configurations); final rawById = {for (final row in rawRows) row[DatabaseHelper.columnId] as String: row}; final cached = configurations.map((c) { final raw = rawById[c.id]; final colSpan = (raw?[DatabaseHelper.columnGridColSpan] as int?) ?? 1; final rowSpan = (raw?[DatabaseHelper.columnGridRowSpan] as int?) ?? 1; final order = raw?[DatabaseHelper.columnConfigOrder] as int?; return _WeightedConfig(configuration: c, colSpan: colSpan, rowSpan: rowSpan, order: order); }).toList() ..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); return cached; } if(visitAppContext.beaconSections == null) { List? sections = await ApiService.getAllBeacons(visitAppContext.clientAPI, visitAppContext.instanceId!); if(sections != null && sections.isNotEmpty) { List beaconSections = sections.map((e) => BeaconSection(minorBeaconId: e.beaconId, orderInConfig: e.order, configurationId: e.configurationId, sectionId: e.id, sectionType: e.type)).toList(); visitAppContext.beaconSections = beaconSections; try { // Clear all before await DatabaseHelper.instance.clearTable(DatabaseTableType.beaconSection); // Store it locally for offline mode for(var beaconSection in beaconSections) { await DatabaseHelper.instance.insert(DatabaseTableType.beaconSection, ModelsHelper.beaconSectionToMap(beaconSection)); } print("STORE beaconSection DONE"); } catch(e) { print("Issue during beaconSection insertion"); print(e); } print("Got some Beacons for you"); print(beaconSections); appContext.setContext(visitAppContext); } } // Charge l'ApplicationInstance Mobile pour savoir si l'assistant/statistiques sont activés if (visitAppContext.applicationInstanceDTO == null && visitAppContext.instanceId != null) { try { final instances = await visitAppContext.clientAPI.applicationInstanceApi! .applicationInstanceGet(instanceId: visitAppContext.instanceId); final mobileInstance = instances?.where((e) => e.appType == AppType.Mobile).firstOrNull; if (mobileInstance != null) { visitAppContext.applicationInstanceDTO = mobileInstance; // Deuxième point d'alimentation du cache d'images, avec le boot : c'est // ici que l'ApplicationInstance est lue de façon certaine. InstanceImages.refresh( loaderUrl: mobileInstance.loaderImageUrl, mainImageUrl: mobileInstance.mainImageUrl, ); if (mobileInstance.hasStats == true) { visitAppContext.statisticsService = StatisticsService( clientAPI: visitAppContext.clientAPI, instanceId: visitAppContext.instanceId, configurationId: visitAppContext.configuration?.id, appType: 'Mobile', language: visitAppContext.language, ); } } appContext.setContext(visitAppContext); } catch (e) { print("Could not load applicationInstance: $e"); } } // Fetch configurations via application instance links (mirrors manager-app "Applications / Mobile") if (visitAppContext.applicationInstanceDTO?.id != null) { try { final links = await visitAppContext.clientAPI.applicationInstanceApi! .applicationInstanceGetAllApplicationLinkFromApplicationInstance( visitAppContext.applicationInstanceDTO!.id!); final weightedConfigs = links ?.where((l) => l.isActive == true && l.configuration != null) .map((l) => _WeightedConfig( configuration: l.configuration!, colSpan: l.gridColSpan ?? 1, rowSpan: l.gridRowSpan ?? 1, order: l.order, )) .toList() ?? []; weightedConfigs.sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); // Best-effort : garde la visite entière — titre, image, couleurs, spans — // en cache local, et pas seulement order/spans comme avant. Hors ligne, // la table `configurations` est la seule source de l'accueil : une visite // jamais téléchargée en était absente et disparaissait purement de la // grille. Elle y reste maintenant, ternie et non ouvrable, et l'image // suit via le cache disque de `CachedNetworkImageProvider`. for (final w in weightedConfigs) { try { await DatabaseHelper.instance.insert( DatabaseTableType.configurations, { ...ModelsHelper.configurationToMap(w.configuration), // Colonne NOT NULL : un `isOffline` absent du DTO ferait échouer // l'insertion, donc perdre la visite pour l'accueil hors ligne. DatabaseHelper.columnIsOffline: w.configuration.isOffline == true, DatabaseHelper.columnConfigOrder: w.order, DatabaseHelper.columnGridColSpan: w.colSpan, DatabaseHelper.columnGridRowSpan: w.rowSpan, }, ); } catch (e) { print("Could not cache configuration ${w.configuration.id}: $e"); } } return weightedConfigs; } catch (e) { print("Could not load configurations from app instance links: $e"); } } return []; } }