add initial project files for Flutter app with platform-specific configs and basic routing setup
This commit is contained in:
695
lib/pages/map/page.dart
Normal file
695
lib/pages/map/page.dart
Normal file
@@ -0,0 +1,695 @@
|
||||
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<MapPage> createState() => _MapPageState();
|
||||
}
|
||||
|
||||
class _MapPageState extends State<MapPage> 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<LatLng> _points = <LatLng>[];
|
||||
List<LatLng> _resolvedRoute = <LatLng>[];
|
||||
|
||||
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 = <LatLng>[];
|
||||
_routeError = null;
|
||||
_isResolvingRoute = false;
|
||||
_draggingInsertPointIndex = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _resolveRoute() async {
|
||||
if (_points.length < 2) {
|
||||
setState(() {
|
||||
_resolvedRoute = <LatLng>[];
|
||||
_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<double> _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<double>(x, y);
|
||||
}
|
||||
|
||||
LatLng _worldToLatLng(math.Point<double> 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<double>(
|
||||
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<double>(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<double>(
|
||||
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<Widget> _buildTileWidgets() {
|
||||
if (_mapSize == Size.zero) return const <Widget>[];
|
||||
|
||||
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 = <Widget>[];
|
||||
final prefetchUrls = <String>{};
|
||||
|
||||
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<String>(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<Widget> _buildPointWidgets() {
|
||||
final widgets = <Widget>[];
|
||||
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<Widget> _buildInsertHandles() {
|
||||
if (_points.length < 2) return const <Widget>[];
|
||||
|
||||
final handles = <Widget>[];
|
||||
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<Offset> 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/pages/map/routing/osrm_routing_service.dart
Normal file
54
lib/pages/map/routing/osrm_routing_service.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class OsrmRoutingService {
|
||||
const OsrmRoutingService({
|
||||
this.baseUrl = 'https://router.project-osrm.org',
|
||||
this.profile = 'driving',
|
||||
});
|
||||
|
||||
final String baseUrl;
|
||||
final String profile;
|
||||
|
||||
Future<List<LatLng>> fetchRoute(List<LatLng> waypoints) async {
|
||||
if (waypoints.length < 2) {
|
||||
return <LatLng>[];
|
||||
}
|
||||
|
||||
final coordinates = waypoints
|
||||
.map((point) => '${point.longitude},${point.latitude}')
|
||||
.join(';');
|
||||
final uri = Uri.parse(
|
||||
'$baseUrl/route/v1/$profile/$coordinates'
|
||||
'?overview=full&geometries=geojson',
|
||||
);
|
||||
|
||||
final response = await http.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Routing request failed (${response.statusCode})');
|
||||
}
|
||||
|
||||
final payload = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final routes = payload['routes'];
|
||||
if (routes is! List || routes.isEmpty) {
|
||||
throw Exception('No route returned');
|
||||
}
|
||||
|
||||
final geometry = routes.first['geometry'] as Map<String, dynamic>?;
|
||||
final coordinatesList = geometry?['coordinates'];
|
||||
if (coordinatesList is! List || coordinatesList.isEmpty) {
|
||||
throw Exception('Route geometry missing');
|
||||
}
|
||||
|
||||
return coordinatesList.map((pair) {
|
||||
if (pair is! List || pair.length < 2) {
|
||||
throw Exception('Invalid coordinate pair in route geometry');
|
||||
}
|
||||
final lon = (pair[0] as num).toDouble();
|
||||
final lat = (pair[1] as num).toDouble();
|
||||
return LatLng(lat, lon);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
128
lib/pages/map/tiles/hive_tile_cache.dart
Normal file
128
lib/pages/map/tiles/hive_tile_cache.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class HiveTileCache {
|
||||
HiveTileCache._();
|
||||
|
||||
static const String _boxName = 'map_tile_cache_v1';
|
||||
static const int _maxEntries = 6000;
|
||||
static const Duration _staleAfter = Duration(days: 14);
|
||||
static final Map<String, Future<Uint8List?>> _inFlight =
|
||||
<String, Future<Uint8List?>>{};
|
||||
|
||||
static Future<void> init() async {
|
||||
if (!Hive.isBoxOpen(_boxName)) {
|
||||
await Hive.openBox(_boxName);
|
||||
}
|
||||
}
|
||||
|
||||
static Box? get _boxOrNull {
|
||||
try {
|
||||
if (!Hive.isBoxOpen(_boxName)) return null;
|
||||
return Hive.box(_boxName);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Uint8List?> getOrFetch(String url) async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final box = _boxOrNull;
|
||||
if (box == null) return _fetchWithoutCache(url);
|
||||
final cached = _freshCachedBytes(box, url, now);
|
||||
if (cached != null) return cached;
|
||||
|
||||
return _fetchAndStore(box, url, now);
|
||||
}
|
||||
|
||||
static void prefetchUrls(
|
||||
Iterable<String> urls, {
|
||||
int maxCount = 96,
|
||||
}) {
|
||||
final box = _boxOrNull;
|
||||
if (box == null) return;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
var queued = 0;
|
||||
for (final url in urls) {
|
||||
if (queued >= maxCount) break;
|
||||
if (_freshCachedBytes(box, url, now) != null) continue;
|
||||
if (_inFlight.containsKey(url)) continue;
|
||||
queued += 1;
|
||||
_fetchAndStore(box, url, now);
|
||||
}
|
||||
}
|
||||
|
||||
static Uint8List? _freshCachedBytes(Box box, String url, int now) {
|
||||
final cached = box.get(url);
|
||||
if (cached is! Map) return null;
|
||||
final ts = cached['ts'];
|
||||
final bytes = cached['bytes'];
|
||||
if (ts is int &&
|
||||
bytes is Uint8List &&
|
||||
now - ts <= _staleAfter.inMilliseconds) {
|
||||
return bytes;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _fetchAndStore(Box box, String url, int now) {
|
||||
final inFlight = _inFlight[url];
|
||||
if (inFlight != null) return inFlight;
|
||||
|
||||
final future = _fetchAndStoreInternal(box, url, now);
|
||||
_inFlight[url] = future;
|
||||
future.whenComplete(() {
|
||||
_inFlight.remove(url);
|
||||
});
|
||||
return future;
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _fetchAndStoreInternal(
|
||||
Box box,
|
||||
String url,
|
||||
int now,
|
||||
) async {
|
||||
try {
|
||||
final response = await http.get(Uri.parse(url));
|
||||
if (response.statusCode != 200) return null;
|
||||
final bytes = response.bodyBytes;
|
||||
await box.put(url, <String, dynamic>{'ts': now, 'bytes': bytes});
|
||||
await _pruneIfNeeded(box);
|
||||
return bytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Uint8List?> _fetchWithoutCache(String url) async {
|
||||
try {
|
||||
final response = await http.get(Uri.parse(url));
|
||||
if (response.statusCode != 200) return null;
|
||||
return response.bodyBytes;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _pruneIfNeeded(Box box) async {
|
||||
final overflow = box.length - _maxEntries;
|
||||
if (overflow <= 0) return;
|
||||
|
||||
final entries = <MapEntry<dynamic, dynamic>>[];
|
||||
for (final key in box.keys) {
|
||||
entries.add(MapEntry(key, box.get(key)));
|
||||
}
|
||||
|
||||
entries.sort((a, b) {
|
||||
final ats = (a.value is Map && a.value['ts'] is int) ? a.value['ts'] as int : 0;
|
||||
final bts = (b.value is Map && b.value['ts'] is int) ? b.value['ts'] as int : 0;
|
||||
return ats.compareTo(bts);
|
||||
});
|
||||
|
||||
for (var i = 0; i < overflow; i++) {
|
||||
await box.delete(entries[i].key);
|
||||
}
|
||||
}
|
||||
}
|
||||
57
lib/pages/map/tiles/hive_tile_image.dart
Normal file
57
lib/pages/map/tiles/hive_tile_image.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:rra_app/pages/map/tiles/hive_tile_cache.dart';
|
||||
|
||||
class HiveTileImage extends StatefulWidget {
|
||||
const HiveTileImage({required this.url, super.key});
|
||||
|
||||
final String url;
|
||||
|
||||
@override
|
||||
State<HiveTileImage> createState() => _HiveTileImageState();
|
||||
}
|
||||
|
||||
class _HiveTileImageState extends State<HiveTileImage> {
|
||||
Uint8List? _bytes;
|
||||
String? _loadingUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load(widget.url);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant HiveTileImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.url != widget.url) {
|
||||
_load(widget.url);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load(String url) async {
|
||||
_loadingUrl = url;
|
||||
final bytes = await HiveTileCache.getOrFetch(url);
|
||||
if (!mounted || _loadingUrl != url) return;
|
||||
if (bytes != null && bytes.isNotEmpty) {
|
||||
setState(() {
|
||||
_bytes = bytes;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bytes = _bytes;
|
||||
if (bytes == null || bytes.isEmpty) {
|
||||
return const ColoredBox(color: Color(0xFFE0E0E0));
|
||||
}
|
||||
return Image.memory(
|
||||
bytes,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
filterQuality: FilterQuality.low,
|
||||
);
|
||||
}
|
||||
}
|
||||
127
lib/pages/map/widgets/toolbar.dart
Normal file
127
lib/pages/map/widgets/toolbar.dart
Normal file
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn;
|
||||
|
||||
const List<BoxShadow> _hudShadow = [
|
||||
BoxShadow(
|
||||
color: Color(0x26000000),
|
||||
blurRadius: 4,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
];
|
||||
|
||||
class MapToolbar extends StatelessWidget {
|
||||
const MapToolbar({
|
||||
required this.isAddPointArmed,
|
||||
required this.pointCount,
|
||||
required this.onAddPointPressed,
|
||||
required this.onUndoPressed,
|
||||
required this.onClearPressed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool isAddPointArmed;
|
||||
final int pointCount;
|
||||
final VoidCallback onAddPointPressed;
|
||||
final VoidCallback onUndoPressed;
|
||||
final VoidCallback onClearPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
shadcn.OutlinedContainer(
|
||||
boxShadow: _hudShadow,
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
shadcn.Icon(shadcn.LucideIcons.menu, size: 16),
|
||||
SizedBox(height: 2),
|
||||
Text('Menu', style: TextStyle(fontSize: 10)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
shadcn.OutlinedContainer(
|
||||
boxShadow: _hudShadow,
|
||||
child: Column(
|
||||
children: [
|
||||
_ToolButton(
|
||||
icon: shadcn.LucideIcons.plus,
|
||||
label: 'Add',
|
||||
selected: isAddPointArmed,
|
||||
onTap: onAddPointPressed,
|
||||
),
|
||||
_ToolButton(
|
||||
icon: shadcn.LucideIcons.undo2,
|
||||
label: 'Undo',
|
||||
onTap: onUndoPressed,
|
||||
),
|
||||
_ToolButton(
|
||||
icon: shadcn.LucideIcons.trash2,
|
||||
label: 'Clear',
|
||||
onTap: onClearPressed,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
shadcn.OutlinedContainer(
|
||||
boxShadow: _hudShadow,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Text('$pointCount pts', style: const TextStyle(fontSize: 11)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolButton extends StatelessWidget {
|
||||
const _ToolButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.selected = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = selected ? const Color(0xFFFF4D79) : Colors.transparent;
|
||||
final fg = selected ? Colors.white : const Color(0xFF111827);
|
||||
|
||||
return Material(
|
||||
color: bg,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
shadcn.Icon(icon, size: 16, color: fg),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: fg,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user