@@ -21,6 +21,19 @@ enum MapStyle {
2121}
2222
2323extension MapStyleExtension on MapStyle {
24+ /// Convert from stored string preference to MapStyle enum
25+ static MapStyle fromString (String value) {
26+ switch (value) {
27+ case 'light' :
28+ return MapStyle .light;
29+ case 'satellite' :
30+ return MapStyle .satellite;
31+ case 'dark' :
32+ default :
33+ return MapStyle .dark;
34+ }
35+ }
36+
2437 String get label {
2538 switch (this ) {
2639 case MapStyle .dark:
@@ -81,15 +94,18 @@ final class SilentCancellableNetworkTileProvider extends CancellableNetworkTileP
8194/// Map widget with TX/RX markers
8295/// Uses flutter_map with OpenStreetMap tiles
8396class MapWidget extends StatefulWidget {
84- const MapWidget ({super .key});
97+ /// Bottom padding in pixels to account for overlays (e.g., control panel)
98+ /// The map will offset its center point upward by half this value
99+ final double bottomPaddingPixels;
100+
101+ const MapWidget ({super .key, this .bottomPaddingPixels = 0 });
85102
86103 @override
87104 State <MapWidget > createState () => _MapWidgetState ();
88105}
89106
90107class _MapWidgetState extends State <MapWidget > with TickerProviderStateMixin {
91108 final MapController _mapController = MapController ();
92- MapStyle _mapStyle = MapStyle .dark;
93109
94110 // Auto-follow GPS like a navigation app
95111 bool _autoFollow = true ;
@@ -132,6 +148,23 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
132148 super .dispose ();
133149 }
134150
151+ @override
152+ void didUpdateWidget (MapWidget oldWidget) {
153+ super .didUpdateWidget (oldWidget);
154+ // When bottom padding changes (panel opened/closed/minimized), re-center if auto-following
155+ if (widget.bottomPaddingPixels != oldWidget.bottomPaddingPixels &&
156+ _autoFollow &&
157+ _isMapReady &&
158+ _lastGpsPosition != null ) {
159+ WidgetsBinding .instance.addPostFrameCallback ((_) {
160+ if (mounted && _autoFollow && _lastGpsPosition != null ) {
161+ final adjustedPosition = _offsetPositionForPadding (_lastGpsPosition! , widget.bottomPaddingPixels);
162+ _animateToPosition (adjustedPosition);
163+ }
164+ });
165+ }
166+ }
167+
135168 /// Smoothly animate the map to a new position
136169 void _animateToPosition (LatLng target) {
137170 if (! _isMapReady || ! mounted) return ;
@@ -294,6 +327,30 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
294327 _rotationAnimationController! .forward ();
295328 }
296329
330+ /// Offset a lat/lon position by screen pixels (to account for UI overlays)
331+ /// Shifts the map center down so the GPS marker appears centered in the
332+ /// visible map area above the control panel
333+ LatLng _offsetPositionForPadding (LatLng position, double bottomPadding) {
334+ if (bottomPadding <= 0 || ! _isMapReady) return position;
335+
336+ // Shift map center down by half the bottom padding
337+ // This makes the GPS marker appear higher (centered in visible area)
338+ final offsetPixels = bottomPadding / 2 ;
339+
340+ // Get meters per pixel at current zoom
341+ // Approx: 40075km / (256 * 2^zoom) at equator, adjusted by cos(lat)
342+ final zoom = _mapController.camera.zoom;
343+ final metersPerPixel = 40075000 / (256 * math.pow (2 , zoom)) *
344+ math.cos (position.latitude * math.pi / 180 );
345+
346+ // Convert pixel offset to meters, then to latitude offset
347+ // Subtract latitude to move map center south, making marker appear higher on screen
348+ final meterOffset = offsetPixels * metersPerPixel;
349+ final latOffset = meterOffset / 111000 ; // ~111km per degree latitude
350+
351+ return LatLng (position.latitude - latOffset, position.longitude);
352+ }
353+
297354 @override
298355 Widget build (BuildContext context) {
299356 final appState = context.watch <AppStateProvider >();
@@ -317,7 +374,9 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
317374 // Use post frame callback to avoid build-during-build issues
318375 WidgetsBinding .instance.addPostFrameCallback ((_) {
319376 if (mounted && _autoFollow) {
320- _animateToPosition (newPosition); // Smooth animation instead of jump
377+ // Apply offset for bottom padding when control panel is open
378+ final adjustedPosition = _offsetPositionForPadding (newPosition, widget.bottomPaddingPixels);
379+ _animateToPosition (adjustedPosition); // Smooth animation instead of jump
321380 }
322381 });
323382 }
@@ -348,9 +407,12 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
348407 // Disable auto-follow when navigating from log
349408 _autoFollow = false ;
350409 // Navigate to the coordinates with close zoom (18 = street level view)
410+ // Apply offset for bottom padding when control panel is open
351411 WidgetsBinding .instance.addPostFrameCallback ((_) {
352412 if (mounted) {
353- _animateToPositionWithZoom (LatLng (target.lat, target.lon), 18.0 );
413+ final targetPosition = LatLng (target.lat, target.lon);
414+ final adjustedPosition = _offsetPositionForPadding (targetPosition, widget.bottomPaddingPixels);
415+ _animateToPositionWithZoom (adjustedPosition, 18.0 );
354416 }
355417 });
356418 }
@@ -401,14 +463,19 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
401463 },
402464 ),
403465 children: [
404- // Tile layer (dynamic based on selected style)
405- TileLayer (
406- urlTemplate: _mapStyle.urlTemplate,
407- subdomains: _mapStyle.subdomains ?? const [],
408- userAgentPackageName: 'com.meshmapper.app' ,
409- maxZoom: 19 ,
410- retinaMode: RetinaMode .isHighDensity (context), // Enable high-res tiles on retina displays
411- tileProvider: SilentCancellableNetworkTileProvider (), // Silently handles tile errors
466+ // Tile layer (dynamic based on selected style from preferences)
467+ Builder (
468+ builder: (context) {
469+ final mapStyle = MapStyleExtension .fromString (appState.preferences.mapStyle);
470+ return TileLayer (
471+ urlTemplate: mapStyle.urlTemplate,
472+ subdomains: mapStyle.subdomains ?? const [],
473+ userAgentPackageName: 'com.meshmapper.app' ,
474+ maxZoom: 19 ,
475+ retinaMode: RetinaMode .isHighDensity (context), // Enable high-res tiles on retina displays
476+ tileProvider: SilentCancellableNetworkTileProvider (), // Silently handles tile errors
477+ );
478+ },
412479 ),
413480
414481 // MeshMapper coverage overlay (only when zone code available and overlay enabled)
@@ -521,6 +588,7 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
521588
522589 /// Map controls (top-right corner) - Apple Maps style
523590 Widget _buildMapControls (AppStateProvider appState) {
591+ final mapStyle = MapStyleExtension .fromString (appState.preferences.mapStyle);
524592 return Container (
525593 decoration: BoxDecoration (
526594 color: Colors .black.withValues (alpha: 0.7 ),
@@ -531,10 +599,24 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
531599 children: [
532600 // Map style toggle
533601 _buildControlButton (
534- icon: _mapStyle .icon,
535- tooltip: 'Map Style: ${_mapStyle .label }' ,
536- onPressed: _cycleMapStyle,
602+ icon: mapStyle .icon,
603+ tooltip: 'Map Style: ${mapStyle .label }' ,
604+ onPressed: () => _cycleMapStyle (appState) ,
537605 ),
606+ // MeshMapper overlay toggle (only show when zone code available)
607+ if (appState.zoneCode != null ) ...[
608+ Container (
609+ height: 1 ,
610+ width: 32 ,
611+ color: Colors .white24,
612+ ),
613+ _buildControlButton (
614+ icon: Icons .layers,
615+ tooltip: _showMeshMapperOverlay ? 'Hide Coverage Overlay' : 'Show Coverage Overlay' ,
616+ onPressed: _toggleMeshMapperOverlay,
617+ isActive: _showMeshMapperOverlay,
618+ ),
619+ ],
538620 Container (
539621 height: 1 ,
540622 width: 32 ,
@@ -571,20 +653,6 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
571653 onPressed: _toggleRotationLock,
572654 isActive: _rotationLocked,
573655 ),
574- // MeshMapper overlay toggle (only show when zone code available)
575- if (appState.zoneCode != null ) ...[
576- Container (
577- height: 1 ,
578- width: 32 ,
579- color: Colors .white24,
580- ),
581- _buildControlButton (
582- icon: Icons .layers,
583- tooltip: _showMeshMapperOverlay ? 'Hide Coverage Overlay' : 'Show Coverage Overlay' ,
584- onPressed: _toggleMeshMapperOverlay,
585- isActive: _showMeshMapperOverlay,
586- ),
587- ],
588656 // Legend button (always visible)
589657 Container (
590658 height: 1 ,
@@ -628,12 +696,12 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
628696 );
629697 }
630698
631- void _cycleMapStyle () {
632- setState (() {
633- const styles = MapStyle .values ;
634- final currentIndex = styles.indexOf (_mapStyle );
635- _mapStyle = styles[(currentIndex + 1 ) % styles.length];
636- } );
699+ void _cycleMapStyle (AppStateProvider appState ) {
700+ const styles = MapStyle .values;
701+ final currentStyle = MapStyleExtension . fromString (appState.preferences.mapStyle) ;
702+ final currentIndex = styles.indexOf (currentStyle );
703+ final newStyle = styles[(currentIndex + 1 ) % styles.length];
704+ appState. setMapStyle (newStyle.name );
637705 }
638706
639707 void _centerOnPosition () {
@@ -656,7 +724,9 @@ class _MapWidgetState extends State<MapWidget> with TickerProviderStateMixin {
656724 _autoFollow = true ;
657725 _lastGpsPosition = targetPosition;
658726 });
659- _animateToPositionWithZoom (targetPosition, 16.0 ); // Smooth animation with zoom
727+ // Apply offset for bottom padding when control panel is open
728+ final adjustedPosition = _offsetPositionForPadding (targetPosition, widget.bottomPaddingPixels);
729+ _animateToPositionWithZoom (adjustedPosition, 16.0 ); // Smooth animation with zoom
660730 }
661731 }
662732
0 commit comments