mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
Compare commits
19 Commits
2026.0305.
...
v2026.0306
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1856dce27a | ||
|
|
485ae995c3 | ||
|
|
0e1a56d765 | ||
|
|
ca8e8e6ecb | ||
|
|
43f5702666 | ||
|
|
85d0b26c67 | ||
|
|
7cd5351921 | ||
|
|
96feb75712 | ||
|
|
f6779e8776 | ||
|
|
a9d062dc34 | ||
|
|
3680b38302 | ||
|
|
e3902cea1e | ||
|
|
2482f94cd0 | ||
|
|
2b34bc4162 | ||
|
|
03c0c7ad38 | ||
|
|
42fc1f9cd2 | ||
|
|
7807ad7471 | ||
|
|
310eee1ff5 | ||
|
|
b8f03079e7 |
@@ -39,5 +39,13 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
# codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that
|
||||
# fails to compile for iOS simulators. Voice codec support is only required
|
||||
# on real iOS devices, so skip the native Codec2 sources for simulator SDKs.
|
||||
if target.name == 'codec2_flutter'
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['EXCLUDED_SOURCE_FILE_NAMES[sdk=iphonesimulator*]'] = '*.c *.cpp'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -65,6 +65,9 @@ PODS:
|
||||
- ObjectBox (= 4.4.1)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.3.0):
|
||||
- Flutter
|
||||
- record_ios (1.2.0):
|
||||
@@ -102,6 +105,7 @@ DEPENDENCIES:
|
||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
@@ -149,6 +153,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
record_ios:
|
||||
@@ -183,6 +189,7 @@ SPEC CHECKSUMS:
|
||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||
@@ -193,6 +200,6 @@ SPEC CHECKSUMS:
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -489,7 +489,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 81;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -511,7 +511,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 81;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -530,7 +530,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 81;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -547,7 +547,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 81;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -679,7 +679,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 81;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -702,7 +702,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 81;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>81</string>
|
||||
<string>92</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
|
||||
@@ -5,22 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000216">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.428509">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.451844">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="102.185635">
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="110.228647">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="3.215591">
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="475.510001">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
@@ -73,12 +73,14 @@ class DeviceInfo {
|
||||
final String? selfName;
|
||||
|
||||
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
||||
final int? maxContacts; // Max contacts device supports
|
||||
final int? maxChannels; // Max channels device supports
|
||||
final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
||||
final int? blePin; // BLE PIN code
|
||||
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
||||
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
||||
final int? maxContacts; // Max contacts device supports
|
||||
final int? maxChannels; // Max channels device supports
|
||||
final int?
|
||||
telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
||||
final int? blePin; // BLE PIN code
|
||||
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
||||
final int?
|
||||
advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
||||
|
||||
// Firmware info
|
||||
final int? firmwareVersion;
|
||||
@@ -88,6 +90,9 @@ class DeviceInfo {
|
||||
|
||||
// Repeat mode (firmware v9+)
|
||||
final bool? clientRepeat;
|
||||
final bool? supportsSpectrumScan;
|
||||
final int? spectrumScanMinKhz;
|
||||
final int? spectrumScanMaxKhz;
|
||||
final List<({int lower, int upper})>? allowedRepeatFreqRanges;
|
||||
|
||||
DeviceInfo({
|
||||
@@ -124,6 +129,9 @@ class DeviceInfo {
|
||||
this.manufacturerModel,
|
||||
this.semanticVersion,
|
||||
this.clientRepeat,
|
||||
this.supportsSpectrumScan,
|
||||
this.spectrumScanMinKhz,
|
||||
this.spectrumScanMaxKhz,
|
||||
this.allowedRepeatFreqRanges,
|
||||
});
|
||||
|
||||
@@ -160,7 +168,9 @@ class DeviceInfo {
|
||||
|
||||
/// Get storage usage percentage (0-100)
|
||||
double? get storageUsedPercent {
|
||||
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) {
|
||||
if (storageUsedKb == null ||
|
||||
storageTotalKb == null ||
|
||||
storageTotalKb == 0) {
|
||||
return null;
|
||||
}
|
||||
return (storageUsedKb! / storageTotalKb!) * 100.0;
|
||||
@@ -245,6 +255,9 @@ class DeviceInfo {
|
||||
String? manufacturerModel,
|
||||
String? semanticVersion,
|
||||
bool? clientRepeat,
|
||||
bool? supportsSpectrumScan,
|
||||
int? spectrumScanMinKhz,
|
||||
int? spectrumScanMaxKhz,
|
||||
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
||||
}) {
|
||||
return DeviceInfo(
|
||||
@@ -281,7 +294,11 @@ class DeviceInfo {
|
||||
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
||||
semanticVersion: semanticVersion ?? this.semanticVersion,
|
||||
clientRepeat: clientRepeat ?? this.clientRepeat,
|
||||
allowedRepeatFreqRanges: allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
|
||||
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
|
||||
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
|
||||
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
|
||||
allowedRepeatFreqRanges:
|
||||
allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
63
lib/models/message_contact_location.dart
Normal file
63
lib/models/message_contact_location.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class MessageContactLocation {
|
||||
final LatLng location;
|
||||
final String source;
|
||||
final DateTime capturedAt;
|
||||
final DateTime? sourceTimestamp;
|
||||
|
||||
const MessageContactLocation({
|
||||
required this.location,
|
||||
required this.source,
|
||||
required this.capturedAt,
|
||||
this.sourceTimestamp,
|
||||
});
|
||||
|
||||
String get technicalSourceLabel {
|
||||
switch (source) {
|
||||
case 'telemetry':
|
||||
return 'telemetry';
|
||||
case 'advert':
|
||||
return 'advert';
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
String get formattedCoordinates =>
|
||||
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'latitude': location.latitude,
|
||||
'longitude': location.longitude,
|
||||
'source': source,
|
||||
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
|
||||
'sourceTimestampMillis': sourceTimestamp?.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
static MessageContactLocation? fromJson(Map<String, dynamic> json) {
|
||||
final latitude = json['latitude'];
|
||||
final longitude = json['longitude'];
|
||||
final source = json['source'];
|
||||
final capturedAtMillis = json['capturedAtMillis'];
|
||||
if (latitude is! num ||
|
||||
longitude is! num ||
|
||||
source is! String ||
|
||||
capturedAtMillis is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MessageContactLocation(
|
||||
location: LatLng(latitude.toDouble(), longitude.toDouble()),
|
||||
source: source,
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
|
||||
sourceTimestamp: json['sourceTimestampMillis'] is int
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['sourceTimestampMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import 'drawing_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'voice_provider.dart';
|
||||
import 'image_provider.dart' as ip;
|
||||
import 'helpers/fragment_ack_wait_registry.dart';
|
||||
import 'helpers/session_metadata_restore.dart';
|
||||
import '../services/tile_cache_service.dart';
|
||||
import '../services/location_tracking_service.dart';
|
||||
import '../services/packet_capture_storage_service.dart';
|
||||
@@ -62,8 +64,10 @@ class AppProvider with ChangeNotifier {
|
||||
final Map<String, String> _imageSessionSenderKey6 = {};
|
||||
final Map<String, Timer> _voiceMissingRetryTimers = {};
|
||||
final Map<String, int> _voiceMissingRetryAttempts = {};
|
||||
final Map<String, Completer<void>> _voiceFragmentAckWaiters = {};
|
||||
final Map<String, Completer<void>> _imageFragmentAckWaiters = {};
|
||||
final FragmentAckWaitRegistry _voiceFragmentAckWaiters =
|
||||
FragmentAckWaitRegistry();
|
||||
final FragmentAckWaitRegistry _imageFragmentAckWaiters =
|
||||
FragmentAckWaitRegistry();
|
||||
Timer? _packetCaptureFlushTimer;
|
||||
String? _lastPersistedPacketSignature;
|
||||
bool _isPersistingPacketCapture = false;
|
||||
@@ -165,12 +169,35 @@ class AppProvider with ChangeNotifier {
|
||||
// Give DrawingProvider a moment to finish loading too
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
_restoreSessionMetadataFromMessages();
|
||||
|
||||
debugPrint(
|
||||
'🔄 [AppProvider] Early sync: syncing drawings from messages...',
|
||||
);
|
||||
messagesProvider.syncDrawingsWithProvider(drawingProvider);
|
||||
}
|
||||
|
||||
void _restoreSessionMetadataFromMessages() {
|
||||
final restored = restoreSessionMetadataFromMessages(
|
||||
messagesProvider.messages.map((message) => message.text),
|
||||
);
|
||||
|
||||
_voiceSessionSenderKey6.addAll(restored.voiceSenderKeyBySession);
|
||||
for (final entry in restored.imageEnvelopeBySession.entries) {
|
||||
_imageSessionSenderKey6[entry.key] = entry.value.senderKey6.toLowerCase();
|
||||
imageProvider.registerEnvelope(entry.value);
|
||||
}
|
||||
|
||||
final restoredVoice = restored.voiceSenderKeyBySession.length;
|
||||
final restoredImage = restored.imageEnvelopeBySession.length;
|
||||
if (restoredVoice > 0 || restoredImage > 0) {
|
||||
debugPrint(
|
||||
'🔄 [AppProvider] Restored session metadata from messages: '
|
||||
'$restoredVoice voice, $restoredImage image',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load simple mode setting from shared preferences
|
||||
Future<void> _loadSimpleMode() async {
|
||||
try {
|
||||
@@ -580,14 +607,30 @@ class AppProvider with ChangeNotifier {
|
||||
connectionProvider.onMessageReceived = (message) {
|
||||
// Enrich message with sender name from contacts first
|
||||
Message enrichedMessage = message;
|
||||
Contact? senderContact;
|
||||
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
|
||||
final contact = contactsProvider.findContactByKey(
|
||||
message.senderPublicKeyPrefix!,
|
||||
);
|
||||
if (contact != null) {
|
||||
senderContact = contact;
|
||||
enrichedMessage = message.copyWith(senderName: contact.advName);
|
||||
}
|
||||
}
|
||||
senderContact ??= message.senderPublicKeyPrefix != null
|
||||
? contactsProvider.findContactByKey(message.senderPublicKeyPrefix!)
|
||||
: null;
|
||||
senderContact ??= enrichedMessage.senderName != null
|
||||
? contactsProvider.contacts
|
||||
.where((c) => c.advName == enrichedMessage.senderName)
|
||||
.firstOrNull
|
||||
: null;
|
||||
final contactLocationSnapshot = senderContact != null
|
||||
? contactsProvider.buildMessageContactLocationSnapshot(
|
||||
senderContact,
|
||||
capturedAt: enrichedMessage.receivedAt,
|
||||
)
|
||||
: null;
|
||||
|
||||
// Check if message is a drawing broadcast
|
||||
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
||||
@@ -618,6 +661,7 @@ class AppProvider with ChangeNotifier {
|
||||
messagesProvider.addMessage(
|
||||
updatedMessage,
|
||||
contactLookup: (name) => '',
|
||||
contactLocationSnapshot: contactLocationSnapshot,
|
||||
);
|
||||
|
||||
// Broadcast drawing message to SSE clients if server is running
|
||||
@@ -653,6 +697,7 @@ class AppProvider with ChangeNotifier {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
contactLocationSnapshot: contactLocationSnapshot,
|
||||
);
|
||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||
return;
|
||||
@@ -680,6 +725,7 @@ class AppProvider with ChangeNotifier {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
contactLocationSnapshot: contactLocationSnapshot,
|
||||
);
|
||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||
return;
|
||||
@@ -716,6 +762,7 @@ class AppProvider with ChangeNotifier {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
contactLocationSnapshot: contactLocationSnapshot,
|
||||
);
|
||||
|
||||
// Broadcast message to SSE clients if server is running
|
||||
@@ -788,12 +835,12 @@ class AppProvider with ChangeNotifier {
|
||||
|
||||
final imageFetchRequest = ImageFetchRequest.tryParseBinary(payload);
|
||||
if (imageFetchRequest != null) {
|
||||
final requester = contactsProvider.findContactByPrefixHex(
|
||||
imageFetchRequest.requesterKey6,
|
||||
);
|
||||
final requester = _resolveImageFetchRequester(imageFetchRequest);
|
||||
if (requester == null) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Image fetch requester contact not found (binary)',
|
||||
'⚠️ [AppProvider] Image fetch requester contact not found (binary) '
|
||||
'for session ${imageFetchRequest.sessionId} / '
|
||||
'${imageFetchRequest.requesterKey6}',
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
@@ -804,7 +851,9 @@ class AppProvider with ChangeNotifier {
|
||||
}
|
||||
if (requester.outPathLen > _maxDirectPayloadHops) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Image fetch requester too far: ${requester.outPathLen} hops',
|
||||
'⚠️ [AppProvider] Image fetch requester too far: '
|
||||
'${requester.outPathLen} hops for session '
|
||||
'${imageFetchRequest.sessionId}',
|
||||
);
|
||||
messagesProvider.logSystemMessage(
|
||||
text:
|
||||
@@ -813,6 +862,10 @@ class AppProvider with ChangeNotifier {
|
||||
);
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'📷 [AppProvider] Serving image session ${imageFetchRequest.sessionId} '
|
||||
'to ${requester.advName} via ${requester.outPathLen} hop(s)',
|
||||
);
|
||||
unawaited(
|
||||
imageProvider.serveSessionTo(
|
||||
sessionId: imageFetchRequest.sessionId,
|
||||
@@ -988,6 +1041,32 @@ class AppProvider with ChangeNotifier {
|
||||
retryAttempt: retryAttempt,
|
||||
);
|
||||
};
|
||||
|
||||
messagesProvider.onDirectPathFailedCallback =
|
||||
({required contact, required failureStreak}) async {
|
||||
debugPrint(
|
||||
'🧭 [AppProvider] Clearing unhealthy path for ${contact.advName} after $failureStreak failed send chain(s)',
|
||||
);
|
||||
|
||||
contactsProvider.markPathUnhealthy(contact.publicKey);
|
||||
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await connectionProvider.resetPath(contact.publicKey);
|
||||
Future.delayed(const Duration(milliseconds: 150), () {
|
||||
if (connectionProvider.deviceInfo.isConnected) {
|
||||
connectionProvider.getContact(contact.publicKey);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'⚠️ [AppProvider] Failed to reset path for ${contact.advName}: $e',
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Initialize the app (load contacts, sync time, etc.)
|
||||
@@ -1189,6 +1268,42 @@ class AppProvider with ChangeNotifier {
|
||||
return contactsProvider.findContactByPrefixHex(prefixHex.toLowerCase());
|
||||
}
|
||||
|
||||
Contact? _resolveImageFetchRequester(ImageFetchRequest request) {
|
||||
final liveContact = _resolveContactByPrefixHex(request.requesterKey6);
|
||||
if (liveContact != null) {
|
||||
return liveContact;
|
||||
}
|
||||
|
||||
for (final message in messagesProvider.messages.reversed) {
|
||||
final envelope = ImageEnvelope.tryParse(message.text);
|
||||
if (envelope == null || envelope.sessionId != request.sessionId) {
|
||||
continue;
|
||||
}
|
||||
final recipientKey = message.recipientPublicKey;
|
||||
if (recipientKey == null || recipientKey.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final recipient = contactsProvider.findContactByKey(recipientKey);
|
||||
if (recipient == null) {
|
||||
continue;
|
||||
}
|
||||
final recipientKey6 = recipient.publicKey
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
if (recipientKey6 != request.requesterKey6) {
|
||||
continue;
|
||||
}
|
||||
debugPrint(
|
||||
'📷 [AppProvider] Resolved image requester from sent message metadata '
|
||||
'for session ${request.sessionId}: ${recipient.advName}',
|
||||
);
|
||||
return recipient;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
void _scheduleVoiceMissingRetry(
|
||||
String sessionId, {
|
||||
required bool justComplete,
|
||||
@@ -1312,54 +1427,48 @@ class AppProvider with ChangeNotifier {
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final key = _fragmentAckKey(sessionId, index);
|
||||
final completer = Completer<void>();
|
||||
_voiceFragmentAckWaiters[key] = completer;
|
||||
try {
|
||||
await completer.future.timeout(timeout);
|
||||
return true;
|
||||
} catch (_) {
|
||||
if (_voiceFragmentAckWaiters[key] == completer) {
|
||||
_voiceFragmentAckWaiters.remove(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}) => _voiceFragmentAckWaiters.waitFor(
|
||||
_fragmentAckKey(sessionId, index),
|
||||
timeout: timeout,
|
||||
);
|
||||
|
||||
void _completeVoiceFragmentAck(String sessionId, int index) {
|
||||
final key = _fragmentAckKey(sessionId, index);
|
||||
final completer = _voiceFragmentAckWaiters.remove(key);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
final completed = _voiceFragmentAckWaiters.complete(
|
||||
_fragmentAckKey(sessionId, index),
|
||||
);
|
||||
if (completed == 0) {
|
||||
debugPrint(
|
||||
'ℹ️ [AppProvider] Voice fragment ACK had no waiter: $sessionId#$index',
|
||||
);
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'✅ [AppProvider] Voice fragment ACK received for $sessionId#$index ($completed waiter(s))',
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _waitForImageFragmentAck({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final key = _fragmentAckKey(sessionId, index);
|
||||
final completer = Completer<void>();
|
||||
_imageFragmentAckWaiters[key] = completer;
|
||||
try {
|
||||
await completer.future.timeout(timeout);
|
||||
return true;
|
||||
} catch (_) {
|
||||
if (_imageFragmentAckWaiters[key] == completer) {
|
||||
_imageFragmentAckWaiters.remove(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}) => _imageFragmentAckWaiters.waitFor(
|
||||
_fragmentAckKey(sessionId, index),
|
||||
timeout: timeout,
|
||||
);
|
||||
|
||||
void _completeImageFragmentAck(String sessionId, int index) {
|
||||
final key = _fragmentAckKey(sessionId, index);
|
||||
final completer = _imageFragmentAckWaiters.remove(key);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
final completed = _imageFragmentAckWaiters.complete(
|
||||
_fragmentAckKey(sessionId, index),
|
||||
);
|
||||
if (completed == 0) {
|
||||
debugPrint(
|
||||
'ℹ️ [AppProvider] Image fragment ACK had no waiter: $sessionId#$index',
|
||||
);
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'✅ [AppProvider] Image fragment ACK received for $sessionId#$index ($completed waiter(s))',
|
||||
);
|
||||
}
|
||||
|
||||
void _sendVoiceFragmentAck(VoicePacket packet) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import '../models/device_info.dart';
|
||||
@@ -88,6 +89,8 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
bool _isScanning = false;
|
||||
bool get isScanning => _isScanning;
|
||||
bool _isSpectrumScanActive = false;
|
||||
bool get isSpectrumScanActive => _isSpectrumScanActive;
|
||||
|
||||
String? _error;
|
||||
String? get error => _error;
|
||||
@@ -246,7 +249,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
if (pendingOp.messageId != null) {
|
||||
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!);
|
||||
_messageDeliveryTracker.trackPendingDirectMessage(
|
||||
pendingOp.messageId!,
|
||||
pendingOp.contactPublicKey,
|
||||
);
|
||||
}
|
||||
|
||||
await _activeService.sendTextMessage(
|
||||
@@ -311,6 +317,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
};
|
||||
|
||||
service.onMessageWaiting = () {
|
||||
if (_isSpectrumScanActive) {
|
||||
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
|
||||
return;
|
||||
}
|
||||
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
||||
if (_isSyncingMessages) {
|
||||
_syncRequestedWhileBusy = true;
|
||||
@@ -345,7 +355,11 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
service.onMessageSent =
|
||||
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
||||
final messageId = _messageDeliveryTracker.popPendingMessageId();
|
||||
final messageId = contactPublicKey != null
|
||||
? _messageDeliveryTracker.popPendingDirectMessageId(
|
||||
contactPublicKey,
|
||||
)
|
||||
: _messageDeliveryTracker.popPendingMessageId();
|
||||
if (messageId != null) {
|
||||
_messageDeliveryTracker.mapAckTagToMessageId(
|
||||
expectedAckTag,
|
||||
@@ -378,6 +392,9 @@ class ConnectionProvider with ChangeNotifier {
|
||||
manufacturerModel: deviceInfo['manufacturerModel'] as String?,
|
||||
semanticVersion: deviceInfo['semanticVersion'] as String?,
|
||||
clientRepeat: deviceInfo['clientRepeat'] as bool?,
|
||||
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
|
||||
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
|
||||
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
|
||||
);
|
||||
notifyListeners();
|
||||
if (_sseServer.isRunning) {
|
||||
@@ -461,7 +478,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
_isScanning = true;
|
||||
_scannedDevices.clear();
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
_notifyListenersSafely();
|
||||
debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
|
||||
|
||||
try {
|
||||
@@ -477,7 +494,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
debugPrint(
|
||||
'✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}',
|
||||
);
|
||||
notifyListeners();
|
||||
_notifyListenersSafely();
|
||||
} else {
|
||||
// Update RSSI if device already exists
|
||||
final index = _scannedDevices.indexWhere(
|
||||
@@ -488,7 +505,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
debugPrint(
|
||||
' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm',
|
||||
);
|
||||
notifyListeners();
|
||||
_notifyListenersSafely();
|
||||
} else {
|
||||
debugPrint(
|
||||
' ⏭️ [Provider] Device already in list with same RSSI, skipping',
|
||||
@@ -502,7 +519,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
} finally {
|
||||
debugPrint('🏁 [Provider] Scan completed');
|
||||
_isScanning = false;
|
||||
notifyListeners();
|
||||
_notifyListenersSafely();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,6 +527,18 @@ class ConnectionProvider with ChangeNotifier {
|
||||
Future<void> stopScan() async {
|
||||
await FlutterBluePlus.stopScan();
|
||||
_isScanning = false;
|
||||
_notifyListenersSafely();
|
||||
}
|
||||
|
||||
void _notifyListenersSafely() {
|
||||
final phase = SchedulerBinding.instance.schedulerPhase;
|
||||
if (phase == SchedulerPhase.transientCallbacks ||
|
||||
phase == SchedulerPhase.persistentCallbacks) {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
notifyListeners();
|
||||
});
|
||||
return;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -1126,7 +1155,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
|
||||
// We still track here for the SENT response callback to work.
|
||||
if (messageId != null) {
|
||||
_messageDeliveryTracker.trackPendingMessage(messageId);
|
||||
_messageDeliveryTracker.trackPendingDirectMessage(
|
||||
messageId,
|
||||
contactPublicKey,
|
||||
);
|
||||
debugPrint(' 📝 Tracked pending message: $messageId');
|
||||
}
|
||||
|
||||
@@ -1542,6 +1574,43 @@ 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
|
||||
Future<void> setTxPower(int powerDbm) async {
|
||||
if (!_activeService.isConnected) {
|
||||
@@ -1586,6 +1655,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
/// Request fresh device info (triggers SelfInfo response)
|
||||
Future<void> refreshDeviceInfo() async {
|
||||
if (_isSpectrumScanActive) return;
|
||||
if (!_activeService.isConnected) {
|
||||
_error = 'Not connected to device';
|
||||
notifyListeners();
|
||||
@@ -1629,6 +1699,7 @@ class ConnectionProvider with ChangeNotifier {
|
||||
/// Sync messages from device queue
|
||||
/// Call this repeatedly until no more messages are available
|
||||
Future<bool> syncNextMessage() async {
|
||||
if (_isSpectrumScanActive) return false;
|
||||
// Prevent re-entrancy and too-fast triggers
|
||||
if (_isSyncingMessages) {
|
||||
// Another sync (single or loop) is in progress
|
||||
@@ -1667,6 +1738,10 @@ class ConnectionProvider with ChangeNotifier {
|
||||
|
||||
/// Sync all waiting messages from device
|
||||
Future<int> syncAllMessages() async {
|
||||
if (_isSpectrumScanActive) {
|
||||
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
|
||||
return 0;
|
||||
}
|
||||
if (_isSyncingMessages) {
|
||||
// Already syncing; avoid overlapping loops
|
||||
_syncRequestedWhileBusy = true;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import '../services/cayenne_lpp_parser.dart';
|
||||
import '../services/contact_storage_service.dart';
|
||||
import '../utils/key_comparison.dart';
|
||||
@@ -214,6 +215,52 @@ class ContactsProvider with ChangeNotifier {
|
||||
List<Contact> get chatContactsWithLocation =>
|
||||
chatContacts.where((c) => c.displayLocation != null).toList();
|
||||
|
||||
MessageContactLocation? buildMessageContactLocationSnapshot(
|
||||
Contact contact, {
|
||||
DateTime? capturedAt,
|
||||
}) {
|
||||
final snapshotTime = capturedAt ?? DateTime.now();
|
||||
final telemetryGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
|
||||
final telemetryTimestamp = contact.telemetry?.timestamp;
|
||||
|
||||
AdvertLocation? advertLocation;
|
||||
for (final point in contact.advertHistory) {
|
||||
if (!point.timestamp.isAfter(snapshotTime)) {
|
||||
advertLocation = point;
|
||||
break;
|
||||
}
|
||||
}
|
||||
advertLocation ??= contact.advertHistory.isNotEmpty
|
||||
? contact.advertHistory.first
|
||||
: null;
|
||||
|
||||
if (telemetryGps != null) {
|
||||
final shouldUseTelemetry =
|
||||
telemetryTimestamp == null ||
|
||||
advertLocation == null ||
|
||||
!telemetryTimestamp.isBefore(advertLocation.timestamp);
|
||||
if (shouldUseTelemetry) {
|
||||
return MessageContactLocation(
|
||||
location: telemetryGps,
|
||||
source: 'telemetry',
|
||||
capturedAt: snapshotTime,
|
||||
sourceTimestamp: telemetryTimestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (advertLocation != null) {
|
||||
return MessageContactLocation(
|
||||
location: advertLocation.location,
|
||||
source: 'advert',
|
||||
capturedAt: snapshotTime,
|
||||
sourceTimestamp: advertLocation.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Sort contacts by last seen (most recent first)
|
||||
int _sortByLastSeen(Contact a, Contact b) {
|
||||
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||
@@ -470,6 +517,22 @@ class ContactsProvider with ChangeNotifier {
|
||||
return _contacts[keyHex];
|
||||
}
|
||||
|
||||
/// Clear a contact's learned path locally so the UI and next send both
|
||||
/// prefer flood routing until the radio reports a fresh route.
|
||||
void markPathUnhealthy(Uint8List publicKey) {
|
||||
final contact = findContactByKey(publicKey);
|
||||
if (contact == null || !contact.hasPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
_contacts[contact.publicKeyHex] = contact.copyWith(
|
||||
outPathLen: -1,
|
||||
outPath: Uint8List(0),
|
||||
);
|
||||
_persistContacts();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
||||
/// Excludes self key and existing contacts.
|
||||
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
||||
|
||||
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Tracks one or more in-flight waiters for the same fragment ACK key.
|
||||
///
|
||||
/// Duplicate fetch requests can race and wait on the same fragment ACK at once.
|
||||
/// Completing all registered waiters avoids losing the earlier completer when a
|
||||
/// later request registers for the same key.
|
||||
class FragmentAckWaitRegistry {
|
||||
final Map<String, List<Completer<void>>> _waiters = {};
|
||||
|
||||
Future<bool> waitFor(
|
||||
String key, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
final completer = Completer<void>();
|
||||
final waiters = _waiters.putIfAbsent(key, () => <Completer<void>>[]);
|
||||
waiters.add(completer);
|
||||
try {
|
||||
await completer.future.timeout(timeout);
|
||||
return true;
|
||||
} catch (_) {
|
||||
final pending = _waiters[key];
|
||||
pending?.remove(completer);
|
||||
if (pending != null && pending.isEmpty) {
|
||||
_waiters.remove(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int complete(String key) {
|
||||
final waiters = _waiters.remove(key);
|
||||
if (waiters == null || waiters.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
for (final completer in waiters) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
return waiters.length;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Message delivery tracking helper
|
||||
///
|
||||
/// Manages message delivery tracking for sent messages, including:
|
||||
@@ -16,6 +18,9 @@ class MessageDeliveryTracker {
|
||||
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
|
||||
final List<String> _pendingMessageIds = [];
|
||||
|
||||
/// Contact-scoped FIFOs for matching direct-message SENT responses.
|
||||
final Map<String, List<String>> _pendingMessageIdsByContact = {};
|
||||
|
||||
/// Map of ACK tag to message ID for delivery confirmation
|
||||
final Map<int, String> _ackTagToMessageId = {};
|
||||
|
||||
@@ -33,6 +38,15 @@ class MessageDeliveryTracker {
|
||||
_pendingMessageIds.add(messageId);
|
||||
}
|
||||
|
||||
/// Track a pending direct message ID for a specific contact.
|
||||
void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) {
|
||||
trackPendingMessage(messageId);
|
||||
final contactKey = _contactKey(contactPublicKey);
|
||||
_pendingMessageIdsByContact
|
||||
.putIfAbsent(contactKey, () => [])
|
||||
.add(messageId);
|
||||
}
|
||||
|
||||
/// Pop the next pending message ID from FIFO queue
|
||||
///
|
||||
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
||||
@@ -43,6 +57,24 @@ class MessageDeliveryTracker {
|
||||
return _pendingMessageIds.removeAt(0);
|
||||
}
|
||||
|
||||
/// Pop the next pending direct message ID for a specific contact.
|
||||
///
|
||||
/// Falls back to the legacy global FIFO if the contact queue is empty.
|
||||
String? popPendingDirectMessageId(Uint8List contactPublicKey) {
|
||||
final contactKey = _contactKey(contactPublicKey);
|
||||
final queue = _pendingMessageIdsByContact[contactKey];
|
||||
if (queue == null || queue.isEmpty) {
|
||||
return popPendingMessageId();
|
||||
}
|
||||
|
||||
final messageId = queue.removeAt(0);
|
||||
if (queue.isEmpty) {
|
||||
_pendingMessageIdsByContact.remove(contactKey);
|
||||
}
|
||||
_pendingMessageIds.remove(messageId);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
/// Map ACK tag to message ID after RESP_CODE_SENT received
|
||||
///
|
||||
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
||||
@@ -86,6 +118,17 @@ class MessageDeliveryTracker {
|
||||
_ackTagToMessageId.remove(ackTag);
|
||||
_ackTagTimestamps.remove(ackTag);
|
||||
}
|
||||
_pendingMessageIds.remove(messageId);
|
||||
final emptyKeys = <String>[];
|
||||
for (final entry in _pendingMessageIdsByContact.entries) {
|
||||
entry.value.remove(messageId);
|
||||
if (entry.value.isEmpty) {
|
||||
emptyKeys.add(entry.key);
|
||||
}
|
||||
}
|
||||
for (final key in emptyKeys) {
|
||||
_pendingMessageIdsByContact.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up stale ACK mappings
|
||||
@@ -114,6 +157,7 @@ class MessageDeliveryTracker {
|
||||
/// Clear all tracking state
|
||||
void clearTracking() {
|
||||
_pendingMessageIds.clear();
|
||||
_pendingMessageIdsByContact.clear();
|
||||
_ackTagToMessageId.clear();
|
||||
_messageIdToAckTag.clear();
|
||||
_ackTagTimestamps.clear();
|
||||
@@ -133,9 +177,7 @@ class MessageDeliveryTracker {
|
||||
/// Get oldest pending ACK timestamp (for debugging)
|
||||
DateTime? get oldestPendingTimestamp {
|
||||
if (_ackTagTimestamps.isEmpty) return null;
|
||||
return _ackTagTimestamps.values.reduce(
|
||||
(a, b) => a.isBefore(b) ? a : b,
|
||||
);
|
||||
return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||
}
|
||||
|
||||
/// Get diagnostic info for debugging
|
||||
@@ -145,6 +187,15 @@ class MessageDeliveryTracker {
|
||||
'shouldRateLimit': shouldRateLimit,
|
||||
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
||||
'ackTags': _ackTagToMessageId.keys.toList(),
|
||||
'pendingByContact': _pendingMessageIdsByContact.map(
|
||||
(key, value) => MapEntry(key, value.length),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
String _contactKey(Uint8List contactPublicKey) {
|
||||
return contactPublicKey
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/contact.dart';
|
||||
|
||||
@@ -17,11 +20,18 @@ class MessageRetryManager {
|
||||
// Track retry state for each message ID
|
||||
final Map<String, int> _retryAttempts = {};
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
final Map<String, int> _pathFailureStreaks = {};
|
||||
|
||||
// Progressive timeout values in milliseconds
|
||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
||||
static const int _defaultLoRaSf = 10;
|
||||
static const int _defaultLoRaCr = 5;
|
||||
static const int _defaultLoRaBwHz = 250000;
|
||||
static const int _defaultLoRaPreambleSymbols = 8;
|
||||
static const int _defaultLoRaCrcEnabled = 1;
|
||||
static const int _defaultLoRaExplicitHeader = 1;
|
||||
|
||||
/// Get timeout for a specific retry attempt (0-2)
|
||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
||||
@@ -32,6 +42,30 @@ class MessageRetryManager {
|
||||
return _timeouts[attempt];
|
||||
}
|
||||
|
||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
||||
/// provide one or returns an invalid value.
|
||||
int calculateAckTimeoutMs({
|
||||
required String text,
|
||||
required Contact? contact,
|
||||
int? suggestedTimeoutMs,
|
||||
}) {
|
||||
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
||||
return suggestedTimeoutMs;
|
||||
}
|
||||
|
||||
final payloadBytes = utf8.encode(text).length;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||
final hopCount = contact?.hasPath == true
|
||||
? math.max(contact!.outPathLen, 0)
|
||||
: -1;
|
||||
|
||||
if (hopCount < 0) {
|
||||
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
||||
}
|
||||
|
||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||
}
|
||||
|
||||
/// Check if a message is eligible for retry
|
||||
///
|
||||
/// Returns true if:
|
||||
@@ -67,8 +101,8 @@ class MessageRetryManager {
|
||||
/// Contacts without paths already use flood mode automatically.
|
||||
bool shouldUseFloodFallback(Message message, Contact contact) {
|
||||
return message.retryAttempt >= 3 &&
|
||||
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
|
||||
!message.usedFloodFallback;
|
||||
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
|
||||
!message.usedFloodFallback;
|
||||
}
|
||||
|
||||
/// Track a retry attempt for a message
|
||||
@@ -87,6 +121,7 @@ class MessageRetryManager {
|
||||
void clearAll() {
|
||||
_retryAttempts.clear();
|
||||
_lastRetryTimes.clear();
|
||||
_pathFailureStreaks.clear();
|
||||
}
|
||||
|
||||
/// Get current retry attempt for a message (for debugging)
|
||||
@@ -98,4 +133,50 @@ class MessageRetryManager {
|
||||
DateTime? getLastRetryTime(String messageId) {
|
||||
return _lastRetryTimes[messageId];
|
||||
}
|
||||
|
||||
/// Record a successful delivery for a contact and clear any accumulated
|
||||
/// route failure streak for future sends.
|
||||
void recordDeliverySuccess(Contact contact) {
|
||||
_pathFailureStreaks.remove(contact.publicKeyHex);
|
||||
}
|
||||
|
||||
/// Record a permanent route failure for a contact.
|
||||
///
|
||||
/// Returns the updated failure streak so callers can decide when to reset
|
||||
/// the learned path on the radio and in local state.
|
||||
int recordPathFailure(Contact contact) {
|
||||
final contactKey = contact.publicKeyHex;
|
||||
final next = (_pathFailureStreaks[contactKey] ?? 0) + 1;
|
||||
_pathFailureStreaks[contactKey] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
int? getPathFailureStreak(Contact contact) {
|
||||
return _pathFailureStreaks[contact.publicKeyHex];
|
||||
}
|
||||
|
||||
int _estimateLoRaAirtimeMs(int payloadLenBytes) {
|
||||
final sf = _defaultLoRaSf;
|
||||
final bw = _defaultLoRaBwHz.toDouble();
|
||||
final cr = (_defaultLoRaCr - 4).clamp(1, 4);
|
||||
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
|
||||
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
|
||||
|
||||
final symbolMs = ((1 << sf) / bw) * 1000.0;
|
||||
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
|
||||
|
||||
final num =
|
||||
(8 * payloadLenBytes) -
|
||||
(4 * sf) +
|
||||
28 +
|
||||
(16 * _defaultLoRaCrcEnabled) -
|
||||
(20 * ih);
|
||||
final den = 4 * (sf - (2 * de));
|
||||
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
|
||||
final payloadSymbols =
|
||||
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
|
||||
final payloadMs = payloadSymbols * symbolMs;
|
||||
|
||||
return (preambleMs + payloadMs).ceil();
|
||||
}
|
||||
}
|
||||
|
||||
38
lib/providers/helpers/session_metadata_restore.dart
Normal file
38
lib/providers/helpers/session_metadata_restore.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
|
||||
class RestoredSessionMetadata {
|
||||
final Map<String, String> voiceSenderKeyBySession;
|
||||
final Map<String, ImageEnvelope> imageEnvelopeBySession;
|
||||
|
||||
const RestoredSessionMetadata({
|
||||
required this.voiceSenderKeyBySession,
|
||||
required this.imageEnvelopeBySession,
|
||||
});
|
||||
}
|
||||
|
||||
RestoredSessionMetadata restoreSessionMetadataFromMessages(
|
||||
Iterable<String> messageTexts,
|
||||
) {
|
||||
final voiceSenderKeyBySession = <String, String>{};
|
||||
final imageEnvelopeBySession = <String, ImageEnvelope>{};
|
||||
|
||||
for (final text in messageTexts) {
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
|
||||
if (voiceEnvelope != null) {
|
||||
voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope
|
||||
.senderKey6
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
final imageEnvelope = ImageEnvelope.tryParse(text);
|
||||
if (imageEnvelope != null) {
|
||||
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
|
||||
}
|
||||
}
|
||||
|
||||
return RestoredSessionMetadata(
|
||||
voiceSenderKeyBySession: voiceSenderKeyBySession,
|
||||
imageEnvelopeBySession: imageEnvelopeBySession,
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ class ImageProvider with ChangeNotifier {
|
||||
|
||||
/// Incoming sessions keyed by sessionId.
|
||||
final Map<String, ImageSession> _sessions = {};
|
||||
final Set<String> _ignoredIncomingSessions = {};
|
||||
|
||||
/// Outgoing sessions cached for deferred serving.
|
||||
final Map<String, _OutgoingSession> _outgoing = {};
|
||||
@@ -88,6 +89,8 @@ class ImageProvider with ChangeNotifier {
|
||||
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
||||
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||
_sessions[sessionId]?.estimateRemaining();
|
||||
bool isReceiveCanceled(String sessionId) =>
|
||||
_ignoredIncomingSessions.contains(sessionId);
|
||||
|
||||
List<int> missingFragmentIndices(String sessionId) {
|
||||
final session = _sessions[sessionId];
|
||||
@@ -107,6 +110,12 @@ class ImageProvider with ChangeNotifier {
|
||||
///
|
||||
/// Returns true when the session just became complete.
|
||||
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
||||
if (_ignoredIncomingSessions.contains(fragment.sessionId)) {
|
||||
debugPrint(
|
||||
'⏹️ [ImageProvider] Ignoring canceled incoming session ${fragment.sessionId}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(
|
||||
fragment.sessionId,
|
||||
() => ImageSession(
|
||||
@@ -135,9 +144,25 @@ class ImageProvider with ChangeNotifier {
|
||||
return justComplete;
|
||||
}
|
||||
|
||||
void cancelIncomingSession(String sessionId) {
|
||||
_ignoredIncomingSessions.add(sessionId);
|
||||
_sessions.remove(sessionId);
|
||||
unawaited(_persist());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resumeIncomingSession(String sessionId) {
|
||||
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Register envelope metadata for a session (called when IE1 is received
|
||||
/// before any binary fragments arrive).
|
||||
void registerEnvelope(ImageEnvelope envelope) {
|
||||
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||
return;
|
||||
}
|
||||
final existing = _sessions[envelope.sessionId];
|
||||
if (existing == null) {
|
||||
_sessions[envelope.sessionId] = ImageSession(
|
||||
@@ -249,6 +274,7 @@ class ImageProvider with ChangeNotifier {
|
||||
Future<void> clearAll() async {
|
||||
_sessions.clear();
|
||||
_outgoing.clear();
|
||||
_ignoredIncomingSessions.clear();
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
import '../services/message_storage_service.dart';
|
||||
@@ -20,6 +21,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
bool _isInitialized = false;
|
||||
AppLocalizations? _localizations;
|
||||
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||
|
||||
// Track pending sent messages by expected ACK/TAG
|
||||
final Map<int, Message> _pendingSentMessages = {};
|
||||
@@ -28,6 +30,13 @@ class MessagesProvider with ChangeNotifier {
|
||||
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
|
||||
final Map<String, Timer> _timeoutTimers = {};
|
||||
|
||||
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
|
||||
final Map<int, DateTime> _completedAckHistory = {};
|
||||
|
||||
// Preserve ACK tags assigned to a message across retries.
|
||||
final Map<String, Set<int>> _messageAckHistory = {};
|
||||
final Map<int, (String, DateTime)> _ackHistoryLookup = {};
|
||||
|
||||
// Retry management
|
||||
final MessageRetryManager _retryManager = MessageRetryManager();
|
||||
|
||||
@@ -65,6 +74,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
})?
|
||||
sendMessageCallback;
|
||||
|
||||
Future<void> Function({
|
||||
required Contact contact,
|
||||
required int failureStreak,
|
||||
})?
|
||||
onDirectPathFailedCallback;
|
||||
|
||||
List<Message> get messages => List.unmodifiable(_messages);
|
||||
|
||||
List<Message> get contactMessages =>
|
||||
@@ -97,6 +112,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
String? get targetMessageId => _targetMessageId;
|
||||
|
||||
MessageContactLocation? getMessageContactLocation(String messageId) =>
|
||||
_messageContactLocations[messageId];
|
||||
|
||||
/// Set localizations for notifications
|
||||
void setLocalizations(AppLocalizations localizations) {
|
||||
_localizations = localizations;
|
||||
@@ -125,6 +143,11 @@ class MessagesProvider with ChangeNotifier {
|
||||
try {
|
||||
debugPrint('📦 [MessagesProvider] Loading persisted messages...');
|
||||
final storedMessages = await _storageService.loadMessages();
|
||||
final storedContactLocations = await _storageService
|
||||
.loadMessageContactLocations();
|
||||
_messageContactLocations
|
||||
..clear()
|
||||
..addAll(storedContactLocations);
|
||||
|
||||
// Add stored messages with enhancement to ensure SAR detection
|
||||
for (final message in storedMessages) {
|
||||
@@ -287,6 +310,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
void addMessage(
|
||||
Message message, {
|
||||
String Function(String name)? contactLookup,
|
||||
MessageContactLocation? contactLocationSnapshot,
|
||||
}) {
|
||||
// Always enhance message with SAR parser to detect SAR markers
|
||||
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||
@@ -377,6 +401,9 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
_messages.add(finalMessage);
|
||||
if (contactLocationSnapshot != null) {
|
||||
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
|
||||
}
|
||||
|
||||
// If it's a SAR marker message, extract and store the marker
|
||||
if (finalMessage.isSarMarker) {
|
||||
@@ -563,7 +590,10 @@ class MessagesProvider with ChangeNotifier {
|
||||
/// Persist messages to storage (async, non-blocking)
|
||||
Future<void> _persistMessages() async {
|
||||
try {
|
||||
await _storageService.saveMessages(_messages);
|
||||
await _storageService.saveMessages(
|
||||
_messages,
|
||||
messageContactLocations: _messageContactLocations,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||
}
|
||||
@@ -678,6 +708,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
}
|
||||
_messageContactMap.remove(messageId);
|
||||
_groupedMessageMapping.remove(messageId);
|
||||
_messageContactLocations.remove(messageId);
|
||||
|
||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||
|
||||
@@ -706,6 +737,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
void clearMessages() {
|
||||
_messages.clear();
|
||||
_sarMarkers.clear();
|
||||
_messageContactLocations.clear();
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -720,6 +752,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
void clearAll() {
|
||||
_messages.clear();
|
||||
_sarMarkers.clear();
|
||||
_messageContactLocations.clear();
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -906,11 +939,13 @@ class MessagesProvider with ChangeNotifier {
|
||||
final (groupId, recipientPublicKey) = groupMapping;
|
||||
debugPrint(' ✅ This is part of a grouped message: $groupId');
|
||||
|
||||
// Update the recipient status to "sent" in the grouped message
|
||||
// ACK-tracked recipients stay pending until the delivery confirm arrives.
|
||||
updateGroupedMessageRecipientStatus(
|
||||
groupId,
|
||||
recipientPublicKey,
|
||||
MessageDeliveryStatus.sent,
|
||||
expectedAckTag > 0
|
||||
? MessageDeliveryStatus.sending
|
||||
: MessageDeliveryStatus.sent,
|
||||
);
|
||||
|
||||
// Track the ACK for this specific recipient
|
||||
@@ -1015,6 +1050,12 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
if (index != -1) {
|
||||
final message = _messages[index];
|
||||
final contact = _messageContactMap[messageId];
|
||||
final effectiveTimeout = _retryManager.calculateAckTimeoutMs(
|
||||
text: message.text,
|
||||
contact: contact,
|
||||
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
|
||||
);
|
||||
debugPrint(' Current status: ${message.deliveryStatus}');
|
||||
debugPrint(' Message type: ${message.messageType}');
|
||||
debugPrint(
|
||||
@@ -1022,16 +1063,22 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
final updatedMessage = message.copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.sent,
|
||||
deliveryStatus: expectedAckTag > 0
|
||||
? MessageDeliveryStatus.sending
|
||||
: MessageDeliveryStatus.sent,
|
||||
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
||||
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
|
||||
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
|
||||
);
|
||||
_messages[index] = updatedMessage;
|
||||
|
||||
// Only track and set timeout for direct messages (channel messages have expectedAckTag=0)
|
||||
if (expectedAckTag > 0 && suggestedTimeoutMs > 0) {
|
||||
if (expectedAckTag > 0) {
|
||||
// Track by ACK tag for matching with delivery confirmation
|
||||
_pendingSentMessages[expectedAckTag] = updatedMessage;
|
||||
_messageAckHistory
|
||||
.putIfAbsent(messageId, () => <int>{})
|
||||
.add(expectedAckTag);
|
||||
_ackHistoryLookup[expectedAckTag] = (messageId, DateTime.now());
|
||||
debugPrint(
|
||||
' ✅ Added to pending messages map with ACK: $expectedAckTag',
|
||||
);
|
||||
@@ -1042,7 +1089,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
// Start timeout timer using message ID as key
|
||||
_timeoutTimers[messageId] = Timer(
|
||||
Duration(milliseconds: suggestedTimeoutMs),
|
||||
Duration(milliseconds: effectiveTimeout),
|
||||
() {
|
||||
debugPrint(
|
||||
'⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)',
|
||||
@@ -1054,7 +1101,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)',
|
||||
'⏱️ [MessagesProvider] Started ${effectiveTimeout}ms timeout timer for message $messageId (ACK $expectedAckTag)',
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
@@ -1245,6 +1292,8 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
/// Update message status to delivered with RTT
|
||||
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
||||
_cleanupCompletedAckHistory();
|
||||
_cleanupAckHistoryLookup();
|
||||
debugPrint(
|
||||
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
||||
);
|
||||
@@ -1296,6 +1345,7 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
_ackTagToRecipients.remove(ackCode);
|
||||
_pendingSentMessages.remove(ackCode);
|
||||
_rememberCompletedAck(ackCode);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
@@ -1339,9 +1389,15 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
// Remove from pending
|
||||
_pendingSentMessages.remove(ackCode);
|
||||
_rememberCompletedAck(ackCode);
|
||||
_clearAckHistoryForMessage(message.id);
|
||||
|
||||
// Clear retry tracking on successful delivery
|
||||
_retryManager.clearRetry(message.id);
|
||||
final deliveredContact = _messageContactMap[message.id];
|
||||
if (deliveredContact != null) {
|
||||
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)',
|
||||
@@ -1362,6 +1418,43 @@ class MessagesProvider with ChangeNotifier {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
final historicalMatch = _ackHistoryLookup[ackCode];
|
||||
if (historicalMatch != null) {
|
||||
final historicalMessageId = historicalMatch.$1;
|
||||
final historicalIndex = _messages.indexWhere(
|
||||
(m) => m.id == historicalMessageId,
|
||||
);
|
||||
if (historicalIndex != -1 &&
|
||||
_messages[historicalIndex].deliveryStatus !=
|
||||
MessageDeliveryStatus.delivered) {
|
||||
_messages[historicalIndex] = _messages[historicalIndex].copyWith(
|
||||
deliveryStatus: MessageDeliveryStatus.delivered,
|
||||
roundTripTimeMs: roundTripTimeMs,
|
||||
deliveredAt: DateTime.now(),
|
||||
);
|
||||
_timeoutTimers[historicalMessageId]?.cancel();
|
||||
_timeoutTimers.remove(historicalMessageId);
|
||||
_rememberCompletedAck(ackCode);
|
||||
_clearAckHistoryForMessage(historicalMessageId);
|
||||
_retryManager.clearRetry(historicalMessageId);
|
||||
final deliveredContact = _messageContactMap[historicalMessageId];
|
||||
if (deliveredContact != null) {
|
||||
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||
}
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
debugPrint(
|
||||
'✅ [MessagesProvider] Historical ACK $ackCode matched message $historicalMessageId',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_completedAckHistory.containsKey(ackCode)) {
|
||||
debugPrint(
|
||||
'ℹ️ [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
|
||||
);
|
||||
@@ -1482,18 +1575,31 @@ class MessagesProvider with ChangeNotifier {
|
||||
debugPrint(
|
||||
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
||||
);
|
||||
final currentIndex = _messages.indexWhere((m) => m.id == messageId);
|
||||
if (currentIndex == -1) {
|
||||
return;
|
||||
}
|
||||
final currentMessage = _messages[currentIndex];
|
||||
if (currentMessage.deliveryStatus == MessageDeliveryStatus.delivered) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sendMessageCallback != null) {
|
||||
await sendMessageCallback!(
|
||||
final queued = await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: nextAttempt,
|
||||
);
|
||||
if (!queued) {
|
||||
_markAsPermanentlyFailed(messageId, currentMessage);
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
|
||||
);
|
||||
_markAsPermanentlyFailed(messageId, currentMessage);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1529,17 +1635,21 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
// Send with flood mode (no retry after this)
|
||||
if (sendMessageCallback != null) {
|
||||
await sendMessageCallback!(
|
||||
final queued = await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: 0, // Reset attempt for flood
|
||||
);
|
||||
if (!queued) {
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
||||
);
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
@@ -1562,10 +1672,27 @@ class MessagesProvider with ChangeNotifier {
|
||||
if (message.expectedAckTag != null) {
|
||||
_pendingSentMessages.remove(message.expectedAckTag);
|
||||
}
|
||||
_clearAckHistoryForMessage(messageId);
|
||||
|
||||
// Clear retry tracking
|
||||
_retryManager.clearRetry(messageId);
|
||||
|
||||
final failedContact = _messageContactMap[messageId];
|
||||
if (failedContact != null && failedContact.hasPath) {
|
||||
final failureStreak = _retryManager.recordPathFailure(failedContact);
|
||||
debugPrint(
|
||||
' Path failure streak for ${failedContact.advName}: $failureStreak',
|
||||
);
|
||||
if (failureStreak >= 2 && onDirectPathFailedCallback != null) {
|
||||
unawaited(
|
||||
onDirectPathFailedCallback!(
|
||||
contact: failedContact,
|
||||
failureStreak: failureStreak,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -1608,17 +1735,21 @@ class MessagesProvider with ChangeNotifier {
|
||||
|
||||
// Send again
|
||||
if (sendMessageCallback != null) {
|
||||
await sendMessageCallback!(
|
||||
final queued = await sendMessageCallback!(
|
||||
contactPublicKey: contact.publicKey,
|
||||
text: message.text,
|
||||
messageId: messageId,
|
||||
contact: contact,
|
||||
retryAttempt: 0,
|
||||
);
|
||||
if (!queued) {
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
|
||||
);
|
||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||
}
|
||||
|
||||
_persistMessages();
|
||||
@@ -1631,10 +1762,62 @@ class MessagesProvider with ChangeNotifier {
|
||||
timer.cancel();
|
||||
}
|
||||
_timeoutTimers.clear();
|
||||
_completedAckHistory.clear();
|
||||
_messageAckHistory.clear();
|
||||
_ackHistoryLookup.clear();
|
||||
|
||||
// Clear retry manager
|
||||
_retryManager.clearAll();
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _rememberCompletedAck(int ackCode) {
|
||||
_completedAckHistory[ackCode] = DateTime.now();
|
||||
_cleanupCompletedAckHistory();
|
||||
}
|
||||
|
||||
void _cleanupCompletedAckHistory({
|
||||
Duration maxAge = const Duration(minutes: 15),
|
||||
}) {
|
||||
final cutoff = DateTime.now().subtract(maxAge);
|
||||
final staleAcks = _completedAckHistory.entries
|
||||
.where((entry) => entry.value.isBefore(cutoff))
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
for (final ack in staleAcks) {
|
||||
_completedAckHistory.remove(ack);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearAckHistoryForMessage(String messageId) {
|
||||
final ackTags = _messageAckHistory.remove(messageId);
|
||||
if (ackTags == null) {
|
||||
return;
|
||||
}
|
||||
for (final ack in ackTags) {
|
||||
_ackHistoryLookup.remove(ack);
|
||||
}
|
||||
}
|
||||
|
||||
void _cleanupAckHistoryLookup({
|
||||
Duration maxAge = const Duration(minutes: 15),
|
||||
}) {
|
||||
final cutoff = DateTime.now().subtract(maxAge);
|
||||
final staleAcks = _ackHistoryLookup.entries
|
||||
.where((entry) => entry.value.$2.isBefore(cutoff))
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
for (final ack in staleAcks) {
|
||||
final messageId = _ackHistoryLookup.remove(ack)?.$1;
|
||||
if (messageId == null) {
|
||||
continue;
|
||||
}
|
||||
final history = _messageAckHistory[messageId];
|
||||
history?.remove(ack);
|
||||
if (history != null && history.isEmpty) {
|
||||
_messageAckHistory.remove(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ class VoiceProvider with ChangeNotifier {
|
||||
|
||||
/// Active sessions keyed by sessionId.
|
||||
final Map<String, VoiceSession> _sessions = {};
|
||||
final Set<String> _ignoredIncomingSessions = {};
|
||||
|
||||
/// Currently playing session ID, or null.
|
||||
String? _playingSessionId;
|
||||
@@ -117,6 +118,8 @@ class VoiceProvider with ChangeNotifier {
|
||||
_outgoingSessions.containsKey(sessionId);
|
||||
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||
_sessions[sessionId]?.estimateRemaining();
|
||||
bool isReceiveCanceled(String sessionId) =>
|
||||
_ignoredIncomingSessions.contains(sessionId);
|
||||
|
||||
List<int> missingPacketIndices(String sessionId) {
|
||||
final session = _sessions[sessionId];
|
||||
@@ -133,6 +136,12 @@ class VoiceProvider with ChangeNotifier {
|
||||
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
||||
/// Returns true if the session just became complete.
|
||||
bool addPacket(VoicePacket packet) {
|
||||
if (_ignoredIncomingSessions.contains(packet.sessionId)) {
|
||||
debugPrint(
|
||||
'⏹️ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(
|
||||
packet.sessionId,
|
||||
() => VoiceSession(
|
||||
@@ -159,6 +168,23 @@ class VoiceProvider with ChangeNotifier {
|
||||
return justComplete;
|
||||
}
|
||||
|
||||
void cancelIncomingSession(String sessionId) {
|
||||
_ignoredIncomingSessions.add(sessionId);
|
||||
_sessions.remove(sessionId);
|
||||
if (_playingSessionId == sessionId) {
|
||||
unawaited(_player.stop());
|
||||
_playingSessionId = null;
|
||||
}
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resumeIncomingSession(String sessionId) {
|
||||
if (_ignoredIncomingSessions.remove(sessionId)) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache encoded packets for deferred voice serving.
|
||||
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
||||
if (packets.isEmpty) return;
|
||||
@@ -237,6 +263,7 @@ class VoiceProvider with ChangeNotifier {
|
||||
Future<void> clearStoredVoiceData() async {
|
||||
_sessions.clear();
|
||||
_outgoingSessions.clear();
|
||||
_ignoredIncomingSessions.clear();
|
||||
_playingSessionId = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../services/validation_service.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -722,7 +722,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Client Repeat Mode'),
|
||||
subtitle: deviceInfo.allowedRepeatFreqRanges != null &&
|
||||
subtitle:
|
||||
deviceInfo.allowedRepeatFreqRanges != null &&
|
||||
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
||||
? Text(
|
||||
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'map_management_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'device_config_screen.dart';
|
||||
import 'packet_log_screen.dart';
|
||||
import 'spectrum_scan_screen.dart';
|
||||
import '../utils/toast_logger.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../widgets/permission_request_dialog.dart';
|
||||
@@ -45,9 +46,9 @@ class HomeScreen extends StatefulWidget {
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late final AppProvider _appProvider;
|
||||
int _currentIndex = 0;
|
||||
bool _isMapFullscreen = false;
|
||||
bool _showRxTxIndicators = true;
|
||||
@@ -73,9 +74,13 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_appProvider = context.read<AppProvider>();
|
||||
_isMapEnabled = _appProvider.isMapEnabled;
|
||||
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||
_appProvider.addListener(_handleAppProviderChanged);
|
||||
|
||||
// Initialize synchronously so first build always has a valid controller.
|
||||
_initTabController();
|
||||
_loadTabVisibilityAndInitTabs();
|
||||
_loadRxTxPreference();
|
||||
|
||||
// Show permission dialog after the first frame if needed
|
||||
@@ -86,31 +91,39 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTabVisibilityAndInitTabs() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final mapEnabled = prefs.getBool('map_enabled') ?? true;
|
||||
final contactsEnabled = prefs.getBool('contacts_enabled') ?? true;
|
||||
if (!mounted) return;
|
||||
if (_isMapEnabled != mapEnabled || _isContactsEnabled != contactsEnabled) {
|
||||
_updateTabController(
|
||||
mapEnabled: mapEnabled,
|
||||
contactsEnabled: contactsEnabled,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _initTabController() {
|
||||
_tabController = TabController(length: _enabledTabs.length, vsync: this);
|
||||
_tabController.addListener(_onTabChanged);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_handleTabActivated(_currentTab);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleAppProviderChanged() {
|
||||
if (!mounted) return;
|
||||
_updateTabController(
|
||||
mapEnabled: _appProvider.isMapEnabled,
|
||||
contactsEnabled: _appProvider.isContactsEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
void _onTabChanged() {
|
||||
final previousTab = _currentTab;
|
||||
final nextIndex = _tabController.index;
|
||||
|
||||
setState(() {
|
||||
_currentIndex = _tabController.index;
|
||||
_currentIndex = nextIndex;
|
||||
if (_currentTab != _HomeTab.map) {
|
||||
_isMapFullscreen = false;
|
||||
}
|
||||
});
|
||||
|
||||
final nextTab = _currentTab;
|
||||
if (previousTab != nextTab) {
|
||||
_handleTabActivated(nextTab);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateTabController({
|
||||
@@ -122,15 +135,21 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
final oldTabs = _enabledTabs;
|
||||
final oldIndex = _tabController.index;
|
||||
final oldIndex = oldTabs.isEmpty
|
||||
? 0
|
||||
: _tabController.index.clamp(0, oldTabs.length - 1);
|
||||
final oldTab = oldTabs[oldIndex];
|
||||
|
||||
final oldController = _tabController;
|
||||
oldController.removeListener(_onTabChanged);
|
||||
oldController.dispose();
|
||||
|
||||
// Update state
|
||||
_isMapEnabled = mapEnabled;
|
||||
_isContactsEnabled = contactsEnabled;
|
||||
if (!_isMapEnabled) {
|
||||
_isMapFullscreen = false;
|
||||
}
|
||||
|
||||
final newTabs = _enabledTabs;
|
||||
final newIndex = newTabs.indexOf(oldTab);
|
||||
@@ -143,11 +162,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
_tabController.index = _currentIndex;
|
||||
|
||||
setState(() {});
|
||||
|
||||
// Dispose old controller after widgets have rebound to the new controller.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
oldController.dispose();
|
||||
});
|
||||
_handleTabActivated(_currentTab);
|
||||
}
|
||||
|
||||
void _navigateToTab(_HomeTab tab) {
|
||||
@@ -157,6 +172,23 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTabActivated(_HomeTab tab) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
|
||||
switch (tab) {
|
||||
case _HomeTab.messages:
|
||||
context.read<MessagesProvider>().markAllAsRead();
|
||||
break;
|
||||
case _HomeTab.contacts:
|
||||
context.read<ContactsProvider>().markAllAsViewed();
|
||||
break;
|
||||
case _HomeTab.map:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadRxTxPreference() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted) {
|
||||
@@ -168,6 +200,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appProvider.removeListener(_handleAppProviderChanged);
|
||||
_tabController.removeListener(_onTabChanged);
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
@@ -312,18 +345,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
messagesProvider.setLocalizations(localizations);
|
||||
}
|
||||
|
||||
// Check if tab visibility settings changed and update tab controller
|
||||
final appProvider = context.watch<AppProvider>();
|
||||
if (_isMapEnabled != appProvider.isMapEnabled ||
|
||||
_isContactsEnabled != appProvider.isContactsEnabled) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_updateTabController(
|
||||
mapEnabled: appProvider.isMapEnabled,
|
||||
contactsEnabled: appProvider.isContactsEnabled,
|
||||
);
|
||||
});
|
||||
}
|
||||
context.watch<AppProvider>();
|
||||
|
||||
final enabledTabs = _enabledTabs;
|
||||
final isMapTabActive = _currentTab == _HomeTab.map;
|
||||
@@ -381,6 +403,26 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
});
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.radar),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Spectrum Scan'),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
final navigator = Navigator.of(context);
|
||||
Future.delayed(Duration.zero, () {
|
||||
if (!mounted) return;
|
||||
navigator.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SpectrumScanScreen(),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:math' as math;
|
||||
@@ -44,11 +45,13 @@ class MessagesTab extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MessagesTabState extends State<MessagesTab> {
|
||||
static const int _maxContactMessageBytes = 156;
|
||||
static const int _maxChannelMessageBytes = 127;
|
||||
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
int _characterCount = 0;
|
||||
static const int _maxCharacters = 160;
|
||||
int _messageByteCount = 0;
|
||||
String? _highlightedMessageId;
|
||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||
|
||||
@@ -166,10 +169,44 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
|
||||
void _updateCharacterCount() {
|
||||
setState(() {
|
||||
_characterCount = _textController.text.length;
|
||||
_messageByteCount = utf8.encode(_textController.text).length;
|
||||
});
|
||||
}
|
||||
|
||||
int get _maxMessageBytes =>
|
||||
_destinationType == MessageDestinationPreferences.destinationTypeChannel
|
||||
? _maxChannelMessageBytes
|
||||
: _maxContactMessageBytes;
|
||||
|
||||
TextInputFormatter get _messageByteLimiter =>
|
||||
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||
if (utf8.encode(newValue.text).length <= _maxMessageBytes) {
|
||||
return newValue;
|
||||
}
|
||||
return oldValue;
|
||||
});
|
||||
|
||||
void _enforceMessageByteLimit() {
|
||||
final currentText = _textController.text;
|
||||
if (utf8.encode(currentText).length <= _maxMessageBytes) {
|
||||
_updateCharacterCount();
|
||||
return;
|
||||
}
|
||||
|
||||
var truncated = currentText;
|
||||
while (truncated.isNotEmpty &&
|
||||
utf8.encode(truncated).length > _maxMessageBytes) {
|
||||
truncated = truncated.substring(0, truncated.length - 1);
|
||||
}
|
||||
|
||||
_textController.value = _textController.value.copyWith(
|
||||
text: truncated,
|
||||
selection: TextSelection.collapsed(offset: truncated.length),
|
||||
composing: TextRange.empty,
|
||||
);
|
||||
_updateCharacterCount();
|
||||
}
|
||||
|
||||
/// Load saved message destination from preferences
|
||||
Future<void> _loadSavedDestination() async {
|
||||
final savedDestination =
|
||||
@@ -211,6 +248,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
await MessageDestinationPreferences.clearDestination();
|
||||
}
|
||||
}
|
||||
|
||||
_enforceMessageByteLimit();
|
||||
}
|
||||
|
||||
/// Show recipient selector bottom sheet
|
||||
@@ -250,6 +289,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
_selectedRecipient = recipient;
|
||||
});
|
||||
|
||||
_enforceMessageByteLimit();
|
||||
|
||||
// Save to preferences
|
||||
await MessageDestinationPreferences.setDestination(
|
||||
type,
|
||||
@@ -287,6 +328,18 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
return 'Select recipient';
|
||||
}
|
||||
|
||||
String _getDestinationLabel() {
|
||||
if (_destinationType ==
|
||||
MessageDestinationPreferences.destinationTypeChannel &&
|
||||
_selectedRecipient != null) {
|
||||
return _selectedRecipient!.getLocalizedDisplayName(context);
|
||||
}
|
||||
if (_selectedRecipient != null) {
|
||||
return _selectedRecipient!.displayName;
|
||||
}
|
||||
return 'Public Channel';
|
||||
}
|
||||
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _textController.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
@@ -371,7 +424,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
);
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(sentMessage);
|
||||
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
||||
|
||||
// Send to selected channel
|
||||
await connectionProvider.sendChannelMessage(
|
||||
@@ -604,7 +657,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
||||
);
|
||||
messagesProvider.addSentMessage(placeholder);
|
||||
messagesProvider.addSentMessage(placeholder, contact: recipient);
|
||||
|
||||
// Send IE1 envelope via normal message path.
|
||||
final envelopeText = envelope.encode();
|
||||
@@ -641,21 +694,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
'chunk=${imageDataBytesPerFragment}B',
|
||||
);
|
||||
|
||||
// Push all fragments immediately for direct contacts.
|
||||
// For channels, fragments are served on demand via IR1 fetch requests.
|
||||
if (!isChannel && recipient != null) {
|
||||
// Small delay so the IE1 envelope can propagate before fragments arrive.
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (!mounted) return;
|
||||
final served = await imageProvider.serveSessionTo(
|
||||
sessionId: sessionId,
|
||||
requester: recipient,
|
||||
);
|
||||
debugPrint(
|
||||
'📷 [Image] Pushed ${served ? fragments.length : 0} '
|
||||
'fragments to ${recipient.advName}',
|
||||
);
|
||||
}
|
||||
// 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 (!mounted) return;
|
||||
@@ -922,7 +962,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
channelIdx: channelIdx,
|
||||
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
||||
);
|
||||
messagesProvider.addSentMessage(sentMsg);
|
||||
messagesProvider.addSentMessage(sentMsg, contact: recipient);
|
||||
|
||||
try {
|
||||
if (isChannel) {
|
||||
@@ -1328,9 +1368,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
|
||||
);
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(sentMessage);
|
||||
|
||||
// Look up the room contact for path logging
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final roomContact = contactsProvider.contacts.where((c) {
|
||||
@@ -1338,6 +1375,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
c.publicKey.matches(roomPublicKey);
|
||||
}).firstOrNull;
|
||||
|
||||
// Add to messages list with "sending" status
|
||||
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
|
||||
|
||||
// Send SAR message to selected room (persisted and immutable)
|
||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: roomPublicKey!,
|
||||
@@ -1564,137 +1604,399 @@ class _MessagesTabState extends State<MessagesTab> {
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Quick actions (+) button
|
||||
IconButton(
|
||||
icon: Icon(_isRecording ? Icons.stop : Icons.add),
|
||||
tooltip: _isRecording ? 'Stop recording' : 'More actions',
|
||||
onPressed: _isRecording
|
||||
? _stopAndSendVoice
|
||||
: _showComposerActions,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryContainer,
|
||||
foregroundColor: _isRecording
|
||||
? Colors.red
|
||||
: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Destination switcher button
|
||||
IconButton(
|
||||
icon: Icon(_getDestinationIcon()),
|
||||
tooltip: _getDestinationTooltip(),
|
||||
onPressed: _showRecipientSelector,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor:
|
||||
_destinationType ==
|
||||
MessageDestinationPreferences
|
||||
.destinationTypeChannel
|
||||
? Theme.of(
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest
|
||||
: Theme.of(context).colorScheme.secondaryContainer,
|
||||
foregroundColor:
|
||||
_destinationType ==
|
||||
MessageDestinationPreferences
|
||||
.destinationTypeChannel
|
||||
? Theme.of(context).colorScheme.onSurface
|
||||
: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Text field with embedded send button
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: _maxCharacters,
|
||||
maxLines: null,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(context)!.typeYourMessage,
|
||||
hintStyle: const TextStyle(fontSize: 14),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
isDense: true,
|
||||
counterText: _characterCount >= 150
|
||||
? '$_characterCount/$_maxCharacters'
|
||||
: '',
|
||||
counterStyle: TextStyle(
|
||||
fontSize: 10,
|
||||
color: _characterCount > _maxCharacters * 0.9
|
||||
? Colors.orange
|
||||
: Theme.of(context).textTheme.bodySmall?.color,
|
||||
),
|
||||
suffixIcon: GestureDetector(
|
||||
onLongPressStart:
|
||||
(_voiceSupported && !_isSendingVoice)
|
||||
? (_) => _startVoiceRecording()
|
||||
: null,
|
||||
onLongPressEnd: (_voiceSupported && _isRecording)
|
||||
? (_) => _stopAndSendVoice()
|
||||
: null,
|
||||
onLongPressCancel: (_voiceSupported && _isRecording)
|
||||
? () => _stopAndSendVoice()
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: _isSendingVoice
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).dividerColor.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
_isRecording ? Icons.stop : Icons.add,
|
||||
size: 22,
|
||||
),
|
||||
tooltip: _isRecording
|
||||
? 'Stop recording'
|
||||
: 'More actions',
|
||||
onPressed: _isRecording
|
||||
? _stopAndSendVoice
|
||||
: _showComposerActions,
|
||||
color: _isRecording
|
||||
? Colors.red
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_isRecording
|
||||
? Icons.mic
|
||||
: Icons.send_rounded,
|
||||
size: 22,
|
||||
color: _isRecording
|
||||
? Colors.red
|
||||
: (_textController.text.trim().isEmpty
|
||||
? Theme.of(context).disabledColor
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary),
|
||||
),
|
||||
onPressed:
|
||||
_isRecording ||
|
||||
_isSendingVoice ||
|
||||
_textController.text.trim().isEmpty
|
||||
? null
|
||||
: _sendMessage,
|
||||
tooltip: _isRecording
|
||||
? 'Recording... release to send voice'
|
||||
: (_isSendingVoice
|
||||
? 'Sending voice...'
|
||||
: _voiceSupported
|
||||
? 'Send (long press to record voice)'
|
||||
: 'Send'),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: _showRecipientSelector,
|
||||
child: Ink(
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(
|
||||
20,
|
||||
),
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.dividerColor
|
||||
.withValues(alpha: 0.35),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getDestinationIcon(),
|
||||
size: 17,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getDestinationLabel(),
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight:
|
||||
FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.expand_more_rounded,
|
||||
size: 20,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(
|
||||
milliseconds: 180,
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: 46,
|
||||
maxHeight: 132,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: _focusNode.hasFocus
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary
|
||||
: Theme.of(context).dividerColor
|
||||
.withValues(alpha: 0.35),
|
||||
width: _focusNode.hasFocus ? 1.4 : 1,
|
||||
),
|
||||
boxShadow: _focusNode.hasFocus
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.10),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
keyboardType: TextInputType.multiline,
|
||||
inputFormatters: [
|
||||
_messageByteLimiter,
|
||||
],
|
||||
style: const TextStyle(fontSize: 15),
|
||||
textAlignVertical:
|
||||
TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
hintText: AppLocalizations.of(
|
||||
context,
|
||||
)!.typeYourMessage,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(alpha: 0.9),
|
||||
),
|
||||
filled: false,
|
||||
fillColor: Colors.transparent,
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
),
|
||||
textInputAction:
|
||||
TextInputAction.newline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final canSendText =
|
||||
!_isRecording &&
|
||||
!_isSendingVoice &&
|
||||
_textController.text
|
||||
.trim()
|
||||
.isNotEmpty;
|
||||
final semanticsLabel = _isRecording
|
||||
? 'Recording... release to send voice'
|
||||
: (_isSendingVoice
|
||||
? 'Sending voice...'
|
||||
: _voiceSupported
|
||||
? 'Send (long press to record voice)'
|
||||
: 'Send');
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled:
|
||||
canSendText ||
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice),
|
||||
label: semanticsLabel,
|
||||
onTap: canSendText
|
||||
? _sendMessage
|
||||
: null,
|
||||
onLongPress:
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice)
|
||||
? () {
|
||||
if (_isRecording) {
|
||||
_stopAndSendVoice();
|
||||
return;
|
||||
}
|
||||
_startVoiceRecording();
|
||||
}
|
||||
: null,
|
||||
child: Tooltip(
|
||||
message: semanticsLabel,
|
||||
excludeFromSemantics: true,
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onLongPressStart:
|
||||
(_voiceSupported &&
|
||||
!_isSendingVoice)
|
||||
? (_) => _startVoiceRecording()
|
||||
: null,
|
||||
onLongPressEnd:
|
||||
(_voiceSupported &&
|
||||
_isRecording)
|
||||
? (_) => _stopAndSendVoice()
|
||||
: null,
|
||||
onLongPressCancel:
|
||||
(_voiceSupported &&
|
||||
_isRecording)
|
||||
? () => _stopAndSendVoice()
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(
|
||||
milliseconds: 180,
|
||||
),
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color:
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? Colors.transparent
|
||||
: Theme.of(context)
|
||||
.dividerColor
|
||||
.withValues(
|
||||
alpha: 0.35,
|
||||
),
|
||||
),
|
||||
boxShadow:
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? [
|
||||
BoxShadow(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(
|
||||
alpha:
|
||||
0.22,
|
||||
),
|
||||
blurRadius: 14,
|
||||
offset:
|
||||
const Offset(
|
||||
0,
|
||||
6,
|
||||
),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: _isSendingVoice
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_isRecording
|
||||
? Icons
|
||||
.mic_rounded
|
||||
: Icons
|
||||
.send_rounded,
|
||||
size: 22,
|
||||
color:
|
||||
canSendText ||
|
||||
_isRecording
|
||||
? Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.onPrimary
|
||||
: Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$_messageByteCount/$_maxMessageBytes',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
_messageByteCount >
|
||||
_maxMessageBytes *
|
||||
0.9
|
||||
? Colors.orange.shade800
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant
|
||||
.withValues(
|
||||
alpha: 0.9,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -403,25 +403,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
channelCount: 2,
|
||||
);
|
||||
|
||||
final sarMessages = SampleDataGenerator.generateSarMarkerMessages(
|
||||
final sampleMessages = SampleDataGenerator.generateAllMessages(
|
||||
centerLocation: centerLocation,
|
||||
l10n: l10n,
|
||||
foundPersonCount: 2,
|
||||
fireCount: 1,
|
||||
stagingCount: 1,
|
||||
objectCount: 1,
|
||||
);
|
||||
|
||||
final channelMessages = SampleDataGenerator.generateChannelMessages(
|
||||
centerLocation: centerLocation,
|
||||
l10n: l10n,
|
||||
generalChannelMessages: 8,
|
||||
emergencyChannelMessages: 5,
|
||||
);
|
||||
|
||||
// Combine all messages
|
||||
final allMessages = [...sarMessages, ...channelMessages];
|
||||
|
||||
// Add to providers
|
||||
final contactsProvider = Provider.of<ContactsProvider>(
|
||||
context,
|
||||
@@ -433,7 +425,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
|
||||
contactsProvider.addContacts(contacts);
|
||||
messagesProvider.addMessages(allMessages);
|
||||
for (final message in sampleMessages.messages) {
|
||||
messagesProvider.addMessage(
|
||||
message,
|
||||
contactLocationSnapshot: sampleMessages.contactLocations[message.id],
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -446,8 +443,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
AppLocalizations.of(context)!.loadedSampleData(
|
||||
teamCount,
|
||||
channelCount,
|
||||
sarMessages.length,
|
||||
channelMessages.length,
|
||||
sampleMessages.messages.where((m) => m.isSarMarker).length,
|
||||
sampleMessages.messages.length,
|
||||
),
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
|
||||
601
lib/screens/spectrum_scan_screen.dart
Normal file
601
lib/screens/spectrum_scan_screen.dart
Normal file
@@ -0,0 +1,601 @@
|
||||
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';
|
||||
|
||||
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(
|
||||
const SnackBar(
|
||||
content: Text('Spectrum scan returned no candidate frequencies'),
|
||||
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: const Text('Spectrum Scan')),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,14 @@ class CayenneLppParser {
|
||||
|
||||
int fieldCount = 0;
|
||||
while (reader.hasRemaining) {
|
||||
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
||||
debugPrint(
|
||||
' Detected zero-padded telemetry tail, stopping parse at position '
|
||||
'${data.length - reader.remainingBytesCount}',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
fieldCount++;
|
||||
debugPrint(
|
||||
@@ -250,6 +258,14 @@ class CayenneLppParser {
|
||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||
}
|
||||
|
||||
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
||||
final start = data.length - remainingBytes;
|
||||
for (int i = start; i < data.length; i++) {
|
||||
if (data[i] != 0) return false;
|
||||
}
|
||||
return remainingBytes > 0;
|
||||
}
|
||||
|
||||
/// Create Cayenne LPP data for GPS location
|
||||
/// Standard Cayenne LPP GPS format (type 0x88):
|
||||
/// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000
|
||||
|
||||
@@ -64,6 +64,8 @@ class LocalePreferences {
|
||||
return 'Italiano';
|
||||
case 'el':
|
||||
return 'Greek';
|
||||
case 'cn':
|
||||
return '简体中文';
|
||||
default:
|
||||
return locale.languageCode;
|
||||
}
|
||||
@@ -88,6 +90,8 @@ class LocalePreferences {
|
||||
return 'Italiano';
|
||||
case 'el':
|
||||
return 'Ελληνικά';
|
||||
case 'cn':
|
||||
return '简体中文';
|
||||
default:
|
||||
return locale.languageCode;
|
||||
}
|
||||
|
||||
@@ -31,17 +31,22 @@ class MeshMapNode {
|
||||
}
|
||||
|
||||
class MeshMapNodesService {
|
||||
static const String _nodesEndpoint = 'https://api.meshcore.nz/api/v1/map/nodes';
|
||||
static const String _nodesEndpoint =
|
||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
||||
static const Duration traceCacheTtl = Duration(minutes: 10);
|
||||
static List<MeshMapNode>? _cachedNodes;
|
||||
static DateTime? _cachedAt;
|
||||
|
||||
static Future<List<MeshMapNode>> fetchNodes({bool forceRefresh = false}) async {
|
||||
static Future<List<MeshMapNode>> fetchNodes({
|
||||
bool forceRefresh = false,
|
||||
Duration cacheTtl = _cacheTtl,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
if (!forceRefresh &&
|
||||
_cachedNodes != null &&
|
||||
_cachedAt != null &&
|
||||
now.difference(_cachedAt!) < _cacheTtl) {
|
||||
now.difference(_cachedAt!) < cacheTtl) {
|
||||
return _cachedNodes!;
|
||||
}
|
||||
|
||||
@@ -58,7 +63,9 @@ class MeshMapNodesService {
|
||||
final nodes = nodesRaw
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MeshMapNode.fromJson)
|
||||
.where((n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0)
|
||||
.where(
|
||||
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
|
||||
)
|
||||
.toList();
|
||||
|
||||
_cachedNodes = nodes;
|
||||
|
||||
@@ -2,15 +2,21 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Service for persisting messages to local storage
|
||||
class MessageStorageService {
|
||||
static const String _messagesKey = 'stored_messages';
|
||||
static const String _messageContactLocationsKey =
|
||||
'stored_message_contact_locations';
|
||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||
|
||||
/// Save messages to persistent storage
|
||||
Future<void> saveMessages(List<Message> messages) async {
|
||||
Future<void> saveMessages(
|
||||
List<Message> messages, {
|
||||
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -24,6 +30,19 @@ class MessageStorageService {
|
||||
|
||||
final jsonString = jsonEncode(limitedList);
|
||||
await prefs.setString(_messagesKey, jsonString);
|
||||
final retainedMessageIds = limitedList
|
||||
.map((entry) => entry['id'] as String)
|
||||
.toSet();
|
||||
final locationJson = <String, dynamic>{};
|
||||
for (final entry in messageContactLocations.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
locationJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
await prefs.setString(
|
||||
_messageContactLocationsKey,
|
||||
jsonEncode(locationJson),
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||
@@ -33,6 +52,36 @@ class MessageStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageContactLocation>> loadMessageContactLocations()
|
||||
async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messageContactLocationsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, MessageContactLocation>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final snapshot = MessageContactLocation.fromJson(value);
|
||||
if (snapshot != null) {
|
||||
result[entry.key] = snapshot;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading contact locations: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Load messages from persistent storage
|
||||
Future<List<Message>> loadMessages() async {
|
||||
try {
|
||||
@@ -66,6 +115,7 @@ class MessageStorageService {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_messagesKey);
|
||||
await prefs.remove(_messageContactLocationsKey);
|
||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||
|
||||
32
lib/utils/avatar_label_helper.dart
Normal file
32
lib/utils/avatar_label_helper.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
class AvatarLabelHelper {
|
||||
static String buildLabel(String name) {
|
||||
final trimmed = name.trim();
|
||||
if (trimmed.isEmpty) return '?';
|
||||
|
||||
if (trimmed.startsWith('#')) {
|
||||
final hashBody = trimmed.substring(1).replaceAll(RegExp(r'[\s_-]+'), '');
|
||||
if (hashBody.isEmpty) {
|
||||
return '#';
|
||||
}
|
||||
return '#${_take(hashBody, 2)}'.toUpperCase();
|
||||
}
|
||||
|
||||
final parts = trimmed
|
||||
.split(RegExp(r'[\s_-]+'))
|
||||
.where((part) => part.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (parts.length >= 2) {
|
||||
final first = _take(parts[0], 1);
|
||||
final second = _take(parts[1], 1);
|
||||
return '$first$second'.toUpperCase();
|
||||
}
|
||||
|
||||
return _take(trimmed, 2).toUpperCase();
|
||||
}
|
||||
|
||||
static String _take(String value, int count) {
|
||||
if (value.length <= count) return value;
|
||||
return value.substring(0, count);
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,10 @@ int safeImageDataBytesForPath(int pathLen) {
|
||||
? maxRawPayloadFromCommandFrame
|
||||
: maxRawPayloadFromMesh;
|
||||
final maxData = maxRawPayload - ImagePacket._headerLen;
|
||||
return maxData.clamp(1, 255).toInt();
|
||||
// Keep a safety margin below the theoretical direct-route ceiling.
|
||||
// Fragments at the absolute 172-byte command-frame limit have proven flaky
|
||||
// in practice, so cap to the conservative protocol default.
|
||||
return maxData.clamp(1, ImagePacket.maxDataBytes).toInt();
|
||||
}
|
||||
|
||||
/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa.
|
||||
@@ -361,9 +364,7 @@ class ImageFetchRequest {
|
||||
final ts = _parseInt(parts[3], base36: true);
|
||||
final normalizedWant = wantToken == 'a'
|
||||
? 'all'
|
||||
: ((wantToken.startsWith('m'))
|
||||
? 'missing'
|
||||
: wantToken);
|
||||
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
||||
|
||||
if (sid == null) return null;
|
||||
final missingIndices = <int>[];
|
||||
@@ -532,7 +533,11 @@ String _toBase36(int value) => value.toRadixString(36);
|
||||
|
||||
String _encodeSessionId(String sessionIdHex) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
||||
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
|
||||
throw ArgumentError.value(
|
||||
sessionIdHex,
|
||||
'sessionIdHex',
|
||||
'Expected 8 hex chars',
|
||||
);
|
||||
}
|
||||
final value = int.parse(sessionIdHex, radix: 16);
|
||||
return value.toRadixString(36);
|
||||
@@ -546,10 +551,7 @@ String? _decodeSessionId(String token) {
|
||||
}
|
||||
|
||||
String _encodeMissingIndicesCompact(List<int> indices) {
|
||||
final sorted = indices
|
||||
.where((v) => v >= 0 && v <= 254)
|
||||
.toSet()
|
||||
.toList()
|
||||
final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
|
||||
..sort();
|
||||
if (sorted.isEmpty) return '';
|
||||
final chunks = <String>[];
|
||||
@@ -562,7 +564,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
|
||||
continue;
|
||||
}
|
||||
chunks.add(
|
||||
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
||||
start == prev
|
||||
? _toBase36(start)
|
||||
: '${_toBase36(start)}-${_toBase36(prev)}',
|
||||
);
|
||||
start = curr;
|
||||
prev = curr;
|
||||
|
||||
@@ -24,15 +24,30 @@ extension MessageLocalization on Message {
|
||||
|
||||
switch (deliveryStatus) {
|
||||
case MessageDeliveryStatus.sending:
|
||||
if (isContactMessage) {
|
||||
if (retryAttempt > 0) {
|
||||
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/3';
|
||||
}
|
||||
return l10n.pending;
|
||||
}
|
||||
return l10n.sending;
|
||||
case MessageDeliveryStatus.sent:
|
||||
return l10n.sent;
|
||||
case MessageDeliveryStatus.delivered:
|
||||
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
||||
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/3';
|
||||
}
|
||||
if (retryAttempt > 0) {
|
||||
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/3';
|
||||
}
|
||||
if (roundTripTimeMs != null) {
|
||||
return l10n.deliveredWithTime(roundTripTimeMs!);
|
||||
}
|
||||
return l10n.delivered;
|
||||
case MessageDeliveryStatus.failed:
|
||||
if (retryAttempt > 0) {
|
||||
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/3';
|
||||
}
|
||||
return l10n.failed;
|
||||
case MessageDeliveryStatus.received:
|
||||
return '';
|
||||
|
||||
@@ -4,12 +4,66 @@ import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// Generates sample data for testing/demo purposes
|
||||
class SampleDataGenerator {
|
||||
static final Random _random = Random();
|
||||
|
||||
static MessageContactLocation _sampleSnapshot({
|
||||
required LatLng location,
|
||||
required DateTime receivedAt,
|
||||
required String source,
|
||||
}) {
|
||||
return MessageContactLocation(
|
||||
location: location,
|
||||
source: source,
|
||||
capturedAt: receivedAt,
|
||||
sourceTimestamp: receivedAt.subtract(const Duration(minutes: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
static LatLng _randomNearbyLocation(LatLng center, double spread) {
|
||||
final latOffset = (_random.nextDouble() - 0.5) * spread;
|
||||
final lonOffset = (_random.nextDouble() - 0.5) * spread;
|
||||
return LatLng(center.latitude + latOffset, center.longitude + lonOffset);
|
||||
}
|
||||
|
||||
static SampleMessageBatch generateAllMessages({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int foundPersonCount = 2,
|
||||
int fireCount = 1,
|
||||
int stagingCount = 1,
|
||||
int objectCount = 1,
|
||||
int generalChannelMessages = 8,
|
||||
int emergencyChannelMessages = 5,
|
||||
}) {
|
||||
final sarBatch = generateSarMarkerMessages(
|
||||
centerLocation: centerLocation,
|
||||
l10n: l10n,
|
||||
foundPersonCount: foundPersonCount,
|
||||
fireCount: fireCount,
|
||||
stagingCount: stagingCount,
|
||||
objectCount: objectCount,
|
||||
);
|
||||
final channelBatch = generateChannelMessages(
|
||||
centerLocation: centerLocation,
|
||||
l10n: l10n,
|
||||
generalChannelMessages: generalChannelMessages,
|
||||
emergencyChannelMessages: emergencyChannelMessages,
|
||||
);
|
||||
|
||||
return SampleMessageBatch(
|
||||
messages: [...sarBatch.messages, ...channelBatch.messages],
|
||||
contactLocations: {
|
||||
...sarBatch.contactLocations,
|
||||
...channelBatch.contactLocations,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate sample contacts around a center location
|
||||
static List<Contact> generateContacts({
|
||||
required LatLng centerLocation,
|
||||
@@ -113,7 +167,7 @@ class SampleDataGenerator {
|
||||
}
|
||||
|
||||
/// Generate sample SAR markers around a center location
|
||||
static List<Message> generateSarMarkerMessages({
|
||||
static SampleMessageBatch generateSarMarkerMessages({
|
||||
required LatLng centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int foundPersonCount = 2,
|
||||
@@ -122,6 +176,7 @@ class SampleDataGenerator {
|
||||
int objectCount = 1,
|
||||
}) {
|
||||
final messages = <Message>[];
|
||||
final contactLocations = <String, MessageContactLocation>{};
|
||||
final now = DateTime.now();
|
||||
int messageId = 1;
|
||||
|
||||
@@ -137,7 +192,7 @@ class SampleDataGenerator {
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
|
||||
messages.add(Message(
|
||||
final message = Message(
|
||||
id: 'sample_fp_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
@@ -150,7 +205,13 @@ class SampleDataGenerator {
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🧑',
|
||||
senderName: l10n.sampleTeamMember,
|
||||
));
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.006),
|
||||
receivedAt: timestamp,
|
||||
source: 'telemetry',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
@@ -166,7 +227,7 @@ class SampleDataGenerator {
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
|
||||
messages.add(Message(
|
||||
final message = Message(
|
||||
id: 'sample_fire_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
@@ -179,7 +240,13 @@ class SampleDataGenerator {
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🔥',
|
||||
senderName: l10n.sampleScout,
|
||||
));
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.008),
|
||||
receivedAt: timestamp,
|
||||
source: 'advert',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
@@ -195,7 +262,7 @@ class SampleDataGenerator {
|
||||
);
|
||||
|
||||
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
|
||||
messages.add(Message(
|
||||
final message = Message(
|
||||
id: 'sample_staging_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
@@ -208,7 +275,13 @@ class SampleDataGenerator {
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '🏕️',
|
||||
senderName: l10n.sampleBase,
|
||||
));
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.01),
|
||||
receivedAt: timestamp,
|
||||
source: 'advert',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
@@ -231,24 +304,34 @@ class SampleDataGenerator {
|
||||
l10n.sampleObjectTrailMarker,
|
||||
];
|
||||
|
||||
messages.add(Message(
|
||||
final message = Message(
|
||||
id: 'sample_object_$messageId',
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
text: 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
|
||||
text:
|
||||
'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
|
||||
receivedAt: timestamp,
|
||||
isSarMarker: true,
|
||||
sarGpsCoordinates: LatLng(lat, lon),
|
||||
sarCustomEmoji: '📦',
|
||||
senderName: l10n.sampleSearcher,
|
||||
));
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.007),
|
||||
receivedAt: timestamp,
|
||||
source: 'telemetry',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
return messages;
|
||||
return SampleMessageBatch(
|
||||
messages: messages,
|
||||
contactLocations: contactLocations,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate sample map drawings
|
||||
@@ -272,7 +355,9 @@ class SampleDataGenerator {
|
||||
'id': 'sample_line_${now.millisecondsSinceEpoch}',
|
||||
'color': Colors.blue.toARGB32(),
|
||||
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
|
||||
'points': linePoints.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
|
||||
'points': linePoints
|
||||
.map((p) => {'lat': p.latitude, 'lon': p.longitude})
|
||||
.toList(),
|
||||
'sender': l10n.sampleTeamMember,
|
||||
});
|
||||
|
||||
@@ -297,7 +382,7 @@ class SampleDataGenerator {
|
||||
}
|
||||
|
||||
/// Generate sample channel messages for public channels
|
||||
static List<Message> generateChannelMessages({
|
||||
static SampleMessageBatch generateChannelMessages({
|
||||
LatLng? centerLocation,
|
||||
required AppLocalizations l10n,
|
||||
int generalChannelMessages = 8,
|
||||
@@ -306,6 +391,7 @@ class SampleDataGenerator {
|
||||
// Use provided location or default to Ljubljana, Slovenia
|
||||
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
|
||||
final messages = <Message>[];
|
||||
final contactLocations = <String, MessageContactLocation>{};
|
||||
final now = DateTime.now();
|
||||
int messageId = 1000; // Start with high ID to avoid conflicts
|
||||
|
||||
@@ -352,7 +438,11 @@ class SampleDataGenerator {
|
||||
];
|
||||
|
||||
// Generate General channel messages
|
||||
for (int i = 0; i < generalChannelMessages && i < generalMessages.length; i++) {
|
||||
for (
|
||||
int i = 0;
|
||||
i < generalChannelMessages && i < generalMessages.length;
|
||||
i++
|
||||
) {
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
@@ -361,7 +451,7 @@ class SampleDataGenerator {
|
||||
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
|
||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||
|
||||
messages.add(Message(
|
||||
final message = Message(
|
||||
id: 'sample_general_$messageId',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 0, // General channel
|
||||
@@ -372,12 +462,22 @@ class SampleDataGenerator {
|
||||
text: generalMessages[i],
|
||||
receivedAt: timestamp,
|
||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
||||
));
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(center, 0.018),
|
||||
receivedAt: timestamp,
|
||||
source: i.isEven ? 'telemetry' : 'advert',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
// Generate Emergency channel messages
|
||||
for (int i = 0; i < emergencyChannelMessages && i < emergencyMessages.length; i++) {
|
||||
for (
|
||||
int i = 0;
|
||||
i < emergencyChannelMessages && i < emergencyMessages.length;
|
||||
i++
|
||||
) {
|
||||
final senderKey = Uint8List.fromList(
|
||||
List.generate(32, (_) => _random.nextInt(256)),
|
||||
);
|
||||
@@ -386,7 +486,7 @@ class SampleDataGenerator {
|
||||
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
|
||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||
|
||||
messages.add(Message(
|
||||
final message = Message(
|
||||
id: 'sample_emergency_$messageId',
|
||||
messageType: MessageType.channel,
|
||||
channelIdx: 1, // Emergency channel
|
||||
@@ -397,10 +497,29 @@ class SampleDataGenerator {
|
||||
text: emergencyMessages[i],
|
||||
receivedAt: timestamp,
|
||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
||||
));
|
||||
);
|
||||
messages.add(message);
|
||||
contactLocations[message.id] = _sampleSnapshot(
|
||||
location: _randomNearbyLocation(center, 0.012),
|
||||
receivedAt: timestamp,
|
||||
source: i.isEven ? 'advert' : 'telemetry',
|
||||
);
|
||||
messageId++;
|
||||
}
|
||||
|
||||
return messages;
|
||||
return SampleMessageBatch(
|
||||
messages: messages,
|
||||
contactLocations: contactLocations,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SampleMessageBatch {
|
||||
final List<Message> messages;
|
||||
final Map<String, MessageContactLocation> contactLocations;
|
||||
|
||||
const SampleMessageBatch({
|
||||
required this.messages,
|
||||
required this.contactLocations,
|
||||
});
|
||||
}
|
||||
|
||||
129
lib/widgets/common/contact_avatar.dart
Normal file
129
lib/widgets/common/contact_avatar.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../services/trail_color_service.dart';
|
||||
import '../../utils/avatar_label_helper.dart';
|
||||
|
||||
class ContactAvatar extends StatelessWidget {
|
||||
final Contact contact;
|
||||
final double radius;
|
||||
final String? displayName;
|
||||
|
||||
const ContactAvatar({
|
||||
super.key,
|
||||
required this.contact,
|
||||
this.radius = 20,
|
||||
this.displayName,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final backgroundColor = _getBackgroundColor(context);
|
||||
final foregroundColor = _getForegroundColor(backgroundColor);
|
||||
final emoji = contact.roleEmoji;
|
||||
|
||||
if (emoji != null && emoji.isNotEmpty) {
|
||||
return _buildAvatarFrame(
|
||||
backgroundColor: backgroundColor,
|
||||
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
||||
);
|
||||
}
|
||||
|
||||
if (_shouldUseLabelFallback) {
|
||||
return _buildAvatarFrame(
|
||||
backgroundColor: backgroundColor,
|
||||
child: Text(
|
||||
AvatarLabelHelper.buildLabel(displayName ?? contact.displayName),
|
||||
style: TextStyle(
|
||||
color: foregroundColor,
|
||||
fontSize: radius * 0.68,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildAvatarFrame(
|
||||
backgroundColor: backgroundColor,
|
||||
child: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: foregroundColor,
|
||||
size: radius,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarFrame({
|
||||
required Color backgroundColor,
|
||||
required Widget child,
|
||||
}) {
|
||||
if (_usesSquareShape) {
|
||||
return Container(
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(radius * 0.6),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: backgroundColor,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
bool get _shouldUseLabelFallback =>
|
||||
contact.type == ContactType.chat ||
|
||||
contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room;
|
||||
|
||||
bool get _usesSquareShape =>
|
||||
contact.type == ContactType.channel || contact.type == ContactType.room;
|
||||
|
||||
Color _getBackgroundColor(BuildContext context) {
|
||||
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
|
||||
return TrailColorService.getTrailColor(contact);
|
||||
}
|
||||
|
||||
switch (contact.type) {
|
||||
case ContactType.none:
|
||||
return Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
case ContactType.chat:
|
||||
return Colors.blue;
|
||||
case ContactType.repeater:
|
||||
return Colors.orange;
|
||||
case ContactType.room:
|
||||
return Colors.purple;
|
||||
case ContactType.channel:
|
||||
return Colors.teal;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getForegroundColor(Color backgroundColor) {
|
||||
return ThemeData.estimateBrightnessForColor(backgroundColor) ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87;
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(ContactType type) {
|
||||
switch (type) {
|
||||
case ContactType.none:
|
||||
return Icons.help_outline;
|
||||
case ContactType.chat:
|
||||
return Icons.person;
|
||||
case ContactType.repeater:
|
||||
return Icons.router;
|
||||
case ContactType.room:
|
||||
return Icons.meeting_room;
|
||||
case ContactType.channel:
|
||||
return Icons.public;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class ConnectionDialog extends StatefulWidget {
|
||||
class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
late final ConnectionProvider _connectionProvider;
|
||||
final NetworkScannerService _networkScanner = NetworkScannerService();
|
||||
final List<DiscoveredServer> _discoveredServers = [];
|
||||
int _scannedCount = 0;
|
||||
@@ -46,13 +47,14 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
_connectionProvider = Provider.of<ConnectionProvider>(context, listen: false);
|
||||
|
||||
// Start BLE scan by default
|
||||
final connectionProvider = Provider.of<ConnectionProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
connectionProvider.startScan();
|
||||
// Defer scan startup until after the first frame so Provider listeners
|
||||
// are not notified while this dialog is still being built.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_connectionProvider.startScan();
|
||||
});
|
||||
|
||||
// Set up network scanner callbacks
|
||||
_networkScanner.onServerDiscovered = (server) {
|
||||
@@ -81,11 +83,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final connectionProvider = Provider.of<ConnectionProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
connectionProvider.stopScan();
|
||||
_connectionProvider.stopScan();
|
||||
_networkScanner.stopScan();
|
||||
// Remove listener before disposing to prevent memory leaks
|
||||
_tabController.removeListener(_onTabChanged);
|
||||
|
||||
394
lib/widgets/device/spectrum_scan_panel.dart
Normal file
394
lib/widgets/device/spectrum_scan_panel.dart
Normal file
@@ -0,0 +1,394 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_client/meshcore_client.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: 'Quiet',
|
||||
),
|
||||
const _LegendChip(color: Colors.orange, label: 'Moderate'),
|
||||
_LegendChip(
|
||||
color: theme.colorScheme.error,
|
||||
label: '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.surface.withValues(alpha: 0.7),
|
||||
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: const Icon(Icons.north_east),
|
||||
label: const Text('Use selected frequency'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
scheme.surface,
|
||||
scheme.surfaceContainerHighest.withValues(alpha: 0.9),
|
||||
],
|
||||
),
|
||||
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),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
scheme.primary.withValues(alpha: 0.12),
|
||||
scheme.tertiary.withValues(alpha: 0.08),
|
||||
scheme.primary.withValues(alpha: 0.12),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: rangeLeft,
|
||||
top: 12,
|
||||
width: (rangeRight - rangeLeft).clamp(8.0, width),
|
||||
height: 56,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: scheme.primary.withValues(alpha: 0.20),
|
||||
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.tertiary.withValues(alpha: 0.32),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -849,6 +849,7 @@ class DrawingToolbar extends StatelessWidget {
|
||||
contactPublicKey: room.publicKey,
|
||||
text: message,
|
||||
messageId: messageId,
|
||||
contact: room,
|
||||
);
|
||||
debugPrint(' ✅ Sent successfully');
|
||||
|
||||
|
||||
@@ -68,8 +68,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
senderKey6FromEnvelope: envelope.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
);
|
||||
final effectivePathLen =
|
||||
sender != null && sender.outPathLen >= 0
|
||||
final effectivePathLen = sender != null && sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
: widget.message.pathLen;
|
||||
final isComplete = imageProvider.isComplete(envelope.sessionId);
|
||||
@@ -79,7 +78,12 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
|
||||
if (_isRequesting && isComplete) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _isRequesting = false);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_errorText = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,8 +193,38 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
'$received/$total',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
onPressed: () => _cancelReceive(envelope.sessionId),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
color: Colors.white70,
|
||||
tooltip: 'Cancel image receive',
|
||||
),
|
||||
),
|
||||
] else if (_errorText != null) ...[
|
||||
const Icon(Icons.broken_image, color: Colors.red, size: 36),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.broken_image, color: Colors.red, size: 36),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () => _requestAndFetch(
|
||||
envelope,
|
||||
radioBw: radioBw,
|
||||
radioSf: radioSf,
|
||||
radioCr: radioCr,
|
||||
pathLen: pathLen,
|
||||
),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Retry'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
// Tap-to-load icon.
|
||||
IconButton(
|
||||
@@ -221,6 +255,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
final conn = context.read<ConnectionProvider>();
|
||||
final imageProvider = context.read<ip.ImageProvider>();
|
||||
imageProvider.resumeIncomingSession(envelope.sessionId);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
@@ -264,7 +300,6 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
}
|
||||
|
||||
setState(() => _errorText = null);
|
||||
final imageProvider = context.read<ip.ImageProvider>();
|
||||
final deviceKey = conn.deviceInfo.publicKey;
|
||||
if (deviceKey == null || deviceKey.length < 6) {
|
||||
await _showBlockingAlert(
|
||||
@@ -322,7 +357,9 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
if (!mounted) return;
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
|
||||
final effectivePathLen = sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
: pathLen;
|
||||
final txEstimate = estimateImageTransmitDuration(
|
||||
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
|
||||
sizeBytes: missing.isEmpty
|
||||
@@ -357,6 +394,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
|
||||
);
|
||||
}
|
||||
|
||||
void _cancelReceive(String sessionId) {
|
||||
if (!mounted) return;
|
||||
_requestTimeoutTimer?.cancel();
|
||||
context.read<ip.ImageProvider>().cancelIncomingSession(sessionId);
|
||||
_showToast('Image receive canceled');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_errorText = 'Image receive canceled';
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showBlockingAlert(String title, String message) async {
|
||||
if (!mounted) return;
|
||||
_showToast('$title: $message');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
|
||||
Future<_TraceResult> _loadTrace() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final nodes = await MeshMapNodesService.fetchNodes();
|
||||
final nodes = await MeshMapNodesService.fetchNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
final packetPath = _extractPathFromPacketLogs(
|
||||
logs: connectionProvider.bleService.packetLogs,
|
||||
message: widget.message,
|
||||
@@ -166,23 +168,31 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
child: hasMapPath
|
||||
? flutter_map.FlutterMap(
|
||||
options: flutter_map.MapOptions(
|
||||
initialCameraFit: flutter_map.CameraFit.bounds(
|
||||
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
|
||||
padding: const EdgeInsets.all(28),
|
||||
),
|
||||
initialCameraFit:
|
||||
flutter_map.CameraFit.bounds(
|
||||
bounds:
|
||||
flutter_map
|
||||
.LatLngBounds.fromPoints(
|
||||
mapPoints,
|
||||
),
|
||||
padding: const EdgeInsets.all(28),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
userAgentPackageName:
|
||||
'com.meshcore.sar',
|
||||
),
|
||||
flutter_map.PolylineLayer(
|
||||
polylines: [
|
||||
flutter_map.Polyline(
|
||||
points: mapPoints,
|
||||
strokeWidth: 4,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -202,12 +212,14 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
height: 34,
|
||||
child: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: entry.key == 0
|
||||
backgroundColor:
|
||||
entry.key == 0
|
||||
? Colors.green
|
||||
: (entry.key ==
|
||||
trace
|
||||
.matchedPathNodes
|
||||
.whereType<MeshMapNode>()
|
||||
trace.matchedPathNodes
|
||||
.whereType<
|
||||
MeshMapNode
|
||||
>()
|
||||
.length -
|
||||
1
|
||||
? Colors.red
|
||||
@@ -216,7 +228,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
'${entry.key + 1}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
@@ -228,7 +241,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
],
|
||||
)
|
||||
: const Center(
|
||||
child: Text('Not enough geolocated nodes to draw path'),
|
||||
child: Text(
|
||||
'Not enough geolocated nodes to draw path',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -244,8 +259,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
),
|
||||
if (relayNodes.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text('No relay nodes could be matched for this message.'),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
'No relay nodes could be matched for this message.',
|
||||
),
|
||||
),
|
||||
...relayNodes.map(
|
||||
(node) => ListTile(
|
||||
@@ -286,10 +306,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
|
||||
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
|
||||
if (prefixHex == null || prefixHex.isEmpty) return null;
|
||||
final matches = nodes
|
||||
.where((n) => n.publicKey.startsWith(prefixHex))
|
||||
.toList()
|
||||
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||
final matches =
|
||||
nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList()
|
||||
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||
return matches.isEmpty ? null : matches.first;
|
||||
}
|
||||
|
||||
@@ -298,7 +317,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
required Message message,
|
||||
}) {
|
||||
if (message.pathLen <= 0 || message.pathLen >= 255) return null;
|
||||
final expectedPayloadType = message.messageType == MessageType.channel ? 0x05 : 0x02;
|
||||
final expectedPayloadType = message.messageType == MessageType.channel
|
||||
? 0x05
|
||||
: 0x02;
|
||||
BlePacketLog? bestLog;
|
||||
var bestDeltaMs = 999999999;
|
||||
|
||||
@@ -313,7 +334,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
if (pathLen != message.pathLen) continue;
|
||||
if (raw.length < 5 + pathLen) continue;
|
||||
|
||||
final deltaMs = (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||
final deltaMs =
|
||||
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
|
||||
if (deltaMs < bestDeltaMs) {
|
||||
bestDeltaMs = deltaMs;
|
||||
bestLog = log;
|
||||
@@ -335,7 +357,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
final result = <MeshMapNode?>[];
|
||||
for (var i = 0; i < pathHashes.length; i++) {
|
||||
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
|
||||
final candidates = nodes.where((n) => n.publicKey.startsWith(hashHex)).toList();
|
||||
final candidates = nodes
|
||||
.where((n) => n.publicKey.startsWith(hashHex))
|
||||
.toList();
|
||||
if (candidates.isEmpty) {
|
||||
result.add(null);
|
||||
continue;
|
||||
@@ -343,7 +367,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
|
||||
List<MeshMapNode> filtered = candidates;
|
||||
if (i == 0 && senderPrefix != null) {
|
||||
final senderMatches = filtered.where((n) => n.publicKey.startsWith(senderPrefix)).toList();
|
||||
final senderMatches = filtered
|
||||
.where((n) => n.publicKey.startsWith(senderPrefix))
|
||||
.toList();
|
||||
if (senderMatches.isNotEmpty) filtered = senderMatches;
|
||||
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
|
||||
final recipientMatches = filtered
|
||||
@@ -366,7 +392,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
||||
}) {
|
||||
if (relayCount <= 0 || sender == null || recipient == null) return const [];
|
||||
final candidates = nodes.where((n) {
|
||||
if (sender.publicKey == n.publicKey || recipient.publicKey == n.publicKey) {
|
||||
if (sender.publicKey == n.publicKey ||
|
||||
recipient.publicKey == n.publicKey) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -97,9 +97,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'Tic-Tac-Toe · Game ${state.gameId}',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelMedium?.copyWith(
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
),
|
||||
@@ -167,7 +165,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
recipientPublicKey: opponent.publicKey,
|
||||
);
|
||||
messagesProvider.addSentMessage(sentMessage);
|
||||
messagesProvider.addSentMessage(sentMessage, contact: opponent);
|
||||
|
||||
final sent = await connectionProvider.sendTextMessage(
|
||||
contactPublicKey: opponent.publicKey,
|
||||
|
||||
@@ -65,8 +65,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
senderKey6FromEnvelope: envelope?.senderKey6,
|
||||
senderName: widget.message.senderName,
|
||||
);
|
||||
final effectivePathLen =
|
||||
sender != null && sender.outPathLen >= 0
|
||||
final effectivePathLen = sender != null && sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
: widget.message.pathLen;
|
||||
final isPlaying = voiceProvider.isPlaying(voiceId);
|
||||
@@ -77,6 +76,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_errorText = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -125,6 +125,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
await voiceProvider.stop();
|
||||
return;
|
||||
}
|
||||
if (_isRequesting) {
|
||||
_cancelReceive(voiceId);
|
||||
return;
|
||||
}
|
||||
if (isComplete) {
|
||||
await voiceProvider.play(voiceId);
|
||||
return;
|
||||
@@ -151,7 +155,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
child: Icon(
|
||||
isPlaying
|
||||
? Icons.stop
|
||||
: (_isRequesting ? Icons.downloading : Icons.play_arrow),
|
||||
: (_isRequesting ? Icons.close : Icons.play_arrow),
|
||||
size: 28,
|
||||
color: widget.isSentByMe
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
@@ -215,6 +219,8 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
}) async {
|
||||
if (_isRequesting) return;
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final voiceProvider = context.read<VoiceProvider>();
|
||||
voiceProvider.resumeIncomingSession(sessionId);
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
|
||||
contactsProvider: contactsProvider,
|
||||
@@ -300,7 +306,9 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
}
|
||||
|
||||
// Timeout = 2× estimated LoRa airtime (min 30s).
|
||||
final effectivePathLen = sender.outPathLen >= 0 ? sender.outPathLen : pathLen;
|
||||
final effectivePathLen = sender.outPathLen >= 0
|
||||
? sender.outPathLen
|
||||
: pathLen;
|
||||
final txEstimate = envelope != null
|
||||
? estimateVoiceTransmitDuration(
|
||||
packetCount: envelope.total,
|
||||
@@ -333,6 +341,18 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelReceive(String sessionId) {
|
||||
if (!mounted) return;
|
||||
_requestTimeoutTimer?.cancel();
|
||||
context.read<VoiceProvider>().cancelIncomingSession(sessionId);
|
||||
_showToast('Voice receive canceled');
|
||||
setState(() {
|
||||
_isRequesting = false;
|
||||
_autoPlayWhenReady = false;
|
||||
_errorText = 'Voice receive canceled';
|
||||
});
|
||||
}
|
||||
|
||||
void _showToast(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -16,6 +16,7 @@ import geolocator_apple
|
||||
import nsd_macos
|
||||
import objectbox_flutter_libs
|
||||
import package_info_plus
|
||||
import path_provider_foundation
|
||||
import record_macos
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
@@ -34,6 +35,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
||||
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
70
pubspec.lock
70
pubspec.lock
@@ -129,14 +129,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
codec2_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -243,7 +235,7 @@ packages:
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
@@ -646,14 +638,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
gsettings:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -662,14 +646,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.8"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
http:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -846,14 +822,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -883,7 +851,7 @@ packages:
|
||||
description:
|
||||
path: "."
|
||||
ref: main
|
||||
resolved-ref: d6f91774f19136ff71b0087feaf95fa5490524d9
|
||||
resolved-ref: "3f870e98ee9527a3137bfcbdd1454036912fb609"
|
||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
@@ -911,14 +879,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.4"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -991,14 +951,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.1"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1040,13 +992,13 @@ packages:
|
||||
source: hosted
|
||||
version: "2.2.22"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
version: "2.5.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1183,14 +1135,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.5+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1702,5 +1646,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.10.3 <4.0.0"
|
||||
flutter: ">=3.38.4"
|
||||
dart: ">=3.10.0 <4.0.0"
|
||||
flutter: ">=3.38.0"
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 2026.0305.2+8
|
||||
version: 2026.0306.2+12
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
@@ -144,6 +144,10 @@ dev_dependencies:
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^6.0.0
|
||||
flutter_launcher_icons: "^0.14.4"
|
||||
fake_async: ^1.3.3
|
||||
|
||||
dependency_overrides:
|
||||
path_provider_foundation: 2.5.1
|
||||
|
||||
flutter_launcher_icons:
|
||||
android: "launcher_icon"
|
||||
|
||||
@@ -194,5 +194,43 @@ void main() {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('builds message snapshot from latest valid telemetry', () {
|
||||
final telemetryData = CayenneLppParser.createGpsData(
|
||||
latitude: 45.0001,
|
||||
longitude: 13.9999,
|
||||
);
|
||||
|
||||
provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData);
|
||||
final contact = provider.findContactByKey(publicKey)!;
|
||||
final snapshot = provider.buildMessageContactLocationSnapshot(
|
||||
contact,
|
||||
capturedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
expect(snapshot, isNotNull);
|
||||
expect(snapshot!.source, equals('telemetry'));
|
||||
expect(snapshot.location.latitude, closeTo(45.0001, 0.0001));
|
||||
expect(snapshot.location.longitude, closeTo(13.9999, 0.0001));
|
||||
});
|
||||
|
||||
test('builds message snapshot from advert when telemetry is invalid', () {
|
||||
final invalidTelemetry = CayenneLppParser.createGpsData(
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
);
|
||||
|
||||
provider.updateTelemetry(publicKey.sublist(0, 6), invalidTelemetry);
|
||||
final contact = provider.findContactByKey(publicKey)!;
|
||||
final snapshot = provider.buildMessageContactLocationSnapshot(
|
||||
contact,
|
||||
capturedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
expect(snapshot, isNotNull);
|
||||
expect(snapshot!.source, equals('advert'));
|
||||
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
|
||||
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
37
test/providers/helpers/fragment_ack_wait_registry_test.dart
Normal file
37
test/providers/helpers/fragment_ack_wait_registry_test.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/helpers/fragment_ack_wait_registry.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('FragmentAckWaitRegistry', () {
|
||||
test('completes multiple waiters registered for the same key', () async {
|
||||
final registry = FragmentAckWaitRegistry();
|
||||
|
||||
final first = registry.waitFor(
|
||||
'voice:1',
|
||||
timeout: const Duration(milliseconds: 200),
|
||||
);
|
||||
final second = registry.waitFor(
|
||||
'voice:1',
|
||||
timeout: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
expect(registry.complete('voice:1'), equals(2));
|
||||
expect(await first, isTrue);
|
||||
expect(await second, isTrue);
|
||||
});
|
||||
|
||||
test('times out and cleans up a waiter when no ack arrives', () async {
|
||||
final registry = FragmentAckWaitRegistry();
|
||||
|
||||
final completed = await registry.waitFor(
|
||||
'voice:2',
|
||||
timeout: const Duration(milliseconds: 20),
|
||||
);
|
||||
|
||||
expect(completed, isFalse);
|
||||
expect(registry.complete('voice:2'), equals(0));
|
||||
});
|
||||
});
|
||||
}
|
||||
37
test/providers/helpers/message_delivery_tracker_test.dart
Normal file
37
test/providers/helpers/message_delivery_tracker_test.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/helpers/message_delivery_tracker.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('MessageDeliveryTracker', () {
|
||||
test('matches pending direct messages by contact', () {
|
||||
final tracker = MessageDeliveryTracker();
|
||||
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
|
||||
final bob = Uint8List.fromList(List<int>.filled(32, 0xBB));
|
||||
|
||||
tracker.trackPendingDirectMessage('alice-1', alice);
|
||||
tracker.trackPendingDirectMessage('bob-1', bob);
|
||||
tracker.trackPendingDirectMessage('alice-2', alice);
|
||||
|
||||
expect(tracker.popPendingDirectMessageId(alice), 'alice-1');
|
||||
expect(tracker.popPendingDirectMessageId(bob), 'bob-1');
|
||||
expect(tracker.popPendingDirectMessageId(alice), 'alice-2');
|
||||
});
|
||||
|
||||
test('removeByMessageId clears pending queue state', () {
|
||||
final tracker = MessageDeliveryTracker();
|
||||
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
|
||||
|
||||
tracker.trackPendingDirectMessage('alice-1', alice);
|
||||
tracker.mapAckTagToMessageId(42, 'alice-1');
|
||||
|
||||
tracker.removeByMessageId('alice-1');
|
||||
|
||||
expect(tracker.getMessageIdForAck(42), isNull);
|
||||
expect(tracker.popPendingDirectMessageId(alice), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
86
test/providers/helpers/session_metadata_restore_test.dart
Normal file
86
test/providers/helpers/session_metadata_restore_test.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/helpers/session_metadata_restore.dart';
|
||||
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||
|
||||
void main() {
|
||||
group('restoreSessionMetadataFromMessages', () {
|
||||
test(
|
||||
'restores voice and image session senders from persisted envelopes',
|
||||
() {
|
||||
final voiceEnvelope = VoiceEnvelope(
|
||||
sessionId: '00112233',
|
||||
mode: VoicePacketMode.mode1200,
|
||||
total: 4,
|
||||
durationMs: 4000,
|
||||
senderKey6: 'AABBCCDDEEFF',
|
||||
timestampSec: 123456,
|
||||
);
|
||||
final imageEnvelope = ImageEnvelope(
|
||||
sessionId: '195cb2fb',
|
||||
format: ImageFormat.avif,
|
||||
total: 7,
|
||||
width: 118,
|
||||
height: 256,
|
||||
sizeBytes: 1069,
|
||||
senderKey6: 'FE8B30EE05FC',
|
||||
timestampSec: 123457,
|
||||
);
|
||||
|
||||
final restored = restoreSessionMetadataFromMessages([
|
||||
'plain text',
|
||||
voiceEnvelope.encodeText(),
|
||||
imageEnvelope.encode(),
|
||||
]);
|
||||
|
||||
expect(
|
||||
restored.voiceSenderKeyBySession,
|
||||
equals({'00112233': 'aabbccddeeff'}),
|
||||
);
|
||||
expect(restored.imageEnvelopeBySession.keys, equals({'195cb2fb'}));
|
||||
expect(
|
||||
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6,
|
||||
equals('fe8b30ee05fc'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps latest envelope when a session appears multiple times', () {
|
||||
final first = ImageEnvelope(
|
||||
sessionId: '195cb2fb',
|
||||
format: ImageFormat.avif,
|
||||
total: 7,
|
||||
width: 100,
|
||||
height: 100,
|
||||
sizeBytes: 900,
|
||||
senderKey6: '001122334455',
|
||||
timestampSec: 100,
|
||||
);
|
||||
final second = ImageEnvelope(
|
||||
sessionId: '195cb2fb',
|
||||
format: ImageFormat.jpeg,
|
||||
total: 8,
|
||||
width: 118,
|
||||
height: 256,
|
||||
sizeBytes: 1069,
|
||||
senderKey6: 'AABBCCDDEEFF',
|
||||
timestampSec: 101,
|
||||
);
|
||||
|
||||
final restored = restoreSessionMetadataFromMessages([
|
||||
first.encode(),
|
||||
second.encode(),
|
||||
]);
|
||||
|
||||
expect(restored.imageEnvelopeBySession.length, equals(1));
|
||||
expect(
|
||||
restored.imageEnvelopeBySession['195cb2fb']?.senderKey6,
|
||||
equals('aabbccddeeff'),
|
||||
);
|
||||
expect(
|
||||
restored.imageEnvelopeBySession['195cb2fb']?.format,
|
||||
equals(ImageFormat.jpeg),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
52
test/providers/image_provider_cancel_test.dart
Normal file
52
test/providers/image_provider_cancel_test.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/providers/image_provider.dart';
|
||||
import 'package:meshcore_sar_app/utils/image_message_parser.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
group('ImageProvider cancel receive', () {
|
||||
test('ignores incoming fragments after cancel until resumed', () {
|
||||
final provider = ImageProvider();
|
||||
const sessionId = '01020304';
|
||||
const envelope = ImageEnvelope(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.avif,
|
||||
total: 2,
|
||||
width: 32,
|
||||
height: 32,
|
||||
sizeBytes: 4,
|
||||
senderKey6: 'aabbccddeeff',
|
||||
timestampSec: 1700000000,
|
||||
);
|
||||
final fragment = ImagePacket(
|
||||
sessionId: sessionId,
|
||||
format: ImageFormat.avif,
|
||||
index: 0,
|
||||
total: 2,
|
||||
data: Uint8List.fromList([1, 2]),
|
||||
);
|
||||
|
||||
provider.registerEnvelope(envelope);
|
||||
provider.cancelIncomingSession(sessionId);
|
||||
|
||||
expect(provider.isReceiveCanceled(sessionId), isTrue);
|
||||
expect(provider.session(sessionId), isNull);
|
||||
|
||||
provider.addFragment(fragment, width: 32, height: 32);
|
||||
|
||||
expect(provider.session(sessionId), isNull);
|
||||
|
||||
provider.resumeIncomingSession(sessionId);
|
||||
provider.registerEnvelope(envelope);
|
||||
provider.addFragment(fragment, width: 32, height: 32);
|
||||
|
||||
expect(provider.isReceiveCanceled(sessionId), isFalse);
|
||||
expect(provider.session(sessionId)?.receivedCount, equals(1));
|
||||
});
|
||||
});
|
||||
}
|
||||
236
test/providers/messages_provider_retransmission_test.dart
Normal file
236
test/providers/messages_provider_retransmission_test.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/models/message.dart';
|
||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||
|
||||
Contact _buildContact() {
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||
type: ContactType.chat,
|
||||
flags: 0,
|
||||
outPathLen: 1,
|
||||
outPath: Uint8List.fromList([1, 2, 3, 4]),
|
||||
advName: 'Teammate',
|
||||
lastAdvert: 1700000000,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: 1700000000,
|
||||
);
|
||||
}
|
||||
|
||||
Message _buildDirectMessage(String id) {
|
||||
return Message(
|
||||
id: id,
|
||||
messageType: MessageType.contact,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'hello',
|
||||
receivedAt: DateTime.now(),
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
recipientPublicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('MessagesProvider retransmission', () {
|
||||
test('direct messages stay pending until delivery ACK arrives', () {
|
||||
final provider = MessagesProvider();
|
||||
provider.addSentMessage(
|
||||
_buildDirectMessage('m1'),
|
||||
contact: _buildContact(),
|
||||
);
|
||||
|
||||
provider.markMessageSent('m1', 77, 250);
|
||||
|
||||
expect(
|
||||
provider.messages.single.deliveryStatus,
|
||||
MessageDeliveryStatus.sending,
|
||||
);
|
||||
expect(provider.messages.single.expectedAckTag, 77);
|
||||
|
||||
provider.markMessageDelivered(77, 180);
|
||||
|
||||
expect(
|
||||
provider.messages.single.deliveryStatus,
|
||||
MessageDeliveryStatus.delivered,
|
||||
);
|
||||
expect(provider.messages.single.roundTripTimeMs, 180);
|
||||
});
|
||||
|
||||
test('channel messages are marked sent immediately', () {
|
||||
final provider = MessagesProvider();
|
||||
provider.addSentMessage(
|
||||
Message(
|
||||
id: 'c1',
|
||||
messageType: MessageType.channel,
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
channelIdx: 0,
|
||||
pathLen: 0,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000000,
|
||||
text: 'broadcast',
|
||||
receivedAt: DateTime.now(),
|
||||
deliveryStatus: MessageDeliveryStatus.sending,
|
||||
),
|
||||
);
|
||||
|
||||
provider.markMessageSent('c1', 0, 0);
|
||||
|
||||
expect(
|
||||
provider.messages.single.deliveryStatus,
|
||||
MessageDeliveryStatus.sent,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing ACK schedules a delayed retransmission', () {
|
||||
fakeAsync((async) {
|
||||
final provider = MessagesProvider();
|
||||
var retryCalls = 0;
|
||||
provider.sendMessageCallback =
|
||||
({
|
||||
required contactPublicKey,
|
||||
required text,
|
||||
required messageId,
|
||||
required contact,
|
||||
retryAttempt = 0,
|
||||
}) async {
|
||||
retryCalls += 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
provider.addSentMessage(
|
||||
_buildDirectMessage('m2'),
|
||||
contact: _buildContact(),
|
||||
);
|
||||
provider.markMessageSent('m2', 88, 10);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 11));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(provider.messages.single.retryAttempt, 1);
|
||||
expect(
|
||||
provider.messages.single.deliveryStatus,
|
||||
MessageDeliveryStatus.sending,
|
||||
);
|
||||
expect(retryCalls, 0);
|
||||
|
||||
async.elapse(const Duration(seconds: 4));
|
||||
async.flushMicrotasks();
|
||||
|
||||
expect(retryCalls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test('uses calculated timeout when radio timeout is missing', () {
|
||||
final provider = MessagesProvider();
|
||||
provider.addSentMessage(
|
||||
_buildDirectMessage('m3'),
|
||||
contact: _buildContact(),
|
||||
);
|
||||
|
||||
provider.markMessageSent('m3', 99, 0);
|
||||
|
||||
expect(provider.messages.single.suggestedTimeoutMs, isNotNull);
|
||||
expect(
|
||||
provider.messages.single.suggestedTimeoutMs!,
|
||||
greaterThanOrEqualTo(4000),
|
||||
);
|
||||
});
|
||||
|
||||
test('older retry ack still marks message delivered', () {
|
||||
final provider = MessagesProvider();
|
||||
provider.addSentMessage(
|
||||
_buildDirectMessage('m4'),
|
||||
contact: _buildContact(),
|
||||
);
|
||||
|
||||
provider.markMessageSent('m4', 111, 10);
|
||||
provider.markMessageSent('m4', 112, 10);
|
||||
provider.markMessageDelivered(111, 220);
|
||||
|
||||
expect(
|
||||
provider.messages.single.deliveryStatus,
|
||||
MessageDeliveryStatus.delivered,
|
||||
);
|
||||
expect(provider.messages.single.roundTripTimeMs, 220);
|
||||
});
|
||||
|
||||
test('repeated max-retry failures request path reset', () async {
|
||||
final provider = MessagesProvider();
|
||||
final contact = _buildContact();
|
||||
final resetRequests = <(String, int)>[];
|
||||
provider.onDirectPathFailedCallback =
|
||||
({required contact, required failureStreak}) async {
|
||||
resetRequests.add((contact.advName, failureStreak));
|
||||
};
|
||||
|
||||
provider.addSentMessage(
|
||||
_buildDirectMessage('m5').copyWith(
|
||||
retryAttempt: 3,
|
||||
usedFloodFallback: true,
|
||||
),
|
||||
contact: contact,
|
||||
);
|
||||
provider.markMessageFailed('m5');
|
||||
|
||||
provider.addSentMessage(
|
||||
_buildDirectMessage('m6').copyWith(
|
||||
retryAttempt: 3,
|
||||
usedFloodFallback: true,
|
||||
),
|
||||
contact: contact,
|
||||
);
|
||||
provider.markMessageFailed('m6');
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(resetRequests, [('Teammate', 2)]);
|
||||
});
|
||||
|
||||
test('successful delivery clears path failure streak', () async {
|
||||
final provider = MessagesProvider();
|
||||
final contact = _buildContact();
|
||||
final resetRequests = <int>[];
|
||||
provider.onDirectPathFailedCallback =
|
||||
({required contact, required failureStreak}) async {
|
||||
resetRequests.add(failureStreak);
|
||||
};
|
||||
|
||||
provider.addSentMessage(
|
||||
_buildDirectMessage('m7').copyWith(
|
||||
retryAttempt: 3,
|
||||
usedFloodFallback: true,
|
||||
),
|
||||
contact: contact,
|
||||
);
|
||||
provider.markMessageFailed('m7');
|
||||
|
||||
provider.addSentMessage(
|
||||
_buildDirectMessage('m8'),
|
||||
contact: contact,
|
||||
);
|
||||
provider.markMessageSent('m8', 123, 10);
|
||||
provider.markMessageDelivered(123, 150);
|
||||
|
||||
provider.addSentMessage(
|
||||
_buildDirectMessage('m9').copyWith(
|
||||
retryAttempt: 3,
|
||||
usedFloodFallback: true,
|
||||
),
|
||||
contact: contact,
|
||||
);
|
||||
provider.markMessageFailed('m9');
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(resetRequests, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/message.dart';
|
||||
import 'package:meshcore_sar_app/models/message_contact_location.dart';
|
||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||
import 'package:meshcore_sar_app/utils/voice_message_parser.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('MessagesProvider voice detection', () {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('marks VE2 envelope messages as voice', () {
|
||||
final provider = MessagesProvider();
|
||||
final envelope = VoiceEnvelope(
|
||||
@@ -65,5 +72,39 @@ void main() {
|
||||
expect(stored.isVoice, isTrue);
|
||||
expect(stored.voiceId, equals('00112233'));
|
||||
});
|
||||
|
||||
test('persists received contact location snapshots', () async {
|
||||
final provider = MessagesProvider();
|
||||
final message = Message(
|
||||
id: 'm3',
|
||||
messageType: MessageType.contact,
|
||||
pathLen: 1,
|
||||
textType: MessageTextType.plain,
|
||||
senderTimestamp: 1700000002,
|
||||
text: 'status update',
|
||||
receivedAt: DateTime.now(),
|
||||
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
|
||||
);
|
||||
|
||||
provider.addMessage(
|
||||
message,
|
||||
contactLocationSnapshot: MessageContactLocation(
|
||||
location: const LatLng(46.0569, 14.5058),
|
||||
source: 'advert',
|
||||
capturedAt: DateTime.now(),
|
||||
sourceTimestamp: DateTime.now(),
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
final restoredProvider = MessagesProvider();
|
||||
await restoredProvider.initialize();
|
||||
final snapshot = restoredProvider.getMessageContactLocation('m3');
|
||||
|
||||
expect(snapshot, isNotNull);
|
||||
expect(snapshot!.source, equals('advert'));
|
||||
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
|
||||
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -410,6 +410,24 @@ void main() {
|
||||
expect(decoded.batteryMilliVolts, closeTo(3850, 1));
|
||||
});
|
||||
|
||||
test('stops parsing at zero-padded telemetry tail', () {
|
||||
final payload = Uint8List.fromList([
|
||||
0x01, 0x74, 0x01, 0x5F, // voltage 3.51V
|
||||
0x01, 0x88, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00,
|
||||
]);
|
||||
|
||||
final decoded = CayenneLppParser.parse(payload);
|
||||
|
||||
expect(decoded.batteryMilliVolts, closeTo(3510, 1));
|
||||
expect(decoded.batteryPercentage, closeTo(42.5, 0.1));
|
||||
expect(decoded.gpsLocation, isNotNull);
|
||||
expect(decoded.gpsLocation!.latitude, 0.0);
|
||||
expect(decoded.gpsLocation!.longitude, 0.0);
|
||||
expect(decoded.extraSensorData?['digital_input_0'], isNull);
|
||||
});
|
||||
|
||||
test('empty data returns empty telemetry', () {
|
||||
final empty = Uint8List(0);
|
||||
final decoded = CayenneLppParser.parse(empty);
|
||||
|
||||
26
test/utils/avatar_label_helper_test.dart
Normal file
26
test/utils/avatar_label_helper_test.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/utils/avatar_label_helper.dart';
|
||||
|
||||
void main() {
|
||||
group('AvatarLabelHelper.buildLabel', () {
|
||||
test('returns two initials for multi-word names', () {
|
||||
expect(AvatarLabelHelper.buildLabel('John Smith'), 'JS');
|
||||
});
|
||||
|
||||
test('returns first two characters for single-word names', () {
|
||||
expect(AvatarLabelHelper.buildLabel('Alpha'), 'AL');
|
||||
});
|
||||
|
||||
test('allows three compact characters for hash-prefixed names', () {
|
||||
expect(AvatarLabelHelper.buildLabel('#ops'), '#OP');
|
||||
});
|
||||
|
||||
test('removes spacing after hash-prefixed names', () {
|
||||
expect(AvatarLabelHelper.buildLabel('# foo alpha'), '#FO');
|
||||
});
|
||||
|
||||
test('keeps non-hash labels at two characters', () {
|
||||
expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -113,4 +113,17 @@ void main() {
|
||||
expect(parsed.index, equals(9));
|
||||
});
|
||||
});
|
||||
|
||||
group('safeImageDataBytesForPath', () {
|
||||
test('caps direct-route fragments to conservative default size', () {
|
||||
expect(safeImageDataBytesForPath(0), equals(ImagePacket.maxDataBytes));
|
||||
});
|
||||
|
||||
test('shrinks for longer paths but never exceeds conservative default', () {
|
||||
expect(
|
||||
safeImageDataBytesForPath(2),
|
||||
lessThanOrEqualTo(ImagePacket.maxDataBytes),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
106
test/widgets/contact_avatar_test.dart
Normal file
106
test/widgets/contact_avatar_test.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_sar_app/models/contact.dart';
|
||||
import 'package:meshcore_sar_app/widgets/common/contact_avatar.dart';
|
||||
|
||||
void main() {
|
||||
Contact buildContact({
|
||||
required String name,
|
||||
required ContactType type,
|
||||
int secondByte = 0,
|
||||
}) {
|
||||
final publicKey = Uint8List(32);
|
||||
publicKey[1] = secondByte;
|
||||
|
||||
return Contact(
|
||||
publicKey: publicKey,
|
||||
type: type,
|
||||
flags: 0,
|
||||
outPathLen: 0,
|
||||
outPath: Uint8List(0),
|
||||
advName: name,
|
||||
lastAdvert: 0,
|
||||
advLat: 0,
|
||||
advLon: 0,
|
||||
lastMod: 0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> pumpAvatar(WidgetTester tester, Contact contact) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(body: Center(child: ContactAvatar(contact: contact))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders label avatar for rooms', (tester) async {
|
||||
await pumpAvatar(
|
||||
tester,
|
||||
buildContact(name: 'Operations Room', type: ContactType.room),
|
||||
);
|
||||
|
||||
expect(find.text('OR'), findsOneWidget);
|
||||
expect(find.byType(CircleAvatar), findsNothing);
|
||||
expect(find.byIcon(Icons.meeting_room), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders hash label avatar for rooms', (tester) async {
|
||||
await pumpAvatar(
|
||||
tester,
|
||||
buildContact(name: '#ops-room', type: ContactType.room),
|
||||
);
|
||||
|
||||
expect(find.text('#OP'), findsOneWidget);
|
||||
expect(find.byType(CircleAvatar), findsNothing);
|
||||
expect(find.byIcon(Icons.meeting_room), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders compact hash label avatar when name includes spacing', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpAvatar(
|
||||
tester,
|
||||
buildContact(name: '# foo alpha', type: ContactType.room),
|
||||
);
|
||||
|
||||
expect(find.text('#FO'), findsOneWidget);
|
||||
expect(find.byType(CircleAvatar), findsNothing);
|
||||
expect(find.byIcon(Icons.meeting_room), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders label avatar for channels', (tester) async {
|
||||
await pumpAvatar(
|
||||
tester,
|
||||
buildContact(name: '#ops', type: ContactType.channel, secondByte: 3),
|
||||
);
|
||||
|
||||
expect(find.text('#OP'), findsOneWidget);
|
||||
expect(find.byType(CircleAvatar), findsNothing);
|
||||
expect(find.byIcon(Icons.public), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders non-hash label avatar for channels', (tester) async {
|
||||
await pumpAvatar(
|
||||
tester,
|
||||
buildContact(name: 'Command Net', type: ContactType.channel, secondByte: 3),
|
||||
);
|
||||
|
||||
expect(find.text('CN'), findsOneWidget);
|
||||
expect(find.byType(CircleAvatar), findsNothing);
|
||||
expect(find.byIcon(Icons.public), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders round avatar for chat contacts', (tester) async {
|
||||
await pumpAvatar(
|
||||
tester,
|
||||
buildContact(name: 'John Smith', type: ContactType.chat),
|
||||
);
|
||||
|
||||
expect(find.text('JS'), findsOneWidget);
|
||||
expect(find.byType(CircleAvatar), findsOneWidget);
|
||||
expect(find.byIcon(Icons.person), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user