Fix node edge blinking, prevent nav+edit conflicts, smarter follow-me w/rt nav

This commit is contained in:
stopflock
2025-12-05 15:27:01 -06:00
parent 6cda350f22
commit 24214e94f9
10 changed files with 281 additions and 21 deletions
+3
View File
@@ -93,6 +93,9 @@ const Duration kDebounceCameraRefresh = Duration(milliseconds: 500);
// Pre-fetch area configuration
const double kPreFetchAreaExpansionMultiplier = 3.0; // Expand visible bounds by this factor for pre-fetching
const double kNodeRenderingBoundsExpansion = 1.3; // Expand visible bounds by this factor for node rendering to prevent edge blinking
const double kRouteProximityThresholdMeters = 500.0; // Distance threshold for determining if user is near route when resuming navigation
const double kResumeNavigationZoomLevel = 16.0; // Zoom level when resuming navigation
const int kPreFetchZoomLevel = 10; // Always pre-fetch at this zoom level for consistent area sizes
const int kMaxPreFetchSplitDepth = 3; // Maximum recursive splits when hitting Overpass node limit
@@ -3,12 +3,14 @@ import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../app_state.dart' show AppState, FollowMeMode;
import '../../widgets/map_view.dart';
import '../../dev_config.dart';
/// Coordinates all navigation and routing functionality including route planning,
/// map centering, zoom management, and route visualization.
class NavigationCoordinator {
FollowMeMode? _previousFollowMeMode; // Track follow-me mode before overview
/// Start a route with automatic follow-me detection and appropriate centering
void startRoute({
@@ -56,8 +58,7 @@ class NavigationCoordinator {
// Hide the overview
appState.hideRouteOverview();
// Zoom and center for resumed route
// For resume, we always center on user if GPS is available, otherwise start pin
// Get user location to determine centering and follow-me behavior
LatLng? userLocation;
try {
userLocation = mapViewKey?.currentState?.getUserLocation();
@@ -65,12 +66,53 @@ class NavigationCoordinator {
debugPrint('[NavigationCoordinator] Could not get user location for route resume: $e');
}
_zoomAndCenterForRoute(
mapController: mapController,
followMeEnabled: appState.followMeMode != FollowMeMode.off, // Use current follow-me state
userLocation: userLocation,
routeStart: appState.routeStart,
);
// Determine if user is near the route path
bool isNearRoute = false;
if (userLocation != null && appState.routePath != null) {
isNearRoute = _isUserNearRoute(userLocation, appState.routePath!);
}
// Choose center point and follow-me behavior
LatLng centerPoint;
bool shouldEnableFollowMe = false;
if (isNearRoute && userLocation != null) {
// User is near route - center on GPS and enable follow-me
centerPoint = userLocation;
shouldEnableFollowMe = true;
debugPrint('[NavigationCoordinator] User near route - centering on GPS with follow-me');
} else {
// User far from route or no GPS - center on route start
centerPoint = appState.routeStart ?? userLocation ?? LatLng(0, 0);
shouldEnableFollowMe = false;
debugPrint('[NavigationCoordinator] User far from route - centering on start without follow-me');
}
// Apply the centering and zoom
try {
mapController.animateTo(
dest: centerPoint,
zoom: kResumeNavigationZoomLevel,
duration: const Duration(milliseconds: 800),
curve: Curves.easeOut,
);
} catch (e) {
debugPrint('[NavigationCoordinator] Could not animate to resume location: $e');
}
// Set follow-me mode based on proximity
if (shouldEnableFollowMe) {
// Restore previous follow-me mode if user is near route
final modeToRestore = _previousFollowMeMode ?? FollowMeMode.follow;
appState.setFollowMeMode(modeToRestore);
debugPrint('[NavigationCoordinator] Restored follow-me mode: $modeToRestore');
} else {
// Keep follow-me off if user is far from route
debugPrint('[NavigationCoordinator] Keeping follow-me off - user far from route');
}
// Clear stored follow-me mode
_previousFollowMeMode = null;
}
/// Handle navigation button press with route overview logic
@@ -82,6 +124,9 @@ class NavigationCoordinator {
if (appState.showRouteButton) {
// Route button - show route overview and zoom to show route
// Store current follow-me mode and disable it to prevent unexpected map jumps during overview
_previousFollowMeMode = appState.followMeMode;
appState.setFollowMeMode(FollowMeMode.off);
appState.showRouteOverview();
zoomToShowFullRoute(appState: appState, mapController: mapController);
} else {
@@ -146,6 +191,20 @@ class NavigationCoordinator {
}
}
/// Check if user location is near the route path
bool _isUserNearRoute(LatLng userLocation, List<LatLng> routePath) {
if (routePath.isEmpty) return false;
// Check distance to each point in the route path
for (final routePoint in routePath) {
final distance = const Distance().as(LengthUnit.Meter, userLocation, routePoint);
if (distance <= kRouteProximityThresholdMeters) {
return true;
}
}
return false;
}
/// Internal method to zoom and center for route start/resume
void _zoomAndCenterForRoute({
required AnimatedMapController mapController,
+2 -2
View File
@@ -291,7 +291,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
mapController: _mapController,
onSelectedNodeChanged: (id) => setState(() => _selectedNodeId = id),
);
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => Padding(
padding: EdgeInsets.only(
@@ -348,7 +348,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
location: location,
mapController: _mapController,
);
final controller = _scaffoldKey.currentState!.showBottomSheet(
(ctx) => Padding(
padding: EdgeInsets.only(
+22 -4
View File
@@ -24,6 +24,21 @@ class MapDataManager {
}
}
/// Expand bounds by the given multiplier, maintaining center point.
/// Used to expand rendering bounds to prevent nodes blinking at screen edges.
LatLngBounds _expandBounds(LatLngBounds bounds, double multiplier) {
final centerLat = (bounds.north + bounds.south) / 2;
final centerLng = (bounds.east + bounds.west) / 2;
final latSpan = (bounds.north - bounds.south) * multiplier / 2;
final lngSpan = (bounds.east - bounds.west) * multiplier / 2;
return LatLngBounds(
LatLng(centerLat - latSpan, centerLng - lngSpan),
LatLng(centerLat + latSpan, centerLng + lngSpan),
);
}
/// Get nodes to render based on current map state
/// Returns a MapDataResult containing all relevant node data and limit state
MapDataResult getNodesForRendering({
@@ -39,10 +54,13 @@ class MapDataManager {
bool isLimitActive = false;
if (currentZoom >= minZoom) {
// Above minimum zoom - get cached nodes directly (no Provider needed)
allNodes = (mapBounds != null)
? NodeProviderWithCache.instance.getCachedNodesForBounds(mapBounds)
: <OsmNode>[];
// Above minimum zoom - get cached nodes with expanded bounds to prevent edge blinking
if (mapBounds != null) {
final expandedBounds = _expandBounds(mapBounds, kNodeRenderingBoundsExpansion);
allNodes = NodeProviderWithCache.instance.getCachedNodesForBounds(expandedBounds);
} else {
allNodes = <OsmNode>[];
}
// Filter out invalid coordinates before applying limit
final validNodes = allNodes.where((node) {
+12 -4
View File
@@ -62,16 +62,22 @@ class MarkerLayerBuilder {
return LayoutBuilder(
builder: (context, constraints) {
// Determine if we should dim node markers (when suspected location is selected)
final shouldDimNodes = appState.selectedSuspectedLocation != null;
// 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,
userLocation: userLocation,
selectedNodeId: selectedNodeId,
onNodeTap: onNodeTap,
onNodeTap: onNodeTap, // Keep the original callback
shouldDim: shouldDimNodes,
enabled: !shouldDisableNodeTaps, // Use enabled parameter instead
);
// Build suspected location markers (respect same zoom and count limits as nodes)
@@ -101,7 +107,9 @@ class MarkerLayerBuilder {
locations: filteredSuspectedLocations,
mapController: mapController.mapController,
selectedLocationId: appState.selectedSuspectedLocation?.ticketNo,
onLocationTap: onSuspectedLocationTap,
onLocationTap: onSuspectedLocationTap, // Keep the original callback
shouldDimAll: shouldDisableNodeTaps,
enabled: !shouldDisableNodeTaps, // Use enabled parameter instead
),
);
}
+8
View File
@@ -13,11 +13,13 @@ class NodeMapMarker extends StatefulWidget {
final OsmNode node;
final MapController mapController;
final void Function(OsmNode)? onNodeTap;
final bool enabled;
const NodeMapMarker({
required this.node,
required this.mapController,
this.onNodeTap,
this.enabled = true,
Key? key,
}) : super(key: key);
@@ -31,6 +33,8 @@ class _NodeMapMarkerState extends State<NodeMapMarker> {
static const Duration tapTimeout = kMarkerTapTimeout;
void _onTap() {
if (!widget.enabled) return; // Don't respond to taps when disabled
_tapTimer = Timer(tapTimeout, () {
// Don't center immediately - let the sheet opening handle the coordinated animation
@@ -48,6 +52,8 @@ class _NodeMapMarkerState extends State<NodeMapMarker> {
}
void _onDoubleTap() {
if (!widget.enabled) return; // Don't respond to double taps when disabled
_tapTimer?.cancel();
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
}
@@ -96,6 +102,7 @@ class NodeMarkersBuilder {
int? selectedNodeId,
void Function(OsmNode)? onNodeTap,
bool shouldDim = false,
bool enabled = true,
}) {
final markers = <Marker>[
// Node markers
@@ -116,6 +123,7 @@ class NodeMarkersBuilder {
node: n,
mapController: mapController,
onNodeTap: onNodeTap,
enabled: enabled,
),
),
);
@@ -13,11 +13,13 @@ class SuspectedLocationMapMarker extends StatefulWidget {
final SuspectedLocation location;
final MapController mapController;
final void Function(SuspectedLocation)? onLocationTap;
final bool enabled;
const SuspectedLocationMapMarker({
required this.location,
required this.mapController,
this.onLocationTap,
this.enabled = true,
Key? key,
}) : super(key: key);
@@ -31,6 +33,8 @@ class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker>
static const Duration tapTimeout = kMarkerTapTimeout;
void _onTap() {
if (!widget.enabled) return; // Don't respond to taps when disabled
_tapTimer = Timer(tapTimeout, () {
// Use callback if provided, otherwise fallback to direct modal
if (widget.onLocationTap != null) {
@@ -46,6 +50,8 @@ class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker>
}
void _onDoubleTap() {
if (!widget.enabled) return; // Don't respond to double taps when disabled
_tapTimer?.cancel();
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
}
@@ -73,6 +79,8 @@ class SuspectedLocationMarkersBuilder {
required MapController mapController,
String? selectedLocationId,
void Function(SuspectedLocation)? onLocationTap,
bool shouldDimAll = false,
bool enabled = true,
}) {
final markers = <Marker>[];
@@ -81,7 +89,7 @@ class SuspectedLocationMarkersBuilder {
// Check if this location should be highlighted (selected) or dimmed
final isSelected = selectedLocationId == location.ticketNo;
final shouldDim = selectedLocationId != null && !isSelected;
final shouldDim = shouldDimAll || (selectedLocationId != null && !isSelected);
markers.add(
Marker(
@@ -94,6 +102,7 @@ class SuspectedLocationMarkersBuilder {
location: location,
mapController: mapController,
onLocationTap: onLocationTap,
enabled: enabled,
),
),
),