445 lines
17 KiB
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',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|