mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_path_content_progression_page.dart

445 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import 'package:manager_api_new/api.dart';
import 'dart:async';
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';
/// Vue de progression centrée contenu (escape game, ou parcours sans carte).
class GuidedPathContentProgressionPage extends StatefulWidget {
final GuidedPathDTO path;
final VisitAppContext visitAppContext;
const GuidedPathContentProgressionPage({
Key? key,
required this.path,
required this.visitAppContext,
}) : super(key: key);
@override
State<GuidedPathContentProgressionPage> createState() =>
_GuidedPathContentProgressionPageState();
}
class _GuidedPathContentProgressionPageState
extends State<GuidedPathContentProgressionPage> {
late List<GuidedStepDTO> _steps;
int _currentStepIndex = 0;
final Set<String> _completedStepIds = {};
List<QuestionSubDTO> _stepQuestions = [];
bool _quizCompleted = false;
bool _quizPassed = false;
bool _inGeoZone = false;
StreamSubscription<Position>? _positionSub;
@override
void initState() {
super.initState();
_steps = [...(widget.path.steps ?? [])]
..sort((a, b) => (a.order ?? 0).compareTo(b.order ?? 0));
_initStepState();
_startLocationTracking();
}
@override
void dispose() {
_positionSub?.cancel();
super.dispose();
}
// ─── State ────────────────────────────────────────────────────────────────
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 = [];
}
}
GuidedStepDTO? get _currentStep =>
_steps.isEmpty ? null : _steps[_currentStepIndex];
String _translate(List<TranslationDTO>? list) =>
TranslationHelper.get(list, widget.visitAppContext);
bool _hasGeoTrigger(GuidedStepDTO step) =>
(step.geometry?.type == 'Point') ||
(step.triggerGeoPointId != null && step.triggerGeoPoint != null);
bool get _canAdvance {
final step = _currentStep;
if (step == null || step.isStepLocked == true) return false;
if (_hasGeoTrigger(step) && !_inGeoZone) return false;
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'),
),
],
),
);
}
// ─── Géoloc ───────────────────────────────────────────────────────────────
Future<void> _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) => _checkGeoZone(LatLng(pos.latitude, pos.longitude)));
}
void _checkGeoZone(LatLng position) {
final step = _currentStep;
if (step == null || !_hasGeoTrigger(step)) return;
LatLng? center;
double radius = step.zoneRadiusMeters ?? 30;
if (step.geometry?.type == 'Point') {
final c = step.geometry!.coordinates as List;
center = LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble());
} else if (step.triggerGeoPoint?.geometry?.type == 'Point') {
final c = step.triggerGeoPoint!.geometry!.coordinates as List;
center = LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble());
}
if (center == null) return;
final dist = const Distance().distance(position, center);
if ((dist <= radius) != _inGeoZone) {
setState(() => _inGeoZone = dist <= radius);
}
}
// ─── Build ────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final step = _currentStep;
if (step == null) return const Scaffold(body: Center(child: Text('Aucune étape')));
final title = _translate(step.title);
final desc = _translate(step.description);
final hasQuiz = step.quizQuestions?.isNotEmpty == true;
final hasTimer = step.isStepTimer == true && (step.timerSeconds ?? 0) > 0;
final hasGeo = _hasGeoTrigger(step);
final isLocked = step.isStepLocked == true;
return Scaffold(
body: SafeArea(
child: Column(
children: [
// ── Header ──────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Progress indicator
Text(
'Étape ${_currentStepIndex + 1} / ${_steps.length}',
style: TextStyle(color: Colors.grey[600], fontSize: 13),
),
],
),
),
// Dot progress
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_steps.length, (i) {
final done = _completedStepIds.contains(_steps[i].id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: done
? Colors.green
: i == _currentStepIndex
? Colors.blue
: Colors.grey[300],
),
),
);
}),
),
),
// ── Contenu scrollable ───────────────────────────────────────
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image
if (step.imageUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
step.imageUrl!,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
),
if (step.imageUrl != null) const SizedBox(height: 16),
// Locked
if (isLocked)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
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 && !isLocked)
Text(desc, style: const TextStyle(fontSize: 15, height: 1.6)),
if (desc.isNotEmpty) const SizedBox(height: 16),
// Timer (barre)
if (hasTimer && !isLocked)
GuidedStepTimer(
key: ValueKey(step.id),
seconds: step.timerSeconds!,
expiredMessage: _translate(step.timerExpiredMessage),
showAsBar: true,
onExpired: () => setState(() {}),
),
if (hasTimer) const SizedBox(height: 16),
// Géo
if (hasGeo && !isLocked)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _inGeoZone
? Colors.green.withOpacity(0.1)
: Colors.orange.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _inGeoZone ? Colors.green : Colors.orange),
),
child: Row(
children: [
Icon(
_inGeoZone ? Icons.location_on : Icons.location_searching,
color: _inGeoZone ? Colors.green : Colors.orange,
size: 18,
),
const SizedBox(width: 8),
Text(
_inGeoZone
? 'Vous êtes dans la zone ✓'
: 'Approchez-vous du point indiqué',
style: TextStyle(
color: _inGeoZone ? Colors.green[700] : Colors.orange[700],
fontSize: 13,
),
),
],
),
),
if (hasGeo) const SizedBox(height: 16),
// Quiz
if (hasQuiz && !isLocked && !_quizCompleted)
SizedBox(
height: 340,
child: QuestionsListWidget(
questionsSubDTO: _stepQuestions,
isShowResponse: false,
onShowResponse: () {
final good = _stepQuestions
.where((q) =>
q.chosen != null &&
q.chosen ==
q.responsesSubDTO!
.indexWhere((r) => r.isGood == true))
.length;
setState(() {
_quizCompleted = true;
_quizPassed = good == _stepQuestions.length;
});
},
orientation: MediaQuery.of(context).orientation,
),
),
if (hasQuiz && _quizCompleted)
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'),
),
],
],
),
],
),
),
),
// ── Navigation ───────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
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',
),
),
],
),
),
],
),
),
);
}
}