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 SingleTickerProviderStateMixin { static const _london = LatLng(51.5074, -0.1278); static const _routeColor = Color(0xFFE02425); static const _tileSize = 256.0; 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; 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; String _lastPrefetchSignature = ''; @override void initState() { super.initState(); _zoomTicker = createTicker(_onZoomTick); } @override void dispose() { _zoomTicker.dispose(); super.dispose(); } 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 => _zoom.floor().clamp(2, 18); double get _tileZoomFactor => math.pow(2, _zoom - _tileZoom).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 = math.Point(centerWorld.x - delta.dx, centerWorld.y - delta.dy); void apply() { _mapCenter = _worldToLatLng(nextCenter, _zoom); } if (notify) { setState(apply); } else { apply(); } } void _setZoomAroundFocal(double requestedZoom, Offset focal) { if (_mapSize == Size.zero) return; final nextZoom = requestedZoom.clamp(2.0, 18.0); if ((nextZoom - _zoom).abs() < 0.0001) return; final focalLatLng = _screenToLatLng(focal); final focalWorldNext = _latLngToWorld(focalLatLng, nextZoom); final centerWorldNext = math.Point( focalWorldNext.x - (focal.dx - _mapSize.width / 2), focalWorldNext.y - (focal.dy - _mapSize.height / 2), ); _zoom = nextZoom; _mapCenter = _worldToLatLng(centerWorldNext, nextZoom); } 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 _buildTileWidgets() { if (_mapSize == Size.zero) return const []; final tileZoom = _tileZoom; final factor = _tileZoomFactor; 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(); final maxTileX = (maxWorldX / _tileSize).floor(); final minTileY = (minWorldY / _tileSize).floor(); final maxTileY = (maxWorldY / _tileSize).floor(); final maxIndex = math.pow(2, tileZoom).toInt(); final tiles = []; 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'); tiles.add( Positioned( key: ValueKey(url), left: left, top: top, width: _tileSize * factor, height: _tileSize * factor, child: HiveTileImage(url: url), ), ); if (tileZoom < 18) { 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'), ); } } } } } final prefetchList = prefetchUrls.toList()..sort(); final signature = prefetchList.join('|'); if (signature != _lastPrefetchSignature) { _lastPrefetchSignature = signature; HiveTileCache.prefetchUrls(prefetchList); } return tiles; } 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 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) { setState(() { _setZoomAroundFocal(_zoom + math.log(event.scale) / math.ln2, event.localPosition); _targetZoom = _zoom; }); } else if (event is PointerScrollEvent) { if (event.kind == PointerDeviceKind.trackpad) { _panMap(event.scrollDelta); } else { final zoomStep = math.log(1.1) / math.ln2; _zoomFocal = event.localPosition; _targetZoom = (_targetZoom + (event.scrollDelta.dy < 0 ? zoomStep : -zoomStep)) .clamp(2.0, 18.0); if (!_zoomTicker.isTicking) _zoomTicker.start(); } } }, onPointerPanZoomStart: (PointerPanZoomStartEvent event) { _lastPinchScale = 1.0; }, onPointerPanZoomUpdate: (PointerPanZoomUpdateEvent event) { setState(() { if ((_lastPinchScale - event.scale).abs() > 0.25) { _lastPinchScale = event.scale; } final scaleChange = event.scale / _lastPinchScale; _lastPinchScale = event.scale; final dz = math.log(scaleChange) / math.ln2; _setZoomAroundFocal(_zoom + dz, event.localPosition); _targetZoom = _zoom; if (event.panDelta != Offset.zero) { _panMap(event.panDelta, notify: false); } }); }, onPointerPanZoomEnd: (PointerPanZoomEndEvent event) {}, child: GestureDetector( key: _mapKey, behavior: HitTestBehavior.opaque, onPanUpdate: (details) { if (_draggingInsertPointIndex != null) return; _panMap(details.delta); }, onTapUp: (details) { if (!_isAddPointArmed || _draggingInsertPointIndex != null) return; _addPoint(_screenToLatLng(details.localPosition)); }, child: ClipRect( child: Stack( children: [ ..._buildTileWidgets(), 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, ), ), ], ), ), ), ), 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 _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), ), ], ), ); } }