import 'package:flutter/material.dart'; import 'package:manager_api_new/api.dart'; import 'package:mymuseum_visitapp/Helpers/translationHelper.dart'; import 'package:mymuseum_visitapp/Models/visitContext.dart'; import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart'; import 'package:mymuseum_visitapp/Screens/Sections/GuidedPath/guided_path_map_progression_page.dart'; import 'package:mymuseum_visitapp/constants.dart'; class ParcoursPage extends StatefulWidget { final ParcoursDTO section; final VisitAppContext visitAppContextIn; const ParcoursPage({ Key? key, required this.section, required this.visitAppContextIn, }) : super(key: key); @override State createState() => _ParcoursPageState(); } class _ParcoursPageState extends State { List _paths = []; bool _loading = true; MapDTO? _baseMap; static const _bg = Color(0xFFF6F3EE); static const _inkDark = Color(0xFF1E2A33); static const _inkMid = Color(0xFF46555F); static const _inkMuted = Color(0xFF6B7B86); static const _borderLight = Color(0xFFEFEAE2); static const _gold = Color(0xFFD4AF37); Color get _primary => kMainColor; @override void initState() { super.initState(); _loadPaths(); } Future _loadPaths() async { if (widget.section.id == null) { setState(() => _loading = false); return; } try { final api = widget.visitAppContextIn.clientAPI.sectionParcoursApi!; final paths = await api.sectionParcoursGetAllGuidedPathFromSection(widget.section.id!); if (!mounted) return; setState(() { _paths = (paths ?? [])..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0)); _loading = false; }); if (widget.section.showMap == true && widget.section.baseSectionMapId != null) { _loadBaseMap(); } } catch (_) { if (mounted) setState(() => _loading = false); } } Future _loadBaseMap() async { try { final rawMap = await widget.visitAppContextIn.clientAPI.sectionApi! .sectionGetDetail(widget.section.baseSectionMapId!); if (rawMap != null && mounted) { setState(() => _baseMap = MapDTO.fromJson(rawMap)); } } catch (_) {} } String get _sectionTitle => TranslationHelper.get(widget.section.title, widget.visitAppContextIn); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: _bg, body: SafeArea( child: Column( children: [ _buildHero(context), Expanded(child: _buildPathList()), ], ), ), ); } Widget _buildHero(BuildContext context) { return Stack( children: [ Container( height: 220, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ _primary.withOpacity(0.85), _primary, ], ), ), child: widget.section.imageSource != null ? Image.network( widget.section.imageSource!, width: double.infinity, height: 220, fit: BoxFit.cover, color: _primary.withOpacity(0.5), colorBlendMode: BlendMode.darken, errorBuilder: (_, __, ___) => const SizedBox(), ) : null, ), Container( height: 220, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, Colors.black.withOpacity(0.45)], ), ), ), Positioned( top: 12, left: 16, child: GestureDetector( onTap: () => Navigator.of(context).pop(), child: Container( width: 40, height: 40, decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(14), ), child: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18), ), ), ), Positioned( left: 20, bottom: 20, right: 20, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_paths.isNotEmpty) Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: Colors.white.withOpacity(0.92), borderRadius: BorderRadius.circular(8), ), child: Text( '${_paths.length} parcours', style: TextStyle( color: _primary, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 0.4, ), ), ), const SizedBox(height: 8), Text( _sectionTitle, style: const TextStyle( color: Colors.white, fontSize: 26, fontWeight: FontWeight.w600, letterSpacing: -0.5, height: 1.1, ), ), ], ), ), ], ); } Widget _buildPathList() { if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_paths.isEmpty) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.route_outlined, size: 48, color: _inkMuted), const SizedBox(height: 12), Text( 'Aucun parcours disponible', style: TextStyle(color: _inkMuted, fontSize: 15), ), ], ), ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), child: Text( 'Choisir un parcours', style: TextStyle( color: _inkDark, fontSize: 20, fontWeight: FontWeight.w600, letterSpacing: -0.3, ), ), ), Expanded( child: ListView.builder( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), itemCount: _paths.length, itemBuilder: (_, i) => _buildPathCard(_paths[i]), ), ), ], ); } Widget _buildPathCard(GuidedPathDTO path) { final title = TranslationHelper.get(path.title, widget.visitAppContextIn); final desc = TranslationHelper.get(path.description, widget.visitAppContextIn); final stepCount = path.steps?.length ?? 0; final isGame = path.isGameMode == true; final durationMin = path.estimatedDurationMinutes; return GestureDetector( onTap: () => _onPathTap(path), child: Container( margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: _borderLight), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.06), blurRadius: 16, offset: const Offset(0, 6), ), ], ), child: Row( children: [ Container( width: 88, height: 88, decoration: BoxDecoration( borderRadius: const BorderRadius.only( topLeft: Radius.circular(19), bottomLeft: Radius.circular(19), ), gradient: isGame ? const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF1a2436), Color(0xFF0B1018)], ) : LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ _primary.withOpacity(0.8), _primary, ], ), ), child: Icon( isGame ? Icons.lock_outlined : Icons.route_outlined, color: isGame ? _gold : Colors.white.withOpacity(0.9), size: 32, ), ), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(13, 12, 12, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: isGame ? _gold.withOpacity(0.15) : _primary.withOpacity(0.1), borderRadius: BorderRadius.circular(7), ), child: Text( isGame ? 'ESCAPE GAME' : 'DÉCOUVERTE', style: TextStyle( color: isGame ? _gold : _primary, fontSize: 10.5, fontWeight: FontWeight.w700, letterSpacing: 0.4, ), ), ), const SizedBox(height: 6), Text( title, style: const TextStyle( color: _inkDark, fontSize: 17, fontWeight: FontWeight.w600, height: 1.15, ), ), if (desc.isNotEmpty) ...[ const SizedBox(height: 3), Text( desc, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: _inkMuted, fontSize: 12.5, height: 1.35, ), ), ], const SizedBox(height: 6), Row( children: [ Icon(Icons.flag_outlined, size: 12, color: _inkMuted), const SizedBox(width: 4), Text( '$stepCount étape${stepCount > 1 ? 's' : ''}', style: const TextStyle( color: _inkMuted, fontSize: 12, fontWeight: FontWeight.w600, ), ), if (durationMin != null) ...[ const SizedBox(width: 10), Icon(Icons.access_time_outlined, size: 12, color: _inkMuted), const SizedBox(width: 4), Text( '~$durationMin min', style: const TextStyle( color: _inkMuted, fontSize: 12, fontWeight: FontWeight.w600, ), ), ], ], ), ], ), ), ), Padding( padding: const EdgeInsets.only(right: 14), child: Icon(Icons.arrow_forward_ios, size: 14, color: _inkMuted), ), ], ), ), ); } void _onPathTap(GuidedPathDTO path) { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) => _ParcoursPathStartSheet( path: path, section: widget.section, baseMap: _baseMap, visitAppContext: widget.visitAppContextIn, primaryColor: _primary, ), ); } } class _ParcoursPathStartSheet extends StatelessWidget { final GuidedPathDTO path; final ParcoursDTO section; final MapDTO? baseMap; final VisitAppContext visitAppContext; final Color primaryColor; static const _bg = Color(0xFFF6F3EE); static const _inkDark = Color(0xFF1E2A33); static const _inkMid = Color(0xFF46555F); static const _inkMuted = Color(0xFF6B7B86); static const _gold = Color(0xFFD4AF37); const _ParcoursPathStartSheet({ Key? key, required this.path, required this.section, required this.baseMap, required this.visitAppContext, required this.primaryColor, }) : super(key: key); bool get _isGame => path.isGameMode == true; bool get _showMap => section.showMap == true && baseMap != null; String get _title => TranslationHelper.get(path.title, visitAppContext); String get _desc => TranslationHelper.get(path.description, visitAppContext); String get _gameIntro { final msg = path.gameMessageDebut; if (msg == null || msg.isEmpty) return ''; final found = msg.firstWhere( (m) => m.language == visitAppContext.language, orElse: () => msg.first, ); return found.value ?? ''; } @override Widget build(BuildContext context) { return _isGame ? _buildGameStart(context) : _buildNormalStart(context); } Widget _buildNormalStart(BuildContext context) { final stepCount = path.steps?.length ?? 0; final durationMin = path.estimatedDurationMinutes; return Container( decoration: const BoxDecoration( color: _bg, borderRadius: BorderRadius.vertical(top: Radius.circular(26)), ), padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom + 24, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 40, height: 5, margin: const EdgeInsets.only(top: 11, bottom: 4), decoration: BoxDecoration( color: const Color(0xFFD8D2C8), borderRadius: BorderRadius.circular(3), ), ), ), Container( height: 180, margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [primaryColor.withOpacity(0.7), primaryColor], ), ), child: Stack( children: [ Positioned.fill( child: ClipRRect( borderRadius: BorderRadius.circular(20), child: CustomPaint( painter: _MapSketchPainter(primaryColor), ), ), ), Positioned( left: 14, top: 12, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: Colors.white.withOpacity(0.92), borderRadius: BorderRadius.circular(8), ), child: const Text( 'DÉCOUVERTE LIBRE', style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, letterSpacing: 0.4, color: Color(0xFF264863), ), ), ), ), Positioned( left: 16, right: 16, bottom: 14, child: Text( _title, style: const TextStyle( color: Colors.white, fontSize: 22, fontWeight: FontWeight.w700, letterSpacing: -0.3, height: 1.1, ), ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), child: Row( children: [ if (durationMin != null) ...[ Icon(Icons.access_time, size: 15, color: primaryColor), const SizedBox(width: 5), Text('~$durationMin min', style: TextStyle(color: const Color(0xFF54636E), fontSize: 13, fontWeight: FontWeight.w600)), const SizedBox(width: 18), ], Icon(Icons.map_outlined, size: 15, color: primaryColor), const SizedBox(width: 5), Text('$stepCount étape${stepCount > 1 ? 's' : ''}', style: const TextStyle(color: Color(0xFF54636E), fontSize: 13, fontWeight: FontWeight.w600)), ], ), ), if (_desc.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), child: Text( _desc, style: const TextStyle( color: _inkMid, fontSize: 15, height: 1.55, ), ), ), const SizedBox(height: 20), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () { Navigator.of(context).pop(); _startPath(context); }, style: ElevatedButton.styleFrom( backgroundColor: primaryColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 17), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), elevation: 0, ), child: const Text( 'Commencer le parcours', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700), ), ), ), ), ], ), ); } Widget _buildGameStart(BuildContext context) { final stepCount = path.steps?.length ?? 0; final durationMin = path.estimatedDurationMinutes; final intro = _gameIntro; return Container( decoration: BoxDecoration( gradient: RadialGradient( center: const Alignment(0, -0.4), radius: 1.2, colors: [const Color(0xFF16202e), const Color(0xFF0B1018)], ), borderRadius: const BorderRadius.vertical(top: Radius.circular(26)), ), padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom + 34, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Center( child: Container( width: 40, height: 5, margin: const EdgeInsets.only(top: 11, bottom: 20), decoration: BoxDecoration( color: _gold.withOpacity(0.4), borderRadius: BorderRadius.circular(3), ), ), ), Container( width: 100, height: 100, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: _gold.withOpacity(0.5), width: 1.5), boxShadow: [ BoxShadow( color: _gold.withOpacity(0.2), blurRadius: 30, spreadRadius: 4, ), ], ), child: const Icon(Icons.lock_open_outlined, color: _gold, size: 40), ), const SizedBox(height: 18), const Text( 'ESCAPE GAME', style: TextStyle( color: _gold, fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 4, ), ), const SizedBox(height: 10), Padding( padding: const EdgeInsets.symmetric(horizontal: 28), child: Text( _title, textAlign: TextAlign.center, style: const TextStyle( color: Color(0xFFF4ECD8), fontSize: 28, fontWeight: FontWeight.w600, letterSpacing: -0.5, height: 1.1, ), ), ), if (intro.isNotEmpty) ...[ const SizedBox(height: 14), Padding( padding: const EdgeInsets.symmetric(horizontal: 28), child: Text( '« $intro »', textAlign: TextAlign.center, style: TextStyle( color: const Color(0xFFb9ac8c), fontSize: 15, fontStyle: FontStyle.italic, height: 1.6, ), ), ), ], const SizedBox(height: 18), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('$stepCount énigme${stepCount > 1 ? 's' : ''}', style: const TextStyle(color: Color(0xFF8a8068), fontSize: 13, fontWeight: FontWeight.w600)), if (durationMin != null) ...[ const SizedBox(width: 8), const Text('·', style: TextStyle(color: Color(0xFF8a8068))), const SizedBox(width: 8), Text('~$durationMin min', style: const TextStyle(color: Color(0xFF8a8068), fontSize: 13, fontWeight: FontWeight.w600)), ], ], ), const SizedBox(height: 26), Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () { Navigator.of(context).pop(); _startPath(context); }, style: ElevatedButton.styleFrom( backgroundColor: _gold, foregroundColor: const Color(0xFF1a1305), padding: const EdgeInsets.symmetric(vertical: 18), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), elevation: 0, ), child: const Text( "Démarrer l'aventure", style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800, letterSpacing: 0.3), ), ), ), ), const SizedBox(height: 8), if (_isGame) const Text( 'Activez le son pour une immersion totale', style: TextStyle(color: Color(0xFF6f6655), fontSize: 12), ), ], ), ); } void _startPath(BuildContext context) { if (_showMap) { Navigator.of(context).push(MaterialPageRoute( builder: (_) => GuidedPathMapProgressionPage( path: path, mapDTO: baseMap!, visitAppContext: visitAppContext, ), )); } else { Navigator.of(context).push(MaterialPageRoute( builder: (_) => GuidedPathContentProgressionPage( path: path, visitAppContext: visitAppContext, ), )); } } } class _MapSketchPainter extends CustomPainter { final Color primaryColor; _MapSketchPainter(this.primaryColor); @override void paint(Canvas canvas, Size size) { final bgPaint = Paint() ..color = Colors.white.withOpacity(0.06) ..style = PaintingStyle.fill; final roadPaint = Paint() ..color = Colors.white.withOpacity(0.12) ..strokeWidth = 8 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; final routePaint = Paint() ..color = Colors.white.withOpacity(0.7) ..strokeWidth = 2 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; final road1 = Path() ..moveTo(-10, size.height * 0.45) ..lineTo(size.width * 0.35, size.height * 0.45) ..lineTo(size.width * 0.55, size.height * 0.2) ..lineTo(size.width + 10, size.height * 0.2); final road2 = Path() ..moveTo(size.width * 0.2, -10) ..lineTo(size.width * 0.2, size.height * 0.55) ..lineTo(size.width * 0.4, size.height + 10); canvas.drawPath(road1, roadPaint); canvas.drawPath(road2, roadPaint); final route = Path() ..moveTo(size.width * 0.15, size.height * 0.65) ..lineTo(size.width * 0.35, size.height * 0.35) ..lineTo(size.width * 0.65, size.height * 0.55) ..lineTo(size.width * 0.82, size.height * 0.35); final dashPaint = Paint() ..color = Colors.white.withOpacity(0.8) ..strokeWidth = 2 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; _drawDashedPath(canvas, route, dashPaint, dashLength: 4, gapLength: 8); final dotPaint = Paint() ..color = Colors.white ..style = PaintingStyle.fill; canvas.drawCircle(Offset(size.width * 0.15, size.height * 0.65), 5, dotPaint); canvas.drawCircle(Offset(size.width * 0.82, size.height * 0.35), 5, dotPaint..color = const Color(0xFF2E9E6B)); } void _drawDashedPath(Canvas canvas, Path path, Paint paint, {required double dashLength, required double gapLength}) { final metrics = path.computeMetrics(); for (final metric in metrics) { double distance = 0; while (distance < metric.length) { final start = distance; final end = (start + dashLength).clamp(0.0, metric.length); canvas.drawPath(metric.extractPath(start, end), paint); distance += dashLength + gapLength; } } } @override bool shouldRepaint(_MapSketchPainter old) => old.primaryColor != primaryColor; }