import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/ResponseSubDTO.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_step_timer.dart'; import 'package:mymuseum_visitapp/Screens/Sections/Quiz/questions_list.dart'; import 'package:mymuseum_visitapp/constants.dart'; class GuidedPathMapProgressionPage extends StatefulWidget { final GuidedPathDTO path; final MapDTO mapDTO; final VisitAppContext visitAppContext; const GuidedPathMapProgressionPage({ Key? key, required this.path, required this.mapDTO, required this.visitAppContext, }) : super(key: key); @override State createState() => _GuidedPathMapProgressionPageState(); } class _GuidedPathMapProgressionPageState extends State with SingleTickerProviderStateMixin { late List _steps; int _currentStepIndex = 0; final Set _completedStepIds = {}; // Quiz state for current step List _stepQuestions = []; bool _quizCompleted = false; bool _quizPassed = false; // Geo trigger state LatLng? _userPosition; bool _inGeoZone = false; StreamSubscription? _positionSub; // Map final MapController _mapController = MapController(); // Pulsing animation for current step marker late AnimationController _pulseController; late Animation _pulseAnimation; @override void initState() { super.initState(); _steps = [...(widget.path.steps ?? [])] ..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); _pulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), )..repeat(reverse: true); _pulseAnimation = Tween(begin: 1.0, end: 1.4).animate( CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut), ); _initStepState(); _startLocationTracking(); } @override void dispose() { _positionSub?.cancel(); _pulseController.dispose(); super.dispose(); } // ─── Navigation ────────────────────────────────────────────────────────── void _initStepState() { final step = _currentStep; if (step == null) return; _quizCompleted = false; _quizPassed = false; _inGeoZone = false; if (step.quizQuestions?.isNotEmpty == true) { _stepQuestions = step.quizQuestions!.map((q) => QuestionSubDTO( chosen: null, label: q.label, responsesSubDTO: ResponseSubDTO().fromJSON(q.responses), resourceId: q.resourceId, resourceUrl: q.resource?.url, order: q.order, )).toList(); } else { _stepQuestions = []; } // Re-check geo zone with current position if (_userPosition != null) _checkGeoZone(_userPosition!); // Center map on step if it has geometry _centerMapOnCurrentStep(); } void _centerMapOnCurrentStep() { final coords = _currentStep?.geometry?.type == 'Point' ? _currentStep!.geometry!.coordinates as List? : null; if (coords != null && coords.length >= 2) { final lat = (coords[1] as num).toDouble(); final lon = (coords[0] as num).toDouble(); WidgetsBinding.instance.addPostFrameCallback((_) { _mapController.move(LatLng(lat, lon), _mapController.camera.zoom); }); } } bool get _canAdvance { final step = _currentStep; if (step == null) return false; if (step.isStepLocked == true) return false; // Geo trigger required if (_hasGeoTrigger(step) && !_inGeoZone) return false; // Quiz required to pass if ((widget.path.requireSuccessToAdvance ?? false) && step.quizQuestions?.isNotEmpty == true && !_quizPassed) return false; return true; } void _advance() { final step = _currentStep; if (step == null || !_canAdvance) return; if (step.id != null) _completedStepIds.add(step.id!); if (_currentStepIndex < _steps.length - 1) { setState(() { _currentStepIndex++; _initStepState(); }); } else { _showCompletionDialog(); } } void _goBack() { if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false)) { setState(() { _currentStepIndex--; _initStepState(); }); } } void _showCompletionDialog() { showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Parcours terminé !'), content: const Text('Vous avez complété toutes les étapes.'), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); Navigator.of(context).pop(); }, child: const Text('Fermer'), ), ], ), ); } // ─── Geo tracking ──────────────────────────────────────────────────────── Future _startLocationTracking() async { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) return; LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) return; } if (permission == LocationPermission.deniedForever) return; _positionSub = Geolocator.getPositionStream( locationSettings: const LocationSettings(accuracy: LocationAccuracy.high, distanceFilter: 5), ).listen((pos) { final newPos = LatLng(pos.latitude, pos.longitude); setState(() => _userPosition = newPos); _checkGeoZone(newPos); }); } void _checkGeoZone(LatLng position) { final step = _currentStep; if (step == null || !_hasGeoTrigger(step)) return; LatLng? triggerCenter; double radius = step.zoneRadiusMeters ?? 30; if (step.geometry?.type == 'Point') { final coords = step.geometry!.coordinates as List; triggerCenter = LatLng((coords[1] as num).toDouble(), (coords[0] as num).toDouble()); } else if (step.triggerGeoPoint?.geometry?.type == 'Point') { final coords = step.triggerGeoPoint!.geometry!.coordinates as List; triggerCenter = LatLng((coords[1] as num).toDouble(), (coords[0] as num).toDouble()); } if (triggerCenter == null) return; final distMeters = const Distance().distance(position, triggerCenter); final inZone = distMeters <= radius; if (inZone != _inGeoZone) { setState(() => _inGeoZone = inZone); } } bool _hasGeoTrigger(GuidedStepDTO step) => (step.geometry?.type == 'Point') || (step.triggerGeoPointId != null && step.triggerGeoPoint != null); // ─── Helpers ───────────────────────────────────────────────────────────── GuidedStepDTO? get _currentStep => _steps.isEmpty ? null : _steps[_currentStepIndex]; String _translate(List? list) => TranslationHelper.get(list, widget.visitAppContext); // ─── Map ───────────────────────────────────────────────────────────────── List _buildStepMarkers() { final markers = []; for (int i = 0; i < _steps.length; i++) { final step = _steps[i]; final isCompleted = _completedStepIds.contains(step.id); final isCurrent = i == _currentStepIndex; final hideNext = widget.path.hideNextStepsUntilComplete ?? false; final isVisible = !hideNext || isCompleted || isCurrent; if (!isVisible) continue; final coords = step.geometry?.type == 'Point' && step.geometry?.coordinates is List ? step.geometry!.coordinates as List : null; if (coords == null || coords.length < 2) continue; final lat = (coords[1] as num).toDouble(); final lon = (coords[0] as num).toDouble(); markers.add(Marker( point: LatLng(lat, lon), width: 44, height: 44, child: GestureDetector( onTap: isCurrent ? null : null, // tapping non-current steps could scroll sheet child: isCurrent ? AnimatedBuilder( animation: _pulseAnimation, builder: (_, __) => Transform.scale( scale: _pulseAnimation.value, child: const _StepPin(state: _StepPinState.current), ), ) : _StepPin( state: isCompleted ? _StepPinState.completed : step.isStepLocked == true ? _StepPinState.locked : _StepPinState.upcoming, ), ), )); } return markers; } Marker? _buildUserMarker() { if (_userPosition == null) return null; return Marker( point: _userPosition!, width: 36, height: 36, child: Container( decoration: BoxDecoration( color: Colors.blue, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: [BoxShadow(color: Colors.blue.withOpacity(0.4), blurRadius: 8, spreadRadius: 2)], ), child: const Icon(Icons.person, color: Colors.white, size: 18), ), ); } // ─── Build ─────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final center = widget.mapDTO.latitude != null && widget.mapDTO.longitude != null ? LatLng(double.tryParse(widget.mapDTO.latitude!)!, double.tryParse(widget.mapDTO.longitude!)!) : const LatLng(50.465503, 4.865105); final zoom = widget.mapDTO.zoom?.toDouble() ?? 15.0; final userMarker = _buildUserMarker(); final stepMarkers = _buildStepMarkers(); return Scaffold( body: Stack( children: [ // ── Carte plein écran ────────────────────────────────────────── FlutterMap( mapController: _mapController, options: MapOptions( initialCenter: center, initialZoom: zoom, ), children: [ TileLayer( urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', userAgentPackageName: 'be.unov.myinfomate.visitapp', ), MarkerLayer( markers: [ ...stepMarkers, if (userMarker != null) userMarker, ], ), ], ), // ── Bouton retour ────────────────────────────────────────────── Positioned( top: MediaQuery.of(context).padding.top + 10, left: 10, child: _CircleButton( icon: Icons.arrow_back, onTap: () => Navigator.of(context).pop(), ), ), // ── Badge progression ────────────────────────────────────────── Positioned( top: MediaQuery.of(context).padding.top + 10, right: 10, child: _ProgressBadge( current: _currentStepIndex + 1, total: _steps.length, completedIds: _completedStepIds, steps: _steps, ), ), // ── Bottom sheet drag ────────────────────────────────────────── DraggableScrollableSheet( initialChildSize: 0.22, minChildSize: 0.12, maxChildSize: 0.75, snap: true, snapSizes: const [0.22, 0.75], builder: (_, scrollController) => _buildSheet(scrollController), ), ], ), ); } Widget _buildSheet(ScrollController scrollController) { final step = _currentStep; if (step == null) return const SizedBox(); final title = _translate(step.title); final desc = _translate(step.description); final hasGeo = _hasGeoTrigger(step); final hasQuiz = step.quizQuestions?.isNotEmpty == true; final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0; return Container( decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 10)], ), child: CustomScrollView( controller: scrollController, slivers: [ SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Handle Center( child: Padding( padding: const EdgeInsets.only(top: 10, bottom: 6), child: Container( width: 40, height: 4, decoration: BoxDecoration( color: Colors.grey[300], borderRadius: BorderRadius.circular(2), ), ), ), ), // ── Peek row ──────────────────────────────────────────── Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Expanded( child: Text( title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), if (hasGeo && !_inGeoZone) Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.location_searching, size: 14, color: Colors.orange[700]), const SizedBox(width: 4), Text( 'Zone requise', style: TextStyle(fontSize: 12, color: Colors.orange[700]), ), ], ), if (hasTimer) Padding( padding: const EdgeInsets.only(left: 8), child: GuidedStepTimer( key: ValueKey(step.id), seconds: step.timerSeconds!, expiredMessage: _translate(step.timerExpiredMessage), showAsBar: false, ), ), ], ), ), const Divider(height: 20), // ── Détail étendu ─────────────────────────────────────── // Image if (step.imageUrl != null) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.network( step.imageUrl!, height: 160, width: double.infinity, fit: BoxFit.cover, ), ), ), // Locked if (step.isStepLocked == true) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ const Icon(Icons.lock, color: Colors.grey), const SizedBox(width: 8), Text('Étape verrouillée', style: TextStyle(color: Colors.grey[600])), ], ), ), // Description if (desc.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Text(desc, style: const TextStyle(fontSize: 14, height: 1.5)), ), // Timer (mode barre, dans la version étendue) if (hasTimer) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: GuidedStepTimer( key: ValueKey('bar_${step.id}'), seconds: step.timerSeconds!, expiredMessage: _translate(step.timerExpiredMessage), showAsBar: true, ), ), // Géo trigger if (hasGeo) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: _GeoZoneIndicator(inZone: _inGeoZone), ), // Quiz if (hasQuiz && !_quizCompleted) SizedBox( height: 340, child: QuestionsListWidget( questionsSubDTO: _stepQuestions, isShowResponse: false, onShowResponse: () { final goodCount = _stepQuestions.where((q) => q.chosen != null && q.chosen == q.responsesSubDTO!.indexWhere((r) => r.isGood == true), ).length; setState(() { _quizCompleted = true; _quizPassed = goodCount == _stepQuestions.length; }); }, orientation: MediaQuery.of(context).orientation, ), ), if (hasQuiz && _quizCompleted) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), child: Row( children: [ Icon( _quizPassed ? Icons.check_circle : Icons.cancel, color: _quizPassed ? Colors.green : Colors.red, ), const SizedBox(width: 8), Text( _quizPassed ? 'Quiz réussi !' : 'Quiz échoué', style: TextStyle( color: _quizPassed ? Colors.green : Colors.red, fontWeight: FontWeight.w500, ), ), if (!_quizPassed) ...[ const Spacer(), TextButton( onPressed: () => setState(() { _quizCompleted = false; _quizPassed = false; _stepQuestions = _currentStep!.quizQuestions!.map((q) => QuestionSubDTO( chosen: null, label: q.label, responsesSubDTO: ResponseSubDTO().fromJSON(q.responses), resourceId: q.resourceId, resourceUrl: q.resource?.url, order: q.order, )).toList(); }), child: const Text('Réessayer'), ), ], ], ), ), // ── Boutons navigation ─────────────────────────────────── Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), child: Row( children: [ if (_currentStepIndex > 0 && !(widget.path.isLinear ?? false)) OutlinedButton.icon( onPressed: _goBack, icon: const Icon(Icons.arrow_back, size: 16), label: const Text('Précédent'), ), const Spacer(), FilledButton.icon( onPressed: _canAdvance ? _advance : null, icon: Icon( _currentStepIndex < _steps.length - 1 ? Icons.arrow_forward : Icons.flag, size: 16, ), label: Text( _currentStepIndex < _steps.length - 1 ? 'Suivant' : 'Terminer', ), ), ], ), ), ], ), ), ], ), ); } } // ─── Widgets auxiliaires ─────────────────────────────────────────────────── enum _StepPinState { current, completed, upcoming, locked } class _StepPin extends StatelessWidget { final _StepPinState state; const _StepPin({required this.state}); @override Widget build(BuildContext context) { switch (state) { case _StepPinState.completed: return const CircleAvatar( backgroundColor: Colors.green, radius: 18, child: Icon(Icons.check, color: Colors.white, size: 18), ); case _StepPinState.current: return const CircleAvatar( backgroundColor: Colors.blue, radius: 18, child: Icon(Icons.location_on, color: Colors.white, size: 18), ); case _StepPinState.locked: return CircleAvatar( backgroundColor: Colors.grey[400], radius: 18, child: const Icon(Icons.lock, color: Colors.white, size: 16), ); case _StepPinState.upcoming: return CircleAvatar( backgroundColor: Colors.grey[300], radius: 18, child: Icon(Icons.location_on, color: Colors.grey[600], size: 18), ); } } } class _CircleButton extends StatelessWidget { final IconData icon; final VoidCallback onTap; const _CircleButton({required this.icon, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( width: 44, height: 44, decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6)], ), child: Icon(icon, size: 22), ), ); } } class _ProgressBadge extends StatelessWidget { final int current; final int total; final Set completedIds; final List steps; const _ProgressBadge({ required this.current, required this.total, required this.completedIds, required this.steps, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 6)], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( '$current / $total', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), const SizedBox(height: 4), Row( mainAxisSize: MainAxisSize.min, children: List.generate(total, (i) { final isCompleted = steps[i].id != null && completedIds.contains(steps[i].id); final isCurrent = i == current - 1; return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: Container( width: 8, height: 8, decoration: BoxDecoration( shape: BoxShape.circle, color: isCompleted ? Colors.green : isCurrent ? Colors.blue : Colors.grey[300], ), ), ); }), ), ], ), ); } } class _GeoZoneIndicator extends StatelessWidget { final bool inZone; const _GeoZoneIndicator({required this.inZone}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: inZone ? Colors.green.withOpacity(0.1) : Colors.orange.withOpacity(0.1), borderRadius: BorderRadius.circular(8), border: Border.all(color: inZone ? Colors.green : Colors.orange), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( inZone ? Icons.location_on : Icons.location_searching, color: inZone ? Colors.green : Colors.orange, size: 18, ), const SizedBox(width: 8), Text( inZone ? 'Vous êtes dans la zone ✓' : 'Approchez-vous du point indiqué', style: TextStyle( color: inZone ? Colors.green[700] : Colors.orange[700], fontSize: 13, ), ), ], ), ); } }