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

1211 lines
57 KiB
Dart

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<HomePage3> createState() => _HomePage3State();
}
class _HomePage3State extends State<HomePage3> with WidgetsBindingObserver {
int currentIndex = 0;
late List<ConfigurationDTO> 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<String> downloadedConfigIds = {};
/// État réseau au dernier chargement de l'accueil. Une visite non téléchargée
/// n'a rien à montrer sans réseau : elle reste affichée — titre et image sont
/// en cache — mais ternie et non ouvrable, plutôt que disparue de la grille.
bool isOnline = true;
late VisitAppContext visitAppContext;
late Future<List<_WeightedConfig>?> _futureConfigurations;
@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<AppContext>(context, listen: false);
_futureConfigurations = getConfigurationsCall(appContext);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// Le visiteur qui coupe le mode avion ou rejoint le wifi du site revient dans
// l'app par là. Sans ce rappel, l'accueil gardait l'état réseau de son premier
// chargement : tuiles ternies et visites refusées alors que le réseau est revenu.
if (state == AppLifecycleState.resumed) _reloadIfNetworkChanged();
}
/// Reteste le réseau et, s'il a changé, relance le chargement de l'accueil —
/// images et titres compris, qui viennent du même appel.
Future<void> _reloadIfNetworkChanged() async {
final online = await hasNetwork();
if (!mounted || online == isOnline) return;
setState(() {
isOnline = online;
_futureConfigurations = getConfigurationsCall(
Provider.of<AppContext>(context, listen: false));
});
}
Widget _buildCard(BuildContext context, ConfigurationDTO config) {
final lang = visitAppContext.language ?? "FR";
final titleEntry = config.title?.firstWhere(
(t) => t.language == lang,
orElse: () => config.title!.first,
);
final cleanedTitle = (titleEntry?.value ?? '').replaceAll('\n', ' ').replaceAll('<br>', ' ');
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<AppContext>(context, listen: false).getContext();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(TranslationHelper.getFromLocale("noInternet", ctx)),
backgroundColor: kMainColor2,
),
);
}
Future<void> _openConfiguration(BuildContext context, ConfigurationDTO config) async {
final appCtx = Provider.of<AppContext>(context, listen: false);
final ctx = appCtx.getContext() as VisitAppContext;
// Le détail lit l'API dès qu'il n'est pas servi par la base locale : sans
// réseau ni contenu téléchargé, la visite s'ouvrirait vide.
//
// Deuxième mesure du réseau avant de refuser : la première date du chargement
// de l'accueil, et refuser sur une mesure périmée est le pire des cas — le
// visiteur voit ses barres de réseau et l'app lui dit qu'il est hors ligne.
if (!isOnline && !downloadedConfigIds.contains(config.id)) {
await _reloadIfNetworkChanged();
if (!context.mounted) return;
if (!isOnline) {
_showOfflineNotice(context);
return;
}
}
// Une visite hors ligne non téléchargée ouvrait un détail vide : `body.dart`
// lit ses sections uniquement en base locale quand `isOffline` est vrai.
if (config.isOffline == true && !downloadedConfigIds.contains(config.id)) {
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<void> _promptDownload(BuildContext context, ConfigurationDTO config) async {
final appCtx = Provider.of<AppContext>(context, listen: false);
final isUpdate = downloadedConfigIds.contains(config.id);
final configLanguages = config.languages ?? languages;
final confirmed = await showDialog<bool>(
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<String> languagesEnabled,
required String? current,
required Future<void> 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<PackageInfo>(
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<AppContext>(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<dynamic> 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('<br>', ' '),
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<AppContext>(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<AppContext>(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<List<_WeightedConfig>?> getConfigurationsCall(AppContext appContext) async {
final configs = await _fetchConfigurations(appContext);
await _refreshDownloadedState(configs ?? []);
return configs;
}
Future<void> _refreshDownloadedState(List<_WeightedConfig> configs) async {
final downloaded = <String>{};
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<List<_WeightedConfig>?> _fetchConfigurations(AppContext appContext) async {
bool isOnline = await hasNetwork();
this.isOnline = isOnline;
VisitAppContext visitAppContext = appContext.getContext();
List<ConfigurationDTO>? configurations;
configurations = List<ConfigurationDTO>.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<SectionDTO>.from(await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, configuration.id!));
configuration.sectionIds = sections.map((e) => e.id!).toList();
}
// GET BEACONS FROM LOCAL
List<BeaconSection> beaconSections = List<BeaconSection>.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<SectionDTO>? sections = await ApiService.getAllBeacons(visitAppContext.clientAPI, visitAppContext.instanceId!);
if(sections != null && sections.isNotEmpty) {
List<BeaconSection> 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 [];
}
}