From 4fd19dc87c58f1f15db49653dd6398b0815a58ed Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Wed, 11 Mar 2026 15:48:31 -0500 Subject: [PATCH 1/8] Reduce map rebuild work during live tracking --- lib/providers/app_state_provider.dart | 16 +++ lib/widgets/map_widget.dart | 151 ++++++++++++++++++-------- 2 files changed, 122 insertions(+), 45 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a7d50bf..9fbb58d 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -210,6 +210,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Tile refresh after upload int _overlayCacheBust = 0; + int _mapDataRevision = 0; Timer? _tileRefreshTimer; // Auth type from API response (API, Mesh, Manual) @@ -342,6 +343,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { String? get zoneName => _currentZone?['name'] as String?; String? get zoneCode => _currentZone?['code'] as String?; int get overlayCacheBust => _overlayCacheBust; + int get mapDataRevision => _mapDataRevision; int? get zoneSlotsAvailable => _currentZone?['slots_available'] as int?; int? get zoneSlotsMax => _currentZone?['slots_max'] as int?; String? get nearestZoneName => _nearestZone?['name'] as String?; @@ -417,6 +419,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int get offlinePingCount => _apiQueueService.offlinePingCount; OfflineSessionService get offlineSessionService => _offlineSessionService; + void _markMapDataChanged() { + _mapDataRevision++; + } + /// Distance in meters from last TX ping position (like wardrive.js) double? get distanceFromLastPing { if (_currentPosition == null) return null; @@ -1205,6 +1211,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.onTxPing = (ping) { _txPings.add(ping); if (_txPings.length > _maxMapPins) _txPings.removeAt(0); + _markMapDataChanged(); // Add TX log entry (power in watts from preferences) _txLogEntries.add(TxLogEntry( @@ -1222,6 +1229,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _pingService!.onRxPing = (ping) { _rxPings.add(ping); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); + _markMapDataChanged(); // Add RX log entry _rxLogEntries.add(RxLogEntry( @@ -1603,6 +1611,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _rxPings.add(rxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); + _markMapDataChanged(); _currentBatchRepeaters.add(repeaterKey); // Increment RX count immediately when pin is created (not on batch flush) @@ -1670,6 +1679,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { snr: entry.snr ?? existingPin.snr, // UPDATE to best SNR from batch rssi: entry.rssi ?? existingPin.rssi, ); + _markMapDataChanged(); debugLog('[APP] Updated RX pin SNR for repeater=${entry.repeaterId}: ' '${existingPin.snr.toStringAsFixed(2)} -> ${entry.snr?.toStringAsFixed(2) ?? 'null'}'); } else { @@ -1688,6 +1698,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); _rxPings.add(newRxPing); if (_rxPings.length > _maxMapPins) _rxPings.removeAt(0); + _markMapDataChanged(); debugLog('[APP] Created FALLBACK RX pin for repeater=${entry.repeaterId} ' 'at ${entry.lat.toStringAsFixed(5)},${entry.lon.toStringAsFixed(5)}'); } @@ -2504,6 +2515,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); + _markMapDataChanged(); _pingService?.resetStats(); notifyListeners(); } @@ -2514,6 +2526,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxLogEntries.clear(); _discLogEntries.clear(); _errorLogEntries.clear(); + _markMapDataChanged(); notifyListeners(); } @@ -2523,6 +2536,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_discLogEntries.length > _maxLogEntries) { _discLogEntries.removeLast(); } + _markMapDataChanged(); debugLog('[APP] Discovery log entry added: ${entry.nodeCount} nodes discovered'); notifyListeners(); } @@ -3699,6 +3713,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Clear repeaters when exiting zone _repeaters = []; + _markMapDataChanged(); _repeatersLoaded = false; _repeatersLoadedForIata = null; } @@ -3725,6 +3740,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { try { final fetchedRepeaters = await _apiService.fetchRepeaters(iata); _repeaters = fetchedRepeaters; + _markMapDataChanged(); _repeatersLoaded = true; _repeatersLoadedForIata = iata; debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7448452..080b481 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -138,6 +138,8 @@ class MapWidget extends StatefulWidget { class _MapWidgetState extends State with TickerProviderStateMixin { final MapController _mapController = MapController(); + late final SilentCancellableNetworkTileProvider _baseTileProvider; + late final SilentCancellableNetworkTileProvider _overlayTileProvider; // Auto-follow GPS like a navigation app bool _autoFollow = false; // Disabled by default - users often zoom out first @@ -179,6 +181,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { static const LatLng _defaultCenter = LatLng(45.4215, -75.6972); static const double _defaultZoom = 15.0; // Closer zoom for driving + @override + void initState() { + super.initState(); + _baseTileProvider = SilentCancellableNetworkTileProvider(); + _overlayTileProvider = SilentCancellableNetworkTileProvider(); + } + @override void dispose() { _animationController?.dispose(); @@ -419,40 +428,58 @@ class _MapWidgetState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - final appState = context.watch(); + final appState = context.read(); + final mapState = context.select((AppStateProvider state) => ( + preferencesLoaded: state.preferencesLoaded, + mapAutoFollow: state.preferences.mapAutoFollow, + mapAlwaysNorth: state.preferences.mapAlwaysNorth, + mapRotationLocked: state.preferences.mapRotationLocked, + mapStyle: state.preferences.mapStyle, + isImperial: state.preferences.isImperial, + currentPosition: state.currentPosition, + lastKnownPosition: state.lastKnownPosition, + zoneCode: state.zoneCode, + overlayCacheBust: state.overlayCacheBust, + discDropEnabled: state.discDropEnabled, + effectiveHopBytes: state.enforceHopBytes ? state.effectiveHopBytes : null, + mapNavigationTrigger: state.mapNavigationTrigger, + mapNavigationTarget: state.mapNavigationTarget, + mapDataRevision: state.mapDataRevision, + distanceFromLastPing: state.distanceFromLastPing, + )); // Load saved map toggle preferences once, after Hive has finished loading - if (!_prefsApplied && appState.preferencesLoaded) { + if (!_prefsApplied && mapState.preferencesLoaded) { _prefsApplied = true; - _autoFollow = appState.preferences.mapAutoFollow; - _alwaysNorth = appState.preferences.mapAlwaysNorth; - _rotationLocked = appState.preferences.mapRotationLocked; + _autoFollow = mapState.mapAutoFollow; + _alwaysNorth = mapState.mapAlwaysNorth; + _rotationLocked = mapState.mapRotationLocked; } // Determine map center - prefer current GPS, fallback to last known, then Ottawa LatLng center = _defaultCenter; - if (appState.currentPosition != null) { + if (mapState.currentPosition != null) { center = LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, + mapState.currentPosition!.latitude, + mapState.currentPosition!.longitude, ); - } else if (appState.lastKnownPosition != null) { + } else if (mapState.lastKnownPosition != null) { center = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, + mapState.lastKnownPosition!.lat, + mapState.lastKnownPosition!.lon, ); } // One-time zoom to last known position when GPS is not yet available // This runs before GPS locks, so user sees their previous location instead of Ottawa - if (appState.currentPosition == null && - appState.lastKnownPosition != null && + if (mapState.currentPosition == null && + mapState.lastKnownPosition != null && !_hasZoomedToLastKnown && _isMapReady) { _hasZoomedToLastKnown = true; final lastKnownCenter = LatLng( - appState.lastKnownPosition!.lat, - appState.lastKnownPosition!.lon, + mapState.lastKnownPosition!.lat, + mapState.lastKnownPosition!.lon, ); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -462,7 +489,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { }); } - if (appState.currentPosition != null) { + if (mapState.currentPosition != null) { // One-time initial zoom to GPS when we first get a position // This happens even with auto-follow disabled so user sees their location // Don't apply panel offset - center directly on GPS so pin is in middle of screen @@ -507,7 +534,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Handle map rotation based on heading (when not in Always North mode) if (!_alwaysNorth && _isMapReady) { - final heading = appState.currentPosition!.heading; + final heading = mapState.currentPosition!.heading; if (_lastHeading == null) { // First heading after startup — store without rotating so the // initial zoom animation can settle at rotation 0 (where the @@ -529,9 +556,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Handle navigation trigger from log screen or graph // Reset map state and navigate to the target location - if (_isMapReady && appState.mapNavigationTrigger != _lastNavigationTrigger) { - _lastNavigationTrigger = appState.mapNavigationTrigger; - final target = appState.mapNavigationTarget; + if (_isMapReady && mapState.mapNavigationTrigger != _lastNavigationTrigger) { + _lastNavigationTrigger = mapState.mapNavigationTrigger; + final target = mapState.mapNavigationTarget; if (target != null) { // Reset map controls to default state _autoFollow = false; // Disable center on GPS @@ -567,27 +594,27 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return Stack( children: [ // Map - _buildMap(appState, center), + _buildMap(appState, mapState, center), // GPS Info overlay (top-left, respects dynamic island in landscape) Positioned( top: topPadding, left: leftPadding, - child: _buildGpsInfoOverlay(appState), + child: _buildGpsInfoOverlay(mapState), ), // Map controls - top-right in both orientations, collapsible Positioned( top: topPadding, right: 8, - child: _buildCollapsibleMapControls(appState), + child: _buildCollapsibleMapControls(appState, mapState.mapStyle, mapState.zoneCode != null), ), ], ); } /// Collapsible map controls (toggle at top, expands downward) - Widget _buildCollapsibleMapControls(AppStateProvider appState) { + Widget _buildCollapsibleMapControls(AppStateProvider appState, String mapStyleName, bool hasZoneOverlay) { // Use external state if provided, otherwise use internal state final isExpanded = widget.mapControlsExpanded ?? _mapControlsExpanded; final onToggle = widget.onMapControlsToggle ?? () => setState(() => _mapControlsExpanded = !_mapControlsExpanded); @@ -615,12 +642,29 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), // Map controls (only when expanded) - below the toggle button if (isExpanded) - _buildMapControls(appState), + _buildMapControls(appState, mapStyleName, hasZoneOverlay), ], ); } - Widget _buildMap(AppStateProvider appState, LatLng center) { + Widget _buildMap(AppStateProvider appState, ({ + bool preferencesLoaded, + bool mapAutoFollow, + bool mapAlwaysNorth, + bool mapRotationLocked, + String mapStyle, + bool isImperial, + dynamic currentPosition, + ({double lat, double lon})? lastKnownPosition, + String? zoneCode, + int overlayCacheBust, + bool discDropEnabled, + int? effectiveHopBytes, + int mapNavigationTrigger, + ({double lat, double lon})? mapNavigationTarget, + int mapDataRevision, + double? distanceFromLastPing, + }) mapState, LatLng center) { return Builder( builder: (context) => FlutterMap( mapController: _mapController, @@ -646,29 +690,29 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Tile layer (dynamic based on selected style from preferences) Builder( builder: (context) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + final mapStyle = MapStyleExtension.fromString(mapState.mapStyle); return TileLayer( urlTemplate: mapStyle.urlTemplate, subdomains: mapStyle.subdomains ?? const [], userAgentPackageName: 'com.meshmapper.app', maxZoom: 17, retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), - tileProvider: SilentCancellableNetworkTileProvider(), + tileProvider: _baseTileProvider, ); }, ), // MeshMapper coverage overlay (only when zone code available and overlay enabled) - if (appState.zoneCode != null && _showMeshMapperOverlay) + if (mapState.zoneCode != null && _showMeshMapperOverlay) TileLayer( - urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', + urlTemplate: 'https://${mapState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${mapState.overlayCacheBust}', userAgentPackageName: 'com.meshmapper.app', minZoom: 3, maxZoom: 17, tileDisplay: const TileDisplay.fadeIn( reloadStartOpacity: 1.0, // Keep old tile visible until new one loads ), - tileProvider: SilentCancellableNetworkTileProvider(), + tileProvider: _overlayTileProvider, ), // TX markers (green) @@ -687,26 +731,26 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), // Repeater markers (magenta with ID, rotate with map) - MarkerLayer( + MarkerLayer( rotate: true, markers: _buildRepeaterMarkers( appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, + mapState.effectiveHopBytes, ), ), // Current position marker (car icon) - if (appState.currentPosition != null) + if (mapState.currentPosition != null) MarkerLayer( markers: [ Marker( point: LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, + mapState.currentPosition.latitude, + mapState.currentPosition.longitude, ), width: 48, height: 48, - child: _buildCurrentPositionMarker(appState.currentPosition!.heading), + child: _buildCurrentPositionMarker(mapState.currentPosition.heading), ), ], ), @@ -716,10 +760,27 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// GPS info overlay (top-left corner) - Widget _buildGpsInfoOverlay(AppStateProvider appState) { - final position = appState.currentPosition; + Widget _buildGpsInfoOverlay(({ + bool preferencesLoaded, + bool mapAutoFollow, + bool mapAlwaysNorth, + bool mapRotationLocked, + String mapStyle, + bool isImperial, + dynamic currentPosition, + ({double lat, double lon})? lastKnownPosition, + String? zoneCode, + int overlayCacheBust, + bool discDropEnabled, + int? effectiveHopBytes, + int mapNavigationTrigger, + ({double lat, double lon})? mapNavigationTarget, + int mapDataRevision, + double? distanceFromLastPing, + }) mapState) { + final position = mapState.currentPosition; final hasGps = position != null; - final distanceFromLastPing = appState.distanceFromLastPing; + final distanceFromLastPing = mapState.distanceFromLastPing; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), @@ -738,7 +799,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), const SizedBox(width: 6), Text( - hasGps ? formatMeters(position.accuracy, isImperial: appState.preferences.isImperial) : 'No GPS', + hasGps ? formatMeters(position.accuracy, isImperial: mapState.isImperial) : 'No GPS', style: TextStyle( fontSize: 11, fontFamily: 'monospace', @@ -755,7 +816,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), const SizedBox(width: 4), Text( - formatMeters(distanceFromLastPing, isImperial: appState.preferences.isImperial), + formatMeters(distanceFromLastPing, isImperial: mapState.isImperial), style: const TextStyle( fontSize: 11, fontFamily: 'monospace', @@ -775,8 +836,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// Map controls (always vertical, used inside collapsible wrapper) - Widget _buildMapControls(AppStateProvider appState) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + Widget _buildMapControls(AppStateProvider appState, String mapStyleName, bool hasZoneOverlay) { + final mapStyle = MapStyleExtension.fromString(mapStyleName); return Container( decoration: BoxDecoration( @@ -794,7 +855,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { onPressed: () => _cycleMapStyle(appState), ), // MeshMapper overlay toggle (only show when zone code available) - if (appState.zoneCode != null) ...[ + if (hasZoneOverlay) ...[ _buildControlDivider(), _buildControlButton( icon: Icons.layers, From ca538565dae6d636faaaca7e4f43edd3472d5af8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 22 Mar 2026 14:14:17 -0400 Subject: [PATCH 2/8] - Aligned dot marker colors with web map coverage squares. Discovery markers changed from purple to cyan, RX markers changed from blue to purple. TX (green) and Trace (cyan) were already aligned. Applies to map markers, noise floor chart, status bar chips, log screen filters, and home screen stats. --- lib/models/noise_floor_session.dart | 15 ++++++------ lib/screens/connection_screen.dart | 2 +- lib/screens/home_screen.dart | 17 +++++++------- lib/screens/log_screen.dart | 19 +++++++-------- lib/services/meshcore/connection.dart | 28 ++++++++++++++++++++++- lib/utils/ping_colors.dart | 29 +++++++++++++++++++++++ lib/widgets/map_widget.dart | 33 ++++++++++++++------------- lib/widgets/noise_floor_chart.dart | 13 ++++++----- lib/widgets/status_bar.dart | 17 +++++++------- 9 files changed, 117 insertions(+), 56 deletions(-) create mode 100644 lib/utils/ping_colors.dart diff --git a/lib/models/noise_floor_session.dart b/lib/models/noise_floor_session.dart index 9c6b574..6bd9fbf 100644 --- a/lib/models/noise_floor_session.dart +++ b/lib/models/noise_floor_session.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; +import '../utils/ping_colors.dart'; part 'noise_floor_session.g.dart'; @@ -100,13 +101,13 @@ class PingEventMarker extends HiveObject { /// Get the color for this event type Color get color => switch (type) { - PingEventType.txSuccess => Colors.green, - PingEventType.txFail => Colors.red, - PingEventType.rx => Colors.blue, - PingEventType.discSuccess => Colors.purple, - PingEventType.discFail => Colors.grey, - PingEventType.traceSuccess => Colors.cyan, - PingEventType.traceFail => Colors.grey, + PingEventType.txSuccess => PingColors.txSuccess, + PingEventType.txFail => PingColors.txFail, + PingEventType.rx => PingColors.rx, + PingEventType.discSuccess => PingColors.discSuccess, + PingEventType.discFail => PingColors.discFail, + PingEventType.traceSuccess => PingColors.traceSuccess, + PingEventType.traceFail => PingColors.noResponse, }; /// Get a display label for this event type diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index ce97f87..993466e 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -439,7 +439,7 @@ class _ConnectionScreenState extends State with WidgetsBinding } // Portrait: compact vertical layout (bottom bar provided by _buildBody) - return Padding( + return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index d984a98..2e0150f 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; import '../widgets/connection_panel.dart'; import '../widgets/map_widget.dart'; import '../widgets/ping_controls.dart'; @@ -155,7 +156,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.arrow_upward, appState.pingStats.txCount, - Colors.green, + PingColors.txSuccess, onTap: withTapHandlers ? () => _showInfoPopup('tx', appState) : null, ), const SizedBox(width: 8), @@ -163,7 +164,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.arrow_downward, appState.pingStats.rxCount, - Colors.blue, + PingColors.rx, onTap: withTapHandlers ? () => _showInfoPopup('rx', appState) : null, ), const SizedBox(width: 8), @@ -171,7 +172,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.radar, appState.pingStats.discCount, - const Color(0xFF7B68EE), + PingColors.discSuccess, onTap: withTapHandlers ? () => _showInfoPopup('disc', appState) : null, ), const SizedBox(width: 8), @@ -179,7 +180,7 @@ class _HomeScreenState extends State { _buildAppBarStatChip( Icons.route, appState.pingStats.traceCount, - Colors.cyan, + PingColors.traceSuccess, onTap: withTapHandlers ? () => _showInfoPopup('trace', appState) : null, ), const SizedBox(width: 8), @@ -331,16 +332,16 @@ class _HomeScreenState extends State { return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, Colors.green); + return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, Colors.blue); + return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); case 'disc': - return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, const Color(0xFF7B68EE)); + return ('Discovery Requests', 'Discovery request packets we have sent out.', Icons.radar, PingColors.discSuccess); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 97267e0..5c09b5e 100644 --- a/lib/screens/log_screen.dart +++ b/lib/screens/log_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../models/log_entry.dart'; import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; +import '../utils/ping_colors.dart'; import '../widgets/repeater_id_chip.dart'; /// Log screen with two tabs: All Pings (unified TX+RX+DISC+TRC) and Errors @@ -434,13 +435,13 @@ class _AllPingsTabState extends State<_AllPingsTab> { child: IntrinsicHeight( child: Row( children: [ - _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, Colors.green, isFirst: true), + _buildFilterSegment(PingLogType.tx, 'TX', widget.txCount, PingColors.txSuccess, isFirst: true), _segmentDivider(context), - _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, Colors.blue), + _buildFilterSegment(PingLogType.rx, 'RX', widget.rxCount, PingColors.rx), _segmentDivider(context), - _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, const Color(0xFF7B68EE)), + _buildFilterSegment(PingLogType.disc, 'DISC', widget.discCount, PingColors.discSuccess), _segmentDivider(context), - _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, Colors.cyan, isLast: true), + _buildFilterSegment(PingLogType.trace, 'TRC', widget.traceCount, PingColors.traceSuccess, isLast: true), ], ), ), @@ -548,10 +549,10 @@ class _AllPingsTabState extends State<_AllPingsTab> { static Widget _buildTypeBadge(PingLogType type) { final (label, color) = switch (type) { - PingLogType.tx => ('TX', Colors.green), - PingLogType.rx => ('RX', Colors.blue), - PingLogType.disc => ('DISC', const Color(0xFF7B68EE)), - PingLogType.trace => ('TRC', Colors.cyan), + PingLogType.tx => ('TX', PingColors.txSuccess), + PingLogType.rx => ('RX', PingColors.rx), + PingLogType.disc => ('DISC', PingColors.discSuccess), + PingLogType.trace => ('TRC', PingColors.traceSuccess), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), @@ -833,7 +834,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w500, - color: Color(0xFF7B68EE), + color: PingColors.discSuccess, ), ), ], diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 07c0810..fd0f389 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -84,6 +84,7 @@ class MeshCoreConnection { Completer? _deviceQueryCompleter; Completer? _selfInfoCompleter; Completer? _sentCompleter; + Completer? _setTimeCompleter; Completer? _channelInfoCompleter; Completer? _statsCompleter; Completer? _exportContactCompleter; @@ -369,10 +370,23 @@ class MeshCoreConnection { switch (responseCode) { case ResponseCodes.ok: debugLog('[CONN] Received OK response'); + _setTimeCompleter?.complete(); + _setTimeCompleter = null; break; case ResponseCodes.err: final errorCode = reader.remainingBytesCount > 0 ? reader.readByte() : 0; debugLog('[CONN] Received ERR response (error code: $errorCode)'); + // Time sync: error code 6 (ERR_CODE_ILLEGAL_ARG) means "no sync needed" — treat as success + if (_setTimeCompleter != null) { + if (errorCode == 6) { + debugLog('[CONN] Time sync not needed (error code 6) - treating as success'); + } else { + debugWarn('[CONN] Time sync error (code $errorCode) - continuing anyway'); + } + _setTimeCompleter?.complete(); + _setTimeCompleter = null; + break; + } // Complete any pending completers with error final errException = Exception('Command error (code $errorCode)'); _statsCompleter?.completeError(errException); @@ -758,12 +772,23 @@ class MeshCoreConnection { ); } - /// Set device time + /// Set device time and await OK/ERROR response from device Future setDeviceTime(int epochSecs) async { + _setTimeCompleter = Completer(); + final future = _setTimeCompleter!.future; + final data = BufferWriter(); data.writeByte(CommandCodes.setDeviceTime); data.writeUInt32LE(epochSecs); await _sendToRadio(data); + + return future.timeout( + const Duration(seconds: 5), + onTimeout: () { + _setTimeCompleter = null; + debugWarn('[CONN] Time sync timed out - continuing anyway'); + }, + ); } /// Set TX power @@ -1158,6 +1183,7 @@ class MeshCoreConnection { void dispose() { _stopNoiseFloorPolling(); _stopBatteryPolling(); + _setTimeCompleter = null; _dataSubscription?.cancel(); _stepController.close(); _channelMessageController.close(); diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart new file mode 100644 index 0000000..2587a93 --- /dev/null +++ b/lib/utils/ping_colors.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +/// Centralized color constants for ping types (TX, RX, DISC, Trace). +/// +/// Dot/marker colors are aligned with coverage layer squares on the +/// MeshMapper web map: +/// BIDIR=#7EE094, TX=#FD8928, DISC/TRACE=#51D4E9, RX=#7D54C7, +/// DEAD=#9E9689, DROP=#E04F5D +class PingColors { + PingColors._(); + + // ── TX (green — we can't distinguish BIDIR vs TX client-side) ── + static const Color txSuccess = Color(0xFF4CAF50); + static const Color txSuccessLegend = Color(0xFF22C55E); + static const Color txFail = Color(0xFFF44336); + + // ── RX (purple — matches RX web map squares #7D54C7) ── + static const Color rx = Color(0xFF7D54C7); + + // ── DISC (cyan — matches DISC/TRACE web map squares #51D4E9) ── + static const Color discSuccess = Color(0xFF51D4E9); + static const Color discFail = Color(0xFF9E9E9E); + + // ── Trace (cyan family — same web map layer as DISC) ── + static const Color traceSuccess = Color(0xFF00BCD4); + + // ── Shared ── + static const Color noResponse = Color(0xFF9E9E9E); +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7d2e2ef..9d4a3db 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -14,6 +14,7 @@ import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; /// Map style options @@ -732,10 +733,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Color for the overlay ping-type dot static Color _overlayTypeColor(OverlayPingType type) { return switch (type) { - OverlayPingType.tx => Colors.green, - OverlayPingType.disc => Colors.purple, - OverlayPingType.trace => Colors.cyan, - OverlayPingType.rx => Colors.blue, + OverlayPingType.tx => PingColors.txSuccess, + OverlayPingType.disc => PingColors.discSuccess, + OverlayPingType.trace => PingColors.traceSuccess, + OverlayPingType.rx => PingColors.rx, }; } @@ -1229,49 +1230,49 @@ class _MapWidgetState extends State with TickerProviderStateMixin { children: [ _buildLegendItem( context: context, - color: const Color(0xFF22C55E), + color: PingColors.txSuccessLegend, label: 'TX', description: 'Location where you sent a ping and heard a repeater', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: Colors.red, + color: PingColors.txFail, label: 'TX', description: 'Location where you sent a ping but no repeater was heard', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: const Color(0xFF0EA5E9), + color: PingColors.rx, label: 'RX', description: 'Location where you received a message from the mesh', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: const Color(0xFF7D54C7), + color: PingColors.discSuccess, label: 'DISC', description: 'Location where you sent a discovery request and a repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3)), _buildLegendItem( context: context, - color: Colors.cyan, + color: PingColors.traceSuccess, label: 'TRC', description: 'Location where a trace reached the repeater', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, - color: Colors.grey, + color: PingColors.discFail, label: 'DISC', description: 'Location where you sent a discovery request but no repeater responded', ), Divider(height: 1, color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2)), _buildLegendItem( context: context, - color: Colors.grey, + color: PingColors.noResponse, label: 'TRC', description: 'Location where a trace got no response', ), @@ -1670,7 +1671,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { onTap: () => _showTxPingDetails(ping), child: Container( decoration: BoxDecoration( - color: ping.heardRepeaters.isEmpty ? Colors.red : Colors.green, + color: ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: const [ @@ -1690,8 +1691,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { List _buildRxMarkers(List pings) { return pings.map((ping) { - // Use blue to match the RX chip in status bar - const color = Colors.blue; + // Use purple to match RX coverage squares on web map + const color = PingColors.rx; return Marker( point: LatLng(ping.latitude, ping.longitude), @@ -2026,8 +2027,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// DISC marker color (#7B68EE - medium slate blue/purple) - static const Color _discMarkerColor = Color(0xFF7B68EE); + /// DISC marker color (#51D4E9 - cyan, matches DISC/TRACE web map squares) + static const Color _discMarkerColor = PingColors.discSuccess; /// Repeater marker color (#a52163 - magenta/pink) - Active static const Color _repeaterMarkerColor = Color(0xFFA52163); diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index aa760b4..8659252 100644 --- a/lib/widgets/noise_floor_chart.dart +++ b/lib/widgets/noise_floor_chart.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/noise_floor_session.dart'; import '../providers/app_state_provider.dart'; +import '../utils/ping_colors.dart'; import 'repeater_id_chip.dart'; /// Interactive noise floor chart with pinch-to-zoom and pan @@ -848,12 +849,12 @@ class InteractiveNoiseFloorChartState extends State runSpacing: 8, alignment: WrapAlignment.center, children: [ - _legendItem(context, Colors.green, 'TX Success'), - _legendItem(context, Colors.red, 'TX Fail'), - _legendItem(context, Colors.blue, 'RX'), - _legendItem(context, Colors.purple, 'DISC Success'), - _legendItem(context, Colors.cyan, 'Trace Success'), - _legendItem(context, Colors.grey, 'No Response'), + _legendItem(context, PingColors.txSuccess, 'TX Success'), + _legendItem(context, PingColors.txFail, 'TX Fail'), + _legendItem(context, PingColors.rx, 'RX'), + _legendItem(context, PingColors.discSuccess, 'DISC Success'), + _legendItem(context, PingColors.traceSuccess, 'Trace Success'), + _legendItem(context, PingColors.noResponse, 'No Response'), ], ); } diff --git a/lib/widgets/status_bar.dart b/lib/widgets/status_bar.dart index 563d28f..403b6d9 100644 --- a/lib/widgets/status_bar.dart +++ b/lib/widgets/status_bar.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../models/connection_state.dart'; import '../providers/app_state_provider.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// Status bar showing GPS, connection, and queue status class StatusBar extends StatefulWidget { @@ -150,16 +151,16 @@ class _StatusBarState extends State { return ('Locating...', 'Acquiring GPS signal and checking your zone status.', Icons.gps_not_fixed, Colors.blue); case 'tx': - return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, Colors.green); + return ('TX Packets', 'TX packets that have been sent out. These are messages to the #wardriving channel.', Icons.arrow_upward, PingColors.txSuccess); case 'rx': - return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, Colors.blue); + return ('RX Packets', 'RX packets that we have heard from the mesh. These were not initiated by us.', Icons.arrow_downward, PingColors.rx); case 'disc': - return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, const Color(0xFF7B68EE)); + return ('Discovery Requests', 'Discovery requests we have heard a response for.', Icons.radar, PingColors.discSuccess); case 'trace': - return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, Colors.cyan); + return ('Trace Responses', 'Trace path requests that received a response from the target repeater.', Icons.route, PingColors.traceSuccess); case 'upload': return ('Uploaded', 'Pings sent to MeshMapper servers. Your data helps build the community coverage map!', Icons.cloud_done, Colors.teal); @@ -239,7 +240,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.arrow_upward, value: appState.pingStats.txCount, - color: Colors.green, + color: PingColors.txSuccess, onTap: () => _showInfoPopup(context, 'tx'), ), @@ -249,7 +250,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.arrow_downward, value: appState.pingStats.rxCount, - color: Colors.blue, + color: PingColors.rx, onTap: () => _showInfoPopup(context, 'rx'), ), @@ -259,7 +260,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.radar, value: appState.pingStats.discCount, - color: const Color(0xFF7B68EE), // DISC purple + color: PingColors.discSuccess, onTap: () => _showInfoPopup(context, 'disc'), ), @@ -269,7 +270,7 @@ class _StatusBarState extends State { _AnimatedStatChip( icon: Icons.route, value: appState.pingStats.traceCount, - color: Colors.cyan, + color: PingColors.traceSuccess, onTap: () => _showInfoPopup(context, 'trace'), ), From 2a950f3a3b6992f10e89650dfaf7c65b48c86ed1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 22 Mar 2026 20:50:48 -0400 Subject: [PATCH 3/8] - Aligned dot marker colors with web map coverage squares. Discovery markers changed from purple to cyan, RX markers changed from blue to purple. TX (green) and Trace (cyan) were already aligned. Applies to map markers, noise floor chart, status bar chips, log screen filters, and home screen stats. - Map coverage dots now draw in chronological order instead of by category. When your path crosses over itself, the most recent ping always renders on top regardless of type. - Reduced the thick white outline on map coverage dots to a subtle semi-transparent border, improving readability when markers cluster together. Repeater ID markers retain their original styling. --- lib/widgets/map_widget.dart | 206 ++++++++++++++++-------------------- 1 file changed, 89 insertions(+), 117 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 9d4a3db..8190e59 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -681,24 +681,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { tileProvider: SilentCancellableNetworkTileProvider(), ), - // TX markers (green) + // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top MarkerLayer( - markers: _buildTxMarkers(appState.txPings), - ), - - // RX markers (colored by repeater) - MarkerLayer( - markers: _buildRxMarkers(appState.rxPings), - ), - - // DISC markers (purple circles for discovery observations) - MarkerLayer( - markers: _buildDiscMarkers(appState.discLogEntries, appState.discDropEnabled), - ), - - // Trace markers (cyan/red circles for targeted ping results) - MarkerLayer( - markers: _buildTraceMarkers(appState.traceLogEntries), + markers: _buildCoverageMarkers( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + ), ), // Repeater markers (magenta with ID, rotate with map) @@ -1661,119 +1652,100 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - List _buildTxMarkers(List pings) { - return pings.map((ping) { - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showTxPingDetails(ping), - child: Container( - decoration: BoxDecoration( - color: ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - // Simple dot - no arrow (looks good at any map rotation) + /// Shared decoration for coverage dots — diminished border for readability. + BoxDecoration _coverageDotDecoration(Color color) => BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.3), width: 1), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], + ); + + /// Build all coverage dot markers sorted by timestamp (oldest first = drawn underneath). + /// Newer pings always render on top regardless of type. + List _buildCoverageMarkers({ + required List txPings, + required List rxPings, + required List discEntries, + required bool discDropEnabled, + required List traceEntries, + }) { + final timestamped = <(DateTime, Marker)>[ + for (final ping in txPings) + (ping.timestamp, _buildTxMarker(ping)), + for (final ping in rxPings) + (ping.timestamp, _buildRxMarker(ping)), + for (final entry in discEntries) + (entry.timestamp, _buildDiscMarker(entry, discDropEnabled)), + for (final entry in traceEntries) + (entry.timestamp, _buildTraceMarker(entry)), + ]; + + timestamped.sort((a, b) => a.$1.compareTo(b.$1)); + return timestamped.map((e) => e.$2).toList(); + } + + Marker _buildTxMarker(TxPing ping) { + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTxPingDetails(ping), + child: Container( + decoration: _coverageDotDecoration( + ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, ), ), - ); - }).toList(); + ), + ); } - List _buildRxMarkers(List pings) { - return pings.map((ping) { - // Use purple to match RX coverage squares on web map - const color = PingColors.rx; - - return Marker( - point: LatLng(ping.latitude, ping.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showRxPingDetails(ping), - child: Container( - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), - // Simple dot - no arrow (looks good at any map rotation) - ), + Marker _buildRxMarker(RxPing ping) { + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showRxPingDetails(ping), + child: Container( + decoration: _coverageDotDecoration(PingColors.rx), ), - ); - }).toList(); + ), + ); } - List _buildDiscMarkers(List entries, bool discDropEnabled) { - return entries.map((entry) { - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showDiscPingDetails(entry), - child: Container( - decoration: BoxDecoration( - color: entry.nodeCount == 0 - ? (discDropEnabled ? Colors.red : Colors.grey) - : _discMarkerColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), + Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showDiscPingDetails(entry), + child: Container( + decoration: _coverageDotDecoration( + entry.nodeCount == 0 + ? (discDropEnabled ? Colors.red : Colors.grey) + : _discMarkerColor, ), ), - ); - }).toList(); + ), + ); } - List _buildTraceMarkers(List entries) { - return entries.map((entry) { - return Marker( - point: LatLng(entry.latitude, entry.longitude), - width: 20, - height: 20, - child: GestureDetector( - onTap: () => _showTraceDetails(entry), - child: Container( - decoration: BoxDecoration( - color: entry.success ? Colors.cyan : Colors.grey, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: const [ - BoxShadow( - color: Colors.black26, - blurRadius: 4, - offset: Offset(0, 2), - ), - ], - ), + Marker _buildTraceMarker(TraceLogEntry entry) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTraceDetails(entry), + child: Container( + decoration: _coverageDotDecoration( + entry.success ? Colors.cyan : Colors.grey, ), ), - ); - }).toList(); + ), + ); } void _showTraceDetails(TraceLogEntry entry) { From e2cef8fd10ac2450cc1f76d9e47d6bbe0d298070 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 22 Mar 2026 21:02:10 -0400 Subject: [PATCH 4/8] - Fixed inconsistent audio playback on Android where notification sounds (TX/RX pings) would work reliably in some sessions but not at all in others. --- lib/services/audio_service.dart | 121 ++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 41898ca..e45f20e 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -11,11 +11,19 @@ import '../utils/debug_logger_io.dart'; class AudioService { static const String _prefsBoxName = 'audio_preferences'; static const String _enabledKey = 'sound_enabled'; + static const String _txAsset = 'assets/transmitted_packet.mp3'; + static const String _rxAsset = 'assets/received_packet.mp3'; + + /// Delay before releasing audio focus after the last sound plays. + /// Prevents rapid activate/deactivate cycles that break Android audio, + /// while still releasing focus for Android Auto ducking. + static const Duration _focusReleaseDelay = Duration(seconds: 3); AudioPlayer? _txPlayer; AudioPlayer? _rxPlayer; bool _initialized = false; bool _enabled = false; // Disabled by default, remembered once user changes it + Timer? _focusReleaseTimer; /// Whether the audio service is initialized bool get isInitialized => _initialized; @@ -66,9 +74,8 @@ class AudioService { _rxPlayer = AudioPlayer(); // Pre-load the audio assets for instant playback - // Using asset:// prefix for just_audio - await _txPlayer!.setAsset('assets/transmitted_packet.mp3'); - await _rxPlayer!.setAsset('assets/received_packet.mp3'); + await _txPlayer!.setAsset(_txAsset); + await _rxPlayer!.setAsset(_rxAsset); _initialized = true; debugLog('[AUDIO] Audio service initialized, enabled=$_enabled'); @@ -147,55 +154,77 @@ class AudioService { /// Play the transmit sound (when TX ping or Discovery request is sent) Future playTransmitSound() async { - debugLog('[AUDIO] playTransmitSound called - initialized=$_initialized, enabled=$_enabled'); - if (!_initialized || !_enabled) { - debugLog('[AUDIO] playTransmitSound skipped - not initialized or disabled'); - return; - } + await _playSound(_txPlayer, _txAsset, 'TX'); + } + + /// Play the receive sound (when repeater echo or RX observation is detected) + Future playReceiveSound() async { + await _playSound(_rxPlayer, _rxAsset, 'RX'); + } + + /// Shared playback logic for both TX and RX sounds. + /// Ensures audio session is active before playing and debounces focus release. + Future _playSound(AudioPlayer? player, String assetPath, String label) async { + if (!_initialized || !_enabled || player == null) return; try { - debugLog('[AUDIO] Playing transmit sound...'); - // Seek to start and play with timeout to prevent indefinite hangs - // (iOS audio session corruption can cause play() to never complete) - await _txPlayer?.seek(Duration.zero); - await _txPlayer?.play().timeout(const Duration(seconds: 3)); - debugLog('[AUDIO] Transmit sound played successfully'); - // Release audio focus after playback completes - // Critical for Android Auto - without this, car audio stays ducked - await _releaseAudioFocus(); + await _ensureSessionActive(); + await player.seek(Duration.zero); + await player.play().timeout(const Duration(seconds: 3)); + debugLog('[AUDIO] Played $label sound'); + _scheduleFocusRelease(); } on TimeoutException { - debugWarn('[AUDIO] Transmit play() timed out after 3s — resetting audio session'); - await _txPlayer?.stop(); + debugWarn('[AUDIO] $label play() timed out — resetting audio session'); + await player.stop(); await _resetAudioSession(); } catch (e) { - debugError('[AUDIO] Failed to play transmit sound: $e'); + debugError('[AUDIO] Failed to play $label sound: $e'); + // Try to recover the player for next time + try { + await player.stop(); + await player.setAsset(assetPath); + debugLog('[AUDIO] Reloaded $label player after error'); + } catch (reloadError) { + debugError('[AUDIO] Failed to reload $label player: $reloadError'); + } } } - /// Play the receive sound (when repeater echo or RX observation is detected) - Future playReceiveSound() async { - if (!_initialized || !_enabled) return; - + /// Ensure audio session is active before playback. + /// Cancels any pending focus release to prevent a race where releasing + /// focus from a previous sound kills the session for the current sound. + Future _ensureSessionActive() async { + _focusReleaseTimer?.cancel(); try { - // Seek to start and play with timeout to prevent indefinite hangs - await _rxPlayer?.seek(Duration.zero); - await _rxPlayer?.play().timeout(const Duration(seconds: 3)); - debugLog('[AUDIO] Played receive sound'); - // Release audio focus after playback completes - // Critical for Android Auto - without this, car audio stays ducked - await _releaseAudioFocus(); - } on TimeoutException { - debugWarn('[AUDIO] Receive play() timed out after 3s — resetting audio session'); - await _rxPlayer?.stop(); - await _resetAudioSession(); + final session = await AudioSession.instance; + await session.setActive(true); } catch (e) { - debugError('[AUDIO] Failed to play receive sound: $e'); + debugError('[AUDIO] Failed to activate audio session: $e'); + // Continue anyway — playback may still work } } + /// Schedule a delayed audio focus release. + /// Debounced: if another sound plays within the delay window, the timer + /// resets so focus stays active throughout rapid TX→RX sequences. + /// Critical for Android Auto: eventually releases ducking so car audio resumes. + void _scheduleFocusRelease() { + _focusReleaseTimer?.cancel(); + _focusReleaseTimer = Timer(_focusReleaseDelay, () async { + try { + final session = await AudioSession.instance; + await session.setActive(false); + debugLog('[AUDIO] Audio focus released (debounced)'); + } catch (e) { + debugError('[AUDIO] Failed to release audio focus: $e'); + } + }); + } + /// Reset audio session after a play() timeout /// Stops both players, reconfigures the audio session, and reloads assets Future _resetAudioSession() async { + _focusReleaseTimer?.cancel(); try { // Stop both players await _txPlayer?.stop(); @@ -222,8 +251,8 @@ class AudioService { ); // Reload assets so players are ready for next play() - await _txPlayer?.setAsset('assets/transmitted_packet.mp3'); - await _rxPlayer?.setAsset('assets/received_packet.mp3'); + await _txPlayer?.setAsset(_txAsset); + await _rxPlayer?.setAsset(_rxAsset); debugLog('[AUDIO] Audio session reset after timeout'); } catch (e) { @@ -231,20 +260,6 @@ class AudioService { } } - /// Release audio focus after playback completes - /// This is critical for Android Auto - without explicitly releasing focus, - /// the car audio system stays ducked indefinitely - Future _releaseAudioFocus() async { - try { - final session = await AudioSession.instance; - await session.setActive(false); - debugLog('[AUDIO] Audio focus released'); - } catch (e) { - debugError('[AUDIO] Failed to release audio focus: $e'); - // Non-critical - audio still works, just may leave other audio ducked - } - } - /// Enable or disable sound notifications Future setEnabled(bool enabled) async { if (_enabled == enabled) return; @@ -262,6 +277,8 @@ class AudioService { /// Dispose of audio resources void dispose() { debugLog('[AUDIO] Disposing audio service'); + _focusReleaseTimer?.cancel(); + _focusReleaseTimer = null; _txPlayer?.dispose(); _rxPlayer?.dispose(); _txPlayer = null; From 82b9c8684675619037ddb88362edcf9e1f5f0d62 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 22 Mar 2026 21:49:05 -0400 Subject: [PATCH 5/8] - When a zone reports TX capacity full or TX not allowed, Send Ping and Active/Hybrid mode buttons are now hidden instead of showing disabled "Zone Full" states. The Passive mode button expands to fill the full control panel width. Applies to portrait, compact, and landscape layouts. --- lib/widgets/ping_controls.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/widgets/ping_controls.dart b/lib/widgets/ping_controls.dart index 805417d..c05b96f 100644 --- a/lib/widgets/ping_controls.dart +++ b/lib/widgets/ping_controls.dart @@ -86,6 +86,7 @@ class PingControls extends StatelessWidget { // Action buttons row Row( children: [ + if (!txNotAllowed) ...[ // Send Ping button // State flow: "Send Ping" → "Sending..." → "Listening Xs" → "Cooldown Xs" → "Send Ping" // Manual pings use 15-second cooldown, no distance requirement @@ -169,6 +170,7 @@ class PingControls extends StatelessWidget { ), ), const SizedBox(width: 10), + ], // Passive Mode button (toggle) // When ON: shows "Listening..." → "Next Disc Xs" cycle @@ -934,6 +936,7 @@ class _CompactPingControlsState extends State { // - Grey non-expanded buttons are icon-only return Row( children: [ + if (!txNotAllowed) ...[ // Send Ping - expanded buttons stay big even when grey (cooldown) if (sendPingExpanded) Expanded(child: sendPingButton) @@ -951,6 +954,7 @@ class _CompactPingControlsState extends State { else activeModeButton, const SizedBox(width: 6), + ], // Passive Mode if (passiveModeExpanded) @@ -1167,6 +1171,7 @@ class LandscapePingControls extends StatelessWidget { // Action buttons row (icon-only) Row( children: [ + if (!txNotAllowed) ...[ // TX Ping button Expanded( child: _LandscapeIconButton( @@ -1219,6 +1224,7 @@ class LandscapePingControls extends StatelessWidget { ), ), const SizedBox(width: 8), + ], // Passive Mode button Expanded( From b0e8814053aec0568f0ab0e1e8c97868b362b443 Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Sun, 22 Mar 2026 22:51:48 -0500 Subject: [PATCH 6/8] Refactor map widget selector state --- lib/widgets/map_widget.dart | 113 ++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 64 deletions(-) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 2e55dbf..cab6389 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -107,6 +107,25 @@ final class SilentCancellableNetworkTileProvider extends CancellableNetworkTileP ); } +typedef MapState = ({ + bool preferencesLoaded, + bool mapAutoFollow, + bool mapAlwaysNorth, + bool mapRotationLocked, + String mapStyle, + bool isImperial, + dynamic currentPosition, + ({double lat, double lon})? lastKnownPosition, + String? zoneCode, + int overlayCacheBust, + bool discDropEnabled, + int? effectiveHopBytes, + int mapNavigationTrigger, + ({double lat, double lon})? mapNavigationTarget, + int mapDataRevision, + double? distanceFromLastPing, +}); + /// Map widget with TX/RX markers /// Uses flutter_map with OpenStreetMap tiles class MapWidget extends StatefulWidget { @@ -662,24 +681,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - Widget _buildMap(AppStateProvider appState, ({ - bool preferencesLoaded, - bool mapAutoFollow, - bool mapAlwaysNorth, - bool mapRotationLocked, - String mapStyle, - bool isImperial, - dynamic currentPosition, - ({double lat, double lon})? lastKnownPosition, - String? zoneCode, - int overlayCacheBust, - bool discDropEnabled, - int? effectiveHopBytes, - int mapNavigationTrigger, - ({double lat, double lon})? mapNavigationTarget, - int mapDataRevision, - double? distanceFromLastPing, - }) mapState, LatLng center) { + Widget _buildMap(AppStateProvider appState, MapState mapState, LatLng center) { return Builder( builder: (context) => FlutterMap( mapController: _mapController, @@ -731,40 +733,40 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top - MarkerLayer( - markers: _buildCoverageMarkers( - txPings: appState.txPings, - rxPings: appState.rxPings, - discEntries: appState.discLogEntries, - discDropEnabled: appState.discDropEnabled, - traceEntries: appState.traceLogEntries, + MarkerLayer( + markers: _buildCoverageMarkers( + txPings: appState.txPings, + rxPings: appState.rxPings, + discEntries: appState.discLogEntries, + discDropEnabled: appState.discDropEnabled, + traceEntries: appState.traceLogEntries, + ), ), - ), - // Repeater markers (magenta with ID, rotate with map) + // Repeater markers (magenta with ID, rotate with map) MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - mapState.effectiveHopBytes, + rotate: true, + markers: _buildRepeaterMarkers( + appState.repeaters, + mapState.effectiveHopBytes, + ), ), - ), - // Current position marker (car icon) - if (mapState.currentPosition != null) - MarkerLayer( - markers: [ - Marker( - point: LatLng( - mapState.currentPosition.latitude, - mapState.currentPosition.longitude, + // Current position marker (car icon) + if (mapState.currentPosition != null) + MarkerLayer( + markers: [ + Marker( + point: LatLng( + mapState.currentPosition.latitude, + mapState.currentPosition.longitude, + ), + width: 48, + height: 48, + child: _buildCurrentPositionMarker(mapState.currentPosition.heading), ), - width: 48, - height: 48, - child: _buildCurrentPositionMarker(mapState.currentPosition.heading), - ), - ], - ), + ], + ), ], ), ); @@ -829,24 +831,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// GPS info overlay (top-left corner) - Widget _buildGpsInfoOverlay(({ - bool preferencesLoaded, - bool mapAutoFollow, - bool mapAlwaysNorth, - bool mapRotationLocked, - String mapStyle, - bool isImperial, - dynamic currentPosition, - ({double lat, double lon})? lastKnownPosition, - String? zoneCode, - int overlayCacheBust, - bool discDropEnabled, - int? effectiveHopBytes, - int mapNavigationTrigger, - ({double lat, double lon})? mapNavigationTarget, - int mapDataRevision, - double? distanceFromLastPing, - }) mapState) { + Widget _buildGpsInfoOverlay(MapState mapState) { final position = mapState.currentPosition; final distanceFromLastPing = mapState.distanceFromLastPing; From f447f829fcbbbdeb57a5a332604dc8484e7c5356 Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Sun, 22 Mar 2026 22:52:00 -0500 Subject: [PATCH 7/8] Dispose map tile providers --- lib/widgets/map_widget.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index cab6389..2402cfa 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -212,6 +212,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { void dispose() { _animationController?.dispose(); _rotationAnimationController?.dispose(); + _baseTileProvider.dispose(); + _overlayTileProvider.dispose(); super.dispose(); } From c17f559ccac01a49b8da96a178a2c9fa40ffc45c Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Sun, 22 Mar 2026 22:52:08 -0500 Subject: [PATCH 8/8] Document map data revision invalidation --- lib/providers/app_state_provider.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index abc51bc..2f1f8ad 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -521,6 +521,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int get offlinePingCount => _apiQueueService.offlinePingCount; OfflineSessionService get offlineSessionService => _offlineSessionService; + /// Bumps the selected map revision so widgets depending on marker/log data + /// rebuild even when the underlying collections are mutated in place. + /// + /// Call this whenever map-visible data changes without replacing the lists; + /// otherwise the map can keep showing stale markers until some unrelated + /// state change triggers a rebuild. void _markMapDataChanged() { _mapDataRevision++; }