761 lines
27 KiB
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|