import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../core/localization/app_localizations.dart';
import '../core/theme/app_colors.dart';
import '../data/app_data_scope.dart';
import '../data/location_scope.dart';
import '../models/navigation_snapshot.dart';
import '../models/pedestrian_route.dart';
import '../models/tour.dart';
import '../models/transport_mode.dart';
import '../services/location_service.dart';
import '../services/routing_service.dart';
import '../services/analytics_service.dart';
import 'user_location_marker.dart';
class TourMap extends StatefulWidget {
const TourMap({
super.key,
required this.tour,
this.activeStopIndex,
this.height = 280,
this.liveLocation = false,
this.showNavigationCard = false,
this.onNavigationChanged,
this.onStopSelected,
});
final Tour tour;
final int? activeStopIndex;
final double height;
final bool liveLocation;
final bool showNavigationCard;
final ValueChanged<NavigationSnapshot>? onNavigationChanged;
final ValueChanged<int>? onStopSelected;
@override
State<TourMap> createState() => _TourMapState();
}
class _TourMapState extends State<TourMap> {
final MapController _mapController = MapController();
final LocationService _locationService = LocationService();
final RoutingService _routingService = const RoutingService();
final Distance _distance = const Distance();
StreamSubscription<LatLng>? _positionSub;
Timer? _routeDebounce;
LatLng? _userPosition;
LatLng? _lastRoutedPosition;
PedestrianRoute? _route;
String? _locationMessage;
String? _routingMessage;
bool _routing = false;
DateTime? _lastRouteRequest;
List<LatLng> _stopPoints(BuildContext context) =>
widget.tour.stops.map((stop) {
final poi = AppDataScope.of(context).poiById(stop.poiId);
return LatLng(poi.latitude, poi.longitude);
}).toList(growable: false);
int get _activeIndex =>
(widget.activeStopIndex ?? 0).clamp(0, widget.tour.stops.length - 1);
LatLng _nextPoint(BuildContext context) {
final poi =
AppDataScope.of(context).poiById(widget.tour.stops[_activeIndex].poiId);
return LatLng(poi.latitude, poi.longitude);
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.liveLocation) {
_startLocation();
} else {
_loadWholeTourRoute();
}
});
}
@override
void didUpdateWidget(covariant TourMap oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.liveLocation && !oldWidget.liveLocation) {
_startLocation();
} else if (!widget.liveLocation && oldWidget.liveLocation) {
_positionSub?.cancel();
_positionSub = null;
_loadWholeTourRoute();
}
if (widget.activeStopIndex != oldWidget.activeStopIndex) {
_route = null;
_lastRoutedPosition = null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.liveLocation) {
_refreshLiveRoute(force: true);
} else {
_loadWholeTourRoute();
}
});
}
}
Future<void> _startLocation() async {
final current = await _locationService.currentPosition();
if (!mounted) return;
setState(() {
_userPosition = current;
_locationMessage = current == null
? AppLocalizations.tFor(
Localizations.localeOf(context).languageCode,
'locationNowUnavailable',
)
: null;
});
_emitNavigation();
await _refreshLiveRoute(force: true);
await _positionSub?.cancel();
_positionSub = _locationService.watchPosition().listen((position) {
if (!mounted) return;
setState(() {
_userPosition = position;
_locationMessage = null;
});
_emitNavigation();
_scheduleReroute();
});
}
void _scheduleReroute() {
_routeDebounce?.cancel();
_routeDebounce = Timer(const Duration(seconds: 2), () {
_refreshLiveRoute();
});
}
Future<void> _refreshLiveRoute({bool force = false}) async {
final position = _userPosition;
if (position == null) {
_focusTarget();
return;
}
final distanceFromLast = _lastRoutedPosition == null
? double.infinity
: _distance(position, _lastRoutedPosition!);
final recentlyRequested = _lastRouteRequest != null &&
DateTime.now().difference(_lastRouteRequest!) <
const Duration(seconds: 5);
if (!force && (distanceFromLast < 15 || recentlyRequested)) return;
await _loadRoute([position, _nextPoint(context)], live: true);
_lastRoutedPosition = position;
}
Future<void> _loadWholeTourRoute() async {
final points = _stopPoints(context);
if (points.length < 2) {
_focusTarget();
return;
}
await _loadRoute(points, live: false);
}
Future<void> _loadRoute(List<LatLng> points, {required bool live}) async {
if (_routing) return;
final languageCode = Localizations.localeOf(context).languageCode;
final transport = widget.tour.recommendedTransport.apiValue;
setState(() {
_routing = true;
_routingMessage = null;
});
_lastRouteRequest = DateTime.now();
await AnalyticsService.instance
.track('route_calculation_start', properties: {
'tour_id': widget.tour.id,
'route_context': live ? 'live_navigation' : 'tour_preview',
'transport': transport,
});
try {
final route = await _routingService.route(
points,
mode: widget.tour.recommendedTransport,
languageCode: languageCode,
);
if (!mounted) return;
setState(() {
_route = route;
_routing = false;
_routingMessage = null;
});
await AnalyticsService.instance
.track('route_calculation_success', properties: {
'tour_id': widget.tour.id,
'route_context': live ? 'live_navigation' : 'tour_preview',
'transport': transport,
'duration_minutes': route.durationMinutes,
'success': true
});
_fitRoute(route.geometry);
_emitNavigation();
} catch (_) {
await AnalyticsService.instance
.track('route_calculation_error', properties: {
'tour_id': widget.tour.id,
'route_context': live ? 'live_navigation' : 'tour_preview',
'transport': transport,
'success': false
});
if (!mounted) return;
setState(() {
_routing = false;
final lang = languageCode;
_routingMessage =
widget.tour.recommendedTransport.name == 'publicTransit'
? AppLocalizations.tFor(lang, 'transitRoutingPending')
: AppLocalizations.tFor(lang, 'routeLoadFailed');
});
_focusTarget();
_emitNavigation();
}
}
void _useDemoPosition() {
setState(() {
_userPosition = const LatLng(50.9797, 11.3237);
_locationMessage = AppLocalizations.tFor(
Localizations.localeOf(context).languageCode,
'demoLocation',
);
_lastRoutedPosition = null;
});
_emitNavigation();
_refreshLiveRoute(force: true);
}
void _emitNavigation() {
final callback = widget.onNavigationChanged;
if (callback == null || !mounted) return;
final position = _userPosition;
if (position == null) {
callback(
const NavigationSnapshot(
locationAvailable: false,
arrived: false,
),
);
return;
}
final target = _nextPoint(context);
final straightMeters =
_distance.as(LengthUnit.Meter, position, target).round();
final route = _route;
final firstUsefulStep = route?.steps.firstWhere(
(step) => step.distanceMeters > 8 && step.maneuverType != 'arrive',
orElse: () => route.steps.isNotEmpty
? route.steps.first
: NavigationStep(
instruction: AppLocalizations.tFor(
Localizations.localeOf(context).languageCode,
'followRoute',
),
distanceMeters: 0,
durationSeconds: 0,
),
);
callback(
NavigationSnapshot(
locationAvailable: true,
arrived: straightMeters <= 30,
distanceMeters: route?.distanceMeters.round() ?? straightMeters,
durationMinutes: route?.durationMinutes,
instruction: firstUsefulStep?.instruction,
),
);
}
void _focusTarget() {
if (!mounted) return;
final target = _nextPoint(context);
if (_userPosition == null) {
_mapController.move(target, 16.3);
return;
}
_fitRoute([_userPosition!, target]);
}
void _fitRoute(List<LatLng> points) {
if (!mounted || points.isEmpty) return;
if (points.length == 1) {
_mapController.move(points.first, 16.5);
return;
}
try {
_mapController.fitCamera(
CameraFit.coordinates(
coordinates: points,
padding: const EdgeInsets.fromLTRB(42, 54, 42, 118),
maxZoom: 17.2,
),
);
} catch (_) {
// Map may still be attaching during the first frame. A later location or
// route update will fit it again.
}
}
@override
void dispose() {
_routeDebounce?.cancel();
_positionSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final lang = Localizations.localeOf(context).languageCode;
final l10n = AppLocalizations.of(context);
final points = _stopPoints(context);
final nextPoi =
AppDataScope.of(context).poiById(widget.tour.stops[_activeIndex].poiId);
final routePoints = _route?.geometry ?? const <LatLng>[];
final displayUserPosition =
_userPosition ?? LocationScope.of(context).position;
final straightMeters = _userPosition == null
? null
: _distance
.as(LengthUnit.Meter, _userPosition!, _nextPoint(context))
.round();
final arrived = straightMeters != null && straightMeters <= 30;
final routeMeters = _route?.distanceMeters.round();
final routeMinutes = _route?.durationMinutes;
final firstUsefulStep = _route?.steps.firstWhere(
(step) => step.distanceMeters > 8 && step.maneuverType != 'arrive',
orElse: () => _route!.steps.isNotEmpty
? _route!.steps.first
: NavigationStep(
instruction: AppLocalizations.tFor(
Localizations.localeOf(context).languageCode,
'followRoute',
),
distanceMeters: 0,
durationSeconds: 0,
),
);
final center =
points.isNotEmpty ? points.first : const LatLng(50.9796, 11.3290);
return SizedBox(
height: widget.height,
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: Stack(
children: [
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: center,
initialZoom: widget.liveLocation ? 16.2 : 14.9,
minZoom: 12,
maxZoom: 19,
),
children: [
TileLayer(
urlTemplate:
'https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
userAgentPackageName: 'de.meinweimar.cityguide',
),
if (routePoints.length > 1)
PolylineLayer(
polylines: [
Polyline(
points: routePoints,
strokeWidth: 7,
color: AppColors.heartRed,
borderStrokeWidth: 3,
borderColor: Colors.white,
),
],
),
MarkerLayer(
markers: [
for (var i = 0; i < points.length; i++)
Marker(
point: points[i],
width: 42,
height: 42,
child: Tooltip(
message: i == _activeIndex
? l10n.tf('currentStop', {'n': i + 1})
: l10n.tf('switchToStop', {'n': i + 1}),
child: Semantics(
button: widget.onStopSelected != null,
selected: i == _activeIndex,
label: i == _activeIndex
? l10n.tf('currentStop', {'n': i + 1})
: l10n.tf('openStop', {'n': i + 1}),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: widget.onStopSelected == null
? null
: () => widget.onStopSelected!(i),
child: Container(
decoration: BoxDecoration(
color: i == _activeIndex
? AppColors.ink
: AppColors.heartRed,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 3,
),
),
alignment: Alignment.center,
child: Text(
'${i + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
),
),
),
),
),
),
),
),
if (displayUserPosition != null)
Marker(
point: displayUserPosition,
width: 64,
height: 64,
child: const UserLocationMarker(size: 62),
),
],
),
RichAttributionWidget(
attributions: const [
TextSourceAttribution(
'© OpenStreetMap contributors · © CARTO',
),
],
),
],
),
if (_routing)
const Positioned(
left: 0,
right: 0,
top: 0,
child: LinearProgressIndicator(minHeight: 3),
),
if (widget.liveLocation)
Positioned(
right: 10,
top: 10,
child: Column(
children: [
FloatingActionButton.small(
heroTag: null,
backgroundColor: Colors.white,
foregroundColor: AppColors.ink,
onPressed: () {
if (_route != null) {
_fitRoute(_route!.geometry);
} else {
_focusTarget();
}
},
child: const Icon(Icons.my_location_rounded),
),
if (_userPosition == null && kIsWeb) ...[
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: null,
tooltip: l10n.t('demoLocationTooltip'),
backgroundColor: Colors.white,
foregroundColor: AppColors.heartRed,
onPressed: _useDemoPosition,
child: const Icon(Icons.location_searching_rounded),
),
],
],
),
),
if (widget.showNavigationCard)
Positioned(
left: 10,
right: 10,
bottom: 10,
child: Material(
color: AppColors.ink.withValues(alpha: .94),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 11,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
arrived
? Icons.flag_rounded
: _instructionIcon(firstUsefulStep),
color: Colors.white,
size: 28,
),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
arrived
? l10n.tf('reachedNamed',
{'name': nextPoi.title.get(lang)})
: (firstUsefulStep?.instruction ??
l10n.tf('continueToNamed',
{'name': nextPoi.title.get(lang)})),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
_statusText(
l10n: l10n,
routeMeters: routeMeters,
routeMinutes: routeMinutes,
fallbackMeters: straightMeters,
),
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
),
if (_routingMessage != null) ...[
const SizedBox(height: 2),
Text(
_routingMessage!,
style: const TextStyle(
color: Colors.white54,
fontSize: 11,
),
),
],
],
),
),
if (_userPosition == null)
IconButton(
tooltip: l10n.t('retryLocation'),
onPressed: _startLocation,
icon: const Icon(
Icons.gps_fixed,
color: Colors.white,
),
),
],
),
),
),
),
],
),
),
);
}
String _statusText({
required AppLocalizations l10n,
required int? routeMeters,
required int? routeMinutes,
required int? fallbackMeters,
}) {
if (_userPosition == null) {
return _locationMessage ?? l10n.t('locationLoading');
}
if (routeMeters != null && routeMinutes != null) {
return '${_formatDistance(routeMeters)} · ${l10n.tf('approxWalk', {
'n': routeMinutes
})}';
}
if (fallbackMeters != null) {
return l10n.tf(
'airLineWalkLoading', {'distance': _formatDistance(fallbackMeters)});
}
return l10n.t('routeLoading');
}
IconData _instructionIcon(NavigationStep? step) {
if (step?.maneuverType == 'arrive') return Icons.flag_rounded;
final modifier = step?.maneuverModifier;
if (modifier == 'left' ||
modifier == 'slight left' ||
modifier == 'sharp left') {
return Icons.turn_left_rounded;
}
if (modifier == 'right' ||
modifier == 'slight right' ||
modifier == 'sharp right') {
return Icons.turn_right_rounded;
}
if (modifier == 'uturn') return Icons.u_turn_left_rounded;
if (step?.maneuverType == 'roundabout' || step?.maneuverType == 'rotary') {
return Icons.roundabout_left_rounded;
}
return Icons.straight_rounded;
}
String _formatDistance(int meters) {
if (meters < 1000) return '$meters m';
return '${(meters / 1000).toStringAsFixed(1)} km';
}
}

