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>
1211 lines
57 KiB
Dart
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 [];
|
|
}
|
|
} |