diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 6af0578..348e548 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -2,7 +2,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; -import 'package:meshcore_client/meshcore_client.dart' show BufferReader; +import 'package:meshcore_client/meshcore_client.dart' + show BufferReader, MeshCoreConstants; import 'package:shared_preferences/shared_preferences.dart'; import 'connection_provider.dart'; import 'contacts_provider.dart'; @@ -211,6 +212,8 @@ class AppProvider with ChangeNotifier { static const int _maxPacketRetryAttempts = 4; final Map _voiceSessionSenderKey6 = {}; final Map _imageSessionSenderKey6 = {}; + final Map> _pendingChannelVoicePackets = {}; + final Map> _pendingChannelImageFragments = {}; final Map _voiceMissingRetryTimers = {}; final Map _voiceMissingRetryAttempts = {}; final Map _imageMissingRetryTimers = {}; @@ -1315,6 +1318,7 @@ class AppProvider with ChangeNotifier { .toLowerCase(); } voiceProvider.registerEnvelope(voiceEnvelope); + _replayPendingChannelVoicePackets(voiceEnvelope.sessionId); enrichedMessage = enrichedMessage.copyWith( isVoice: true, voiceId: voiceEnvelope.sessionId, @@ -1352,6 +1356,7 @@ class AppProvider with ChangeNotifier { .toLowerCase(); } imageProvider.registerEnvelope(imageEnvelope); + _replayPendingChannelImageFragments(imageEnvelope.sessionId); messagesProvider.addMessage( enrichedMessage, contactLookup: (name) { @@ -1469,15 +1474,7 @@ class AppProvider with ChangeNotifier { return; } - final fastGpsPacket = FastGpsPacket.tryParseBinary(payload); - if (fastGpsPacket != null) { - final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6); - if (sender != null) { - contactsProvider.updateFastGps( - sender.publicKey.sublist(0, 6), - fastGpsPacket, - ); - } + if (_handleFastGpsPayload(payload)) { return; } @@ -1614,66 +1611,44 @@ class AppProvider with ChangeNotifier { return; } - if (ImagePacket.isImageBinary(payload)) { - final frag = ImagePacket.tryParseBinary(payload); - if (frag == null) return; - debugPrint('πŸ“· [AppProvider] Binary image fragment received: $frag'); - final session = imageProvider.session(frag.sessionId); - if (session == null && frag.total < 1) { - debugPrint( - '⚠️ [AppProvider] Dropping compact image fragment without envelope ' - 'for session ${frag.sessionId}', - ); - return; - } - imageProvider.addFragment( - session == null - ? frag - : ImagePacket( - sessionId: frag.sessionId, - format: session.format, - index: frag.index, - total: session.total, - data: frag.data, - ), - width: session?.width ?? 0, - height: session?.height ?? 0, - ); - _scheduleImageMissingRetry( - frag.sessionId, - justComplete: imageProvider.isComplete(frag.sessionId), - ); + if (_handleIncomingImageBinaryPayload(payload)) { return; } - - if (!VoicePacket.isVoiceBinary(payload)) return; - final pkt = VoicePacket.tryParseBinary(payload); - if (pkt == null) return; - debugPrint('πŸŽ™οΈ [AppProvider] Binary voice packet received: $pkt'); - final session = voiceProvider.session(pkt.sessionId); - if (session == null && pkt.total < 1) { - debugPrint( - '⚠️ [AppProvider] Dropping compact voice packet without envelope ' - 'for session ${pkt.sessionId}', - ); - return; - } - final justComplete = voiceProvider.addPacket( - session == null - ? pkt - : VoicePacket( - sessionId: pkt.sessionId, - mode: session.mode, - index: pkt.index, - total: session.total, - codec2Data: pkt.codec2Data, - ), - ); - _scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete); - // Insert or update the placeholder message in the chat list - _handleIncomingVoicePacket(pkt, justComplete: justComplete); + _handleIncomingVoiceBinaryPayload(payload); }; + connectionProvider.onChannelDataReceived = + (channelIdx, pathLen, dataType, payload, snrRaw, rssiDbm) { + if (dataType != MeshCoreConstants.dataTypeDev) { + debugPrint( + 'πŸ“¦ [AppProvider] Ignoring channel datagram type ' + '0x${dataType.toRadixString(16).padLeft(4, '0')} on channel $channelIdx', + ); + return; + } + + if (_handleFastGpsPayload(payload)) { + return; + } + if (_handleIncomingImageBinaryPayload( + payload, + allowPreEnvelopeBuffer: true, + )) { + return; + } + if (_handleIncomingVoiceBinaryPayload( + payload, + allowPreEnvelopeBuffer: true, + )) { + return; + } + + debugPrint( + 'πŸ“¦ [AppProvider] Unknown developer channel payload on channel ' + '$channelIdx: ${payload.isNotEmpty ? payload.first : -1}', + ); + }; + connectionProvider.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) { _handleControlDataDiscovery( @@ -2805,6 +2780,23 @@ class AppProvider with ChangeNotifier { return; } + final channelIdx = locationTrackingService.fastLocationChannelIdx; + if (channelIdx == null) { + return; + } + + final hasTargetChannel = contactsProvider.channels.any( + (channel) => + (channel.publicKey.length > 1 ? channel.publicKey[1] : 0) == + channelIdx, + ); + if (!hasTargetChannel) { + debugPrint( + '⚠️ [AppProvider] Fast GPS target channel $channelIdx is unavailable', + ); + return; + } + final publicKey = connectionProvider.deviceInfo.publicKey; if (publicKey == null || publicKey.length < 6) { return; @@ -2822,10 +2814,14 @@ class AppProvider with ChangeNotifier { ); debugPrint( 'πŸ“ [AppProvider] Sending fast GPS update ($reason): ' - '${position.latitude}, ${position.longitude}', + '${position.latitude}, ${position.longitude} via channel $channelIdx', ); try { - await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary()); + await connectionProvider.sendChannelData( + channelIdx: channelIdx, + dataType: MeshCoreConstants.dataTypeDev, + payload: packet.encodeBinary(), + ); } catch (e) { debugPrint('⚠️ [AppProvider] Fast GPS send failed: $e'); } @@ -3363,6 +3359,173 @@ class AppProvider with ChangeNotifier { }); } + bool _handleFastGpsPayload(Uint8List payload) { + final fastGpsPacket = FastGpsPacket.tryParseBinary(payload); + if (fastGpsPacket == null) { + return false; + } + + final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6); + if (sender != null) { + contactsProvider.updateFastGps( + sender.publicKey.sublist(0, 6), + fastGpsPacket, + ); + } + return true; + } + + bool _handleIncomingImageBinaryPayload( + Uint8List payload, { + bool allowPreEnvelopeBuffer = false, + }) { + if (!ImagePacket.isImageBinary(payload)) { + return false; + } + final frag = ImagePacket.tryParseBinary(payload); + if (frag == null) { + return true; + } + debugPrint('πŸ“· [AppProvider] Binary image fragment received: $frag'); + final session = imageProvider.session(frag.sessionId); + if (session == null && frag.total < 1) { + if (allowPreEnvelopeBuffer) { + _pendingChannelImageFragments.putIfAbsent( + frag.sessionId, + () => {}, + )[frag.index] = frag; + debugPrint( + 'πŸ“· [AppProvider] Buffered compact image fragment before envelope ' + 'for session ${frag.sessionId} index=${frag.index}', + ); + } else { + debugPrint( + '⚠️ [AppProvider] Dropping compact image fragment without envelope ' + 'for session ${frag.sessionId}', + ); + } + return true; + } + + imageProvider.addFragment( + session == null + ? frag + : ImagePacket( + sessionId: frag.sessionId, + format: session.format, + index: frag.index, + total: session.total, + data: frag.data, + ), + width: session?.width ?? 0, + height: session?.height ?? 0, + ); + _scheduleImageMissingRetry( + frag.sessionId, + justComplete: imageProvider.isComplete(frag.sessionId), + ); + return true; + } + + bool _handleIncomingVoiceBinaryPayload( + Uint8List payload, { + bool allowPreEnvelopeBuffer = false, + }) { + if (!VoicePacket.isVoiceBinary(payload)) { + return false; + } + final pkt = VoicePacket.tryParseBinary(payload); + if (pkt == null) { + return true; + } + debugPrint('πŸŽ™οΈ [AppProvider] Binary voice packet received: $pkt'); + final session = voiceProvider.session(pkt.sessionId); + if (session == null && pkt.total < 1) { + if (allowPreEnvelopeBuffer) { + _pendingChannelVoicePackets.putIfAbsent( + pkt.sessionId, + () => {}, + )[pkt.index] = pkt; + debugPrint( + 'πŸŽ™οΈ [AppProvider] Buffered compact voice packet before envelope ' + 'for session ${pkt.sessionId} index=${pkt.index}', + ); + } else { + debugPrint( + '⚠️ [AppProvider] Dropping compact voice packet without envelope ' + 'for session ${pkt.sessionId}', + ); + } + return true; + } + + final normalizedPacket = session == null + ? pkt + : VoicePacket( + sessionId: pkt.sessionId, + mode: session.mode, + index: pkt.index, + total: session.total, + codec2Data: pkt.codec2Data, + ); + final justComplete = voiceProvider.addPacket(normalizedPacket); + _scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete); + _handleIncomingVoicePacket(normalizedPacket, justComplete: justComplete); + return true; + } + + void _replayPendingChannelVoicePackets(String sessionId) { + final pending = _pendingChannelVoicePackets.remove(sessionId); + final session = voiceProvider.session(sessionId); + if (pending == null || pending.isEmpty || session == null) { + return; + } + + final indices = pending.keys.toList()..sort(); + for (final index in indices) { + final packet = pending[index]!; + final normalizedPacket = VoicePacket( + sessionId: packet.sessionId, + mode: session.mode, + index: packet.index, + total: session.total, + codec2Data: packet.codec2Data, + ); + final justComplete = voiceProvider.addPacket(normalizedPacket); + _scheduleVoiceMissingRetry(sessionId, justComplete: justComplete); + _handleIncomingVoicePacket(normalizedPacket, justComplete: justComplete); + } + } + + void _replayPendingChannelImageFragments(String sessionId) { + final pending = _pendingChannelImageFragments.remove(sessionId); + final session = imageProvider.session(sessionId); + if (pending == null || pending.isEmpty || session == null) { + return; + } + + final indices = pending.keys.toList()..sort(); + for (final index in indices) { + final fragment = pending[index]!; + final normalizedFragment = ImagePacket( + sessionId: fragment.sessionId, + format: session.format, + index: fragment.index, + total: session.total, + data: fragment.data, + ); + imageProvider.addFragment( + normalizedFragment, + width: session.width, + height: session.height, + ); + _scheduleImageMissingRetry( + sessionId, + justComplete: imageProvider.isComplete(sessionId), + ); + } + } + /// Insert or update a voice placeholder message for binary raw-data packets. /// /// Binary voice packets arrive without a chat message, so we synthesise one @@ -3767,6 +3930,8 @@ class AppProvider with ChangeNotifier { _imageMissingRetryAttempts.clear(); _voiceSessionSenderKey6.clear(); _imageSessionSenderKey6.clear(); + _pendingChannelVoicePackets.clear(); + _pendingChannelImageFragments.clear(); _lowBatteryNotifiedNodeIds.clear(); notifyListeners(); } @@ -3876,6 +4041,8 @@ class AppProvider with ChangeNotifier { for (final timer in _imageMissingRetryTimers.values) { timer.cancel(); } + _pendingChannelVoicePackets.clear(); + _pendingChannelImageFragments.clear(); super.dispose(); } } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 1f6b3a2..b762c94 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -105,8 +105,6 @@ class ConnectionProvider with ChangeNotifier { bool _isScanning = false; bool get isScanning => _isScanning; - bool _isSpectrumScanActive = false; - bool get isSpectrumScanActive => _isSpectrumScanActive; final Set _pendingDeletedChannelIndices = {}; String? _error; @@ -212,6 +210,15 @@ class ConnectionProvider with ChangeNotifier { onMessageEchoDetected; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; + Function( + int channelIdx, + int pathLen, + int dataType, + Uint8List payload, + int snrRaw, + int? rssiDbm, + )? + onChannelDataReceived; Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)? onControlDataReceived; Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback; @@ -463,10 +470,6 @@ class ConnectionProvider with ChangeNotifier { }; service.onMessageWaiting = () { - if (_isSpectrumScanActive) { - debugPrint('πŸ“₯ [Provider] MSG_WAITING ignored during spectrum scan'); - return; - } if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) { _pendingAutomaticMessageSync = true; debugPrint( @@ -533,6 +536,16 @@ class ConnectionProvider with ChangeNotifier { service.onRawDataReceived = (payload, snrRaw, rssiDbm) => onRawDataReceived?.call(payload, snrRaw, rssiDbm); + service.onChannelDataReceived = + (channelIdx, pathLen, dataType, payload, snrRaw, rssiDbm) => + onChannelDataReceived?.call( + channelIdx, + pathLen, + dataType, + payload, + snrRaw, + rssiDbm, + ); service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) => onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen); @@ -548,9 +561,6 @@ class ConnectionProvider with ChangeNotifier { semanticVersion: deviceInfo['semanticVersion'] as String?, clientRepeat: deviceInfo['clientRepeat'] as bool?, pathHashMode: deviceInfo['pathHashMode'] as int?, - supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?, - spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?, - spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?, ); notifyListeners(); }; @@ -1804,6 +1814,30 @@ class ConnectionProvider with ChangeNotifier { ); } + Future sendChannelData({ + required int channelIdx, + required int dataType, + required Uint8List payload, + }) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.sendChannelData( + channelIdx: channelIdx, + dataType: dataType, + payload: payload, + ); + } catch (e) { + _error = 'Failed to send channel data: $e'; + notifyListeners(); + rethrow; + } + } + /// Request telemetry from contact /// /// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39). @@ -2238,43 +2272,6 @@ class ConnectionProvider with ChangeNotifier { } } - Future scanSpectrum({ - required int startFrequencyKhz, - required int stopFrequencyKhz, - required int bandwidthKhz, - required int stepKhz, - required int dwellMs, - required int thresholdDb, - }) async { - if (!_activeService.isConnected) { - _error = 'Not connected to device'; - notifyListeners(); - return null; - } - - try { - _isSpectrumScanActive = true; - _activeService.setSpectrumScanActive(true); - notifyListeners(); - return await _activeService.scanSpectrum( - startFrequencyKhz: startFrequencyKhz, - stopFrequencyKhz: stopFrequencyKhz, - bandwidthKhz: bandwidthKhz, - stepKhz: stepKhz, - dwellMs: dwellMs, - thresholdDb: thresholdDb, - ); - } catch (e) { - _error = 'Failed to scan spectrum: $e'; - notifyListeners(); - return null; - } finally { - _isSpectrumScanActive = false; - _activeService.setSpectrumScanActive(false); - notifyListeners(); - } - } - /// Set transmit power Future setTxPower(int powerDbm) async { if (!_activeService.isConnected) { @@ -2464,7 +2461,6 @@ class ConnectionProvider with ChangeNotifier { } Future refreshDeviceInfo() async { - if (_isSpectrumScanActive) return; if (!_activeService.isConnected) { _error = 'Not connected to device'; notifyListeners(); @@ -2537,7 +2533,6 @@ class ConnectionProvider with ChangeNotifier { /// Sync messages from device queue /// Call this repeatedly until no more messages are available Future syncNextMessage() async { - if (_isSpectrumScanActive) return false; // Prevent re-entrancy and too-fast triggers if (_isSyncingMessages) { // Another sync (single or loop) is in progress @@ -2576,10 +2571,6 @@ class ConnectionProvider with ChangeNotifier { /// Sync all waiting messages from device Future syncAllMessages({bool force = false}) async { - if (_isSpectrumScanActive) { - debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan'); - return 0; - } if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) { _pendingAutomaticMessageSync = true; debugPrint( diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 5a19fbc..4e8d8c6 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -23,13 +23,11 @@ import 'device_config_screen.dart'; import 'packet_log_screen.dart'; import 'live_traffic_screen.dart'; import 'profiles_screen.dart'; -import 'spectrum_scan_screen.dart'; import '../utils/toast_logger.dart'; import '../l10n/app_localizations.dart'; import '../widgets/permission_request_dialog.dart'; import '../widgets/connection_dialog.dart'; import '../utils/battery_display_helper.dart'; -import '../services/developer_mode_service.dart'; import '../services/mesh_map_nodes_service.dart'; import '../services/profile_device_key_resolver.dart'; import '../services/profile_manager.dart'; @@ -68,7 +66,6 @@ class _HomeScreenState extends State int _currentIndex = 0; bool _isMapFullscreen = false; bool _showRxTxIndicators = true; - bool _isDeveloperModeEnabled = false; bool _isMapEnabled = true; bool _isContactsEnabled = true; bool _isSensorsEnabled = false; @@ -107,7 +104,6 @@ class _HomeScreenState extends State // Initialize synchronously so first build always has a valid controller. _initTabController(); _loadRxTxPreference(); - _loadDeveloperModePreference(); MeshMapNodesService.syncInBackgroundIfStale(); // Show permission dialog after the first frame if needed @@ -291,14 +287,6 @@ class _HomeScreenState extends State } } - Future _loadDeveloperModePreference() async { - final isEnabled = await DeveloperModeService.isEnabled(); - if (!mounted) return; - setState(() { - _isDeveloperModeEnabled = isEnabled; - }); - } - void _showPermissionDialog() { if (!mounted) return; @@ -811,32 +799,6 @@ class _HomeScreenState extends State .read() .profilesEnabled; - if (_isDeveloperModeEnabled) { - items.add( - PopupMenuItem( - child: Row( - children: [ - const Icon(Icons.radar), - SizedBox(width: 8), - Text(AppLocalizations.of(context)!.spectrumScan), - ], - ), - onTap: () { - final navigator = Navigator.of(context); - Future.delayed(Duration.zero, () { - if (!mounted) return; - navigator.push( - MaterialPageRoute( - builder: (context) => - const SpectrumScanScreen(), - ), - ); - }); - }, - ), - ); - } - items.add( PopupMenuItem( child: Row( @@ -954,7 +916,6 @@ class _HomeScreenState extends State ), ); _loadRxTxPreference(); - _loadDeveloperModePreference(); }); }, ), diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 5998a67..b377537 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -6,6 +6,7 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:meshcore_client/meshcore_client.dart' show MeshCoreConstants; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; import '../providers/messages_provider.dart'; @@ -114,7 +115,9 @@ class _ComposerActionTile extends StatelessWidget { width: 38, height: 38, decoration: BoxDecoration( - color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10), + color: effectiveColor.withValues( + alpha: enabled ? 0.14 : 0.10, + ), borderRadius: BorderRadius.circular(14), ), alignment: Alignment.center, @@ -964,6 +967,7 @@ class _MessagesTabState extends State { if (!shouldContinue) return; if (!mounted) return; final connectionProvider = context.read(); + final messagesProvider = context.read(); if (!connectionProvider.deviceInfo.isConnected) { ToastLogger.error(context, 'Not connected to device'); return; @@ -975,6 +979,7 @@ class _MessagesTabState extends State { final rawBytes = await picked.readAsBytes(); setState(() => _isSendingImage = true); + String? failedMessageId; try { // Compress AVIF using user-selected size, compression and color mode. final maxSize = await ImagePreferences.getMaxSize(); @@ -1052,8 +1057,8 @@ class _MessagesTabState extends State { imageProvider.cacheOutgoingSession(sessionId, fragments, envelope); // Add local placeholder message. - final messagesProvider = context.read(); final msgId = 'img_${sessionId}_sent'; + failedMessageId = msgId; final isChannel = _destinationType == MessageDestinationPreferences.destinationTypeChannel; @@ -1111,10 +1116,23 @@ class _MessagesTabState extends State { 'chunk=${imageDataBytesPerFragment}B', ); + if (isChannel) { + for (final fragment in fragments) { + await connectionProvider.sendChannelData( + channelIdx: channelIdx ?? 0, + dataType: MeshCoreConstants.dataTypeDev, + payload: fragment.encodeBinary(), + ); + } + } + // Image fragments are always served on demand after an explicit IR2 // fetch request, including direct contacts. } catch (e, st) { debugPrint('❌ [Image] _pickAndSendImage: $e\n$st'); + if (failedMessageId != null) { + messagesProvider.markMessageFailed(failedMessageId); + } if (!mounted) return; ToastLogger.error(context, 'Image send failed'); } finally { @@ -1423,6 +1441,15 @@ class _MessagesTabState extends State { } debugPrint('πŸŽ™οΈ [Voice] envelope sent for session $sessionId'); + if (isChannel) { + for (final packet in encodedPackets) { + await connectionProvider.sendChannelData( + channelIdx: channelIdx ?? 0, + dataType: MeshCoreConstants.dataTypeDev, + payload: packet.encodeBinary(), + ); + } + } // Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking). // addSentMessage() forces deliveryStatus.sending; we upgrade it here so the // bubble shows "Sent" instead of "Sending" once all packets are on the wire. @@ -1673,98 +1700,99 @@ class _MessagesTabState extends State { LayoutBuilder( builder: (context, constraints) { final actions = [ - _ComposerActionTile( - icon: Icons.search_rounded, - title: l10n.searchMessages, - subtitle: 'Find text in the current conversation', - color: const Color(0xFF2B6CB0), - onTap: () => runAction(() async { - _showFilteredMessageSearch(); - }), - ), - _ComposerActionTile( - icon: Icons.add_location_alt_rounded, - title: l10n.sendSarMarker, - subtitle: 'Share a marker with coordinates', - color: const Color(0xFFB45309), - onTap: () => runAction(() async { - _showSarDialog(); - }), - ), - if (_voiceSupported) _ComposerActionTile( - icon: _isRecording - ? Icons.stop_rounded - : Icons.mic_rounded, - title: _isRecording - ? 'Stop recording' - : 'Record voice', - subtitle: _isSendingVoice - ? 'Voice message is sending' - : _isRecording - ? 'Finish and send your clip' - : 'Capture and send a voice note', - color: const Color(0xFF7C3AED), - enabled: !_isSendingVoice, - onTap: !_isSendingVoice + icon: Icons.search_rounded, + title: l10n.searchMessages, + subtitle: 'Find text in the current conversation', + color: const Color(0xFF2B6CB0), + onTap: () => runAction(() async { + _showFilteredMessageSearch(); + }), + ), + _ComposerActionTile( + icon: Icons.add_location_alt_rounded, + title: l10n.sendSarMarker, + subtitle: 'Share a marker with coordinates', + color: const Color(0xFFB45309), + onTap: () => runAction(() async { + _showSarDialog(); + }), + ), + if (_voiceSupported) + _ComposerActionTile( + icon: _isRecording + ? Icons.stop_rounded + : Icons.mic_rounded, + title: _isRecording + ? 'Stop recording' + : 'Record voice', + subtitle: _isSendingVoice + ? 'Voice message is sending' + : _isRecording + ? 'Finish and send your clip' + : 'Capture and send a voice note', + color: const Color(0xFF7C3AED), + enabled: !_isSendingVoice, + onTap: !_isSendingVoice + ? () => runAction(() async { + if (_isRecording) { + await _stopAndSendVoice(); + } else { + await _startVoiceRecording(); + } + }) + : null, + ), + _ComposerActionTile( + icon: Icons.photo_library_rounded, + title: l10n.sendImageFromGallery, + subtitle: 'Choose an image from your library', + color: const Color(0xFF0F766E), + enabled: !_isSendingImage, + onTap: !_isSendingImage ? () => runAction(() async { - if (_isRecording) { - await _stopAndSendVoice(); - } else { - await _startVoiceRecording(); - } + await _pickAndSendImage( + source: ImageSource.gallery, + ); }) : null, ), - _ComposerActionTile( - icon: Icons.photo_library_rounded, - title: l10n.sendImageFromGallery, - subtitle: 'Choose an image from your library', - color: const Color(0xFF0F766E), - enabled: !_isSendingImage, - onTap: !_isSendingImage - ? () => runAction(() async { - await _pickAndSendImage( - source: ImageSource.gallery, - ); - }) - : null, - ), - _ComposerActionTile( - icon: Icons.camera_alt_rounded, - title: l10n.takePhoto, - subtitle: 'Capture something right now', - color: const Color(0xFF2563EB), - enabled: !_isSendingImage, - onTap: !_isSendingImage - ? () => runAction(() async { - await _pickAndSendImage( - source: ImageSource.camera, - ); - }) - : null, - ), - _ComposerActionTile( - icon: Icons.grid_3x3_rounded, - title: l10n.startTictactoe, - subtitle: l10n.dmOnly, - color: const Color(0xFFBE185D), - onTap: () => runAction(() async { - await _startTicTacToeGame(); - }), - ), - ]; + _ComposerActionTile( + icon: Icons.camera_alt_rounded, + title: l10n.takePhoto, + subtitle: 'Capture something right now', + color: const Color(0xFF2563EB), + enabled: !_isSendingImage, + onTap: !_isSendingImage + ? () => runAction(() async { + await _pickAndSendImage( + source: ImageSource.camera, + ); + }) + : null, + ), + _ComposerActionTile( + icon: Icons.grid_3x3_rounded, + title: l10n.startTictactoe, + subtitle: l10n.dmOnly, + color: const Color(0xFFBE185D), + onTap: () => runAction(() async { + await _startTicTacToeGame(); + }), + ), + ]; return GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: actions.length, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - mainAxisExtent: 126, - ), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + mainAxisExtent: 126, + ), itemBuilder: (context, index) => actions[index], ); }, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index dc4eec4..4e73b9c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -16,6 +16,7 @@ import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; import '../providers/drawing_provider.dart'; import '../providers/map_provider.dart'; +import '../models/contact.dart'; import '../models/config_profile.dart'; import '../services/location_tracking_service.dart'; import '../services/locale_preferences.dart'; @@ -79,6 +80,7 @@ class _SettingsScreenState extends State { bool _fastLocationUpdatesEnabled = false; double _fastLocationMovementThresholdMeters = 10.0; int _fastLocationActiveCadenceSeconds = 10; + int? _fastLocationChannelIdx; bool _rotateMapWithHeading = false; bool _showMapDebugInfo = false; bool _openMapInFullscreen = false; @@ -314,6 +316,7 @@ class _SettingsScreenState extends State { _locationService.fastLocationMovementThresholdMeters; _fastLocationActiveCadenceSeconds = _locationService.fastLocationActiveCadenceSeconds; + _fastLocationChannelIdx = _locationService.fastLocationChannelIdx; }); } @@ -407,6 +410,79 @@ class _SettingsScreenState extends State { }); } + String _describeFastLocationChannel(List channels) { + final channelIdx = _fastLocationChannelIdx; + if (channelIdx == null) { + return 'Not set'; + } + + for (final channel in channels) { + final idx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + if (idx == channelIdx) { + return '${channel.getLocalizedDisplayName(context)} (slot $channelIdx)'; + } + } + + return 'Channel $channelIdx unavailable'; + } + + Future _editFastLocationChannel() async { + final channels = + List.from(context.read().channels) + ..sort((a, b) { + final aIdx = a.publicKey.length > 1 ? a.publicKey[1] : 0; + final bIdx = b.publicKey.length > 1 ? b.publicKey[1] : 0; + return aIdx.compareTo(bIdx); + }); + + final selected = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.block), + title: const Text('Disable fast GPS publishing'), + trailing: _fastLocationChannelIdx == null + ? const Icon(Icons.check) + : null, + onTap: () => Navigator.pop(sheetContext, -1), + ), + for (final channel in channels) + ListTile( + leading: const Icon(Icons.tag), + title: Text(channel.getLocalizedDisplayName(sheetContext)), + subtitle: Text( + 'Channel ${channel.publicKey.length > 1 ? channel.publicKey[1] : 0}', + ), + trailing: + _fastLocationChannelIdx == + (channel.publicKey.length > 1 + ? channel.publicKey[1] + : 0) + ? const Icon(Icons.check) + : null, + onTap: () => Navigator.pop( + sheetContext, + channel.publicKey.length > 1 ? channel.publicKey[1] : 0, + ), + ), + ], + ), + ), + ); + + if (selected == null) return; + await _locationService.updateFastLocationChannelIdx( + selected < 0 ? null : selected, + ); + if (!mounted) return; + setState(() { + _fastLocationChannelIdx = _locationService.fastLocationChannelIdx; + }); + } + Future _saveImageMaxSize(int size) async { await ImagePreferences.setMaxSize(size); if (!mounted) return; @@ -1766,6 +1842,17 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: _editFastLocationActiveCadence, ), + ListTile( + leading: const Icon(Icons.forum), + title: const Text('Fast GPS target channel'), + subtitle: Text( + _describeFastLocationChannel( + context.watch().channels, + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: _editFastLocationChannel, + ), ListTile( leading: Icon(Icons.location_on), title: Text(AppLocalizations.of(context)!.locationPermission), diff --git a/lib/screens/spectrum_scan_screen.dart b/lib/screens/spectrum_scan_screen.dart deleted file mode 100644 index a1286bd..0000000 --- a/lib/screens/spectrum_scan_screen.dart +++ /dev/null @@ -1,602 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:meshcore_client/meshcore_client.dart'; -import 'package:provider/provider.dart'; -import '../models/device_info.dart'; -import '../providers/connection_provider.dart'; -import '../widgets/device/spectrum_scan_panel.dart'; -import '../l10n/app_localizations.dart'; - -class SpectrumScanScreen extends StatefulWidget { - const SpectrumScanScreen({super.key}); - - @override - State createState() => _SpectrumScanScreenState(); -} - -class _SpectrumScanScreenState extends State { - static const List _bandwidthOptions = [ - '7.8 kHz', - '10.4 kHz', - '15.6 kHz', - '20.8 kHz', - '31.25 kHz', - '41.7 kHz', - '62.5 kHz', - '125 kHz', - '250 kHz', - '500 kHz', - ]; - - String _selectedBandwidth = '62.5 kHz'; - bool _isSpectrumScanRunning = false; - bool _rangeInitialized = false; - String? _lastRangeSourceKey; - late double _scanRangeMinMhz; - late double _scanRangeMaxMhz; - late RangeValues _scanRangeValues; - List _scanCandidates = const []; - int? _selectedScanFrequencyKhz; - int _completedScanSectors = 0; - int _totalScanSectors = 0; - - List get _recommendedScanCandidates => - _scanCandidates.take(8).toList(); - - @override - void initState() { - super.initState(); - final deviceInfo = _deviceInfo; - if (deviceInfo.radioBw != null && - deviceInfo.radioBw! >= 0 && - deviceInfo.radioBw! < _bandwidthOptions.length) { - _selectedBandwidth = _bandwidthOptions[deviceInfo.radioBw!]; - } - _syncRangeFromDevice(deviceInfo); - _selectedScanFrequencyKhz = deviceInfo.radioFreq; - } - - DeviceInfo get _deviceInfo => context.read().deviceInfo; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _syncRangeFromDevice(context.watch().deviceInfo); - } - - void _syncRangeFromDevice(DeviceInfo deviceInfo) { - final minKhz = deviceInfo.spectrumScanMinKhz; - final maxKhz = deviceInfo.spectrumScanMaxKhz; - late double normalizedMinMhz; - late double normalizedMaxMhz; - late String sourceKey; - - if (minKhz != null && maxKhz != null && maxKhz > minKhz) { - final normalized = _normalizeScanRange( - minKhz / 1000.0, - maxKhz / 1000.0, - deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null, - ); - normalizedMinMhz = normalized.$1; - normalizedMaxMhz = normalized.$2; - sourceKey = 'fw:$minKhz:$maxKhz'; - } else { - final normalized = _normalizeScanRange( - null, - null, - deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null, - ); - normalizedMinMhz = normalized.$1; - normalizedMaxMhz = normalized.$2; - sourceKey = - 'fallback:${deviceInfo.radioFreq != null ? deviceInfo.radioFreq! ~/ 1000 : 869525}'; - } - - if (_rangeInitialized && _lastRangeSourceKey == sourceKey) { - return; - } - - _scanRangeMinMhz = normalizedMinMhz; - _scanRangeMaxMhz = normalizedMaxMhz; - - if (_scanRangeMaxMhz <= _scanRangeMinMhz) { - _scanRangeMaxMhz = _scanRangeMinMhz + 0.5; - } - - _scanRangeValues = RangeValues(_scanRangeMinMhz, _scanRangeMaxMhz); - _rangeInitialized = true; - _lastRangeSourceKey = sourceKey; - } - - (double, double) _normalizeScanRange( - double? minMhz, - double? maxMhz, - double? centerMhz, - ) { - const hardMinMhz = 800.0; - const hardMaxMhz = 950.0; - final center = centerMhz ?? 869.525; - - if (minMhz != null && maxMhz != null) { - final clampedMin = minMhz.clamp(hardMinMhz, hardMaxMhz); - final clampedMax = maxMhz.clamp(hardMinMhz, hardMaxMhz); - if (clampedMax > clampedMin) { - return (clampedMin, clampedMax); - } - } - - if (center >= 900.0 && center <= 930.0) { - return (902.0, 928.0); - } - - return (863.0, 870.0); - } - - double _bandwidthToKhz(String bw) { - switch (bw) { - case '7.8 kHz': - return 7.8; - case '10.4 kHz': - return 10.4; - case '15.6 kHz': - return 15.6; - case '20.8 kHz': - return 20.8; - case '31.25 kHz': - return 31.25; - case '41.7 kHz': - return 41.7; - case '62.5 kHz': - return 62.5; - case '125 kHz': - return 125.0; - case '250 kHz': - return 250.0; - case '500 kHz': - return 500.0; - default: - return 62.5; - } - } - - String _currentParamProfile(DeviceInfo deviceInfo) { - final sf = deviceInfo.radioSf ?? 8; - final cr = deviceInfo.radioCr ?? 8; - return 'BW $_selectedBandwidth | SF$sf | CR 4/$cr'; - } - - String _recommendationTitle(int index) { - switch (index) { - case 0: - return 'Best candidate'; - case 1: - return 'Alternate'; - case 2: - return 'Fallback'; - default: - return 'Candidate ${index + 1}'; - } - } - - List _possibleBracketFrequenciesKhz() { - final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth); - final halfBandwidthKhz = bandwidthKhz / 2.0; - final startKhz = (_scanRangeValues.start * 1000).round(); - final stopKhz = (_scanRangeValues.end * 1000).round(); - final firstCenterKhz = (startKhz + halfBandwidthKhz).round(); - final lastCenterKhz = (stopKhz - halfBandwidthKhz).round(); - - if (lastCenterKhz < firstCenterKhz) { - return const []; - } - - final stepKhz = bandwidthKhz >= 125.0 ? bandwidthKhz.round() : 25; - final centers = []; - for ( - var centerKhz = firstCenterKhz; - centerKhz <= lastCenterKhz && centers.length < 8; - centerKhz += stepKhz - ) { - centers.add(centerKhz); - } - - if (centers.isEmpty || centers.last != lastCenterKhz) { - centers.add(lastCenterKhz); - } - - return centers.toSet().toList()..sort(); - } - - void _resetDerivedScanResults() { - _scanCandidates = const []; - _selectedScanFrequencyKhz = null; - } - - List<(int startKhz, int stopKhz)> _buildScanSectors(double bandwidthKhz) { - final startKhz = (_scanRangeValues.start * 1000).round(); - final stopKhz = (_scanRangeValues.end * 1000).round(); - final sectorWidthKhz = (bandwidthKhz * 24).round().clamp(250, 1200); - final overlapKhz = bandwidthKhz.round().clamp(8, 500); - final sectors = <(int startKhz, int stopKhz)>[]; - - var sectorStartKhz = startKhz; - while (sectorStartKhz < stopKhz) { - final sectorStopKhz = (sectorStartKhz + sectorWidthKhz).clamp( - sectorStartKhz + overlapKhz, - stopKhz, - ); - sectors.add((sectorStartKhz, sectorStopKhz)); - if (sectorStopKhz >= stopKhz) { - break; - } - sectorStartKhz = sectorStopKhz - overlapKhz; - } - - return sectors; - } - - List _mergeSectorCandidates( - Iterable candidates, - ) { - final byFrequency = {}; - for (final candidate in candidates) { - final existing = byFrequency[candidate.centerFrequencyKhz]; - if (existing == null || - candidate.occupancyPercent < existing.occupancyPercent || - (candidate.occupancyPercent == existing.occupancyPercent && - candidate.peakRssiDbm < existing.peakRssiDbm) || - (candidate.occupancyPercent == existing.occupancyPercent && - candidate.peakRssiDbm == existing.peakRssiDbm && - candidate.avgRssiDbm < existing.avgRssiDbm)) { - byFrequency[candidate.centerFrequencyKhz] = candidate; - } - } - - final merged = byFrequency.values.toList() - ..sort((a, b) { - final occupancyCompare = a.occupancyPercent.compareTo( - b.occupancyPercent, - ); - if (occupancyCompare != 0) return occupancyCompare; - final peakCompare = a.peakRssiDbm.compareTo(b.peakRssiDbm); - if (peakCompare != 0) return peakCompare; - return a.avgRssiDbm.compareTo(b.avgRssiDbm); - }); - return merged; - } - - Future _runSpectrumScan() async { - final connectionProvider = context.read(); - final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth); - final sectors = _buildScanSectors(bandwidthKhz); - final sectorCandidates = []; - - setState(() { - _isSpectrumScanRunning = true; - _resetDerivedScanResults(); - _completedScanSectors = 0; - _totalScanSectors = sectors.length; - }); - - try { - for (var i = 0; i < sectors.length; i++) { - final sector = sectors[i]; - final result = await connectionProvider.scanSpectrum( - startFrequencyKhz: sector.$1, - stopFrequencyKhz: sector.$2, - bandwidthKhz: bandwidthKhz.round(), - stepKhz: (bandwidthKhz / 2).round().clamp(1, 1000), - dwellMs: 160, - thresholdDb: 8, - ); - if (result != null) { - sectorCandidates.addAll(result.candidates); - final mergedCandidates = _mergeSectorCandidates(sectorCandidates); - if (mounted) { - setState(() { - _scanCandidates = mergedCandidates; - if (_selectedScanFrequencyKhz == null && - mergedCandidates.isNotEmpty) { - _selectedScanFrequencyKhz = - mergedCandidates.first.centerFrequencyKhz; - } - _completedScanSectors = i + 1; - }); - } - } else if (mounted) { - setState(() { - _completedScanSectors = i + 1; - }); - } - } - } finally { - if (mounted) { - setState(() { - _isSpectrumScanRunning = false; - if (_completedScanSectors == 0) { - _totalScanSectors = sectors.length; - } - }); - } - } - - if (!mounted) return; - - final mergedCandidates = _mergeSectorCandidates(sectorCandidates); - if (mergedCandidates.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.spectrumScanReturnedNoCandidateFrequencies), - backgroundColor: Colors.red, - ), - ); - return; - } - - setState(() { - _scanCandidates = mergedCandidates; - _selectedScanFrequencyKhz = mergedCandidates.first.centerFrequencyKhz; - }); - } - - Future _applySelectedScanFrequency() async { - if (_selectedScanFrequencyKhz == null) return; - - final connectionProvider = context.read(); - final deviceInfo = connectionProvider.deviceInfo; - - await connectionProvider.setRadioParams( - frequency: _selectedScanFrequencyKhz!, - bandwidth: _bandwidthOptions.indexOf(_selectedBandwidth), - spreadingFactor: deviceInfo.radioSf ?? 8, - codingRate: deviceInfo.radioCr ?? 8, - repeat: deviceInfo.clientRepeat, - ); - await connectionProvider.refreshDeviceInfo(); - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Applied ${(_selectedScanFrequencyKhz! / 1000.0).toStringAsFixed(3)} MHz', - ), - backgroundColor: Colors.green, - ), - ); - } - - int? _currentPreviewFrequencyKhz(DeviceInfo deviceInfo) { - if (_selectedScanFrequencyKhz != null) { - return _selectedScanFrequencyKhz; - } - return deviceInfo.radioFreq; - } - - @override - Widget build(BuildContext context) { - final deviceInfo = context.watch().deviceInfo; - final theme = Theme.of(context); - final possibleBracketFrequencies = _possibleBracketFrequenciesKhz(); - final recommendedScanCandidates = _recommendedScanCandidates; - final recommendationFrequencies = recommendedScanCandidates.isNotEmpty - ? recommendedScanCandidates - .map((candidate) => candidate.centerFrequencyKhz) - .toList() - : possibleBracketFrequencies; - - return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.spectrumScan)), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DropdownButtonFormField( - initialValue: _selectedBandwidth, - decoration: const InputDecoration( - labelText: 'Bandwidth', - border: OutlineInputBorder(), - helperText: - 'Scan and apply frequencies for this bandwidth', - ), - items: _bandwidthOptions.map((value) { - return DropdownMenuItem( - value: value, - child: Text(value), - ); - }).toList(), - onChanged: (value) { - if (value == null) return; - setState(() { - _selectedBandwidth = value; - _resetDerivedScanResults(); - }); - }, - ), - const SizedBox(height: 16), - Text( - deviceInfo.spectrumScanMinKhz != null && - deviceInfo.spectrumScanMaxKhz != null - ? 'Firmware scan range: ${_scanRangeMinMhz.toStringAsFixed(3)}-${_scanRangeMaxMhz.toStringAsFixed(3)} MHz' - : 'Fallback scan range selected from current band. MeshCore commonly uses EU868 or US915.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - if (_isSpectrumScanRunning) ...[ - const SizedBox(height: 8), - Text( - 'Scanning sector ${_completedScanSectors + 1} of $_totalScanSectors. Results update as each sector completes.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.w600, - ), - ), - ], - const SizedBox(height: 12), - SpectrumScanPanel( - theme: theme, - scanSupported: deviceInfo.supportsSpectrumScan == true, - isRunning: _isSpectrumScanRunning, - rangeMinMhz: _scanRangeMinMhz, - rangeMaxMhz: _scanRangeMaxMhz, - rangeValues: _scanRangeValues, - bandwidthKhz: _bandwidthToKhz(_selectedBandwidth), - selectedFrequencyKhz: _currentPreviewFrequencyKhz( - deviceInfo, - ), - graphCandidates: _scanCandidates, - selectableCandidates: recommendedScanCandidates, - onRangeChanged: (values) { - setState(() { - _scanRangeValues = values; - _resetDerivedScanResults(); - }); - }, - onCandidateChanged: (value) { - setState(() { - _selectedScanFrequencyKhz = value; - }); - }, - onRunScan: _runSpectrumScan, - onApplySelected: _applySelectedScanFrequency, - ), - if (recommendationFrequencies.isNotEmpty) ...[ - const SizedBox(height: 18), - Text( - _scanCandidates.isNotEmpty - ? 'Recommended profiles' - : 'Possible brackets', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - _scanCandidates.isNotEmpty - ? 'Suggested frequencies from the latest scan with the radio parameters to keep alongside them.' - : 'Usable frequency brackets derived from the selected span and bandwidth, even without live scan data.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - for ( - var i = 0; - i < recommendationFrequencies.length; - i++ - ) ...[ - _RecommendationTile( - title: _recommendationTitle(i), - frequencyKhz: recommendationFrequencies[i], - candidate: recommendedScanCandidates.isNotEmpty - ? recommendedScanCandidates[i] - : null, - bandwidthKhz: _bandwidthToKhz(_selectedBandwidth), - paramsLabel: _currentParamProfile(deviceInfo), - isSelected: - _selectedScanFrequencyKhz == - recommendationFrequencies[i], - onSelect: () { - setState(() { - _selectedScanFrequencyKhz = - recommendationFrequencies[i]; - }); - }, - ), - if (i != recommendationFrequencies.length - 1) - const SizedBox(height: 10), - ], - ], - ], - ), - ), - ), - ], - ), - ); - } -} - -class _RecommendationTile extends StatelessWidget { - final String title; - final int frequencyKhz; - final SpectrumScanCandidate? candidate; - final double bandwidthKhz; - final String paramsLabel; - final bool isSelected; - final VoidCallback onSelect; - - const _RecommendationTile({ - required this.title, - required this.frequencyKhz, - required this.candidate, - required this.bandwidthKhz, - required this.paramsLabel, - required this.isSelected, - required this.onSelect, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - - return InkWell( - onTap: onSelect, - borderRadius: BorderRadius.circular(14), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: isSelected - ? scheme.primaryContainer.withValues(alpha: 0.55) - : scheme.surfaceContainerHighest.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: isSelected ? scheme.primary : scheme.outlineVariant, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - title, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - if (isSelected) - Icon(Icons.check_circle, color: scheme.primary, size: 18), - ], - ), - const SizedBox(height: 8), - Text( - '${(frequencyKhz / 1000.0).toStringAsFixed(3)} MHz', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 6), - Text(paramsLabel, style: theme.textTheme.bodyMedium), - const SizedBox(height: 6), - Text( - candidate != null - ? 'Occupancy ${candidate!.occupancyPercent}% | Avg ${candidate!.avgRssiDbm} dBm | Peak ${candidate!.peakRssiDbm} dBm' - : 'Bracket ${(frequencyKhz / 1000.0 - bandwidthKhz / 2000.0).toStringAsFixed(3)}-${(frequencyKhz / 1000.0 + bandwidthKhz / 2000.0).toStringAsFixed(3)} MHz', - style: theme.textTheme.bodySmall?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/services/location_tracking_service.dart b/lib/services/location_tracking_service.dart index dbdb4b4..e1b8889 100644 --- a/lib/services/location_tracking_service.dart +++ b/lib/services/location_tracking_service.dart @@ -49,6 +49,7 @@ class LocationTrackingService { 'fast_location_movement_threshold_meters'; static const String _prefKeyFastActiveCadence = 'fast_location_active_cadence_seconds'; + static const String _prefKeyFastChannelIdx = 'fast_location_channel_idx'; String _scopedKey(String baseKey) { return ProfileStorageScope.scopedKey(baseKey); @@ -79,6 +80,9 @@ class LocationTrackingService { /// Cadence for active-use fast GPS updates int fastLocationActiveCadenceSeconds = 10; + /// Target channel index for fast GPS updates; null means disabled/unset. + int? fastLocationChannelIdx; + // ============================================================================ // State Properties // ============================================================================ @@ -528,8 +532,14 @@ class LocationTrackingService { _refreshFastLocationTimer(); } + Future updateFastLocationChannelIdx(int? channelIdx) async { + fastLocationChannelIdx = channelIdx; + await saveSettings(); + _refreshFastLocationTimer(); + } + void _evaluateFastLocationMovement(Position position) { - if (!fastLocationUpdatesEnabled) return; + if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return; final previous = _lastFastLocationSentPosition; if (previous == null) { _emitFastLocationUpdate(position, reason: 'initial'); @@ -552,6 +562,7 @@ class LocationTrackingService { _fastLocationTimer = null; if (!isTracking || !fastLocationUpdatesEnabled || + fastLocationChannelIdx == null || !_isFastLocationActiveUse) { return; } @@ -567,7 +578,7 @@ class LocationTrackingService { } void _emitFastLocationUpdate(Position position, {required String reason}) { - if (!fastLocationUpdatesEnabled) return; + if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return; final now = DateTime.now(); final previous = _lastFastLocationSentPosition; @@ -669,6 +680,7 @@ class LocationTrackingService { 5, 60, ); + fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx)); debugPrint('βœ… [LocationTracking] Settings loaded'); debugPrint(' Min distance: ${minDistanceMeters}m'); @@ -680,6 +692,7 @@ class LocationTrackingService { ' Fast movement threshold: ${fastLocationMovementThresholdMeters}m', ); debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s'); + debugPrint(' Fast channel idx: ${fastLocationChannelIdx ?? "unset"}'); } /// Save settings to SharedPreferences @@ -709,6 +722,12 @@ class LocationTrackingService { _scopedKey(_prefKeyFastActiveCadence), fastLocationActiveCadenceSeconds, ); + final channelKey = _scopedKey(_prefKeyFastChannelIdx); + if (fastLocationChannelIdx == null) { + await prefs.remove(channelKey); + } else { + await prefs.setInt(channelKey, fastLocationChannelIdx!); + } debugPrint('βœ… [LocationTracking] Settings saved'); } diff --git a/lib/widgets/device/spectrum_scan_panel.dart b/lib/widgets/device/spectrum_scan_panel.dart deleted file mode 100644 index e232413..0000000 --- a/lib/widgets/device/spectrum_scan_panel.dart +++ /dev/null @@ -1,387 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:meshcore_client/meshcore_client.dart'; -import '../../l10n/app_localizations.dart'; - -class SpectrumScanPanel extends StatelessWidget { - final ThemeData theme; - final bool scanSupported; - final bool isRunning; - final double rangeMinMhz; - final double rangeMaxMhz; - final RangeValues rangeValues; - final double bandwidthKhz; - final int? selectedFrequencyKhz; - final List graphCandidates; - final List selectableCandidates; - final ValueChanged onRangeChanged; - final ValueChanged onCandidateChanged; - final VoidCallback onRunScan; - final VoidCallback onApplySelected; - - const SpectrumScanPanel({ - super.key, - required this.theme, - required this.scanSupported, - required this.isRunning, - required this.rangeMinMhz, - required this.rangeMaxMhz, - required this.rangeValues, - required this.bandwidthKhz, - required this.selectedFrequencyKhz, - required this.graphCandidates, - required this.selectableCandidates, - required this.onRangeChanged, - required this.onCandidateChanged, - required this.onRunScan, - required this.onApplySelected, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues( - alpha: 0.45, - ), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.tune, color: theme.colorScheme.primary), - const SizedBox(width: 10), - Expanded( - child: Text( - 'Power Scan', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - FilledButton.icon( - onPressed: scanSupported && !isRunning ? onRunScan : null, - icon: isRunning - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.radar), - label: Text( - scanSupported - ? (isRunning ? 'Scanning' : 'Scan') - : 'Unavailable', - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - scanSupported - ? 'Full range with bandwidth footprint. Firmware enforces hardware band limits and pauses the mesh while scanning.' - : 'Full range with bandwidth footprint. This companion does not support spectrum scan mode, so scanning is disabled.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 14), - _FrequencyRangePreview( - minMhz: rangeMinMhz, - maxMhz: rangeMaxMhz, - selectedRange: rangeValues, - selectedBandwidthKhz: bandwidthKhz, - selectedFrequencyKhz: selectedFrequencyKhz, - candidates: graphCandidates, - ), - const SizedBox(height: 10), - Wrap( - spacing: 14, - runSpacing: 6, - children: [ - _LegendChip( - color: theme.colorScheme.primary, - label: AppLocalizations.of(context)!.quiet, - ), - _LegendChip( - color: Colors.orange, - label: AppLocalizations.of(context)!.moderate, - ), - _LegendChip( - color: theme.colorScheme.error, - label: AppLocalizations.of(context)!.busy, - ), - ], - ), - const SizedBox(height: 10), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '${rangeValues.start.toStringAsFixed(3)} MHz', - style: theme.textTheme.labelMedium, - ), - Text( - '${rangeValues.end.toStringAsFixed(3)} MHz', - style: theme.textTheme.labelMedium, - ), - ], - ), - RangeSlider( - values: rangeValues, - min: rangeMinMhz, - max: rangeMaxMhz, - divisions: (((rangeMaxMhz - rangeMinMhz) * 20).round()).clamp( - 1, - 400, - ), - labels: RangeLabels( - rangeValues.start.toStringAsFixed(3), - rangeValues.end.toStringAsFixed(3), - ), - onChanged: onRangeChanged, - ), - if (selectableCandidates.isEmpty) ...[ - const SizedBox(height: 6), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Text( - scanSupported - ? 'No scan results yet. Adjust the range and run a scan to populate candidate frequencies.' - : 'Spectrum preview only. This companion can display the configured span, but cannot scan for open channels.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ] else ...[ - const SizedBox(height: 10), - DropdownButtonFormField( - initialValue: selectedFrequencyKhz, - isExpanded: true, - decoration: const InputDecoration( - labelText: 'Candidate frequency', - border: OutlineInputBorder(), - helperText: 'Best frequencies for the current bandwidth', - ), - items: selectableCandidates.map((candidate) { - return DropdownMenuItem( - value: candidate.centerFrequencyKhz, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz', - overflow: TextOverflow.ellipsis, - ), - Text( - '${candidate.occupancyPercent}% occupied | peak ${candidate.peakRssiDbm} dBm', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); - }).toList(), - selectedItemBuilder: (context) { - return selectableCandidates.map((candidate) { - return Align( - alignment: Alignment.centerLeft, - child: Text( - '${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz', - overflow: TextOverflow.ellipsis, - ), - ); - }).toList(); - }, - onChanged: onCandidateChanged, - ), - const SizedBox(height: 10), - Align( - alignment: Alignment.centerRight, - child: OutlinedButton.icon( - onPressed: selectedFrequencyKhz == null - ? null - : onApplySelected, - icon: Icon(Icons.north_east), - label: Text(AppLocalizations.of(context)!.useSelectedFrequency), - ), - ), - ], - ], - ), - ); - } -} - -class _LegendChip extends StatelessWidget { - final Color color; - final String label; - - const _LegendChip({required this.color, required this.label}); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - const SizedBox(width: 6), - Text(label, style: Theme.of(context).textTheme.labelMedium), - ], - ); - } -} - -class _FrequencyRangePreview extends StatelessWidget { - final double minMhz; - final double maxMhz; - final RangeValues selectedRange; - final double selectedBandwidthKhz; - final int? selectedFrequencyKhz; - final List candidates; - - const _FrequencyRangePreview({ - required this.minMhz, - required this.maxMhz, - required this.selectedRange, - required this.selectedBandwidthKhz, - required this.selectedFrequencyKhz, - required this.candidates, - }); - - double _positionFor(double mhz) { - final span = maxMhz - minMhz; - if (span <= 0) return 0; - return ((mhz - minMhz) / span).clamp(0.0, 1.0); - } - - Color _candidateColor(BuildContext context, int occupancyPercent) { - final scheme = Theme.of(context).colorScheme; - if (occupancyPercent <= 10) return scheme.primary; - if (occupancyPercent <= 35) return Colors.orange; - return scheme.error; - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final spanMhz = maxMhz - minMhz; - final selectedFreqMhz = selectedFrequencyKhz != null - ? selectedFrequencyKhz! / 1000.0 - : null; - final bwMhz = selectedBandwidthKhz / 1000.0; - - return LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final rangeLeft = _positionFor(selectedRange.start) * width; - final rangeRight = _positionFor(selectedRange.end) * width; - - double? bwLeft; - double? bwWidth; - if (selectedFreqMhz != null && spanMhz > 0) { - bwLeft = _positionFor(selectedFreqMhz - (bwMhz / 2)) * width; - final bwRight = _positionFor(selectedFreqMhz + (bwMhz / 2)) * width; - bwWidth = (bwRight - bwLeft).clamp(4.0, width); - } - - return Container( - height: 108, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: scheme.surfaceContainerLow, - border: Border.all(color: scheme.outlineVariant), - ), - child: Stack( - children: [ - Positioned.fill( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 12, - ), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: scheme.surfaceContainerHigh, - ), - ), - ), - ), - Positioned( - left: rangeLeft, - top: 12, - width: (rangeRight - rangeLeft).clamp(8.0, width), - height: 56, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: scheme.primaryContainer, - border: Border.all(color: scheme.primary), - ), - ), - ), - if (bwLeft != null && bwWidth != null) - Positioned( - left: bwLeft, - top: 28, - width: bwWidth, - height: 24, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(999), - color: scheme.tertiaryContainer, - border: Border.all(color: scheme.tertiary), - ), - ), - ), - for (final candidate in candidates) - Positioned( - left: (_positionFor(candidate.centerFrequencyMhz) * width) - .clamp(10.0, width - 18.0), - top: 72, - child: Column( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _candidateColor( - context, - candidate.occupancyPercent, - ), - ), - ), - const SizedBox(height: 4), - Text( - candidate.centerFrequencyMhz.toStringAsFixed(3), - style: Theme.of(context).textTheme.labelSmall, - ), - ], - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/pubspec.lock b/pubspec.lock index 85cc08f..f32ab84 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -827,8 +827,8 @@ packages: dependency: "direct main" description: path: "." - ref: main - resolved-ref: "71902401e9a6bc7d2ed1193f8961e7256646ab76" + ref: dfc5b82c3f8a7f160ca77e45dc65a9eb39cebc5a + resolved-ref: dfc5b82c3f8a7f160ca77e45dc65a9eb39cebc5a url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" diff --git a/pubspec.yaml b/pubspec.yaml index ee23870..4c99b0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git - ref: main + ref: dfc5b82c3f8a7f160ca77e45dc65a9eb39cebc5a # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: diff --git a/test/services/location_tracking_service_test.dart b/test/services/location_tracking_service_test.dart new file mode 100644 index 0000000..29dc77b --- /dev/null +++ b/test/services/location_tracking_service_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:meshcore_sar_app/services/location_tracking_service.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; + +void main() { + final service = LocationTrackingService(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: false, + activeProfileId: 'default', + ); + service.fastLocationUpdatesEnabled = false; + service.fastLocationMovementThresholdMeters = 10.0; + service.fastLocationActiveCadenceSeconds = 10; + service.fastLocationChannelIdx = null; + }); + + test('persists and restores fast location channel idx', () async { + await service.updateFastLocationChannelIdx(3); + + service.fastLocationChannelIdx = null; + await service.loadSettings(); + + expect(service.fastLocationChannelIdx, 3); + }); + + test('clears persisted fast location channel idx when unset', () async { + await service.updateFastLocationChannelIdx(7); + await service.updateFastLocationChannelIdx(null); + + service.fastLocationChannelIdx = 99; + await service.loadSettings(); + + expect(service.fastLocationChannelIdx, isNull); + }); +}