import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart' as ll; import 'package:manager_api_new/api.dart'; import 'package:provider/provider.dart'; import 'package:tablet_app/Helpers/ImageCustomProvider.dart'; import 'package:tablet_app/Helpers/translationHelper.dart'; import 'package:tablet_app/Models/tabletContext.dart'; import 'package:tablet_app/app_context.dart'; import 'package:tablet_app/constants.dart'; /// Bande héros : 26 % de la hauteur. Sur mobile elle en prend 52 % et le /// parallaxe récompense le défilement ; debout devant une borne, personne ne /// défile pour découvrir qu'il y a un programme dessous. const double kEventHeroRatio = 0.26; const int kEventProgrammeFlex = 44; const int kEventMapFlex = 56; /// Couleur du bloc en cours et de ses annotations. Sémantique, distincte de /// l'accent : c'est ce qui permet de relier une ligne du programme à un point /// de la carte sans légende. const Color kEventLiveColor = Color(0xFFC4622D); /// Les coordonnées portées par `geometry.coordinates` sont en **[lng, lat]** /// (GeoJSON). Celles de `GuidedStep` sont en [lat, lng] — les deux ordres /// coexistent en production et une confusion ne lève aucune erreur, elle /// déplace les points. Ne pas réutiliser un helper venu d'un autre contexte. class _Annotation { final GeometryType? geometryType; final Object? coordinates; final String? polyColor; _Annotation(this.geometryType, this.coordinates, this.polyColor); static _Annotation fromDto(MapAnnotationDTO a) => _Annotation(a.geometryType, a.geometry?.coordinates, a.polyColor); static _Annotation fromBlock(MapAnnotation a) => _Annotation(a.geometryType, a.geometry?.coordinates, a.polyColor); } class EventView extends StatefulWidget { final SectionEventDTO section; const EventView({Key? key, required this.section}) : super(key: key); @override State createState() => _EventViewState(); } class _EventViewState extends State { ProgrammeBlock? _selectedBlock; ProgrammeBlock? get _activeBlock { final now = DateTime.now(); return widget.section.programme ?.where((block) => block.startTime != null && block.endTime != null && !now.isBefore(block.startTime!) && !now.isAfter(block.endTime!)) .firstOrNull; } Color _primaryColor(TabletAppContext tabletAppContext) { try { return Color(int.parse( tabletAppContext.configuration!.primaryColor!.split('(0x')[1].split(')')[0], radix: 16)); } catch (_) { return kTestSecondColor; } } String _formatTime(DateTime? date) { if (date == null) return ""; return "${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}"; } String _formatDate(DateTime date) => "${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}"; /// Les dates sont numériques, donc lisibles dans toutes les langues sans /// traduction. C'est ce qui porte l'information « avant / pendant / après » /// quand l'événement n'a pas lieu aujourd'hui. String _formatDateRange() { final start = widget.section.startDate; final end = widget.section.endDate; if (start == null && end == null) return ""; if (start == null) return _formatDate(end!); if (end == null) return _formatDate(start); return "${_formatDate(start)} → ${_formatDate(end)}"; } @override Widget build(BuildContext context) { final appContext = Provider.of(context); final tabletAppContext = appContext.getContext() as TabletAppContext; final primaryColor = _primaryColor(tabletAppContext); final size = MediaQuery.of(context).size; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( height: size.height * kEventHeroRatio, child: _buildHero(appContext, tabletAppContext, primaryColor), ), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded( flex: kEventProgrammeFlex, child: _buildProgramme(tabletAppContext, primaryColor), ), Expanded( flex: kEventMapFlex, child: _buildMapColumn(tabletAppContext, primaryColor), ), ], ), ), ], ); } Widget _buildHero( AppContext appContext, TabletAppContext tabletAppContext, Color primaryColor, ) { final title = TranslationHelper.get(widget.section.title, tabletAppContext); final dateRange = _formatDateRange(); return Stack( fit: StackFit.expand, children: [ if (widget.section.imageSource != null) Image( image: ImageCustomProvider.getImageProvider( appContext, widget.section.imageId, widget.section.imageSource!, ), fit: BoxFit.cover, ) else DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ primaryColor, Color.lerp(primaryColor, Colors.white, 0.45) ?? primaryColor, ], ), ), ), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black54], stops: [0.4, 1.0], ), ), ), Positioned( left: 24.0, right: 24.0, bottom: 18.0, child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Text( title, style: const TextStyle( color: Colors.white, fontSize: kTitleSize, fontWeight: FontWeight.bold, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), if (dateRange.isNotEmpty) Container( margin: const EdgeInsets.only(left: 16.0), padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 6.0), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.92), borderRadius: BorderRadius.circular(20.0), ), child: Text( dateRange, style: TextStyle( color: primaryColor, fontSize: 16.0, fontWeight: FontWeight.w600, ), ), ), ], ), ), ], ); } Widget _buildProgramme(TabletAppContext tabletAppContext, Color primaryColor) { final blocks = widget.section.programme ?? []; if (blocks.isEmpty) return const SizedBox.shrink(); final active = _activeBlock; return ListView.builder( padding: const EdgeInsets.all(16.0), itemCount: blocks.length, itemBuilder: (context, index) { final block = blocks[index]; return _buildBlock( tabletAppContext, primaryColor, block, isActive: block == active, isSelected: block == _selectedBlock, ); }, ); } Widget _buildBlock( TabletAppContext tabletAppContext, Color primaryColor, ProgrammeBlock block, { required bool isActive, required bool isSelected, }) { final title = TranslationHelper.get(block.title, tabletAppContext); final start = _formatTime(block.startTime); final end = _formatTime(block.endTime); final liveLabel = TranslationHelper.getFromLocale("event.live", tabletAppContext); return InkWell( onTap: () => setState(() => _selectedBlock = isSelected ? null : block), child: Container( margin: const EdgeInsets.only(bottom: 10.0), padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 14.0), decoration: BoxDecoration( color: isActive ? kEventLiveColor.withValues(alpha: 0.12) : Colors.white, borderRadius: BorderRadius.circular(8.0), border: Border.all( color: isActive ? kEventLiveColor : (isSelected ? primaryColor : kBackgroundGrey.withValues(alpha: 0.5)), width: isSelected && !isActive ? 2.0 : 1.0, ), ), child: Row( children: [ SizedBox( width: 62.0, child: Text( start, style: TextStyle( fontSize: 16.0, fontWeight: isActive ? FontWeight.bold : FontWeight.normal, color: isActive ? kEventLiveColor : kSecondGrey, ), ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle( fontSize: 18.0, fontWeight: isActive ? FontWeight.bold : FontWeight.w500, color: kMainGrey, ), ), if (start.isNotEmpty && end.isNotEmpty) Text( "$start – $end", style: const TextStyle(fontSize: 14.0, color: kSecondGrey), ), ], ), ), if (isActive && liveLabel.isNotEmpty) Container( padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0), decoration: BoxDecoration( color: kEventLiveColor, borderRadius: BorderRadius.circular(12.0), ), child: Text( liveLabel, style: const TextStyle( color: Colors.white, fontSize: 12.0, fontWeight: FontWeight.bold, ), ), ), ], ), ), ); } Widget _buildMapColumn(TabletAppContext tabletAppContext, Color primaryColor) { final detail = _selectedBlock; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Expanded(child: _buildMap(primaryColor)), if (detail != null) _buildBlockDetail(tabletAppContext, primaryColor, detail), ], ); } Widget _buildMap(Color primaryColor) { final latitude = double.tryParse(widget.section.latitude ?? ""); final longitude = double.tryParse(widget.section.longitude ?? ""); final center = latitude != null && longitude != null ? ll.LatLng(latitude, longitude) : const ll.LatLng(50.465503, 4.865105); final global = (widget.section.globalMapAnnotations ?? []).map(_Annotation.fromDto).toList(); final blockAnnotations = (_selectedBlock ?? _activeBlock)?.mapAnnotations?.map(_Annotation.fromBlock).toList() ?? []; return FlutterMap( options: MapOptions(initialCenter: center, initialZoom: 14.0), children: [ TileLayer( urlTemplate: "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}", userAgentPackageName: "be.unov.tabletapp", ), PolylineLayer(polylines: _polylines(global, primaryColor)), MarkerLayer(markers: _markers(global, primaryColor)), PolylineLayer(polylines: _polylines(blockAnnotations, kEventLiveColor)), MarkerLayer(markers: _markers(blockAnnotations, kEventLiveColor)), ], ); } List _markers(List<_Annotation> annotations, Color color) { final markers = []; for (final annotation in annotations) { if (annotation.geometryType?.value != 0) continue; final coordinates = annotation.coordinates; if (coordinates is! List || coordinates.length < 2) continue; markers.add(Marker( point: ll.LatLng( (coordinates[1] as num).toDouble(), (coordinates[0] as num).toDouble(), ), width: 34.0, height: 34.0, child: Icon(Icons.place, color: color, size: 34.0), )); } return markers; } List _polylines(List<_Annotation> annotations, Color fallback) { final polylines = []; for (final annotation in annotations) { if (annotation.geometryType?.value != 1) continue; final coordinates = annotation.coordinates; if (coordinates is! List) continue; final points = []; for (final point in coordinates) { if (point is List && point.length >= 2) { points.add(ll.LatLng( (point[1] as num).toDouble(), (point[0] as num).toDouble(), )); } } if (points.length < 2) continue; polylines.add(Polyline( points: points, color: _polyColor(annotation.polyColor, fallback), strokeWidth: 4.0, )); } return polylines; } Color _polyColor(String? hex, Color fallback) { if (hex == null) return fallback; final value = int.tryParse("FF${hex.replaceAll('#', '')}", radix: 16); return value != null ? Color(value) : fallback; } /// Sur mobile ce détail s'ouvre en `showModalBottomSheet` : une réponse à /// l'étroitesse. Sur une borne il y a la place à côté, donc il s'affiche sous /// la carte sans masquer ce qu'on regardait. Widget _buildBlockDetail( TabletAppContext tabletAppContext, Color primaryColor, ProgrammeBlock block, ) { final title = TranslationHelper.get(block.title, tabletAppContext); final description = TranslationHelper.get(block.description, tabletAppContext); return Container( constraints: const BoxConstraints(maxHeight: 220.0), padding: const EdgeInsets.all(18.0), decoration: BoxDecoration( color: kBackgroundLight, border: Border(top: BorderSide(color: kBackgroundGrey.withValues(alpha: 0.5))), ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( title, style: const TextStyle( fontSize: 20.0, fontWeight: FontWeight.bold, color: kMainGrey, ), ), ), IconButton( icon: const Icon(Icons.close, color: kSecondGrey), onPressed: () => setState(() => _selectedBlock = null), ), ], ), Text( "${_formatTime(block.startTime)} – ${_formatTime(block.endTime)}", style: TextStyle(fontSize: 15.0, color: primaryColor, fontWeight: FontWeight.w600), ), if (description.isNotEmpty) ...[ const SizedBox(height: 10.0), Text( description, style: const TextStyle(fontSize: 16.0, color: kSecondGrey), ), ], ], ), ), ); } }