mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
12 Commits
v2026.0306
...
v2026.0306
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70a6156e8a | ||
|
|
1856dce27a | ||
|
|
485ae995c3 | ||
|
|
0e1a56d765 | ||
|
|
ca8e8e6ecb | ||
|
|
43f5702666 | ||
|
|
85d0b26c67 | ||
|
|
7cd5351921 | ||
|
|
96feb75712 | ||
|
|
f6779e8776 | ||
|
|
a9d062dc34 | ||
|
|
3680b38302 |
@@ -39,5 +39,13 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_ios_build_settings(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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ PODS:
|
|||||||
- ObjectBox (= 4.4.1)
|
- ObjectBox (= 4.4.1)
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- path_provider_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- permission_handler_apple (9.3.0):
|
- permission_handler_apple (9.3.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- record_ios (1.2.0):
|
- record_ios (1.2.0):
|
||||||
@@ -102,6 +105,7 @@ DEPENDENCIES:
|
|||||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||||
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/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`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
@@ -149,6 +153,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
|
path_provider_foundation:
|
||||||
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
record_ios:
|
record_ios:
|
||||||
@@ -183,6 +189,7 @@ SPEC CHECKSUMS:
|
|||||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
||||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
|
||||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||||
@@ -193,6 +200,6 @@ SPEC CHECKSUMS:
|
|||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -489,7 +489,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 94;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 94;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 94;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 94;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 94;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 84;
|
CURRENT_PROJECT_VERSION = 94;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>84</string>
|
<string>94</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000555">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.408783">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.42343">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="120.492323">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="91.987634">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="12.544865">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="184.536223">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -75,10 +75,12 @@ class DeviceInfo {
|
|||||||
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
|
||||||
final int? maxContacts; // Max contacts device supports
|
final int? maxContacts; // Max contacts device supports
|
||||||
final int? maxChannels; // Max channels 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?
|
||||||
|
telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
|
||||||
final int? blePin; // BLE PIN code
|
final int? blePin; // BLE PIN code
|
||||||
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
|
||||||
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
final int?
|
||||||
|
advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
|
||||||
|
|
||||||
// Firmware info
|
// Firmware info
|
||||||
final int? firmwareVersion;
|
final int? firmwareVersion;
|
||||||
@@ -88,6 +90,9 @@ class DeviceInfo {
|
|||||||
|
|
||||||
// Repeat mode (firmware v9+)
|
// Repeat mode (firmware v9+)
|
||||||
final bool? clientRepeat;
|
final bool? clientRepeat;
|
||||||
|
final bool? supportsSpectrumScan;
|
||||||
|
final int? spectrumScanMinKhz;
|
||||||
|
final int? spectrumScanMaxKhz;
|
||||||
final List<({int lower, int upper})>? allowedRepeatFreqRanges;
|
final List<({int lower, int upper})>? allowedRepeatFreqRanges;
|
||||||
|
|
||||||
DeviceInfo({
|
DeviceInfo({
|
||||||
@@ -124,6 +129,9 @@ class DeviceInfo {
|
|||||||
this.manufacturerModel,
|
this.manufacturerModel,
|
||||||
this.semanticVersion,
|
this.semanticVersion,
|
||||||
this.clientRepeat,
|
this.clientRepeat,
|
||||||
|
this.supportsSpectrumScan,
|
||||||
|
this.spectrumScanMinKhz,
|
||||||
|
this.spectrumScanMaxKhz,
|
||||||
this.allowedRepeatFreqRanges,
|
this.allowedRepeatFreqRanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +168,9 @@ class DeviceInfo {
|
|||||||
|
|
||||||
/// Get storage usage percentage (0-100)
|
/// Get storage usage percentage (0-100)
|
||||||
double? get storageUsedPercent {
|
double? get storageUsedPercent {
|
||||||
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) {
|
if (storageUsedKb == null ||
|
||||||
|
storageTotalKb == null ||
|
||||||
|
storageTotalKb == 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return (storageUsedKb! / storageTotalKb!) * 100.0;
|
return (storageUsedKb! / storageTotalKb!) * 100.0;
|
||||||
@@ -245,6 +255,9 @@ class DeviceInfo {
|
|||||||
String? manufacturerModel,
|
String? manufacturerModel,
|
||||||
String? semanticVersion,
|
String? semanticVersion,
|
||||||
bool? clientRepeat,
|
bool? clientRepeat,
|
||||||
|
bool? supportsSpectrumScan,
|
||||||
|
int? spectrumScanMinKhz,
|
||||||
|
int? spectrumScanMaxKhz,
|
||||||
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
||||||
}) {
|
}) {
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
@@ -281,7 +294,11 @@ class DeviceInfo {
|
|||||||
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
|
||||||
semanticVersion: semanticVersion ?? this.semanticVersion,
|
semanticVersion: semanticVersion ?? this.semanticVersion,
|
||||||
clientRepeat: clientRepeat ?? this.clientRepeat,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -607,14 +607,30 @@ class AppProvider with ChangeNotifier {
|
|||||||
connectionProvider.onMessageReceived = (message) {
|
connectionProvider.onMessageReceived = (message) {
|
||||||
// Enrich message with sender name from contacts first
|
// Enrich message with sender name from contacts first
|
||||||
Message enrichedMessage = message;
|
Message enrichedMessage = message;
|
||||||
|
Contact? senderContact;
|
||||||
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
|
if (message.senderPublicKeyPrefix != null && message.senderName == null) {
|
||||||
final contact = contactsProvider.findContactByKey(
|
final contact = contactsProvider.findContactByKey(
|
||||||
message.senderPublicKeyPrefix!,
|
message.senderPublicKeyPrefix!,
|
||||||
);
|
);
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
|
senderContact = contact;
|
||||||
enrichedMessage = message.copyWith(senderName: contact.advName);
|
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
|
// Check if message is a drawing broadcast
|
||||||
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
if (DrawingMessageParser.isDrawingMessage(enrichedMessage.text)) {
|
||||||
@@ -645,6 +661,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
messagesProvider.addMessage(
|
messagesProvider.addMessage(
|
||||||
updatedMessage,
|
updatedMessage,
|
||||||
contactLookup: (name) => '',
|
contactLookup: (name) => '',
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast drawing message to SSE clients if server is running
|
// Broadcast drawing message to SSE clients if server is running
|
||||||
@@ -680,6 +697,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||||
return;
|
return;
|
||||||
@@ -707,6 +725,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
connectionProvider.broadcastMessageToSseClients(enrichedMessage);
|
||||||
return;
|
return;
|
||||||
@@ -743,6 +762,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
contactLocationSnapshot: contactLocationSnapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast message to SSE clients if server is running
|
// Broadcast message to SSE clients if server is running
|
||||||
@@ -1021,6 +1041,32 @@ class AppProvider with ChangeNotifier {
|
|||||||
retryAttempt: retryAttempt,
|
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.)
|
/// Initialize the app (load contacts, sync time, etc.)
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
bool _isScanning = false;
|
bool _isScanning = false;
|
||||||
bool get isScanning => _isScanning;
|
bool get isScanning => _isScanning;
|
||||||
|
bool _isSpectrumScanActive = false;
|
||||||
|
bool get isSpectrumScanActive => _isSpectrumScanActive;
|
||||||
|
|
||||||
String? _error;
|
String? _error;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
@@ -247,7 +249,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
await Future.delayed(const Duration(milliseconds: 300));
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
if (pendingOp.messageId != null) {
|
if (pendingOp.messageId != null) {
|
||||||
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!);
|
_messageDeliveryTracker.trackPendingDirectMessage(
|
||||||
|
pendingOp.messageId!,
|
||||||
|
pendingOp.contactPublicKey,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _activeService.sendTextMessage(
|
await _activeService.sendTextMessage(
|
||||||
@@ -312,6 +317,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
};
|
};
|
||||||
|
|
||||||
service.onMessageWaiting = () {
|
service.onMessageWaiting = () {
|
||||||
|
if (_isSpectrumScanActive) {
|
||||||
|
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
|
||||||
|
return;
|
||||||
|
}
|
||||||
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
_syncRequestedWhileBusy = true;
|
_syncRequestedWhileBusy = true;
|
||||||
@@ -346,7 +355,11 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
service.onMessageSent =
|
service.onMessageSent =
|
||||||
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
|
||||||
final messageId = _messageDeliveryTracker.popPendingMessageId();
|
final messageId = contactPublicKey != null
|
||||||
|
? _messageDeliveryTracker.popPendingDirectMessageId(
|
||||||
|
contactPublicKey,
|
||||||
|
)
|
||||||
|
: _messageDeliveryTracker.popPendingMessageId();
|
||||||
if (messageId != null) {
|
if (messageId != null) {
|
||||||
_messageDeliveryTracker.mapAckTagToMessageId(
|
_messageDeliveryTracker.mapAckTagToMessageId(
|
||||||
expectedAckTag,
|
expectedAckTag,
|
||||||
@@ -379,6 +392,9 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
manufacturerModel: deviceInfo['manufacturerModel'] as String?,
|
manufacturerModel: deviceInfo['manufacturerModel'] as String?,
|
||||||
semanticVersion: deviceInfo['semanticVersion'] as String?,
|
semanticVersion: deviceInfo['semanticVersion'] as String?,
|
||||||
clientRepeat: deviceInfo['clientRepeat'] as bool?,
|
clientRepeat: deviceInfo['clientRepeat'] as bool?,
|
||||||
|
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
|
||||||
|
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
|
||||||
|
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
if (_sseServer.isRunning) {
|
if (_sseServer.isRunning) {
|
||||||
@@ -1139,7 +1155,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
|
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
|
||||||
// We still track here for the SENT response callback to work.
|
// We still track here for the SENT response callback to work.
|
||||||
if (messageId != null) {
|
if (messageId != null) {
|
||||||
_messageDeliveryTracker.trackPendingMessage(messageId);
|
_messageDeliveryTracker.trackPendingDirectMessage(
|
||||||
|
messageId,
|
||||||
|
contactPublicKey,
|
||||||
|
);
|
||||||
debugPrint(' 📝 Tracked pending message: $messageId');
|
debugPrint(' 📝 Tracked pending message: $messageId');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1555,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
|
/// Set transmit power
|
||||||
Future<void> setTxPower(int powerDbm) async {
|
Future<void> setTxPower(int powerDbm) async {
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
@@ -1599,6 +1655,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Request fresh device info (triggers SelfInfo response)
|
/// Request fresh device info (triggers SelfInfo response)
|
||||||
Future<void> refreshDeviceInfo() async {
|
Future<void> refreshDeviceInfo() async {
|
||||||
|
if (_isSpectrumScanActive) return;
|
||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
_error = 'Not connected to device';
|
_error = 'Not connected to device';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1642,6 +1699,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// Sync messages from device queue
|
/// Sync messages from device queue
|
||||||
/// Call this repeatedly until no more messages are available
|
/// Call this repeatedly until no more messages are available
|
||||||
Future<bool> syncNextMessage() async {
|
Future<bool> syncNextMessage() async {
|
||||||
|
if (_isSpectrumScanActive) return false;
|
||||||
// Prevent re-entrancy and too-fast triggers
|
// Prevent re-entrancy and too-fast triggers
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
// Another sync (single or loop) is in progress
|
// Another sync (single or loop) is in progress
|
||||||
@@ -1680,6 +1738,10 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Sync all waiting messages from device
|
/// Sync all waiting messages from device
|
||||||
Future<int> syncAllMessages() async {
|
Future<int> syncAllMessages() async {
|
||||||
|
if (_isSpectrumScanActive) {
|
||||||
|
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (_isSyncingMessages) {
|
if (_isSyncingMessages) {
|
||||||
// Already syncing; avoid overlapping loops
|
// Already syncing; avoid overlapping loops
|
||||||
_syncRequestedWhileBusy = true;
|
_syncRequestedWhileBusy = true;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../services/contact_storage_service.dart';
|
import '../services/contact_storage_service.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
@@ -214,6 +215,52 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
List<Contact> get chatContactsWithLocation =>
|
List<Contact> get chatContactsWithLocation =>
|
||||||
chatContacts.where((c) => c.displayLocation != null).toList();
|
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)
|
/// Sort contacts by last seen (most recent first)
|
||||||
int _sortByLastSeen(Contact a, Contact b) {
|
int _sortByLastSeen(Contact a, Contact b) {
|
||||||
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
return b.lastSeenTime.compareTo(a.lastSeenTime);
|
||||||
@@ -470,6 +517,22 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return _contacts[keyHex];
|
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).
|
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
|
||||||
/// Excludes self key and existing contacts.
|
/// Excludes self key and existing contacts.
|
||||||
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
/// Message delivery tracking helper
|
/// Message delivery tracking helper
|
||||||
///
|
///
|
||||||
/// Manages message delivery tracking for sent messages, including:
|
/// 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
|
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
|
||||||
final List<String> _pendingMessageIds = [];
|
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
|
/// Map of ACK tag to message ID for delivery confirmation
|
||||||
final Map<int, String> _ackTagToMessageId = {};
|
final Map<int, String> _ackTagToMessageId = {};
|
||||||
|
|
||||||
@@ -33,6 +38,15 @@ class MessageDeliveryTracker {
|
|||||||
_pendingMessageIds.add(messageId);
|
_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
|
/// Pop the next pending message ID from FIFO queue
|
||||||
///
|
///
|
||||||
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
|
||||||
@@ -43,6 +57,24 @@ class MessageDeliveryTracker {
|
|||||||
return _pendingMessageIds.removeAt(0);
|
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
|
/// Map ACK tag to message ID after RESP_CODE_SENT received
|
||||||
///
|
///
|
||||||
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
/// Creates bidirectional mapping for efficient cleanup and tracking.
|
||||||
@@ -86,6 +118,17 @@ class MessageDeliveryTracker {
|
|||||||
_ackTagToMessageId.remove(ackTag);
|
_ackTagToMessageId.remove(ackTag);
|
||||||
_ackTagTimestamps.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
|
/// Clean up stale ACK mappings
|
||||||
@@ -114,6 +157,7 @@ class MessageDeliveryTracker {
|
|||||||
/// Clear all tracking state
|
/// Clear all tracking state
|
||||||
void clearTracking() {
|
void clearTracking() {
|
||||||
_pendingMessageIds.clear();
|
_pendingMessageIds.clear();
|
||||||
|
_pendingMessageIdsByContact.clear();
|
||||||
_ackTagToMessageId.clear();
|
_ackTagToMessageId.clear();
|
||||||
_messageIdToAckTag.clear();
|
_messageIdToAckTag.clear();
|
||||||
_ackTagTimestamps.clear();
|
_ackTagTimestamps.clear();
|
||||||
@@ -133,9 +177,7 @@ class MessageDeliveryTracker {
|
|||||||
/// Get oldest pending ACK timestamp (for debugging)
|
/// Get oldest pending ACK timestamp (for debugging)
|
||||||
DateTime? get oldestPendingTimestamp {
|
DateTime? get oldestPendingTimestamp {
|
||||||
if (_ackTagTimestamps.isEmpty) return null;
|
if (_ackTagTimestamps.isEmpty) return null;
|
||||||
return _ackTagTimestamps.values.reduce(
|
return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
|
||||||
(a, b) => a.isBefore(b) ? a : b,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get diagnostic info for debugging
|
/// Get diagnostic info for debugging
|
||||||
@@ -145,6 +187,15 @@ class MessageDeliveryTracker {
|
|||||||
'shouldRateLimit': shouldRateLimit,
|
'shouldRateLimit': shouldRateLimit,
|
||||||
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
|
||||||
'ackTags': _ackTagToMessageId.keys.toList(),
|
'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/message.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
|
||||||
@@ -17,11 +20,18 @@ class MessageRetryManager {
|
|||||||
// Track retry state for each message ID
|
// Track retry state for each message ID
|
||||||
final Map<String, int> _retryAttempts = {};
|
final Map<String, int> _retryAttempts = {};
|
||||||
final Map<String, DateTime> _lastRetryTimes = {};
|
final Map<String, DateTime> _lastRetryTimes = {};
|
||||||
|
final Map<String, int> _pathFailureStreaks = {};
|
||||||
|
|
||||||
// Progressive timeout values in milliseconds
|
// Progressive timeout values in milliseconds
|
||||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
// These are app-level timeouts, separate from firmware's suggested timeout
|
||||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
// Firmware timeout is for ACK arrival, these are for retry attempts
|
||||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
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)
|
/// Get timeout for a specific retry attempt (0-2)
|
||||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
||||||
@@ -32,6 +42,30 @@ class MessageRetryManager {
|
|||||||
return _timeouts[attempt];
|
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
|
/// Check if a message is eligible for retry
|
||||||
///
|
///
|
||||||
/// Returns true if:
|
/// Returns true if:
|
||||||
@@ -87,6 +121,7 @@ class MessageRetryManager {
|
|||||||
void clearAll() {
|
void clearAll() {
|
||||||
_retryAttempts.clear();
|
_retryAttempts.clear();
|
||||||
_lastRetryTimes.clear();
|
_lastRetryTimes.clear();
|
||||||
|
_pathFailureStreaks.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current retry attempt for a message (for debugging)
|
/// Get current retry attempt for a message (for debugging)
|
||||||
@@ -98,4 +133,50 @@ class MessageRetryManager {
|
|||||||
DateTime? getLastRetryTime(String messageId) {
|
DateTime? getLastRetryTime(String messageId) {
|
||||||
return _lastRetryTimes[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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
import '../services/message_storage_service.dart';
|
import '../services/message_storage_service.dart';
|
||||||
@@ -20,6 +21,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
AppLocalizations? _localizations;
|
AppLocalizations? _localizations;
|
||||||
|
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||||
|
|
||||||
// Track pending sent messages by expected ACK/TAG
|
// Track pending sent messages by expected ACK/TAG
|
||||||
final Map<int, Message> _pendingSentMessages = {};
|
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)
|
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
|
||||||
final Map<String, Timer> _timeoutTimers = {};
|
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
|
// Retry management
|
||||||
final MessageRetryManager _retryManager = MessageRetryManager();
|
final MessageRetryManager _retryManager = MessageRetryManager();
|
||||||
|
|
||||||
@@ -65,6 +74,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
})?
|
})?
|
||||||
sendMessageCallback;
|
sendMessageCallback;
|
||||||
|
|
||||||
|
Future<void> Function({
|
||||||
|
required Contact contact,
|
||||||
|
required int failureStreak,
|
||||||
|
})?
|
||||||
|
onDirectPathFailedCallback;
|
||||||
|
|
||||||
List<Message> get messages => List.unmodifiable(_messages);
|
List<Message> get messages => List.unmodifiable(_messages);
|
||||||
|
|
||||||
List<Message> get contactMessages =>
|
List<Message> get contactMessages =>
|
||||||
@@ -97,6 +112,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
String? get targetMessageId => _targetMessageId;
|
String? get targetMessageId => _targetMessageId;
|
||||||
|
|
||||||
|
MessageContactLocation? getMessageContactLocation(String messageId) =>
|
||||||
|
_messageContactLocations[messageId];
|
||||||
|
|
||||||
/// Set localizations for notifications
|
/// Set localizations for notifications
|
||||||
void setLocalizations(AppLocalizations localizations) {
|
void setLocalizations(AppLocalizations localizations) {
|
||||||
_localizations = localizations;
|
_localizations = localizations;
|
||||||
@@ -125,6 +143,11 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
debugPrint('📦 [MessagesProvider] Loading persisted messages...');
|
debugPrint('📦 [MessagesProvider] Loading persisted messages...');
|
||||||
final storedMessages = await _storageService.loadMessages();
|
final storedMessages = await _storageService.loadMessages();
|
||||||
|
final storedContactLocations = await _storageService
|
||||||
|
.loadMessageContactLocations();
|
||||||
|
_messageContactLocations
|
||||||
|
..clear()
|
||||||
|
..addAll(storedContactLocations);
|
||||||
|
|
||||||
// Add stored messages with enhancement to ensure SAR detection
|
// Add stored messages with enhancement to ensure SAR detection
|
||||||
for (final message in storedMessages) {
|
for (final message in storedMessages) {
|
||||||
@@ -287,6 +310,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void addMessage(
|
void addMessage(
|
||||||
Message message, {
|
Message message, {
|
||||||
String Function(String name)? contactLookup,
|
String Function(String name)? contactLookup,
|
||||||
|
MessageContactLocation? contactLocationSnapshot,
|
||||||
}) {
|
}) {
|
||||||
// Always enhance message with SAR parser to detect SAR markers
|
// Always enhance message with SAR parser to detect SAR markers
|
||||||
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
var enhancedMessage = SarMessageParser.enhanceMessage(message);
|
||||||
@@ -377,6 +401,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_messages.add(finalMessage);
|
_messages.add(finalMessage);
|
||||||
|
if (contactLocationSnapshot != null) {
|
||||||
|
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
// If it's a SAR marker message, extract and store the marker
|
// If it's a SAR marker message, extract and store the marker
|
||||||
if (finalMessage.isSarMarker) {
|
if (finalMessage.isSarMarker) {
|
||||||
@@ -563,7 +590,10 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
/// Persist messages to storage (async, non-blocking)
|
/// Persist messages to storage (async, non-blocking)
|
||||||
Future<void> _persistMessages() async {
|
Future<void> _persistMessages() async {
|
||||||
try {
|
try {
|
||||||
await _storageService.saveMessages(_messages);
|
await _storageService.saveMessages(
|
||||||
|
_messages,
|
||||||
|
messageContactLocations: _messageContactLocations,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||||
}
|
}
|
||||||
@@ -678,6 +708,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_messageContactMap.remove(messageId);
|
_messageContactMap.remove(messageId);
|
||||||
_groupedMessageMapping.remove(messageId);
|
_groupedMessageMapping.remove(messageId);
|
||||||
|
_messageContactLocations.remove(messageId);
|
||||||
|
|
||||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||||
|
|
||||||
@@ -706,6 +737,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void clearMessages() {
|
void clearMessages() {
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
_messageContactLocations.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -720,6 +752,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
void clearAll() {
|
void clearAll() {
|
||||||
_messages.clear();
|
_messages.clear();
|
||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
|
_messageContactLocations.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -906,11 +939,13 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final (groupId, recipientPublicKey) = groupMapping;
|
final (groupId, recipientPublicKey) = groupMapping;
|
||||||
debugPrint(' ✅ This is part of a grouped message: $groupId');
|
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(
|
updateGroupedMessageRecipientStatus(
|
||||||
groupId,
|
groupId,
|
||||||
recipientPublicKey,
|
recipientPublicKey,
|
||||||
MessageDeliveryStatus.sent,
|
expectedAckTag > 0
|
||||||
|
? MessageDeliveryStatus.sending
|
||||||
|
: MessageDeliveryStatus.sent,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track the ACK for this specific recipient
|
// Track the ACK for this specific recipient
|
||||||
@@ -1015,6 +1050,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
final message = _messages[index];
|
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(' Current status: ${message.deliveryStatus}');
|
||||||
debugPrint(' Message type: ${message.messageType}');
|
debugPrint(' Message type: ${message.messageType}');
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1022,16 +1063,22 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final updatedMessage = message.copyWith(
|
final updatedMessage = message.copyWith(
|
||||||
deliveryStatus: MessageDeliveryStatus.sent,
|
deliveryStatus: expectedAckTag > 0
|
||||||
|
? MessageDeliveryStatus.sending
|
||||||
|
: MessageDeliveryStatus.sent,
|
||||||
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
|
||||||
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
|
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
|
||||||
);
|
);
|
||||||
_messages[index] = updatedMessage;
|
_messages[index] = updatedMessage;
|
||||||
|
|
||||||
// Only track and set timeout for direct messages (channel messages have expectedAckTag=0)
|
// 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
|
// Track by ACK tag for matching with delivery confirmation
|
||||||
_pendingSentMessages[expectedAckTag] = updatedMessage;
|
_pendingSentMessages[expectedAckTag] = updatedMessage;
|
||||||
|
_messageAckHistory
|
||||||
|
.putIfAbsent(messageId, () => <int>{})
|
||||||
|
.add(expectedAckTag);
|
||||||
|
_ackHistoryLookup[expectedAckTag] = (messageId, DateTime.now());
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ✅ Added to pending messages map with ACK: $expectedAckTag',
|
' ✅ Added to pending messages map with ACK: $expectedAckTag',
|
||||||
);
|
);
|
||||||
@@ -1042,7 +1089,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Start timeout timer using message ID as key
|
// Start timeout timer using message ID as key
|
||||||
_timeoutTimers[messageId] = Timer(
|
_timeoutTimers[messageId] = Timer(
|
||||||
Duration(milliseconds: suggestedTimeoutMs),
|
Duration(milliseconds: effectiveTimeout),
|
||||||
() {
|
() {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)',
|
'⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)',
|
||||||
@@ -1054,7 +1101,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)',
|
'⏱️ [MessagesProvider] Started ${effectiveTimeout}ms timeout timer for message $messageId (ACK $expectedAckTag)',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1245,6 +1292,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Update message status to delivered with RTT
|
/// Update message status to delivered with RTT
|
||||||
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
|
||||||
|
_cleanupCompletedAckHistory();
|
||||||
|
_cleanupAckHistoryLookup();
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
|
||||||
);
|
);
|
||||||
@@ -1296,6 +1345,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
_ackTagToRecipients.remove(ackCode);
|
_ackTagToRecipients.remove(ackCode);
|
||||||
_pendingSentMessages.remove(ackCode);
|
_pendingSentMessages.remove(ackCode);
|
||||||
|
_rememberCompletedAck(ackCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1339,9 +1389,15 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Remove from pending
|
// Remove from pending
|
||||||
_pendingSentMessages.remove(ackCode);
|
_pendingSentMessages.remove(ackCode);
|
||||||
|
_rememberCompletedAck(ackCode);
|
||||||
|
_clearAckHistoryForMessage(message.id);
|
||||||
|
|
||||||
// Clear retry tracking on successful delivery
|
// Clear retry tracking on successful delivery
|
||||||
_retryManager.clearRetry(message.id);
|
_retryManager.clearRetry(message.id);
|
||||||
|
final deliveredContact = _messageContactMap[message.id];
|
||||||
|
if (deliveredContact != null) {
|
||||||
|
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)',
|
'✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)',
|
||||||
@@ -1362,6 +1418,43 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} 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(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
|
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
|
||||||
);
|
);
|
||||||
@@ -1482,18 +1575,31 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
'⏰ [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) {
|
if (sendMessageCallback != null) {
|
||||||
await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
retryAttempt: nextAttempt,
|
retryAttempt: nextAttempt,
|
||||||
);
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, currentMessage);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
|
'⚠️ [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)
|
// Send with flood mode (no retry after this)
|
||||||
if (sendMessageCallback != null) {
|
if (sendMessageCallback != null) {
|
||||||
await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
retryAttempt: 0, // Reset attempt for flood
|
retryAttempt: 0, // Reset attempt for flood
|
||||||
);
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
||||||
);
|
);
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1562,10 +1672,27 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
if (message.expectedAckTag != null) {
|
if (message.expectedAckTag != null) {
|
||||||
_pendingSentMessages.remove(message.expectedAckTag);
|
_pendingSentMessages.remove(message.expectedAckTag);
|
||||||
}
|
}
|
||||||
|
_clearAckHistoryForMessage(messageId);
|
||||||
|
|
||||||
// Clear retry tracking
|
// Clear retry tracking
|
||||||
_retryManager.clearRetry(messageId);
|
_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();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -1608,17 +1735,21 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Send again
|
// Send again
|
||||||
if (sendMessageCallback != null) {
|
if (sendMessageCallback != null) {
|
||||||
await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: contact,
|
||||||
retryAttempt: 0,
|
retryAttempt: 0,
|
||||||
);
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
|
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
|
||||||
);
|
);
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1631,10 +1762,62 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
timer.cancel();
|
timer.cancel();
|
||||||
}
|
}
|
||||||
_timeoutTimers.clear();
|
_timeoutTimers.clear();
|
||||||
|
_completedAckHistory.clear();
|
||||||
|
_messageAckHistory.clear();
|
||||||
|
_ackHistoryLookup.clear();
|
||||||
|
|
||||||
// Clear retry manager
|
// Clear retry manager
|
||||||
_retryManager.clearAll();
|
_retryManager.clearAll();
|
||||||
|
|
||||||
super.dispose();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../services/validation_service.dart';
|
import '../services/validation_service.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -722,7 +722,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Client Repeat Mode'),
|
title: const Text('Client Repeat Mode'),
|
||||||
subtitle: deviceInfo.allowedRepeatFreqRanges != null &&
|
subtitle:
|
||||||
|
deviceInfo.allowedRepeatFreqRanges != null &&
|
||||||
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
||||||
? Text(
|
? 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(', ')}',
|
'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 'settings_screen.dart';
|
||||||
import 'device_config_screen.dart';
|
import 'device_config_screen.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
|
import 'spectrum_scan_screen.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/permission_request_dialog.dart';
|
import '../widgets/permission_request_dialog.dart';
|
||||||
@@ -45,9 +46,9 @@ class HomeScreen extends StatefulWidget {
|
|||||||
State<HomeScreen> createState() => _HomeScreenState();
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen>
|
class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
|
late final AppProvider _appProvider;
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
bool _isMapFullscreen = false;
|
bool _isMapFullscreen = false;
|
||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
@@ -73,9 +74,13 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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.
|
// Initialize synchronously so first build always has a valid controller.
|
||||||
_initTabController();
|
_initTabController();
|
||||||
_loadTabVisibilityAndInitTabs();
|
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
|
|
||||||
// Show permission dialog after the first frame if needed
|
// 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() {
|
void _initTabController() {
|
||||||
_tabController = TabController(length: _enabledTabs.length, vsync: this);
|
_tabController = TabController(length: _enabledTabs.length, vsync: this);
|
||||||
_tabController.addListener(_onTabChanged);
|
_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() {
|
void _onTabChanged() {
|
||||||
|
final previousTab = _currentTab;
|
||||||
|
final nextIndex = _tabController.index;
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentIndex = _tabController.index;
|
_currentIndex = nextIndex;
|
||||||
if (_currentTab != _HomeTab.map) {
|
if (_currentTab != _HomeTab.map) {
|
||||||
_isMapFullscreen = false;
|
_isMapFullscreen = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final nextTab = _currentTab;
|
||||||
|
if (previousTab != nextTab) {
|
||||||
|
_handleTabActivated(nextTab);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateTabController({
|
void _updateTabController({
|
||||||
@@ -122,15 +135,21 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final oldTabs = _enabledTabs;
|
final oldTabs = _enabledTabs;
|
||||||
final oldIndex = _tabController.index;
|
final oldIndex = oldTabs.isEmpty
|
||||||
|
? 0
|
||||||
|
: _tabController.index.clamp(0, oldTabs.length - 1);
|
||||||
final oldTab = oldTabs[oldIndex];
|
final oldTab = oldTabs[oldIndex];
|
||||||
|
|
||||||
final oldController = _tabController;
|
final oldController = _tabController;
|
||||||
oldController.removeListener(_onTabChanged);
|
oldController.removeListener(_onTabChanged);
|
||||||
|
oldController.dispose();
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
_isMapEnabled = mapEnabled;
|
_isMapEnabled = mapEnabled;
|
||||||
_isContactsEnabled = contactsEnabled;
|
_isContactsEnabled = contactsEnabled;
|
||||||
|
if (!_isMapEnabled) {
|
||||||
|
_isMapFullscreen = false;
|
||||||
|
}
|
||||||
|
|
||||||
final newTabs = _enabledTabs;
|
final newTabs = _enabledTabs;
|
||||||
final newIndex = newTabs.indexOf(oldTab);
|
final newIndex = newTabs.indexOf(oldTab);
|
||||||
@@ -143,11 +162,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
_tabController.index = _currentIndex;
|
_tabController.index = _currentIndex;
|
||||||
|
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
_handleTabActivated(_currentTab);
|
||||||
// Dispose old controller after widgets have rebound to the new controller.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
oldController.dispose();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToTab(_HomeTab tab) {
|
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 {
|
Future<void> _loadRxTxPreference() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -168,6 +200,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_appProvider.removeListener(_handleAppProviderChanged);
|
||||||
_tabController.removeListener(_onTabChanged);
|
_tabController.removeListener(_onTabChanged);
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -312,18 +345,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
messagesProvider.setLocalizations(localizations);
|
messagesProvider.setLocalizations(localizations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if tab visibility settings changed and update tab controller
|
context.watch<AppProvider>();
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
final enabledTabs = _enabledTabs;
|
final enabledTabs = _enabledTabs;
|
||||||
final isMapTabActive = _currentTab == _HomeTab.map;
|
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(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
@@ -44,11 +45,13 @@ class MessagesTab extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MessagesTabState extends State<MessagesTab> {
|
class _MessagesTabState extends State<MessagesTab> {
|
||||||
|
static const int _maxContactMessageBytes = 156;
|
||||||
|
static const int _maxChannelMessageBytes = 127;
|
||||||
|
|
||||||
final TextEditingController _textController = TextEditingController();
|
final TextEditingController _textController = TextEditingController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
int _characterCount = 0;
|
int _messageByteCount = 0;
|
||||||
static const int _maxCharacters = 160;
|
|
||||||
String? _highlightedMessageId;
|
String? _highlightedMessageId;
|
||||||
Timer? _highlightTimer; // Timer for clearing message highlight
|
Timer? _highlightTimer; // Timer for clearing message highlight
|
||||||
|
|
||||||
@@ -166,10 +169,44 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
void _updateCharacterCount() {
|
void _updateCharacterCount() {
|
||||||
setState(() {
|
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
|
/// Load saved message destination from preferences
|
||||||
Future<void> _loadSavedDestination() async {
|
Future<void> _loadSavedDestination() async {
|
||||||
final savedDestination =
|
final savedDestination =
|
||||||
@@ -211,6 +248,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
await MessageDestinationPreferences.clearDestination();
|
await MessageDestinationPreferences.clearDestination();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_enforceMessageByteLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show recipient selector bottom sheet
|
/// Show recipient selector bottom sheet
|
||||||
@@ -250,6 +289,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_selectedRecipient = recipient;
|
_selectedRecipient = recipient;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_enforceMessageByteLimit();
|
||||||
|
|
||||||
// Save to preferences
|
// Save to preferences
|
||||||
await MessageDestinationPreferences.setDestination(
|
await MessageDestinationPreferences.setDestination(
|
||||||
type,
|
type,
|
||||||
@@ -287,59 +328,16 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return 'Select recipient';
|
return 'Select recipient';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getCurrentScopeLabel() {
|
String _getDestinationLabel() {
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel) {
|
MessageDestinationPreferences.destinationTypeChannel &&
|
||||||
final channelName =
|
_selectedRecipient != null) {
|
||||||
_selectedRecipient?.getLocalizedDisplayName(context) ??
|
return _selectedRecipient!.getLocalizedDisplayName(context);
|
||||||
AppLocalizations.of(context)!.publicChannel;
|
|
||||||
return 'Channel > $channelName';
|
|
||||||
}
|
}
|
||||||
|
if (_selectedRecipient != null) {
|
||||||
if (_destinationType == MessageDestinationPreferences.destinationTypeRoom) {
|
return _selectedRecipient!.displayName;
|
||||||
final roomName =
|
|
||||||
_selectedRecipient?.displayName ??
|
|
||||||
AppLocalizations.of(context)!.messages;
|
|
||||||
return 'Room > $roomName';
|
|
||||||
}
|
}
|
||||||
|
return 'Public Channel';
|
||||||
final contactName =
|
|
||||||
_selectedRecipient?.displayName ??
|
|
||||||
AppLocalizations.of(context)!.messages;
|
|
||||||
return 'Direct > $contactName';
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildScopeIndicator() {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.visibility_outlined,
|
|
||||||
size: 14,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
'View: ${_getCurrentScopeLabel()}',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
textAlign: TextAlign.left,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _sendMessage() async {
|
||||||
@@ -426,7 +424,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add to messages list with "sending" status
|
// Add to messages list with "sending" status
|
||||||
messagesProvider.addSentMessage(sentMessage);
|
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
|
||||||
|
|
||||||
// Send to selected channel
|
// Send to selected channel
|
||||||
await connectionProvider.sendChannelMessage(
|
await connectionProvider.sendChannelMessage(
|
||||||
@@ -468,7 +466,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add to messages list with "sending" status
|
// Add to messages list with "sending" status
|
||||||
messagesProvider.addSentMessage(sentMessage);
|
messagesProvider.addSentMessage(
|
||||||
|
sentMessage,
|
||||||
|
contact: _selectedRecipient,
|
||||||
|
);
|
||||||
|
|
||||||
// Send message to selected recipient
|
// Send message to selected recipient
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
@@ -659,7 +660,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
||||||
);
|
);
|
||||||
messagesProvider.addSentMessage(placeholder);
|
messagesProvider.addSentMessage(placeholder, contact: recipient);
|
||||||
|
|
||||||
// Send IE1 envelope via normal message path.
|
// Send IE1 envelope via normal message path.
|
||||||
final envelopeText = envelope.encode();
|
final envelopeText = envelope.encode();
|
||||||
@@ -964,7 +965,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
channelIdx: channelIdx,
|
channelIdx: channelIdx,
|
||||||
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
recipientPublicKey: isChannel ? null : recipient?.publicKey,
|
||||||
);
|
);
|
||||||
messagesProvider.addSentMessage(sentMsg);
|
messagesProvider.addSentMessage(sentMsg, contact: recipient);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isChannel) {
|
if (isChannel) {
|
||||||
@@ -1370,9 +1371,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
|
// 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
|
// Look up the room contact for path logging
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
final roomContact = contactsProvider.contacts.where((c) {
|
final roomContact = contactsProvider.contacts.where((c) {
|
||||||
@@ -1380,6 +1378,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
c.publicKey.matches(roomPublicKey);
|
c.publicKey.matches(roomPublicKey);
|
||||||
}).firstOrNull;
|
}).firstOrNull;
|
||||||
|
|
||||||
|
// Add to messages list with "sending" status
|
||||||
|
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
|
||||||
|
|
||||||
// Send SAR message to selected room (persisted and immutable)
|
// Send SAR message to selected room (persisted and immutable)
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: roomPublicKey!,
|
contactPublicKey: roomPublicKey!,
|
||||||
@@ -1500,8 +1501,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return Consumer<MessagesProvider>(
|
return Consumer<MessagesProvider>(
|
||||||
builder: (context, messagesProvider, child) {
|
builder: (context, messagesProvider, child) {
|
||||||
final messages = _getFilteredMessages(messagesProvider);
|
final messages = _getFilteredMessages(messagesProvider);
|
||||||
|
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
||||||
|
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
|
||||||
|
|
||||||
return Column(
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Messages list with pull-to-refresh
|
// Messages list with pull-to-refresh
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -1511,19 +1517,25 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
? LayoutBuilder(
|
? LayoutBuilder(
|
||||||
builder: (context, constraints) =>
|
builder: (context, constraints) =>
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
keyboardDismissBehavior:
|
||||||
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
|
physics:
|
||||||
|
const AlwaysScrollableScrollPhysics(),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
minHeight: constraints.maxHeight,
|
minHeight: constraints.maxHeight,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.message_outlined,
|
Icons.message_outlined,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: Theme.of(context).disabledColor,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).disabledColor,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -1552,6 +1564,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
|
keyboardDismissBehavior:
|
||||||
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
reverse: true,
|
reverse: true,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
@@ -1606,189 +1620,443 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
border: Border(
|
|
||||||
top: BorderSide(
|
|
||||||
color: Theme.of(context).dividerColor,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
SafeArea(
|
||||||
padding: const EdgeInsets.symmetric(
|
top: false,
|
||||||
horizontal: 8,
|
child: Padding(
|
||||||
vertical: 8,
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
composerBottomPadding,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).dividerColor.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Quick actions (+) button
|
Row(
|
||||||
IconButton(
|
children: [
|
||||||
icon: Icon(_isRecording ? Icons.stop : Icons.add),
|
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
|
tooltip: _isRecording
|
||||||
? 'Stop recording'
|
? 'Stop recording'
|
||||||
: 'More actions',
|
: 'More actions',
|
||||||
onPressed: _isRecording
|
onPressed: _isRecording
|
||||||
? _stopAndSendVoice
|
? _stopAndSendVoice
|
||||||
: _showComposerActions,
|
: _showComposerActions,
|
||||||
style: IconButton.styleFrom(
|
color: _isRecording
|
||||||
backgroundColor: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primaryContainer,
|
|
||||||
foregroundColor: _isRecording
|
|
||||||
? Colors.red
|
? Colors.red
|
||||||
: Theme.of(
|
: Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onPrimaryContainer,
|
).colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 8),
|
||||||
// Destination switcher button
|
Expanded(
|
||||||
IconButton(
|
child: Material(
|
||||||
icon: Icon(_getDestinationIcon()),
|
color: Colors.transparent,
|
||||||
tooltip: _getDestinationTooltip(),
|
child: InkWell(
|
||||||
onPressed: _showRecipientSelector,
|
borderRadius: BorderRadius.circular(20),
|
||||||
style: IconButton.styleFrom(
|
onTap: _showRecipientSelector,
|
||||||
backgroundColor:
|
child: Ink(
|
||||||
_destinationType ==
|
height: 42,
|
||||||
MessageDestinationPreferences
|
decoration: BoxDecoration(
|
||||||
.destinationTypeChannel
|
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(
|
? Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.surfaceContainerHighest
|
).colorScheme.primary
|
||||||
: Theme.of(
|
: Theme.of(context).dividerColor
|
||||||
context,
|
.withValues(alpha: 0.35),
|
||||||
).colorScheme.secondaryContainer,
|
width: _focusNode.hasFocus ? 1.4 : 1,
|
||||||
foregroundColor:
|
|
||||||
_destinationType ==
|
|
||||||
MessageDestinationPreferences
|
|
||||||
.destinationTypeChannel
|
|
||||||
? Theme.of(context).colorScheme.onSurface
|
|
||||||
: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSecondaryContainer,
|
|
||||||
),
|
),
|
||||||
|
boxShadow: _focusNode.hasFocus
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.10),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
]
|
||||||
// Scope label + text field share the same alignment
|
: null,
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(
|
|
||||||
left: 4,
|
|
||||||
right: 4,
|
|
||||||
bottom: 6,
|
|
||||||
),
|
),
|
||||||
child: _buildScopeIndicator(),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
),
|
),
|
||||||
TextField(
|
child: TextField(
|
||||||
controller: _textController,
|
controller: _textController,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
maxLength: _maxCharacters,
|
minLines: 1,
|
||||||
maxLines: null,
|
maxLines: 4,
|
||||||
maxLengthEnforcement:
|
keyboardType: TextInputType.multiline,
|
||||||
MaxLengthEnforcement.enforced,
|
inputFormatters: [
|
||||||
style: const TextStyle(fontSize: 14),
|
_messageByteLimiter,
|
||||||
|
],
|
||||||
|
style: const TextStyle(fontSize: 15),
|
||||||
|
textAlignVertical:
|
||||||
|
TextAlignVertical.center,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: AppLocalizations.of(
|
hintText: AppLocalizations.of(
|
||||||
context,
|
context,
|
||||||
)!.typeYourMessage,
|
)!.typeYourMessage,
|
||||||
hintStyle: const TextStyle(fontSize: 14),
|
hintStyle: TextStyle(
|
||||||
border: OutlineInputBorder(
|
fontSize: 15,
|
||||||
borderRadius: BorderRadius.circular(24),
|
color: Theme.of(context)
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: 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
|
.colorScheme
|
||||||
.primary),
|
.onSurfaceVariant
|
||||||
|
.withValues(alpha: 0.9),
|
||||||
),
|
),
|
||||||
onPressed:
|
filled: false,
|
||||||
_isRecording ||
|
fillColor: Colors.transparent,
|
||||||
_isSendingVoice ||
|
border: InputBorder.none,
|
||||||
|
isCollapsed: true,
|
||||||
|
),
|
||||||
|
textInputAction:
|
||||||
|
TextInputAction.newline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final canSendText =
|
||||||
|
!_isRecording &&
|
||||||
|
!_isSendingVoice &&
|
||||||
_textController.text
|
_textController.text
|
||||||
.trim()
|
.trim()
|
||||||
.isEmpty
|
.isNotEmpty;
|
||||||
? null
|
final semanticsLabel = _isRecording
|
||||||
: _sendMessage,
|
|
||||||
tooltip: _isRecording
|
|
||||||
? 'Recording... release to send voice'
|
? 'Recording... release to send voice'
|
||||||
: (_isSendingVoice
|
: (_isSendingVoice
|
||||||
? 'Sending voice...'
|
? 'Sending voice...'
|
||||||
: _voiceSupported
|
: _voiceSupported
|
||||||
? 'Send (long press to record voice)'
|
? 'Send (long press to record voice)'
|
||||||
: 'Send'),
|
: '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,
|
||||||
|
onTap: canSendText
|
||||||
|
? () {
|
||||||
|
debugPrint(
|
||||||
|
'👆 [MessagesTab] Send button tapped '
|
||||||
|
'(canSendText=$canSendText, '
|
||||||
|
'textLength=${_textController.text.trim().length}, '
|
||||||
|
'recording=$_isRecording, '
|
||||||
|
'sendingVoice=$_isSendingVoice)',
|
||||||
|
);
|
||||||
|
_sendMessage();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onLongPressStart:
|
||||||
|
(_voiceSupported &&
|
||||||
|
!_isSendingVoice)
|
||||||
|
? (_) {
|
||||||
|
debugPrint(
|
||||||
|
'🎙️ [MessagesTab] Send button long-press start '
|
||||||
|
'(voiceSupported=$_voiceSupported, '
|
||||||
|
'sendingVoice=$_isSendingVoice, '
|
||||||
|
'recording=$_isRecording)',
|
||||||
|
);
|
||||||
|
_startVoiceRecording();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onLongPressEnd:
|
||||||
|
(_voiceSupported &&
|
||||||
|
_isRecording)
|
||||||
|
? (_) {
|
||||||
|
debugPrint(
|
||||||
|
'🎙️ [MessagesTab] Send button long-press end '
|
||||||
|
'(recording=$_isRecording)',
|
||||||
|
);
|
||||||
|
_stopAndSendVoice();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onLongPressCancel:
|
||||||
|
(_voiceSupported &&
|
||||||
|
_isRecording)
|
||||||
|
? () {
|
||||||
|
debugPrint(
|
||||||
|
'🎙️ [MessagesTab] Send button long-press cancel '
|
||||||
|
'(recording=$_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,
|
channelCount: 2,
|
||||||
);
|
);
|
||||||
|
|
||||||
final sarMessages = SampleDataGenerator.generateSarMarkerMessages(
|
final sampleMessages = SampleDataGenerator.generateAllMessages(
|
||||||
centerLocation: centerLocation,
|
centerLocation: centerLocation,
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
foundPersonCount: 2,
|
foundPersonCount: 2,
|
||||||
fireCount: 1,
|
fireCount: 1,
|
||||||
stagingCount: 1,
|
stagingCount: 1,
|
||||||
objectCount: 1,
|
objectCount: 1,
|
||||||
);
|
|
||||||
|
|
||||||
final channelMessages = SampleDataGenerator.generateChannelMessages(
|
|
||||||
centerLocation: centerLocation,
|
|
||||||
l10n: l10n,
|
|
||||||
generalChannelMessages: 8,
|
generalChannelMessages: 8,
|
||||||
emergencyChannelMessages: 5,
|
emergencyChannelMessages: 5,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combine all messages
|
|
||||||
final allMessages = [...sarMessages, ...channelMessages];
|
|
||||||
|
|
||||||
// Add to providers
|
// Add to providers
|
||||||
final contactsProvider = Provider.of<ContactsProvider>(
|
final contactsProvider = Provider.of<ContactsProvider>(
|
||||||
context,
|
context,
|
||||||
@@ -433,7 +425,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
contactsProvider.addContacts(contacts);
|
contactsProvider.addContacts(contacts);
|
||||||
messagesProvider.addMessages(allMessages);
|
for (final message in sampleMessages.messages) {
|
||||||
|
messagesProvider.addMessage(
|
||||||
|
message,
|
||||||
|
contactLocationSnapshot: sampleMessages.contactLocations[message.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -446,8 +443,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
AppLocalizations.of(context)!.loadedSampleData(
|
AppLocalizations.of(context)!.loadedSampleData(
|
||||||
teamCount,
|
teamCount,
|
||||||
channelCount,
|
channelCount,
|
||||||
sarMessages.length,
|
sampleMessages.messages.where((m) => m.isSarMarker).length,
|
||||||
channelMessages.length,
|
sampleMessages.messages.length,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: Colors.green,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,8 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Greek';
|
return 'Greek';
|
||||||
|
case 'cn':
|
||||||
|
return '简体中文';
|
||||||
default:
|
default:
|
||||||
return locale.languageCode;
|
return locale.languageCode;
|
||||||
}
|
}
|
||||||
@@ -88,6 +90,8 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Ελληνικά';
|
return 'Ελληνικά';
|
||||||
|
case 'cn':
|
||||||
|
return '简体中文';
|
||||||
default:
|
default:
|
||||||
return locale.languageCode;
|
return locale.languageCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,17 +31,22 @@ class MeshMapNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MeshMapNodesService {
|
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 _cacheTtl = Duration(minutes: 2);
|
||||||
|
static const Duration traceCacheTtl = Duration(minutes: 10);
|
||||||
static List<MeshMapNode>? _cachedNodes;
|
static List<MeshMapNode>? _cachedNodes;
|
||||||
static DateTime? _cachedAt;
|
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();
|
final now = DateTime.now();
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
_cachedNodes != null &&
|
_cachedNodes != null &&
|
||||||
_cachedAt != null &&
|
_cachedAt != null &&
|
||||||
now.difference(_cachedAt!) < _cacheTtl) {
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
return _cachedNodes!;
|
return _cachedNodes!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +63,9 @@ class MeshMapNodesService {
|
|||||||
final nodes = nodesRaw
|
final nodes = nodesRaw
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
.map(MeshMapNode.fromJson)
|
.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();
|
.toList();
|
||||||
|
|
||||||
_cachedNodes = nodes;
|
_cachedNodes = nodes;
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
class MessageStorageService {
|
class MessageStorageService {
|
||||||
static const String _messagesKey = 'stored_messages';
|
static const String _messagesKey = 'stored_messages';
|
||||||
|
static const String _messageContactLocationsKey =
|
||||||
|
'stored_message_contact_locations';
|
||||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||||
|
|
||||||
/// Save messages to persistent storage
|
/// 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 {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
@@ -24,6 +30,19 @@ class MessageStorageService {
|
|||||||
|
|
||||||
final jsonString = jsonEncode(limitedList);
|
final jsonString = jsonEncode(limitedList);
|
||||||
await prefs.setString(_messagesKey, jsonString);
|
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(
|
debugPrint(
|
||||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
'✅ [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
|
/// Load messages from persistent storage
|
||||||
Future<List<Message>> loadMessages() async {
|
Future<List<Message>> loadMessages() async {
|
||||||
try {
|
try {
|
||||||
@@ -66,6 +115,7 @@ class MessageStorageService {
|
|||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_messagesKey);
|
await prefs.remove(_messagesKey);
|
||||||
|
await prefs.remove(_messageContactLocationsKey);
|
||||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error clearing messages: $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,15 +24,30 @@ extension MessageLocalization on Message {
|
|||||||
|
|
||||||
switch (deliveryStatus) {
|
switch (deliveryStatus) {
|
||||||
case MessageDeliveryStatus.sending:
|
case MessageDeliveryStatus.sending:
|
||||||
|
if (isContactMessage) {
|
||||||
|
if (retryAttempt > 0) {
|
||||||
|
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/3';
|
||||||
|
}
|
||||||
|
return l10n.pending;
|
||||||
|
}
|
||||||
return l10n.sending;
|
return l10n.sending;
|
||||||
case MessageDeliveryStatus.sent:
|
case MessageDeliveryStatus.sent:
|
||||||
return l10n.sent;
|
return l10n.sent;
|
||||||
case MessageDeliveryStatus.delivered:
|
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) {
|
if (roundTripTimeMs != null) {
|
||||||
return l10n.deliveredWithTime(roundTripTimeMs!);
|
return l10n.deliveredWithTime(roundTripTimeMs!);
|
||||||
}
|
}
|
||||||
return l10n.delivered;
|
return l10n.delivered;
|
||||||
case MessageDeliveryStatus.failed:
|
case MessageDeliveryStatus.failed:
|
||||||
|
if (retryAttempt > 0) {
|
||||||
|
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/3';
|
||||||
|
}
|
||||||
return l10n.failed;
|
return l10n.failed;
|
||||||
case MessageDeliveryStatus.received:
|
case MessageDeliveryStatus.received:
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -4,12 +4,66 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
|
import '../models/message_contact_location.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Generates sample data for testing/demo purposes
|
/// Generates sample data for testing/demo purposes
|
||||||
class SampleDataGenerator {
|
class SampleDataGenerator {
|
||||||
static final Random _random = Random();
|
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
|
/// Generate sample contacts around a center location
|
||||||
static List<Contact> generateContacts({
|
static List<Contact> generateContacts({
|
||||||
required LatLng centerLocation,
|
required LatLng centerLocation,
|
||||||
@@ -113,7 +167,7 @@ class SampleDataGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate sample SAR markers around a center location
|
/// Generate sample SAR markers around a center location
|
||||||
static List<Message> generateSarMarkerMessages({
|
static SampleMessageBatch generateSarMarkerMessages({
|
||||||
required LatLng centerLocation,
|
required LatLng centerLocation,
|
||||||
required AppLocalizations l10n,
|
required AppLocalizations l10n,
|
||||||
int foundPersonCount = 2,
|
int foundPersonCount = 2,
|
||||||
@@ -122,6 +176,7 @@ class SampleDataGenerator {
|
|||||||
int objectCount = 1,
|
int objectCount = 1,
|
||||||
}) {
|
}) {
|
||||||
final messages = <Message>[];
|
final messages = <Message>[];
|
||||||
|
final contactLocations = <String, MessageContactLocation>{};
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
int messageId = 1;
|
int messageId = 1;
|
||||||
|
|
||||||
@@ -137,7 +192,7 @@ class SampleDataGenerator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
|
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_fp_$messageId',
|
id: 'sample_fp_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
@@ -150,7 +205,13 @@ class SampleDataGenerator {
|
|||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '🧑',
|
sarCustomEmoji: '🧑',
|
||||||
senderName: l10n.sampleTeamMember,
|
senderName: l10n.sampleTeamMember,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.006),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'telemetry',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +227,7 @@ class SampleDataGenerator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
|
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_fire_$messageId',
|
id: 'sample_fire_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
@@ -179,7 +240,13 @@ class SampleDataGenerator {
|
|||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '🔥',
|
sarCustomEmoji: '🔥',
|
||||||
senderName: l10n.sampleScout,
|
senderName: l10n.sampleScout,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.008),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'advert',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +262,7 @@ class SampleDataGenerator {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
|
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_staging_$messageId',
|
id: 'sample_staging_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
@@ -208,7 +275,13 @@ class SampleDataGenerator {
|
|||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '🏕️',
|
sarCustomEmoji: '🏕️',
|
||||||
senderName: l10n.sampleBase,
|
senderName: l10n.sampleBase,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.01),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'advert',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,24 +304,34 @@ class SampleDataGenerator {
|
|||||||
l10n.sampleObjectTrailMarker,
|
l10n.sampleObjectTrailMarker,
|
||||||
];
|
];
|
||||||
|
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_object_$messageId',
|
id: 'sample_object_$messageId',
|
||||||
messageType: MessageType.contact,
|
messageType: MessageType.contact,
|
||||||
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
senderPublicKeyPrefix: senderKey.sublist(0, 6),
|
||||||
pathLen: 1,
|
pathLen: 1,
|
||||||
textType: MessageTextType.plain,
|
textType: MessageTextType.plain,
|
||||||
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
|
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,
|
receivedAt: timestamp,
|
||||||
isSarMarker: true,
|
isSarMarker: true,
|
||||||
sarGpsCoordinates: LatLng(lat, lon),
|
sarGpsCoordinates: LatLng(lat, lon),
|
||||||
sarCustomEmoji: '📦',
|
sarCustomEmoji: '📦',
|
||||||
senderName: l10n.sampleSearcher,
|
senderName: l10n.sampleSearcher,
|
||||||
));
|
);
|
||||||
|
messages.add(message);
|
||||||
|
contactLocations[message.id] = _sampleSnapshot(
|
||||||
|
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.007),
|
||||||
|
receivedAt: timestamp,
|
||||||
|
source: 'telemetry',
|
||||||
|
);
|
||||||
messageId++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
return SampleMessageBatch(
|
||||||
|
messages: messages,
|
||||||
|
contactLocations: contactLocations,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate sample map drawings
|
/// Generate sample map drawings
|
||||||
@@ -272,7 +355,9 @@ class SampleDataGenerator {
|
|||||||
'id': 'sample_line_${now.millisecondsSinceEpoch}',
|
'id': 'sample_line_${now.millisecondsSinceEpoch}',
|
||||||
'color': Colors.blue.toARGB32(),
|
'color': Colors.blue.toARGB32(),
|
||||||
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
|
'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,
|
'sender': l10n.sampleTeamMember,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,7 +382,7 @@ class SampleDataGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generate sample channel messages for public channels
|
/// Generate sample channel messages for public channels
|
||||||
static List<Message> generateChannelMessages({
|
static SampleMessageBatch generateChannelMessages({
|
||||||
LatLng? centerLocation,
|
LatLng? centerLocation,
|
||||||
required AppLocalizations l10n,
|
required AppLocalizations l10n,
|
||||||
int generalChannelMessages = 8,
|
int generalChannelMessages = 8,
|
||||||
@@ -306,6 +391,7 @@ class SampleDataGenerator {
|
|||||||
// Use provided location or default to Ljubljana, Slovenia
|
// Use provided location or default to Ljubljana, Slovenia
|
||||||
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
|
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
|
||||||
final messages = <Message>[];
|
final messages = <Message>[];
|
||||||
|
final contactLocations = <String, MessageContactLocation>{};
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
int messageId = 1000; // Start with high ID to avoid conflicts
|
int messageId = 1000; // Start with high ID to avoid conflicts
|
||||||
|
|
||||||
@@ -352,7 +438,11 @@ class SampleDataGenerator {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Generate General channel messages
|
// 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(
|
final senderKey = Uint8List.fromList(
|
||||||
List.generate(32, (_) => _random.nextInt(256)),
|
List.generate(32, (_) => _random.nextInt(256)),
|
||||||
);
|
);
|
||||||
@@ -361,7 +451,7 @@ class SampleDataGenerator {
|
|||||||
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
|
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
|
||||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||||
|
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_general_$messageId',
|
id: 'sample_general_$messageId',
|
||||||
messageType: MessageType.channel,
|
messageType: MessageType.channel,
|
||||||
channelIdx: 0, // General channel
|
channelIdx: 0, // General channel
|
||||||
@@ -372,12 +462,22 @@ class SampleDataGenerator {
|
|||||||
text: generalMessages[i],
|
text: generalMessages[i],
|
||||||
receivedAt: timestamp,
|
receivedAt: timestamp,
|
||||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
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++;
|
messageId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate Emergency channel messages
|
// 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(
|
final senderKey = Uint8List.fromList(
|
||||||
List.generate(32, (_) => _random.nextInt(256)),
|
List.generate(32, (_) => _random.nextInt(256)),
|
||||||
);
|
);
|
||||||
@@ -386,7 +486,7 @@ class SampleDataGenerator {
|
|||||||
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
|
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
|
||||||
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
final timestamp = now.subtract(Duration(minutes: minutesAgo));
|
||||||
|
|
||||||
messages.add(Message(
|
final message = Message(
|
||||||
id: 'sample_emergency_$messageId',
|
id: 'sample_emergency_$messageId',
|
||||||
messageType: MessageType.channel,
|
messageType: MessageType.channel,
|
||||||
channelIdx: 1, // Emergency channel
|
channelIdx: 1, // Emergency channel
|
||||||
@@ -397,10 +497,29 @@ class SampleDataGenerator {
|
|||||||
text: emergencyMessages[i],
|
text: emergencyMessages[i],
|
||||||
receivedAt: timestamp,
|
receivedAt: timestamp,
|
||||||
senderName: teamNames[_random.nextInt(teamNames.length)],
|
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++;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
contactPublicKey: room.publicKey,
|
||||||
text: message,
|
text: message,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
|
contact: room,
|
||||||
);
|
);
|
||||||
debugPrint(' ✅ Sent successfully');
|
debugPrint(' ✅ Sent successfully');
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ import '../../utils/key_comparison.dart';
|
|||||||
import '../../utils/voice_message_parser.dart';
|
import '../../utils/voice_message_parser.dart';
|
||||||
import '../../utils/image_message_parser.dart';
|
import '../../utils/image_message_parser.dart';
|
||||||
import '../../utils/tictactoe_message_parser.dart';
|
import '../../utils/tictactoe_message_parser.dart';
|
||||||
|
import '../../utils/avatar_label_helper.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../utils/message_extensions.dart';
|
import '../../utils/message_extensions.dart';
|
||||||
|
import '../common/contact_avatar.dart';
|
||||||
import 'voice_message_bubble.dart';
|
import 'voice_message_bubble.dart';
|
||||||
import 'image_message_bubble.dart';
|
import 'image_message_bubble.dart';
|
||||||
import 'tictactoe_message_bubble.dart';
|
import 'tictactoe_message_bubble.dart';
|
||||||
@@ -62,6 +64,103 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
bool _isExpanded = false;
|
bool _isExpanded = false;
|
||||||
bool _showReceivedStats = false;
|
bool _showReceivedStats = false;
|
||||||
|
|
||||||
|
Widget _buildHeaderAvatar(
|
||||||
|
BuildContext context, {
|
||||||
|
required bool isOwnMessage,
|
||||||
|
required bool isChannelMessage,
|
||||||
|
required dynamic senderContact,
|
||||||
|
required String displayName,
|
||||||
|
}) {
|
||||||
|
if (isOwnMessage) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 10.5,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||||
|
child: Icon(
|
||||||
|
Icons.account_circle,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (senderContact is Contact) {
|
||||||
|
return ContactAvatar(
|
||||||
|
contact: senderContact,
|
||||||
|
radius: 10.5,
|
||||||
|
displayName: displayName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final background = isChannelMessage
|
||||||
|
? Colors.teal.withValues(alpha: 0.16)
|
||||||
|
: Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||||
|
final foreground = isChannelMessage
|
||||||
|
? Colors.teal.shade800
|
||||||
|
: Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: 10.5,
|
||||||
|
backgroundColor: background,
|
||||||
|
child: Text(
|
||||||
|
AvatarLabelHelper.buildLabel(displayName),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: foreground,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBubbleMetaFooter(
|
||||||
|
BuildContext context, {
|
||||||
|
required Message message,
|
||||||
|
required bool isOwnMessage,
|
||||||
|
required bool isSarMarker,
|
||||||
|
}) {
|
||||||
|
final metaColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
|
||||||
|
|
||||||
|
final items = <Widget>[
|
||||||
|
Text(
|
||||||
|
message.getLocalizedTimeAgo(context),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: metaColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!isOwnMessage && !isSarMarker && message.pathLen < 255) {
|
||||||
|
items.addAll([
|
||||||
|
Text(
|
||||||
|
' • ',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||||
|
),
|
||||||
|
Icon(Icons.alt_route, size: 11, color: metaColor),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(
|
||||||
|
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(color: metaColor),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Row(mainAxisSize: MainAxisSize.min, children: items),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(MessageBubble oldWidget) {
|
void didUpdateWidget(MessageBubble oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -118,7 +217,26 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Add retry message to provider
|
// Add retry message to provider
|
||||||
messagesProvider.addSentMessage(retryMessage);
|
Contact? roomContact;
|
||||||
|
if (failedMessage.messageType == MessageType.contact) {
|
||||||
|
if (failedMessage.recipientPublicKey == null) {
|
||||||
|
messagesProvider.markMessageFailed(retryMessageId);
|
||||||
|
ToastLogger.error(
|
||||||
|
context,
|
||||||
|
AppLocalizations.of(context)!.cannotRetryMissingRecipient,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
roomContact = contactsProvider.contacts.where((c) {
|
||||||
|
return c.publicKey.length >=
|
||||||
|
failedMessage.recipientPublicKey!.length &&
|
||||||
|
c.publicKey.matches(failedMessage.recipientPublicKey!);
|
||||||
|
}).firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesProvider.addSentMessage(retryMessage, contact: roomContact);
|
||||||
|
|
||||||
// Resend the message
|
// Resend the message
|
||||||
if (failedMessage.messageType == MessageType.contact) {
|
if (failedMessage.messageType == MessageType.contact) {
|
||||||
@@ -132,14 +250,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the room contact for path logging
|
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
|
||||||
final roomContact = contactsProvider.contacts.where((c) {
|
|
||||||
return c.publicKey.length >=
|
|
||||||
failedMessage.recipientPublicKey!.length &&
|
|
||||||
c.publicKey.matches(failedMessage.recipientPublicKey!);
|
|
||||||
}).firstOrNull;
|
|
||||||
|
|
||||||
// Resend to the same room
|
// Resend to the same room
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: failedMessage.recipientPublicKey!,
|
contactPublicKey: failedMessage.recipientPublicKey!,
|
||||||
@@ -345,6 +455,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
final radioSf = connectionProvider.deviceInfo.radioSf;
|
final radioSf = connectionProvider.deviceInfo.radioSf;
|
||||||
final radioCr = connectionProvider.deviceInfo.radioCr;
|
final radioCr = connectionProvider.deviceInfo.radioCr;
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final voiceProvider = context.read<VoiceProvider>();
|
final voiceProvider = context.read<VoiceProvider>();
|
||||||
final imageProvider = context.read<ip.ImageProvider>();
|
final imageProvider = context.read<ip.ImageProvider>();
|
||||||
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
final selfPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
@@ -386,6 +497,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
recipientName = recipientContact?.advName;
|
recipientName = recipientContact?.advName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final senderLocationSnapshot = messagesProvider.getMessageContactLocation(
|
||||||
|
widget.message.id,
|
||||||
|
);
|
||||||
|
|
||||||
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
|
||||||
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
|
final legacyVoicePacket = VoicePacket.tryParseText(widget.message.text);
|
||||||
final voiceSession = widget.message.voiceId != null
|
final voiceSession = widget.message.voiceId != null
|
||||||
@@ -459,6 +574,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
: null);
|
: null);
|
||||||
final rssiDbm =
|
final rssiDbm =
|
||||||
matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm;
|
matchedRxLog?.logRxDataInfo?.rssiDbm ?? widget.message.lastEchoRssiDbm;
|
||||||
|
final retryCause = _retryCauseLabel(widget.message);
|
||||||
|
final retryResult = _retryResultLabel(widget.message);
|
||||||
|
final retryMode = _retryModeLabel(widget.message);
|
||||||
|
|
||||||
final rawLines = <String>[
|
final rawLines = <String>[
|
||||||
'Message ID: ${widget.message.id}',
|
'Message ID: ${widget.message.id}',
|
||||||
@@ -483,8 +601,14 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
|
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
|
||||||
'Retry attempt: ${widget.message.retryAttempt}',
|
'Retry attempt: ${widget.message.retryAttempt}',
|
||||||
'Used flood fallback: ${widget.message.usedFloodFallback}',
|
'Used flood fallback: ${widget.message.usedFloodFallback}',
|
||||||
|
'Retry cause: ${retryCause ?? '-'}',
|
||||||
|
'Retry mode: ${retryMode ?? '-'}',
|
||||||
|
'Retry result: ${retryResult ?? '-'}',
|
||||||
'Sender key prefix: ${senderPrefixHex ?? '-'}',
|
'Sender key prefix: ${senderPrefixHex ?? '-'}',
|
||||||
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
|
'Sender name: ${senderName ?? widget.message.senderName ?? '-'}',
|
||||||
|
'Sender location at receipt: ${senderLocationSnapshot?.formattedCoordinates ?? '-'}',
|
||||||
|
'Sender location source: ${senderLocationSnapshot?.technicalSourceLabel ?? '-'}',
|
||||||
|
'Sender location timestamp: ${senderLocationSnapshot?.sourceTimestamp?.toIso8601String() ?? '-'}',
|
||||||
'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
|
'Recipient key prefix: ${recipientPrefixHex ?? '-'}',
|
||||||
'Recipient name: ${recipientName ?? '-'}',
|
'Recipient name: ${recipientName ?? '-'}',
|
||||||
'Drawing flag: ${widget.message.isDrawing}',
|
'Drawing flag: ${widget.message.isDrawing}',
|
||||||
@@ -723,6 +847,25 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
value: widget.message.expectedAckTag!
|
value: widget.message.expectedAckTag!
|
||||||
.toString(),
|
.toString(),
|
||||||
),
|
),
|
||||||
|
if (widget.message.suggestedTimeoutMs != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'ACK timeout',
|
||||||
|
value:
|
||||||
|
'${widget.message.suggestedTimeoutMs} ms',
|
||||||
|
),
|
||||||
|
if (retryCause != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Retry cause',
|
||||||
|
value: retryCause,
|
||||||
|
),
|
||||||
|
if (retryMode != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Retry mode',
|
||||||
|
value: retryMode,
|
||||||
|
),
|
||||||
if (widget.message.roundTripTimeMs != null)
|
if (widget.message.roundTripTimeMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
context,
|
||||||
@@ -733,7 +876,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
context,
|
||||||
label: l10n.retryAttempt,
|
label: l10n.retryAttempt,
|
||||||
value: widget.message.retryAttempt.toString(),
|
value: '${widget.message.retryAttempt}/3',
|
||||||
|
),
|
||||||
|
if (widget.message.lastRetryAt != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Last retry',
|
||||||
|
value: _formatRfc3339(
|
||||||
|
widget.message.lastRetryAt!,
|
||||||
|
),
|
||||||
|
onCopy: () => copyField(
|
||||||
|
_formatRfc3339(widget.message.lastRetryAt!),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (widget.message.usedFloodFallback)
|
if (widget.message.usedFloodFallback)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
@@ -741,6 +895,12 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
label: l10n.floodFallback,
|
label: l10n.floodFallback,
|
||||||
value: l10n.yes,
|
value: l10n.yes,
|
||||||
),
|
),
|
||||||
|
if (retryResult != null)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Retry result',
|
||||||
|
value: retryResult,
|
||||||
|
),
|
||||||
if (packetPathHex != null)
|
if (packetPathHex != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
context,
|
||||||
@@ -1582,6 +1742,85 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
return _hopDisplayLabel(message);
|
return _hopDisplayLabel(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _retryCauseLabel(Message message) {
|
||||||
|
if (!message.isContactMessage || message.expectedAckTag == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.deliveryStatus == MessageDeliveryStatus.sending &&
|
||||||
|
message.retryAttempt == 0) {
|
||||||
|
return 'Waiting for delivery ACK';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.retryAttempt > 0 || message.usedFloodFallback) {
|
||||||
|
return 'Delivery ACK timeout';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.deliveryStatus == MessageDeliveryStatus.failed) {
|
||||||
|
return 'Delivery confirmation not received';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _retryModeLabel(Message message) {
|
||||||
|
if (!message.isContactMessage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.usedFloodFallback) {
|
||||||
|
return 'Flood fallback';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.retryAttempt > 0 || message.expectedAckTag != null) {
|
||||||
|
return 'Learned direct path';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _retryResultLabel(Message message) {
|
||||||
|
if (!message.isContactMessage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.deliveryStatus == MessageDeliveryStatus.delivered) {
|
||||||
|
if (message.usedFloodFallback) {
|
||||||
|
return 'Delivered after flood fallback';
|
||||||
|
}
|
||||||
|
if (message.retryAttempt > 0) {
|
||||||
|
return 'Delivered after retry';
|
||||||
|
}
|
||||||
|
if (message.expectedAckTag != null) {
|
||||||
|
return 'Delivery confirmed';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.deliveryStatus == MessageDeliveryStatus.sending) {
|
||||||
|
if (message.usedFloodFallback) {
|
||||||
|
return 'Flood fallback in progress';
|
||||||
|
}
|
||||||
|
if (message.retryAttempt > 0) {
|
||||||
|
return 'Retry in progress';
|
||||||
|
}
|
||||||
|
if (message.expectedAckTag != null) {
|
||||||
|
return 'Awaiting confirmation';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.deliveryStatus == MessageDeliveryStatus.failed) {
|
||||||
|
if (message.usedFloodFallback) {
|
||||||
|
return 'Failed after flood fallback';
|
||||||
|
}
|
||||||
|
if (message.retryAttempt > 0) {
|
||||||
|
return 'Failed after retry attempts';
|
||||||
|
}
|
||||||
|
return 'Delivery failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Widget _techChip(
|
Widget _techChip(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
@@ -1805,7 +2044,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
? '${l10n.channel}: $channelDisplayName'
|
? '${l10n.channel}: $channelDisplayName'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
final shouldFloatBubble = message.isChannelMessage || widget.isCompact;
|
final shouldFloatBubble = widget.isCompact;
|
||||||
final bubble = ConstrainedBox(
|
final bubble = ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: shouldFloatBubble
|
maxWidth: shouldFloatBubble
|
||||||
@@ -1821,7 +2060,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
? null
|
? null
|
||||||
: () => _showMessageOptions(context),
|
: () => _showMessageOptions(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: EdgeInsets.zero,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: widget.isHighlighted
|
color: widget.isHighlighted
|
||||||
@@ -1946,7 +2185,11 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.draw, size: 16, color: Colors.white),
|
const Icon(
|
||||||
|
Icons.draw,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.mapDrawing,
|
AppLocalizations.of(context)!.mapDrawing,
|
||||||
@@ -1960,15 +2203,6 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
Text(
|
|
||||||
message.getLocalizedTimeAgo(context),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
fontWeight: isSarMarker
|
|
||||||
? FontWeight.w600
|
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -1990,22 +2224,19 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isOwnMessage)
|
_buildHeaderAvatar(
|
||||||
Icon(
|
context,
|
||||||
Icons.account_circle,
|
isOwnMessage: isOwnMessage,
|
||||||
size: 16,
|
isChannelMessage: message.isChannelMessage,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
senderContact: senderContact,
|
||||||
)
|
displayName: displayName,
|
||||||
else if (message.isChannelMessage)
|
),
|
||||||
const Icon(Icons.tag, size: 16)
|
|
||||||
else
|
|
||||||
const Icon(Icons.person, size: 16),
|
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Flexible(
|
||||||
|
child: Text(
|
||||||
displayName,
|
displayName,
|
||||||
style: Theme.of(context).textTheme.labelMedium
|
style: Theme.of(context).textTheme.labelMedium
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
@@ -2017,12 +2248,67 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
// Show destination/source context on a separate line.
|
),
|
||||||
if (!widget.isCompact &&
|
if (!widget.isCompact &&
|
||||||
(recipientSubtitle != null ||
|
(recipientSubtitle != null ||
|
||||||
receivedChannelSubtitle != null)) ...[
|
receivedChannelSubtitle != null)) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(width: 8),
|
||||||
Row(
|
if (message.isChannelMessage)
|
||||||
|
Flexible(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.65),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isOwnMessage
|
||||||
|
? Icons.campaign_outlined
|
||||||
|
: Icons.tag,
|
||||||
|
size: 11,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.color
|
||||||
|
?.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
isOwnMessage
|
||||||
|
? recipientDisplayName!
|
||||||
|
: channelDisplayName!,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
|
?.color
|
||||||
|
?.withValues(alpha: 0.82),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isOwnMessage
|
isOwnMessage
|
||||||
@@ -2041,7 +2327,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
isOwnMessage
|
isOwnMessage
|
||||||
? recipientSubtitle!
|
? recipientSubtitle!
|
||||||
: receivedChannelSubtitle!,
|
: receivedChannelSubtitle!,
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.labelSmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
@@ -2056,86 +2344,146 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Time for regular messages (not shown for SAR/drawing as it's already above)
|
|
||||||
if (!isSarMarker && !message.isDrawing) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
// Hop count indicator for received messages
|
|
||||||
if (!isOwnMessage && message.pathLen < 255) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(
|
|
||||||
Icons.alt_route,
|
|
||||||
size: 11,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Text(
|
|
||||||
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.labelSmall?.color?.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
|
||||||
Text(
|
|
||||||
message.getLocalizedTimeAgo(context),
|
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// SAR marker content
|
// SAR marker content
|
||||||
if (isSarMarker && message.sarMarkerType != null) ...[
|
if (isSarMarker && message.sarMarkerType != null) ...[
|
||||||
Column(
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(
|
||||||
|
alpha: isDarkMode ? 0.06 : 0.42,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: _getSarMarkerBorderColor(
|
||||||
|
context,
|
||||||
|
isDarkMode,
|
||||||
|
).withValues(alpha: 0.22),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _getSarMarkerBorderColor(
|
||||||
|
context,
|
||||||
|
isDarkMode,
|
||||||
|
).withValues(alpha: isDarkMode ? 0.2 : 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
message.sarCustomEmoji ??
|
||||||
|
message.sarMarkerType!.emoji,
|
||||||
|
style: const TextStyle(fontSize: 30, height: 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
message.sarCustomEmoji ?? message.sarMarkerType!.emoji,
|
message.sarMarkerType!.getLocalizedName(
|
||||||
style: const TextStyle(fontSize: 32),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
message.sarNotes != null &&
|
|
||||||
message.sarNotes!.isNotEmpty
|
|
||||||
? message.sarNotes!
|
|
||||||
: message.sarMarkerType!.getLocalizedName(
|
|
||||||
context,
|
context,
|
||||||
),
|
),
|
||||||
style: Theme.of(context).textTheme.titleSmall
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (message.sarNotes != null &&
|
||||||
|
message.sarNotes!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
message.sarNotes!,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
|
height: 1.25,
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.86),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!widget.isCompact)
|
if (!widget.isCompact)
|
||||||
Icon(
|
Padding(
|
||||||
Icons.chevron_right,
|
padding: const EdgeInsets.only(left: 8, top: 2),
|
||||||
size: 18,
|
child: Icon(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
Icons.chevron_right_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: _getSarMarkerBorderColor(
|
||||||
|
context,
|
||||||
|
isDarkMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (message.sarGpsCoordinates != null) ...[
|
if (message.sarGpsCoordinates != null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(
|
||||||
|
alpha: isDarkMode ? 0.18 : 0.05,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.place_outlined,
|
||||||
|
size: 15,
|
||||||
|
color: _getSarMarkerBorderColor(
|
||||||
|
context,
|
||||||
|
isDarkMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
'${message.sarGpsCoordinates!.latitude.toStringAsFixed(5)}, ${message.sarGpsCoordinates!.longitude.toStringAsFixed(5)}',
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
style: Theme.of(context).textTheme.labelMedium
|
||||||
|
?.copyWith(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
// Drawing message content (skip in compact mode - drawings hidden)
|
// Drawing message content (skip in compact mode - drawings hidden)
|
||||||
else if (message.isDrawing &&
|
else if (message.isDrawing &&
|
||||||
@@ -2201,7 +2549,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
colorName,
|
colorName,
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -2229,10 +2579,16 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
// Tic-Tac-Toe control message content
|
// Tic-Tac-Toe control message content
|
||||||
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
|
else if (ticTacToeEvent?.type == TicTacToeEventType.start &&
|
||||||
!widget.isCompact)
|
!widget.isCompact)
|
||||||
TicTacToeMessageBubble(message: message, isSentByMe: isOwnMessage)
|
TicTacToeMessageBubble(
|
||||||
|
message: message,
|
||||||
|
isSentByMe: isOwnMessage,
|
||||||
|
)
|
||||||
// Regular message content
|
// Regular message content
|
||||||
else if (!message.isDrawing || widget.isCompact)
|
else if (!message.isDrawing || widget.isCompact)
|
||||||
Text(message.text, style: Theme.of(context).textTheme.bodyMedium),
|
Text(
|
||||||
|
message.text,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
|
||||||
if (!widget.isCompact &&
|
if (!widget.isCompact &&
|
||||||
!isSarMarker &&
|
!isSarMarker &&
|
||||||
@@ -2319,7 +2675,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Icon(
|
Icon(
|
||||||
_isExpanded ? Icons.expand_less : Icons.expand_more,
|
_isExpanded
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.expand_more,
|
||||||
size: 14,
|
size: 14,
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -2343,7 +2701,9 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
AppLocalizations.of(context)!.recipientDetails,
|
AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.recipientDetails,
|
||||||
style: Theme.of(context).textTheme.labelSmall
|
style: Theme.of(context).textTheme.labelSmall
|
||||||
?.copyWith(fontWeight: FontWeight.bold),
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
@@ -2469,7 +2829,10 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withValues(alpha: 0.2),
|
color: Colors.orange.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
border: Border.all(color: Colors.orange, width: 1),
|
border: Border.all(
|
||||||
|
color: Colors.orange,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -2502,15 +2865,30 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final bubbleWithMeta = Column(
|
||||||
|
crossAxisAlignment: isOwnMessage
|
||||||
|
? CrossAxisAlignment.end
|
||||||
|
: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
bubble,
|
||||||
|
_buildBubbleMetaFooter(
|
||||||
|
context,
|
||||||
|
message: message,
|
||||||
|
isOwnMessage: isOwnMessage,
|
||||||
|
isSarMarker: isSarMarker,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
if (!shouldFloatBubble) {
|
if (!shouldFloatBubble) {
|
||||||
return bubble;
|
return bubbleWithMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: isOwnMessage
|
mainAxisAlignment: isOwnMessage
|
||||||
? MainAxisAlignment.end
|
? MainAxisAlignment.end
|
||||||
: MainAxisAlignment.start,
|
: MainAxisAlignment.start,
|
||||||
children: [Flexible(child: bubble)],
|
children: [Flexible(child: bubbleWithMeta)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
|
|
||||||
Future<_TraceResult> _loadTrace() async {
|
Future<_TraceResult> _loadTrace() async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final nodes = await MeshMapNodesService.fetchNodes();
|
final nodes = await MeshMapNodesService.fetchNodes(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
);
|
||||||
final packetPath = _extractPathFromPacketLogs(
|
final packetPath = _extractPathFromPacketLogs(
|
||||||
logs: connectionProvider.bleService.packetLogs,
|
logs: connectionProvider.bleService.packetLogs,
|
||||||
message: widget.message,
|
message: widget.message,
|
||||||
@@ -166,8 +168,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
child: hasMapPath
|
child: hasMapPath
|
||||||
? flutter_map.FlutterMap(
|
? flutter_map.FlutterMap(
|
||||||
options: flutter_map.MapOptions(
|
options: flutter_map.MapOptions(
|
||||||
initialCameraFit: flutter_map.CameraFit.bounds(
|
initialCameraFit:
|
||||||
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
|
flutter_map.CameraFit.bounds(
|
||||||
|
bounds:
|
||||||
|
flutter_map
|
||||||
|
.LatLngBounds.fromPoints(
|
||||||
|
mapPoints,
|
||||||
|
),
|
||||||
padding: const EdgeInsets.all(28),
|
padding: const EdgeInsets.all(28),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -175,14 +182,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
flutter_map.TileLayer(
|
flutter_map.TileLayer(
|
||||||
urlTemplate:
|
urlTemplate:
|
||||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
userAgentPackageName: 'com.meshcore.sar',
|
userAgentPackageName:
|
||||||
|
'com.meshcore.sar',
|
||||||
),
|
),
|
||||||
flutter_map.PolylineLayer(
|
flutter_map.PolylineLayer(
|
||||||
polylines: [
|
polylines: [
|
||||||
flutter_map.Polyline(
|
flutter_map.Polyline(
|
||||||
points: mapPoints,
|
points: mapPoints,
|
||||||
strokeWidth: 4,
|
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,
|
height: 34,
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
backgroundColor: entry.key == 0
|
backgroundColor:
|
||||||
|
entry.key == 0
|
||||||
? Colors.green
|
? Colors.green
|
||||||
: (entry.key ==
|
: (entry.key ==
|
||||||
trace
|
trace.matchedPathNodes
|
||||||
.matchedPathNodes
|
.whereType<
|
||||||
.whereType<MeshMapNode>()
|
MeshMapNode
|
||||||
|
>()
|
||||||
.length -
|
.length -
|
||||||
1
|
1
|
||||||
? Colors.red
|
? Colors.red
|
||||||
@@ -216,7 +228,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight:
|
||||||
|
FontWeight.bold,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -228,7 +241,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
: const Center(
|
: 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)
|
if (relayNodes.isEmpty)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
child: Text('No relay nodes could be matched for this message.'),
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No relay nodes could be matched for this message.',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
...relayNodes.map(
|
...relayNodes.map(
|
||||||
(node) => ListTile(
|
(node) => ListTile(
|
||||||
@@ -286,9 +306,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
|
|
||||||
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
|
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
|
||||||
if (prefixHex == null || prefixHex.isEmpty) return null;
|
if (prefixHex == null || prefixHex.isEmpty) return null;
|
||||||
final matches = nodes
|
final matches =
|
||||||
.where((n) => n.publicKey.startsWith(prefixHex))
|
nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList()
|
||||||
.toList()
|
|
||||||
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||||
return matches.isEmpty ? null : matches.first;
|
return matches.isEmpty ? null : matches.first;
|
||||||
}
|
}
|
||||||
@@ -298,7 +317,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
required Message message,
|
required Message message,
|
||||||
}) {
|
}) {
|
||||||
if (message.pathLen <= 0 || message.pathLen >= 255) return null;
|
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;
|
BlePacketLog? bestLog;
|
||||||
var bestDeltaMs = 999999999;
|
var bestDeltaMs = 999999999;
|
||||||
|
|
||||||
@@ -313,7 +334,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
if (pathLen != message.pathLen) continue;
|
if (pathLen != message.pathLen) continue;
|
||||||
if (raw.length < 5 + 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) {
|
if (deltaMs < bestDeltaMs) {
|
||||||
bestDeltaMs = deltaMs;
|
bestDeltaMs = deltaMs;
|
||||||
bestLog = log;
|
bestLog = log;
|
||||||
@@ -335,7 +357,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
final result = <MeshMapNode?>[];
|
final result = <MeshMapNode?>[];
|
||||||
for (var i = 0; i < pathHashes.length; i++) {
|
for (var i = 0; i < pathHashes.length; i++) {
|
||||||
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
|
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) {
|
if (candidates.isEmpty) {
|
||||||
result.add(null);
|
result.add(null);
|
||||||
continue;
|
continue;
|
||||||
@@ -343,7 +367,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
|
|
||||||
List<MeshMapNode> filtered = candidates;
|
List<MeshMapNode> filtered = candidates;
|
||||||
if (i == 0 && senderPrefix != null) {
|
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;
|
if (senderMatches.isNotEmpty) filtered = senderMatches;
|
||||||
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
|
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
|
||||||
final recipientMatches = filtered
|
final recipientMatches = filtered
|
||||||
@@ -366,7 +392,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
}) {
|
}) {
|
||||||
if (relayCount <= 0 || sender == null || recipient == null) return const [];
|
if (relayCount <= 0 || sender == null || recipient == null) return const [];
|
||||||
final candidates = nodes.where((n) {
|
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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -97,9 +97,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Tic-Tac-Toe · Game ${state.gameId}',
|
'Tic-Tac-Toe · Game ${state.gameId}',
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
context,
|
|
||||||
).textTheme.labelMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: titleColor,
|
color: titleColor,
|
||||||
),
|
),
|
||||||
@@ -167,7 +165,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
|
|||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
recipientPublicKey: opponent.publicKey,
|
recipientPublicKey: opponent.publicKey,
|
||||||
);
|
);
|
||||||
messagesProvider.addSentMessage(sentMessage);
|
messagesProvider.addSentMessage(sentMessage, contact: opponent);
|
||||||
|
|
||||||
final sent = await connectionProvider.sendTextMessage(
|
final sent = await connectionProvider.sendTextMessage(
|
||||||
contactPublicKey: opponent.publicKey,
|
contactPublicKey: opponent.publicKey,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import geolocator_apple
|
|||||||
import nsd_macos
|
import nsd_macos
|
||||||
import objectbox_flutter_libs
|
import objectbox_flutter_libs
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
|
import path_provider_foundation
|
||||||
import record_macos
|
import record_macos
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
@@ -34,6 +35,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
|
||||||
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
70
pubspec.lock
70
pubspec.lock
@@ -129,14 +129,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.2"
|
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:
|
codec2_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -243,7 +235,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.0"
|
version: "3.3.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: fake_async
|
name: fake_async
|
||||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||||
@@ -646,14 +638,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.5"
|
version: "0.2.5"
|
||||||
glob:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: glob
|
|
||||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.3"
|
|
||||||
gsettings:
|
gsettings:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -662,14 +646,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.8"
|
version: "0.2.8"
|
||||||
hooks:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: hooks
|
|
||||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.1"
|
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -846,14 +822,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.2"
|
version: "2.6.2"
|
||||||
logging:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: logging
|
|
||||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.0"
|
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -883,7 +851,7 @@ packages:
|
|||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: main
|
ref: main
|
||||||
resolved-ref: "11f51ccaba850531496179bf63e023cb4c9ad797"
|
resolved-ref: "3f870e98ee9527a3137bfcbdd1454036912fb609"
|
||||||
url: "https://github.com/dz0ny/meshcore_client.git"
|
url: "https://github.com/dz0ny/meshcore_client.git"
|
||||||
source: git
|
source: git
|
||||||
version: "0.1.0"
|
version: "0.1.0"
|
||||||
@@ -911,14 +879,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -991,14 +951,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.3.1"
|
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:
|
package_info_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1040,13 +992,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.22"
|
version: "2.2.22"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.6.0"
|
version: "2.5.1"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1183,14 +1135,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.5+1"
|
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:
|
record:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1702,5 +1646,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.3 <4.0.0"
|
dart: ">=3.10.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
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
|
# 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
|
# 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.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 2026.0305.3+9
|
version: 2026.0306.3+13
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -144,6 +144,10 @@ dev_dependencies:
|
|||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
flutter_launcher_icons: "^0.14.4"
|
flutter_launcher_icons: "^0.14.4"
|
||||||
|
fake_async: ^1.3.3
|
||||||
|
|
||||||
|
dependency_overrides:
|
||||||
|
path_provider_foundation: 2.5.1
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: "launcher_icon"
|
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/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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
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 'dart:typed_data';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/models/message.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/providers/messages_provider.dart';
|
||||||
import 'package:meshcore_sar_app/utils/voice_message_parser.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() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
group('MessagesProvider voice detection', () {
|
group('MessagesProvider voice detection', () {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
test('marks VE2 envelope messages as voice', () {
|
test('marks VE2 envelope messages as voice', () {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
final envelope = VoiceEnvelope(
|
final envelope = VoiceEnvelope(
|
||||||
@@ -65,5 +72,39 @@ void main() {
|
|||||||
expect(stored.isVoice, isTrue);
|
expect(stored.isVoice, isTrue);
|
||||||
expect(stored.voiceId, equals('00112233'));
|
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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
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