diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3d4e8f5 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @MrAlders0n diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..951a208 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + branches: [main, dev] + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: Analyze & Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - run: flutter pub get + - run: dart run build_runner build --delete-conflicting-outputs + - run: flutter analyze + - run: flutter test diff --git a/assets/device-models.json b/assets/device-models.json index 0da3f2e..49cb9e6 100644 --- a/assets/device-models.json +++ b/assets/device-models.json @@ -243,6 +243,14 @@ "txPower": 22, "notes": "Seeed Tracker T1000" }, + { + "manufacturer": "Seeed Xiao-nrf52", + "shortName": "Seeed Xiao nRF52", + "power": 0.3, + "platform": "nrf52", + "txPower": 22, + "notes": "Seeed Xiao nRF52840 bare board, no PA amplifier" + }, { "manufacturer": "Xiao C3", "shortName": "Xiao C3", diff --git a/lib/models/user_preferences.dart b/lib/models/user_preferences.dart index 5f25a07..5efca8d 100644 --- a/lib/models/user_preferences.dart +++ b/lib/models/user_preferences.dart @@ -188,7 +188,7 @@ class UserPreferences { autoStopAfterIdle: (json['autoStopAfterIdle'] as bool?) ?? true, showTopRepeaters: (json['showTopRepeaters'] as bool?) ?? false, markerStyle: (json['markerStyle'] as String?) ?? 'dot', - gpsMarkerStyle: (json['gpsMarkerStyle'] as String?) ?? 'arrow', + gpsMarkerStyle: _migrateGpsMarkerStyle(json['gpsMarkerStyle'] as String?), colorVisionType: (json['colorVisionType'] as String?) ?? 'none', mapTilesEnabled: (json['mapTilesEnabled'] as bool?) ?? true, disconnectAlertEnabled: (json['disconnectAlertEnabled'] as bool?) ?? false, @@ -200,6 +200,13 @@ class UserPreferences { ); } + /// Migrate the legacy 'pacman' gps marker id to 'chomper' after the rename. + static String _migrateGpsMarkerStyle(String? value) { + if (value == null) return 'arrow'; + if (value == 'pacman') return 'chomper'; + return value; + } + /// Convert to JSON (for persistence) Map toJson() { return { diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 1e8e6a8..f56db05 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -71,6 +71,8 @@ enum OfflineUploadResult { partialFailure, /// Another upload is already in progress uploadInProgress, + /// GPS position required but not available + gpsRequired, } /// Main application state provider @@ -280,6 +282,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { AutoMode _autoModeBeforeGrace = AutoMode.active; static const Duration _zoneGraceTimeout = Duration(minutes: 5); + // Zone transfer state — tracks session zone for zone-to-zone detection + String? _sessionZoneCode; + bool _isZoneTransferInProgress = false; + String? _zoneTransferFrom; + String? _zoneTransferTo; + // Geofence zone check log throttle (while disconnected) DateTime? _lastZoneCheckLogTime; int _zoneCheckSuppressedCount = 0; @@ -501,6 +509,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { '${(_zoneGraceSecondsRemaining ~/ 60).toString().padLeft(2, '0')}:' '${(_zoneGraceSecondsRemaining % 60).toString().padLeft(2, '0')}'; + // Zone transfer getters + bool get isZoneTransferInProgress => _isZoneTransferInProgress; + String? get zoneTransferFrom => _zoneTransferFrom; + String? get zoneTransferTo => _zoneTransferTo; + // Repeater markers getters List get repeaters => List.unmodifiable(_repeaters); @@ -1772,6 +1785,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[CONN] Connected with limited access'); } + // Track session zone for zone-to-zone transfer detection + _sessionZoneCode = zoneCode; + // Start periodic zone refresh to keep slot counts current if (!_preferences.offlineMode) { _startZoneRefreshTimer(); @@ -2729,6 +2745,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _regionalChannels = []; _scope = null; + // Clear zone transfer state + _sessionZoneCode = null; + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + // Clear discovered devices so user must scan fresh _discoveredDevices = []; @@ -3119,6 +3141,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[APP] Offline mode ${enabled ? 'enabled' : 'disabled'}'); if (enabled) { + // Cancel zone check retries — offline mode doesn't need zone validation + _clearZoneCheckError(); + _isCheckingZone = false; + _stopMaintenancePolling(); // Start periodic auto-save to prevent data loss from app kill _startOfflineAutoSaveTimer(); // Clear zone data when entering offline mode @@ -3185,6 +3211,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 5b. Start periodic auto-save to prevent data loss from app kill _startOfflineAutoSaveTimer(); + // 5c. Cancel zone check retries and maintenance polling + _clearZoneCheckError(); + _isCheckingZone = false; + _stopMaintenancePolling(); + // 6. Clear zone data _inZone = null; _currentZone = null; @@ -3380,6 +3411,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await ChannelService.setRegionalChannels(_regionalChannels); } + // Track session zone for zone-to-zone transfer detection + _sessionZoneCode = zoneCode; + debugLog('[APP] Successfully switched to online mode'); return (success: true, error: null); } catch (e) { @@ -3626,7 +3660,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { onProgress?.call('Authenticating...'); - // 3. Authenticate with offline_mode: true, skipSessionStore: true + // 3. Check GPS before auth — the server requires current coordinates for geo-auth + if (_currentPosition == null) { + debugError('[OFFLINE] Upload requires GPS - location services not available'); + return OfflineUploadResult.gpsRequired; + } + + // 4. Authenticate with offline_mode: true, skipSessionStore: true // This prevents writing to shared _sessionId/_txAllowed/etc. debugLog('[OFFLINE] Authenticating for offline upload with device: $deviceName'); final authResult = await _apiService.requestAuth( @@ -4313,6 +4353,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return; } + if (_preferences.offlineMode) { + debugLog('[GEOFENCE] Skipping zone check: offline mode enabled'); + return; + } + if (_isCheckingZone) { debugLog('[GEOFENCE] Zone check already in progress, skipping duplicate call'); return; @@ -4407,15 +4452,30 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _inZone = result['in_zone'] == true; if (_inZone!) { - _currentZone = result['zone'] as Map?; + final newZone = result['zone'] as Map?; + final newZoneCode = newZone?['code'] as String? ?? ''; + final newZoneName = newZone?['name'] ?? 'Unknown'; + + // Detect zone-to-zone transition during active session + if (isConnected && + !_preferences.offlineMode && + _sessionZoneCode != null && + newZoneCode.isNotEmpty && + newZoneCode != _sessionZoneCode && + !_isInZoneGracePeriod && + !_isZoneTransferInProgress) { + _currentZone = newZone; + _nearestZone = null; + await _handleZoneTransfer(newZoneCode, newZoneName); + return; + } + + _currentZone = newZone; _nearestZone = null; - final zoneName = _currentZone?['name'] ?? 'Unknown'; - final zoneCode = _currentZone?['code'] as String? ?? ''; - debugLog('[GEOFENCE] In zone: $zoneName ($zoneCode)'); + debugLog('[GEOFENCE] In zone: $newZoneName ($newZoneCode)'); - // Fetch repeaters for this zone - if (zoneCode.isNotEmpty) { - await _fetchRepeatersForZone(zoneCode); + if (newZoneCode.isNotEmpty) { + _fetchRepeatersForZone(newZoneCode); // fire-and-forget — don't block zone check } } else { _currentZone = null; @@ -4603,7 +4663,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // checkZoneStatus updates _inZone and calls notifyListeners (overlay auto-updates) if (_inZone == true) { - debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']}'); + final reEnteredZoneCode = _currentZone?['code'] as String? ?? ''; + debugLog('[ZONE GRACE] Zone re-entered: ${_currentZone?['name']} ($reEnteredZoneCode)'); + + // If re-entering a DIFFERENT zone, do a full zone transfer instead of simple resume + if (_sessionZoneCode != null && + reEnteredZoneCode.isNotEmpty && + reEnteredZoneCode != _sessionZoneCode) { + debugLog('[ZONE GRACE] Re-entered different zone ($reEnteredZoneCode vs session $_sessionZoneCode) — transferring'); + _cancelZoneGraceTimers(); + _isInZoneGracePeriod = false; + _zoneGraceSecondsRemaining = 0; + _autoPingWasEnabledBeforeGrace = false; + await _handleZoneTransfer(reEnteredZoneCode, _currentZone?['name'] ?? 'Unknown'); + return; + } + await _onZoneGraceReEntry(); } } @@ -4688,6 +4763,265 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { await _abandonZoneGracePeriod(); } + // ============================================ + // Zone-to-Zone Transfer + // ============================================ + + /// Handle zone-to-zone transfer during active wardriving session. + /// Releases old zone session and acquires new session for target zone. + /// Preserves BLE connection and radio configuration. + Future _handleZoneTransfer(String newZoneCode, String newZoneName) async { + if (_isZoneTransferInProgress) { + debugLog('[ZONE] Transfer already in progress, skipping'); + return; + } + + final oldZoneCode = _sessionZoneCode ?? 'unknown'; + _isZoneTransferInProgress = true; + _zoneTransferFrom = oldZoneCode; + _zoneTransferTo = newZoneCode; + debugLog('[ZONE] Starting zone transfer: $oldZoneCode → $newZoneCode'); + notifyListeners(); + + try { + // 1. Save auto-ping state for restoration + final wasAutoPing = _autoPingEnabled; + final previousMode = _autoMode; + + // 2. Pause auto-ping and wardriving activity + _autoPingTimer.stop(); + _rxWindowTimer.stop(); + _cooldownTimer.stop(); + if (_autoPingEnabled) { + _autoPingEnabled = false; + _idleAutoStopReference = null; + debugLog('[ZONE] Auto-ping paused for zone transfer'); + } + + // 3. Disable heartbeat (old session is about to be released) + _apiService.disableHeartbeat(); + + // 4. Stop RX logger (no valid session context during transfer) + _rxLogger?.stopWardriving(trigger: 'zone_transfer'); + + // 5. Stop zone refresh timer (we're handling the zone change now) + _stopZoneRefreshTimer(); + + // 6. Cancel idle disconnect timer + _cancelIdleDisconnectTimer(); + + // 7. Clear API queue (items were created for old zone's session) + await _apiQueueService.clearOnDisconnect(); + + // 8. Release old session (best effort) + if (_devicePublicKey != null && _apiService.hasSession) { + debugLog('[ZONE] Releasing old session for zone $oldZoneCode'); + try { + await _apiService.requestAuth( + reason: 'disconnect', + publicKey: _devicePublicKey!, + ); + debugLog('[ZONE] Old session released'); + } catch (e) { + debugError('[ZONE] Failed to release old session: $e'); + } + } + + // 9. Acquire new session for target zone + final deviceName = _isAnonymousRenamed + ? 'Anonymous' + : (_meshCoreConnection?.selfInfo?.name ?? + connectedDeviceName?.replaceFirst('MeshCore-', '')); + + if (_devicePublicKey == null || deviceName == null || _currentPosition == null) { + debugError('[ZONE] Cannot transfer: missing device key, name, or GPS'); + await disconnect(); + return; + } + + debugLog('[ZONE] Requesting auth for zone $newZoneCode'); + final result = await _apiService.requestAuth( + reason: 'connect', + publicKey: _devicePublicKey!, + who: deviceName, + appVersion: _appVersion, + power: _preferences.powerLevel, + iataCode: newZoneCode, + model: _meshCoreConnection?.deviceModel?.manufacturer ?? + _meshCoreConnection?.deviceInfo?.manufacturer ?? 'Unknown', + lat: _currentPosition!.latitude, + lon: _currentPosition!.longitude, + accuracyMeters: _currentPosition!.accuracy, + ); + + // 10. Check auth result + if (result == null) { + debugError('[ZONE] Auth failed for zone $newZoneCode: network error'); + logError('Zone transfer failed: unable to reach server', severity: ErrorSeverity.error); + await disconnect(); + return; + } + + if (result['maintenance'] == true) { + _maintenanceMode = true; + _maintenanceMessage = result['maintenance_message'] as String?; + _maintenanceUrl = result['maintenance_url'] as String?; + _startMaintenancePolling(); + notifyListeners(); + await disconnect(); + return; + } + + if (result['success'] != true) { + final reason = result['reason'] as String? ?? 'unknown'; + final message = result['message'] as String? ?? 'Auth failed'; + debugError('[ZONE] Auth failed for zone $newZoneCode: $reason - $message'); + logError('Zone transfer failed: $message', severity: ErrorSeverity.error); + await disconnect(); + return; + } + + // 11. Auth succeeded — update session zone code + _sessionZoneCode = newZoneCode; + debugLog('[ZONE] Auth succeeded for zone $newZoneCode'); + + if (result['type'] != null) { + _authType = result['type'] as String; + } + + _syncZoneCapacityFromAuth(result); + + // 12. Update regional channels from new auth response + final apiChannels = _apiService.channels; + await ChannelService.setRegionalChannels(apiChannels); + _regionalChannels = ChannelService.getRegionalChannelNames(); + debugLog('[ZONE] Regional channels updated: $_regionalChannels'); + + // 13. Update PacketValidator with new channel configuration + if (_unifiedRxHandler != null) { + final allowedChannelsData = ChannelService.getAllowedChannelsForValidator(); + final allowedChannels = {}; + for (final entry in allowedChannelsData.entries) { + allowedChannels[entry.key] = ChannelInfo( + channelName: entry.value.channelName, + key: entry.value.key, + hash: entry.value.hash, + ); + } + final newValidator = PacketValidator( + allowedChannels: allowedChannels, + disableRssiFilter: _preferences.disableRssiFilter, + ); + _unifiedRxHandler!.updateValidator(newValidator); + debugLog('[ZONE] PacketValidator updated with ${allowedChannels.length} channels'); + } + + // 14. Update flood scope from new auth response + final apiScopes = _apiService.scopes; + final firstScope = apiScopes.isNotEmpty ? apiScopes.first : null; + final isWildcard = firstScope == null || firstScope == '*' || firstScope == '#*'; + if (!isWildcard) { + final scopeName = firstScope; + _scope = scopeName.startsWith('#') ? scopeName : '#$scopeName'; + final scopeKey = CryptoService.deriveScopeKey(scopeName); + debugLog('[ZONE] Setting flood scope: $scopeName'); + await _meshCoreConnection!.setFloodScope(scopeKey); + } else { + if (_scope != null) { + try { + await _meshCoreConnection?.clearFloodScope(); + } catch (e) { + debugLog('[ZONE] Failed to clear flood scope: $e'); + } + } + _scope = null; + debugLog('[ZONE] No regional scope — using unscoped flood'); + } + + // 15. Enforce regional admin policies from new zone + if (_apiService.enforceHybrid && !_preferences.hybridModeEnabled) { + _preferences = _preferences.copyWith(hybridModeEnabled: true); + debugLog('[ZONE] Hybrid mode force-enabled by new zone admin'); + } + if (_apiService.enforceDiscDrop && !_preferences.discDropEnabled) { + _preferences = _preferences.copyWith(discDropEnabled: true); + debugLog('[ZONE] Discovery drop force-enabled by new zone admin'); + } + if (_preferences.autoPingInterval < _apiService.minModeInterval) { + _preferences = _preferences.copyWith(autoPingInterval: _apiService.minModeInterval); + debugLog('[ZONE] Auto-ping interval bumped to ${_apiService.minModeInterval}s by new zone admin'); + } + + // 16. Reconfigure path hash mode if new zone requires different hop bytes + await _configurePathHashMode(); + if (_pingService != null) { + _pingService!.hopBytes = effectiveHopBytes; + _pingService!.traceHopBytes = _traceHopBytes; + } + + // 17. Fetch repeaters for the new zone + _repeatersLoaded = false; + _repeatersLoadedForIata = null; + await _fetchRepeatersForZone(newZoneCode); + + // 18. Re-enable heartbeat + _apiService.enableHeartbeat( + gpsProvider: () { + final pos = _gpsService.lastPosition; + if (pos == null) return null; + return (lat: pos.latitude, lon: pos.longitude); + }, + ); + + // 19. Restart zone refresh timer + _startZoneRefreshTimer(); + + // 20. Prepare API queue for fresh data in new zone + await _apiQueueService.clearBeforeConnect(); + + // 21. Restore auto-ping if it was active + if (wasAutoPing) { + _restoreAutoPingTimer?.cancel(); + _restoreAutoPingTimer = Timer(const Duration(milliseconds: 500), () { + _restoreAutoPingTimer = null; + if (_isDisposed || + _userRequestedDisconnect || + _connectionStep != ConnectionStep.connected || + _pingService == null) { + debugLog('[ZONE] Skipping auto-ping restore (stale or disconnected state)'); + return; + } + if (!_autoPingEnabled) { + toggleAutoPing(previousMode); + debugLog('[ZONE] Auto-ping restored (mode=$previousMode)'); + } + }); + } else { + _startIdleDisconnectTimer(); + } + + debugLog('[ZONE] Zone transfer complete: $oldZoneCode → $newZoneCode'); + } catch (e) { + debugError('[ZONE] Zone transfer error: $e'); + logError('Zone transfer failed: $e', severity: ErrorSeverity.error); + await disconnect(); + } finally { + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + notifyListeners(); + } + } + + /// Cancel zone transfer (user-triggered from UI cancel button). + Future cancelZoneTransfer() async { + debugLog('[ZONE] Zone transfer cancelled by user'); + _isZoneTransferInProgress = false; + _zoneTransferFrom = null; + _zoneTransferTo = null; + await disconnect(); + } + /// Fetch repeaters for a zone (called when zone is discovered) /// Only fetches once per IATA code to avoid redundant network requests Future _fetchRepeatersForZone(String iata) async { @@ -4700,11 +5034,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { debugLog('[MAP] Fetching repeaters for zone: $iata'); try { final fetchedRepeaters = await _apiService.fetchRepeaters(iata); - _repeaters = fetchedRepeaters; - _repeatersLoaded = true; - _repeatersLoadedForIata = iata; - debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); - notifyListeners(); + if (fetchedRepeaters.isNotEmpty) { + _repeaters = fetchedRepeaters; + _repeatersLoaded = true; + _repeatersLoadedForIata = iata; + debugLog('[MAP] Loaded ${_repeaters.length} repeaters for zone $iata'); + notifyListeners(); + } else { + debugWarn('[MAP] No repeaters returned for zone $iata — will retry on next zone check'); + } } catch (e) { debugError('[MAP] Failed to fetch repeaters: $e'); } diff --git a/lib/screens/connection_screen.dart b/lib/screens/connection_screen.dart index ae749bb..2ebb0da 100644 --- a/lib/screens/connection_screen.dart +++ b/lib/screens/connection_screen.dart @@ -109,6 +109,11 @@ class _ConnectionScreenState extends State with WidgetsBinding /// Routes to the correct sub-view (zone bar is already rendered above) Widget _buildStateContent(BuildContext context, AppStateProvider appState) { + // Show zone transfer in progress + if (appState.isZoneTransferInProgress) { + return _buildZoneTransferView(context, appState); + } + // Show zone grace period state if (appState.isInZoneGracePeriod) { return _buildZoneGraceView(context, appState); @@ -341,6 +346,64 @@ class _ConnectionScreenState extends State with WidgetsBinding ); } + Widget _buildZoneTransferView(BuildContext context, AppStateProvider appState) { + final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; + final from = appState.zoneTransferFrom ?? '?'; + final to = appState.zoneTransferTo ?? '?'; + + return SafeArea( + child: Center( + child: Padding( + padding: EdgeInsets.all(isLandscape ? 16 : 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + SizedBox(height: isLandscape ? 12 : 24), + Text( + 'Changing Zone...', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + '$from → $to', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + SizedBox(height: isLandscape ? 8 : 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + Text( + 'Re-authenticating...', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + SizedBox(height: isLandscape ? 16 : 24), + OutlinedButton( + onPressed: () => appState.cancelZoneTransfer(), + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ); + } + Widget _buildReconnectingView(BuildContext context, AppStateProvider appState) { final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape; final deviceName = appState.rememberedDevice?.displayName ?? 'device'; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5b8906d..e835e9d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -407,6 +407,17 @@ class _HomeScreenState extends State { ), ), + // Zone transfer overlay (both orientations) + if (appState.isZoneTransferInProgress) + Positioned.fill( + child: Container( + color: Colors.black54, + child: Center( + child: _buildZoneTransferOverlay(appState), + ), + ), + ), + // Portrait: bottom control panel if (!isLandscape) Positioned( @@ -833,6 +844,86 @@ class _HomeScreenState extends State { ); } + /// Zone transfer overlay shown centered over the map during zone-to-zone transfer + Widget _buildZoneTransferOverlay(AppStateProvider appState) { + final from = appState.zoneTransferFrom ?? '?'; + final to = appState.zoneTransferTo ?? '?'; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 260), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28), + decoration: BoxDecoration( + color: const Color(0xFF1E293B), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Colors.orange.shade400, + ), + const SizedBox(height: 20), + Text( + 'Changing Zone...', + style: TextStyle( + color: Colors.grey.shade100, + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Text( + '$from → $to', + style: TextStyle( + color: Colors.grey.shade400, + fontSize: 14, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey.shade500, + ), + ), + const SizedBox(width: 8), + Text( + 'Re-authenticating...', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 13, + ), + ), + ], + ), + const SizedBox(height: 20), + OutlinedButton( + onPressed: () => appState.cancelZoneTransfer(), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.orange.shade400, + side: BorderSide(color: Colors.orange.shade400), + ), + child: const Text('Cancel'), + ), + ], + ), + ), + ); + } + Widget _buildControlPanel() { return Card( margin: const EdgeInsets.all(8), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 89e323a..4df361e 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1006,7 +1006,7 @@ class _SettingsScreenState extends State { case 'bike': return 'Bike'; case 'boat': return 'Boat'; case 'walk': return 'Walk'; - case 'pacman': return 'Pac-Man'; + case 'chomper': return 'Chomper'; case 'arrow': default: return 'Arrow'; } @@ -1066,7 +1066,7 @@ class _SettingsScreenState extends State { ('bike', 'Bike', const Icon(Icons.directions_bike)), ('boat', 'Boat', const Icon(Icons.directions_boat)), ('walk', 'Walk', const Icon(Icons.directions_walk)), - ('pacman', 'Pac-Man', const _PacmanIcon()), + ('chomper', 'Chomper', const _ChomperIcon()), ]; showModalBottomSheet( context: context, @@ -2001,6 +2001,10 @@ class _SettingsScreenState extends State { message = 'Authentication failed - Advert your device on the mesh'; backgroundColor = Colors.red; break; + case OfflineUploadResult.gpsRequired: + message = 'GPS required - enable location services to upload'; + backgroundColor = Colors.red; + break; case OfflineUploadResult.partialFailure: message = 'Partial upload - some pings failed'; backgroundColor = Colors.orange; @@ -2630,24 +2634,24 @@ class _OfflineSessionTile extends StatelessWidget { } } -/// Pac-Man icon widget for the GPS marker selector -class _PacmanIcon extends StatelessWidget { - const _PacmanIcon(); +/// Chomper icon widget for the GPS marker selector +class _ChomperIcon extends StatelessWidget { + const _ChomperIcon(); @override Widget build(BuildContext context) { return CustomPaint( size: const Size(24, 24), - painter: _PacmanIconPainter( + painter: _ChomperIconPainter( color: IconTheme.of(context).color ?? Colors.grey, ), ); } } -class _PacmanIconPainter extends CustomPainter { +class _ChomperIconPainter extends CustomPainter { final Color color; - const _PacmanIconPainter({required this.color}); + const _ChomperIconPainter({required this.color}); @override void paint(Canvas canvas, Size size) { @@ -2655,7 +2659,7 @@ class _PacmanIconPainter extends CustomPainter { final cy = size.height / 2; const radius = 10.0; - const mouthAngle = 45.0 * (math.pi / 180); + const mouthAngle = 70.0 * (math.pi / 180); // Mouth faces right for the settings icon (natural reading direction) const startAngle = mouthAngle / 2; const sweepAngle = 2 * math.pi - mouthAngle; @@ -2674,15 +2678,9 @@ class _PacmanIconPainter extends CustomPainter { ) ..close(); canvas.drawPath(path, paint); - - // Eye - final eyePaint = Paint() - ..color = color == Colors.grey ? Colors.white : Colors.white.withValues(alpha: 0.9) - ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(cx + 2, cy - 3.5), 1.5, eyePaint); } @override - bool shouldRepaint(covariant _PacmanIconPainter oldDelegate) => + bool shouldRepaint(covariant _ChomperIconPainter oldDelegate) => color != oldDelegate.color; } diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 69c1fd2..eb8a47d 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -189,15 +189,12 @@ class ApiService { } Map? data; - if (response.statusCode == 200) { + try { data = json.decode(response.body) as Map; - } else if (response.body.isNotEmpty) { - // Try to parse structured error responses (e.g., 403 gps_inaccurate) - try { - data = json.decode(response.body) as Map; - } catch (e) { - debugWarn('[API] Non-JSON response body (HTML error page, etc.): $e'); - } + } on FormatException { + // CDN/proxy can return HTML error pages with HTTP 200 + debugError('[API] Non-JSON response from /status (HTTP ${response.statusCode}): ' + '${response.body.length > 200 ? response.body.substring(0, 200) : response.body}'); } _logApiCall( diff --git a/lib/services/meshcore/packet_metadata.dart b/lib/services/meshcore/packet_metadata.dart index c65a6ba..49e1f0a 100644 --- a/lib/services/meshcore/packet_metadata.dart +++ b/lib/services/meshcore/packet_metadata.dart @@ -104,6 +104,9 @@ class PacketMetadata { // Extract encrypted payload after path data final int payloadOffset = pathDataOffset + pathByteLen; + if (payloadOffset > raw.length) { + throw RangeError('Packet too short: payload offset $payloadOffset exceeds packet length ${raw.length}'); + } final Uint8List encryptedPayload = raw.sublist(payloadOffset); debugLog('[RX PARSE] Parsed metadata: header=0x${header.toRadixString(16).padLeft(2, '0')}, ' diff --git a/lib/services/meshcore/tx_tracker.dart b/lib/services/meshcore/tx_tracker.dart index 50b1d2c..575536e 100644 --- a/lib/services/meshcore/tx_tracker.dart +++ b/lib/services/meshcore/tx_tracker.dart @@ -139,6 +139,15 @@ class TxTracker { reportedRssi = null; } + // Only direct (1-hop) echoes yield a meaningful SNR/RSSI for the repeater + // that heard our TX: the radio reports last-hop link quality, so for any + // multi-hop relay the metrics describe a different link entirely. + if (!carpeaterStripped && metadata.pathHashCount > 1) { + debugLog('[TX LOG] Ignoring: multi-hop echo (pathHashCount=${metadata.pathHashCount}) ' + 'from $pathHex — SNR/RSSI would measure last-hop link, not repeater downlink'); + return false; + } + // VALIDATION STEP 2: Check user carpeater filter (before RSSI check so user // never sees confusing "RSSI too strong" errors for a device they told the app to ignore) if (!carpeaterStripped && shouldIgnoreRepeater != null && shouldIgnoreRepeater!(pathHex.toUpperCase())) { diff --git a/lib/widgets/map_widget.dart b/lib/widgets/map_widget.dart index e8c21f9..76d2eda 100644 --- a/lib/widgets/map_widget.dart +++ b/lib/widgets/map_widget.dart @@ -805,10 +805,10 @@ class _MapWidgetState extends State with TickerProviderStateMixin { if (appState.currentPosition != null) MarkerLayer( // Vehicle/boat icons stay upright by counter-rotating against map rotation; - // arrow, walk, and pacman rotate with heading (handled by Transform.rotate in the painter) + // arrow, walk, and chomper rotate with heading (handled by Transform.rotate in the painter) rotate: appState.preferences.gpsMarkerStyle != 'arrow' && appState.preferences.gpsMarkerStyle != 'walk' && - appState.preferences.gpsMarkerStyle != 'pacman', + appState.preferences.gpsMarkerStyle != 'chomper', markers: [ Marker( point: LatLng( @@ -2437,8 +2437,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { final headingRadians = heading * (math.pi / 180); final style = context.read().preferences.gpsMarkerStyle; - // Arrow, walk, and pacman rotate with heading; vehicle/boat icons don't (they face up) - final shouldRotate = style == 'arrow' || style == 'walk' || style == 'pacman'; + // Arrow, walk, and chomper rotate with heading; vehicle/boat icons don't (they face up) + final shouldRotate = style == 'arrow' || style == 'walk' || style == 'chomper'; final CustomPainter painter; switch (style) { @@ -2450,8 +2450,8 @@ class _MapWidgetState extends State with TickerProviderStateMixin { painter = const _BoatMarkerPainter(); case 'walk': painter = const _WalkMarkerPainter(); - case 'pacman': - painter = const _PacmanMarkerPainter(); + case 'chomper': + painter = const _ChomperMarkerPainter(); case 'arrow': default: painter = const _ArrowPainter(); @@ -3610,9 +3610,9 @@ class _WalkMarkerPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } -/// Paints a Pac-Man for GPS position marker — mouth faces up (direction of travel) -class _PacmanMarkerPainter extends CustomPainter { - const _PacmanMarkerPainter(); +/// Paints a Chomper (cyan wedge) GPS position marker — mouth faces up (direction of travel) +class _ChomperMarkerPainter extends CustomPainter { + const _ChomperMarkerPainter(); @override void paint(Canvas canvas, Size size) { @@ -3620,10 +3620,10 @@ class _PacmanMarkerPainter extends CustomPainter { final cy = size.height / 2; const radius = 10.0; - // Mouth opening angle: 45° total (22.5° each side of the top) + // Mouth opening angle: 70° total (35° each side of the top) // In canvas coordinates, 0° = 3 o'clock, so "up" = -90° = -π/2. - // Arc sweeps clockwise. We start at (-90 - 22.5)° and sweep (360 - 45)°. - const mouthAngle = 45.0 * (math.pi / 180); + // Arc sweeps clockwise. We start at (-90 + 35)° and sweep (360 - 70)°. + const mouthAngle = 70.0 * (math.pi / 180); const startAngle = -math.pi / 2 + mouthAngle / 2; // right edge of mouth const sweepAngle = 2 * math.pi - mouthAngle; @@ -3643,9 +3643,9 @@ class _PacmanMarkerPainter extends CustomPainter { ..close(); canvas.drawPath(outlinePath, outlinePaint); - // Yellow Pac-Man body + // Cyan body final bodyPaint = Paint() - ..color = const Color(0xFFFFEB3B) // Material yellow + ..color = const Color(0xFF00BCD4) ..style = PaintingStyle.fill; final bodyPath = ui.Path() @@ -3658,12 +3658,6 @@ class _PacmanMarkerPainter extends CustomPainter { ) ..close(); canvas.drawPath(bodyPath, bodyPaint); - - // Eye — small black circle, offset toward the mouth side (upper-left of center) - final eyePaint = Paint() - ..color = Colors.black - ..style = PaintingStyle.fill; - canvas.drawCircle(Offset(cx - 2.5, cy - 4.5), 1.5, eyePaint); } @override