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/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e090f55..2f1f8ad 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -237,6 +237,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) @@ -441,6 +442,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?; @@ -519,6 +521,16 @@ 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++; + } + /// Distance in meters from last TX ping position (like wardrive.js) double? get distanceFromLastPing { if (_currentPosition == null) return null; @@ -1319,6 +1331,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( @@ -1336,6 +1349,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( @@ -1818,6 +1832,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) @@ -1889,6 +1904,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 { @@ -1907,6 +1923,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)}'); } @@ -2825,6 +2842,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { void clearPings() { _txPings.clear(); _rxPings.clear(); + _markMapDataChanged(); _clearOverlayState(); _pingService?.resetStats(); notifyListeners(); @@ -2837,6 +2855,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _discLogEntries.clear(); _traceLogEntries.clear(); _errorLogEntries.clear(); + _markMapDataChanged(); _clearOverlayState(); notifyListeners(); } @@ -2847,6 +2866,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(); } @@ -4187,6 +4207,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Clear repeaters when exiting zone _repeaters = []; + _markMapDataChanged(); _repeatersLoaded = false; _repeatersLoadedForIata = null; } @@ -4275,6 +4296,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/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/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; 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..2402cfa 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 @@ -106,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 { @@ -138,6 +158,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,10 +201,19 @@ 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(); _rotationAnimationController?.dispose(); + _baseTileProvider.dispose(); + _overlayTileProvider.dispose(); super.dispose(); } @@ -419,40 +450,63 @@ 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, + )); + final overlayState = context.select((AppStateProvider state) => ( + showTopRepeaters: state.preferences.showTopRepeaters, + topRepeaters: state.topRepeatersBySnr, + rxOverlaySlot: state.rxOverlaySlot, + )); // 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 +516,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 +561,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 +583,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,7 +621,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return Stack( children: [ // Map - _buildMap(appState, center), + _buildMap(appState, mapState, center), // GPS Info + Top Repeaters overlay (top-left, respects dynamic island in landscape) Positioned( @@ -576,10 +630,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildGpsInfoOverlay(appState), - if (appState.preferences.showTopRepeaters) ...[ + _buildGpsInfoOverlay(mapState), + if (overlayState.showTopRepeaters) ...[ const SizedBox(height: 6), - _buildTopRepeatersOverlay(appState), + _buildTopRepeatersOverlay(overlayState.topRepeaters, overlayState.rxOverlaySlot), ], ], ), @@ -589,14 +643,14 @@ class _MapWidgetState extends State with TickerProviderStateMixin { 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); @@ -624,12 +678,12 @@ 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, MapState mapState, LatLng center) { return Builder( builder: (context) => FlutterMap( mapController: _mapController, @@ -655,75 +709,66 @@ 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) - 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), - ), - - // Repeater markers (magenta with ID, rotate with map) - MarkerLayer( - rotate: true, - markers: _buildRepeaterMarkers( - appState.repeaters, - appState.enforceHopBytes ? appState.effectiveHopBytes : null, + // 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, + ), ), - ), - // Current position marker (car icon) - if (appState.currentPosition != null) + // Repeater markers (magenta with ID, rotate with map) MarkerLayer( - markers: [ - Marker( - point: LatLng( - appState.currentPosition!.latitude, - appState.currentPosition!.longitude, - ), - width: 48, - height: 48, - child: _buildCurrentPositionMarker(appState.currentPosition!.heading), - ), - ], + 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, + ), + width: 48, + height: 48, + child: _buildCurrentPositionMarker(mapState.currentPosition.heading), + ), + ], + ), ], ), ); @@ -732,10 +777,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, }; } @@ -788,10 +833,62 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } /// GPS info overlay (top-left corner) + Widget _buildGpsInfoOverlay(MapState mapState) { + final position = mapState.currentPosition; + final distanceFromLastPing = mapState.distanceFromLastPing; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // GPS Status + Icon( + position != null ? Icons.gps_fixed : Icons.gps_off, + size: 14, + color: position != null ? _getAccuracyColor(position.accuracy) : Colors.grey, + ), + const SizedBox(width: 6), + Text( + position != null ? formatMeters(position.accuracy, isImperial: mapState.isImperial) : 'No GPS', + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: position != null ? _getAccuracyColor(position.accuracy) : Colors.grey, + ), + ), + // Distance since last TX ping (like wardrive.js) + if (position != null && distanceFromLastPing != null) ...[ + const SizedBox(width: 12), + const Icon( + Icons.straighten, + size: 12, + color: Colors.white70, + ), + const SizedBox(width: 4), + Text( + formatMeters(distanceFromLastPing, isImperial: mapState.isImperial), + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.white70, + ), + ), + ], + ], + ), + ); + } + /// Top heard repeaters overlay (bottom-right of map) - Widget _buildTopRepeatersOverlay(AppStateProvider appState) { - final topRepeaters = appState.topRepeatersBySnr; - final rxSlot = appState.rxOverlaySlot; + Widget _buildTopRepeatersOverlay( + List<({String repeaterId, double snr, OverlayPingType type})> topRepeaters, + ({String repeaterId, double snr})? rxSlot, + ) { final isEmpty = topRepeaters.isEmpty && rxSlot == null; return Container( @@ -851,58 +948,6 @@ class _MapWidgetState extends State with TickerProviderStateMixin { return Colors.green; } - Widget _buildGpsInfoOverlay(AppStateProvider appState) { - final position = appState.currentPosition; - final hasGps = position != null; - final distanceFromLastPing = appState.distanceFromLastPing; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // GPS Status - Icon( - hasGps ? Icons.gps_fixed : Icons.gps_off, - size: 14, - color: hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, - ), - const SizedBox(width: 6), - Text( - hasGps ? formatMeters(position.accuracy, isImperial: appState.preferences.isImperial) : 'No GPS', - style: TextStyle( - fontSize: 11, - fontFamily: 'monospace', - color: hasGps ? _getAccuracyColor(position.accuracy) : Colors.grey, - ), - ), - // Distance since last TX ping (like wardrive.js) - if (hasGps && distanceFromLastPing != null) ...[ - const SizedBox(width: 12), - const Icon( - Icons.straighten, - size: 12, - color: Colors.white70, - ), - const SizedBox(width: 4), - Text( - formatMeters(distanceFromLastPing, isImperial: appState.preferences.isImperial), - style: const TextStyle( - fontSize: 11, - fontFamily: 'monospace', - color: Colors.white70, - ), - ), - ], - ], - ), - ); - } - Color _getAccuracyColor(double accuracy) { if (accuracy <= 10) return Colors.green; if (accuracy <= 30) return Colors.orange; @@ -910,8 +955,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( @@ -929,7 +974,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, @@ -1229,49 +1274,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', ), @@ -1660,119 +1705,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 ? Colors.red : Colors.green, - 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 blue to match the RX chip in status bar - const color = Colors.blue; - - 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) { @@ -2026,8 +2052,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/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( 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'), ),