import 'dart:async'; import 'dart:math' as math; import 'package:flutter/gestures.dart' show PointerDeviceKind, PointerPanZoomEndEvent, PointerPanZoomStartEvent, PointerPanZoomUpdateEvent, PointerScaleEvent, PointerScrollEvent; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:go_router/go_router.dart'; import 'package:latlong2/latlong.dart' show LatLng; import 'package:rra_app/pages/map/routing/osrm_routing_service.dart'; import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart'; import 'package:rra_app/pages/map/tiles/hive_tile_image.dart'; import 'package:rra_app/pages/map/widgets/toolbar.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; class MapPage extends StatefulWidget { static final GoRoute route = GoRoute( path: '/', builder: (context, state) => const MapPage(), ); const MapPage({super.key}); @override State createState() => _MapPageState(); } class _MapPageState extends State with TickerProviderStateMixin { static const _london = LatLng(51.5074, -0.1278); static const _routeColor = Color(0xFFE02425); static const _tileSize = 256.0; static const _tileSourcePixelRatio = 2.0; static const _minZoom = 2.0; static const _maxZoom = 24.0; static const _minTileZoom = 2; static const _maxTileZoom = 22; static const _tileOverscan = 1; static const _tileFadeReadyRatio = 0.72; static const _tileFadeDuration = Duration(milliseconds: 220); static const _interactionIdleDelay = Duration(milliseconds: 280); static const _prefetchDebounceDelay = Duration(milliseconds: 280); static const _tilePromotionActiveInterval = Duration(seconds: 1); static const _tilePromotionIdleInterval = Duration(seconds: 1); static const _tilePromotionBatchSize = 28; static const _fpsWindowSize = 90; static const _fpsUpdateInterval = Duration(milliseconds: 240); static const _tileUrlTemplate = 'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/256/{z}/{x}/{y}@2x?access_token=pk.eyJ1IjoiaW1iZW5qaTAzIiwiYSI6ImNtN2Rqdmw5MDA0bzEyaXM3YjE1emkzOXAifQ.cYyCPQE7OvZx0hzKX2hEiQ'; final GlobalKey _mapKey = GlobalKey(); final OsrmRoutingService _routingService = const OsrmRoutingService(); final List _points = []; List _resolvedRoute = []; LatLng _mapCenter = _london; double _zoom = 12; double _targetZoom = 12; int _activeTileZoom = 12; double _devicePixelRatio = 2.0; Offset _zoomFocal = Offset.zero; Size _mapSize = Size.zero; late final Ticker _zoomTicker; double _lastPinchScale = 1.0; bool _isAddPointArmed = false; bool _isResolvingRoute = false; String? _routeError; int _routeRequestId = 0; int? _draggingInsertPointIndex; int _lastPrefetchHash = 0; int _lastPrefetchCount = -1; List _pendingPrefetchUrls = const []; Timer? _prefetchDebounceTimer; bool _isInteractionActive = false; Timer? _interactionIdleTimer; Offset _pendingPanDelta = Offset.zero; bool _panFrameScheduled = false; Offset _pendingPanZoomDelta = Offset.zero; double _pendingPanZoomZoomDelta = 0.0; Offset _pendingPanZoomFocal = Offset.zero; bool _panZoomFrameScheduled = false; final List _frameMsWindow = []; late final Stopwatch _fpsUpdateStopwatch; double _displayFps = 0; double _displayFrameMs = 0; final Set _readyTileUrls = {}; final Set _pendingReadyTileUrls = {}; Timer? _tilePromotionTimer; int? _fallbackTileZoom; bool _fallbackSyncScheduled = false; late final AnimationController _fallbackFadeController; void _cancelPrefetchTimer() { _prefetchDebounceTimer?.cancel(); _prefetchDebounceTimer = null; } void _cancelInteractionTimer() { _interactionIdleTimer?.cancel(); _interactionIdleTimer = null; } void _cancelTilePromotionTimer() { _tilePromotionTimer?.cancel(); _tilePromotionTimer = null; } @override void initState() { super.initState(); _zoomTicker = createTicker(_onZoomTick); _fallbackFadeController = AnimationController( vsync: this, duration: _tileFadeDuration, lowerBound: 0, upperBound: 1, value: 0, )..addStatusListener((status) { if (status != AnimationStatus.dismissed) return; if (!mounted || _fallbackTileZoom == null) return; setState(() { _fallbackTileZoom = null; }); }); _fpsUpdateStopwatch = Stopwatch()..start(); SchedulerBinding.instance.addTimingsCallback(_onFrameTimings); } @override void dispose() { _cancelPrefetchTimer(); _cancelInteractionTimer(); _cancelTilePromotionTimer(); SchedulerBinding.instance.removeTimingsCallback(_onFrameTimings); _fallbackFadeController.dispose(); _zoomTicker.dispose(); super.dispose(); } void _onFrameTimings(List timings) { for (final timing in timings) { final frameMs = timing.totalSpan.inMicroseconds / 1000.0; _frameMsWindow.add(frameMs); } if (_frameMsWindow.length > _fpsWindowSize) { _frameMsWindow.removeRange(0, _frameMsWindow.length - _fpsWindowSize); } if (_fpsUpdateStopwatch.elapsed < _fpsUpdateInterval) return; _fpsUpdateStopwatch.reset(); if (_frameMsWindow.isEmpty || !mounted) return; var totalMs = 0.0; for (final ms in _frameMsWindow) { totalMs += ms; } final avgMs = totalMs / _frameMsWindow.length; final fps = avgMs <= 0 ? 0.0 : (1000.0 / avgMs); if ((fps - _displayFps).abs() < 0.2 && (avgMs - _displayFrameMs).abs() < 0.2) { return; } setState(() { _displayFps = fps; _displayFrameMs = avgMs; }); } void _addPoint(LatLng point) { setState(() { _points.add(point); }); _resolveRoute(); } void _undoPoint() { if (_points.isEmpty) return; setState(() { _points.removeLast(); }); _resolveRoute(); } void _clearPoints() { if (_points.isEmpty) return; setState(() { _points.clear(); _resolvedRoute = []; _routeError = null; _isResolvingRoute = false; _draggingInsertPointIndex = null; }); } Future _resolveRoute() async { if (_points.length < 2) { setState(() { _resolvedRoute = []; _routeError = null; _isResolvingRoute = false; }); return; } final requestId = ++_routeRequestId; setState(() { _isResolvingRoute = true; _routeError = null; }); try { final route = await _routingService.fetchRoute(_points); if (!mounted || requestId != _routeRequestId) return; setState(() { _resolvedRoute = route; _isResolvingRoute = false; _routeError = null; }); } catch (_) { if (!mounted || requestId != _routeRequestId) return; setState(() { _resolvedRoute = _points.toList(); _isResolvingRoute = false; _routeError = 'Routing unavailable. Showing straight-line fallback.'; }); } } Color _pointColor(int index) { if (_points.length == 1) return const Color(0xFF0F9D58); if (index == 0) return const Color(0xFF0F9D58); if (index == _points.length - 1) return const Color(0xFFE11D48); return const Color(0xFF2563EB); } math.Point _latLngToWorld(LatLng latLng, double zoom) { final scale = _tileSize * math.pow(2, zoom).toDouble(); final x = (latLng.longitude + 180.0) / 360.0 * scale; final sinLat = math.sin(latLng.latitude * math.pi / 180.0); final y = (0.5 - math.log((1 + sinLat) / (1 - sinLat)) / (4 * math.pi)) * scale; return math.Point(x, y); } LatLng _worldToLatLng(math.Point world, double zoom) { final scale = _tileSize * math.pow(2, zoom).toDouble(); final lon = world.x / scale * 360.0 - 180.0; final n = math.pi - 2.0 * math.pi * world.y / scale; final lat = 180.0 / math.pi * math.atan(0.5 * (math.exp(n) - math.exp(-n))); return LatLng(lat, lon); } int get _tileZoom => _activeTileZoom; int _idealTileZoom(double zoomValue, {required double devicePixelRatio}) { final clampedDeviceRatio = devicePixelRatio.clamp(1.0, 4.0).toDouble(); final maxTileZoomForScreenResolution = zoomValue + (math.log(clampedDeviceRatio / _tileSourcePixelRatio) / math.ln2); return maxTileZoomForScreenResolution.floor().clamp( _minTileZoom, _maxTileZoom, ); } math.Point _clampWorldY(math.Point world, double zoom) { final scale = _tileSize * math.pow(2, zoom).toDouble(); final maxY = math.max(0.0, scale - 1); return math.Point(world.x, world.y.clamp(0.0, maxY).toDouble()); } Offset _latLngToScreen(LatLng latLng) { final centerWorld = _latLngToWorld(_mapCenter, _zoom); final pointWorld = _latLngToWorld(latLng, _zoom); return Offset( pointWorld.x - centerWorld.x + _mapSize.width / 2, pointWorld.y - centerWorld.y + _mapSize.height / 2, ); } LatLng _screenToLatLng(Offset screenPoint) { final centerWorld = _latLngToWorld(_mapCenter, _zoom); final world = math.Point( centerWorld.x + (screenPoint.dx - _mapSize.width / 2), centerWorld.y + (screenPoint.dy - _mapSize.height / 2), ); return _worldToLatLng(world, _zoom); } Offset? _globalToMapLocal(Offset globalPosition) { final context = _mapKey.currentContext; if (context == null) return null; final box = context.findRenderObject(); if (box is! RenderBox) return null; return box.globalToLocal(globalPosition); } void _panMap(Offset delta, {bool notify = true}) { if (_mapSize == Size.zero) return; final centerWorld = _latLngToWorld(_mapCenter, _zoom); final nextCenter = _clampWorldY( math.Point(centerWorld.x - delta.dx, centerWorld.y - delta.dy), _zoom, ); void apply() { _mapCenter = _worldToLatLng(nextCenter, _zoom); } if (notify) { setState(apply); } else { apply(); } } void _queuePan(Offset delta) { if (delta == Offset.zero) return; _noteInteraction(); _pendingPanDelta += delta; if (_panFrameScheduled) return; _panFrameScheduled = true; SchedulerBinding.instance.scheduleFrameCallback((_) { _panFrameScheduled = false; if (!mounted) return; final pending = _pendingPanDelta; _pendingPanDelta = Offset.zero; if (pending == Offset.zero) return; setState(() { _panMap(pending, notify: false); }); }); } void _queuePanZoomUpdate({ required Offset panDelta, required double zoomDelta, required Offset focal, }) { if (panDelta == Offset.zero && zoomDelta.abs() < 0.0001) return; _noteInteraction(); _pendingPanZoomDelta += panDelta; _pendingPanZoomZoomDelta += zoomDelta; _pendingPanZoomFocal = focal; if (_panZoomFrameScheduled) return; _panZoomFrameScheduled = true; SchedulerBinding.instance.scheduleFrameCallback((_) { _panZoomFrameScheduled = false; if (!mounted) return; final pendingPan = _pendingPanZoomDelta; final pendingZoom = _pendingPanZoomZoomDelta; final pendingFocal = _pendingPanZoomFocal; _pendingPanZoomDelta = Offset.zero; _pendingPanZoomZoomDelta = 0.0; if (pendingPan == Offset.zero && pendingZoom.abs() < 0.0001) return; setState(() { if (pendingZoom.abs() >= 0.0001) { _setZoomAroundFocal(_zoom + pendingZoom, pendingFocal); _targetZoom = _zoom; } if (pendingPan != Offset.zero) { _panMap(pendingPan, notify: false); } }); }); } void _noteInteraction() { _isInteractionActive = true; _interactionIdleTimer?.cancel(); _interactionIdleTimer = Timer(_interactionIdleDelay, () { _isInteractionActive = false; _flushPrefetchNow(); _flushTilePromotions(forceAll: true); }); } void _setZoomAroundFocal(double requestedZoom, Offset focal) { if (_mapSize == Size.zero) return; final nextZoom = requestedZoom.clamp(_minZoom, _maxZoom); if ((nextZoom - _zoom).abs() < 0.0001) return; final focalLatLng = _screenToLatLng(focal); final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom); final centerWorldNext = _clampWorldY( math.Point( focalWorldNext.x - (focal.dx - _mapSize.width / 2), focalWorldNext.y - (focal.dy - _mapSize.height / 2), ), nextZoom, ); final previousTileZoom = _activeTileZoom; _zoom = nextZoom; _mapCenter = _worldToLatLng(centerWorldNext, nextZoom); _activeTileZoom = _idealTileZoom( _zoom, devicePixelRatio: _devicePixelRatio, ); final nextTileZoom = _activeTileZoom; if (previousTileZoom != nextTileZoom) { _fallbackTileZoom = previousTileZoom; _fallbackFadeController.stop(); _fallbackFadeController.value = 1; } } void _onZoomTick(Duration _) { if (!mounted) return; setState(() { const t = 0.2; final newZoom = _zoom + (_targetZoom - _zoom) * t; final done = (_targetZoom - newZoom).abs() < 0.001; final actual = done ? _targetZoom : newZoom; _setZoomAroundFocal(actual, _zoomFocal); if (done) _zoomTicker.stop(); }); } LatLng _segmentMidpoint(LatLng a, LatLng b) { return LatLng( (a.latitude + b.latitude) / 2, (a.longitude + b.longitude) / 2, ); } void _startInsertPointDrag(int segmentIndex, Offset globalPosition) { if (segmentIndex < 0 || segmentIndex >= _points.length - 1) return; final midpoint = _segmentMidpoint( _points[segmentIndex], _points[segmentIndex + 1], ); final insertIndex = segmentIndex + 1; setState(() { _points.insert(insertIndex, midpoint); _draggingInsertPointIndex = insertIndex; _isAddPointArmed = false; _resolvedRoute = _points.toList(); _isResolvingRoute = false; _routeError = null; }); _updateInsertPointDrag(globalPosition); } void _updateInsertPointDrag(Offset globalPosition) { final index = _draggingInsertPointIndex; if (index == null || index < 0 || index >= _points.length) return; final local = _globalToMapLocal(globalPosition); if (local == null) return; final point = _screenToLatLng(local); setState(() { _points[index] = point; _resolvedRoute = _points.toList(); }); } void _finishInsertPointDrag() { if (_draggingInsertPointIndex == null) return; setState(() { _draggingInsertPointIndex = null; }); _resolveRoute(); } ({List widgets, Set urls, Set prefetchUrls}) _buildTileLayer({required int tileZoom, bool prefetchChildren = false}) { if (_mapSize == Size.zero) { return (widgets: [], urls: {}, prefetchUrls: {}); } final factor = math.pow(2, _zoom - tileZoom).toDouble(); final centerWorld = _latLngToWorld(_mapCenter, tileZoom.toDouble()); final minWorldX = centerWorld.x - (_mapSize.width / 2) / factor; final maxWorldX = centerWorld.x + (_mapSize.width / 2) / factor; final minWorldY = centerWorld.y - (_mapSize.height / 2) / factor; final maxWorldY = centerWorld.y + (_mapSize.height / 2) / factor; final minTileX = (minWorldX / _tileSize).floor() - _tileOverscan; final maxTileX = (maxWorldX / _tileSize).floor() + _tileOverscan; final minTileY = (minWorldY / _tileSize).floor() - _tileOverscan; final maxTileY = (maxWorldY / _tileSize).floor() + _tileOverscan; final maxIndex = math.pow(2, tileZoom).toInt(); final tiles = []; final urls = {}; final prefetchUrls = {}; for (var tileX = minTileX; tileX <= maxTileX; tileX++) { final wrappedX = ((tileX % maxIndex) + maxIndex) % maxIndex; for (var tileY = minTileY; tileY <= maxTileY; tileY++) { if (tileY < 0 || tileY >= maxIndex) continue; final left = (tileX * _tileSize - minWorldX) * factor; final top = (tileY * _tileSize - minWorldY) * factor; final url = _tileUrlTemplate .replaceAll('{z}', '$tileZoom') .replaceAll('{x}', '$wrappedX') .replaceAll('{y}', '$tileY'); urls.add(url); tiles.add( Positioned( key: ValueKey(url), left: left, top: top, width: _tileSize * factor, height: _tileSize * factor, child: HiveTileImage(url: url, onLoaded: _markTileReady), ), ); if (prefetchChildren && tileZoom < _maxTileZoom) { final childZoom = tileZoom + 1; final childMaxIndex = math.pow(2, childZoom).toInt(); final childXBase = wrappedX * 2; final childYBase = tileY * 2; for (var dx = 0; dx < 2; dx++) { for (var dy = 0; dy < 2; dy++) { final childX = ((childXBase + dx) % childMaxIndex + childMaxIndex) % childMaxIndex; final childY = childYBase + dy; if (childY < 0 || childY >= childMaxIndex) continue; prefetchUrls.add( _tileUrlTemplate .replaceAll('{z}', '$childZoom') .replaceAll('{x}', '$childX') .replaceAll('{y}', '$childY'), ); } } } } } return (widgets: tiles, urls: urls, prefetchUrls: prefetchUrls); } void _markTileReady(String url) { if (_readyTileUrls.contains(url) || _pendingReadyTileUrls.contains(url)) { return; } _pendingReadyTileUrls.add(url); _scheduleTilePromotionTick(); } void _scheduleTilePromotionTick() { if (_tilePromotionTimer != null || _pendingReadyTileUrls.isEmpty) return; final delay = _isInteractionActive ? _tilePromotionActiveInterval : _tilePromotionIdleInterval; _tilePromotionTimer = Timer(delay, () { _tilePromotionTimer = null; _flushTilePromotions(forceAll: true); }); } void _flushTilePromotions({bool forceAll = false}) { if (_pendingReadyTileUrls.isEmpty) return; final maxPromotions = forceAll || !_isInteractionActive ? _pendingReadyTileUrls.length : _tilePromotionBatchSize; final promoted = []; final iterator = _pendingReadyTileUrls.iterator; while (promoted.length < maxPromotions && iterator.moveNext()) { promoted.add(iterator.current); } if (promoted.isEmpty) return; var changed = false; for (final url in promoted) { _pendingReadyTileUrls.remove(url); changed = _readyTileUrls.add(url) || changed; } if (changed && mounted && _fallbackTileZoom != null) { setState(() {}); } if (_pendingReadyTileUrls.isNotEmpty) { _scheduleTilePromotionTick(); } } double _tileReadyRatio(Set urls) { if (urls.isEmpty) return 1; var ready = 0; for (final url in urls) { if (_readyTileUrls.contains(url)) { ready += 1; } } return ready / urls.length; } void _syncFallbackFade(double readyRatio) { if (_fallbackTileZoom == null || _fallbackSyncScheduled) return; _fallbackSyncScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { _fallbackSyncScheduled = false; if (!mounted || _fallbackTileZoom == null) return; if (readyRatio >= _tileFadeReadyRatio) { if (_fallbackFadeController.status == AnimationStatus.reverse || _fallbackFadeController.value <= 0) { return; } _fallbackFadeController.reverse(from: _fallbackFadeController.value); return; } if (_fallbackFadeController.value != 1) { _fallbackFadeController.stop(); _fallbackFadeController.value = 1; } }); } void _schedulePrefetch(Set urls) { if (urls.isEmpty) return; final hash = Object.hashAllUnordered(urls); if (hash == _lastPrefetchHash && urls.length == _lastPrefetchCount) return; _lastPrefetchHash = hash; _lastPrefetchCount = urls.length; _pendingPrefetchUrls = urls.toList(growable: false); if (_isInteractionActive) return; _prefetchDebounceTimer?.cancel(); _prefetchDebounceTimer = Timer(_prefetchDebounceDelay, _flushPrefetchNow); } void _flushPrefetchNow() { _prefetchDebounceTimer?.cancel(); _prefetchDebounceTimer = null; final pending = _pendingPrefetchUrls; _pendingPrefetchUrls = const []; if (pending.isEmpty) return; HiveTileCache.prefetchUrls(pending); } List _buildPointWidgets() { final widgets = []; for (var i = 0; i < _points.length; i++) { final screen = _latLngToScreen(_points[i]); widgets.add( Positioned( left: screen.dx - 17, top: screen.dy - 17, child: IgnorePointer( child: Container( width: 34, height: 34, decoration: BoxDecoration( color: _pointColor(i), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), ), child: const Icon(Icons.circle, size: 10, color: Colors.white), ), ), ), ); } return widgets; } List _buildInsertHandles() { if (_points.length < 2) return const []; final handles = []; for (var i = 0; i < _points.length - 1; i++) { final midpoint = _segmentMidpoint(_points[i], _points[i + 1]); final screen = _latLngToScreen(midpoint); handles.add( Positioned( left: screen.dx - 22, top: screen.dy - 22, child: GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: (details) => _startInsertPointDrag(i, details.globalPosition), onPanUpdate: (details) => _updateInsertPointDrag(details.globalPosition), onPanEnd: (_) => _finishInsertPointDrag(), onPanCancel: _finishInsertPointDrag, child: SizedBox( width: 44, height: 44, child: Center( child: Container( width: 30, height: 30, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.95), shape: BoxShape.circle, border: Border.all( color: const Color(0xFF111827), width: 1.5, ), ), child: const Icon( Icons.open_with, size: 14, color: Color(0xFF111827), ), ), ), ), ), ), ); } return handles; } @override Widget build(BuildContext context) { final nextDevicePixelRatio = MediaQuery.devicePixelRatioOf(context); if ((nextDevicePixelRatio - _devicePixelRatio).abs() > 0.01) { _devicePixelRatio = nextDevicePixelRatio; final nextTileZoom = _idealTileZoom( _zoom, devicePixelRatio: _devicePixelRatio, ); if (nextTileZoom != _activeTileZoom) { _fallbackTileZoom = _activeTileZoom; _fallbackFadeController.stop(); _fallbackFadeController.value = 1; _activeTileZoom = nextTileZoom; } } final currentLayer = _buildTileLayer( tileZoom: _tileZoom, prefetchChildren: !_isInteractionActive, ); final currentReadyRatio = _tileReadyRatio(currentLayer.urls); _syncFallbackFade(currentReadyRatio); _schedulePrefetch(currentLayer.prefetchUrls); final fallbackLayer = _fallbackTileZoom == null || _fallbackTileZoom == _tileZoom ? null : _buildTileLayer(tileZoom: _fallbackTileZoom!); final routePoints = _resolvedRoute.isNotEmpty ? _resolvedRoute : _points; final routeScreenPoints = routePoints.map(_latLngToScreen).toList(); return Scaffold( body: Stack( children: [ Positioned.fill( child: LayoutBuilder( builder: (context, constraints) { _mapSize = constraints.biggest; return Listener( onPointerSignal: (event) { if (event is PointerScaleEvent) { _noteInteraction(); setState(() { _setZoomAroundFocal( _zoom + math.log(event.scale) / math.ln2, event.localPosition, ); _targetZoom = _zoom; }); } else if (event is PointerScrollEvent) { if (event.kind == PointerDeviceKind.trackpad) { _queuePan(event.scrollDelta); } else { _noteInteraction(); final zoomStep = math.log(1.1) / math.ln2; _zoomFocal = event.localPosition; _targetZoom = (_targetZoom + (event.scrollDelta.dy < 0 ? zoomStep : -zoomStep)) .clamp(_minZoom, _maxZoom); if (!_zoomTicker.isTicking) _zoomTicker.start(); } } }, onPointerPanZoomStart: (PointerPanZoomStartEvent event) { _lastPinchScale = 1.0; _pendingPanZoomDelta = Offset.zero; _pendingPanZoomZoomDelta = 0.0; _pendingPanZoomFocal = event.localPosition; _noteInteraction(); }, onPointerPanZoomUpdate: (PointerPanZoomUpdateEvent event) { final scaleChange = event.scale / _lastPinchScale; _lastPinchScale = event.scale; final dz = (scaleChange - 1.0).abs() > 0.001 ? math.log(scaleChange) / math.ln2 : 0.0; _queuePanZoomUpdate( panDelta: event.panDelta, zoomDelta: dz, focal: event.localPosition, ); }, onPointerPanZoomEnd: (PointerPanZoomEndEvent event) { _noteInteraction(); }, child: GestureDetector( key: _mapKey, behavior: HitTestBehavior.opaque, onPanUpdate: (details) { if (_draggingInsertPointIndex != null) return; _queuePan(details.delta); }, onTapUp: (details) { if (!_isAddPointArmed || _draggingInsertPointIndex != null) { return; } _addPoint(_screenToLatLng(details.localPosition)); }, child: ClipRect( child: Stack( children: [ const Positioned.fill( child: ColoredBox(color: Color(0xFFECECEC)), ), if (fallbackLayer != null) FadeTransition( opacity: _fallbackFadeController, child: Stack(children: fallbackLayer.widgets), ), ...currentLayer.widgets, Positioned.fill( child: IgnorePointer( child: CustomPaint( painter: _RoutePainter( points: routeScreenPoints, routeColor: _routeColor, ), ), ), ), ..._buildPointWidgets(), ..._buildInsertHandles(), ], ), ), ), ); }, ), ), SafeArea( child: Align( alignment: Alignment.topLeft, child: Padding( padding: const EdgeInsets.all(10), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ MapToolbar( isAddPointArmed: _isAddPointArmed, pointCount: _points.length, onAddPointPressed: () { setState(() { _isAddPointArmed = !_isAddPointArmed; }); }, onUndoPressed: _undoPoint, onClearPressed: _clearPoints, ), const SizedBox(width: 8), shadcn.OutlinedContainer( boxShadow: const [ BoxShadow( color: Color(0x26000000), blurRadius: 4, spreadRadius: 2, ), ], padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), child: _StatusPanel( isAddPointArmed: _isAddPointArmed, isResolvingRoute: _isResolvingRoute, routeError: _routeError, hasRoute: _points.length > 1 && _routeError == null, ), ), ], ), ), ), ), SafeArea( child: Align( alignment: Alignment.topRight, child: Padding( padding: const EdgeInsets.all(10), child: IgnorePointer( child: _PerformanceHud( fps: _displayFps, frameMs: _displayFrameMs, ), ), ), ), ), const SafeArea( child: Align( alignment: Alignment.bottomLeft, child: Padding( padding: EdgeInsets.all(10), child: IgnorePointer(child: _HudCornerNotice()), ), ), ), ], ), ); } } class _RoutePainter extends CustomPainter { const _RoutePainter({required this.points, required this.routeColor}); final List points; final Color routeColor; @override void paint(Canvas canvas, Size size) { if (points.length < 2) return; final path = Path()..moveTo(points.first.dx, points.first.dy); for (var i = 1; i < points.length; i++) { path.lineTo(points[i].dx, points[i].dy); } final white = Paint() ..color = Colors.white ..strokeWidth = 9 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round; final route = Paint() ..color = routeColor ..strokeWidth = 5 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round ..strokeCap = StrokeCap.round; canvas.drawPath(path, white); canvas.drawPath(path, route); } @override bool shouldRepaint(covariant _RoutePainter oldDelegate) { return oldDelegate.points != points || oldDelegate.routeColor != routeColor; } } class _HudCornerNotice extends StatelessWidget { const _HudCornerNotice(); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.8), borderRadius: BorderRadius.circular(4), ), child: const Padding( padding: EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Text( '© 2026 IMBENJI.NET LTD - Roadbound Map Maker\n' 'Alpha v2602-7a - This is a public alpha release;\n' 'Expect bugs, missing features, and breaking changes.', style: TextStyle( fontSize: 12, height: 1.35, fontWeight: FontWeight.w700, color: Color(0xFF525252), fontFamily: 'monospace', ), ), ), ); } } class _PerformanceHud extends StatelessWidget { const _PerformanceHud({required this.fps, required this.frameMs}); final double fps; final double frameMs; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(10), boxShadow: const [ BoxShadow(color: Color(0x26000000), blurRadius: 4, spreadRadius: 2), ], ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '${fps.toStringAsFixed(1)} FPS', style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF111827), ), ), Text( '${frameMs.toStringAsFixed(1)} ms', style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Color(0xFF4B5563), ), ), ], ), ), ); } } class _StatusPanel extends StatelessWidget { const _StatusPanel({ required this.isAddPointArmed, required this.isResolvingRoute, required this.routeError, required this.hasRoute, }); final bool isAddPointArmed; final bool isResolvingRoute; final String? routeError; final bool hasRoute; @override Widget build(BuildContext context) { return SizedBox( width: 220, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row( children: [ shadcn.Icon(shadcn.LucideIcons.mapPin, size: 14), SizedBox(width: 8), Expanded( child: Text( 'London Route Builder', overflow: TextOverflow.ellipsis, ), ), ], ), const SizedBox(height: 8), if (isResolvingRoute) const Row( children: [ SizedBox( width: 10, height: 10, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 8), Expanded( child: Text( 'Resolving route...', overflow: TextOverflow.ellipsis, ), ), ], ) else if (hasRoute) const Text( 'Route resolved with OSRM', style: TextStyle(fontSize: 12), ), if (routeError != null) ...[ const SizedBox(height: 4), Text( routeError!, style: const TextStyle(fontSize: 12, color: Color(0xFFB91C1C)), ), ], const SizedBox(height: 4), Text( isAddPointArmed ? 'Tap anywhere on the map to add a point' : 'Drag midpoint handles to insert points', style: const TextStyle(fontSize: 12), ), ], ), ); } }