Break up map_view monopoly

This commit is contained in:
stopflock
2025-12-02 20:26:43 -06:00
parent a0601cd6ae
commit 3d5edf320e
6 changed files with 757 additions and 354 deletions
+127
View File
@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import '../../models/osm_node.dart';
import '../../app_state.dart';
import '../camera_provider_with_cache.dart';
import '../../dev_config.dart';
/// Manages data fetching, filtering, and node limit logic for the map.
/// Handles profile changes, zoom level restrictions, and node rendering limits.
class MapDataManager {
// Track node limit state for parent notification
bool _lastNodeLimitState = false;
/// Get minimum zoom level for node fetching based on upload mode
int getMinZoomForNodes(UploadMode uploadMode) {
// OSM API (sandbox mode) needs higher zoom level due to bbox size limits
if (uploadMode == UploadMode.sandbox) {
return kOsmApiMinZoomLevel;
} else {
return kNodeMinZoomLevel;
}
}
/// Get nodes to render based on current map state
/// Returns a MapDataResult containing all relevant node data and limit state
MapDataResult getNodesForRendering({
required double currentZoom,
required LatLngBounds? mapBounds,
required UploadMode uploadMode,
required int maxNodes,
void Function(bool isLimited)? onNodeLimitChanged,
}) {
final minZoom = getMinZoomForNodes(uploadMode);
List<OsmNode> allNodes;
List<OsmNode> nodesToRender;
bool isLimitActive = false;
if (currentZoom >= minZoom) {
// Above minimum zoom - get cached nodes directly (no Provider needed)
allNodes = (mapBounds != null)
? CameraProviderWithCache.instance.getCachedNodesForBounds(mapBounds)
: <OsmNode>[];
// Filter out invalid coordinates before applying limit
final validNodes = allNodes.where((node) {
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
node.coord.latitude.abs() <= 90 &&
node.coord.longitude.abs() <= 180;
}).toList();
// Apply rendering limit to prevent UI lag
if (validNodes.length > maxNodes) {
nodesToRender = validNodes.take(maxNodes).toList();
isLimitActive = true;
debugPrint('[MapDataManager] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices');
} else {
nodesToRender = validNodes;
isLimitActive = false;
}
} else {
// Below minimum zoom - don't render any nodes
allNodes = <OsmNode>[];
nodesToRender = <OsmNode>[];
isLimitActive = false;
}
// Notify parent if limit state changed (for button disabling)
if (isLimitActive != _lastNodeLimitState) {
_lastNodeLimitState = isLimitActive;
// Schedule callback after build completes to avoid setState during build
WidgetsBinding.instance.addPostFrameCallback((_) {
onNodeLimitChanged?.call(isLimitActive);
});
}
return MapDataResult(
allNodes: allNodes,
nodesToRender: nodesToRender,
isLimitActive: isLimitActive,
validNodesCount: isLimitActive ? allNodes.where((node) {
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
node.coord.latitude.abs() <= 90 &&
node.coord.longitude.abs() <= 180;
}).length : 0,
);
}
/// Show zoom warning if user is below minimum zoom level
void showZoomWarningIfNeeded(BuildContext context, double currentZoom, UploadMode uploadMode) {
final minZoom = getMinZoomForNodes(uploadMode);
// Only show warning once per zoom level to avoid spam
if (currentZoom.floor() == (minZoom - 1)) {
final message = uploadMode == UploadMode.sandbox
? 'Zoom to level $minZoom or higher to see nodes in sandbox mode (OSM API bbox limit)'
: 'Zoom to level $minZoom or higher to see surveillance nodes';
// Show a brief snackbar
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
/// Result object containing node data and rendering state
class MapDataResult {
final List<OsmNode> allNodes;
final List<OsmNode> nodesToRender;
final bool isLimitActive;
final int validNodesCount;
const MapDataResult({
required this.allNodes,
required this.nodesToRender,
required this.isLimitActive,
required this.validNodesCount,
});
}
@@ -0,0 +1,57 @@
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../state/session_state.dart';
import '../../dev_config.dart';
/// Manages map interaction options and gesture handling logic.
/// Handles constrained node interactions, zoom restrictions, and gesture configuration.
class MapInteractionManager {
/// Get interaction options for the map based on whether we're editing a constrained node.
/// Allows zoom and rotation but disables all forms of panning for constrained nodes unless extract is enabled.
InteractionOptions getInteractionOptions(EditNodeSession? editSession) {
// Check if we're editing a constrained node that's not being extracted
if (editSession?.originalNode.isConstrained == true && editSession?.extractFromWay != true) {
// Constrained node (not extracting): only allow pinch zoom and rotation, disable ALL panning
return const InteractionOptions(
enableMultiFingerGestureRace: true,
flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate,
scrollWheelVelocity: kScrollWheelVelocity,
pinchZoomThreshold: kPinchZoomThreshold,
pinchMoveThreshold: kPinchMoveThreshold,
);
}
// Normal case: all interactions allowed with gesture race to prevent accidental rotation during zoom
return const InteractionOptions(
enableMultiFingerGestureRace: true,
flags: InteractiveFlag.doubleTapDragZoom |
InteractiveFlag.doubleTapZoom |
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchZoom |
InteractiveFlag.rotate |
InteractiveFlag.scrollWheelZoom,
scrollWheelVelocity: kScrollWheelVelocity,
pinchZoomThreshold: kPinchZoomThreshold,
pinchMoveThreshold: kPinchMoveThreshold,
);
}
/// Check if the map has moved significantly enough to cancel stale tile requests.
/// Uses a simple distance threshold - roughly equivalent to 1/4 screen width at zoom 15.
bool mapMovedSignificantly(LatLng? newCenter, LatLng? oldCenter) {
if (newCenter == null || oldCenter == null) return false;
// Calculate approximate distance in meters (rough calculation for performance)
final latDiff = (newCenter.latitude - oldCenter.latitude).abs();
final lngDiff = (newCenter.longitude - oldCenter.longitude).abs();
// Threshold: ~500 meters (roughly 1/4 screen at zoom 15)
// This prevents excessive cancellations on small movements while catching real pans
const double significantMovementThreshold = 0.005; // degrees (~500m at equator)
return latDiff > significantMovementThreshold || lngDiff > significantMovementThreshold;
}
}
+240
View File
@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import '../../models/osm_node.dart';
import '../../models/suspected_location.dart';
import '../../app_state.dart';
import '../../state/session_state.dart';
import '../../dev_config.dart';
import '../camera_icon.dart';
import '../provisional_pin.dart';
import 'camera_markers.dart';
import 'suspected_location_markers.dart';
/// Enumeration for different pin types in navigation
enum PinType { start, end }
/// Simple location pin widget for route visualization
class LocationPin extends StatelessWidget {
final PinType type;
const LocationPin({super.key, required this.type});
@override
Widget build(BuildContext context) {
return Container(
width: 32.0,
height: 32.0,
decoration: BoxDecoration(
color: type == PinType.start ? Colors.green : Colors.red,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
child: Icon(
type == PinType.start ? Icons.play_arrow : Icons.stop,
color: Colors.white,
size: 16,
),
);
}
}
/// Builds all marker layers for the map including surveillance nodes, suspected locations,
/// session markers, navigation pins, and route visualization.
class MarkerLayerBuilder {
/// Build complete marker layers for the map
static Widget buildMarkerLayers({
required List<OsmNode> nodesToRender,
required AnimatedMapController mapController,
required AppState appState,
required AddNodeSession? session,
required EditNodeSession? editSession,
required int? selectedNodeId,
required LatLng? userLocation,
required double currentZoom,
required LatLngBounds? mapBounds,
required Function(OsmNode)? onNodeTap,
required Function(SuspectedLocation)? onSuspectedLocationTap,
}) {
return LayoutBuilder(
builder: (context, constraints) {
// Determine if we should dim node markers (when suspected location is selected)
final shouldDimNodes = appState.selectedSuspectedLocation != null;
final markers = CameraMarkersBuilder.buildCameraMarkers(
cameras: nodesToRender,
mapController: mapController.mapController,
userLocation: userLocation,
selectedNodeId: selectedNodeId,
onNodeTap: onNodeTap,
shouldDim: shouldDimNodes,
);
// Build suspected location markers (respect same zoom and count limits as nodes)
final suspectedLocationMarkers = <Marker>[];
if (appState.suspectedLocationsEnabled && mapBounds != null &&
currentZoom >= (appState.uploadMode == UploadMode.sandbox ? kOsmApiMinZoomLevel : kNodeMinZoomLevel)) {
final suspectedLocations = appState.getSuspectedLocationsInBounds(
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,
),
);
}
// 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 start/end pins for route visualization
final routeMarkers = _buildRouteMarkers(appState);
return MarkerLayer(
markers: [
...suspectedLocationMarkers,
...markers,
...centerMarkers,
...navigationMarkers,
...routeMarkers,
]
);
},
);
}
/// Build center markers for add/edit sessions
static List<Marker> _buildSessionMarkers({
required AnimatedMapController mapController,
required AddNodeSession? session,
required EditNodeSession? editSession,
}) {
final centerMarkers = <Marker>[];
if (session != null || editSession != null) {
try {
final center = mapController.mapController.camera.center;
centerMarkers.add(
Marker(
point: center,
width: kNodeIconDiameter,
height: kNodeIconDiameter,
child: CameraIcon(
type: editSession != null ? CameraIconType.editing : CameraIconType.mock,
),
),
);
} catch (_) {
// Controller not ready yet
}
}
return centerMarkers;
}
/// Build provisional pin for navigation/search mode
static List<Marker> _buildNavigationMarkers(AppState appState) {
final markers = <Marker>[];
if (appState.showProvisionalPin && appState.provisionalPinLocation != null) {
markers.add(
Marker(
point: appState.provisionalPinLocation!,
width: 32.0,
height: 32.0,
child: const ProvisionalPin(),
),
);
}
return markers;
}
/// Build start/end pins for route visualization
static List<Marker> _buildRouteMarkers(AppState appState) {
final markers = <Marker>[];
if (appState.showingOverview || appState.isInRouteMode || appState.isSettingSecondPoint) {
if (appState.routeStart != null) {
markers.add(
Marker(
point: appState.routeStart!,
width: 32.0,
height: 32.0,
child: const LocationPin(type: PinType.start),
),
);
}
if (appState.routeEnd != null) {
markers.add(
Marker(
point: appState.routeEnd!,
width: 32.0,
height: 32.0,
child: const LocationPin(type: PinType.end),
),
);
}
}
return markers;
}
/// Filter suspected locations that are too close to real nodes
static List<SuspectedLocation> _filterSuspectedLocationsByProximity({
required List<SuspectedLocation> suspectedLocations,
required List<OsmNode> realNodes,
required int minDistance, // in meters
}) {
if (minDistance <= 0) return suspectedLocations;
const distance = Distance();
final filteredLocations = <SuspectedLocation>[];
for (final suspected in suspectedLocations) {
bool tooClose = false;
for (final realNode in realNodes) {
final distanceMeters = distance.as(
LengthUnit.Meter,
suspected.centroid,
realNode.coord,
);
if (distanceMeters < minDistance) {
tooClose = true;
break;
}
}
if (!tooClose) {
filteredLocations.add(suspected);
}
}
return filteredLocations;
}
}
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/osm_node.dart';
import '../../app_state.dart';
import '../../state/session_state.dart';
import '../../dev_config.dart';
import 'direction_cones.dart';
/// Builds overlay layers including direction cones, edit lines, selected location bounds, and route paths.
class OverlayLayerBuilder {
/// Build all overlay layers for the map
static List<Widget> buildOverlayLayers({
required List<OsmNode> nodesToRender,
required double currentZoom,
required AddNodeSession? session,
required EditNodeSession? editSession,
required AppState appState,
required BuildContext context,
}) {
final layers = <Widget>[];
// Direction cones (polygons)
final overlays = DirectionConesBuilder.buildDirectionCones(
cameras: nodesToRender,
zoom: currentZoom,
session: session,
editSession: editSession,
context: context,
);
// Add suspected location bounds if one is selected
if (appState.selectedSuspectedLocation != null) {
final selectedLocation = appState.selectedSuspectedLocation!;
if (selectedLocation.bounds.isNotEmpty) {
overlays.add(
Polygon(
points: selectedLocation.bounds,
color: Colors.orange.withOpacity(0.3),
borderColor: Colors.orange,
borderStrokeWidth: 2.0,
),
);
}
}
// Add polygon layer
layers.add(PolygonLayer(polygons: overlays));
// Build edit lines connecting original nodes to their edited positions
final editLines = _buildEditLines(nodesToRender);
if (editLines.isNotEmpty) {
layers.add(PolylineLayer(polylines: editLines));
}
// Build route path visualization
final routeLines = _buildRouteLines(appState);
if (routeLines.isNotEmpty) {
layers.add(PolylineLayer(polylines: routeLines));
}
return layers;
}
/// Build polylines connecting original cameras to their edited positions
static List<Polyline> _buildEditLines(List<OsmNode> nodes) {
final lines = <Polyline>[];
// Create a lookup map of original node IDs to their coordinates
final originalNodes = <int, LatLng>{};
for (final node in nodes) {
if (node.tags['_pending_edit'] == 'true') {
originalNodes[node.id] = node.coord;
}
}
// Find edited nodes and draw lines to their originals
for (final node in nodes) {
final originalIdStr = node.tags['_original_node_id'];
if (originalIdStr != null && node.tags['_pending_upload'] == 'true') {
final originalId = int.tryParse(originalIdStr);
final originalCoord = originalId != null ? originalNodes[originalId] : null;
if (originalCoord != null) {
lines.add(Polyline(
points: [originalCoord, node.coord],
color: kNodeRingColorPending,
strokeWidth: 3.0,
));
}
}
}
return lines;
}
/// Build route path visualization
static List<Polyline> _buildRouteLines(AppState appState) {
final routeLines = <Polyline>[];
if (appState.routePath != null && appState.routePath!.length > 1) {
// Show route line during overview or active route
if (appState.showingOverview || appState.isInRouteMode) {
routeLines.add(Polyline(
points: appState.routePath!,
color: Colors.blue,
strokeWidth: 4.0,
));
}
}
return routeLines;
}
}