Ce commit boucle le travail en cours sur la branche (assistant lunettes Meta, passage du lecteur audio flottant a un onglet, ajustements scanner / liste de configurations / telechargement) et y ajoute le badge de version. Le badge, en bas de la feuille Parametres, affiche « flavor . version . commit ». Un APK pose sur une tablette du terrain n'etait rattachable a aucun commit precis : la version du pubspec ne bougeait pas d'un build a l'autre et rien n'indiquait le flavor reellement installe. kGitSha suit le meme schema que kApiBaseUrl, injecte par --dart-define, et kFlavor expose le flavor deja calcule. package_info_plus etait deja une dependance transitive ; il devient direct, puisqu'il est desormais importe. /!\ Un --dart-define modifie n'est PAS pris en compte sans `flutter clean` sur ce projet : verifie a la sentinelle, le SHA restait absent de libapp.so tant que le cache Dart n'etait pas vide. Un build de release destine au terrain doit donc toujours passer par un clean, sinon le badge affiche le SHA du build precedent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1068 lines
49 KiB
Dart
1068 lines
49 KiB
Dart
import 'dart:async';
|
|
|
|
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/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 = {};
|
|
late VisitAppContext visitAppContext;
|
|
|
|
late Future<List<_WeightedConfig>?> _futureConfigurations;
|
|
|
|
@override
|
|
void initState() {
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
|
super.initState();
|
|
final appContext = Provider.of<AppContext>(context, listen: false);
|
|
_futureConfigurations = getConfigurationsCall(appContext);
|
|
}
|
|
|
|
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);
|
|
|
|
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),
|
|
),
|
|
],
|
|
image: config.imageSource != null
|
|
? DecorationImage(
|
|
fit: BoxFit.cover,
|
|
image: NetworkImage(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)
|
|
Positioned.fill(
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(16),
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
),
|
|
),
|
|
),
|
|
if (isOffline)
|
|
Positioned(
|
|
top: 8,
|
|
right: 8,
|
|
child: GlassPill(
|
|
size: 32,
|
|
onTap: () => _promptDownload(context, config),
|
|
child: Icon(
|
|
needsDownload ? Icons.arrow_downward_rounded : Icons.check_rounded,
|
|
size: 17,
|
|
color: needsDownload ? 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),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _openConfiguration(BuildContext context, ConfigurationDTO config) async {
|
|
final appCtx = Provider.of<AppContext>(context, listen: false);
|
|
final ctx = appCtx.getContext() as VisitAppContext;
|
|
|
|
// 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,
|
|
body: FutureBuilder(
|
|
future: _futureConfigurations,
|
|
builder: (context, AsyncSnapshot<dynamic> snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.done) {
|
|
final mobileConfigIds = visitAppContext.applicationInstanceDTO
|
|
?.configurations
|
|
?.where((c) => c.isActive == true)
|
|
.map((c) => c.configurationId)
|
|
.toSet() ?? {};
|
|
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: [
|
|
if (featuredEvent?.imageSource != null)
|
|
Image.network(featuredEvent!.imageSource!, fit: BoxFit.cover)
|
|
else if (configurations.isNotEmpty && configurations[0].imageSource != null)
|
|
Image.network(
|
|
configurations[0].imageSource!,
|
|
fit: BoxFit.cover,
|
|
),
|
|
// 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(),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (visitAppContext.applicationInstanceDTO?.isAssistant == true)
|
|
Positioned(
|
|
bottom: 24,
|
|
right: 16,
|
|
child: 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),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
} 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(),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
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();
|
|
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;
|
|
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 order/spans en cache local pour que le rendu
|
|
// hors-ligne reste cohérent avec ce qui vient d'être vu en ligne.
|
|
//
|
|
// `update` et non `insert` : ce dernier bascule sur un INSERT quand la
|
|
// ligne n'existe pas, et ces quatre colonnes ne suffisent pas à satisfaire
|
|
// les NOT NULL de la table. Toute visite pas encore téléchargée levait donc
|
|
// « NOT NULL constraint failed: configurations.instanceId » à chaque
|
|
// démarrage. Un UPDATE sans ligne cible ne touche rien, ce qui est la
|
|
// bonne sémantique : ces spans complètent une visite en cache, ils ne la
|
|
// créent pas.
|
|
for (final w in weightedConfigs) {
|
|
try {
|
|
await DatabaseHelper.instance.update(
|
|
DatabaseTableType.configurations,
|
|
{
|
|
DatabaseHelper.columnConfigOrder: w.order,
|
|
DatabaseHelper.columnGridColSpan: w.colSpan,
|
|
DatabaseHelper.columnGridRowSpan: w.rowSpan,
|
|
},
|
|
w.configuration.id!,
|
|
);
|
|
} catch (e) {
|
|
print("Could not cache order/spans for configuration ${w.configuration.id}: $e");
|
|
}
|
|
}
|
|
|
|
return weightedConfigs;
|
|
} catch (e) {
|
|
print("Could not load configurations from app instance links: $e");
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
} |