From 9f27721841fe26258ac4076e7e980766c0ae44d3 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 3 Mar 2026 16:09:19 -0700 Subject: [PATCH 1/6] Improve camera node rendering: zoom scaling, wedge cones, clustering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add flutter_map_marker_cluster for clustering camera markers at zoom <= 13 - Replace donut-shaped direction cones with wedge/pie shapes emanating from camera center, with smoother arcs (36 points/90° vs 12/45°) - Zoom-gate static camera cones to only appear at zoom >= 14 (session cones remain always visible during add/edit) - Scale camera marker diameter with zoom level (8–28px range, 18px at z15) - Move user location marker out of node markers into separate layer to avoid it being clustered with camera nodes Co-Authored-By: Claude Opus 4.6 --- lib/dev_config.dart | 23 +++ lib/widgets/camera_icon.dart | 9 +- lib/widgets/cluster_icon.dart | 31 ++++ lib/widgets/map/direction_cones.dart | 76 +++++---- lib/widgets/map/marker_layer_builder.dart | 178 +++++++++++++--------- lib/widgets/map/node_markers.dart | 32 ++-- lib/widgets/map_view.dart | 6 +- pubspec.lock | 24 +++ pubspec.yaml | 1 + 9 files changed, 238 insertions(+), 142 deletions(-) create mode 100644 lib/widgets/cluster_icon.dart diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 903b92a9..9dd5613c 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -163,8 +163,31 @@ const int kMaxReasonableTileCount = 20000; const int kAbsoluteMaxTileCount = 50000; const int kAbsoluteMaxZoom = 23; +// Direction cone zoom gating +const int kDirectionConeMinZoomLevel = 14; + +// Direction cone arc smoothness +const int kDirectionConeArcPoints = 36; // points per 90 degrees +const int kDirectionConeMinArcPoints = 12; // minimum for narrow FOVs + // Node icon configuration const double kNodeIconDiameter = 18.0; + +// Node marker zoom scaling +const double kNodeIconReferenceZoom = 15.0; +const double kNodeIconScalePerZoom = 2.0; // px per zoom level +const double kNodeIconMinDiameter = 8.0; +const double kNodeIconMaxDiameter = 28.0; + +/// Returns marker diameter scaled by current zoom level +double getScaledNodeDiameter(double zoom) { + final scaled = kNodeIconDiameter + (zoom - kNodeIconReferenceZoom) * kNodeIconScalePerZoom; + return scaled.clamp(kNodeIconMinDiameter, kNodeIconMaxDiameter); +} + +// Clustering +const int kNodeClusterMaxZoomLevel = 13; // clustering active at zoom <= 13; disabled at zoom >= 14 +const double kClusterIconDiameter = 32.0; const double _kNodeRingThicknessBase = 2.5; const double kNodeDotOpacity = 0.3; // Opacity for the grey dot interior const Color kNodeRingColorReal = Color(0xFF3036F0); // Real nodes from OSM - blue diff --git a/lib/widgets/camera_icon.dart b/lib/widgets/camera_icon.dart index 0637936d..4a258c74 100644 --- a/lib/widgets/camera_icon.dart +++ b/lib/widgets/camera_icon.dart @@ -13,8 +13,9 @@ enum CameraIconType { /// Simple camera icon with grey dot and colored ring class CameraIcon extends StatelessWidget { final CameraIconType type; - - const CameraIcon({super.key, required this.type}); + final double diameter; + + const CameraIcon({super.key, required this.type, this.diameter = kNodeIconDiameter}); Color get _ringColor { switch (type) { @@ -36,8 +37,8 @@ class CameraIcon extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - width: kNodeIconDiameter, - height: kNodeIconDiameter, + width: diameter, + height: diameter, decoration: BoxDecoration( shape: BoxShape.circle, color: _ringColor.withValues(alpha: kNodeDotOpacity), diff --git a/lib/widgets/cluster_icon.dart b/lib/widgets/cluster_icon.dart new file mode 100644 index 00000000..490763f8 --- /dev/null +++ b/lib/widgets/cluster_icon.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import '../dev_config.dart'; + +/// Cluster icon showing a blue circle with white count text +class ClusterIcon extends StatelessWidget { + final int count; + + const ClusterIcon({super.key, required this.count}); + + @override + Widget build(BuildContext context) { + return Container( + width: kClusterIconDiameter, + height: kClusterIconDiameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: kNodeRingColorReal.withValues(alpha: 0.7), + border: Border.all(color: kNodeRingColorReal, width: 2), + ), + alignment: Alignment.center, + child: Text( + count.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/widgets/map/direction_cones.dart b/lib/widgets/map/direction_cones.dart index 5c38aed8..9b9dfa0f 100644 --- a/lib/widgets/map/direction_cones.dart +++ b/lib/widgets/map/direction_cones.dart @@ -86,18 +86,21 @@ class DirectionConesBuilder { } // Add cones for cameras with direction (but exclude camera being edited) - for (final node in cameras) { - if (_isValidCameraWithDirection(node) && - (editSession == null || node.id != editSession.originalNode.id)) { - // Build a cone for each direction+fov pair - for (final directionFov in node.directionFovPairs) { - overlays.add(_buildConeWithFov( - node.coord, - directionFov.centerDegrees, - directionFov.fovDegrees, - zoom, - context: context, - )); + // Only show at sufficient zoom where direction is meaningful + if (zoom >= kDirectionConeMinZoomLevel) { + for (final node in cameras) { + if (_isValidCameraWithDirection(node) && + (editSession == null || node.id != editSession.originalNode.id)) { + // Build a cone for each direction+fov pair + for (final directionFov in node.directionFovPairs) { + overlays.add(_buildConeWithFov( + node.coord, + directionFov.centerDegrees, + directionFov.fovDegrees, + zoom, + context: context, + )); + } } } } @@ -136,6 +139,7 @@ class DirectionConesBuilder { } /// Internal cone building method that handles the actual rendering + /// Builds a wedge/pie shape emanating from the camera center static Polygon _buildConeInternal({ required LatLng origin, required double bearingDeg, @@ -157,20 +161,17 @@ class DirectionConesBuilder { isActiveDirection: isActiveDirection, ); } - - // Calculate pixel-based radii - final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength); - final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context)); - + + // Calculate pixel-based outer radius using scaled marker diameter + final diameter = getScaledNodeDiameter(zoom); + final outerRadiusPx = diameter + (diameter * kDirectionConeBaseLength); + // Convert pixels to coordinate distances with zoom scaling final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom); final outerRadius = outerRadiusPx * pixelToCoordinate; - final innerRadius = innerRadiusPx * pixelToCoordinate; - - // Number of points for the outer arc (within our directional range) - // Scale arc points based on FOV width for better rendering - final baseArcPoints = 12; - final arcPoints = math.max(6, (baseArcPoints * halfAngleDeg / 45).round()); + + // Smooth arc: scale points by FOV width + final arcPoints = math.max(kDirectionConeMinArcPoints, (kDirectionConeArcPoints * halfAngleDeg / 90).round()); LatLng project(double deg, double distance) { final rad = deg * math.pi / 180; @@ -180,20 +181,13 @@ class DirectionConesBuilder { return LatLng(origin.latitude + dLat, origin.longitude + dLon); } - // Build outer arc points only within our directional sector - final points = []; - - // Add outer arc points from left to right (counterclockwise for proper polygon winding) + // Build wedge/pie shape: origin → outer arc → auto-closes back to origin + final points = [origin]; + for (int i = 0; i <= arcPoints; i++) { final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints); points.add(project(angle, outerRadius)); } - - // Add inner arc points from right to left (to close the donut shape) - for (int i = arcPoints; i >= 0; i--) { - final angle = bearingDeg - halfAngleDeg + (i * 2 * halfAngleDeg / arcPoints); - points.add(project(angle, innerRadius)); - } // Adjust opacity based on direction state double opacity = kDirectionConeOpacity; @@ -210,7 +204,6 @@ class DirectionConesBuilder { } /// Build a full circle for 360-degree FOV cases - /// Returns just the outer circle - we'll handle the donut effect differently static Polygon _buildFullCircle({ required LatLng origin, required double zoom, @@ -218,14 +211,14 @@ class DirectionConesBuilder { bool isSession = false, bool isActiveDirection = true, }) { - // Calculate pixel-based radii - final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength); - + // Calculate pixel-based radius using scaled marker diameter + final diameter = getScaledNodeDiameter(zoom); + final outerRadiusPx = diameter + (diameter * kDirectionConeBaseLength); + // Convert pixels to coordinate distances with zoom scaling final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom); final outerRadius = outerRadiusPx * pixelToCoordinate; - - // Create simple filled circle - no donut complexity + const int circlePoints = 60; final points = []; @@ -236,9 +229,8 @@ class DirectionConesBuilder { distance * math.sin(rad) / math.cos(origin.latitude * math.pi / 180); return LatLng(origin.latitude + dLat, origin.longitude + dLon); } - - // Add outer circle points - simple complete circle - for (int i = 0; i <= circlePoints; i++) { // Note: <= to ensure closure + + for (int i = 0; i < circlePoints; i++) { final angle = (i * 360.0 / circlePoints) % 360.0; points.add(project(angle, outerRadius)); } diff --git a/lib/widgets/map/marker_layer_builder.dart b/lib/widgets/map/marker_layer_builder.dart index 26528b40..92b4c870 100644 --- a/lib/widgets/map/marker_layer_builder.dart +++ b/lib/widgets/map/marker_layer_builder.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart'; +import 'package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart'; import 'package:latlong2/latlong.dart'; import '../../models/osm_node.dart'; @@ -8,6 +9,7 @@ import '../../models/suspected_location.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; import '../camera_icon.dart'; +import '../cluster_icon.dart'; import '../provisional_pin.dart'; import 'node_markers.dart'; import 'suspected_location_markers.dart'; @@ -44,8 +46,10 @@ class LocationPin extends StatelessWidget { /// session markers, navigation pins, and route visualization. class MarkerLayerBuilder { - /// Build complete marker layers for the map - static Widget buildMarkerLayers({ + /// Build complete marker layers for the map. + /// Returns a list of widgets: a cluster layer for node markers and + /// a regular MarkerLayer for all other markers. + static List buildMarkerLayers({ required List nodesToRender, required AnimatedMapController mapController, required AppState appState, @@ -58,85 +62,111 @@ class MarkerLayerBuilder { required Function(OsmNode)? onNodeTap, required Function(SuspectedLocation)? onSuspectedLocationTap, }) { - return LayoutBuilder( - builder: (context, constraints) { - - // Determine if nodes should be dimmed and/or disabled - final shouldDimNodes = appState.selectedSuspectedLocation != null || - appState.isInSearchMode || - appState.showingOverview; - - // Disable node interactions when navigation is in conflicting state - final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview; - - final markers = NodeMarkersBuilder.buildNodeMarkers( - nodes: nodesToRender, + // Determine if nodes should be dimmed and/or disabled + final shouldDimNodes = appState.selectedSuspectedLocation != null || + appState.isInSearchMode || + appState.showingOverview; + + // Disable node interactions when navigation is in conflicting state + final shouldDisableNodeTaps = appState.isInSearchMode || appState.showingOverview; + + final markers = NodeMarkersBuilder.buildNodeMarkers( + nodes: nodesToRender, + mapController: mapController.mapController, + zoom: currentZoom, + selectedNodeId: selectedNodeId, + onNodeTap: onNodeTap, + shouldDim: shouldDimNodes, + enabled: !shouldDisableNodeTaps, + ); + + // User location marker (separate from node markers for clustering) + final userLocationMarkers = [ + if (userLocation != null) + Marker( + point: userLocation, + width: 16, + height: 16, + child: const Icon(Icons.my_location, color: Colors.blue), + ), + ]; + + // Build suspected location markers (respect same zoom and count limits as nodes) + final suspectedLocationMarkers = []; + if (appState.suspectedLocationsEnabled && mapBounds != null && + currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) { + final suspectedLocations = appState.getSuspectedLocationsInBoundsSync( + north: mapBounds.north, + south: mapBounds.south, + east: mapBounds.east, + west: mapBounds.west, + ); + + // Apply same node count limit as surveillance nodes + final maxNodes = appState.maxNodes; + final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList(); + + // Filter out suspected locations that are too close to real nodes + final filteredSuspectedLocations = _filterSuspectedLocationsByProximity( + suspectedLocations: limitedSuspectedLocations, + realNodes: nodesToRender, + minDistance: appState.suspectedLocationMinDistance, + ); + + suspectedLocationMarkers.addAll( + SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers( + locations: filteredSuspectedLocations, mapController: mapController.mapController, - userLocation: userLocation, - selectedNodeId: selectedNodeId, - onNodeTap: onNodeTap, // Keep the original callback - shouldDim: shouldDimNodes, - enabled: !shouldDisableNodeTaps, // Use enabled parameter instead - ); + selectedLocationId: appState.selectedSuspectedLocation?.ticketNo, + onLocationTap: onSuspectedLocationTap, + shouldDimAll: shouldDisableNodeTaps, + enabled: !shouldDisableNodeTaps, + ), + ); + } - // Build suspected location markers (respect same zoom and count limits as nodes) - final suspectedLocationMarkers = []; - if (appState.suspectedLocationsEnabled && mapBounds != null && - currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) { - final suspectedLocations = appState.getSuspectedLocationsInBoundsSync( - north: mapBounds.north, - south: mapBounds.south, - east: mapBounds.east, - west: mapBounds.west, - ); - - // Apply same node count limit as surveillance nodes - final maxNodes = appState.maxNodes; - final limitedSuspectedLocations = suspectedLocations.take(maxNodes).toList(); - - // Filter out suspected locations that are too close to real nodes - final filteredSuspectedLocations = _filterSuspectedLocationsByProximity( - suspectedLocations: limitedSuspectedLocations, - realNodes: nodesToRender, - minDistance: appState.suspectedLocationMinDistance, - ); - - suspectedLocationMarkers.addAll( - SuspectedLocationMarkersBuilder.buildSuspectedLocationMarkers( - locations: filteredSuspectedLocations, - mapController: mapController.mapController, - selectedLocationId: appState.selectedSuspectedLocation?.ticketNo, - onLocationTap: onSuspectedLocationTap, // Keep the original callback - shouldDimAll: shouldDisableNodeTaps, - enabled: !shouldDisableNodeTaps, // Use enabled parameter instead - ), - ); - } + // Build center marker for add/edit sessions + final centerMarkers = _buildSessionMarkers( + mapController: mapController, + session: session, + editSession: editSession, + ); - // Build center marker for add/edit sessions - final centerMarkers = _buildSessionMarkers( - mapController: mapController, - session: session, - editSession: editSession, - ); + // Build provisional pin for navigation/search mode + final navigationMarkers = _buildNavigationMarkers(appState); - // Build provisional pin for navigation/search mode - final navigationMarkers = _buildNavigationMarkers(appState); + // Build start/end pins for route visualization + final routeMarkers = _buildRouteMarkers(appState); - // Build start/end pins for route visualization - final routeMarkers = _buildRouteMarkers(appState); + // Node markers go into cluster layer + final clusterLayer = MarkerClusterLayerWidget( + options: MarkerClusterLayerOptions( + markers: markers, + maxClusterRadius: 80, + disableClusteringAtZoom: kNodeClusterMaxZoomLevel, + zoomToBoundsOnClick: true, + spiderfyCluster: false, + centerMarkerOnClick: false, + markerChildBehavior: true, // Let NodeMapMarker handle its own gestures + size: Size(kClusterIconDiameter, kClusterIconDiameter), + builder: (context, clusterMarkers) { + return ClusterIcon(count: clusterMarkers.length); + }, + ), + ); - return MarkerLayer( - markers: [ - ...suspectedLocationMarkers, - ...markers, - ...centerMarkers, - ...navigationMarkers, - ...routeMarkers, - ] - ); - }, + // All other markers stay in a regular layer + final otherMarkersLayer = MarkerLayer( + markers: [ + ...suspectedLocationMarkers, + ...userLocationMarkers, + ...centerMarkers, + ...navigationMarkers, + ...routeMarkers, + ], ); + + return [clusterLayer, otherMarkersLayer]; } /// Build center markers for add/edit sessions diff --git a/lib/widgets/map/node_markers.dart b/lib/widgets/map/node_markers.dart index 7c386bef..e522a62e 100644 --- a/lib/widgets/map/node_markers.dart +++ b/lib/widgets/map/node_markers.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; import '../../dev_config.dart'; import '../../models/osm_node.dart'; @@ -14,12 +13,14 @@ class NodeMapMarker extends StatefulWidget { final MapController mapController; final void Function(OsmNode)? onNodeTap; final bool enabled; - + final double diameter; + const NodeMapMarker({ - required this.node, - required this.mapController, + required this.node, + required this.mapController, this.onNodeTap, this.enabled = true, + this.diameter = kNodeIconDiameter, super.key, }); @@ -91,7 +92,7 @@ class _NodeMapMarkerState extends State { return GestureDetector( onTap: _onTap, onDoubleTap: _onDoubleTap, - child: CameraIcon(type: iconType), + child: CameraIcon(type: iconType, diameter: widget.diameter), ); } } @@ -101,12 +102,13 @@ class NodeMarkersBuilder { static List buildNodeMarkers({ required List nodes, required MapController mapController, - LatLng? userLocation, + required double zoom, int? selectedNodeId, void Function(OsmNode)? onNodeTap, bool shouldDim = false, bool enabled = true, }) { + final diameter = getScaledNodeDiameter(zoom); final markers = [ // Node markers ...nodes @@ -115,31 +117,23 @@ class NodeMarkersBuilder { // Check if this node should be highlighted (selected) or dimmed final isSelected = selectedNodeId == n.id; final shouldDimNode = shouldDim || (selectedNodeId != null && !isSelected); - + return Marker( point: n.coord, - width: kNodeIconDiameter, - height: kNodeIconDiameter, + width: diameter, + height: diameter, child: Opacity( opacity: shouldDimNode ? 0.5 : 1.0, child: NodeMapMarker( - node: n, + node: n, mapController: mapController, onNodeTap: onNodeTap, enabled: enabled, + diameter: diameter, ), ), ); }), - - // User location marker - if (userLocation != null) - Marker( - point: userLocation, - width: 16, - height: 16, - child: const Icon(Icons.my_location, color: Colors.blue), - ), ]; return markers; diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 1c817a46..086f5e4e 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -341,8 +341,8 @@ class MapViewState extends State { Widget cameraLayers = LayoutBuilder( builder: (context, constraints) { - // Build all marker layers - final markerLayer = MarkerLayerBuilder.buildMarkerLayers( + // Build all marker layers (cluster layer + other markers layer) + final markerLayers = MarkerLayerBuilder.buildMarkerLayers( nodesToRender: nodeData.nodesToRender, mapController: _controller, appState: appState, @@ -369,7 +369,7 @@ class MapViewState extends State { return Stack( children: [ ...overlayLayers, - markerLayer, + ...markerLayers, ], ); }, diff --git a/pubspec.lock b/pubspec.lock index b3ea42f0..039e321d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + animated_stack_widget: + dependency: transitive + description: + name: animated_stack_widget + sha256: ce4788dd158768c9d4388354b6fb72600b78e041a37afc4c279c63ecafcb9408 + url: "https://pub.dev" + source: hosted + version: "0.0.4" ansicolor: dependency: transitive description: @@ -278,6 +286,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.0" + flutter_map_marker_cluster: + dependency: "direct main" + description: + name: flutter_map_marker_cluster + sha256: "04a20d9b1c3a18b67cc97c1240f75361ab98449b735ab06f2534ece0d0794733" + url: "https://pub.dev" + source: hosted + version: "8.2.2" + flutter_map_marker_popup: + dependency: transitive + description: + name: flutter_map_marker_popup + sha256: "982b38455e739fe04abf05066340e0ce5883c40fb08b121cc8c60f5ee2c664a3" + url: "https://pub.dev" + source: hosted + version: "8.1.0" flutter_native_splash: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index bbc1e0e3..9d1df56f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: provider: ^6.1.2 flutter_map: ^8.2.1 flutter_map_animations: ^0.9.0 + flutter_map_marker_cluster: ^8.2.2 latlong2: ^0.9.0 geolocator: ^10.1.0 http: ^1.2.1 From 60c058338a84108f560b01b831d423551cd42309 Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Tue, 3 Mar 2026 16:17:27 -0700 Subject: [PATCH 2/6] Raise default max rendered nodes from 500 to 2000 Clustering now handles visual density at low zoom, so the previous 500 limit was overly conservative. Raise to 2000 while keeping it user- configurable in settings for lower-end devices. Bump the performance warning threshold from 1000 to 5000 to match. Co-Authored-By: Claude Opus 4.6 --- lib/dev_config.dart | 2 +- lib/screens/settings/sections/max_nodes_section.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 9dd5613c..2c82f3ac 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -142,7 +142,7 @@ const double kNavigationMinRouteDistance = 100.0; // meters - minimum distance b const double kNavigationDistanceWarningThreshold = 300000.0; // meters - distance threshold for timeout warning (30km) // Node display configuration -const int kDefaultMaxNodes = 500; // Default maximum number of nodes to render on the map at once +const int kDefaultMaxNodes = 2000; // Default maximum number of nodes to render on the map at once (clustering handles visual density) // NSI (Name Suggestion Index) configuration const int kNSIMinimumHitCount = 500; // Minimum hit count for NSI suggestions to be considered useful diff --git a/lib/screens/settings/sections/max_nodes_section.dart b/lib/screens/settings/sections/max_nodes_section.dart index 51f8a833..94569d79 100644 --- a/lib/screens/settings/sections/max_nodes_section.dart +++ b/lib/screens/settings/sections/max_nodes_section.dart @@ -34,7 +34,7 @@ class _MaxNodesSectionState extends State { final locService = LocalizationService.instance; final appState = context.watch(); final current = appState.maxNodes; - final showWarning = current > 1000; + final showWarning = current > 5000; return Column( crossAxisAlignment: CrossAxisAlignment.start, From 0db01f112ab345963b4b5bf5652c987bbb5ffd2c Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Sun, 8 Mar 2026 22:12:51 -0600 Subject: [PATCH 3/6] Add multi-endpoint service registry with per-endpoint policy overrides Evolve from hard-coded primary/fallback endpoint pairs to a user-configurable service registry. Each endpoint carries its own name, URL, enabled state, and optional retry/timeout overrides. - ServiceEndpoint model with JSON serialization and sensible defaults - ServiceRegistry generic ordered list with SharedPreferences persistence - executeWithEndpointList() iterates enabled endpoints with per-endpoint policy - OverpassService and RoutingService accept List - SettingsState uses ServiceRegistry instances with migration from old format - API Endpoints UI section for managing endpoint registries Co-Authored-By: Claude Opus 4.6 --- lib/app_state.dart | 12 + lib/localizations/de.json | 18 +- lib/localizations/en.json | 18 +- lib/localizations/es.json | 18 +- lib/localizations/fr.json | 18 +- lib/localizations/it.json | 18 +- lib/localizations/nl.json | 18 +- lib/localizations/pl.json | 18 +- lib/localizations/pt.json | 18 +- lib/localizations/tr.json | 18 +- lib/localizations/uk.json | 18 +- lib/localizations/zh.json | 18 +- lib/models/service_endpoint.dart | 116 ++++++ lib/models/service_registry_entry.dart | 22 ++ lib/screens/advanced_settings_screen.dart | 3 + .../sections/api_endpoints_section.dart | 280 ++++++++++++++ lib/services/overpass_service.dart | 50 ++- lib/services/routing_service.dart | 32 +- lib/services/service_policy.dart | 65 +++- lib/state/service_registry.dart | 133 +++++++ lib/state/settings_state.dart | 89 ++++- test/models/service_endpoint_test.dart | 83 ++++ test/services/overpass_service_test.dart | 39 +- test/services/routing_service_test.dart | 15 +- test/services/service_policy_test.dart | 207 ++++++++++ test/state/service_registry_test.dart | 362 ++++++++++++++++++ test/state/settings_state_test.dart | 155 ++++++++ 27 files changed, 1789 insertions(+), 72 deletions(-) create mode 100644 lib/models/service_endpoint.dart create mode 100644 lib/models/service_registry_entry.dart create mode 100644 lib/screens/settings/sections/api_endpoints_section.dart create mode 100644 lib/state/service_registry.dart create mode 100644 test/models/service_endpoint_test.dart create mode 100644 test/state/service_registry_test.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index 2688946f..e2a96622 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -11,6 +11,7 @@ import 'models/operator_profile.dart'; import 'models/osm_node.dart'; import 'models/pending_upload.dart'; import 'models/suspected_location.dart'; +import 'models/service_endpoint.dart'; import 'models/tile_provider.dart'; import 'models/search_result.dart'; import 'services/offline_area_service.dart'; @@ -31,6 +32,7 @@ import 'state/operator_profile_state.dart'; import 'state/profile_state.dart'; import 'state/search_state.dart'; import 'state/session_state.dart'; +import 'state/service_registry.dart'; import 'state/settings_state.dart'; import 'state/suspected_location_state.dart'; import 'state/upload_queue_state.dart'; @@ -167,6 +169,14 @@ class AppState extends ChangeNotifier { bool get hasUnreadMessages => _messagesState.hasUnreadMessages; bool get isCheckingMessages => _messagesState.isChecking; + // API endpoint settings + List get routingEndpoints => _settingsState.routingEndpoints; + List get enabledRoutingEndpoints => _settingsState.enabledRoutingEndpoints; + List get overpassEndpoints => _settingsState.overpassEndpoints; + List get enabledOverpassEndpoints => _settingsState.enabledOverpassEndpoints; + ServiceRegistry get routingRegistry => _settingsState.routingRegistry; + ServiceRegistry get overpassRegistry => _settingsState.overpassRegistry; + // Tile provider state List get tileProviders => _settingsState.tileProviders; TileType? get selectedTileType => _settingsState.selectedTileType; @@ -763,6 +773,8 @@ class AppState extends ChangeNotifier { await _settingsState.setDistanceUnit(unit); } + // Endpoint registry methods are accessed via routingRegistry/overpassRegistry directly + // ---------- Queue Methods ---------- void clearQueue() { _uploadQueueState.clearQueue(); diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 2c8e745f..a1bf7dd5 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -74,7 +74,23 @@ "advancedSettings": "Erweiterte Einstellungen", "advancedSettingsSubtitle": "Leistungs-, Warnungs- und Kachelanbieter-Einstellungen", "proximityAlerts": "Näherungswarnungen", - "networkStatusIndicator": "Netzwerkstatus-Anzeige" + "networkStatusIndicator": "Netzwerkstatus-Anzeige", + "apiEndpoints": "API-Endpunkte", + "apiEndpointsDescription": "Prioritätsreihenfolge der API-Endpunkte konfigurieren, benutzerdefinierte Endpunkte hinzufügen oder auf Standard zurücksetzen.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Knotenquelle", + "invalidUrl": "Geben Sie eine gültige HTTPS-URL ein", + "addEndpoint": "Endpunkt hinzufügen", + "editEndpoint": "Endpunkt bearbeiten", + "resetToDefaults": "Auf Standard zurücksetzen", + "endpointName": "Endpunktname", + "endpointUrl": "Endpunkt-URL", + "endpointMaxRetries": "Max. Wiederholungen", + "endpointTimeout": "Timeout (Sekunden)", + "endpointEnabled": "Aktiviert", + "noEnabledEndpoints": "Mindestens ein Endpunkt muss aktiviert bleiben", + "confirmResetEndpoints": "Alle Endpunkte auf Standardkonfiguration zurücksetzen?", + "builtInEndpoint": "Eingebaut" }, "proximityAlerts": { "getNotified": "Benachrichtigung erhalten beim Annähern an Überwachungsgeräte", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 0c27a461..98627ae1 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -111,7 +111,23 @@ "advancedSettings": "Advanced Settings", "advancedSettingsSubtitle": "Performance, alerts, and tile provider settings", "proximityAlerts": "Proximity Alerts", - "networkStatusIndicator": "Network Status Indicator" + "networkStatusIndicator": "Network Status Indicator", + "apiEndpoints": "API Endpoints", + "apiEndpointsDescription": "Configure API endpoint priority order, add custom endpoints, or reset to defaults.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Node Source", + "invalidUrl": "Enter a valid HTTPS URL", + "addEndpoint": "Add Endpoint", + "editEndpoint": "Edit Endpoint", + "resetToDefaults": "Reset to Defaults", + "endpointName": "Endpoint Name", + "endpointUrl": "Endpoint URL", + "endpointMaxRetries": "Max Retries", + "endpointTimeout": "Timeout (seconds)", + "endpointEnabled": "Enabled", + "noEnabledEndpoints": "At least one endpoint must remain enabled", + "confirmResetEndpoints": "Reset all endpoints to their default configuration?", + "builtInEndpoint": "Built-in" }, "proximityAlerts": { "getNotified": "Get notified when approaching surveillance devices", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index cd2f80a7..9e86cd8c 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -111,7 +111,23 @@ "advancedSettings": "Configuración Avanzada", "advancedSettingsSubtitle": "Configuración de rendimiento, alertas y proveedores de teselas", "proximityAlerts": "Alertas de Proximidad", - "networkStatusIndicator": "Indicador de Estado de Red" + "networkStatusIndicator": "Indicador de Estado de Red", + "apiEndpoints": "Endpoints de API", + "apiEndpointsDescription": "Configurar el orden de prioridad de los endpoints de API, agregar endpoints personalizados o restablecer valores predeterminados.", + "apiEndpointRouting": "Enrutamiento", + "apiEndpointNodeSource": "Fuente de nodos", + "invalidUrl": "Ingrese una URL HTTPS válida", + "addEndpoint": "Agregar Endpoint", + "editEndpoint": "Editar Endpoint", + "resetToDefaults": "Restablecer Valores Predeterminados", + "endpointName": "Nombre del Endpoint", + "endpointUrl": "URL del Endpoint", + "endpointMaxRetries": "Máx. Reintentos", + "endpointTimeout": "Tiempo de espera (segundos)", + "endpointEnabled": "Habilitado", + "noEnabledEndpoints": "Al menos un endpoint debe permanecer habilitado", + "confirmResetEndpoints": "¿Restablecer todos los endpoints a su configuración predeterminada?", + "builtInEndpoint": "Incorporado" }, "proximityAlerts": { "getNotified": "Recibe notificaciones al acercarte a dispositivos de vigilancia", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 18d4ba1e..79ea40ec 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -111,7 +111,23 @@ "advancedSettings": "Paramètres Avancés", "advancedSettingsSubtitle": "Paramètres de performance, alertes et fournisseurs de tuiles", "proximityAlerts": "Alertes de Proximité", - "networkStatusIndicator": "Indicateur de Statut Réseau" + "networkStatusIndicator": "Indicateur de Statut Réseau", + "apiEndpoints": "Points d'accès API", + "apiEndpointsDescription": "Configurer l'ordre de priorité des points d'accès API, ajouter des points d'accès personnalisés ou réinitialiser aux valeurs par défaut.", + "apiEndpointRouting": "Routage", + "apiEndpointNodeSource": "Source des nœuds", + "invalidUrl": "Entrez une URL HTTPS valide", + "addEndpoint": "Ajouter un Point d'accès", + "editEndpoint": "Modifier le Point d'accès", + "resetToDefaults": "Réinitialiser aux Valeurs par Défaut", + "endpointName": "Nom du Point d'accès", + "endpointUrl": "URL du Point d'accès", + "endpointMaxRetries": "Max. Tentatives", + "endpointTimeout": "Délai d'attente (secondes)", + "endpointEnabled": "Activé", + "noEnabledEndpoints": "Au moins un point d'accès doit rester activé", + "confirmResetEndpoints": "Réinitialiser tous les points d'accès à leur configuration par défaut ?", + "builtInEndpoint": "Intégré" }, "proximityAlerts": { "getNotified": "Recevoir des notifications en s'approchant de dispositifs de surveillance", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 4c573d9a..3f904b4c 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -111,7 +111,23 @@ "advancedSettings": "Impostazioni Avanzate", "advancedSettingsSubtitle": "Impostazioni di prestazioni, avvisi e fornitori di tessere", "proximityAlerts": "Avvisi di Prossimità", - "networkStatusIndicator": "Indicatore di Stato di Rete" + "networkStatusIndicator": "Indicatore di Stato di Rete", + "apiEndpoints": "Endpoint API", + "apiEndpointsDescription": "Configura l'ordine di priorità degli endpoint API, aggiungi endpoint personalizzati o ripristina i valori predefiniti.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Fonte dei nodi", + "invalidUrl": "Inserisci un URL HTTPS valido", + "addEndpoint": "Aggiungi Endpoint", + "editEndpoint": "Modifica Endpoint", + "resetToDefaults": "Ripristina Predefiniti", + "endpointName": "Nome Endpoint", + "endpointUrl": "URL Endpoint", + "endpointMaxRetries": "Max Tentativi", + "endpointTimeout": "Timeout (secondi)", + "endpointEnabled": "Abilitato", + "noEnabledEndpoints": "Almeno un endpoint deve rimanere abilitato", + "confirmResetEndpoints": "Ripristinare tutti gli endpoint alla configurazione predefinita?", + "builtInEndpoint": "Integrato" }, "proximityAlerts": { "getNotified": "Ricevi notifiche quando ti avvicini a dispositivi di sorveglianza", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index 40037a0f..d7102c7e 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -111,7 +111,23 @@ "advancedSettings": "Geavanceerde Instellingen", "advancedSettingsSubtitle": "Prestaties, waarschuwingen en tile provider instellingen", "proximityAlerts": "Nabijheids Waarschuwingen", - "networkStatusIndicator": "Netwerk Status Indicator" + "networkStatusIndicator": "Netwerk Status Indicator", + "apiEndpoints": "API-eindpunten", + "apiEndpointsDescription": "Configureer de prioriteitsvolgorde van API-eindpunten, voeg aangepaste eindpunten toe of reset naar standaard.", + "apiEndpointRouting": "Routing", + "apiEndpointNodeSource": "Knooppuntbron", + "invalidUrl": "Voer een geldige HTTPS-URL in", + "addEndpoint": "Eindpunt Toevoegen", + "editEndpoint": "Eindpunt Bewerken", + "resetToDefaults": "Standaardwaarden Herstellen", + "endpointName": "Eindpuntnaam", + "endpointUrl": "Eindpunt-URL", + "endpointMaxRetries": "Max. Pogingen", + "endpointTimeout": "Timeout (seconden)", + "endpointEnabled": "Ingeschakeld", + "noEnabledEndpoints": "Minstens één eindpunt moet ingeschakeld blijven", + "confirmResetEndpoints": "Alle eindpunten terugzetten naar standaardconfiguratie?", + "builtInEndpoint": "Ingebouwd" }, "proximityAlerts": { "getNotified": "Krijg meldingen wanneer u surveillance apparaten nadert", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 5d7a2c1f..1508569f 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -111,7 +111,23 @@ "advancedSettings": "Ustawienia Zaawansowane", "advancedSettingsSubtitle": "Wydajność, alerty i ustawienia dostawców kafelków", "proximityAlerts": "Alerty Bliskości", - "networkStatusIndicator": "Wskaźnik Stanu Sieci" + "networkStatusIndicator": "Wskaźnik Stanu Sieci", + "apiEndpoints": "Punkty końcowe API", + "apiEndpointsDescription": "Skonfiguruj kolejność priorytetów punktów końcowych API, dodaj niestandardowe punkty końcowe lub przywróć wartości domyślne.", + "apiEndpointRouting": "Trasowanie", + "apiEndpointNodeSource": "Źródło węzłów", + "invalidUrl": "Wprowadź prawidłowy URL HTTPS", + "addEndpoint": "Dodaj Punkt Końcowy", + "editEndpoint": "Edytuj Punkt Końcowy", + "resetToDefaults": "Przywróć Domyślne", + "endpointName": "Nazwa Punktu Końcowego", + "endpointUrl": "URL Punktu Końcowego", + "endpointMaxRetries": "Maks. Powtórzeń", + "endpointTimeout": "Limit czasu (sekundy)", + "endpointEnabled": "Włączony", + "noEnabledEndpoints": "Co najmniej jeden punkt końcowy musi pozostać włączony", + "confirmResetEndpoints": "Przywrócić wszystkie punkty końcowe do konfiguracji domyślnej?", + "builtInEndpoint": "Wbudowany" }, "proximityAlerts": { "getNotified": "Otrzymuj powiadomienia przy zbliżaniu się do urządzeń nadzoru", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index e135485d..6c7fee34 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -111,7 +111,23 @@ "advancedSettings": "Configurações Avançadas", "advancedSettingsSubtitle": "Configurações de desempenho, alertas e provedores de mapas", "proximityAlerts": "Alertas de Proximidade", - "networkStatusIndicator": "Indicador de Status de Rede" + "networkStatusIndicator": "Indicador de Status de Rede", + "apiEndpoints": "Endpoints de API", + "apiEndpointsDescription": "Configurar a ordem de prioridade dos endpoints de API, adicionar endpoints personalizados ou redefinir para os padrões.", + "apiEndpointRouting": "Roteamento", + "apiEndpointNodeSource": "Fonte de nós", + "invalidUrl": "Insira um URL HTTPS válido", + "addEndpoint": "Adicionar Endpoint", + "editEndpoint": "Editar Endpoint", + "resetToDefaults": "Redefinir para Padrões", + "endpointName": "Nome do Endpoint", + "endpointUrl": "URL do Endpoint", + "endpointMaxRetries": "Máx. Tentativas", + "endpointTimeout": "Tempo limite (segundos)", + "endpointEnabled": "Habilitado", + "noEnabledEndpoints": "Pelo menos um endpoint deve permanecer habilitado", + "confirmResetEndpoints": "Redefinir todos os endpoints para a configuração padrão?", + "builtInEndpoint": "Integrado" }, "proximityAlerts": { "getNotified": "Receba notificações ao se aproximar de dispositivos de vigilância", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index 491a4844..e139d567 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -111,7 +111,23 @@ "advancedSettings": "Gelişmiş Ayarlar", "advancedSettingsSubtitle": "Performans, uyarılar ve döşeme sağlayıcı ayarları", "proximityAlerts": "Yakınlık Uyarıları", - "networkStatusIndicator": "Ağ Durumu Göstergesi" + "networkStatusIndicator": "Ağ Durumu Göstergesi", + "apiEndpoints": "API Uç Noktaları", + "apiEndpointsDescription": "API uç noktası öncelik sırasını yapılandırın, özel uç noktalar ekleyin veya varsayılanlara sıfırlayın.", + "apiEndpointRouting": "Yönlendirme", + "apiEndpointNodeSource": "Düğüm kaynağı", + "invalidUrl": "Geçerli bir HTTPS URL'si girin", + "addEndpoint": "Uç Nokta Ekle", + "editEndpoint": "Uç Noktayı Düzenle", + "resetToDefaults": "Varsayılanlara Sıfırla", + "endpointName": "Uç Nokta Adı", + "endpointUrl": "Uç Nokta URL'si", + "endpointMaxRetries": "Maks. Yeniden Deneme", + "endpointTimeout": "Zaman Aşımı (saniye)", + "endpointEnabled": "Etkin", + "noEnabledEndpoints": "En az bir uç nokta etkin kalmalıdır", + "confirmResetEndpoints": "Tüm uç noktalar varsayılan yapılandırmaya sıfırlansın mı?", + "builtInEndpoint": "Yerleşik" }, "proximityAlerts": { "getNotified": "Gözetleme cihazlarına yaklaşırken bildirim al", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index e86696b7..c427c8a5 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -111,7 +111,23 @@ "advancedSettings": "Розширені Налаштування", "advancedSettingsSubtitle": "Продуктивність, сповіщення та налаштування постачальників плиток", "proximityAlerts": "Сповіщення Про Близькість", - "networkStatusIndicator": "Індикатор Стану Мережі" + "networkStatusIndicator": "Індикатор Стану Мережі", + "apiEndpoints": "Кінцеві точки API", + "apiEndpointsDescription": "Налаштувати порядок пріоритету кінцевих точок API, додати власні кінцеві точки або скинути до стандартних.", + "apiEndpointRouting": "Маршрутизація", + "apiEndpointNodeSource": "Джерело вузлів", + "invalidUrl": "Введіть дійсну HTTPS URL-адресу", + "addEndpoint": "Додати Кінцеву Точку", + "editEndpoint": "Редагувати Кінцеву Точку", + "resetToDefaults": "Скинути до Стандартних", + "endpointName": "Назва Кінцевої Точки", + "endpointUrl": "URL Кінцевої Точки", + "endpointMaxRetries": "Макс. Спроб", + "endpointTimeout": "Тайм-аут (секунди)", + "endpointEnabled": "Увімкнено", + "noEnabledEndpoints": "Принаймні одна кінцева точка повинна залишатися увімкненою", + "confirmResetEndpoints": "Скинути всі кінцеві точки до стандартної конфігурації?", + "builtInEndpoint": "Вбудована" }, "proximityAlerts": { "getNotified": "Отримувати сповіщення при наближенні до пристроїв спостереження", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index a62a8585..857a5a62 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -111,7 +111,23 @@ "advancedSettings": "高级设置", "advancedSettingsSubtitle": "性能、警报和地图提供商设置", "proximityAlerts": "邻近警报", - "networkStatusIndicator": "网络状态指示器" + "networkStatusIndicator": "网络状态指示器", + "apiEndpoints": "API 端点", + "apiEndpointsDescription": "配置 API 端点优先级顺序,添加自定义端点或重置为默认值。", + "apiEndpointRouting": "路由", + "apiEndpointNodeSource": "节点来源", + "invalidUrl": "请输入有效的 HTTPS URL", + "addEndpoint": "添加端点", + "editEndpoint": "编辑端点", + "resetToDefaults": "重置为默认值", + "endpointName": "端点名称", + "endpointUrl": "端点 URL", + "endpointMaxRetries": "最大重试次数", + "endpointTimeout": "超时时间(秒)", + "endpointEnabled": "已启用", + "noEnabledEndpoints": "至少需要保留一个已启用的端点", + "confirmResetEndpoints": "将所有端点重置为默认配置?", + "builtInEndpoint": "内置" }, "proximityAlerts": { "getNotified": "接近监控设备时接收通知", diff --git a/lib/models/service_endpoint.dart b/lib/models/service_endpoint.dart new file mode 100644 index 00000000..1b94dd79 --- /dev/null +++ b/lib/models/service_endpoint.dart @@ -0,0 +1,116 @@ +import 'service_registry_entry.dart'; + +/// A configurable API endpoint with optional resilience overrides. +/// +/// Used by [RoutingService] and [OverpassService] as entries in +/// their priority-ordered endpoint lists. +class ServiceEndpoint implements ServiceRegistryEntry { + @override + final String id; + @override + final String name; + + /// The endpoint URL (must be HTTPS). + final String url; + + @override + final bool enabled; + @override + final bool isBuiltIn; + + /// Override the service's default max retry count. Null = use default. + final int? maxRetries; + + /// Override the service's default HTTP timeout in seconds. Null = use default. + final int? timeoutSeconds; + + const ServiceEndpoint({ + required this.id, + required this.name, + required this.url, + this.enabled = true, + this.isBuiltIn = false, + this.maxRetries, + this.timeoutSeconds, + }); + + @override + Map toJson() => { + 'id': id, + 'name': name, + 'url': url, + 'enabled': enabled, + 'isBuiltIn': isBuiltIn, + if (maxRetries != null) 'maxRetries': maxRetries, + if (timeoutSeconds != null) 'timeoutSeconds': timeoutSeconds, + }; + + static ServiceEndpoint fromJson(Map json) => ServiceEndpoint( + id: json['id'] as String, + name: json['name'] as String, + url: json['url'] as String, + enabled: json['enabled'] as bool? ?? true, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + maxRetries: json['maxRetries'] as int?, + timeoutSeconds: json['timeoutSeconds'] as int?, + ); + + ServiceEndpoint copyWith({ + String? id, + String? name, + String? url, + bool? enabled, + bool? isBuiltIn, + int? maxRetries, + int? timeoutSeconds, + }) => ServiceEndpoint( + id: id ?? this.id, + name: name ?? this.name, + url: url ?? this.url, + enabled: enabled ?? this.enabled, + isBuiltIn: isBuiltIn ?? this.isBuiltIn, + maxRetries: maxRetries ?? this.maxRetries, + timeoutSeconds: timeoutSeconds ?? this.timeoutSeconds, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ServiceEndpoint && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// Default routing endpoints. +class DefaultServiceEndpoints { + static List routing() => const [ + ServiceEndpoint( + id: 'routing-deflock', + name: 'Deflock Primary', + url: 'https://api.dontgetflocked.com/api/v1/deflock/directions', + isBuiltIn: true, + ), + ServiceEndpoint( + id: 'routing-alprwatch', + name: 'ALPRWatch Fallback', + url: 'https://alprwatch.org/api/v1/deflock/directions', + isBuiltIn: true, + ), + ]; + + static List overpass() => const [ + ServiceEndpoint( + id: 'overpass-deflock', + name: 'Deflock Node Source', + url: 'https://overpass.deflock.org/api/interpreter', + isBuiltIn: true, + ), + ServiceEndpoint( + id: 'overpass-public', + name: 'Public Overpass', + url: 'https://overpass-api.de/api/interpreter', + isBuiltIn: true, + ), + ]; +} diff --git a/lib/models/service_registry_entry.dart b/lib/models/service_registry_entry.dart new file mode 100644 index 00000000..db671b75 --- /dev/null +++ b/lib/models/service_registry_entry.dart @@ -0,0 +1,22 @@ +/// Shared interface for entries managed by a [ServiceRegistry]. +/// +/// Both [ServiceEndpoint] and (in a future PR) [TileType] implement this, +/// enabling generic list management, persistence, and UI components. +abstract interface class ServiceRegistryEntry { + /// Unique identifier for this entry. + String get id; + + /// Human-readable display name. + String get name; + + /// Whether this entry is active. Disabled entries are skipped by + /// the resilience engine and hidden from primary selection UI. + bool get enabled; + + /// Whether this entry was provided by the app (built-in default). + /// Built-in entries cannot be deleted in production builds. + bool get isBuiltIn; + + /// Serialize to JSON for SharedPreferences persistence. + Map toJson(); +} diff --git a/lib/screens/advanced_settings_screen.dart b/lib/screens/advanced_settings_screen.dart index b5630a44..ffc78a30 100644 --- a/lib/screens/advanced_settings_screen.dart +++ b/lib/screens/advanced_settings_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'settings/sections/api_endpoints_section.dart'; import 'settings/sections/max_nodes_section.dart'; import 'settings/sections/proximity_alerts_section.dart'; import 'settings/sections/suspected_locations_section.dart'; @@ -35,6 +36,8 @@ class AdvancedSettingsScreen extends StatelessWidget { // NetworkStatusSection(), // Commented out - network status indicator now defaults to enabled // Divider(), TileProviderSection(), + Divider(), + ApiEndpointsSection(), ], ), ), diff --git a/lib/screens/settings/sections/api_endpoints_section.dart b/lib/screens/settings/sections/api_endpoints_section.dart new file mode 100644 index 00000000..da4cc510 --- /dev/null +++ b/lib/screens/settings/sections/api_endpoints_section.dart @@ -0,0 +1,280 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../../app_state.dart'; +import '../../../models/service_endpoint.dart'; +import '../../../state/service_registry.dart'; +import '../../../services/localization_service.dart'; + +class ApiEndpointsSection extends StatelessWidget { + const ApiEndpointsSection({super.key}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => Consumer( + builder: (context, appState, _) { + final loc = LocalizationService.instance; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + loc.t('settings.apiEndpoints'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + loc.t('settings.apiEndpointsDescription'), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + _EndpointRegistryList( + label: loc.t('settings.apiEndpointRouting'), + registry: appState.routingRegistry, + ), + const SizedBox(height: 16), + _EndpointRegistryList( + label: loc.t('settings.apiEndpointNodeSource'), + registry: appState.overpassRegistry, + ), + ], + ); + }, + ), + ); + } +} + +class _EndpointRegistryList extends StatelessWidget { + final String label; + final ServiceRegistry registry; + + const _EndpointRegistryList({ + required this.label, + required this.registry, + }); + + bool _isValidHttpsUrl(String url) { + if (url.isEmpty) return false; + final uri = Uri.tryParse(url); + return uri != null && uri.scheme == 'https' && uri.host.isNotEmpty; + } + + @override + Widget build(BuildContext context) { + final loc = LocalizationService.instance; + final entries = registry.entries; + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + )), + const SizedBox(height: 8), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + itemCount: entries.length, + onReorder: (oldIndex, newIndex) { + registry.reorder(oldIndex, newIndex); + }, + itemBuilder: (context, index) { + final endpoint = entries[index]; + return _EndpointTile( + key: ValueKey(endpoint.id), + endpoint: endpoint, + index: index, + registry: registry, + canDelete: !endpoint.isBuiltIn || kDebugMode, + onToggle: (enabled) { + // Prevent disabling the last enabled endpoint + if (!enabled && registry.enabledEntries.length <= 1) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(loc.t('settings.noEnabledEndpoints'))), + ); + return; + } + registry.addOrUpdate(endpoint.copyWith(enabled: enabled)); + }, + ); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton.icon( + icon: const Icon(Icons.add, size: 18), + label: Text(loc.t('settings.addEndpoint')), + onPressed: () => _showAddDialog(context), + ), + const Spacer(), + TextButton.icon( + icon: const Icon(Icons.restore, size: 18), + label: Text(loc.t('settings.resetToDefaults')), + onPressed: () => _showResetDialog(context), + ), + ], + ), + ], + ); + } + + void _showAddDialog(BuildContext context) { + final loc = LocalizationService.instance; + final nameController = TextEditingController(); + final urlController = TextEditingController(); + String? urlError; + + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text(loc.t('settings.addEndpoint')), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: InputDecoration( + labelText: loc.t('settings.endpointName'), + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + TextField( + controller: urlController, + decoration: InputDecoration( + labelText: loc.t('settings.endpointUrl'), + border: const OutlineInputBorder(), + errorText: urlError, + hintText: 'https://', + ), + keyboardType: TextInputType.url, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(loc.t('actions.cancel')), + ), + TextButton( + onPressed: () { + final url = urlController.text.trim(); + final name = nameController.text.trim(); + if (!_isValidHttpsUrl(url)) { + setState(() => urlError = loc.t('settings.invalidUrl')); + return; + } + if (name.isEmpty) return; + final id = 'custom-${DateTime.now().millisecondsSinceEpoch}'; + registry.addOrUpdate(ServiceEndpoint( + id: id, + name: name, + url: url, + )); + Navigator.pop(context); + }, + child: Text(loc.t('actions.ok')), + ), + ], + ), + ), + ); + } + + void _showResetDialog(BuildContext context) { + final loc = LocalizationService.instance; + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(loc.t('settings.resetToDefaults')), + content: Text(loc.t('settings.confirmResetEndpoints')), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(loc.t('actions.cancel')), + ), + TextButton( + onPressed: () { + registry.resetToDefaults(); + Navigator.pop(dialogContext); + }, + child: Text(loc.t('actions.ok')), + ), + ], + ), + ); + } +} + +class _EndpointTile extends StatelessWidget { + final ServiceEndpoint endpoint; + final int index; + final ServiceRegistry registry; + final bool canDelete; + final void Function(bool enabled) onToggle; + + const _EndpointTile({ + super.key, + required this.endpoint, + required this.index, + required this.registry, + required this.canDelete, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(vertical: 2), + child: ListTile( + dense: true, + leading: ReorderableDragStartListener( + index: index, + child: const Icon(Icons.drag_handle, size: 20), + ), + title: Text( + endpoint.name, + style: theme.textTheme.bodyMedium?.copyWith( + color: endpoint.enabled ? null : theme.disabledColor, + ), + ), + subtitle: Text( + endpoint.url, + style: theme.textTheme.bodySmall?.copyWith( + color: endpoint.enabled ? theme.hintColor : theme.disabledColor, + ), + overflow: TextOverflow.ellipsis, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: endpoint.enabled, + onChanged: onToggle, + ), + if (canDelete) + IconButton( + icon: const Icon(Icons.delete_outline, size: 20), + onPressed: () { + try { + registry.delete(endpoint.id); + } on StateError catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 05b8c186..0b882917 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -6,6 +6,8 @@ import 'package:flutter_map/flutter_map.dart'; import '../models/node_profile.dart'; import '../models/osm_node.dart'; +import '../models/service_endpoint.dart'; +import '../app_state.dart'; import '../dev_config.dart'; import 'http_client.dart'; import 'service_policy.dart'; @@ -21,48 +23,60 @@ class OverpassService { ); final http.Client _client; - /// Optional override endpoint. When null, uses [defaultEndpoint]. - final String? _endpointOverride; + /// Optional override endpoints for testing. + final List? _endpointsOverride; - OverpassService({http.Client? client, String? endpoint}) + OverpassService({http.Client? client, List? endpoints}) : _client = client ?? UserAgentClient(), - _endpointOverride = endpoint; + _endpointsOverride = endpoints; - /// Resolve the primary endpoint: constructor override or default. - String get _primaryEndpoint => _endpointOverride ?? defaultEndpoint; + /// Resolve the endpoint list: constructor override > AppState > defaults. + List get _endpoints { + if (_endpointsOverride != null) return _endpointsOverride; + try { + final endpoints = AppState.instance.enabledOverpassEndpoints; + if (endpoints.isNotEmpty) return endpoints; + } catch (_) { + // AppState may not be initialized (e.g., in tests) + } + return DefaultServiceEndpoints.overpass(); + } /// Fetch surveillance nodes from Overpass API with retry and fallback. /// Throws NetworkError for retryable failures, NodeLimitError for area splitting. Future> fetchNodes({ required LatLngBounds bounds, required List profiles, - ResiliencePolicy? policy, + int? maxRetries, }) async { if (profiles.isEmpty) return []; final query = _buildQuery(bounds, profiles); - final endpoint = _primaryEndpoint; - final canFallback = _endpointOverride == null; - final effectivePolicy = policy ?? _policy; - - return executeWithFallback>( - primaryUrl: endpoint, - fallbackUrl: canFallback ? fallbackEndpoint : null, - execute: (url) => _attemptFetch(url, query, effectivePolicy), + + final effectivePolicy = maxRetries != null + ? ResiliencePolicy( + maxRetries: maxRetries, + httpTimeout: _policy.httpTimeout, + ) + : _policy; + + return executeWithEndpointList>( + endpoints: _endpoints, + execute: (url) => _attemptFetch(url, query), classifyError: _classifyError, - policy: effectivePolicy, + defaultPolicy: effectivePolicy, ); } /// Single POST + parse attempt (no retry logic — handled by executeWithFallback). - Future> _attemptFetch(String endpoint, String query, ResiliencePolicy policy) async { + Future> _attemptFetch(String endpoint, String query) async { debugPrint('[OverpassService] POST $endpoint'); try { final response = await _client.post( Uri.parse(endpoint), body: {'data': query}, - ).timeout(policy.httpTimeout); + ).timeout(_policy.httpTimeout); if (response.statusCode == 200) { return _parseResponse(response.body); diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart index 072a54af..c8ae5a7c 100644 --- a/lib/services/routing_service.dart +++ b/lib/services/routing_service.dart @@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../app_state.dart'; +import '../models/service_endpoint.dart'; import 'http_client.dart'; import 'service_policy.dart'; @@ -34,17 +35,26 @@ class RoutingService { ); final http.Client _client; - /// Optional override URL. When null, uses [defaultUrl]. - final String? _baseUrlOverride; + /// Optional override endpoints for testing. + final List? _endpointsOverride; - RoutingService({http.Client? client, String? baseUrl}) + RoutingService({http.Client? client, List? endpoints}) : _client = client ?? UserAgentClient(), - _baseUrlOverride = baseUrl; + _endpointsOverride = endpoints; void close() => _client.close(); - /// Resolve the primary URL to use: constructor override or default. - String get _primaryUrl => _baseUrlOverride ?? defaultUrl; + /// Resolve the endpoint list: constructor override > AppState > defaults. + List get _endpoints { + if (_endpointsOverride != null) return _endpointsOverride; + try { + final endpoints = AppState.instance.enabledRoutingEndpoints; + if (endpoints.isNotEmpty) return endpoints; + } catch (_) { + // AppState may not be initialized yet (e.g., in tests) + } + return DefaultServiceEndpoints.routing(); + } // Calculate route between two points Future calculateRoute({ @@ -81,15 +91,11 @@ class RoutingService { 'show_exclusion_zone': false, }; - final primaryUrl = _primaryUrl; - final canFallback = _baseUrlOverride == null; - - return executeWithFallback( - primaryUrl: primaryUrl, - fallbackUrl: canFallback ? fallbackUrl : null, + return executeWithEndpointList( + endpoints: _endpoints, execute: (url) => _postRoute(url, params), classifyError: _classifyError, - policy: _policy, + defaultPolicy: _policy, ); } diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 8fd66cf2..d57a8e9c 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import '../models/service_endpoint.dart'; /// Identifies the type of external service being accessed. /// Used by [ServicePolicyResolver] to determine the correct compliance policy. @@ -255,24 +256,71 @@ enum ErrorDisposition { class ResiliencePolicy { final int maxRetries; final Duration httpTimeout; - final Duration _retryBackoffBase; - final int _retryBackoffMaxMs; + final Duration retryBackoffBase; + final int retryBackoffMaxMs; const ResiliencePolicy({ this.maxRetries = 1, this.httpTimeout = const Duration(seconds: 30), - Duration retryBackoffBase = const Duration(milliseconds: 200), - int retryBackoffMaxMs = 5000, - }) : _retryBackoffBase = retryBackoffBase, - _retryBackoffMaxMs = retryBackoffMaxMs; + this.retryBackoffBase = const Duration(milliseconds: 200), + this.retryBackoffMaxMs = 5000, + }); Duration retryDelay(int attempt) { - final ms = (_retryBackoffBase.inMilliseconds * (1 << attempt)) - .clamp(0, _retryBackoffMaxMs); + final ms = (retryBackoffBase.inMilliseconds * (1 << attempt)) + .clamp(0, retryBackoffMaxMs); return Duration(milliseconds: ms); } } +/// Execute a request against an ordered list of endpoints with retry and fallback. +/// +/// Tries each enabled endpoint in list order. For each endpoint: +/// 1. Applies per-endpoint policy overrides (maxRetries, timeout) over [defaultPolicy] +/// 2. Retries on [ErrorDisposition.retry] errors up to the effective maxRetries +/// 3. On [ErrorDisposition.fallback], skips remaining retries and moves to next endpoint +/// 4. On [ErrorDisposition.abort], rethrows immediately (no further endpoints tried) +/// +/// Throws [StateError] if no endpoints are enabled. +/// Rethrows the last error if all endpoints are exhausted. +Future executeWithEndpointList({ + required List endpoints, + required Future Function(String url) execute, + required ErrorDisposition Function(Object error) classifyError, + ResiliencePolicy defaultPolicy = const ResiliencePolicy(), +}) async { + final enabled = endpoints.where((e) => e.enabled).toList(growable: false); + if (enabled.isEmpty) { + throw StateError('No enabled endpoints configured'); + } + + Object? lastError; + for (final endpoint in enabled) { + final effectivePolicy = ResiliencePolicy( + maxRetries: endpoint.maxRetries ?? defaultPolicy.maxRetries, + httpTimeout: endpoint.timeoutSeconds != null + ? Duration(seconds: endpoint.timeoutSeconds!) + : defaultPolicy.httpTimeout, + retryBackoffBase: defaultPolicy.retryBackoffBase, + retryBackoffMaxMs: defaultPolicy.retryBackoffMaxMs, + ); + try { + return await _executeWithRetries( + endpoint.url, + execute, + classifyError, + effectivePolicy, + ); + } catch (e) { + final disposition = classifyError(e); + if (disposition == ErrorDisposition.abort) rethrow; + lastError = e; + debugPrint('[Resilience] Endpoint ${endpoint.name} failed ($e), trying next'); + } + } + throw lastError!; // All endpoints exhausted +} + /// Execute a request with retry and fallback logic. /// /// 1. Tries [execute] against [primaryUrl] up to `policy.maxRetries + 1` times. @@ -282,6 +330,7 @@ class ResiliencePolicy { /// - [ErrorDisposition.retry]: retries with backoff, then fallback if exhausted /// 3. If [fallbackUrl] is non-null and primary failed with a non-abort error, /// repeats the retry loop against the fallback. +@Deprecated('Use executeWithEndpointList instead') Future executeWithFallback({ required String primaryUrl, required String? fallbackUrl, diff --git a/lib/state/service_registry.dart b/lib/state/service_registry.dart new file mode 100644 index 00000000..e2d74e26 --- /dev/null +++ b/lib/state/service_registry.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/service_registry_entry.dart'; + +/// Generic list manager for [ServiceRegistryEntry] subtypes. +/// +/// Handles persistence (JSON in SharedPreferences), CRUD operations, +/// reordering, default management, and change notification. +class ServiceRegistry { + /// SharedPreferences key for this registry's data. + final String prefsKey; + + /// Deserialize a JSON map to a registry entry. + final T Function(Map) fromJson; + + /// Factory for creating the default entries (built-in endpoints/tiles). + final List Function() createDefaults; + + /// Called after every mutation to notify the owning state object. + final VoidCallback onChanged; + + List _entries = []; + SharedPreferences? _prefs; + + ServiceRegistry({ + required this.prefsKey, + required this.fromJson, + required this.createDefaults, + required this.onChanged, + }); + + /// Unmodifiable view of all entries in priority order. + List get entries => List.unmodifiable(_entries); + + /// Only entries with [enabled] == true, in priority order. + List get enabledEntries => + _entries.where((e) => e.enabled).toList(growable: false); + + /// Load from SharedPreferences; initialize with defaults if absent or corrupt. + Future load(SharedPreferences prefs) async { + _prefs = prefs; + if (prefs.containsKey(prefsKey)) { + try { + final json = jsonDecode(prefs.getString(prefsKey)!) as List; + _entries = json + .map((j) => fromJson(j as Map)) + .toList(); + await _addMissingDefaults(); + return; + } catch (e) { + debugPrint('[ServiceRegistry] Error loading $prefsKey: $e'); + } + } + _entries = List.of(createDefaults()); + await _save(); + } + + /// Add a new entry or update an existing one (matched by id). + /// New entries are appended; existing entries are replaced in-place. + Future addOrUpdate(T entry) async { + final index = _entries.indexWhere((e) => e.id == entry.id); + if (index >= 0) { + _entries[index] = entry; + } else { + _entries.add(entry); + } + await _save(); + onChanged(); + } + + /// Remove an entry by id. + /// Throws [StateError] if this would empty the list. + /// Throws [UnsupportedError] if the entry is built-in and we're in release mode. + Future delete(String id) async { + final index = _entries.indexWhere((e) => e.id == id); + if (index < 0) return; + if (_entries.length <= 1) { + throw StateError('Cannot delete the last entry'); + } + if (_entries[index].isBuiltIn && !kDebugMode) { + throw UnsupportedError('Cannot delete built-in entries in release mode'); + } + _entries.removeAt(index); + await _save(); + onChanged(); + } + + /// Move entry from [oldIndex] to [newIndex]. + Future reorder(int oldIndex, int newIndex) async { + if (oldIndex == newIndex) return; + // ReorderableListView convention: if moving down, newIndex is +1 + final adjustedNew = newIndex > oldIndex ? newIndex - 1 : newIndex; + final entry = _entries.removeAt(oldIndex); + _entries.insert(adjustedNew, entry); + await _save(); + onChanged(); + } + + /// Replace all entries with defaults. + Future resetToDefaults() async { + _entries = List.of(createDefaults()); + await _save(); + onChanged(); + } + + /// Find an entry by id, or null if not found. + T? findById(String id) { + for (final e in _entries) { + if (e.id == id) return e; + } + return null; + } + + /// Add any default entries that are missing from the current list. + Future _addMissingDefaults() async { + final existingIds = _entries.map((e) => e.id).toSet(); + final defaults = createDefaults(); + var changed = false; + for (final d in defaults) { + if (!existingIds.contains(d.id)) { + _entries.add(d); + changed = true; + } + } + if (changed) await _save(); + } + + Future _save() async { + final json = jsonEncode(_entries.map((e) => e.toJson()).toList()); + await _prefs!.setString(prefsKey, json); + } +} diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index e8840860..51ee4c8b 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -3,10 +3,12 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:collection/collection.dart'; +import '../models/service_endpoint.dart'; import '../models/tile_provider.dart'; import '../services/provider_tile_cache_manager.dart'; import '../dev_config.dart'; import '../keys.dart'; +import 'service_registry.dart'; // Enum for upload mode (Production, OSM Sandbox, Simulate) enum UploadMode { production, sandbox, simulate } @@ -39,6 +41,10 @@ class SettingsState extends ChangeNotifier { static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing'; static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance'; static const String _distanceUnitPrefsKey = 'distance_unit'; + static const String _routingEndpointPrefsKey = 'routing_endpoint'; + static const String _overpassEndpointPrefsKey = 'overpass_endpoint'; + static const String _routingEndpointsPrefsKey = 'routing_endpoints'; + static const String _overpassEndpointsPrefsKey = 'overpass_endpoints'; bool _offlineMode = false; bool _pauseQueueProcessing = false; @@ -55,6 +61,20 @@ class SettingsState extends ChangeNotifier { int _navigationAvoidanceDistance = 250; // meters DistanceUnit _distanceUnit = DistanceUnit.metric; + late final ServiceRegistry _routingRegistry = ServiceRegistry( + prefsKey: _routingEndpointsPrefsKey, + fromJson: ServiceEndpoint.fromJson, + createDefaults: DefaultServiceEndpoints.routing, + onChanged: notifyListeners, + ); + + late final ServiceRegistry _overpassRegistry = ServiceRegistry( + prefsKey: _overpassEndpointsPrefsKey, + fromJson: ServiceEndpoint.fromJson, + createDefaults: DefaultServiceEndpoints.overpass, + onChanged: notifyListeners, + ); + // Getters bool get offlineMode => _offlineMode; bool get pauseQueueProcessing => _pauseQueueProcessing; @@ -69,7 +89,25 @@ class SettingsState extends ChangeNotifier { String get selectedTileTypeId => _selectedTileTypeId; int get navigationAvoidanceDistance => _navigationAvoidanceDistance; DistanceUnit get distanceUnit => _distanceUnit; - + + /// Ordered list of routing endpoints (priority order). + List get routingEndpoints => _routingRegistry.entries; + + /// Only enabled routing endpoints. + List get enabledRoutingEndpoints => _routingRegistry.enabledEntries; + + /// Ordered list of Overpass endpoints (priority order). + List get overpassEndpoints => _overpassRegistry.entries; + + /// Only enabled Overpass endpoints. + List get enabledOverpassEndpoints => _overpassRegistry.enabledEntries; + + /// The routing registry for direct UI manipulation. + ServiceRegistry get routingRegistry => _routingRegistry; + + /// The Overpass registry for direct UI manipulation. + ServiceRegistry get overpassRegistry => _overpassRegistry; + /// Get the currently selected tile type TileType? get selectedTileType { for (final provider in _tileProviders) { @@ -168,6 +206,22 @@ class SettingsState extends ChangeNotifier { await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } + // Migrate old single-string endpoints to new registry format + await _migrateOldEndpoint( + prefs: prefs, + oldKey: _routingEndpointPrefsKey, + registry: _routingRegistry, + ); + await _migrateOldEndpoint( + prefs: prefs, + oldKey: _overpassEndpointPrefsKey, + registry: _overpassRegistry, + ); + + // Load endpoint registries + await _routingRegistry.load(prefs); + await _overpassRegistry.load(prefs); + // Load tile providers (default to built-in providers if none saved) await _loadTileProviders(prefs); @@ -417,4 +471,37 @@ class SettingsState extends ChangeNotifier { } } + /// Migrate old single-string endpoint to new registry list format. + Future _migrateOldEndpoint({ + required SharedPreferences prefs, + required String oldKey, + required ServiceRegistry registry, + }) async { + // Skip if new format already exists or old format doesn't + if (prefs.containsKey(registry.prefsKey) || !prefs.containsKey(oldKey)) return; + + final oldUrl = prefs.getString(oldKey); + if (oldUrl != null && oldUrl.isNotEmpty) { + // Create a list: custom endpoint first (highest priority), then defaults + final defaults = registry.createDefaults(); + // Check if custom URL matches any default + final isDefault = defaults.any((d) => d.url == oldUrl); + if (!isDefault) { + final customEntry = ServiceEndpoint( + id: 'custom-${oldKey.replaceAll('_', '-')}', + name: 'Custom', + url: oldUrl, + ); + final migrated = [customEntry, ...defaults]; + await prefs.setString( + registry.prefsKey, + jsonEncode(migrated.map((e) => e.toJson()).toList()), + ); + } + } + // Clean up old key + await prefs.remove(oldKey); + } + } + diff --git a/test/models/service_endpoint_test.dart b/test/models/service_endpoint_test.dart new file mode 100644 index 00000000..92d68357 --- /dev/null +++ b/test/models/service_endpoint_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; + +void main() { + group('ServiceEndpoint', () { + test('toJson/fromJson round-trip preserves all fields', () { + const endpoint = ServiceEndpoint( + id: 'test', name: 'Test', url: 'https://example.com', + enabled: false, isBuiltIn: true, maxRetries: 5, timeoutSeconds: 10, + ); + final restored = ServiceEndpoint.fromJson(endpoint.toJson()); + expect(restored.id, endpoint.id); + expect(restored.name, endpoint.name); + expect(restored.url, endpoint.url); + expect(restored.enabled, endpoint.enabled); + expect(restored.isBuiltIn, endpoint.isBuiltIn); + expect(restored.maxRetries, endpoint.maxRetries); + expect(restored.timeoutSeconds, endpoint.timeoutSeconds); + }); + + test('fromJson uses defaults for missing optional fields', () { + final endpoint = ServiceEndpoint.fromJson({ + 'id': 'x', 'name': 'X', 'url': 'https://x.com', + }); + expect(endpoint.enabled, isTrue); + expect(endpoint.isBuiltIn, isFalse); + expect(endpoint.maxRetries, isNull); + expect(endpoint.timeoutSeconds, isNull); + }); + + test('copyWith replaces specified fields only', () { + const original = ServiceEndpoint( + id: 'a', name: 'A', url: 'https://a.com', + ); + final modified = original.copyWith(name: 'B', enabled: false); + expect(modified.id, 'a'); + expect(modified.name, 'B'); + expect(modified.url, 'https://a.com'); + expect(modified.enabled, isFalse); + }); + + test('equality is by id', () { + const a = ServiceEndpoint(id: 'x', name: 'A', url: 'https://a.com'); + const b = ServiceEndpoint(id: 'x', name: 'B', url: 'https://b.com'); + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('different ids are not equal', () { + const a = ServiceEndpoint(id: 'x', name: 'A', url: 'https://a.com'); + const b = ServiceEndpoint(id: 'y', name: 'A', url: 'https://a.com'); + expect(a, isNot(equals(b))); + }); + }); + + group('DefaultServiceEndpoints', () { + test('routing returns 2 built-in endpoints', () { + final endpoints = DefaultServiceEndpoints.routing(); + expect(endpoints, hasLength(2)); + expect(endpoints.every((e) => e.isBuiltIn), isTrue); + expect(endpoints.every((e) => e.enabled), isTrue); + }); + + test('overpass returns 2 built-in endpoints', () { + final endpoints = DefaultServiceEndpoints.overpass(); + expect(endpoints, hasLength(2)); + expect(endpoints.every((e) => e.isBuiltIn), isTrue); + expect(endpoints.every((e) => e.enabled), isTrue); + }); + + test('routing endpoints have expected URLs', () { + final endpoints = DefaultServiceEndpoints.routing(); + expect(endpoints[0].url, contains('dontgetflocked.com')); + expect(endpoints[1].url, contains('alprwatch.org')); + }); + + test('overpass endpoints have expected URLs', () { + final endpoints = DefaultServiceEndpoints.overpass(); + expect(endpoints[0].url, contains('overpass.deflock.org')); + expect(endpoints[1].url, contains('overpass-api.de')); + }); + }); +} diff --git a/test/services/overpass_service_test.dart b/test/services/overpass_service_test.dart index b862a2a1..16298d90 100644 --- a/test/services/overpass_service_test.dart +++ b/test/services/overpass_service_test.dart @@ -6,8 +6,8 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:mocktail/mocktail.dart'; import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/services/overpass_service.dart'; -import 'package:deflockapp/services/service_policy.dart'; class MockHttpClient extends Mock implements http.Client {} @@ -37,8 +37,11 @@ void main() { setUp(() { mockClient = MockHttpClient(); - // Initialize OverpassService with a mock HTTP client for testing - service = OverpassService(client: mockClient); + // Use explicit endpoints to avoid AppState dependency in tests + service = OverpassService( + client: mockClient, + endpoints: DefaultServiceEndpoints.overpass(), + ); }); /// Helper: stub a successful Overpass response with the given elements. @@ -250,7 +253,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -260,7 +263,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -271,7 +274,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -281,7 +284,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -291,7 +294,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -302,7 +305,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); }); @@ -345,7 +348,7 @@ void main() { }); final nodes = await service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)); + bounds: bounds, profiles: profiles, maxRetries: 0); expect(nodes, hasLength(1)); // primary (1 attempt, 0 retries) + fallback (1 attempt) = 2 @@ -361,7 +364,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); @@ -398,7 +401,7 @@ void main() { }); final nodes = await service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2)); + bounds: bounds, profiles: profiles, maxRetries: 2); expect(nodes, hasLength(1)); // 1 primary (no retry on fallback disposition) + 1 fallback = 2 @@ -412,7 +415,7 @@ void main() { await expectLater( () => service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); @@ -421,10 +424,12 @@ void main() { .called(2); }); - test('does NOT fallback when using custom endpoint', () async { + test('single custom endpoint does not fallback', () async { final customService = OverpassService( client: mockClient, - endpoint: 'https://custom.example.com/api/interpreter', + endpoints: const [ + ServiceEndpoint(id: 'custom', name: 'Custom', url: 'https://custom.example.com/api/interpreter'), + ], ); when(() => mockClient.post(any(), body: any(named: 'body'))) @@ -433,7 +438,7 @@ void main() { await expectLater( () => customService.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 0)), + bounds: bounds, profiles: profiles, maxRetries: 0), throwsA(isA()), ); @@ -470,7 +475,7 @@ void main() { }); final nodes = await service.fetchNodes( - bounds: bounds, profiles: profiles, policy: const ResiliencePolicy(maxRetries: 2)); + bounds: bounds, profiles: profiles, maxRetries: 2); expect(nodes, hasLength(1)); // 3 primary attempts (1 + 2 retries) + 1 fallback = 4 diff --git a/test/services/routing_service_test.dart b/test/services/routing_service_test.dart index e26b25f1..76da9bbb 100644 --- a/test/services/routing_service_test.dart +++ b/test/services/routing_service_test.dart @@ -7,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:deflockapp/app_state.dart'; import 'package:deflockapp/models/node_profile.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/services/routing_service.dart'; class MockHttpClient extends Mock implements http.Client {} @@ -34,7 +35,11 @@ void main() { mockAppState = MockAppState(); AppState.instance = mockAppState; - service = RoutingService(client: mockClient); + // Use fixed endpoints so tests don't try to resolve AppState settings + service = RoutingService( + client: mockClient, + endpoints: DefaultServiceEndpoints.routing(), + ); }); tearDown(() { @@ -455,10 +460,12 @@ void main() { )).called(4); }); - test('does NOT fallback when using custom baseUrl', () async { + test('single custom endpoint does not fallback', () async { final customService = RoutingService( client: mockClient, - baseUrl: 'https://custom.example.com/route', + endpoints: const [ + ServiceEndpoint(id: 'custom', name: 'Custom', url: 'https://custom.example.com/route'), + ], ); when(() => mockAppState.enabledProfiles).thenReturn([]); @@ -476,7 +483,7 @@ void main() { throwsA(isA()), ); - // 2 attempts (1 + 1 retry), no fallback with custom URL + // 2 attempts (1 + 1 retry), no fallback with single endpoint verify(() => mockClient.post( any(), headers: any(named: 'headers'), diff --git a/test/services/service_policy_test.dart b/test/services/service_policy_test.dart index 1243ab51..75a99729 100644 --- a/test/services/service_policy_test.dart +++ b/test/services/service_policy_test.dart @@ -1,6 +1,7 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/services/service_policy.dart'; void main() { @@ -386,6 +387,212 @@ void main() { }); }); + group('executeWithEndpointList', () { + const defaultPolicy = ResiliencePolicy( + maxRetries: 1, + retryBackoffBase: Duration.zero, + ); + + test('throws StateError when no endpoints enabled', () async { + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com', enabled: false), + ], + execute: (url) => Future.value('ok'), + classifyError: (_) => ErrorDisposition.retry, + ), + throwsA(isA()), + ); + }); + + test('succeeds with single enabled endpoint', () async { + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ], + execute: (url) => Future.value('ok'), + classifyError: (_) => ErrorDisposition.retry, + ); + expect(result, 'ok'); + }); + + test('tries next endpoint when retry exhausted', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + urlsSeen.add(url); + if (url == 'https://a.com') throw Exception('fail'); + return Future.value('ok from b'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: defaultPolicy, + ); + + expect(result, 'ok from b'); + // endpoint A: 1 attempt + 1 retry = 2 calls, then endpoint B: 1 call + expect(urlsSeen.where((u) => u == 'https://a.com').length, 2); + expect(urlsSeen.where((u) => u == 'https://b.com').length, 1); + }); + + test('fallback disposition skips to next immediately', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + urlsSeen.add(url); + if (url == 'https://a.com') throw Exception('rate limited'); + return Future.value('ok from b'); + }, + classifyError: (_) => ErrorDisposition.fallback, + defaultPolicy: defaultPolicy, + ); + + expect(result, 'ok from b'); + // Fallback: only 1 call to A (no retries), then 1 call to B + expect(urlsSeen.where((u) => u == 'https://a.com').length, 1); + expect(urlsSeen.where((u) => u == 'https://b.com').length, 1); + }); + + test('abort stops entire chain', () async { + final urlsSeen = []; + + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + urlsSeen.add(url); + throw Exception('validation error'); + }, + classifyError: (_) => ErrorDisposition.abort, + defaultPolicy: defaultPolicy, + ), + throwsA(isA()), + ); + + // Only A called once, B never called + expect(urlsSeen, ['https://a.com']); + }); + + test('per-endpoint maxRetries override', () async { + int callCount = 0; + + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com', maxRetries: 0), + ], + execute: (url) { + callCount++; + throw Exception('fail'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: const ResiliencePolicy( + maxRetries: 5, + retryBackoffBase: Duration.zero, + ), + ), + throwsA(isA()), + ); + + // maxRetries=0 means only 1 attempt (no retries) + expect(callCount, 1); + }); + + test('per-endpoint timeout override is used in effective policy', () async { + // We can't easily test the actual timeout behavior, but we can verify + // the endpoint is used successfully with the override + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com', timeoutSeconds: 1), + ], + execute: (url) => Future.value('ok'), + classifyError: (_) => ErrorDisposition.retry, + ); + expect(result, 'ok'); + }); + + test('disabled endpoints skipped', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'disabled', name: 'Disabled', url: 'https://disabled.com', enabled: false), + ServiceEndpoint(id: 'enabled', name: 'Enabled', url: 'https://enabled.com'), + ], + execute: (url) { + urlsSeen.add(url); + return Future.value('ok'); + }, + classifyError: (_) => ErrorDisposition.retry, + ); + + expect(result, 'ok'); + expect(urlsSeen, ['https://enabled.com']); + }); + + test('all endpoints fail rethrows last error', () async { + await expectLater( + () => executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ], + execute: (url) { + if (url == 'https://b.com') throw Exception('b failed'); + throw Exception('a failed'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: const ResiliencePolicy( + maxRetries: 0, + retryBackoffBase: Duration.zero, + ), + ), + throwsA(isA().having( + (e) => e.toString(), 'message', contains('b failed'), + )), + ); + }); + + test('3 endpoints, first 2 fail, third succeeds', () async { + final urlsSeen = []; + + final result = await executeWithEndpointList( + endpoints: const [ + ServiceEndpoint(id: 'a', name: 'A', url: 'https://a.com'), + ServiceEndpoint(id: 'b', name: 'B', url: 'https://b.com'), + ServiceEndpoint(id: 'c', name: 'C', url: 'https://c.com'), + ], + execute: (url) { + urlsSeen.add(url); + if (url == 'https://c.com') return Future.value('ok from c'); + throw Exception('fail'); + }, + classifyError: (_) => ErrorDisposition.retry, + defaultPolicy: const ResiliencePolicy( + maxRetries: 0, + retryBackoffBase: Duration.zero, + ), + ); + + expect(result, 'ok from c'); + expect(urlsSeen, ['https://a.com', 'https://b.com', 'https://c.com']); + }); + }); + + // ignore: deprecated_member_use_from_same_package group('executeWithFallback', () { const policy = ResiliencePolicy( maxRetries: 2, diff --git a/test/state/service_registry_test.dart b/test/state/service_registry_test.dart new file mode 100644 index 00000000..6d14c3c4 --- /dev/null +++ b/test/state/service_registry_test.dart @@ -0,0 +1,362 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:deflockapp/models/service_registry_entry.dart'; +import 'package:deflockapp/state/service_registry.dart'; + +/// Simple test entry implementing ServiceRegistryEntry. +class TestEntry implements ServiceRegistryEntry { + @override + final String id; + @override + final String name; + @override + final bool enabled; + @override + final bool isBuiltIn; + + const TestEntry({ + required this.id, + required this.name, + this.enabled = true, + this.isBuiltIn = false, + }); + + @override + Map toJson() => { + 'id': id, + 'name': name, + 'enabled': enabled, + 'isBuiltIn': isBuiltIn, + }; + + static TestEntry fromJson(Map json) => TestEntry( + id: json['id'] as String, + name: json['name'] as String, + enabled: json['enabled'] as bool? ?? true, + isBuiltIn: json['isBuiltIn'] as bool? ?? false, + ); + + TestEntry copyWith({bool? enabled}) => TestEntry( + id: id, + name: name, + enabled: enabled ?? this.enabled, + isBuiltIn: isBuiltIn, + ); +} + +List _createDefaults() => const [ + TestEntry(id: 'default-1', name: 'Default One', isBuiltIn: true), + TestEntry(id: 'default-2', name: 'Default Two', isBuiltIn: true), +]; + +void main() { + late ServiceRegistry registry; + late int changeCount; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + changeCount = 0; + registry = ServiceRegistry( + prefsKey: 'test_entries', + fromJson: TestEntry.fromJson, + createDefaults: _createDefaults, + onChanged: () => changeCount++, + ); + }); + + group('load', () { + test('fresh install creates and persists defaults', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].id, 'default-1'); + expect(registry.entries[1].id, 'default-2'); + + // Verify persisted + expect(prefs.containsKey('test_entries'), isTrue); + }); + + test('existing valid JSON loads entries', () async { + final existing = [ + const TestEntry(id: 'custom', name: 'Custom'), + const TestEntry(id: 'default-1', name: 'D1', isBuiltIn: true), + const TestEntry(id: 'default-2', name: 'D2', isBuiltIn: true), + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(3)); + expect(registry.entries[0].id, 'custom'); + }); + + test('corrupted JSON falls back to defaults', () async { + SharedPreferences.setMockInitialValues({ + 'test_entries': 'not valid json!!!', + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].id, 'default-1'); + }); + + test('adds missing built-in entries to existing list', () async { + final existing = [ + const TestEntry(id: 'custom', name: 'Custom'), + const TestEntry(id: 'default-1', name: 'D1', isBuiltIn: true), + // default-2 is missing + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.entries, hasLength(3)); + expect(registry.entries[2].id, 'default-2'); + }); + }); + + group('addOrUpdate', () { + test('new entry appended to end', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'new-one', name: 'New One'), + ); + + expect(registry.entries, hasLength(3)); + expect(registry.entries.last.id, 'new-one'); + expect(changeCount, 1); + }); + + test('existing entry replaced in-place preserving position', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'default-1', name: 'Updated Name', isBuiltIn: true), + ); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].name, 'Updated Name'); + expect(changeCount, 1); + }); + + test('persists changes', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'new-one', name: 'New'), + ); + + // Re-load from prefs to verify persistence + final registry2 = ServiceRegistry( + prefsKey: 'test_entries', + fromJson: TestEntry.fromJson, + createDefaults: _createDefaults, + onChanged: () {}, + ); + await registry2.load(prefs); + expect(registry2.entries, hasLength(3)); + expect(registry2.entries.last.id, 'new-one'); + }); + }); + + group('delete', () { + test('removes entry and persists', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + // Add a custom entry first so we have 3 + await registry.addOrUpdate( + const TestEntry(id: 'custom', name: 'Custom'), + ); + changeCount = 0; + + await registry.delete('custom'); + + expect(registry.entries, hasLength(2)); + expect(registry.findById('custom'), isNull); + expect(changeCount, 1); + }); + + test('throws StateError on last entry', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + // Remove one default so only one remains + await registry.delete('default-2'); + + await expectLater( + () => registry.delete('default-1'), + throwsA(isA()), + ); + }); + + test('no-op for nonexistent id', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.delete('nonexistent'); + expect(registry.entries, hasLength(2)); + expect(changeCount, 0); + }); + }); + + group('reorder', () { + test('moves entry from first to last', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'third', name: 'Third'), + ); + changeCount = 0; + + await registry.reorder(0, 3); // Move index 0 to after index 2 + + expect(registry.entries[0].id, 'default-2'); + expect(registry.entries[1].id, 'third'); + expect(registry.entries[2].id, 'default-1'); + expect(changeCount, 1); + }); + + test('same index is no-op', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.reorder(0, 0); + + expect(changeCount, 0); + }); + + test('persists new order', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.reorder(0, 2); + + // Re-load + final registry2 = ServiceRegistry( + prefsKey: 'test_entries', + fromJson: TestEntry.fromJson, + createDefaults: _createDefaults, + onChanged: () {}, + ); + await registry2.load(prefs); + expect(registry2.entries[0].id, 'default-2'); + expect(registry2.entries[1].id, 'default-1'); + }); + }); + + group('resetToDefaults', () { + test('replaces all entries with defaults', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'custom', name: 'Custom'), + ); + changeCount = 0; + + await registry.resetToDefaults(); + + expect(registry.entries, hasLength(2)); + expect(registry.entries[0].id, 'default-1'); + expect(changeCount, 1); + }); + + test('custom entries removed', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + await registry.addOrUpdate( + const TestEntry(id: 'custom-1', name: 'C1'), + ); + await registry.addOrUpdate( + const TestEntry(id: 'custom-2', name: 'C2'), + ); + + await registry.resetToDefaults(); + + expect(registry.findById('custom-1'), isNull); + expect(registry.findById('custom-2'), isNull); + }); + }); + + group('enabledEntries', () { + test('filters disabled entries', () async { + // Include the defaults (which load will check for) plus custom entries + final existing = [ + const TestEntry(id: 'default-1', name: 'D1', enabled: true, isBuiltIn: true), + const TestEntry(id: 'default-2', name: 'D2', enabled: false, isBuiltIn: true), + const TestEntry(id: 'c', name: 'C', enabled: true), + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + final enabled = registry.enabledEntries; + expect(enabled, hasLength(2)); + expect(enabled[0].id, 'default-1'); + expect(enabled[1].id, 'c'); + }); + + test('returns empty list when all disabled', () async { + final existing = [ + const TestEntry(id: 'default-1', name: 'D1', enabled: false, isBuiltIn: true), + const TestEntry(id: 'default-2', name: 'D2', enabled: false, isBuiltIn: true), + ]; + SharedPreferences.setMockInitialValues({ + 'test_entries': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.enabledEntries, isEmpty); + }); + }); + + group('findById', () { + test('returns entry when found', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.findById('default-1')?.name, 'Default One'); + }); + + test('returns null when not found', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect(registry.findById('nonexistent'), isNull); + }); + }); + + group('entries list immutability', () { + test('entries returns unmodifiable list', () async { + final prefs = await SharedPreferences.getInstance(); + await registry.load(prefs); + + expect( + () => registry.entries.add(const TestEntry(id: 'x', name: 'X')), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/state/settings_state_test.dart b/test/state/settings_state_test.dart index 8e56a242..a5535064 100644 --- a/test/state/settings_state_test.dart +++ b/test/state/settings_state_test.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:deflockapp/models/service_endpoint.dart'; import 'package:deflockapp/state/settings_state.dart'; import 'package:deflockapp/keys.dart'; @@ -70,4 +72,157 @@ void main() { expect(state.uploadMode, UploadMode.simulate); }); }); + + group('Endpoint registry', () { + test('fresh install loads defaults for both registries', () async { + final state = SettingsState(); + await state.init(); + + expect(state.routingEndpoints, hasLength(2)); + expect(state.routingEndpoints[0].id, 'routing-deflock'); + expect(state.overpassEndpoints, hasLength(2)); + expect(state.overpassEndpoints[0].id, 'overpass-deflock'); + }); + + test('registries return unmodifiable lists', () async { + final state = SettingsState(); + await state.init(); + + expect( + () => state.routingEndpoints.add( + const ServiceEndpoint(id: 'x', name: 'X', url: 'https://x.com'), + ), + throwsA(isA()), + ); + }); + + test('registry mutations fire notifications', () async { + final state = SettingsState(); + await state.init(); + + int notifyCount = 0; + state.addListener(() => notifyCount++); + + await state.routingRegistry.addOrUpdate( + const ServiceEndpoint(id: 'custom', name: 'Custom', url: 'https://custom.com'), + ); + expect(notifyCount, 1); + }); + + test('enabledRoutingEndpoints filters disabled entries', () async { + final state = SettingsState(); + await state.init(); + + // Disable the first routing endpoint + final first = state.routingEndpoints[0]; + await state.routingRegistry.addOrUpdate( + first.copyWith(enabled: false), + ); + + expect(state.enabledRoutingEndpoints, hasLength(1)); + expect(state.enabledRoutingEndpoints[0].id, 'routing-alprwatch'); + }); + }); + + group('Endpoint migration', () { + test('old routing_endpoint string migrates to registry list', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://custom.example.com/route', + }); + + final state = SettingsState(); + await state.init(); + + // Should have 3 endpoints: custom first, then 2 defaults + expect(state.routingEndpoints, hasLength(3)); + expect(state.routingEndpoints[0].url, 'https://custom.example.com/route'); + expect(state.routingEndpoints[0].name, 'Custom'); + expect(state.routingEndpoints[1].id, 'routing-deflock'); + + // Old key should be removed + final prefs = await SharedPreferences.getInstance(); + expect(prefs.containsKey('routing_endpoint'), isFalse); + }); + + test('old overpass_endpoint string migrates to registry list', () async { + SharedPreferences.setMockInitialValues({ + 'overpass_endpoint': 'https://custom-overpass.example.com/api/interpreter', + }); + + final state = SettingsState(); + await state.init(); + + expect(state.overpassEndpoints, hasLength(3)); + expect(state.overpassEndpoints[0].url, 'https://custom-overpass.example.com/api/interpreter'); + }); + + test('old key matching a default URL does not create duplicate', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://api.dontgetflocked.com/api/v1/deflock/directions', + }); + + final state = SettingsState(); + await state.init(); + + // Should just have the 2 defaults, no duplicate custom entry + expect(state.routingEndpoints, hasLength(2)); + }); + + test('no old key with no new key creates defaults', () async { + final state = SettingsState(); + await state.init(); + + expect(state.routingEndpoints, hasLength(2)); + expect(state.overpassEndpoints, hasLength(2)); + }); + + test('new format takes precedence over old format', () async { + final existing = [ + const ServiceEndpoint(id: 'custom', name: 'Existing', url: 'https://existing.com'), + ]; + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://old.example.com/route', + 'routing_endpoints': jsonEncode(existing.map((e) => e.toJson()).toList()), + }); + + final state = SettingsState(); + await state.init(); + + // New format should take precedence; old key is NOT migrated when new exists + // The registry should load existing + add missing defaults + expect(state.routingEndpoints[0].url, 'https://existing.com'); + }); + + test('migration is idempotent', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': 'https://custom.example.com/route', + }); + + final state1 = SettingsState(); + await state1.init(); + final count1 = state1.routingEndpoints.length; + + // Init again (simulating app restart) + final state2 = SettingsState(); + await state2.init(); + + expect(state2.routingEndpoints.length, count1); + }); + + test('empty old endpoint string does not create custom entry', () async { + SharedPreferences.setMockInitialValues({ + 'routing_endpoint': '', + }); + + final state = SettingsState(); + await state.init(); + + expect(state.routingEndpoints, hasLength(2)); + + // Old key should be cleaned up + final prefs = await SharedPreferences.getInstance(); + expect(prefs.containsKey('routing_endpoint'), isFalse); + }); + }); } + From 29b29dc4a24cad436a87acbc84473e62685099bd Mon Sep 17 00:00:00 2001 From: Doug Borg Date: Thu, 12 Mar 2026 10:49:49 -0600 Subject: [PATCH 4/6] Add vector tile support with parallel fetching and resilience improvements - Add OSM vector tile support with Stadia Maps and MapTiler providers - Parallelize quadrant fetching with smart 429 retry - Cancel stale Overpass fetch requests via generation counter - Add progressive rendering during parallel quadrant fetching - Cancel debounce timer in dispose to prevent post-disposal callbacks - Fix vector tile API key propagation and improve download UX --- build_keys.conf.example | 10 +- do_builds.sh | 11 +- lib/keys.dart | 8 +- lib/localizations/de.json | 7 +- lib/localizations/en.json | 7 +- lib/localizations/es.json | 7 +- lib/localizations/fr.json | 7 +- lib/localizations/it.json | 7 +- lib/localizations/nl.json | 7 +- lib/localizations/pl.json | 7 +- lib/localizations/pt.json | 7 +- lib/localizations/tr.json | 7 +- lib/localizations/uk.json | 7 +- lib/localizations/zh.json | 7 +- lib/models/tile_provider.dart | 100 ++- lib/screens/tile_provider_editor_screen.dart | 156 ++-- lib/services/map_data_provider.dart | 6 +- .../map_data_submodules/tiles_from_local.dart | 23 +- lib/services/node_data_manager.dart | 340 ++++++--- lib/services/node_spatial_cache.dart | 3 + lib/services/overpass_service.dart | 56 ++ lib/services/service_policy.dart | 19 +- lib/services/vector_style_service.dart | 305 ++++++++ lib/widgets/map/layer_selector_button.dart | 13 +- lib/widgets/map/map_data_manager.dart | 19 +- lib/widgets/map/tile_layer_manager.dart | 157 +++- lib/widgets/map_view.dart | 11 +- lib/widgets/node_provider_with_cache.dart | 22 +- pubspec.lock | 64 ++ pubspec.yaml | 4 +- test/models/tile_provider_test.dart | 142 +++- test/services/node_data_manager_test.dart | 713 ++++++++++++++++++ test/services/tiles_from_local_test.dart | 126 ++-- test/services/vector_style_service_test.dart | 64 ++ 34 files changed, 2163 insertions(+), 286 deletions(-) create mode 100644 lib/services/vector_style_service.dart create mode 100644 test/services/node_data_manager_test.dart create mode 100644 test/services/vector_style_service_test.dart diff --git a/build_keys.conf.example b/build_keys.conf.example index 5638a972..6441ae68 100644 --- a/build_keys.conf.example +++ b/build_keys.conf.example @@ -1,10 +1,14 @@ -# Local OSM client ID configuration for builds +# Local build key configuration # Copy this file to build_keys.conf and fill in your values # This file is gitignored to keep your keys secret # # Get your client IDs from: -# Production: https://www.openstreetmap.org/oauth2/applications +# Production: https://www.openstreetmap.org/oauth2/applications # Sandbox: https://master.apis.dev.openstreetmap.org/oauth2/applications OSM_PROD_CLIENTID=your_production_client_id_here -OSM_SANDBOX_CLIENTID=your_sandbox_client_id_here \ No newline at end of file +OSM_SANDBOX_CLIENTID=your_sandbox_client_id_here + +# Optional: Stadia Maps API key for vector tiles +# Get a free key from https://stadiamaps.com +STADIA_API_KEY= \ No newline at end of file diff --git a/do_builds.sh b/do_builds.sh index 94dbac67..c0b880b7 100755 --- a/do_builds.sh +++ b/do_builds.sh @@ -62,9 +62,10 @@ if [ ! -f "build_keys.conf" ]; then exit 1 fi -echo "Loading OSM client IDs from build_keys.conf..." +echo "Loading keys from build_keys.conf..." OSM_PROD_CLIENTID=$(read_from_file "OSM_PROD_CLIENTID") OSM_SANDBOX_CLIENTID=$(read_from_file "OSM_SANDBOX_CLIENTID") +STADIA_API_KEY=$(read_from_file "STADIA_API_KEY") # Check required keys if [ -z "$OSM_PROD_CLIENTID" ]; then @@ -80,6 +81,14 @@ fi # Build the dart-define arguments DART_DEFINE_ARGS="--dart-define=OSM_PROD_CLIENTID=$OSM_PROD_CLIENTID --dart-define=OSM_SANDBOX_CLIENTID=$OSM_SANDBOX_CLIENTID" +# Optional keys +if [ -n "$STADIA_API_KEY" ]; then + DART_DEFINE_ARGS="$DART_DEFINE_ARGS --dart-define=STADIA_API_KEY=$STADIA_API_KEY" + echo " Stadia Maps API key: configured" +else + echo " Stadia Maps API key: not set (vector tiles will require manual key entry)" +fi + # Run tests before building echo "Running tests..." flutter test || exit 1 diff --git a/lib/keys.dart b/lib/keys.dart index 7c193815..f4f84bc3 100644 --- a/lib/keys.dart +++ b/lib/keys.dart @@ -1,5 +1,4 @@ -// OpenStreetMap OAuth client IDs for this app. -// These must be provided via --dart-define at build time. +// Build-time API keys, provided via --dart-define. /// Whether OSM OAuth secrets were provided at build time. /// When false, the app should force simulate mode. @@ -17,4 +16,7 @@ String get kOsmProdClientId { String get kOsmSandboxClientId { const fromBuild = String.fromEnvironment('OSM_SANDBOX_CLIENTID'); return fromBuild; -} \ No newline at end of file +} + +// Stadia Maps API key (optional — vector tiles won't load without it). +const kStadiaApiKey = String.fromEnvironment('STADIA_API_KEY'); diff --git a/lib/localizations/de.json b/lib/localizations/de.json index a1bf7dd5..b3f8653a 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -300,7 +300,12 @@ "fetchPreview": "Vorschau Laden", "previewTileLoaded": "Vorschau-Kachel erfolgreich geladen", "previewTileFailed": "Vorschau laden fehlgeschlagen: {}", - "save": "Speichern" + "save": "Speichern", + "rasterTiles": "Raster", + "vectorTiles": "Vektor", + "styleUrl": "Stil-URL", + "styleUrlHint": "https://beispiel.com/style.json", + "styleUrlRequired": "Stil-URL ist erforderlich" }, "profiles": { "nodeProfiles": "Knoten-Profile", diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 98627ae1..9e4e5883 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -337,7 +337,12 @@ "fetchPreview": "Fetch Preview", "previewTileLoaded": "Preview tile loaded successfully", "previewTileFailed": "Failed to fetch preview: {}", - "save": "Save" + "save": "Save", + "rasterTiles": "Raster", + "vectorTiles": "Vector", + "styleUrl": "Style URL", + "styleUrlHint": "https://example.com/style.json", + "styleUrlRequired": "Style URL is required" }, "profiles": { "nodeProfiles": "Node Profiles", diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 9e86cd8c..70f28a38 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -337,7 +337,12 @@ "fetchPreview": "Obtener Vista Previa", "previewTileLoaded": "Tile de vista previa cargado exitosamente", "previewTileFailed": "Falló al obtener vista previa: {}", - "save": "Guardar" + "save": "Guardar", + "rasterTiles": "Ráster", + "vectorTiles": "Vectorial", + "styleUrl": "URL de estilo", + "styleUrlHint": "https://ejemplo.com/style.json", + "styleUrlRequired": "La URL de estilo es obligatoria" }, "profiles": { "nodeProfiles": "Perfiles de Nodos", diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 79ea40ec..4755cf7e 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -337,7 +337,12 @@ "fetchPreview": "Récupérer Aperçu", "previewTileLoaded": "Tuile d'aperçu chargée avec succès", "previewTileFailed": "Échec de récupération de l'aperçu: {}", - "save": "Sauvegarder" + "save": "Sauvegarder", + "rasterTiles": "Raster", + "vectorTiles": "Vectoriel", + "styleUrl": "URL de style", + "styleUrlHint": "https://exemple.com/style.json", + "styleUrlRequired": "L'URL de style est requise" }, "profiles": { "nodeProfiles": "Profils de Nœuds", diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 3f904b4c..463bb10d 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -337,7 +337,12 @@ "fetchPreview": "Ottieni Anteprima", "previewTileLoaded": "Tile di anteprima caricato con successo", "previewTileFailed": "Impossibile ottenere l'anteprima: {}", - "save": "Salva" + "save": "Salva", + "rasterTiles": "Raster", + "vectorTiles": "Vettoriale", + "styleUrl": "URL dello stile", + "styleUrlHint": "https://esempio.com/style.json", + "styleUrlRequired": "L'URL dello stile è obbligatorio" }, "profiles": { "nodeProfiles": "Profili Nodo", diff --git a/lib/localizations/nl.json b/lib/localizations/nl.json index d7102c7e..3e5024e2 100644 --- a/lib/localizations/nl.json +++ b/lib/localizations/nl.json @@ -337,7 +337,12 @@ "fetchPreview": "Haal Voorbeeld Op", "previewTileLoaded": "Voorbeeld tile succesvol geladen", "previewTileFailed": "Kon voorbeeld niet ophalen: {}", - "save": "Opslaan" + "save": "Opslaan", + "rasterTiles": "Raster", + "vectorTiles": "Vector", + "styleUrl": "Stijl-URL", + "styleUrlHint": "https://voorbeeld.com/style.json", + "styleUrlRequired": "Stijl-URL is vereist" }, "profiles": { "nodeProfiles": "Node Profielen", diff --git a/lib/localizations/pl.json b/lib/localizations/pl.json index 1508569f..e3e89fb7 100644 --- a/lib/localizations/pl.json +++ b/lib/localizations/pl.json @@ -337,7 +337,12 @@ "fetchPreview": "Pobierz Podgląd", "previewTileLoaded": "Kafelek podglądu załadowany pomyślnie", "previewTileFailed": "Nie udało się pobrać podglądu: {}", - "save": "Zapisz" + "save": "Zapisz", + "rasterTiles": "Rastrowe", + "vectorTiles": "Wektorowe", + "styleUrl": "URL stylu", + "styleUrlHint": "https://przyklad.com/style.json", + "styleUrlRequired": "URL stylu jest wymagany" }, "profiles": { "nodeProfiles": "Profile Węzłów", diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 6c7fee34..5ffbca75 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -337,7 +337,12 @@ "fetchPreview": "Buscar Preview", "previewTileLoaded": "Tile de preview carregado com sucesso", "previewTileFailed": "Falha ao buscar preview: {}", - "save": "Salvar" + "save": "Salvar", + "rasterTiles": "Raster", + "vectorTiles": "Vetorial", + "styleUrl": "URL do estilo", + "styleUrlHint": "https://exemplo.com/style.json", + "styleUrlRequired": "URL do estilo é obrigatório" }, "profiles": { "nodeProfiles": "Perfis de Nó", diff --git a/lib/localizations/tr.json b/lib/localizations/tr.json index e139d567..fa258441 100644 --- a/lib/localizations/tr.json +++ b/lib/localizations/tr.json @@ -337,7 +337,12 @@ "fetchPreview": "Önizleme Getir", "previewTileLoaded": "Önizleme döşemesi başarıyla yüklendi", "previewTileFailed": "Önizleme getirilemedi: {}", - "save": "Kaydet" + "save": "Kaydet", + "rasterTiles": "Raster", + "vectorTiles": "Vektör", + "styleUrl": "Stil URL'si", + "styleUrlHint": "https://ornek.com/style.json", + "styleUrlRequired": "Stil URL'si gerekli" }, "profiles": { "nodeProfiles": "Düğüm Profilleri", diff --git a/lib/localizations/uk.json b/lib/localizations/uk.json index c427c8a5..af90b4f2 100644 --- a/lib/localizations/uk.json +++ b/lib/localizations/uk.json @@ -337,7 +337,12 @@ "fetchPreview": "Отримати Попередній Перегляд", "previewTileLoaded": "Плитка попереднього перегляду успішно завантажена", "previewTileFailed": "Не вдалося отримати попередній перегляд: {}", - "save": "Зберегти" + "save": "Зберегти", + "rasterTiles": "Растрові", + "vectorTiles": "Векторні", + "styleUrl": "URL стилю", + "styleUrlHint": "https://приклад.com/style.json", + "styleUrlRequired": "URL стилю є обов'язковим" }, "profiles": { "nodeProfiles": "Профілі Вузлів", diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index 857a5a62..b19e11b7 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -337,7 +337,12 @@ "fetchPreview": "获取预览", "previewTileLoaded": "预览瓦片加载成功", "previewTileFailed": "获取预览失败:{}", - "save": "保存" + "save": "保存", + "rasterTiles": "栅格", + "vectorTiles": "矢量", + "styleUrl": "样式 URL", + "styleUrlHint": "https://example.com/style.json", + "styleUrlRequired": "样式 URL 为必填项" }, "profiles": { "nodeProfiles": "节点配置文件", diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index 5304d33e..fee452d5 100644 --- a/lib/models/tile_provider.dart +++ b/lib/models/tile_provider.dart @@ -1,8 +1,18 @@ import 'dart:convert'; import 'dart:typed_data'; +import '../keys.dart'; import '../services/service_policy.dart'; +/// Placeholder token in URL templates that gets replaced with the actual API key. +const kApiKeyPlaceholder = '{api_key}'; + +/// Whether a tile type serves raster (PNG/JPEG) or vector (style JSON) tiles. +enum TileSourceType { + rasterXyz, + vectorStyle, +} + /// A specific tile type within a provider class TileType { final String id; @@ -11,6 +21,8 @@ class TileType { final String attribution; final Uint8List? previewTile; // Single tile image data for preview final int maxZoom; // Maximum zoom level for this tile type + final TileSourceType sourceType; + final String? styleUrl; // Vector style JSON URL (for vectorStyle types) TileType({ required this.id, @@ -19,8 +31,16 @@ class TileType { required this.attribution, this.previewTile, this.maxZoom = 18, // Default max zoom level + this.sourceType = TileSourceType.rasterXyz, + this.styleUrl, }); + /// Whether this tile type uses vector tiles. + bool get isVector => sourceType == TileSourceType.vectorStyle; + + /// Whether this tile type uses raster tiles. + bool get isRaster => sourceType == TileSourceType.rasterXyz; + /// Create URL for a specific tile, replacing template variables /// /// Supported placeholders: @@ -56,7 +76,7 @@ class TileType { .replaceAll('{y}', y.toString()); if (apiKey != null && apiKey.isNotEmpty) { - url = url.replaceAll('{api_key}', apiKey); + url = url.replaceAll(kApiKeyPlaceholder, apiKey); } return url; @@ -76,7 +96,7 @@ class TileType { } /// Check if this tile type needs an API key - bool get requiresApiKey => urlTemplate.contains('{api_key}'); + bool get requiresApiKey => urlTemplate.contains(kApiKeyPlaceholder); /// The service policy that applies to this tile type's server. /// Cached because [urlTemplate] is immutable. @@ -84,8 +104,10 @@ class TileType { ServicePolicyResolver.resolve(urlTemplate); /// Whether this tile server's usage policy permits offline/bulk downloading. - /// Resolved via [ServicePolicyResolver] from the URL template. - bool get allowsOfflineDownload => servicePolicy.allowsOfflineDownload; + /// Always false for vector tile types (offline download not yet supported). + /// For raster types, resolved via [ServicePolicyResolver] from the URL template. + bool get allowsOfflineDownload => + isVector ? false : servicePolicy.allowsOfflineDownload; Map toJson() => { 'id': id, @@ -94,18 +116,33 @@ class TileType { 'attribution': attribution, 'previewTile': previewTile != null ? base64Encode(previewTile!) : null, 'maxZoom': maxZoom, + if (sourceType != TileSourceType.rasterXyz) + 'sourceType': sourceType.name, + if (styleUrl != null) 'styleUrl': styleUrl, }; - static TileType fromJson(Map json) => TileType( - id: json['id'], - name: json['name'], - urlTemplate: json['urlTemplate'], - attribution: json['attribution'], - previewTile: json['previewTile'] != null - ? base64Decode(json['previewTile']) - : null, - maxZoom: json['maxZoom'] ?? 18, // Default to 18 if not specified - ); + static TileType fromJson(Map json) { + final sourceTypeName = json['sourceType'] as String?; + final sourceType = sourceTypeName != null + ? TileSourceType.values.firstWhere( + (e) => e.name == sourceTypeName, + orElse: () => TileSourceType.rasterXyz, + ) + : TileSourceType.rasterXyz; + + return TileType( + id: json['id'], + name: json['name'], + urlTemplate: json['urlTemplate'], + attribution: json['attribution'], + previewTile: json['previewTile'] != null + ? base64Decode(json['previewTile']) + : null, + maxZoom: json['maxZoom'] ?? 18, + sourceType: sourceType, + styleUrl: json['styleUrl'], + ); + } TileType copyWith({ String? id, @@ -114,6 +151,8 @@ class TileType { String? attribution, Uint8List? previewTile, int? maxZoom, + TileSourceType? sourceType, + String? styleUrl, }) => TileType( id: id ?? this.id, name: name ?? this.name, @@ -121,6 +160,8 @@ class TileType { attribution: attribution ?? this.attribution, previewTile: previewTile ?? this.previewTile, maxZoom: maxZoom ?? this.maxZoom, + sourceType: sourceType ?? this.sourceType, + styleUrl: styleUrl ?? this.styleUrl, ); @override @@ -256,6 +297,37 @@ class DefaultTileProviders { ), ], ), + TileProvider( + id: 'stadiamaps_vector', + name: 'Stadia Maps (Vector)', + apiKey: kStadiaApiKey.isNotEmpty ? kStadiaApiKey : null, + tileTypes: [ + TileType( + id: 'stadia_osm_bright', + name: 'OSM Bright', + urlTemplate: 'https://tiles.stadiamaps.com/styles/osm_bright.json?api_key={api_key}', + attribution: '© Stadia Maps © OpenMapTiles © OpenStreetMap contributors', + maxZoom: 20, + sourceType: TileSourceType.vectorStyle, + styleUrl: 'https://tiles.stadiamaps.com/styles/osm_bright.json?api_key={api_key}', + ), + ], + ), + TileProvider( + id: 'maptiler_vector', + name: 'MapTiler (Vector)', + tileTypes: [ + TileType( + id: 'maptiler_streets', + name: 'Streets', + urlTemplate: 'https://api.maptiler.com/maps/streets-v2/style.json?key={api_key}', + attribution: '© MapTiler © OpenStreetMap contributors', + maxZoom: 20, + sourceType: TileSourceType.vectorStyle, + styleUrl: 'https://api.maptiler.com/maps/streets-v2/style.json?key={api_key}', + ), + ], + ), ]; } } diff --git a/lib/screens/tile_provider_editor_screen.dart b/lib/screens/tile_provider_editor_screen.dart index d8337001..ff698c93 100644 --- a/lib/screens/tile_provider_editor_screen.dart +++ b/lib/screens/tile_provider_editor_screen.dart @@ -127,7 +127,9 @@ class _TileProviderEditorScreenState extends State { subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(tileType.urlTemplate), + Text(tileType.isVector + ? tileType.styleUrl ?? tileType.urlTemplate + : tileType.urlTemplate), Text( tileType.attribution, style: Theme.of(context).textTheme.bodySmall, @@ -261,6 +263,8 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { late final TextEditingController _urlController; late final TextEditingController _attributionController; late final TextEditingController _maxZoomController; + late final TextEditingController _styleUrlController; + late TileSourceType _sourceType; Uint8List? _previewTile; bool _isLoadingPreview = false; @@ -272,6 +276,8 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { _urlController = TextEditingController(text: tileType?.urlTemplate ?? ''); _attributionController = TextEditingController(text: tileType?.attribution ?? ''); _maxZoomController = TextEditingController(text: (tileType?.maxZoom ?? 18).toString()); + _styleUrlController = TextEditingController(text: tileType?.styleUrl ?? ''); + _sourceType = tileType?.sourceType ?? TileSourceType.rasterXyz; _previewTile = tileType?.previewTile; } @@ -281,6 +287,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { _urlController.dispose(); _attributionController.dispose(); _maxZoomController.dispose(); + _styleUrlController.dispose(); super.dispose(); } @@ -291,6 +298,8 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { builder: (context, child) { final locService = LocalizationService.instance; + final isVector = _sourceType == TileSourceType.vectorStyle; + return AlertDialog( title: Text(widget.tileType != null ? locService.t('tileTypeEditor.editTileType') : locService.t('tileTypeEditor.addTileType')), content: SizedBox( @@ -300,6 +309,26 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { child: Column( mainAxisSize: MainAxisSize.min, children: [ + // Source type selector + SegmentedButton( + segments: [ + ButtonSegment( + value: TileSourceType.rasterXyz, + label: Text(locService.t('tileTypeEditor.rasterTiles')), + icon: const Icon(Icons.grid_on), + ), + ButtonSegment( + value: TileSourceType.vectorStyle, + label: Text(locService.t('tileTypeEditor.vectorTiles')), + icon: const Icon(Icons.route), + ), + ], + selected: {_sourceType}, + onSelectionChanged: (selected) { + setState(() => _sourceType = selected.first); + }, + ), + const SizedBox(height: 16), TextFormField( controller: _nameController, decoration: InputDecoration( @@ -309,26 +338,43 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { validator: (value) => value?.trim().isEmpty == true ? locService.t('tileTypeEditor.nameRequired') : null, ), const SizedBox(height: 16), - TextFormField( - controller: _urlController, - decoration: InputDecoration( - labelText: locService.t('tileTypeEditor.urlTemplate'), - hintText: locService.t('tileTypeEditor.urlTemplateHint'), + if (isVector) ...[ + // Vector: show style URL field + TextFormField( + controller: _styleUrlController, + decoration: InputDecoration( + labelText: locService.t('tileTypeEditor.styleUrl'), + hintText: locService.t('tileTypeEditor.styleUrlHint'), + ), + validator: (value) { + if (value?.trim().isEmpty == true) { + return locService.t('tileTypeEditor.styleUrlRequired'); + } + return null; + }, ), - validator: (value) { - if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.urlTemplateRequired'); - - // Check for either quadkey OR x+y+z placeholders - final hasQuadkey = value!.contains('{quadkey}'); - final hasXYZ = value.contains('{x}') && value.contains('{y}') && value.contains('{z}'); - - if (!hasQuadkey && !hasXYZ) { - return locService.t('tileTypeEditor.urlTemplatePlaceholders'); - } - - return null; - }, - ), + ] else ...[ + // Raster: show URL template field + TextFormField( + controller: _urlController, + decoration: InputDecoration( + labelText: locService.t('tileTypeEditor.urlTemplate'), + hintText: locService.t('tileTypeEditor.urlTemplateHint'), + ), + validator: (value) { + if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.urlTemplateRequired'); + + final hasQuadkey = value!.contains('{quadkey}'); + final hasXYZ = value.contains('{x}') && value.contains('{y}') && value.contains('{z}'); + + if (!hasQuadkey && !hasXYZ) { + return locService.t('tileTypeEditor.urlTemplatePlaceholders'); + } + + return null; + }, + ), + ], const SizedBox(height: 16), TextFormField( controller: _attributionController, @@ -354,32 +400,34 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { return null; }, ), - const SizedBox(height: 16), - Row( - children: [ - TextButton.icon( - onPressed: _isLoadingPreview ? null : _fetchPreviewTile, - icon: _isLoadingPreview - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.preview), - label: Text(locService.t('tileTypeEditor.fetchPreview')), - ), - const SizedBox(width: 8), - if (_previewTile != null) - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - ), - child: Image.memory(_previewTile!, fit: BoxFit.cover), + if (!isVector) ...[ + const SizedBox(height: 16), + Row( + children: [ + TextButton.icon( + onPressed: _isLoadingPreview ? null : _fetchPreviewTile, + icon: _isLoadingPreview + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.preview), + label: Text(locService.t('tileTypeEditor.fetchPreview')), ), - ], - ), + const SizedBox(width: 8), + if (_previewTile != null) + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + ), + child: Image.memory(_previewTile!, fit: BoxFit.cover), + ), + ], + ), + ], ], ), ), @@ -456,16 +504,26 @@ class _TileTypeDialogState extends State<_TileTypeDialog> { void _saveTileType() { if (!_formKey.currentState!.validate()) return; - final tileTypeId = widget.tileType?.id ?? + final tileTypeId = widget.tileType?.id ?? '${_nameController.text.toLowerCase().replaceAll(' ', '_')}_${DateTime.now().millisecondsSinceEpoch}'; - + + final isVector = _sourceType == TileSourceType.vectorStyle; + final styleUrl = isVector ? _styleUrlController.text.trim() : null; + // For vector types, use the style URL as the urlTemplate so + // ServicePolicyResolver can still extract the host. + final urlTemplate = isVector + ? (styleUrl ?? '') + : _urlController.text.trim(); + final tileType = TileType( id: tileTypeId, name: _nameController.text.trim(), - urlTemplate: _urlController.text.trim(), + urlTemplate: urlTemplate, attribution: _attributionController.text.trim(), - previewTile: _previewTile, + previewTile: isVector ? null : _previewTile, maxZoom: int.parse(_maxZoomController.text.trim()), + sourceType: _sourceType, + styleUrl: styleUrl, ); widget.onSave(tileType); diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 6f5e99b5..231de6ac 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -60,7 +60,11 @@ class MapDataProvider { throw OfflineModeException("Cannot fetch remote nodes for offline area download in offline mode."); } - // For downloads, always fetch fresh data (don't use cache) + // For downloads, always fetch fresh data (don't use cache). + // Note: passes null generation, so downloads are never cancelled by stale-fetch + // detection and will hold semaphore slots until complete. This is intentional — + // offline downloads should run to completion — but means concurrent downloads + // can block foreground map fetches via the shared semaphore. return _nodeDataManager.fetchWithSplitting(bounds, profiles); } diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart index 5113134d..92c2bb5f 100644 --- a/lib/services/map_data_submodules/tiles_from_local.dart +++ b/lib/services/map_data_submodules/tiles_from_local.dart @@ -58,29 +58,36 @@ Future> fetchLocalTile({ /// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds. /// -/// Uses the same Mercator projection math as [latLonToTile] in -/// offline_tile_utils.dart, but only computes the bounding tile range -/// instead of enumerating every tile at that zoom level. +/// Matches [computeTileList]'s inclusion rules: ±1 tile padding with clamping +/// to `[0, nTiles-1]`. This ensures tiles downloaded along boundary edges +/// (which include the padding) are found during offline lookups. /// /// Note: Y axis is inverted in tile coordinates — north = lower Y. @visibleForTesting bool tileInBounds(LatLngBounds bounds, int z, int x, int y) { - final n = pow(2.0, z); + final int nTiles = 1 << z; + final double n = nTiles.toDouble(); final west = bounds.west; final east = bounds.east; final north = bounds.north; final south = bounds.south; - final minX = ((west + 180.0) / 360.0 * n).floor(); - final maxX = ((east + 180.0) / 360.0 * n).floor(); + int minX = ((west + 180.0) / 360.0 * n).floor(); + int maxX = ((east + 180.0) / 360.0 * n).floor(); // North → lower Y (Mercator projection inverts latitude) - final minY = ((1.0 - log(tan(north * pi / 180.0) + + int minY = ((1.0 - log(tan(north * pi / 180.0) + 1.0 / cos(north * pi / 180.0)) / pi) / 2.0 * n).floor(); - final maxY = ((1.0 - log(tan(south * pi / 180.0) + + int maxY = ((1.0 - log(tan(south * pi / 180.0) + 1.0 / cos(south * pi / 180.0)) / pi) / 2.0 * n).floor(); + // Match computeTileList behavior: expand by ±1 tile and clamp to [0, nTiles - 1]. + minX = max(0, minX - 1); + maxX = min(nTiles - 1, maxX + 1); + minY = max(0, minY - 1); + maxY = min(nTiles - 1, maxY + 1); + return x >= minX && x <= maxX && y >= minY && y <= maxY; } diff --git a/lib/services/node_data_manager.dart b/lib/services/node_data_manager.dart index 2dbaeeb3..2f4e249d 100644 --- a/lib/services/node_data_manager.dart +++ b/lib/services/node_data_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'package:flutter/widgets.dart'; import 'package:latlong2/latlong.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -14,19 +15,107 @@ import 'map_data_submodules/nodes_from_local.dart'; import 'offline_area_service.dart'; import 'offline_areas/offline_area_models.dart'; +/// Resizable async semaphore for limiting concurrent Overpass requests. +class _AsyncSemaphore { + int _maxConcurrent; + int _current = 0; + final _waiters = Queue>(); + + _AsyncSemaphore(int maxConcurrent) : _maxConcurrent = maxConcurrent < 1 ? 1 : maxConcurrent; + + int get maxConcurrent => _maxConcurrent; + + /// Resize the semaphore. If capacity increased, wake up queued waiters. + void resize(int newMax) { + _maxConcurrent = newMax < 1 ? 1 : newMax; + // Wake exactly the number of newly available slots. + // Can't use _current in the loop condition because woken waiters + // haven't incremented it yet (their continuations are microtasks). + var available = _maxConcurrent - _current; + while (available > 0 && _waiters.isNotEmpty) { + _waiters.removeFirst().complete(); + available--; + } + } + + Future run(Future Function() fn) async { + while (_current >= _maxConcurrent) { + final completer = Completer(); + _waiters.add(completer); + await completer.future; + } + _current++; + try { + return await fn(); + } finally { + _current--; + if (_waiters.isNotEmpty && _current < _maxConcurrent) { + _waiters.removeFirst().complete(); + } + } + } +} + /// Coordinates node data fetching between cache, Overpass, and OSM API. /// Simple interface: give me nodes for this view with proper caching and error handling. class NodeDataManager extends ChangeNotifier { static final NodeDataManager _instance = NodeDataManager._(); factory NodeDataManager() => _instance; - NodeDataManager._(); - final OverpassService _overpassService = OverpassService(); - final NodeSpatialCache _cache = NodeSpatialCache(); - + NodeDataManager._({ + OverpassService? overpassService, + NodeSpatialCache? cache, + }) : _overpassService = overpassService ?? OverpassService(), + _cache = cache ?? NodeSpatialCache(); + + @visibleForTesting + factory NodeDataManager.forTesting({ + OverpassService? overpassService, + NodeSpatialCache? cache, + }) => NodeDataManager._(overpassService: overpassService, cache: cache); + + final OverpassService _overpassService; + final NodeSpatialCache _cache; + + // Concurrency limiter for Overpass requests + _AsyncSemaphore? _overpassSemaphore; + Future<_AsyncSemaphore>? _semaphoreInitFuture; + + // Generation counter for cancelling stale fetch requests. + // Each new getNodesFor() call increments this; queued work checks before proceeding. + int _fetchGeneration = 0; + int? _lastLoggedStaleGeneration; + + bool _isStale(int? generation) { + if (generation == null || generation == _fetchGeneration) return false; + if (_lastLoggedStaleGeneration != generation) { + _lastLoggedStaleGeneration = generation; + debugPrint('[NodeDataManager] Fetch generation $generation is stale ' + '(current: $_fetchGeneration), cancelling remaining work'); + } + return true; + } + + @visibleForTesting + void advanceFetchGeneration() => _fetchGeneration++; + + Future<_AsyncSemaphore> _getOrCreateSemaphore() { + return _semaphoreInitFuture ??= _createSemaphore().catchError((e, st) { + _semaphoreInitFuture = null; // Allow retry on next fetch + Error.throwWithStackTrace(e, st); + }); + } + + Future<_AsyncSemaphore> _createSemaphore() async { + final slots = await _overpassService.getSlotCount(); + _overpassSemaphore = _AsyncSemaphore(slots); + debugPrint('[NodeDataManager] Overpass semaphore: $slots slots'); + return _overpassSemaphore!; + } + // Track ongoing user-initiated requests for status reporting final Set _userInitiatedRequests = {}; - + /// Get nodes for the given bounds and profiles. /// Returns cached data immediately if available, otherwise fetches from appropriate source. Future> getNodesFor({ @@ -43,7 +132,7 @@ class NodeDataManager extends ChangeNotifier { if (isUserInitiated) { NetworkStatus.instance.clear(); } - + if (uploadMode == UploadMode.sandbox) { // Offline + Sandbox = no nodes (local cache is production data) debugPrint('[NodeDataManager] Offline + Sandbox mode: returning no nodes'); @@ -51,7 +140,7 @@ class NodeDataManager extends ChangeNotifier { } else { // Offline + Production = use local offline areas (instant) final offlineNodes = await fetchLocalNodes(bounds: bounds, profiles: profiles); - + // Add offline nodes to cache so they integrate with the rest of the system if (offlineNodes.isNotEmpty) { _cache.addOrUpdateNodes(offlineNodes); @@ -59,7 +148,7 @@ class NodeDataManager extends ChangeNotifier { _cache.markAreaAsFetched(bounds, offlineNodes); notifyListeners(); } - + // Show brief success for user-initiated offline loads with data if (isUserInitiated && offlineNodes.isNotEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -71,7 +160,7 @@ class NodeDataManager extends ChangeNotifier { NetworkStatus.instance.setNoData(); }); } - + return offlineNodes; } } @@ -79,15 +168,15 @@ class NodeDataManager extends ChangeNotifier { // Handle sandbox mode (always fetch from OSM API, but integrate with cache system for UI) if (uploadMode == UploadMode.sandbox) { debugPrint('[NodeDataManager] Sandbox mode: fetching from OSM API'); - + // Track user-initiated requests for status reporting final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode'; - + if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) { debugPrint('[NodeDataManager] Sandbox request already in progress for this area'); return _cache.getNodesFor(bounds); } - + // Start status tracking for user-initiated requests if (isUserInitiated) { _userInitiatedRequests.add(requestKey); @@ -96,7 +185,7 @@ class NodeDataManager extends ChangeNotifier { } else { debugPrint('[NodeDataManager] Starting background sandbox request (no status reporting)'); } - + try { final nodes = await fetchOsmApiNodes( bounds: bounds, @@ -104,7 +193,7 @@ class NodeDataManager extends ChangeNotifier { uploadMode: uploadMode, maxResults: 0, ); - + // Add nodes to cache for UI integration (even though we don't rely on cache for subsequent fetches) if (nodes.isNotEmpty) { _cache.addOrUpdateNodes(nodes); @@ -113,10 +202,10 @@ class NodeDataManager extends ChangeNotifier { // Mark area as fetched even with no nodes so UI knows we've checked this area _cache.markAreaAsFetched(bounds, []); } - + // Update UI notifyListeners(); - + // Set success after the next frame renders, but only for user-initiated requests if (isUserInitiated) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -124,12 +213,12 @@ class NodeDataManager extends ChangeNotifier { }); debugPrint('[NodeDataManager] User-initiated sandbox request completed successfully: ${nodes.length} nodes'); } - + return nodes; - + } catch (e) { debugPrint('[NodeDataManager] Sandbox fetch failed: $e'); - + // Only report errors for user-initiated requests if (isUserInitiated) { if (e is RateLimitError) { @@ -141,7 +230,7 @@ class NodeDataManager extends ChangeNotifier { } debugPrint('[NodeDataManager] User-initiated sandbox request failed: $e'); } - + // Return whatever we have in cache for this area (likely empty for sandbox) return _cache.getNodesFor(bounds); } finally { @@ -159,13 +248,13 @@ class NodeDataManager extends ChangeNotifier { // Not cached - need to fetch final requestKey = '${bounds.hashCode}_${profiles.map((p) => p.id).join('_')}_$uploadMode'; - + // Only allow one user-initiated request per area at a time if (isUserInitiated && _userInitiatedRequests.contains(requestKey)) { debugPrint('[NodeDataManager] User request already in progress for this area'); return _cache.getNodesFor(bounds); } - + // Start status tracking for user-initiated requests only if (isUserInitiated) { _userInitiatedRequests.add(requestKey); @@ -175,12 +264,19 @@ class NodeDataManager extends ChangeNotifier { debugPrint('[NodeDataManager] Starting background request (no status reporting)'); } + final generation = ++_fetchGeneration; try { - final nodes = await fetchWithSplitting(bounds, profiles, isUserInitiated: isUserInitiated); - + final nodes = await fetchWithSplitting(bounds, profiles, + isUserInitiated: isUserInitiated, generation: generation); + + // If this fetch became stale (user panned away), skip UI updates + if (_isStale(generation)) { + return _cache.getNodesFor(bounds); + } + // Update cache and notify listeners notifyListeners(); - + // Set success after the next frame renders, but only for user-initiated requests if (isUserInitiated) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -188,14 +284,14 @@ class NodeDataManager extends ChangeNotifier { }); debugPrint('[NodeDataManager] User-initiated request completed successfully'); } - + return nodes; - + } catch (e) { debugPrint('[NodeDataManager] Fetch failed: $e'); - - // Only report errors for user-initiated requests - if (isUserInitiated) { + + // Skip error reporting for stale requests + if (isUserInitiated && !_isStale(generation)) { if (e is RateLimitError) { NetworkStatus.instance.setRateLimited(); } else if (e.toString().contains('timeout')) { @@ -205,7 +301,7 @@ class NodeDataManager extends ChangeNotifier { } debugPrint('[NodeDataManager] User-initiated request failed: $e'); } - + // Return whatever we have in cache for this area return _cache.getNodesFor(bounds); } finally { @@ -215,90 +311,143 @@ class NodeDataManager extends ChangeNotifier { } } - /// Fetch nodes with automatic area splitting if needed + /// Fetch nodes with automatic area splitting if needed. + /// When [generation] is non-null, the request is cancelled if a newer + /// generation has started (user panned/zoomed away). Future> fetchWithSplitting( - LatLngBounds bounds, + LatLngBounds bounds, List profiles, { int splitDepth = 0, + int rateLimitRetries = 0, bool isUserInitiated = false, + int? generation, }) async { const maxSplitDepth = 3; // 4^3 = 64 max sub-areas - + + // Checkpoint 1: bail before entering semaphore + if (_isStale(generation)) return []; + try { // Expand bounds slightly to reduce edge effects - final expandedBounds = _expandBounds(bounds, 1.2); - - final nodes = await _overpassService.fetchNodes( - bounds: expandedBounds, - profiles: profiles, + final expandedBounds = splitDepth == 0 ? expandBounds(bounds, 1.2) : bounds; + + final semaphore = await _getOrCreateSemaphore(); + // Checkpoint 2: stale request woke from queue — don't make HTTP call + final nodes = await semaphore.run>( + () { + if (_isStale(generation)) return Future.value([]); + return _overpassService.fetchNodes( + bounds: expandedBounds, + profiles: profiles, + ); + }, ); - - // Success - cache the data for the expanded area - _cache.markAreaAsFetched(expandedBounds, nodes); + + // Cache real data even if stale (valid for if user pans back). + // Skip marking area if stale and got empty result (short-circuited). + if (nodes.isNotEmpty || !_isStale(generation)) { + _cache.markAreaAsFetched(expandedBounds, nodes); + if (nodes.isNotEmpty) { + notifyListeners(); // Progressive rendering: each quadrant renders immediately + } + } return nodes; - + } on NodeLimitError { // Hit node limit or timeout - split area if not too deep if (splitDepth >= maxSplitDepth) { debugPrint('[NodeDataManager] Max split depth reached, giving up'); return []; } - + + // Checkpoint 3: don't spawn 4 new sub-requests for stale fetch + if (_isStale(generation)) return []; + debugPrint('[NodeDataManager] Splitting area (depth: $splitDepth)'); - + // Only report splitting status for user-initiated requests if (isUserInitiated && splitDepth == 0) { NetworkStatus.instance.setSplitting(); } - - return _fetchSplitAreas(bounds, profiles, splitDepth + 1, isUserInitiated: isUserInitiated); - + + return _fetchSplitAreas(bounds, profiles, splitDepth + 1, + isUserInitiated: isUserInitiated, generation: generation); + } on RateLimitError { - // Rate limited - wait and return empty - debugPrint('[NodeDataManager] Rate limited, backing off'); - await Future.delayed(const Duration(seconds: 30)); - return []; + if (rateLimitRetries >= 2) { + debugPrint('[NodeDataManager] Max rate limit retries reached, giving up'); + return []; + } + + // Checkpoint 4: don't wait up to 2 minutes for a stale request + if (_isStale(generation)) return []; + + debugPrint('[NodeDataManager] Rate limited, polling for slot (retry ${rateLimitRetries + 1}/2)'); + if (isUserInitiated) NetworkStatus.instance.setRateLimited(); + + // Poll until slot available; resize semaphore with fresh slot count + final slots = await _overpassService.waitForSlot(); + + // Checkpoint 5: became stale during the wait + if (_isStale(generation)) return []; + + _overpassSemaphore?.resize(slots); + debugPrint('[NodeDataManager] Semaphore resized to $slots slots'); + + return fetchWithSplitting( + bounds, profiles, + splitDepth: splitDepth, + rateLimitRetries: rateLimitRetries + 1, + isUserInitiated: isUserInitiated, + generation: generation, + ); } } - /// Fetch data by splitting area into quadrants + /// Fetch data by splitting area into quadrants (parallel) Future> _fetchSplitAreas( - LatLngBounds bounds, + LatLngBounds bounds, List profiles, int splitDepth, { bool isUserInitiated = false, + int? generation, }) async { - final quadrants = _splitBounds(bounds); - final allNodes = []; - - for (final quadrant in quadrants) { - try { - final nodes = await fetchWithSplitting( - quadrant, - profiles, - splitDepth: splitDepth, - isUserInitiated: isUserInitiated, - ); - allNodes.addAll(nodes); - } catch (e) { - debugPrint('[NodeDataManager] Quadrant fetch failed: $e'); - // Continue with other quadrants - } - } - + // Checkpoint 6: don't spawn quadrants for stale tree + if (_isStale(generation)) return []; + + final quadrants = splitBounds(bounds); + + final results = await Future.wait( + quadrants.map((quadrant) async { + try { + return await fetchWithSplitting( + quadrant, profiles, + splitDepth: splitDepth, + isUserInitiated: isUserInitiated, + generation: generation, + ); + } catch (e) { + debugPrint('[NodeDataManager] Quadrant fetch failed: $e'); + return []; + } + }), + ); + + final allNodes = results.expand((nodes) => nodes).toList(); debugPrint('[NodeDataManager] Split fetch complete: ${allNodes.length} total nodes'); return allNodes; } /// Split bounds into 4 quadrants - List _splitBounds(LatLngBounds bounds) { + @visibleForTesting + static List splitBounds(LatLngBounds bounds) { final centerLat = (bounds.north + bounds.south) / 2; final centerLng = (bounds.east + bounds.west) / 2; - + return [ // Southwest LatLngBounds(LatLng(bounds.south, bounds.west), LatLng(centerLat, centerLng)), - // Southeast + // Southeast LatLngBounds(LatLng(bounds.south, centerLng), LatLng(centerLat, bounds.east)), // Northwest LatLngBounds(LatLng(centerLat, bounds.west), LatLng(bounds.north, centerLng)), @@ -307,20 +456,6 @@ class NodeDataManager extends ChangeNotifier { ]; } - /// Expand bounds by given factor around center point - LatLngBounds _expandBounds(LatLngBounds bounds, double factor) { - final centerLat = (bounds.north + bounds.south) / 2; - final centerLng = (bounds.east + bounds.west) / 2; - - final latSpan = (bounds.north - bounds.south) * factor / 2; - final lngSpan = (bounds.east - bounds.west) * factor / 2; - - return LatLngBounds( - LatLng(centerLat - latSpan, centerLng - lngSpan), - LatLng(centerLat + latSpan, centerLng + lngSpan), - ); - } - /// Add or update nodes in cache (for upload queue integration) void addOrUpdateNodes(List nodes) { _cache.addOrUpdateNodes(nodes); @@ -347,7 +482,7 @@ class NodeDataManager extends ChangeNotifier { }) async { // Clear any cached data for this area _cache.clear(); - + // Re-fetch as user-initiated request await getNodesFor( bounds: bounds, @@ -374,16 +509,16 @@ class NodeDataManager extends ChangeNotifier { Future preloadOfflineNodes() async { try { final offlineAreaService = OfflineAreaService(); - + for (final area in offlineAreaService.offlineAreas) { if (area.status != OfflineAreaStatus.complete) continue; - + // Load nodes from this offline area final nodes = await fetchLocalNodes( bounds: area.bounds, profiles: [], // Empty profiles = load all nodes ); - + if (nodes.isNotEmpty) { _cache.addOrUpdateNodes(nodes); // Mark the offline area as having coverage so submit buttons work @@ -391,7 +526,7 @@ class NodeDataManager extends ChangeNotifier { debugPrint('[NodeDataManager] Preloaded ${nodes.length} offline nodes from area ${area.name}'); } } - + notifyListeners(); } catch (e) { debugPrint('[NodeDataManager] Error preloading offline nodes: $e'); @@ -400,4 +535,19 @@ class NodeDataManager extends ChangeNotifier { /// Get cache statistics String get cacheStats => _cache.stats.toString(); -} \ No newline at end of file +} + +/// Expand bounds by given factor around center point. +/// Shared by [NodeDataManager] (fetch expansion) and [MapDataManager] (render expansion). +LatLngBounds expandBounds(LatLngBounds bounds, double factor) { + final centerLat = (bounds.north + bounds.south) / 2; + final centerLng = (bounds.east + bounds.west) / 2; + + final latSpan = (bounds.north - bounds.south) * factor / 2; + final lngSpan = (bounds.east - bounds.west) * factor / 2; + + return LatLngBounds( + LatLng(centerLat - latSpan, centerLng - lngSpan), + LatLng(centerLat + latSpan, centerLng + lngSpan), + ); +} diff --git a/lib/services/node_spatial_cache.dart b/lib/services/node_spatial_cache.dart index 35d90442..4e27fd90 100644 --- a/lib/services/node_spatial_cache.dart +++ b/lib/services/node_spatial_cache.dart @@ -13,6 +13,9 @@ class NodeSpatialCache { factory NodeSpatialCache() => _instance; NodeSpatialCache._(); + @visibleForTesting + NodeSpatialCache.forTesting(); + final List _fetchedAreas = []; final Map _nodes = {}; // nodeId -> node diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart index 0b882917..343b294e 100644 --- a/lib/services/overpass_service.dart +++ b/lib/services/overpass_service.dart @@ -17,6 +17,8 @@ import 'service_policy.dart'; class OverpassService { static const String defaultEndpoint = 'https://overpass.deflock.org/api/interpreter'; static const String fallbackEndpoint = 'https://overpass-api.de/api/interpreter'; + static const String _statusEndpoint = 'https://overpass-api.de/api/status'; + static const int defaultSlotCount = 4; static const _policy = ResiliencePolicy( maxRetries: 3, httpTimeout: Duration(seconds: 45), @@ -122,6 +124,60 @@ class OverpassService { return ErrorDisposition.retry; } + /// Query Overpass /api/status to get the rate limit (slot count per IP). + Future getSlotCount() async { + try { + final response = await _client.get(Uri.parse(_statusEndpoint)) + .timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final match = RegExp(r'Rate limit:\s*(\d+)').firstMatch(response.body); + if (match != null) return int.parse(match.group(1)!); + } + } catch (e) { + debugPrint('[OverpassService] Failed to get slot count: $e'); + } + return defaultSlotCount; + } + + /// Poll /api/status until a slot is available. Returns observed slot count. + Future waitForSlot({ + Duration maxWait = const Duration(minutes: 2), + Duration Function()? elapsedFn, + }) async { + final stopwatch = Stopwatch()..start(); + final elapsed = elapsedFn ?? () => stopwatch.elapsed; + int observedSlots = defaultSlotCount; + + while (elapsed() < maxWait) { + try { + final response = await _client.get(Uri.parse(_statusEndpoint)) + .timeout(const Duration(seconds: 5)); + + if (response.statusCode == 200) { + final slotMatch = RegExp(r'Rate limit:\s*(\d+)').firstMatch(response.body); + if (slotMatch != null) observedSlots = int.parse(slotMatch.group(1)!); + + if (response.body.contains('slots available now')) return observedSlots; + + final match = RegExp(r'in (\d+) seconds').firstMatch(response.body); + if (match != null) { + final wait = int.parse(match.group(1)!).clamp(1, 30); + debugPrint('[OverpassService] Waiting $wait seconds for slot'); + await Future.delayed(Duration(seconds: wait)); + continue; + } + } + } catch (e) { + debugPrint('[OverpassService] Status check failed: $e'); + } + + await Future.delayed(const Duration(seconds: 5)); + } + + debugPrint('[OverpassService] Max wait time exceeded, proceeding anyway'); + return observedSlots; + } + /// Build Overpass QL query for given bounds and profiles String _buildQuery(LatLngBounds bounds, List profiles) { final nodeClauses = profiles.map((profile) { diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index d57a8e9c..3fa8f42e 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../models/service_endpoint.dart'; +import '../models/tile_provider.dart' show kApiKeyPlaceholder; /// Identifies the type of external service being accessed. /// Used by [ServicePolicyResolver] to determine the correct compliance policy. @@ -16,6 +17,8 @@ enum ServiceType { // Third-party tile services bingTiles, // *.tiles.virtualearth.net mapboxTiles, // api.mapbox.com + stadiaTiles, // tiles.stadiamaps.com + maptilerTiles, // api.maptiler.com // Everything else custom, // user's own infrastructure / unknown @@ -130,6 +133,16 @@ class ServicePolicy { minCacheTtl = null, attributionUrl = null; + /// Vector tile services (Stadia Maps, MapTiler, etc.) + /// Concurrency managed by vector_map_tiles; offline download not yet supported. + const ServicePolicy.vectorTiles() + : maxConcurrentRequests = 0, // managed by vector_map_tiles + minRequestInterval = null, + allowsOfflineDownload = false, + requiresClientCaching = false, + minCacheTtl = null, + attributionUrl = null; + /// Custom/self-hosted service — permissive defaults. const ServicePolicy.custom({ int maxConcurrent = 8, @@ -170,6 +183,8 @@ class ServicePolicyResolver { 'taginfo.openstreetmap.org': ServiceType.tagInfo, 'tiles.virtualearth.net': ServiceType.bingTiles, 'api.mapbox.com': ServiceType.mapboxTiles, + 'tiles.stadiamaps.com': ServiceType.stadiaTiles, + 'api.maptiler.com': ServiceType.maptilerTiles, }; /// ServiceType → policy mapping. @@ -181,6 +196,8 @@ class ServicePolicyResolver { ServiceType.tagInfo: const ServicePolicy.tagInfo(), ServiceType.bingTiles: const ServicePolicy.bingTiles(), ServiceType.mapboxTiles: const ServicePolicy.mapboxTiles(), + ServiceType.stadiaTiles: const ServicePolicy.vectorTiles(), + ServiceType.maptilerTiles: const ServicePolicy.vectorTiles(), ServiceType.custom: const ServicePolicy(), }; @@ -234,7 +251,7 @@ class ServicePolicyResolver { .replaceAll(RegExp(r'\{z\}'), '0') .replaceAll(RegExp(r'\{x\}'), '0') .replaceAll(RegExp(r'\{y\}'), '0') - .replaceAll(RegExp(r'\{api_key\}'), 'key'); + .replaceAll(kApiKeyPlaceholder, 'key'); return Uri.parse(cleaned).host.toLowerCase(); } catch (_) { return null; diff --git a/lib/services/vector_style_service.dart b/lib/services/vector_style_service.dart new file mode 100644 index 00000000..da4ee9f1 --- /dev/null +++ b/lib/services/vector_style_service.dart @@ -0,0 +1,305 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:vector_map_tiles/vector_map_tiles.dart'; +import 'package:vector_tile_renderer/vector_tile_renderer.dart' hide TileLayer; + +import '../models/tile_provider.dart' show kApiKeyPlaceholder; +import 'http_client.dart'; + +/// Singleton service that loads and caches vector tile styles. +/// +/// Replaces [StyleReader] to properly propagate API keys to all sub-URLs +/// (source TileJSON endpoints, tile data URLs, sprite URLs, glyph URLs). +/// The upstream [StyleReader] only handles `{key}` token substitution for +/// non-Mapbox providers, which doesn't work for providers like Stadia Maps +/// that require query-parameter authentication on every request. +/// +/// Features: +/// - In-memory cache keyed by `styleUrl|apiKey` +/// - Deduplication of concurrent loads (same key returns same Future) +/// - `{api_key}` substitution in all URLs derived from the style JSON +/// - [evict] and [clear] methods for cache management +class VectorStyleService { + VectorStyleService._() : _httpClient = UserAgentClient(); + static final VectorStyleService instance = VectorStyleService._(); + + /// Shared HTTP client with User-Agent header for all style/tile/sprite requests. + final http.Client _httpClient; + + /// Cached styles keyed by `styleUrl|apiKey`. + final Map _cache = {}; + + /// In-flight load futures for deduplication. + final Map> _pending = {}; + + /// Load a vector tile style, returning a cached result if available. + /// + /// [styleUrl] is the style JSON URL (may contain `{api_key}`). + /// [apiKey] is substituted into the URL if present. + Future