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

761 lines
27 KiB
Dart

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<GuidedPathMapProgressionPage> createState() => _GuidedPathMapProgressionPageState();
}
class _GuidedPathMapProgressionPageState extends State<GuidedPathMapProgressionPage>
with SingleTickerProviderStateMixin {
late List<GuidedStepDTO> _steps;
int _currentStepIndex = 0;
final Set<String> _completedStepIds = {};
// Quiz state for current step
List<QuestionSubDTO> _stepQuestions = [];
bool _quizCompleted = false;
bool _quizPassed = false;
// Geo trigger state
LatLng? _userPosition;
bool _inGeoZone = false;
StreamSubscription<Position>? _positionSub;
// Map
final MapController _mapController = MapController();
// Pulsing animation for current step marker
late AnimationController _pulseController;
late Animation<double> _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<double>(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<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) {
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<TranslationDTO>? list) =>
TranslationHelper.get(list, widget.visitAppContext);
// ─── Map ─────────────────────────────────────────────────────────────────
List<Marker> _buildStepMarkers() {
final markers = <Marker>[];
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<String> completedIds;
final List<GuidedStepDTO> 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,
),
),
],
),
);
}
}