mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Merge pull request #34 from dz0ny/feat/channel-datagrams
feat: Add channel datagrams #1928
This commit is contained in:
@@ -2,7 +2,8 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/foundation.dart';
|
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 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'connection_provider.dart';
|
import 'connection_provider.dart';
|
||||||
import 'contacts_provider.dart';
|
import 'contacts_provider.dart';
|
||||||
@@ -211,6 +212,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
static const int _maxPacketRetryAttempts = 4;
|
static const int _maxPacketRetryAttempts = 4;
|
||||||
final Map<String, String> _voiceSessionSenderKey6 = {};
|
final Map<String, String> _voiceSessionSenderKey6 = {};
|
||||||
final Map<String, String> _imageSessionSenderKey6 = {};
|
final Map<String, String> _imageSessionSenderKey6 = {};
|
||||||
|
final Map<String, Map<int, VoicePacket>> _pendingChannelVoicePackets = {};
|
||||||
|
final Map<String, Map<int, ImagePacket>> _pendingChannelImageFragments = {};
|
||||||
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
||||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||||
final Map<String, Timer> _imageMissingRetryTimers = {};
|
final Map<String, Timer> _imageMissingRetryTimers = {};
|
||||||
@@ -1315,6 +1318,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
voiceProvider.registerEnvelope(voiceEnvelope);
|
voiceProvider.registerEnvelope(voiceEnvelope);
|
||||||
|
_replayPendingChannelVoicePackets(voiceEnvelope.sessionId);
|
||||||
enrichedMessage = enrichedMessage.copyWith(
|
enrichedMessage = enrichedMessage.copyWith(
|
||||||
isVoice: true,
|
isVoice: true,
|
||||||
voiceId: voiceEnvelope.sessionId,
|
voiceId: voiceEnvelope.sessionId,
|
||||||
@@ -1352,6 +1356,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
imageProvider.registerEnvelope(imageEnvelope);
|
imageProvider.registerEnvelope(imageEnvelope);
|
||||||
|
_replayPendingChannelImageFragments(imageEnvelope.sessionId);
|
||||||
messagesProvider.addMessage(
|
messagesProvider.addMessage(
|
||||||
enrichedMessage,
|
enrichedMessage,
|
||||||
contactLookup: (name) {
|
contactLookup: (name) {
|
||||||
@@ -1469,15 +1474,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final fastGpsPacket = FastGpsPacket.tryParseBinary(payload);
|
if (_handleFastGpsPayload(payload)) {
|
||||||
if (fastGpsPacket != null) {
|
|
||||||
final sender = _resolveContactByPrefixHex(fastGpsPacket.senderKey6);
|
|
||||||
if (sender != null) {
|
|
||||||
contactsProvider.updateFastGps(
|
|
||||||
sender.publicKey.sublist(0, 6),
|
|
||||||
fastGpsPacket,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1614,64 +1611,42 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ImagePacket.isImageBinary(payload)) {
|
if (_handleIncomingImageBinaryPayload(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;
|
return;
|
||||||
}
|
}
|
||||||
imageProvider.addFragment(
|
_handleIncomingVoiceBinaryPayload(payload);
|
||||||
session == null
|
};
|
||||||
? frag
|
|
||||||
: ImagePacket(
|
connectionProvider.onChannelDataReceived =
|
||||||
sessionId: frag.sessionId,
|
(channelIdx, pathLen, dataType, payload, snrRaw, rssiDbm) {
|
||||||
format: session.format,
|
if (dataType != MeshCoreConstants.dataTypeDev) {
|
||||||
index: frag.index,
|
debugPrint(
|
||||||
total: session.total,
|
'📦 [AppProvider] Ignoring channel datagram type '
|
||||||
data: frag.data,
|
'0x${dataType.toRadixString(16).padLeft(4, '0')} on channel $channelIdx',
|
||||||
),
|
|
||||||
width: session?.width ?? 0,
|
|
||||||
height: session?.height ?? 0,
|
|
||||||
);
|
|
||||||
_scheduleImageMissingRetry(
|
|
||||||
frag.sessionId,
|
|
||||||
justComplete: imageProvider.isComplete(frag.sessionId),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VoicePacket.isVoiceBinary(payload)) return;
|
if (_handleFastGpsPayload(payload)) {
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
final justComplete = voiceProvider.addPacket(
|
if (_handleIncomingImageBinaryPayload(
|
||||||
session == null
|
payload,
|
||||||
? pkt
|
allowPreEnvelopeBuffer: true,
|
||||||
: VoicePacket(
|
)) {
|
||||||
sessionId: pkt.sessionId,
|
return;
|
||||||
mode: session.mode,
|
}
|
||||||
index: pkt.index,
|
if (_handleIncomingVoiceBinaryPayload(
|
||||||
total: session.total,
|
payload,
|
||||||
codec2Data: pkt.codec2Data,
|
allowPreEnvelopeBuffer: true,
|
||||||
),
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'📦 [AppProvider] Unknown developer channel payload on channel '
|
||||||
|
'$channelIdx: ${payload.isNotEmpty ? payload.first : -1}',
|
||||||
);
|
);
|
||||||
_scheduleVoiceMissingRetry(pkt.sessionId, justComplete: justComplete);
|
|
||||||
// Insert or update the placeholder message in the chat list
|
|
||||||
_handleIncomingVoicePacket(pkt, justComplete: justComplete);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
connectionProvider.onControlDataReceived =
|
connectionProvider.onControlDataReceived =
|
||||||
@@ -2805,6 +2780,23 @@ class AppProvider with ChangeNotifier {
|
|||||||
return;
|
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;
|
final publicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
if (publicKey == null || publicKey.length < 6) {
|
if (publicKey == null || publicKey.length < 6) {
|
||||||
return;
|
return;
|
||||||
@@ -2822,10 +2814,14 @@ class AppProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📍 [AppProvider] Sending fast GPS update ($reason): '
|
'📍 [AppProvider] Sending fast GPS update ($reason): '
|
||||||
'${position.latitude}, ${position.longitude}',
|
'${position.latitude}, ${position.longitude} via channel $channelIdx',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await connectionProvider.sendRawPrivateMulticast(packet.encodeBinary());
|
await connectionProvider.sendChannelData(
|
||||||
|
channelIdx: channelIdx,
|
||||||
|
dataType: MeshCoreConstants.dataTypeDev,
|
||||||
|
payload: packet.encodeBinary(),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [AppProvider] Fast GPS send failed: $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,
|
||||||
|
() => <int, ImagePacket>{},
|
||||||
|
)[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,
|
||||||
|
() => <int, VoicePacket>{},
|
||||||
|
)[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.
|
/// Insert or update a voice placeholder message for binary raw-data packets.
|
||||||
///
|
///
|
||||||
/// Binary voice packets arrive without a chat message, so we synthesise one
|
/// Binary voice packets arrive without a chat message, so we synthesise one
|
||||||
@@ -3767,6 +3930,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
_imageMissingRetryAttempts.clear();
|
_imageMissingRetryAttempts.clear();
|
||||||
_voiceSessionSenderKey6.clear();
|
_voiceSessionSenderKey6.clear();
|
||||||
_imageSessionSenderKey6.clear();
|
_imageSessionSenderKey6.clear();
|
||||||
|
_pendingChannelVoicePackets.clear();
|
||||||
|
_pendingChannelImageFragments.clear();
|
||||||
_lowBatteryNotifiedNodeIds.clear();
|
_lowBatteryNotifiedNodeIds.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -3876,6 +4041,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
for (final timer in _imageMissingRetryTimers.values) {
|
for (final timer in _imageMissingRetryTimers.values) {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
|
_pendingChannelVoicePackets.clear();
|
||||||
|
_pendingChannelImageFragments.clear();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,8 +105,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
bool _isScanning = false;
|
bool _isScanning = false;
|
||||||
bool get isScanning => _isScanning;
|
bool get isScanning => _isScanning;
|
||||||
bool _isSpectrumScanActive = false;
|
|
||||||
bool get isSpectrumScanActive => _isSpectrumScanActive;
|
|
||||||
final Set<int> _pendingDeletedChannelIndices = <int>{};
|
final Set<int> _pendingDeletedChannelIndices = <int>{};
|
||||||
|
|
||||||
String? _error;
|
String? _error;
|
||||||
@@ -212,6 +210,15 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
onMessageEchoDetected;
|
onMessageEchoDetected;
|
||||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||||
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
|
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)?
|
Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)?
|
||||||
onControlDataReceived;
|
onControlDataReceived;
|
||||||
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
|
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
|
||||||
@@ -463,10 +470,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
service.onMessageWaiting = () {
|
service.onMessageWaiting = () {
|
||||||
if (_isSpectrumScanActive) {
|
|
||||||
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
|
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
|
||||||
_pendingAutomaticMessageSync = true;
|
_pendingAutomaticMessageSync = true;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -533,6 +536,16 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
|
service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
|
||||||
onRawDataReceived?.call(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) =>
|
service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) =>
|
||||||
onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen);
|
onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen);
|
||||||
|
|
||||||
@@ -548,9 +561,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
semanticVersion: deviceInfo['semanticVersion'] as String?,
|
semanticVersion: deviceInfo['semanticVersion'] as String?,
|
||||||
clientRepeat: deviceInfo['clientRepeat'] as bool?,
|
clientRepeat: deviceInfo['clientRepeat'] as bool?,
|
||||||
pathHashMode: deviceInfo['pathHashMode'] as int?,
|
pathHashMode: deviceInfo['pathHashMode'] as int?,
|
||||||
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
|
|
||||||
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
|
|
||||||
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
|
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
};
|
};
|
||||||
@@ -1804,6 +1814,30 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> 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
|
/// Request telemetry from contact
|
||||||
///
|
///
|
||||||
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
|
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
|
||||||
@@ -2238,43 +2272,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<SpectrumScanResult?> 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
|
/// Set transmit power
|
||||||
Future<void> setTxPower(int powerDbm) async {
|
Future<void> setTxPower(int powerDbm) async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
@@ -2464,7 +2461,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshDeviceInfo() async {
|
Future<void> refreshDeviceInfo() async {
|
||||||
if (_isSpectrumScanActive) return;
|
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
_error = 'Not connected to device';
|
_error = 'Not connected to device';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -2537,7 +2533,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// Sync messages from device queue
|
/// Sync messages from device queue
|
||||||
/// Call this repeatedly until no more messages are available
|
/// Call this repeatedly until no more messages are available
|
||||||
Future<bool> syncNextMessage() async {
|
Future<bool> syncNextMessage() async {
|
||||||
if (_isSpectrumScanActive) return false;
|
|
||||||
// Prevent re-entrancy and too-fast triggers
|
// Prevent re-entrancy and too-fast triggers
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
// Another sync (single or loop) is in progress
|
// Another sync (single or loop) is in progress
|
||||||
@@ -2576,10 +2571,6 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Sync all waiting messages from device
|
/// Sync all waiting messages from device
|
||||||
Future<int> syncAllMessages({bool force = false}) async {
|
Future<int> syncAllMessages({bool force = false}) async {
|
||||||
if (_isSpectrumScanActive) {
|
|
||||||
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
|
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
|
||||||
_pendingAutomaticMessageSync = true;
|
_pendingAutomaticMessageSync = true;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
|
|||||||
@@ -23,13 +23,11 @@ import 'device_config_screen.dart';
|
|||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
import 'live_traffic_screen.dart';
|
import 'live_traffic_screen.dart';
|
||||||
import 'profiles_screen.dart';
|
import 'profiles_screen.dart';
|
||||||
import 'spectrum_scan_screen.dart';
|
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/permission_request_dialog.dart';
|
import '../widgets/permission_request_dialog.dart';
|
||||||
import '../widgets/connection_dialog.dart';
|
import '../widgets/connection_dialog.dart';
|
||||||
import '../utils/battery_display_helper.dart';
|
import '../utils/battery_display_helper.dart';
|
||||||
import '../services/developer_mode_service.dart';
|
|
||||||
import '../services/mesh_map_nodes_service.dart';
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
import '../services/profile_device_key_resolver.dart';
|
import '../services/profile_device_key_resolver.dart';
|
||||||
import '../services/profile_manager.dart';
|
import '../services/profile_manager.dart';
|
||||||
@@ -68,7 +66,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
bool _isMapFullscreen = false;
|
bool _isMapFullscreen = false;
|
||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
bool _isDeveloperModeEnabled = false;
|
|
||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
bool _isContactsEnabled = true;
|
bool _isContactsEnabled = true;
|
||||||
bool _isSensorsEnabled = false;
|
bool _isSensorsEnabled = false;
|
||||||
@@ -107,7 +104,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
// Initialize synchronously so first build always has a valid controller.
|
// Initialize synchronously so first build always has a valid controller.
|
||||||
_initTabController();
|
_initTabController();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
_loadDeveloperModePreference();
|
|
||||||
MeshMapNodesService.syncInBackgroundIfStale();
|
MeshMapNodesService.syncInBackgroundIfStale();
|
||||||
|
|
||||||
// Show permission dialog after the first frame if needed
|
// Show permission dialog after the first frame if needed
|
||||||
@@ -291,14 +287,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadDeveloperModePreference() async {
|
|
||||||
final isEnabled = await DeveloperModeService.isEnabled();
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_isDeveloperModeEnabled = isEnabled;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showPermissionDialog() {
|
void _showPermissionDialog() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -811,32 +799,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
.read<ProfileManager>()
|
.read<ProfileManager>()
|
||||||
.profilesEnabled;
|
.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(
|
items.add(
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -954,7 +916,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
_loadDeveloperModePreference();
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'dart:math' as math;
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:meshcore_client/meshcore_client.dart' show MeshCoreConstants;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../providers/messages_provider.dart';
|
import '../providers/messages_provider.dart';
|
||||||
@@ -114,7 +115,9 @@ class _ComposerActionTile extends StatelessWidget {
|
|||||||
width: 38,
|
width: 38,
|
||||||
height: 38,
|
height: 38,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10),
|
color: effectiveColor.withValues(
|
||||||
|
alpha: enabled ? 0.14 : 0.10,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
@@ -964,6 +967,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (!shouldContinue) return;
|
if (!shouldContinue) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
ToastLogger.error(context, 'Not connected to device');
|
ToastLogger.error(context, 'Not connected to device');
|
||||||
return;
|
return;
|
||||||
@@ -975,6 +979,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
final rawBytes = await picked.readAsBytes();
|
final rawBytes = await picked.readAsBytes();
|
||||||
|
|
||||||
setState(() => _isSendingImage = true);
|
setState(() => _isSendingImage = true);
|
||||||
|
String? failedMessageId;
|
||||||
try {
|
try {
|
||||||
// Compress AVIF using user-selected size, compression and color mode.
|
// Compress AVIF using user-selected size, compression and color mode.
|
||||||
final maxSize = await ImagePreferences.getMaxSize();
|
final maxSize = await ImagePreferences.getMaxSize();
|
||||||
@@ -1052,8 +1057,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
imageProvider.cacheOutgoingSession(sessionId, fragments, envelope);
|
imageProvider.cacheOutgoingSession(sessionId, fragments, envelope);
|
||||||
|
|
||||||
// Add local placeholder message.
|
// Add local placeholder message.
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
|
||||||
final msgId = 'img_${sessionId}_sent';
|
final msgId = 'img_${sessionId}_sent';
|
||||||
|
failedMessageId = msgId;
|
||||||
final isChannel =
|
final isChannel =
|
||||||
_destinationType ==
|
_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel;
|
MessageDestinationPreferences.destinationTypeChannel;
|
||||||
@@ -1111,10 +1116,23 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
'chunk=${imageDataBytesPerFragment}B',
|
'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
|
// Image fragments are always served on demand after an explicit IR2
|
||||||
// fetch request, including direct contacts.
|
// fetch request, including direct contacts.
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
|
debugPrint('❌ [Image] _pickAndSendImage: $e\n$st');
|
||||||
|
if (failedMessageId != null) {
|
||||||
|
messagesProvider.markMessageFailed(failedMessageId);
|
||||||
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ToastLogger.error(context, 'Image send failed');
|
ToastLogger.error(context, 'Image send failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1423,6 +1441,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
debugPrint('🎙️ [Voice] envelope sent for session $sessionId');
|
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).
|
// Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking).
|
||||||
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
|
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
|
||||||
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
|
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
|
||||||
@@ -1759,7 +1786,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: actions.length,
|
itemCount: actions.length,
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 2,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: 12,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: 12,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../providers/app_provider.dart';
|
|||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/drawing_provider.dart';
|
import '../providers/drawing_provider.dart';
|
||||||
import '../providers/map_provider.dart';
|
import '../providers/map_provider.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
import '../models/config_profile.dart';
|
import '../models/config_profile.dart';
|
||||||
import '../services/location_tracking_service.dart';
|
import '../services/location_tracking_service.dart';
|
||||||
import '../services/locale_preferences.dart';
|
import '../services/locale_preferences.dart';
|
||||||
@@ -79,6 +80,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
bool _fastLocationUpdatesEnabled = false;
|
bool _fastLocationUpdatesEnabled = false;
|
||||||
double _fastLocationMovementThresholdMeters = 10.0;
|
double _fastLocationMovementThresholdMeters = 10.0;
|
||||||
int _fastLocationActiveCadenceSeconds = 10;
|
int _fastLocationActiveCadenceSeconds = 10;
|
||||||
|
int? _fastLocationChannelIdx;
|
||||||
bool _rotateMapWithHeading = false;
|
bool _rotateMapWithHeading = false;
|
||||||
bool _showMapDebugInfo = false;
|
bool _showMapDebugInfo = false;
|
||||||
bool _openMapInFullscreen = false;
|
bool _openMapInFullscreen = false;
|
||||||
@@ -314,6 +316,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
_locationService.fastLocationMovementThresholdMeters;
|
_locationService.fastLocationMovementThresholdMeters;
|
||||||
_fastLocationActiveCadenceSeconds =
|
_fastLocationActiveCadenceSeconds =
|
||||||
_locationService.fastLocationActiveCadenceSeconds;
|
_locationService.fastLocationActiveCadenceSeconds;
|
||||||
|
_fastLocationChannelIdx = _locationService.fastLocationChannelIdx;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +410,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _describeFastLocationChannel(List<Contact> 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<void> _editFastLocationChannel() async {
|
||||||
|
final channels =
|
||||||
|
List<Contact>.from(context.read<ContactsProvider>().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<int?>(
|
||||||
|
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<void> _saveImageMaxSize(int size) async {
|
Future<void> _saveImageMaxSize(int size) async {
|
||||||
await ImagePreferences.setMaxSize(size);
|
await ImagePreferences.setMaxSize(size);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -1766,6 +1842,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: _editFastLocationActiveCadence,
|
onTap: _editFastLocationActiveCadence,
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.forum),
|
||||||
|
title: const Text('Fast GPS target channel'),
|
||||||
|
subtitle: Text(
|
||||||
|
_describeFastLocationChannel(
|
||||||
|
context.watch<ContactsProvider>().channels,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: _editFastLocationChannel,
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.location_on),
|
leading: Icon(Icons.location_on),
|
||||||
title: Text(AppLocalizations.of(context)!.locationPermission),
|
title: Text(AppLocalizations.of(context)!.locationPermission),
|
||||||
|
|||||||
@@ -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<SpectrumScanScreen> createState() => _SpectrumScanScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
|
|
||||||
static const List<String> _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<SpectrumScanCandidate> _scanCandidates = const [];
|
|
||||||
int? _selectedScanFrequencyKhz;
|
|
||||||
int _completedScanSectors = 0;
|
|
||||||
int _totalScanSectors = 0;
|
|
||||||
|
|
||||||
List<SpectrumScanCandidate> 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<ConnectionProvider>().deviceInfo;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
_syncRangeFromDevice(context.watch<ConnectionProvider>().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<int> _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 = <int>[];
|
|
||||||
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<SpectrumScanCandidate> _mergeSectorCandidates(
|
|
||||||
Iterable<SpectrumScanCandidate> candidates,
|
|
||||||
) {
|
|
||||||
final byFrequency = <int, SpectrumScanCandidate>{};
|
|
||||||
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<void> _runSpectrumScan() async {
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
|
||||||
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
|
|
||||||
final sectors = _buildScanSectors(bandwidthKhz);
|
|
||||||
final sectorCandidates = <SpectrumScanCandidate>[];
|
|
||||||
|
|
||||||
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<void> _applySelectedScanFrequency() async {
|
|
||||||
if (_selectedScanFrequencyKhz == null) return;
|
|
||||||
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
|
||||||
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<ConnectionProvider>().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<String>(
|
|
||||||
initialValue: _selectedBandwidth,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Bandwidth',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
helperText:
|
|
||||||
'Scan and apply frequencies for this bandwidth',
|
|
||||||
),
|
|
||||||
items: _bandwidthOptions.map((value) {
|
|
||||||
return DropdownMenuItem<String>(
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,6 +49,7 @@ class LocationTrackingService {
|
|||||||
'fast_location_movement_threshold_meters';
|
'fast_location_movement_threshold_meters';
|
||||||
static const String _prefKeyFastActiveCadence =
|
static const String _prefKeyFastActiveCadence =
|
||||||
'fast_location_active_cadence_seconds';
|
'fast_location_active_cadence_seconds';
|
||||||
|
static const String _prefKeyFastChannelIdx = 'fast_location_channel_idx';
|
||||||
|
|
||||||
String _scopedKey(String baseKey) {
|
String _scopedKey(String baseKey) {
|
||||||
return ProfileStorageScope.scopedKey(baseKey);
|
return ProfileStorageScope.scopedKey(baseKey);
|
||||||
@@ -79,6 +80,9 @@ class LocationTrackingService {
|
|||||||
/// Cadence for active-use fast GPS updates
|
/// Cadence for active-use fast GPS updates
|
||||||
int fastLocationActiveCadenceSeconds = 10;
|
int fastLocationActiveCadenceSeconds = 10;
|
||||||
|
|
||||||
|
/// Target channel index for fast GPS updates; null means disabled/unset.
|
||||||
|
int? fastLocationChannelIdx;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// State Properties
|
// State Properties
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -528,8 +532,14 @@ class LocationTrackingService {
|
|||||||
_refreshFastLocationTimer();
|
_refreshFastLocationTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateFastLocationChannelIdx(int? channelIdx) async {
|
||||||
|
fastLocationChannelIdx = channelIdx;
|
||||||
|
await saveSettings();
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
}
|
||||||
|
|
||||||
void _evaluateFastLocationMovement(Position position) {
|
void _evaluateFastLocationMovement(Position position) {
|
||||||
if (!fastLocationUpdatesEnabled) return;
|
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
|
||||||
final previous = _lastFastLocationSentPosition;
|
final previous = _lastFastLocationSentPosition;
|
||||||
if (previous == null) {
|
if (previous == null) {
|
||||||
_emitFastLocationUpdate(position, reason: 'initial');
|
_emitFastLocationUpdate(position, reason: 'initial');
|
||||||
@@ -552,6 +562,7 @@ class LocationTrackingService {
|
|||||||
_fastLocationTimer = null;
|
_fastLocationTimer = null;
|
||||||
if (!isTracking ||
|
if (!isTracking ||
|
||||||
!fastLocationUpdatesEnabled ||
|
!fastLocationUpdatesEnabled ||
|
||||||
|
fastLocationChannelIdx == null ||
|
||||||
!_isFastLocationActiveUse) {
|
!_isFastLocationActiveUse) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -567,7 +578,7 @@ class LocationTrackingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _emitFastLocationUpdate(Position position, {required String reason}) {
|
void _emitFastLocationUpdate(Position position, {required String reason}) {
|
||||||
if (!fastLocationUpdatesEnabled) return;
|
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final previous = _lastFastLocationSentPosition;
|
final previous = _lastFastLocationSentPosition;
|
||||||
@@ -669,6 +680,7 @@ class LocationTrackingService {
|
|||||||
5,
|
5,
|
||||||
60,
|
60,
|
||||||
);
|
);
|
||||||
|
fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx));
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Settings loaded');
|
debugPrint('✅ [LocationTracking] Settings loaded');
|
||||||
debugPrint(' Min distance: ${minDistanceMeters}m');
|
debugPrint(' Min distance: ${minDistanceMeters}m');
|
||||||
@@ -680,6 +692,7 @@ class LocationTrackingService {
|
|||||||
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
|
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
|
||||||
);
|
);
|
||||||
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
|
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
|
||||||
|
debugPrint(' Fast channel idx: ${fastLocationChannelIdx ?? "unset"}');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save settings to SharedPreferences
|
/// Save settings to SharedPreferences
|
||||||
@@ -709,6 +722,12 @@ class LocationTrackingService {
|
|||||||
_scopedKey(_prefKeyFastActiveCadence),
|
_scopedKey(_prefKeyFastActiveCadence),
|
||||||
fastLocationActiveCadenceSeconds,
|
fastLocationActiveCadenceSeconds,
|
||||||
);
|
);
|
||||||
|
final channelKey = _scopedKey(_prefKeyFastChannelIdx);
|
||||||
|
if (fastLocationChannelIdx == null) {
|
||||||
|
await prefs.remove(channelKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setInt(channelKey, fastLocationChannelIdx!);
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Settings saved');
|
debugPrint('✅ [LocationTracking] Settings saved');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SpectrumScanCandidate> graphCandidates;
|
|
||||||
final List<SpectrumScanCandidate> selectableCandidates;
|
|
||||||
final ValueChanged<RangeValues> onRangeChanged;
|
|
||||||
final ValueChanged<int?> 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<int>(
|
|
||||||
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<int>(
|
|
||||||
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<SpectrumScanCandidate> 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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -827,8 +827,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: dfc5b82c3f8a7f160ca77e45dc65a9eb39cebc5a
|
||||||
resolved-ref: "71902401e9a6bc7d2ed1193f8961e7256646ab76"
|
resolved-ref: dfc5b82c3f8a7f160ca77e45dc65a9eb39cebc5a
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ dependencies:
|
|||||||
meshcore_client:
|
meshcore_client:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/dz0ny/meshcore_client.git
|
url: https://github.com/dz0ny/meshcore_client.git
|
||||||
ref: main
|
ref: dfc5b82c3f8a7f160ca77e45dc65a9eb39cebc5a
|
||||||
|
|
||||||
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
|
||||||
codec2_flutter:
|
codec2_flutter:
|
||||||
|
|||||||
40
test/services/location_tracking_service_test.dart
Normal file
40
test/services/location_tracking_service_test.dart
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user