import 'dart:async'; import 'package:flutter/material.dart'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/services.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/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'; class _WeightedConfig { final ConfigurationDTO configuration; final int colSpan; final int rowSpan; final int? order; const _WeightedConfig({required this.configuration, this.colSpan = 1, this.rowSpan = 1, this.order}); } Color? _parseStoredColor(String? raw) { if (raw == null) return null; try { return Color(int.parse(raw.split('(0x')[1].split(')')[0], radix: 16)); } catch (_) { return null; } } class HomePage3 extends StatefulWidget { const HomePage3({Key? key}) : super(key: key); @override State createState() => _HomePage3State(); } class _HomePage3State extends State with WidgetsBindingObserver { int currentIndex = 0; late List configurations = []; List<_WeightedConfig> weightedConfigs = []; List alreadyDownloaded = []; late VisitAppContext visitAppContext; late Future?> _futureConfigurations; @override void initState() { SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.initState(); final appContext = Provider.of(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('
', ' '); final fallbackBaseColor = _parseStoredColor(config.primaryColor) ?? _parseStoredColor(visitAppContext.applicationInstanceDTO?.primaryColor) ?? kMainColor1; final fallbackColor = Color.lerp(fallbackBaseColor, Colors.white, 0.35)!; return InkWell( borderRadius: BorderRadius.circular(16), onTap: () { Navigator.of(context).push(MaterialPageRoute( builder: (context) => ConfigurationPage( configuration: config, isAlreadyAllowed: visitAppContext.isScanBeaconAlreadyAllowed, ), )); }, 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], ), ), ), ), // Title at bottom-left — hauteur bornée + clip : empêche le débordement // même si le moteur HTML natif ignore -webkit-line-clamp (contenu Quill). Positioned( bottom: 10, left: 10, right: 28, child: ClipRect( child: SizedBox( height: 40, child: HtmlWidget( cleanedTitle, textStyle: const TextStyle( color: Colors.white, fontFamily: 'Roboto', fontSize: 14, fontWeight: FontWeight.w600, ), customStylesBuilder: (_) => { 'font-family': 'Roboto', '-webkit-line-clamp': '2', }, ), ), ), ), // Chevron const Positioned( bottom: 10, right: 8, child: Icon(Icons.chevron_right, size: 18, color: Colors.white), ), ], ), ), ), ), ), ); } 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, 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.grey.shade300, borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 20), // Entrée assistant vocal (si feature disponible) if (visitAppContext.applicationInstanceDTO?.isAssistant == true) ...[ ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.mic, color: kMainColor1), title: const Text('Assistant vocal', style: TextStyle(fontWeight: FontWeight.w600)), subtitle: const Text('Lunettes ou micro téléphone'), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.pop(ctx); VoiceModeSheet.show(ctx, visitAppContext: visitAppContext); }, ), const Divider(), ], const Text('Langue', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), const SizedBox(height: 12), Wrap( spacing: 10, children: configLanguages.map((lang) { final isSelected = ctx2.language == lang; return GestureDetector( onTap: () async { if (ctx2.language != lang) { ctx2.language = lang; appCtx.setContext(ctx2); await DatabaseHelper.instance.insert(DatabaseTableType.main, ctx2.toMap()); setLocal(() {}); } }, child: Container( width: 48, height: 48, decoration: BoxDecoration( shape: BoxShape.circle, border: isSelected ? Border.all(color: kMainColor, width: 2.5) : null, image: DecorationImage( fit: BoxFit.contain, image: AssetImage('assets/images/old/${lang.toLowerCase()}.png'), ), boxShadow: const [BoxShadow(color: kSecondGrey, spreadRadius: 0.5, blurRadius: 5, offset: Offset(0, 1.5))], ), ), ); }).toList(), ), if (hasNotifications) ...[ const SizedBox(height: 24), const Divider(), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('Notifications push', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), Switch( value: ctx2.notificationsEnabled, activeThumbColor: kMainColor, 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(() {}); }, ), ], ), ], ], ), ); }); }, ); } @override Widget build(BuildContext context) { Size size = MediaQuery.of(context).size; final appContext = Provider.of(context); visitAppContext = appContext.getContext(); return Scaffold( extendBody: true, body: FutureBuilder( future: _futureConfigurations, builder: (context, AsyncSnapshot snapshot) { if (snapshot.connectionState == ConnectionState.done) { final mobileConfigIds = visitAppContext.applicationInstanceDTO ?.configurations?.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: Color(0xFF111111), 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) Text( titleEntry.value ?? '', style: const TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold, height: 1.2, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), Row( children: [ if (dateLabel.isNotEmpty) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: kMainColor.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(12), ), child: Text( dateLabel, style: const TextStyle(color: Colors.white, fontSize: 12), ), ), if (dateLabel.isNotEmpty) const SizedBox(width: 8), GestureDetector( onTap: () { final appCtx = Provider.of(ctx, listen: false); final vCtx = appCtx.getContext() as VisitAppContext; Navigator.of(ctx).push(MaterialPageRoute( builder: (_) => EventPage( section: featuredEvent, visitAppContextIn: vCtx, ), )); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), ), child: Text( 'Découvrir', style: TextStyle( color: kMainColor, fontSize: 12, fontWeight: FontWeight.w700, ), ), ), ), ], ), ], ); }), ), // Icône lunettes / mode vocal — à gauche du bouton settings Positioned( top: 35, right: 68, child: GlassesStatusWidget(visitAppContext: visitAppContext), ), Positioned( top: 35, right: 10, child: Builder(builder: (ctx) { final appCtx = Provider.of(ctx, listen: false); return GestureDetector( 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: Container( width: 50, height: 50, decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle, ), child: const Icon(Icons.settings, color: Colors.white, size: 26), ), ); }), ), ], ), ), ), 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?> getConfigurationsCall(AppContext appContext) async { bool isOnline = await hasNetwork(); VisitAppContext visitAppContext = appContext.getContext(); isOnline = true; // Todo remove if not local test List? configurations; configurations = List.from(await DatabaseHelper.instance.getData(DatabaseTableType.configurations)); alreadyDownloaded = configurations.map((c) => c.id).toList(); print("GOT configurations from LOCAL"); print(configurations.length); print(configurations); if(!isOnline) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(TranslationHelper.getFromLocale("noInternet", appContext.getContext())), backgroundColor: kMainColor2), ); // GET ALL SECTIONIDS FOR ALL CONFIGURATION (OFFLINE) for(var configuration in configurations) { var sections = List.from(await DatabaseHelper.instance.queryWithConfigurationId(DatabaseTableType.sections, configuration.id!)); configuration.sectionIds = sections.map((e) => e.id!).toList(); } // GET BEACONS FROM LOCAL List beaconSections = List.from(await DatabaseHelper.instance.getData(DatabaseTableType.beaconSection)); print("GOT beaconSection from LOCAL"); print(beaconSections); visitAppContext.beaconSections = beaconSections; //appContext.setContext(visitAppContext); // Récupère order/spans en brut (pas exposés sur ConfigurationDTO, qui // vivent sur le lien section↔instance) pour que le layout hors-ligne // corresponde à ce que le visiteur verrait en ligne. final rawRows = await DatabaseHelper.instance.queryAllRows(DatabaseTableType.configurations); final rawById = {for (final row in rawRows) row[DatabaseHelper.columnId] as String: row}; final cached = configurations.map((c) { final raw = rawById[c.id]; final colSpan = (raw?[DatabaseHelper.columnGridColSpan] as int?) ?? 1; final rowSpan = (raw?[DatabaseHelper.columnGridRowSpan] as int?) ?? 1; final order = raw?[DatabaseHelper.columnConfigOrder] as int?; return _WeightedConfig(configuration: c, colSpan: colSpan, rowSpan: rowSpan, order: order); }).toList() ..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); return cached; } if(visitAppContext.beaconSections == null) { List? sections = await ApiService.getAllBeacons(visitAppContext.clientAPI, visitAppContext.instanceId!); if(sections != null && sections.isNotEmpty) { List beaconSections = sections.map((e) => BeaconSection(minorBeaconId: e.beaconId, orderInConfig: e.order, configurationId: e.configurationId, sectionId: e.id, sectionType: e.type)).toList(); visitAppContext.beaconSections = beaconSections; try { // Clear all before await DatabaseHelper.instance.clearTable(DatabaseTableType.beaconSection); // Store it locally for offline mode for(var beaconSection in beaconSections) { await DatabaseHelper.instance.insert(DatabaseTableType.beaconSection, ModelsHelper.beaconSectionToMap(beaconSection)); } print("STORE beaconSection DONE"); } catch(e) { print("Issue during beaconSection insertion"); print(e); } print("Got some Beacons for you"); print(beaconSections); appContext.setContext(visitAppContext); } } // Charge l'ApplicationInstance Mobile pour savoir si l'assistant/statistiques sont activés if (visitAppContext.applicationInstanceDTO == null && visitAppContext.instanceId != null) { try { final instances = await visitAppContext.clientAPI.applicationInstanceApi! .applicationInstanceGet(instanceId: visitAppContext.instanceId); final mobileInstance = instances?.where((e) => e.appType == AppType.Mobile).firstOrNull; if (mobileInstance != null) { visitAppContext.applicationInstanceDTO = mobileInstance; 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.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. for (final w in weightedConfigs) { try { await DatabaseHelper.instance.insert(DatabaseTableType.configurations, { DatabaseHelper.columnId: w.configuration.id, DatabaseHelper.columnConfigOrder: w.order, DatabaseHelper.columnGridColSpan: w.colSpan, DatabaseHelper.columnGridRowSpan: w.rowSpan, }); } 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 []; } }