mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
Break up map_view monopoly
This commit is contained in:
127
lib/widgets/map/map_data_manager.dart
Normal file
127
lib/widgets/map/map_data_manager.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
57
lib/widgets/map/map_interaction_manager.dart
Normal file
57
lib/widgets/map/map_interaction_manager.dart
Normal file
@@ -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
lib/widgets/map/marker_layer_builder.dart
Normal file
240
lib/widgets/map/marker_layer_builder.dart
Normal 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
lib/widgets/map/overlay_layer_builder.dart
Normal file
114
lib/widgets/map/overlay_layer_builder.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,17 @@ import '../models/tile_provider.dart';
|
||||
import '../state/session_state.dart';
|
||||
import 'debouncer.dart';
|
||||
import 'camera_provider_with_cache.dart';
|
||||
import 'camera_icon.dart';
|
||||
import 'map/camera_markers.dart';
|
||||
import 'map/direction_cones.dart';
|
||||
import 'map/map_overlays.dart';
|
||||
import 'map/map_position_manager.dart';
|
||||
import 'map/tile_layer_manager.dart';
|
||||
import 'map/camera_refresh_controller.dart';
|
||||
import 'map/gps_controller.dart';
|
||||
import 'map/suspected_location_markers.dart';
|
||||
import 'map/map_data_manager.dart';
|
||||
import 'map/map_interaction_manager.dart';
|
||||
import 'map/marker_layer_builder.dart';
|
||||
import 'map/overlay_layer_builder.dart';
|
||||
import 'network_status_indicator.dart';
|
||||
import 'node_limit_indicator.dart';
|
||||
import 'provisional_pin.dart';
|
||||
import 'proximity_alert_banner.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../app_state.dart' show FollowMeMode;
|
||||
@@ -72,6 +71,8 @@ class MapViewState extends State<MapView> {
|
||||
late final TileLayerManager _tileManager;
|
||||
late final CameraRefreshController _cameraController;
|
||||
late final GpsController _gpsController;
|
||||
late final MapDataManager _dataManager;
|
||||
late final MapInteractionManager _interactionManager;
|
||||
|
||||
// Track zoom to clear queue on zoom changes
|
||||
double? _lastZoom;
|
||||
@@ -79,9 +80,6 @@ class MapViewState extends State<MapView> {
|
||||
// Track map center to clear queue on significant panning
|
||||
LatLng? _lastCenter;
|
||||
|
||||
// Track node limit state for parent notification
|
||||
bool _lastNodeLimitState = false;
|
||||
|
||||
// State for proximity alert banner
|
||||
bool _showProximityBanner = false;
|
||||
|
||||
@@ -98,6 +96,8 @@ class MapViewState extends State<MapView> {
|
||||
_cameraController = CameraRefreshController();
|
||||
_cameraController.initialize(onCamerasUpdated: _onNodesUpdated);
|
||||
_gpsController = GpsController();
|
||||
_dataManager = MapDataManager();
|
||||
_interactionManager = MapInteractionManager();
|
||||
|
||||
// Initialize proximity alert service
|
||||
ProximityAlertService().initialize(
|
||||
@@ -236,88 +236,7 @@ class MapViewState extends State<MapView> {
|
||||
static Future<void> clearStoredMapPosition() =>
|
||||
MapPositionManager.clearStoredMapPosition();
|
||||
|
||||
/// Get minimum zoom level for node fetching based on upload mode
|
||||
int _getMinZoomForNodes(BuildContext context) {
|
||||
final appState = context.read<AppState>();
|
||||
final uploadMode = appState.uploadMode;
|
||||
|
||||
// OSM API (sandbox mode) needs higher zoom level due to bbox size limits
|
||||
if (uploadMode == UploadMode.sandbox) {
|
||||
return kOsmApiMinZoomLevel;
|
||||
} else {
|
||||
return kNodeMinZoomLevel;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show zoom warning if user is below minimum zoom level
|
||||
void _showZoomWarningIfNeeded(BuildContext context, double currentZoom, int minZoom) {
|
||||
// Only show warning once per zoom level to avoid spam
|
||||
if (currentZoom.floor() == (minZoom - 1)) {
|
||||
final appState = context.read<AppState>();
|
||||
final uploadMode = appState.uploadMode;
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _refreshNodesFromProvider() {
|
||||
@@ -409,205 +328,48 @@ class MapViewState extends State<MapView> {
|
||||
mapBounds = null;
|
||||
}
|
||||
|
||||
final minZoom = _getMinZoomForNodes(context);
|
||||
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
|
||||
final maxNodes = appState.maxNodes;
|
||||
if (validNodes.length > maxNodes) {
|
||||
nodesToRender = validNodes.take(maxNodes).toList();
|
||||
isLimitActive = true;
|
||||
debugPrint('[MapView] 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((_) {
|
||||
widget.onNodeLimitChanged?.call(isLimitActive);
|
||||
});
|
||||
}
|
||||
// Get node data using the data manager
|
||||
final nodeData = _dataManager.getNodesForRendering(
|
||||
currentZoom: currentZoom,
|
||||
mapBounds: mapBounds,
|
||||
uploadMode: appState.uploadMode,
|
||||
maxNodes: appState.maxNodes,
|
||||
onNodeLimitChanged: widget.onNodeLimitChanged,
|
||||
);
|
||||
|
||||
// Build camera layers using the limited nodes
|
||||
Widget cameraLayers = 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: _controller.mapController,
|
||||
userLocation: _gpsController.currentLocation,
|
||||
selectedNodeId: widget.selectedNodeId,
|
||||
onNodeTap: widget.onNodeTap,
|
||||
shouldDim: shouldDimNodes,
|
||||
);
|
||||
|
||||
// Build suspected location markers (respect same zoom and count limits as nodes)
|
||||
final suspectedLocationMarkers = <Marker>[];
|
||||
if (appState.suspectedLocationsEnabled && mapBounds != null && currentZoom >= minZoom) {
|
||||
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: _controller.mapController,
|
||||
selectedLocationId: appState.selectedSuspectedLocation?.ticketNo,
|
||||
onLocationTap: widget.onSuspectedLocationTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Get current zoom level for direction cones (already have currentZoom)
|
||||
try {
|
||||
currentZoom = _controller.mapController.camera.zoom;
|
||||
} catch (_) {
|
||||
// Controller not ready yet, use fallback
|
||||
}
|
||||
|
||||
final overlays = DirectionConesBuilder.buildDirectionCones(
|
||||
cameras: nodesToRender,
|
||||
zoom: currentZoom,
|
||||
// Build all marker layers
|
||||
final markerLayer = MarkerLayerBuilder.buildMarkerLayers(
|
||||
nodesToRender: nodeData.nodesToRender,
|
||||
mapController: _controller,
|
||||
appState: appState,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
selectedNodeId: widget.selectedNodeId,
|
||||
userLocation: _gpsController.currentLocation,
|
||||
currentZoom: currentZoom,
|
||||
mapBounds: mapBounds,
|
||||
onNodeTap: widget.onNodeTap,
|
||||
onSuspectedLocationTap: widget.onSuspectedLocationTap,
|
||||
);
|
||||
|
||||
// Build all overlay layers
|
||||
final overlayLayers = OverlayLayerBuilder.buildOverlayLayers(
|
||||
nodesToRender: nodeData.nodesToRender,
|
||||
currentZoom: currentZoom,
|
||||
session: session,
|
||||
editSession: editSession,
|
||||
appState: appState,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build edit lines connecting original nodes to their edited positions
|
||||
final editLines = _buildEditLines(nodesToRender);
|
||||
|
||||
// Build center marker for add/edit sessions
|
||||
final centerMarkers = <Marker>[];
|
||||
if (session != null || editSession != null) {
|
||||
try {
|
||||
final center = _controller.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
|
||||
}
|
||||
}
|
||||
|
||||
// Build provisional pin for navigation/search mode
|
||||
if (appState.showProvisionalPin && appState.provisionalPinLocation != null) {
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: appState.provisionalPinLocation!,
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: const ProvisionalPin(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Build start/end pins for route visualization
|
||||
if (appState.showingOverview || appState.isInRouteMode || appState.isSettingSecondPoint) {
|
||||
if (appState.routeStart != null) {
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: appState.routeStart!,
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: const LocationPin(type: PinType.start),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (appState.routeEnd != null) {
|
||||
centerMarkers.add(
|
||||
Marker(
|
||||
point: appState.routeEnd!,
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: const LocationPin(type: PinType.end),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Build route path visualization
|
||||
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 Stack(
|
||||
children: [
|
||||
PolygonLayer(polygons: overlays),
|
||||
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
|
||||
if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines),
|
||||
MarkerLayer(markers: [...suspectedLocationMarkers, ...markers, ...centerMarkers]),
|
||||
...overlayLayers,
|
||||
markerLayer,
|
||||
|
||||
// Node limit indicator (top-left) - shown when limit is active
|
||||
Builder(
|
||||
@@ -617,13 +379,9 @@ class MapViewState extends State<MapView> {
|
||||
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||
|
||||
return NodeLimitIndicator(
|
||||
isActive: isLimitActive,
|
||||
renderedCount: nodesToRender.length,
|
||||
totalCount: 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,
|
||||
isActive: nodeData.isLimitActive,
|
||||
renderedCount: nodeData.nodesToRender.length,
|
||||
totalCount: nodeData.validNodesCount,
|
||||
top: 8.0 + searchBarOffset,
|
||||
left: 8.0,
|
||||
);
|
||||
@@ -646,7 +404,7 @@ class MapViewState extends State<MapView> {
|
||||
initialZoom: _positionManager.initialZoom ?? 15,
|
||||
minZoom: 1.0,
|
||||
maxZoom: (appState.selectedTileType?.maxZoom ?? 18).toDouble(),
|
||||
interactionOptions: _getInteractionOptions(editSession),
|
||||
interactionOptions: _interactionManager.getInteractionOptions(editSession),
|
||||
onPositionChanged: (pos, gesture) {
|
||||
setState(() {}); // Instant UI update for zoom, etc.
|
||||
if (gesture) {
|
||||
@@ -711,7 +469,7 @@ class MapViewState extends State<MapView> {
|
||||
final currentTileLevel = currentZoom.round();
|
||||
final lastTileLevel = _lastZoom?.round();
|
||||
final tileLevelChanged = lastTileLevel != null && currentTileLevel != lastTileLevel;
|
||||
final centerMoved = _mapMovedSignificantly(currentCenter, _lastCenter);
|
||||
final centerMoved = _interactionManager.mapMovedSignificantly(currentCenter, _lastCenter);
|
||||
|
||||
if (tileLevelChanged || centerMoved) {
|
||||
_tileDebounce(() {
|
||||
@@ -735,13 +493,13 @@ class MapViewState extends State<MapView> {
|
||||
});
|
||||
|
||||
// Request more nodes on any map movement/zoom at valid zoom level (slower debounce)
|
||||
final minZoom = _getMinZoomForNodes(context);
|
||||
final minZoom = _dataManager.getMinZoomForNodes(appState.uploadMode);
|
||||
if (pos.zoom >= minZoom) {
|
||||
_cameraDebounce(_refreshNodesFromProvider);
|
||||
} else {
|
||||
// Skip nodes at low zoom - no loading state needed
|
||||
// Show zoom warning if needed
|
||||
_showZoomWarningIfNeeded(context, pos.zoom, minZoom);
|
||||
_dataManager.showZoomWarningIfNeeded(context, pos.zoom, appState.uploadMode);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -788,7 +546,7 @@ class MapViewState extends State<MapView> {
|
||||
builder: (context) {
|
||||
// Calculate position based on node limit indicator presence and search bar
|
||||
final searchBarOffset = (!appState.offlineMode && appState.isInSearchMode) ? 60.0 : 0.0;
|
||||
final nodeLimitOffset = isLimitActive ? 48.0 : 0.0; // Height of node limit indicator + spacing
|
||||
final nodeLimitOffset = nodeData.isLimitActive ? 48.0 : 0.0; // Height of node limit indicator + spacing
|
||||
|
||||
return NetworkStatusIndicator(
|
||||
top: 8.0 + searchBarOffset + nodeLimitOffset,
|
||||
@@ -810,71 +568,5 @@ class MapViewState extends State<MapView> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build polylines connecting original cameras to their edited positions
|
||||
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;
|
||||
}
|
||||
|
||||
/// Filter suspected locations that are too close to real nodes
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user