2026-07-17 15:23:12 +02:00

817 lines
27 KiB
Dart

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<ParcoursPage> createState() => _ParcoursPageState();
}
class _ParcoursPageState extends State<ParcoursPage> {
List<GuidedPathDTO> _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<void> _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<void> _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;
}