diff --git a/lib/models/api_queue_item.dart b/lib/models/api_queue_item.dart index 337cab1..0f58d31 100644 --- a/lib/models/api_queue_item.dart +++ b/lib/models/api_queue_item.dart @@ -52,6 +52,10 @@ class ApiQueueItem extends HiveObject { @HiveField(14) final bool externalAntenna; + /// Radio power in watts (e.g., 0.3, 1.0, 2.0) — included in every API post + @HiveField(15) + final double? power; + ApiQueueItem({ required this.type, required this.latitude, @@ -63,6 +67,7 @@ class ApiQueueItem extends HiveObject { this.retryCount = 0, this.lastRetryAt, this.noiseFloor, + this.power, }); /// Create from TX ping @@ -74,6 +79,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'TX', @@ -84,6 +90,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate — flush timer controls upload timing externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -96,6 +103,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'RX', @@ -106,6 +114,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -123,6 +132,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { // Format: "repeaterId:nodeType:localSnr:localRssi:remoteSnr:pubkeyFull" final heardRepeats = '$repeaterId:$nodeType:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}:$pubkeyFull'; @@ -135,6 +145,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -150,6 +161,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { final heardRepeats = '$repeaterId:${localSnr.toStringAsFixed(2)}:$localRssi:${remoteSnr.toStringAsFixed(2)}'; return ApiQueueItem( @@ -161,6 +173,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -171,6 +184,7 @@ class ApiQueueItem extends HiveObject { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) { return ApiQueueItem( type: 'DISC', @@ -181,6 +195,7 @@ class ApiQueueItem extends HiveObject { canUploadAfter: DateTime.now().millisecondsSinceEpoch, // Immediate externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); } @@ -201,6 +216,7 @@ class ApiQueueItem extends HiveObject { 'remote_snr': parts.length > 3 ? double.tryParse(parts[3]) : null, 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -216,6 +232,7 @@ class ApiQueueItem extends HiveObject { 'repeater_id': 'None', 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -234,6 +251,7 @@ class ApiQueueItem extends HiveObject { 'public_key': parts.length > 5 ? parts[5] : '', 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } @@ -245,6 +263,7 @@ class ApiQueueItem extends HiveObject { 'heard_repeats': heardRepeats, 'timestamp': timestamp.millisecondsSinceEpoch ~/ 1000, // Unix timestamp in seconds 'external_antenna': externalAntenna, + 'power': power != null ? '${power!.toStringAsFixed(1)}w' : null, }; } 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/models/user_preferences.dart b/lib/models/user_preferences.dart index 4063fc8..3275a1f 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -85,6 +85,21 @@ class UserPreferences { /// Show top 3 repeaters by SNR on the map during wardriving final bool showTopRepeaters; + /// Coverage marker style on the map (dot, pin, diamond) + final String markerStyle; + + /// GPS position marker style (arrow, car, bike, boat, walk) + final String gpsMarkerStyle; + + /// Color vision type for accessibility (none, protanopia, deuteranopia, tritanopia, achromatopsia) + final String colorVisionType; + + /// Download map tiles (base map + coverage overlay). When false, no tile network requests are made to save mobile data. + final bool mapTilesEnabled; + + /// Disconnect alert: play audible alert when pinging stops unexpectedly (BLE disconnect, idle timeout, maintenance) + final bool disconnectAlertEnabled; + const UserPreferences({ this.powerLevel = 0.3, this.txPower = 22, @@ -114,6 +129,11 @@ class UserPreferences { this.minPingDistanceMeters = 25, this.autoStopAfterIdle = true, this.showTopRepeaters = false, + this.markerStyle = 'dot', + this.gpsMarkerStyle = 'arrow', + this.colorVisionType = 'none', + this.mapTilesEnabled = true, + this.disconnectAlertEnabled = false, }); /// Create from JSON (for persistence) @@ -147,6 +167,11 @@ class UserPreferences { minPingDistanceMeters: (json['minPingDistanceMeters'] as int?) ?? 25, autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, + markerStyle: (json['markerStyle'] as String?) ?? 'dot', + gpsMarkerStyle: (json['gpsMarkerStyle'] as String?) ?? 'arrow', + colorVisionType: (json['colorVisionType'] as String?) ?? 'none', + mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, + disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, ); } @@ -181,6 +206,11 @@ class UserPreferences { 'minPingDistanceMeters': minPingDistanceMeters, 'autoStopAfterIdle': autoStopAfterIdle, 'showTopRepeaters': showTopRepeaters, + 'markerStyle': markerStyle, + 'gpsMarkerStyle': gpsMarkerStyle, + 'colorVisionType': colorVisionType, + 'mapTilesEnabled': mapTilesEnabled, + 'disconnectAlertEnabled': disconnectAlertEnabled, }; } @@ -214,6 +244,11 @@ class UserPreferences { int? minPingDistanceMeters, bool? autoStopAfterIdle, bool? showTopRepeaters, + String? markerStyle, + String? gpsMarkerStyle, + String? colorVisionType, + bool? mapTilesEnabled, + bool? disconnectAlertEnabled, }) { return UserPreferences( powerLevel: powerLevel ?? this.powerLevel, @@ -244,6 +279,11 @@ class UserPreferences { minPingDistanceMeters: minPingDistanceMeters ?? this.minPingDistanceMeters, autoStopAfterIdle: autoStopAfterIdle ?? this.autoStopAfterIdle, showTopRepeaters: showTopRepeaters ?? this.showTopRepeaters, + markerStyle: markerStyle ?? this.markerStyle, + gpsMarkerStyle: gpsMarkerStyle ?? this.gpsMarkerStyle, + colorVisionType: colorVisionType ?? this.colorVisionType, + mapTilesEnabled: mapTilesEnabled ?? this.mapTilesEnabled, + disconnectAlertEnabled: disconnectAlertEnabled ?? this.disconnectAlertEnabled, ); } @@ -302,7 +342,12 @@ class UserPreferences { other.deleteChannelOnDisconnect == deleteChannelOnDisconnect && other.minPingDistanceMeters == minPingDistanceMeters && other.autoStopAfterIdle == autoStopAfterIdle && - other.showTopRepeaters == showTopRepeaters; + other.showTopRepeaters == showTopRepeaters && + other.markerStyle == markerStyle && + other.gpsMarkerStyle == gpsMarkerStyle && + other.colorVisionType == colorVisionType && + other.mapTilesEnabled == mapTilesEnabled && + other.disconnectAlertEnabled == disconnectAlertEnabled; } @override @@ -335,6 +380,11 @@ class UserPreferences { minPingDistanceMeters, autoStopAfterIdle, showTopRepeaters, + markerStyle, + gpsMarkerStyle, + colorVisionType, + mapTilesEnabled, + disconnectAlertEnabled, ]); } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e090f55..712f8cf 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -37,6 +37,7 @@ import '../services/meshcore/unified_rx_handler.dart'; import '../services/ping_service.dart'; import '../services/countdown_timer_service.dart'; import '../utils/constants.dart'; +import '../utils/ping_colors.dart'; import '../services/wakelock_service.dart'; import '../utils/debug_logger_io.dart'; @@ -260,6 +261,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { int _reconnectRestoreGeneration = 0; static const int _maxReconnectAttempts = 3; static const Duration _reconnectDelay = Duration(seconds: 3); + static const Duration _reconnectDelayAfterBondError = Duration(seconds: 5); + bool _lastReconnectWasBondError = false; + + // Idle disconnect timer — disconnects after 15 min without manual ping or auto-ping + Timer? _idleDisconnectTimer; + static const Duration _idleDisconnectTimeout = Duration(minutes: 15); + + // Geofence zone check log throttle (while disconnected) + DateTime? _lastZoneCheckLogTime; + int _zoneCheckSuppressedCount = 0; // Map navigation trigger (for navigating to log entry coordinates) ({double lat, double lon})? _mapNavigationTarget; @@ -487,6 +498,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Audio service getters bool get isSoundEnabled => _audioService.isEnabled; + bool get isTxSoundEnabled => _audioService.isTxEnabled; + bool get isRxSoundEnabled => _audioService.isRxEnabled; + bool get isDisconnectAlertEnabled => _preferences.disconnectAlertEnabled; AudioService get audioService => _audioService; bool get isConnected => _connectionStep == ConnectionStep.connected; @@ -740,7 +754,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // This allows users to know if they've entered/exited a zone while moving // Skip zone checks when offline mode is enabled if (!isConnected && !_preferences.offlineMode && _shouldRecheckZone(position)) { - debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + // Throttle log to once per 30s to avoid spam while driving + final now = DateTime.now(); + if (_lastZoneCheckLogTime == null || now.difference(_lastZoneCheckLogTime!) >= const Duration(seconds: 30)) { + if (_zoneCheckSuppressedCount > 0) { + debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone (suppressed $_zoneCheckSuppressedCount similar in last 30s)'); + } else { + debugLog('[GEOFENCE] Moved 100m+ while disconnected, rechecking zone'); + } + _lastZoneCheckLogTime = now; + _zoneCheckSuppressedCount = 0; + } else { + _zoneCheckSuppressedCount++; + } await checkZoneStatus(); } @@ -1310,6 +1336,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Get external antenna value for API payloads _pingService!.getExternalAntenna = () => _preferences.externalAntenna; + // Get power level from preferences (includes per-device overrides and manual selection) + _pingService!.getPowerLevel = () => _preferences.powerLevel; + // Check if TX is allowed by API (zone capacity) _pingService!.checkTxAllowed = () => txAllowed; @@ -1707,6 +1736,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!_preferences.offlineMode) { _startZoneRefreshTimer(); } + + // Enable heartbeat immediately on connection to keep server session alive + // Previously only enabled on auto-ping start, causing silent session expiry + if (!_preferences.offlineMode && _apiService.hasSession) { + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + debugLog('[HEARTBEAT] Enabled on connection'); + } + + // Start 15-minute idle disconnect timer (cancelled by manual ping or auto-ping start) + _startIdleDisconnectTimer(); } else { // No API session - offline mode or auth skipped debugLog('[CONN] Connected without API session (offline mode)'); @@ -1955,6 +2000,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterId: entry.repeaterId, externalAntenna: _preferences.externalAntenna, noiseFloor: _meshCoreConnection?.lastNoiseFloor, + power: _preferences.powerLevel, ); // Update UI @@ -2167,6 +2213,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } Future _fullDisconnectCleanup() async { + // Guard against double cleanup (e.g., reconnect timeout + BLE disconnect event) + if (_connectionStep == ConnectionStep.disconnected) { + debugLog('[CONN] Already disconnected, skipping duplicate cleanup'); + return; + } _cancelPendingAutoPingRestore(); _connectionStep = ConnectionStep.disconnected; @@ -2182,6 +2233,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer.stop(); _cooldownTimer.stop(); if (_autoPingEnabled) { + if (!_userRequestedDisconnect) { + _playDisconnectAlert(); + } _autoPingEnabled = false; _idleAutoStopReference = null; debugLog('[AUTO] Auto-ping disabled due to BLE disconnect'); @@ -2237,8 +2291,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Start auto-reconnect after unexpected BLE disconnect Future _startAutoReconnect() async { _cancelPendingAutoPingRestore(); + _cancelIdleDisconnectTimer(); _isAutoReconnecting = true; _reconnectAttempt = 0; + _lastReconnectWasBondError = false; _connectionStep = ConnectionStep.reconnecting; // Remember auto-ping state before cleanup @@ -2307,8 +2363,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Auto-reconnect attempt $_reconnectAttempt of $_maxReconnectAttempts'); notifyListeners(); + // Use longer delay after bond errors to give iOS time to clear stale keys + final delay = _lastReconnectWasBondError ? _reconnectDelayAfterBondError : _reconnectDelay; + // Delay before attempting reconnection - _reconnectTimer = Timer(_reconnectDelay, () async { + _reconnectTimer = Timer(delay, () async { if (!_isAutoReconnecting) return; // Cancelled while waiting try { @@ -2318,6 +2377,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // If we get here and connection step is 'connected', success! if (_connectionStep == ConnectionStep.connected) { debugLog('[CONN] Auto-reconnect succeeded on attempt $_reconnectAttempt'); + _lastReconnectWasBondError = false; _onReconnectSuccess(); } else if (_isAutoReconnecting) { // Connection failed but didn't throw - try again @@ -2329,6 +2389,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } catch (e) { debugError('[CONN] Auto-reconnect attempt $_reconnectAttempt failed: $e'); if (_isAutoReconnecting) { + // Check for iOS apple-code 14 (Peer removed pairing information) + // The MeshCore device cleared its bond keys — clear iOS stale bond before retrying + await _handleBondErrorIfNeeded(e); + // Reset step back to reconnecting for UI _connectionStep = ConnectionStep.reconnecting; _connectionError = null; @@ -2339,6 +2403,41 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }); } + /// Start 15-minute idle disconnect timer. + /// Fires if user does not send a manual ping or start auto-ping within 15 minutes. + void _startIdleDisconnectTimer() { + _idleDisconnectTimer?.cancel(); + _idleDisconnectTimer = Timer(_idleDisconnectTimeout, () { + if (!isConnected || _autoPingEnabled) return; + debugLog('[IDLE] 15-minute idle timeout reached — disconnecting'); + logError('Disconnected: 15 minutes of inactivity', severity: ErrorSeverity.warning); + disconnect(); + }); + debugLog('[IDLE] Idle disconnect timer started (${_idleDisconnectTimeout.inMinutes} min)'); + } + + /// Cancel the idle disconnect timer + void _cancelIdleDisconnectTimer() { + if (_idleDisconnectTimer != null) { + _idleDisconnectTimer!.cancel(); + _idleDisconnectTimer = null; + debugLog('[IDLE] Idle disconnect timer cancelled'); + } + } + + /// Detect iOS apple-code 14/15 bond errors and clear the stale bond before retry + Future _handleBondErrorIfNeeded(Object error) async { + final errorStr = error.toString(); + if (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')) { + _lastReconnectWasBondError = true; + final deviceId = _rememberedDevice?.id; + if (deviceId != null) { + debugLog('[CONN] Bond error detected (apple-code 14/15) — clearing stale bond for $deviceId'); + await _bluetoothService.removeBond(deviceId); + } + } + } + /// Called when auto-reconnect succeeds void _onReconnectSuccess() { // Cancel timers @@ -2378,6 +2477,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Auto-ping restored after reconnect (mode=$previousMode)'); } }); + } else { + // No auto-ping to restore — start idle timer + _startIdleDisconnectTimer(); } notifyListeners(); @@ -2398,6 +2500,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimeoutTimer = null; _cancelPendingAutoPingRestore(); + // Alert if auto-ping was running before disconnect + if (_autoPingWasEnabled) { + _playDisconnectAlert(); + } + // Clear reconnect state _isAutoReconnecting = false; _reconnectAttempt = 0; @@ -2423,6 +2530,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect _userRequestedDisconnect = true; + // Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + // Cancel any active auto-reconnect _reconnectTimer?.cancel(); _reconnectTimer = null; @@ -2601,6 +2711,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Send a manual TX ping Future sendPing() async { if (_pingService == null) return false; + if (_isAutoReconnecting) { + debugLog('[PING] Ignoring ping during auto-reconnect'); + return false; + } // Check session validity before starting (skip in offline mode) if (!_preferences.offlineMode) { @@ -2608,6 +2722,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!sessionCheck) return false; } + // Reset idle disconnect timer (user is actively pinging) + _startIdleDisconnectTimer(); + // Set sending state immediately for instant UI feedback _isPingSending = true; notifyListeners(); @@ -2648,6 +2765,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Auto-stop auto-ping after prolonged idle (no movement) void _triggerIdleAutoStop() { if (!_autoPingEnabled) return; + _playDisconnectAlert(); final elapsed = _idleAutoStopReference != null ? DateTime.now().difference(_idleAutoStopReference!).inMinutes : 30; @@ -2706,8 +2824,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // End noise floor session when mode is disabled await _endNoiseFloorSession(); - // Disable heartbeat when stopping auto mode - _apiService.disableHeartbeat(); + // Keep heartbeat enabled (stays on while connected to prevent session expiry) + // Re-start idle disconnect timer now that user is idle again + _startIdleDisconnectTimer(); _autoPingEnabled = false; _idleAutoStopReference = null; @@ -2724,6 +2843,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[PASSIVE MODE] Stopped - no cooldown (listen-only mode)'); } } else { + // Cancel idle disconnect timer — auto-ping keeps the session active + _cancelIdleDisconnectTimer(); + // Check session validity before starting (skip in offline mode) if (!_preferences.offlineMode) { final sessionCheck = await _checkSessionBeforeAction(); @@ -3062,6 +3184,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return (success: false, error: _modeSwitchError); } + // Re-check zone status BEFORE auth (zone data was cleared when entering offline mode) + debugLog('[APP] Re-checking zone status before auth...'); + await checkZoneStatus(); + + if (zoneCode == null) { + debugError('[APP] Cannot switch to online mode: not in a zone'); + _modeSwitchError = 'Could not determine your zone. Check GPS and internet connection.'; + return (success: false, error: _modeSwitchError); + } + // ============================================================ // STAGE 1: Try existing public_key authentication // ============================================================ @@ -3189,12 +3321,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await ChannelService.setRegionalChannels(_regionalChannels); } - // 7. Re-check zone status - if (_currentPosition != null) { - debugLog('[GEOFENCE] Re-checking zone status after online mode enabled'); - await checkZoneStatus(); - } - debugLog('[APP] Successfully switched to online mode'); return (success: true, error: null); } catch (e) { @@ -3702,6 +3828,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _savePreferences(); } + /// Set color vision type for accessibility and persist + void setColorVisionType(String type) { + _preferences = _preferences.copyWith(colorVisionType: type); + PingColors.setColorVisionType( + ColorVisionType.values.firstWhere((e) => e.name == type, orElse: () => ColorVisionType.none), + ); + debugLog('[A11Y] Color vision type set to $type'); + notifyListeners(); + _savePreferences(); + } + /// Set unit system preference (metric or imperial) void setUnitSystem(String system) { _preferences = _preferences.copyWith(unitSystem: system); @@ -3754,6 +3891,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } + /// Set TX sound enabled state (ping sent / discovery sent) + Future setTxSoundEnabled(bool enabled) async { + await _audioService.setTxEnabled(enabled); + notifyListeners(); + } + + /// Set RX sound enabled state (repeater echo / RX observation) + Future setRxSoundEnabled(bool enabled) async { + await _audioService.setRxEnabled(enabled); + notifyListeners(); + } + + /// Set disconnect alert enabled state + Future setDisconnectAlertEnabled(bool enabled) async { + _preferences = _preferences.copyWith(disconnectAlertEnabled: enabled); + await _savePreferences(); + debugLog('[AUDIO] Disconnect alert ${enabled ? 'enabled' : 'disabled'}'); + notifyListeners(); + } + + /// Play disconnect alert if enabled (triple beep for unexpected ping stop) + void _playDisconnectAlert() { + if (!_audioService.isEnabled || !_preferences.disconnectAlertEnabled) return; + debugLog('[AUDIO] Playing disconnect alert — pinging stopped unexpectedly'); + _audioService.playAlertSound(); + } + /// Navigate to coordinates on map (triggered from log entries) void navigateToMapCoordinates(double latitude, double longitude) { _mapNavigationTarget = (lat: latitude, lon: longitude); @@ -3901,6 +4065,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { Future _handleMaintenanceModeConnected(String message, String? url) async { debugLog('[MAINTENANCE] Ending session due to maintenance mode'); + // Alert if auto-ping was running (maintenance is not user-initiated) + if (_autoPingEnabled) { + _playDisconnectAlert(); + } + // Log to error log (this sets _requestErrorLogSwitch = true) logError('Maintenance Mode Enabled: $message', severity: ErrorSeverity.warning); @@ -4710,6 +4879,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Apply saved min ping distance to GpsService and PingService _gpsService.setMinPingDistance(_preferences.minPingDistanceMeters.toDouble()); PingService.currentMinDistance = _preferences.minPingDistanceMeters; + + // Apply saved color vision type + PingColors.setColorVisionType( + ColorVisionType.values.firstWhere( + (e) => e.name == _preferences.colorVisionType, + orElse: () => ColorVisionType.none, + ), + ); } } catch (e) { debugLog('[APP] Failed to load preferences: $e'); @@ -5088,6 +5265,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _reconnectTimer?.cancel(); _reconnectTimeoutTimer?.cancel(); _restoreAutoPingTimer?.cancel(); + _idleDisconnectTimer?.cancel(); _offlineAutoSaveTimer?.cancel(); _zoneRefreshTimer?.cancel(); _tileRefreshTimer?.cancel(); 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..0064326 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); @@ -998,9 +999,7 @@ class _HomeScreenState extends State { /// Get color based on noise floor value (lower is better) Color _getNoiseFloorColor(int noiseFloor) { - if (noiseFloor <= -100) return Colors.green; // -100 to -120: great - if (noiseFloor <= -90) return Colors.orange; // -90 to -100: okay - return Colors.red; // 0 to -90: bad + return PingColors.noiseFloorColor(noiseFloor.toDouble()); } /// Get battery icon based on percentage diff --git a/lib/screens/log_screen.dart b/lib/screens/log_screen.dart index 97267e0..565f392 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), @@ -808,14 +809,7 @@ class _AllPingsTabState extends State<_AllPingsTab> { Widget _buildDiscNodeRow(BuildContext context, DiscoveredNodeEntry node) { final rxSnrColor = _snrColorFromValue(node.localSnr); final rssiColor = _rssiColor(node.localRssi); - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), @@ -830,10 +824,10 @@ class _AllPingsTabState extends State<_AllPingsTab> { Flexible(child: RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 14)), Text( node.nodeTypeLabel, - style: const TextStyle( + style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, - color: Color(0xFF7B68EE), + color: PingColors.discSuccess, ), ), ], @@ -984,29 +978,23 @@ class _AllPingsTabState extends State<_AllPingsTab> { static Color _snrColor(SnrSeverity? severity) { return switch (severity) { - SnrSeverity.poor => Colors.red, - SnrSeverity.fair => Colors.orange, - SnrSeverity.good => Colors.green, + SnrSeverity.poor => PingColors.signalBad, + SnrSeverity.fair => PingColors.signalMedium, + SnrSeverity.good => PingColors.signalGood, null => Colors.grey, }; } - static Color _snrColorFromValue(double snr) { - if (snr <= -1) return Colors.red; - if (snr <= 5) return Colors.orange; - return Colors.green; - } + static Color _snrColorFromValue(double snr) => PingColors.snrColor(snr); static Color _snrColorFromNullableValue(double? snr) { if (snr == null) return Colors.grey; - return _snrColorFromValue(snr); + return PingColors.snrColor(snr); } static Color _rssiColor(int? rssi) { if (rssi == null) return Colors.grey; - if (rssi >= -70) return Colors.green; - if (rssi >= -100) return Colors.orange; - return Colors.red; + return PingColors.rssiColor(rssi); } } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c99446f..b001366 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -146,6 +146,26 @@ class _SettingsScreenState extends State { appState.setThemeMode(isDark ? 'dark' : 'light'); }, ), + if (!kIsWeb) + _BackgroundModeToggle(appState: appState), + SwitchListTile( + secondary: Icon(prefs.mapTilesEnabled ? Icons.map : Icons.map_outlined), + title: const Text('Disable Map Tiles'), + subtitle: Text(prefs.mapTilesEnabled + ? 'Map and coverage tiles load normally' + : 'Disabled to save mobile data'), + value: !prefs.mapTilesEnabled, + onChanged: (value) { + appState.updatePreferences(prefs.copyWith(mapTilesEnabled: !value)); + }, + ), + ListTile( + leading: const Icon(Icons.visibility), + title: const Text('Color Vision'), + subtitle: Text(_colorVisionLabel(prefs.colorVisionType)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showColorVisionSelector(context, appState), + ), SwitchListTile( secondary: Icon( prefs.isImperial ? Icons.square_foot : Icons.straighten, @@ -166,8 +186,50 @@ class _SettingsScreenState extends State { appState.updatePreferences(prefs.copyWith(showTopRepeaters: value)); }, ), - if (!kIsWeb) - _BackgroundModeToggle(appState: appState), + ListTile( + leading: const Icon(Icons.place), + title: const Text('Map Marker Style'), + subtitle: Text(_markerStyleLabel(prefs.markerStyle)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showMarkerStyleSelector(context, appState), + ), + ListTile( + leading: const Icon(Icons.my_location), + title: const Text('GPS Marker'), + subtitle: Text(_gpsMarkerLabel(prefs.gpsMarkerStyle)), + trailing: const Icon(Icons.chevron_right), + onTap: () => _showGpsMarkerSelector(context, appState), + ), + SwitchListTile( + secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), + title: const Text('Sound Notifications'), + subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), + value: appState.isSoundEnabled, + onChanged: (_) => appState.toggleSoundEnabled(), + ), + if (appState.isSoundEnabled) ...[ + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Ping Sent'), + subtitle: const Text('Sound when TX ping or discovery is sent'), + value: appState.isTxSoundEnabled, + onChanged: (value) => appState.setTxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Response Received'), + subtitle: const Text('Sound when repeater echo or RX is received'), + value: appState.isRxSoundEnabled, + onChanged: (value) => appState.setRxSoundEnabled(value), + ), + SwitchListTile( + secondary: const SizedBox(width: 24), + title: const Text('Disconnect Alert'), + subtitle: const Text('Triple beep when pinging stops unexpectedly'), + value: appState.isDisconnectAlertEnabled, + onChanged: (value) => appState.setDisconnectAlertEnabled(value), + ), + ], ]), // Ping Settings @@ -207,13 +269,6 @@ class _SettingsScreenState extends State { enabled: !isAutoMode, onTap: isAutoMode ? null : () => _showDistanceSelector(context, appState), ), - SwitchListTile( - secondary: Icon(appState.isSoundEnabled ? Icons.volume_up : Icons.volume_off), - title: const Text('Sound Notifications'), - subtitle: Text(appState.isSoundEnabled ? 'Plays on ping events' : 'Silent'), - value: appState.isSoundEnabled, - onChanged: (_) => appState.toggleSoundEnabled(), - ), SwitchListTile( secondary: const Icon(Icons.timer_off), title: const Text('Auto-Stop After Idle'), @@ -879,6 +934,179 @@ class _SettingsScreenState extends State { ); } + String _markerStyleLabel(String style) { + switch (style) { + case 'circle': return 'Outlined Dot'; + case 'pin': return 'Pin'; + case 'diamond': return 'Diamond'; + case 'dot': + default: return 'Dot'; + } + } + + String _gpsMarkerLabel(String style) { + switch (style) { + case 'car': return 'Car'; + case 'bike': return 'Bike'; + case 'boat': return 'Boat'; + case 'walk': return 'Walk'; + case 'arrow': + default: return 'Arrow'; + } + } + + void _showMarkerStyleSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('dot', 'Dot', Icons.circle), + ('circle', 'Outlined Dot', Icons.circle_outlined), + ('pin', 'Pin', Icons.place), + ('diamond', 'Diamond', Icons.diamond), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Map Marker Style', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.markerStyle, + onChanged: (v) { + if (v != null) { + appState.updatePreferences(appState.preferences.copyWith(markerStyle: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, icon) in options) + RadioListTile( + secondary: Icon(icon), + title: Text(label), + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + void _showGpsMarkerSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('arrow', 'Arrow', Icons.navigation), + ('car', 'Car', Icons.directions_car), + ('bike', 'Bike', Icons.directions_bike), + ('boat', 'Boat', Icons.directions_boat), + ('walk', 'Walk', Icons.directions_walk), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('GPS Marker', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.gpsMarkerStyle, + onChanged: (v) { + if (v != null) { + appState.updatePreferences(appState.preferences.copyWith(gpsMarkerStyle: v)); + } + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, icon) in options) + RadioListTile( + secondary: Icon(icon), + title: Text(label), + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + String _colorVisionLabel(String type) { + return switch (type) { + 'protanopia' => 'Protanopia (red-blind)', + 'deuteranopia' => 'Deuteranopia (green-blind)', + 'tritanopia' => 'Tritanopia (blue-blind)', + 'achromatopsia' => 'Achromatopsia (monochrome)', + _ => 'Default', + }; + } + + void _showColorVisionSelector(BuildContext context, AppStateProvider appState) { + final options = [ + ('none', 'Default', 'Standard color palette'), + ('protanopia', 'Protanopia', 'Red-blind — difficulty distinguishing red and green'), + ('deuteranopia', 'Deuteranopia', 'Green-blind — difficulty distinguishing red and green'), + ('tritanopia', 'Tritanopia', 'Blue-blind — difficulty distinguishing blue and yellow'), + ('achromatopsia', 'Achromatopsia', 'Total color blindness — sees in greyscale'), + ]; + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text('Color Vision', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ), + RadioGroup( + groupValue: appState.preferences.colorVisionType, + onChanged: (v) { + if (v != null) appState.setColorVisionType(v); + Navigator.pop(context); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (value, label, subtitle) in options) + RadioListTile( + secondary: const Icon(Icons.visibility), + title: Text(label), + subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null, + value: value, + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + Widget _buildSection(BuildContext context, String title, List children) { return Padding( padding: const EdgeInsets.only(bottom: 8), diff --git a/lib/services/api_queue_service.dart b/lib/services/api_queue_service.dart index 2cb45fa..eb8b70f 100644 --- a/lib/services/api_queue_service.dart +++ b/lib/services/api_queue_service.dart @@ -223,6 +223,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromTx( latitude: latitude, @@ -231,6 +232,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -266,6 +268,7 @@ class ApiQueueService { required String repeaterId, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromRx( latitude: latitude, @@ -274,6 +277,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -309,6 +313,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromDisc( latitude: latitude, @@ -322,6 +327,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -358,6 +364,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromTrace( latitude: latitude, @@ -369,6 +376,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue @@ -401,6 +409,7 @@ class ApiQueueService { required int timestamp, required bool externalAntenna, int? noiseFloor, + double? power, }) async { final item = ApiQueueItem.fromDiscDrop( latitude: latitude, @@ -408,6 +417,7 @@ class ApiQueueService { timestamp: timestamp, externalAntenna: externalAntenna, noiseFloor: noiseFloor, + power: power, ); // In offline mode, accumulate to offline pings list instead of queue diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 064c25a..2da5d0c 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -40,7 +40,7 @@ class ApiService { int? _sessionExpiresAt; Timer? _heartbeatTimer; Timer? _heartbeatRetryTimer; - Timer? _sessionDeadlineTimer; + int _heartbeatRetryCount = 0; static const int _maxHeartbeatRetries = 5; Function? _onSessionExpiring; @@ -607,8 +607,6 @@ class ApiService { _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; _heartbeatRetryCount = 0; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; debugLog('[HEARTBEAT] Heartbeat mode disabled'); } @@ -642,9 +640,6 @@ class ApiService { _sendScheduledHeartbeat(); }); } - - // Schedule session deadline timer at exact expiry - _scheduleSessionDeadline(expiresAt); } /// Send scheduled heartbeat with GPS coordinates @@ -693,32 +688,6 @@ class ApiService { } } - /// Schedule a hard deadline timer at the exact session expiry time. - /// If the server is unreachable and all heartbeat retries fail, this fires - /// and triggers the same disconnect flow as a server-returned session_expired. - void _scheduleSessionDeadline(int expiresAt) { - _sessionDeadlineTimer?.cancel(); - if (!_heartbeatEnabled) return; - - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final secondsUntilExpiry = expiresAt - now; - - if (secondsUntilExpiry <= 0) { - _onSessionDeadlineReached(); - return; - } - - debugLog('[HEARTBEAT] Session deadline set for ${secondsUntilExpiry}s from now'); - _sessionDeadlineTimer = Timer(Duration(seconds: secondsUntilExpiry), _onSessionDeadlineReached); - } - - /// Called when the session deadline timer fires — server was unreachable - void _onSessionDeadlineReached() { - debugError('[HEARTBEAT] Session deadline reached - server unreachable, triggering session expiry'); - _clearSession(); - onSessionError?.call('session_expired', 'Session has timed out (server unreachable)'); - } - /// Clear session data and cancel all timers void _clearSession() { _sessionId = null; @@ -736,8 +705,6 @@ class ApiService { _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; _heartbeatRetryCount = 0; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; debugLog('[API] Session cleared'); } @@ -982,8 +949,6 @@ class ApiService { _heartbeatTimer = null; _heartbeatRetryTimer?.cancel(); _heartbeatRetryTimer = null; - _sessionDeadlineTimer?.cancel(); - _sessionDeadlineTimer = null; _client.close(); } } diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 41898ca..6b74c07 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -11,11 +11,23 @@ import '../utils/debug_logger_io.dart'; class AudioService { static const String _prefsBoxName = 'audio_preferences'; static const String _enabledKey = 'sound_enabled'; + static const String _txEnabledKey = 'tx_sound_enabled'; + static const String _rxEnabledKey = 'rx_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 + bool _txEnabled = true; // TX sound sub-toggle (only matters when master is on) + bool _rxEnabled = true; // RX sound sub-toggle (only matters when master is on) + Timer? _focusReleaseTimer; /// Whether the audio service is initialized bool get isInitialized => _initialized; @@ -23,6 +35,12 @@ class AudioService { /// Whether sound notifications are enabled bool get isEnabled => _enabled; + /// Whether TX sound is enabled (ping sent / discovery sent) + bool get isTxEnabled => _txEnabled; + + /// Whether RX sound is enabled (repeater echo / RX observation) + bool get isRxEnabled => _rxEnabled; + /// Initialize the audio service and pre-load sounds Future initialize() async { if (_initialized) return; @@ -66,9 +84,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'); @@ -92,9 +109,15 @@ class AudioService { } else { debugLog('[AUDIO] No saved preference, using default: $_enabled'); } + + final txEnabled = box.get(_txEnabledKey); + if (txEnabled != null) _txEnabled = txEnabled as bool; + final rxEnabled = box.get(_rxEnabledKey); + if (rxEnabled != null) _rxEnabled = rxEnabled as bool; + debugLog('[AUDIO] Loaded sub-toggles: tx=$_txEnabled, rx=$_rxEnabled'); } catch (e) { debugError('[AUDIO] Failed to load enabled state: $e'); - // Keep default (disabled) + // Keep defaults } } @@ -147,55 +170,79 @@ 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; - } + if (!_txEnabled) return; + await _playSound(_txPlayer, _txAsset, 'TX'); + } + + /// Play the receive sound (when repeater echo or RX observation is detected) + Future playReceiveSound() async { + if (!_rxEnabled) return; + 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 +269,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,17 +278,28 @@ 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 { + /// Play disconnect alert sound (triple beep pattern). + /// Independent of master sound toggle — this is a safety alert. + Future playAlertSound() async { + if (!_initialized || _txPlayer == null) return; + try { - final session = await AudioSession.instance; - await session.setActive(false); - debugLog('[AUDIO] Audio focus released'); + await _ensureSessionActive(); + for (int i = 0; i < 3; i++) { + await _txPlayer!.seek(Duration.zero); + await _txPlayer!.play().timeout(const Duration(seconds: 3)); + if (i < 2) { + await Future.delayed(const Duration(milliseconds: 300)); + } + } + debugLog('[AUDIO] Played disconnect alert (triple beep)'); + _scheduleFocusRelease(); + } on TimeoutException { + debugWarn('[AUDIO] Alert play() timed out — resetting audio session'); + await _txPlayer!.stop(); + await _resetAudioSession(); } catch (e) { - debugError('[AUDIO] Failed to release audio focus: $e'); - // Non-critical - audio still works, just may leave other audio ducked + debugError('[AUDIO] Failed to play alert sound: $e'); } } @@ -259,9 +317,38 @@ class AudioService { await setEnabled(!_enabled); } + /// Enable or disable TX sound notifications + Future setTxEnabled(bool enabled) async { + if (_txEnabled == enabled) return; + _txEnabled = enabled; + debugLog('[AUDIO] TX sound ${enabled ? 'enabled' : 'disabled'}'); + await _saveSetting(_txEnabledKey, enabled); + } + + /// Enable or disable RX sound notifications + Future setRxEnabled(bool enabled) async { + if (_rxEnabled == enabled) return; + _rxEnabled = enabled; + debugLog('[AUDIO] RX sound ${enabled ? 'enabled' : 'disabled'}'); + await _saveSetting(_rxEnabledKey, enabled); + } + + /// Save a single setting to Hive + Future _saveSetting(String key, dynamic value) async { + final box = await _openBoxSafely(_prefsBoxName); + if (box == null) return; + try { + await box.put(key, value); + } catch (e) { + debugError('[AUDIO] Failed to save $key: $e'); + } + } + /// 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/bluetooth/bluetooth_service.dart b/lib/services/bluetooth/bluetooth_service.dart index e1fe5eb..4702178 100644 --- a/lib/services/bluetooth/bluetooth_service.dart +++ b/lib/services/bluetooth/bluetooth_service.dart @@ -86,6 +86,12 @@ abstract class BluetoothService { /// Used for remembered devices to ensure name is available during connect void cacheDeviceInfo(DiscoveredDevice device); + /// Remove BLE bond/pairing for a device + /// On Android: removes the system bond entry + /// On iOS: best-effort — calls cancelPeripheralConnection to nudge CoreBluetooth + /// into clearing stale encryption keys (used after apple-code 14 errors) + Future removeBond(String deviceId); + /// Dispose of resources void dispose(); } diff --git a/lib/services/bluetooth/mobile_bluetooth.dart b/lib/services/bluetooth/mobile_bluetooth.dart index d279477..8fb3d62 100644 --- a/lib/services/bluetooth/mobile_bluetooth.dart +++ b/lib/services/bluetooth/mobile_bluetooth.dart @@ -397,13 +397,26 @@ class MobileBluetoothService implements BluetoothService { return; // Success - exit retry loop } catch (e, stackTrace) { + final errorStr = e.toString(); + // Check for Android error 133 (GATT_ERROR) - a well-known Android BLE stack issue // that typically succeeds on retry - final isError133 = Platform.isAndroid && e.toString().contains('android-code: 133'); - - if (isError133 && attempt < _maxRetries) { - debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); - await Future.delayed(_retryDelay); + final isError133 = Platform.isAndroid && errorStr.contains('android-code: 133'); + + // Check for iOS apple-code 14 (Peer removed pairing information) or + // apple-code 15 (Failed to encrypt the connection) — both indicate stale bond keys + final isBondError = Platform.isIOS && + (errorStr.contains('apple-code: 14') || errorStr.contains('apple-code: 15') || errorStr.contains('Peer removed pairing information')); + + if ((isError133 || isBondError) && attempt < _maxRetries) { + if (isBondError) { + debugLog('[BLE] Bond error (apple-code 14/15) on attempt $attempt, removing bond and retrying...'); + await removeBond(deviceId); + await Future.delayed(const Duration(seconds: 2)); + } else { + debugLog('[BLE] Error 133 on attempt $attempt, retrying after delay...'); + await Future.delayed(_retryDelay); + } // Force cleanup before retry try { await _bleDevice?.disconnect(); @@ -470,6 +483,19 @@ class MobileBluetoothService implements BluetoothService { debugLog('[BLE] Cached device info: ${device.name} (${device.id})'); } + @override + Future removeBond(String deviceId) async { + try { + final device = fbp.BluetoothDevice.fromId(deviceId); + debugLog('[BLE] Removing bond for $deviceId'); + await device.removeBond(); + debugLog('[BLE] Bond removed for $deviceId'); + } catch (e) { + // removeBond may not be supported on all platforms/devices — log and continue + debugLog('[BLE] removeBond failed (continuing): $e'); + } + } + @override void dispose() { _isDisposed = true; diff --git a/lib/services/bluetooth/web_bluetooth.dart b/lib/services/bluetooth/web_bluetooth.dart index 81054ed..ef5ed4c 100644 --- a/lib/services/bluetooth/web_bluetooth.dart +++ b/lib/services/bluetooth/web_bluetooth.dart @@ -262,6 +262,11 @@ class WebBluetoothService implements BluetoothService { // No caching needed - this method is for mobile remembered devices } + @override + Future removeBond(String deviceId) async { + // Web Bluetooth does not support bond management + } + @override void dispose() { _notificationSubscription?.cancel(); diff --git a/lib/services/gps_service.dart b/lib/services/gps_service.dart index 753c67c..6eced5c 100644 --- a/lib/services/gps_service.dart +++ b/lib/services/gps_service.dart @@ -358,6 +358,30 @@ class GpsService { return null; // Valid } + /// Request a fresh GPS position from the hardware for auto-ping accuracy. + /// On mobile, this forces a warm-start GPS read (typically < 1 second when + /// GPS is already streaming). Falls back to lastPosition on timeout/error. + Future getFreshPosition({Duration timeout = const Duration(seconds: 3)}) async { + // Simulator provides its own positions — use cached + if (_simulatorEnabled) { + return _lastPosition; + } + + try { + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + timeLimit: timeout, + ); + debugLog('[GPS] Fresh position acquired: ${position.latitude.toStringAsFixed(5)}, ' + '${position.longitude.toStringAsFixed(5)} (accuracy: ${position.accuracy.toStringAsFixed(1)}m)'); + _lastPosition = position; + return position; + } catch (e) { + debugLog('[GPS] Fresh position request failed, using cached: $e'); + return _lastPosition; + } + } + /// Get current position (single request) Future getCurrentPosition() async { if (!await requestPermissions()) { diff --git a/lib/services/meshcore/connection.dart b/lib/services/meshcore/connection.dart index 07c0810..5f7513d 100644 --- a/lib/services/meshcore/connection.dart +++ b/lib/services/meshcore/connection.dart @@ -65,6 +65,7 @@ class SelfInfo { /// 9. Connected State class MeshCoreConnection { final BluetoothService _bluetooth; + bool _disposed = false; final _stepController = StreamController.broadcast(); final _channelMessageController = StreamController.broadcast(); final _rawDataController = StreamController>.broadcast(); @@ -84,6 +85,7 @@ class MeshCoreConnection { Completer? _deviceQueryCompleter; Completer? _selfInfoCompleter; Completer? _sentCompleter; + Completer? _setTimeCompleter; Completer? _channelInfoCompleter; Completer? _statsCompleter; Completer? _exportContactCompleter; @@ -176,8 +178,8 @@ class MeshCoreConnection { void _updateStep(ConnectionStep step) { _currentStep = step; - if (_stepController.isClosed) { - debugError('[CONN] Cannot update step - controller is closed!'); + if (_disposed || _stepController.isClosed) { + debugLog('[CONN] Ignoring step update on disposed connection (expected during reconnect)'); return; } debugLog('[CONN] Step: $step'); @@ -188,8 +190,11 @@ class MeshCoreConnection { /// Returns (deviceModel, deviceModelMatched) for display/reporting purposes /// Note: This method does NOT modify radio TX power settings - it only reads device info Future<({DeviceModel? deviceModel, bool deviceModelMatched})> connect(String deviceId, List deviceModels) async { + if (_disposed) { + throw Exception('Connection instance has been disposed'); + } bool deviceModelMatched = false; - + try { // Step 1: BLE Connect _updateStep(ConnectionStep.bleConnecting); @@ -369,10 +374,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); @@ -641,10 +659,16 @@ class MeshCoreConnection { if (statsType == StatsTypes.radio) { final noiseFloor = reader.readInt16LE(); // Skip remaining fields (lastRssi, lastSnr, txAirSecs, rxAirSecs) - _lastNoiseFloor = noiseFloor; - _noiseFloorController.add(noiseFloor); // Emit to stream - debugLog('[CONN] Noise floor updated: ${noiseFloor}dBm'); - _statsCompleter?.complete(noiseFloor); + if (noiseFloor == 0) { + // MeshCore 1.14.x AGC reset zeroes out noise floor briefly; discard + debugLog('[CONN] Noise floor reading is 0dBm (AGC reset), ignoring'); + _statsCompleter?.complete(0); + } else { + _lastNoiseFloor = noiseFloor; + _noiseFloorController.add(noiseFloor); // Emit to stream + debugLog('[CONN] Noise floor updated: ${noiseFloor}dBm'); + _statsCompleter?.complete(noiseFloor); + } } else { debugLog('[CONN] Unknown stats type: $statsType'); _statsCompleter?.complete(0); @@ -758,12 +782,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 @@ -925,9 +960,10 @@ class MeshCoreConnection { } /// Send ping to #wardriving channel - /// Format: @[MapperBot] LAT, LON [power] + /// Format: @[MapperBot] LAT, LON + /// Power is no longer included in the mesh message — it is sent per-ping in the API payload instead /// Reference: buildPayload() in wardrive.js - Future sendPing(double lat, double lon, double powerWatts) async { + Future sendPing(double lat, double lon) async { final channel = _wardrivingChannel; if (channel == null) { throw Exception('Wardriving channel not initialized'); @@ -935,9 +971,7 @@ class MeshCoreConnection { // Format coordinates to 5 decimal places with comma separator final coordsStr = '${lat.toStringAsFixed(5)}, ${lon.toStringAsFixed(5)}'; - // Format power as "X.Xw" (e.g., "1.0w", "0.3w") - final powerStr = '${powerWatts.toStringAsFixed(1)}w'; - final message = '@[MapperBot] $coordsStr [$powerStr]'; + final message = '@[MapperBot] $coordsStr'; debugLog('[CONN] Sending ping: $message'); final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; @@ -1156,8 +1190,10 @@ class MeshCoreConnection { /// Dispose of resources void dispose() { + _disposed = true; _stopNoiseFloorPolling(); _stopBatteryPolling(); + _setTimeCompleter = null; _dataSubscription?.cancel(); _stepController.close(); _channelMessageController.close(); diff --git a/lib/services/meshcore/disc_tracker.dart b/lib/services/meshcore/disc_tracker.dart index 5796d6f..23dd9d6 100644 --- a/lib/services/meshcore/disc_tracker.dart +++ b/lib/services/meshcore/disc_tracker.dart @@ -45,7 +45,7 @@ class DiscTracker { /// @param windowDuration - How long to listen (default 7 seconds) void startTracking({ required Uint8List tag, - Duration windowDuration = const Duration(seconds: 5), + Duration windowDuration = const Duration(seconds: 7), }) { debugLog('[DISC] Starting discovery tracking'); debugLog('[DISC] Tag: ${tag.map((b) => b.toRadixString(16).padLeft(2, '0')).join('')}'); diff --git a/lib/services/ping_service.dart b/lib/services/ping_service.dart index d782282..50bc46e 100644 --- a/lib/services/ping_service.dart +++ b/lib/services/ping_service.dart @@ -44,8 +44,8 @@ class PingService { static const Duration _rxListeningWindow = Duration(seconds: 5); /// Cooldown period between pings (5 seconds) static const Duration _autoPingCooldown = Duration(seconds: 5); - /// Discovery listening window duration (5 seconds) - static const Duration _discoveryListeningWindow = Duration(seconds: 5); + /// Discovery listening window duration (7 seconds) + static const Duration _discoveryListeningWindow = Duration(seconds: 7); /// Discovery request interval (30 seconds - repeaters only respond 4 times per 2 minutes) static const Duration _discoveryInterval = Duration(seconds: 30); /// Cooldown period between manual pings (15 seconds) @@ -137,6 +137,9 @@ class PingService { /// Callback to get the external antenna value for API payloads bool Function()? getExternalAntenna; + /// Callback to get the power level in watts (0.3, 0.6, 1.0, 2.0) from user preferences + double Function()? getPowerLevel; + /// Callback to check if discovery drop is enabled (failed discoveries → API) bool Function()? getDiscDropEnabled; @@ -471,6 +474,13 @@ class PingService { Future sendTxPing({bool manual = true}) async { debugLog('[PING] sendTxPing called (manual=$manual)'); + // Guard: don't send pings if connection is not in connected state + // Handles race where timer callback fires after reconnect started + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[PING] Ignoring TX ping — not connected (step: ${_connection.currentStep})'); + return false; + } + // Early guard: prevent concurrent ping execution (critical for preventing BLE GATT errors) // Reference: state.pingInProgress check in wardrive.js if (_pingInProgress) { @@ -480,6 +490,13 @@ class PingService { _pingInProgress = true; try { + // For auto pings, request a fresh GPS position before validation. + // This ensures the 25m distance check and ping coordinates reflect + // where the device is NOW, not where it was at the last stream event. + if (!manual) { + await _gpsService.getFreshPosition(); + } + // Use different validation and cooldown for manual vs auto pings if (manual) { // Manual ping: 15-second cooldown, no distance check @@ -515,7 +532,11 @@ class PingService { _skipReason = 'too close'; debugLog('[PING] Auto ping blocked: too close to last ping, scheduling next'); } - _scheduleNextAutoPing(); + if (_hybridModeEnabled) { + _scheduleNextHybridPing(); + } else { + _scheduleNextAutoPing(); + } } _pingInProgress = false; return false; @@ -531,15 +552,12 @@ class PingService { _pingInProgress = false; return false; } - // Use power in watts (0.3, 0.6, 1.0, 2.0) - matches web client buildPayload() - final powerWatts = _connection.deviceModel?.power ?? 0.3; - // Also get txPower in dBm for API queue (for database records) final txPowerDbm = _connection.deviceModel?.txPower ?? 22; // Build ping message (same format used for TxTracker correlation) + // Power is no longer included in the mesh message — sent per-ping in API payload final coordsStr = '${position.latitude.toStringAsFixed(5)}, ${position.longitude.toStringAsFixed(5)}'; - final powerStr = '${powerWatts.toStringAsFixed(1)}w'; - final pingMessage = '@[MapperBot] $coordsStr [$powerStr]'; + final pingMessage = '@[MapperBot] $coordsStr'; // Capture noise floor at ping time final noiseFloor = _connection.lastNoiseFloor; @@ -620,8 +638,8 @@ class PingService { // Play transmit sound immediately before sending _audioService?.playTransmitSound(); - // Send ping via BLE - uses watts format like "1.0w" - await _connection.sendPing(position.latitude, position.longitude, powerWatts); + // Send ping via BLE (coordinates only — power is in API payload) + await _connection.sendPing(position.latitude, position.longitude); // Mark ping time and position _lastTxTime = DateTime.now(); @@ -725,6 +743,7 @@ class PingService { timestamp: txTimestamp, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); debugLog('[PING] Queued TX entry with heard_repeats: $heardRepeats'); @@ -808,6 +827,11 @@ class PingService { _autoTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { debugLog('[ACTIVE MODE] Auto ping timer fired'); + // Guard: connection may have dropped since timer was scheduled + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[ACTIVE MODE] Not connected, ignoring timer'); + return; + } // Double-check guards before sending ping if (!_autoPingEnabled || _passiveModeEnabled) { debugLog('[ACTIVE MODE] Auto mode no longer running, ignoring timer'); @@ -1081,13 +1105,19 @@ class PingService { /// Send a discovery request and start listening window Future _sendDiscoveryRequest() async { + // Guard: don't send discovery during reconnect (race with timer queue) + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[DISC] Ignoring discovery request — not connected (step: ${_connection.currentStep})'); + return; + } + if (!_autoPingEnabled || (!_passiveModeEnabled && !_hybridModeEnabled)) { debugLog('[DISC] Not in Passive/Hybrid Mode, skipping discovery request'); return; } - // Check GPS - final position = _gpsService.lastPosition; + // Request fresh GPS position before discovery (same rationale as TX auto-ping) + final position = await _gpsService.getFreshPosition(); if (position == null) { debugLog('[DISC] No GPS position, skipping discovery request'); _pingInProgress = false; @@ -1205,6 +1235,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); } @@ -1222,6 +1253,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); debugLog('[DISC] Discovery drop queued (no response)'); } @@ -1275,8 +1307,10 @@ class PingService { _autoTimer = null; // Subtract listening window so interval is measured start-to-start - // At 15s: wait = 15000 - 5000 = 10000ms. Clamp to min 1s. - final listenMs = _rxListeningWindow.inMilliseconds; // 5000 + // TX uses 5s RX window, discovery uses 7s window + final listenMs = _nextPingIsDiscovery + ? _discoveryListeningWindow.inMilliseconds + : _rxListeningWindow.inMilliseconds; final waitMs = (_autoPingIntervalMs - listenMs).clamp(1000, _autoPingIntervalMs); final isNextDisc = _nextPingIsDiscovery; @@ -1286,6 +1320,10 @@ class PingService { _autoTimer = Timer(Duration(milliseconds: waitMs), () { if (!_autoPingEnabled || !_hybridModeEnabled) return; + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[HYBRID] Not connected, ignoring timer'); + return; + } if (_pingInProgress) { debugLog('[HYBRID] Ping already in progress, skipping'); return; @@ -1471,6 +1509,7 @@ class PingService { timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, externalAntenna: getExternalAntenna?.call() ?? false, noiseFloor: _pendingTxNoiseFloor, + power: getPowerLevel?.call(), ); // Update stats @@ -1500,6 +1539,10 @@ class PingService { _targetedTimer?.cancel(); _targetedTimer = Timer(Duration(milliseconds: _autoPingIntervalMs), () { debugLog('[TRACE] Targeted ping timer fired'); + if (_connection.currentStep != ConnectionStep.connected) { + debugLog('[TRACE] Not connected, ignoring timer'); + return; + } if (_autoPingEnabled && _targetedModeEnabled) { if (_pingInProgress) { debugLog('[TRACE] Ping already in progress, skipping'); diff --git a/lib/utils/ping_colors.dart b/lib/utils/ping_colors.dart new file mode 100644 index 0000000..6455db0 --- /dev/null +++ b/lib/utils/ping_colors.dart @@ -0,0 +1,250 @@ +import 'package:flutter/material.dart'; +import '../utils/debug_logger_io.dart'; + +/// Color vision deficiency types for accessibility. +/// +/// Users select their CVD type in Settings > General > Color Vision. +/// The app adapts all semantic colors (ping types, signal quality, +/// repeater status, noise floor) to a distinguishable palette. +enum ColorVisionType { + none, // Default — current palette + protanopia, // Red-blind (~1% males) + deuteranopia, // Green-blind (~1% males) + tritanopia, // Blue-blind (~0.003%) + achromatopsia, // Total color blindness (monochrome) +} + +/// Immutable palette holding every semantic color the app uses. +class ColorPalette { + // Ping type colors + final Color txSuccess; + final Color txSuccessLegend; + final Color txFail; + final Color rx; + final Color discSuccess; + final Color discFail; + final Color traceSuccess; + final Color noResponse; + + // Signal quality (SNR/RSSI) traffic-light + final Color signalGood; + final Color signalMedium; + final Color signalBad; + + // Repeater status on map + final Color repeaterActive; + final Color repeaterNew; + final Color repeaterDead; + final Color repeaterDuplicate; + + // Noise floor gradient (good → medium → bad) + final Color noiseFloorGood; + final Color noiseFloorMedium; + final Color noiseFloorBad; + + const ColorPalette({ + required this.txSuccess, + required this.txSuccessLegend, + required this.txFail, + required this.rx, + required this.discSuccess, + required this.discFail, + required this.traceSuccess, + required this.noResponse, + required this.signalGood, + required this.signalMedium, + required this.signalBad, + required this.repeaterActive, + required this.repeaterNew, + required this.repeaterDead, + required this.repeaterDuplicate, + required this.noiseFloorGood, + required this.noiseFloorMedium, + required this.noiseFloorBad, + }); +} + +/// Concrete palette definitions for each CVD type. +/// +/// Color choices for CVD palettes based on Wong (2011) "Points of view: +/// Color blindness" — Nature Methods. All colors within each palette are +/// mutually distinguishable for the target CVD type. +class ColorPalettes { + ColorPalettes._(); + + /// Default palette — matches original app colors and web map squares. + /// BIDIR=#7EE094, TX=#FD8928, DISC/TRACE=#51D4E9, RX=#7D54C7, + /// DEAD=#9E9689, DROP=#E04F5D + static const none = ColorPalette( + txSuccess: Color(0xFF4CAF50), + txSuccessLegend: Color(0xFF22C55E), + txFail: Color(0xFFF44336), + rx: Color(0xFF7D54C7), + discSuccess: Color(0xFF51D4E9), + discFail: Color(0xFF9E9E9E), + traceSuccess: Color(0xFF00BCD4), + noResponse: Color(0xFF9E9E9E), + signalGood: Colors.green, + signalMedium: Colors.orange, + signalBad: Colors.red, + repeaterActive: Color(0xFFD63384), + repeaterNew: Color(0xFFFD7E14), + repeaterDead: Color(0xFF6C757D), + repeaterDuplicate: Color(0xFFDC3545), + noiseFloorGood: Colors.green, + noiseFloorMedium: Colors.orange, + noiseFloorBad: Colors.red, + ); + + /// Protanopia (red-blind) — replaces red/green axis with blue/orange. + /// Also used for deuteranopia since both are red-green CVD. + static const protanopia = ColorPalette( + txSuccess: Color(0xFF0072B2), // Wong blue + txSuccessLegend: Color(0xFF56B4E9), // Wong sky blue + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFF56B4E9), // Wong sky blue + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFF009E73), // Wong bluish green + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF0072B2), // Blue + signalMedium: Color(0xFFF0E442), // Wong yellow + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFF0E442), // Yellow + repeaterDead: Color(0xFF9E9E9E), // Grey + repeaterDuplicate: Color(0xFFD55E00), // Vermillion + noiseFloorGood: Color(0xFF0072B2), + noiseFloorMedium: Color(0xFFF0E442), + noiseFloorBad: Color(0xFFD55E00), + ); + + /// Tritanopia (blue-blind) — replaces blue/cyan with orange/vermillion. + /// Red/green distinction is preserved since tritan users can see those. + static const tritanopia = ColorPalette( + txSuccess: Color(0xFF009E73), // Wong bluish green + txSuccessLegend: Color(0xFF22C55E), // Bright green (visible) + txFail: Color(0xFFD55E00), // Wong vermillion + rx: Color(0xFFCC79A7), // Wong reddish purple + discSuccess: Color(0xFFE69F00), // Wong orange (replaces cyan) + discFail: Color(0xFF9E9E9E), // Grey (unchanged) + traceSuccess: Color(0xFFD55E00), // Vermillion (replaces cyan) + noResponse: Color(0xFF9E9E9E), // Grey (unchanged) + signalGood: Color(0xFF009E73), // Bluish green + signalMedium: Color(0xFFE69F00), // Orange + signalBad: Color(0xFFD55E00), // Vermillion + repeaterActive: Color(0xFFCC79A7), // Reddish purple + repeaterNew: Color(0xFFE69F00), // Orange + repeaterDead: Color(0xFF9E9E9E), // Grey + repeaterDuplicate: Color(0xFFD55E00), // Vermillion + noiseFloorGood: Color(0xFF009E73), + noiseFloorMedium: Color(0xFFE69F00), + noiseFloorBad: Color(0xFFD55E00), + ); + + /// Achromatopsia (monochrome) — luminance-only palette. + /// Relies on maximum brightness contrast between categories. + /// Secondary indicators (icons, text) are essential with this palette. + static const achromatopsia = ColorPalette( + txSuccess: Color(0xFFE0E0E0), // Light + txSuccessLegend: Color(0xFFE0E0E0), + txFail: Color(0xFF616161), // Dark + rx: Color(0xFF9E9E9E), // Medium + discSuccess: Color(0xFFBDBDBD), // Medium-light + discFail: Color(0xFF757575), // Medium-dark + traceSuccess: Color(0xFF757575), // Medium-dark + noResponse: Color(0xFF616161), // Dark + signalGood: Color(0xFFE0E0E0), // Light + signalMedium: Color(0xFF9E9E9E), // Medium + signalBad: Color(0xFF424242), // Very dark + repeaterActive: Color(0xFFE0E0E0), // Light + repeaterNew: Color(0xFFBDBDBD), // Medium-light + repeaterDead: Color(0xFF616161), // Dark + repeaterDuplicate: Color(0xFF424242), // Very dark + noiseFloorGood: Color(0xFFE0E0E0), + noiseFloorMedium: Color(0xFF9E9E9E), + noiseFloorBad: Color(0xFF424242), + ); + + /// Look up palette for a given CVD type. + static ColorPalette forType(ColorVisionType type) { + return switch (type) { + ColorVisionType.none => none, + ColorVisionType.protanopia => protanopia, + ColorVisionType.deuteranopia => protanopia, // Same as protanopia + ColorVisionType.tritanopia => tritanopia, + ColorVisionType.achromatopsia => achromatopsia, + }; + } +} + +/// Centralized color accessors for ping types, signal quality, repeater +/// status, and noise floor. +/// +/// All getters delegate to the active [ColorPalette], which changes when the +/// user selects a different color vision type in Settings. Existing call +/// sites (`PingColors.txSuccess`, etc.) continue to work unchanged. +class PingColors { + PingColors._(); + + static ColorPalette _activePalette = ColorPalettes.none; + static ColorVisionType _currentType = ColorVisionType.none; + + /// Set the active palette. Called by AppStateProvider when the + /// colorVisionType preference changes or on app startup. + static void setColorVisionType(ColorVisionType type) { + _currentType = type; + _activePalette = ColorPalettes.forType(type); + debugLog('[A11Y] Color palette set to ${type.name}'); + } + + /// Current CVD type (for UI display in settings). + static ColorVisionType get currentType => _currentType; + + // ── Ping type colors (same API as before) ── + static Color get txSuccess => _activePalette.txSuccess; + static Color get txSuccessLegend => _activePalette.txSuccessLegend; + static Color get txFail => _activePalette.txFail; + static Color get rx => _activePalette.rx; + static Color get discSuccess => _activePalette.discSuccess; + static Color get discFail => _activePalette.discFail; + static Color get traceSuccess => _activePalette.traceSuccess; + static Color get noResponse => _activePalette.noResponse; + + // ── Signal quality (SNR/RSSI traffic-light) ── + static Color get signalGood => _activePalette.signalGood; + static Color get signalMedium => _activePalette.signalMedium; + static Color get signalBad => _activePalette.signalBad; + + // ── Repeater status ── + static Color get repeaterActive => _activePalette.repeaterActive; + static Color get repeaterNew => _activePalette.repeaterNew; + static Color get repeaterDead => _activePalette.repeaterDead; + static Color get repeaterDuplicate => _activePalette.repeaterDuplicate; + + // ── Noise floor gradient ── + static Color get noiseFloorGood => _activePalette.noiseFloorGood; + static Color get noiseFloorMedium => _activePalette.noiseFloorMedium; + static Color get noiseFloorBad => _activePalette.noiseFloorBad; + + // ── Convenience: SNR color from value ── + static Color snrColor(double snr) { + if (snr <= -1) return signalBad; + if (snr <= 5) return signalMedium; + return signalGood; + } + + // ── Convenience: RSSI color from value ── + static Color rssiColor(int rssi) { + if (rssi >= -70) return signalGood; + if (rssi >= -100) return signalMedium; + return signalBad; + } + + // ── Convenience: Noise floor color from dBm ── + static Color noiseFloorColor(double dbm) { + if (dbm <= -100) return noiseFloorGood; + if (dbm <= -90) return noiseFloorMedium; + return noiseFloorBad; + } +} diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index 7d2e2ef..caa4b97 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 @@ -653,22 +654,24 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), children: [ // Tile layer (dynamic based on selected style from preferences) - Builder( - builder: (context) { - final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); - return TileLayer( - urlTemplate: mapStyle.urlTemplate, - subdomains: mapStyle.subdomains ?? const [], - userAgentPackageName: 'com.meshmapper.app', - maxZoom: 17, - retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), - tileProvider: SilentCancellableNetworkTileProvider(), - ); - }, - ), + // Skipped entirely when map tiles are disabled to save mobile data + if (appState.preferences.mapTilesEnabled) + Builder( + builder: (context) { + final mapStyle = MapStyleExtension.fromString(appState.preferences.mapStyle); + return TileLayer( + urlTemplate: mapStyle.urlTemplate, + subdomains: mapStyle.subdomains ?? const [], + userAgentPackageName: 'com.meshmapper.app', + maxZoom: 17, + retinaMode: mapStyle.supportsRetina && RetinaMode.isHighDensity(context), + tileProvider: SilentCancellableNetworkTileProvider(), + ); + }, + ), - // MeshMapper coverage overlay (only when zone code available and overlay enabled) - if (appState.zoneCode != null && _showMeshMapperOverlay) + // MeshMapper coverage overlay (only when zone code available, overlay enabled, and tiles enabled) + if (appState.preferences.mapTilesEnabled && appState.zoneCode != null && _showMeshMapperOverlay) TileLayer( urlTemplate: 'https://${appState.zoneCode!.toLowerCase()}.meshmapper.net/tiles.php?x={x}&y={y}&z={z}&t=${appState.overlayCacheBust}', userAgentPackageName: 'com.meshmapper.app', @@ -680,24 +683,15 @@ class _MapWidgetState extends State with TickerProviderStateMixin { tileProvider: SilentCancellableNetworkTileProvider(), ), - // 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) + // Coverage markers (TX, RX, DISC, Trace) — sorted by timestamp, newest on top 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) @@ -709,9 +703,13 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ), ), - // Current position marker (car icon) + // Current position marker if (appState.currentPosition != null) MarkerLayer( + // Vehicle/boat icons stay upright by counter-rotating against map rotation; + // arrow and walk rotate with heading (handled by Transform.rotate in the painter) + rotate: appState.preferences.gpsMarkerStyle != 'arrow' && + appState.preferences.gpsMarkerStyle != 'walk', markers: [ Marker( point: LatLng( @@ -732,10 +730,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, }; } @@ -844,12 +842,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// SNR color: green > 5, orange -1..5, red <= -1 - static Color _snrColor(double snr) { - if (snr <= -1) return Colors.red; - if (snr <= 5) return Colors.orange; - return Colors.green; - } + /// SNR color (delegates to active palette) + static Color _snrColor(double snr) => PingColors.snrColor(snr); Widget _buildGpsInfoOverlay(AppStateProvider appState) { final position = appState.currentPosition; @@ -904,9 +898,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { } Color _getAccuracyColor(double accuracy) { - if (accuracy <= 10) return Colors.green; - if (accuracy <= 30) return Colors.orange; - return Colors.red; + if (accuracy <= 10) return PingColors.signalGood; + if (accuracy <= 30) return PingColors.signalMedium; + return PingColors.signalBad; } /// Map controls (always vertical, used inside collapsible wrapper) @@ -1229,49 +1223,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 +1654,120 @@ 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) + /// Build a coverage marker child widget based on the user's marker style preference. + Widget _buildCoverageMarkerChild(Color color) { + final style = context.read().preferences.markerStyle; + switch (style) { + case 'circle': + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2.0), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], ), - ), - ); - }).toList(); + ); + case 'pin': + return CustomPaint( + size: const Size(20, 20), + painter: _PinMarkerPainter(color), + ); + case 'diamond': + return CustomPaint( + size: const Size(20, 20), + painter: _DiamondMarkerPainter(color), + ); + case 'dot': + default: + return Container( + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withValues(alpha: 0.6), width: 1.5), + boxShadow: const [BoxShadow(color: Colors.black12, blurRadius: 2, offset: Offset(0, 1))], + ), + ); + } } - List _buildRxMarkers(List pings) { - return pings.map((ping) { - // Use blue to match the RX chip in status bar - const color = Colors.blue; + /// 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(); + } - 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 _buildTxMarker(TxPing ping) { + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showTxPingDetails(ping), + child: _buildCoverageMarkerChild( + ping.heardRepeaters.isEmpty ? PingColors.txFail : PingColors.txSuccess, ), - ); - }).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 _buildRxMarker(RxPing ping) { + return Marker( + point: LatLng(ping.latitude, ping.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showRxPingDetails(ping), + child: _buildCoverageMarkerChild(PingColors.rx), + ), + ); + } + + Marker _buildDiscMarker(DiscLogEntry entry, bool discDropEnabled) { + return Marker( + point: LatLng(entry.latitude, entry.longitude), + width: 20, + height: 20, + child: GestureDetector( + onTap: () => _showDiscPingDetails(entry), + child: _buildCoverageMarkerChild( + entry.nodeCount == 0 + ? (discDropEnabled ? PingColors.txFail : PingColors.discFail) + : _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: _buildCoverageMarkerChild( + entry.success ? Colors.cyan : Colors.grey, ), - ); - }).toList(); + ), + ); } void _showTraceDetails(TraceLogEntry entry) { @@ -1948,32 +1943,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final localRssi = entry.localRssi ?? 0; final remoteSnr = entry.remoteSnr ?? 0; - Color rxSnrColor; - if (localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (localRssi >= -70) { - rssiColor = Colors.green; - } else if (localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final rxSnrColor = PingColors.snrColor(localSnr); + final rssiColor = PingColors.rssiColor(localRssi); + final txSnrColor = PingColors.snrColor(remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, entry.targetRepeaterId), @@ -2026,20 +1998,20 @@ class _MapWidgetState extends State with TickerProviderStateMixin { ); } - /// DISC marker color (#7B68EE - medium slate blue/purple) - static const Color _discMarkerColor = Color(0xFF7B68EE); + /// DISC marker color (delegates to active palette) + static Color get _discMarkerColor => PingColors.discSuccess; - /// Repeater marker color (#a52163 - magenta/pink) - Active - static const Color _repeaterMarkerColor = Color(0xFFA52163); + /// Repeater marker color - Active (delegates to active palette) + static Color get _repeaterMarkerColor => PingColors.repeaterActive; - /// Duplicate repeater marker color (#a51d2a - red) - static const Color _repeaterDuplicateColor = Color(0xFFA51D2A); + /// Duplicate repeater marker color (delegates to active palette) + static Color get _repeaterDuplicateColor => PingColors.repeaterDuplicate; - /// New repeater marker color (#c05802 - orange) - static const Color _repeaterNewColor = Color(0xFFC05802); + /// New repeater marker color (delegates to active palette) + static Color get _repeaterNewColor => PingColors.repeaterNew; - /// Dead repeater marker color (grey) - static const Color _repeaterDeadColor = Colors.grey; + /// Dead repeater marker color (delegates to active palette) + static Color get _repeaterDeadColor => PingColors.repeaterDead; /// Get set of duplicate repeater IDs Set _getDuplicateRepeaterIds(List repeaters) { @@ -2127,15 +2099,28 @@ class _MapWidgetState extends State with TickerProviderStateMixin { // Convert heading from degrees to radians // heading is 0-360 degrees, 0 = North, 90 = East final headingRadians = heading * (math.pi / 180); + final style = context.read().preferences.gpsMarkerStyle; + + // Arrow and walk rotate with heading; vehicle/boat icons don't (they face up) + final shouldRotate = style == 'arrow' || style == 'walk'; + + final CustomPainter painter; + switch (style) { + case 'car': + painter = const _CarMarkerPainter(); + case 'bike': + painter = const _BikeMarkerPainter(); + case 'boat': + painter = const _BoatMarkerPainter(); + case 'walk': + painter = const _WalkMarkerPainter(); + case 'arrow': + default: + painter = const _ArrowPainter(); + } - // Clean directional arrow - return Transform.rotate( - angle: headingRadians, - child: CustomPaint( - size: const Size(24, 24), - painter: _ArrowPainter(), - ), - ); + final child = CustomPaint(size: const Size(24, 24), painter: painter); + return shouldRotate ? Transform.rotate(angle: headingRadians, child: child) : child; } /// Compute node column width based on hop byte count. @@ -2184,11 +2169,11 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.15), + color: PingColors.txSuccess.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.green.withValues(alpha: 0.4)), + border: Border.all(color: PingColors.txSuccess.withValues(alpha: 0.4)), ), - child: const Icon(Icons.arrow_upward, color: Colors.green, size: 24), + child: Icon(Icons.arrow_upward, color: PingColors.txSuccess, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2314,29 +2299,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).dividerColor), // Data rows ...heardRepeaters.map((repeater) { - // Calculate SNR chip color - Color snrColor; - if (repeater.snr == null) { - snrColor = Colors.grey; - } else if (repeater.snr! <= -1) { - snrColor = Colors.red; - } else if (repeater.snr! <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color - Color rssiColor; - if (repeater.rssi == null) { - rssiColor = Colors.grey; - } else if (repeater.rssi! >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi! >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + final snrColor = repeater.snr != null ? PingColors.snrColor(repeater.snr!) : Colors.grey; + final rssiColor = repeater.rssi != null ? PingColors.rssiColor(repeater.rssi!) : Colors.grey; return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId), @@ -2383,25 +2347,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Show RX ping details popup void _showRxPingDetails(RxPing ping) { - // Calculate SNR severity for chip color - Color snrColor; - if (ping.snr <= -1) { - snrColor = Colors.red; - } else if (ping.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // Calculate RSSI chip color based on signal strength - Color rssiColor; - if (ping.rssi >= -70) { - rssiColor = Colors.green; // Strong: -30 to -70 dBm - } else if (ping.rssi >= -100) { - rssiColor = Colors.orange; // Medium: -70 to -100 dBm - } else { - rssiColor = Colors.red; // Weak: -100 to -120 dBm - } + final snrColor = PingColors.snrColor(ping.snr); + final rssiColor = PingColors.rssiColor(ping.rssi); showModalBottomSheet( context: context, @@ -2622,7 +2569,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { borderRadius: BorderRadius.circular(12), border: Border.all(color: _discMarkerColor.withValues(alpha: 0.4)), ), - child: const Icon(Icons.radar, color: _discMarkerColor, size: 24), + child: Icon(Icons.radar, color: _discMarkerColor, size: 24), ), const SizedBox(width: 12), Expanded( @@ -2761,33 +2708,9 @@ class _MapWidgetState extends State with TickerProviderStateMixin { Divider(height: 1, color: Theme.of(context).dividerColor), // Data rows ...entry.discoveredNodes.map((node) { - // Calculate colors - Color rxSnrColor; - if (node.localSnr <= -1) { - rxSnrColor = Colors.red; - } else if (node.localSnr <= 5) { - rxSnrColor = Colors.orange; - } else { - rxSnrColor = Colors.green; - } - - Color rssiColor; - if (node.localRssi >= -70) { - rssiColor = Colors.green; - } else if (node.localRssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } - - Color txSnrColor; - if (node.remoteSnr <= -1) { - txSnrColor = Colors.red; - } else if (node.remoteSnr <= 5) { - txSnrColor = Colors.orange; - } else { - txSnrColor = Colors.green; - } + final rxSnrColor = PingColors.snrColor(node.localSnr); + final rssiColor = PingColors.rssiColor(node.localRssi); + final txSnrColor = PingColors.snrColor(node.remoteSnr.toDouble()); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, node.repeaterId, fullHexId: node.pubkeyHex), @@ -2803,7 +2726,7 @@ class _MapWidgetState extends State with TickerProviderStateMixin { RepeaterIdChip(repeaterId: node.repeaterId, fontSize: 13), Text( node.nodeTypeLabel, - style: const TextStyle( + style: TextStyle( fontSize: 10, fontWeight: FontWeight.w500, color: _discMarkerColor, @@ -3057,6 +2980,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { /// Paints a crisp directional arrow pointing up class _ArrowPainter extends CustomPainter { + const _ArrowPainter(); + @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); @@ -3094,6 +3019,320 @@ class _ArrowPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } +/// Paints a car silhouette for GPS position marker +class _CarMarkerPainter extends CustomPainter { + const _CarMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + // Car body outline (rounded rect, slightly larger) + final outlineRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: 14, height: 20), + const Radius.circular(4), + ); + canvas.drawRRect(outlineRect, outlinePaint); + + // Blue car body + final bodyPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + final bodyRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: 11, height: 17), + const Radius.circular(3), + ); + canvas.drawRRect(bodyRect, bodyPaint); + + // Windshield (darker blue rectangle near top) + final windshieldPaint = Paint() + ..color = const Color(0xFF1565C0) + ..style = PaintingStyle.fill; + final windshieldRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy - 3), width: 7, height: 4), + const Radius.circular(1), + ); + canvas.drawRRect(windshieldRect, windshieldPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a bicycle silhouette for GPS position marker +class _BikeMarkerPainter extends CustomPainter { + const _BikeMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3; + + final bikePaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Two wheels + const wheelR = 4.0; + final leftWheel = Offset(cx - 5, cy + 3); + final rightWheel = Offset(cx + 5, cy + 3); + + // White outlines for wheels + canvas.drawCircle(leftWheel, wheelR + 1, outlinePaint); + canvas.drawCircle(rightWheel, wheelR + 1, outlinePaint); + + // Frame outline + final framePath = ui.Path() + ..moveTo(leftWheel.dx, leftWheel.dy) + ..lineTo(cx, cy - 5) // Up to handlebars + ..lineTo(rightWheel.dx, rightWheel.dy) // Down to rear + ..moveTo(cx, cy - 5) + ..lineTo(cx + 2, cy - 7); // Handlebar + canvas.drawPath(framePath, Paint()..color = Colors.white..style = PaintingStyle.stroke..strokeWidth = 3.5..strokeCap = StrokeCap.round); + + // Blue wheels + canvas.drawCircle(leftWheel, wheelR, bikePaint); + canvas.drawCircle(rightWheel, wheelR, bikePaint); + + // Blue frame + canvas.drawPath(framePath, bikePaint); + + // Seat dot + canvas.drawCircle(Offset(cx - 1, cy - 4), 1.5, fillPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a boat silhouette for GPS position marker +class _BoatMarkerPainter extends CustomPainter { + const _BoatMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + // White outline + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Hull outline (wider) + final hullOutline = ui.Path() + ..moveTo(cx - 9, cy + 1) + ..lineTo(cx - 6, cy + 8) + ..lineTo(cx + 6, cy + 8) + ..lineTo(cx + 9, cy + 1) + ..close(); + canvas.drawPath(hullOutline, outlinePaint); + + // Hull fill + final hull = ui.Path() + ..moveTo(cx - 7, cy + 2) + ..lineTo(cx - 5, cy + 7) + ..lineTo(cx + 5, cy + 7) + ..lineTo(cx + 7, cy + 2) + ..close(); + canvas.drawPath(hull, fillPaint); + + // Mast outline + canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), + Paint()..color = Colors.white..strokeWidth = 3..strokeCap = StrokeCap.round); + // Mast + canvas.drawLine(Offset(cx, cy + 2), Offset(cx, cy - 9), + Paint()..color = const Color(0xFF2196F3)..strokeWidth = 1.5..strokeCap = StrokeCap.round); + + // Sail outline + final sailOutline = ui.Path() + ..moveTo(cx + 1, cy - 8) + ..lineTo(cx + 7, cy) + ..lineTo(cx + 1, cy) + ..close(); + canvas.drawPath(sailOutline, outlinePaint); + + // Sail + final sail = ui.Path() + ..moveTo(cx + 1, cy - 7) + ..lineTo(cx + 6, cy - 0.5) + ..lineTo(cx + 1, cy - 0.5) + ..close(); + canvas.drawPath(sail, Paint()..color = const Color(0xFF64B5F6)..style = PaintingStyle.fill); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a walking person silhouette for GPS position marker +class _WalkMarkerPainter extends CustomPainter { + const _WalkMarkerPainter(); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3.5 + ..strokeCap = StrokeCap.round; + + final personPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.8 + ..strokeCap = StrokeCap.round; + + final fillPaint = Paint() + ..color = const Color(0xFF2196F3) + ..style = PaintingStyle.fill; + + // Head outline + fill + canvas.drawCircle(Offset(cx, cy - 7), 3.5, Paint()..color = Colors.white..style = PaintingStyle.fill); + canvas.drawCircle(Offset(cx, cy - 7), 2.5, fillPaint); + + // Body outline + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), outlinePaint); + // Body + canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 3), personPaint); + + // Arms outline + canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), outlinePaint); + // Arms + canvas.drawLine(Offset(cx - 5, cy - 1), Offset(cx + 5, cy - 1), personPaint); + + // Left leg outline + canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), outlinePaint); + // Right leg outline + canvas.drawLine(Offset(cx, cy + 3), Offset(cx + 4, cy + 10), outlinePaint); + // Left leg + canvas.drawLine(Offset(cx, cy + 3), Offset(cx - 4, cy + 10), personPaint); + // Right leg + canvas.drawLine(Offset(cx, cy + 3), Offset(cx + 4, cy + 10), personPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +/// Paints a teardrop/pin marker for coverage dots +class _PinMarkerPainter extends CustomPainter { + final Color color; + const _PinMarkerPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final fillPaint = Paint()..color = color..style = PaintingStyle.fill; + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.8) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.15) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); + + const headRadius = 6.0; + final headCenter = Offset(cx, cy - 2); + final tipY = cy + 9; + + // Combined shadow + final pinPath = ui.Path() + ..addOval(Rect.fromCircle(center: headCenter, radius: headRadius)) + ..moveTo(cx - 4, cy + 1) + ..lineTo(cx, tipY) + ..lineTo(cx + 4, cy + 1) + ..close(); + canvas.drawPath(pinPath, shadowPaint); + + // Triangle point + final triPath = ui.Path() + ..moveTo(cx - 4, cy + 1) + ..lineTo(cx, tipY) + ..lineTo(cx + 4, cy + 1) + ..close(); + canvas.drawPath(triPath, fillPaint); + + // Circle head + canvas.drawCircle(headCenter, headRadius, fillPaint); + canvas.drawCircle(headCenter, headRadius, outlinePaint); + + // Inner dot + canvas.drawCircle(headCenter, 2.0, Paint()..color = Colors.white.withValues(alpha: 0.9)); + } + + @override + bool shouldRepaint(covariant _PinMarkerPainter oldDelegate) => oldDelegate.color != color; +} + +/// Paints a diamond marker for coverage dots +class _DiamondMarkerPainter extends CustomPainter { + final Color color; + const _DiamondMarkerPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + + final outlinePaint = Paint() + ..color = Colors.white.withValues(alpha: 0.7) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + + final fillPaint = Paint() + ..color = color + ..style = PaintingStyle.fill; + + final shadowPaint = Paint() + ..color = Colors.black.withValues(alpha: 0.12) + ..style = PaintingStyle.fill + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5); + + final path = ui.Path() + ..moveTo(cx, cy - 8) // Top + ..lineTo(cx + 8, cy) // Right + ..lineTo(cx, cy + 8) // Bottom + ..lineTo(cx - 8, cy) // Left + ..close(); + + canvas.drawPath(path, shadowPaint); + canvas.drawPath(path, fillPaint); + canvas.drawPath(path, outlinePaint); + } + + @override + bool shouldRepaint(covariant _DiamondMarkerPainter oldDelegate) => oldDelegate.color != color; +} + /// A stateful widget for sound item with play button visual feedback class _SoundItemWidget extends StatefulWidget { final IconData icon; diff --git a/lib/widgets/noise_floor_chart.dart b/lib/widgets/noise_floor_chart.dart index aa760b4..568807c 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 @@ -482,25 +483,8 @@ class InteractiveNoiseFloorChartState extends State /// Build a table row for a repeater (matching TX log style) Widget _buildRepeaterRow(BuildContext context, MarkerRepeaterInfo repeater) { - // SNR color: good (>5), fair (-1 to 5), poor (<-1) - Color snrColor; - if (repeater.snr <= -1) { - snrColor = Colors.red; - } else if (repeater.snr <= 5) { - snrColor = Colors.orange; - } else { - snrColor = Colors.green; - } - - // RSSI color based on signal strength - Color rssiColor; - if (repeater.rssi >= -70) { - rssiColor = Colors.green; - } else if (repeater.rssi >= -100) { - rssiColor = Colors.orange; - } else { - rssiColor = Colors.red; - } + final snrColor = PingColors.snrColor(repeater.snr); + final rssiColor = PingColors.rssiColor(repeater.rssi); return InkWell( onTap: () => RepeaterIdChip.showRepeaterPopup(context, repeater.repeaterId, fullHexId: repeater.pubkeyHex), @@ -702,20 +686,20 @@ class InteractiveNoiseFloorChartState extends State return ((dbm - minY) / yRange).clamp(0.0, 1.0); } - // Smooth gradient with faded transitions + // Smooth gradient with faded transitions (palette-aware) final lineColors = [ - Colors.green, - Colors.green, - Colors.orange, - Colors.red, - Colors.red, + PingColors.noiseFloorGood, + PingColors.noiseFloorGood, + PingColors.noiseFloorMedium, + PingColors.noiseFloorBad, + PingColors.noiseFloorBad, ]; final fillColors = [ - Colors.green.withValues(alpha: 0.2), - Colors.green.withValues(alpha: 0.15), - Colors.orange.withValues(alpha: 0.12), - Colors.red.withValues(alpha: 0.1), - Colors.red.withValues(alpha: 0.08), + PingColors.noiseFloorGood.withValues(alpha: 0.2), + PingColors.noiseFloorGood.withValues(alpha: 0.15), + PingColors.noiseFloorMedium.withValues(alpha: 0.12), + PingColors.noiseFloorBad.withValues(alpha: 0.1), + PingColors.noiseFloorBad.withValues(alpha: 0.08), ]; final stops = [ 0.0, @@ -848,12 +832,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/repeater_id_chip.dart b/lib/widgets/repeater_id_chip.dart index 0088da5..d2ae8e2 100644 --- a/lib/widgets/repeater_id_chip.dart +++ b/lib/widgets/repeater_id_chip.dart @@ -7,6 +7,7 @@ import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; import '../utils/debug_logger_io.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// A styled repeater ID text with a dotted underline hint that it's tappable. /// @@ -201,8 +202,9 @@ class RepeaterIdChip extends StatelessWidget { int? regionHopBytesOverride, }) { final isActive = repeater.isActive; - final badgeColor = isActive ? Colors.green : Colors.grey; + final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; final statusText = isActive ? 'Active' : 'Stale'; + final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Calculate distance string if GPS is available String? distanceText; @@ -273,7 +275,7 @@ class RepeaterIdChip extends StatelessWidget { ), ), const SizedBox(width: 8), - // Active/Stale chip + // Active/Stale chip (icon + text for colorblind accessibility) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( @@ -281,13 +283,20 @@ class RepeaterIdChip extends StatelessWidget { borderRadius: BorderRadius.circular(10), border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), - child: Text( - statusText, - style: TextStyle( - fontSize: 11, - color: badgeColor, - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 8, color: badgeColor), + const SizedBox(width: 4), + Text( + statusText, + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ], diff --git a/lib/widgets/repeater_picker_sheet.dart b/lib/widgets/repeater_picker_sheet.dart index a10fa2d..f78950b 100644 --- a/lib/widgets/repeater_picker_sheet.dart +++ b/lib/widgets/repeater_picker_sheet.dart @@ -6,6 +6,7 @@ import '../models/repeater.dart'; import '../providers/app_state_provider.dart'; import '../services/gps_service.dart'; import '../utils/distance_formatter.dart'; +import '../utils/ping_colors.dart'; /// Show a bottom sheet repeater picker and return the selected repeater. Future showRepeaterPicker(BuildContext context) { @@ -226,7 +227,8 @@ class _RepeaterTile extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isActive = repeater.isActive; - final badgeColor = isActive ? Colors.green : Colors.grey; + final badgeColor = isActive ? PingColors.repeaterActive : PingColors.repeaterDead; + final statusIcon = isActive ? Icons.circle : Icons.circle_outlined; // Distance text String? distanceText; @@ -315,7 +317,7 @@ class _RepeaterTile extends StatelessWidget { ), ), const SizedBox(width: 8), - // Active/Stale chip + // Active/Stale chip (icon + text for colorblind accessibility) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( @@ -324,13 +326,20 @@ class _RepeaterTile extends StatelessWidget { border: Border.all(color: badgeColor.withValues(alpha: 0.4)), ), - child: Text( - isActive ? 'Active' : 'Stale', - style: TextStyle( - fontSize: 11, - color: badgeColor, - fontWeight: FontWeight.w600, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 8, color: badgeColor), + const SizedBox(width: 4), + Text( + isActive ? 'Active' : 'Stale', + style: TextStyle( + fontSize: 11, + color: badgeColor, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ], 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'), ),