mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix RX advert data parsing
This commit is contained in:
@@ -30,6 +30,7 @@ class MessageReceptionDetails {
|
|||||||
final int? senderToReceiptMs;
|
final int? senderToReceiptMs;
|
||||||
final int? estimatedTransmitMs;
|
final int? estimatedTransmitMs;
|
||||||
final int? postTransmitDelayMs;
|
final int? postTransmitDelayMs;
|
||||||
|
final int receivedCopies;
|
||||||
|
|
||||||
const MessageReceptionDetails({
|
const MessageReceptionDetails({
|
||||||
required this.capturedAt,
|
required this.capturedAt,
|
||||||
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
|
|||||||
this.senderToReceiptMs,
|
this.senderToReceiptMs,
|
||||||
this.estimatedTransmitMs,
|
this.estimatedTransmitMs,
|
||||||
this.postTransmitDelayMs,
|
this.postTransmitDelayMs,
|
||||||
|
this.receivedCopies = 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||||
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
|
|||||||
'senderToReceiptMs': senderToReceiptMs,
|
'senderToReceiptMs': senderToReceiptMs,
|
||||||
'estimatedTransmitMs': estimatedTransmitMs,
|
'estimatedTransmitMs': estimatedTransmitMs,
|
||||||
'postTransmitDelayMs': postTransmitDelayMs,
|
'postTransmitDelayMs': postTransmitDelayMs,
|
||||||
|
'receivedCopies': receivedCopies,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MessageReceptionDetails copyWith({
|
||||||
|
DateTime? capturedAt,
|
||||||
|
DateTime? packetLoggedAt,
|
||||||
|
int? rssiDbm,
|
||||||
|
double? snrDb,
|
||||||
|
List<int>? pathBytes,
|
||||||
|
int? senderToReceiptMs,
|
||||||
|
int? estimatedTransmitMs,
|
||||||
|
int? postTransmitDelayMs,
|
||||||
|
int? receivedCopies,
|
||||||
|
}) {
|
||||||
|
return MessageReceptionDetails(
|
||||||
|
capturedAt: capturedAt ?? this.capturedAt,
|
||||||
|
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
|
||||||
|
rssiDbm: rssiDbm ?? this.rssiDbm,
|
||||||
|
snrDb: snrDb ?? this.snrDb,
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
|
||||||
|
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
|
||||||
|
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
|
||||||
|
receivedCopies: receivedCopies ?? this.receivedCopies,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageReceptionDetails mergeDuplicate({
|
||||||
|
MessageReceptionDetails? existing,
|
||||||
|
MessageReceptionDetails? incoming,
|
||||||
|
}) {
|
||||||
|
final base =
|
||||||
|
existing ??
|
||||||
|
incoming ??
|
||||||
|
MessageReceptionDetails(capturedAt: DateTime.now());
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
|
||||||
|
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
|
||||||
|
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
|
||||||
|
snrDb: incoming?.snrDb ?? existing?.snrDb,
|
||||||
|
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
|
||||||
|
senderToReceiptMs:
|
||||||
|
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
|
||||||
|
estimatedTransmitMs:
|
||||||
|
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
|
||||||
|
postTransmitDelayMs:
|
||||||
|
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
|
||||||
|
receivedCopies:
|
||||||
|
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
||||||
final capturedAtMillis = json['capturedAtMillis'];
|
final capturedAtMillis = json['capturedAtMillis'];
|
||||||
if (capturedAtMillis is! int) {
|
if (capturedAtMillis is! int) {
|
||||||
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
|
|||||||
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||||
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
||||||
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
||||||
|
receivedCopies: json['receivedCopies'] as int? ?? 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import '../services/nearest_router_selector.dart';
|
|||||||
import '../services/packet_capture_storage_service.dart';
|
import '../services/packet_capture_storage_service.dart';
|
||||||
import '../services/path_history_service.dart';
|
import '../services/path_history_service.dart';
|
||||||
import '../services/route_hash_preferences.dart';
|
import '../services/route_hash_preferences.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/ble_packet_log.dart';
|
import '../models/ble_packet_log.dart';
|
||||||
@@ -59,6 +60,9 @@ class _DirectMessageRouteSession {
|
|||||||
/// Main App Provider - coordinates all other providers
|
/// Main App Provider - coordinates all other providers
|
||||||
class AppProvider with ChangeNotifier {
|
class AppProvider with ChangeNotifier {
|
||||||
static const int _maxDirectPayloadHops = 3;
|
static const int _maxDirectPayloadHops = 3;
|
||||||
|
static const double _lowBatteryThresholdPercent = 30.0;
|
||||||
|
static const double _lowBatteryResetThresholdPercent = 35.0;
|
||||||
|
static const Duration _lowBatteryCheckInterval = Duration(minutes: 5);
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static bool isDeletedChannelInfo(
|
static bool isDeletedChannelInfo(
|
||||||
int channelIdx,
|
int channelIdx,
|
||||||
@@ -88,6 +92,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
LocationTrackingService();
|
LocationTrackingService();
|
||||||
final PacketCaptureStorageService packetCaptureStorageService =
|
final PacketCaptureStorageService packetCaptureStorageService =
|
||||||
PacketCaptureStorageService();
|
PacketCaptureStorageService();
|
||||||
|
final NotificationService _notificationService = NotificationService();
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool get isInitialized => _isInitialized;
|
bool get isInitialized => _isInitialized;
|
||||||
@@ -117,8 +122,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
|
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
|
||||||
double _messageFontScale = 1.0;
|
double _messageFontScale = 1.0;
|
||||||
double get messageFontScale => _messageFontScale;
|
double get messageFontScale => _messageFontScale;
|
||||||
bool _autoAddDiscoveredContacts = false;
|
|
||||||
bool get autoAddDiscoveredContacts => _autoAddDiscoveredContacts;
|
|
||||||
bool _autoRouteRotationEnabled =
|
bool _autoRouteRotationEnabled =
|
||||||
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
|
MessagingRoutePreferences.defaultAutoRouteRotationEnabled;
|
||||||
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
|
bool get autoRouteRotationEnabled => _autoRouteRotationEnabled;
|
||||||
@@ -150,11 +153,13 @@ class AppProvider with ChangeNotifier {
|
|||||||
_pendingMediaSwarmResponses = {};
|
_pendingMediaSwarmResponses = {};
|
||||||
bool _fastLocationScreenActive = false;
|
bool _fastLocationScreenActive = false;
|
||||||
Timer? _packetCaptureFlushTimer;
|
Timer? _packetCaptureFlushTimer;
|
||||||
|
Timer? _lowBatteryCheckTimer;
|
||||||
String? _lastPersistedPacketSignature;
|
String? _lastPersistedPacketSignature;
|
||||||
bool _isPersistingPacketCapture = false;
|
bool _isPersistingPacketCapture = false;
|
||||||
bool _wasDeviceConnected = false;
|
bool _wasDeviceConnected = false;
|
||||||
bool _hasCompletedConnectionBootstrap = false;
|
bool _hasCompletedConnectionBootstrap = false;
|
||||||
bool _isReconnectSyncInProgress = false;
|
bool _isReconnectSyncInProgress = false;
|
||||||
|
final Set<String> _lowBatteryNotifiedNodeIds = <String>{};
|
||||||
|
|
||||||
AppProvider({
|
AppProvider({
|
||||||
required this.connectionProvider,
|
required this.connectionProvider,
|
||||||
@@ -181,10 +186,10 @@ class AppProvider with ChangeNotifier {
|
|||||||
_loadVoiceEchoCancellationEnabled();
|
_loadVoiceEchoCancellationEnabled();
|
||||||
_loadVoiceNoiseSuppressionEnabled();
|
_loadVoiceNoiseSuppressionEnabled();
|
||||||
_loadMessageFontScale();
|
_loadMessageFontScale();
|
||||||
_loadAutoAddDiscoveredContacts();
|
|
||||||
_loadMessagingRouteSettings();
|
_loadMessagingRouteSettings();
|
||||||
unawaited(_pathHistoryService.initialize());
|
unawaited(_pathHistoryService.initialize());
|
||||||
_startPacketCapturePersistence();
|
_startPacketCapturePersistence();
|
||||||
|
_startLowBatteryWatcher();
|
||||||
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
_syncDrawingsOnStartup(); // Sync drawings immediately after providers load
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
}
|
}
|
||||||
@@ -197,6 +202,86 @@ class AppProvider with ChangeNotifier {
|
|||||||
unawaited(_flushPacketCaptureLogs());
|
unawaited(_flushPacketCaptureLogs());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _startLowBatteryWatcher() {
|
||||||
|
_lowBatteryCheckTimer?.cancel();
|
||||||
|
_lowBatteryCheckTimer = Timer.periodic(_lowBatteryCheckInterval, (_) {
|
||||||
|
unawaited(_checkLowBatteryAlerts());
|
||||||
|
});
|
||||||
|
unawaited(_checkLowBatteryAlerts());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkLowBatteryAlerts() async {
|
||||||
|
final recoveredIds = <String>{};
|
||||||
|
|
||||||
|
final deviceBattery = connectionProvider.deviceInfo.batteryPercent;
|
||||||
|
if (deviceBattery != null &&
|
||||||
|
deviceBattery > _lowBatteryResetThresholdPercent) {
|
||||||
|
recoveredIds.add('device');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final contact in contactsProvider.contacts) {
|
||||||
|
if (contact.isChannel) continue;
|
||||||
|
final battery = contact.displayBattery;
|
||||||
|
if (battery == null) continue;
|
||||||
|
if (battery > _lowBatteryResetThresholdPercent) {
|
||||||
|
recoveredIds.add(contact.publicKeyHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recoveredIds.isNotEmpty) {
|
||||||
|
_lowBatteryNotifiedNodeIds.removeAll(recoveredIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionProvider.deviceInfo.isConnected && deviceBattery != null) {
|
||||||
|
await _notifyLowBatteryIfNeeded(
|
||||||
|
nodeId: 'device',
|
||||||
|
nodeName:
|
||||||
|
connectionProvider.deviceInfo.selfName?.trim().isNotEmpty == true
|
||||||
|
? connectionProvider.deviceInfo.selfName!.trim()
|
||||||
|
: (connectionProvider.deviceInfo.displayName ?? 'Connected device'),
|
||||||
|
batteryPercent: deviceBattery,
|
||||||
|
isCurrentDevice: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final contact in contactsProvider.contacts) {
|
||||||
|
if (contact.isChannel) continue;
|
||||||
|
final battery = contact.displayBattery;
|
||||||
|
if (battery == null) continue;
|
||||||
|
|
||||||
|
await _notifyLowBatteryIfNeeded(
|
||||||
|
nodeId: contact.publicKeyHex,
|
||||||
|
nodeName: contact.displayName,
|
||||||
|
batteryPercent: battery,
|
||||||
|
isCurrentDevice: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _notifyLowBatteryIfNeeded({
|
||||||
|
required String nodeId,
|
||||||
|
required String nodeName,
|
||||||
|
required double batteryPercent,
|
||||||
|
required bool isCurrentDevice,
|
||||||
|
}) async {
|
||||||
|
if (batteryPercent >= _lowBatteryThresholdPercent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_lowBatteryNotifiedNodeIds.contains(nodeId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final shown = await _notificationService.showLowBatteryNotification(
|
||||||
|
nodeId: nodeId,
|
||||||
|
nodeName: nodeName,
|
||||||
|
batteryPercent: batteryPercent,
|
||||||
|
isCurrentDevice: isCurrentDevice,
|
||||||
|
);
|
||||||
|
if (shown) {
|
||||||
|
_lowBatteryNotifiedNodeIds.add(nodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _packetLogSignature(BlePacketLog log) {
|
String _packetLogSignature(BlePacketLog log) {
|
||||||
final prefix = log.rawData.length <= 12
|
final prefix = log.rawData.length <= 12
|
||||||
? log.rawData
|
? log.rawData
|
||||||
@@ -251,7 +336,8 @@ class AppProvider with ChangeNotifier {
|
|||||||
Future<void> _syncDrawingsOnStartup() async {
|
Future<void> _syncDrawingsOnStartup() async {
|
||||||
// Wait for both MessagesProvider and DrawingProvider to finish initializing
|
// Wait for both MessagesProvider and DrawingProvider to finish initializing
|
||||||
int attempts = 0;
|
int attempts = 0;
|
||||||
while ((!messagesProvider.isInitialized || !drawingProvider.isInitialized) &&
|
while ((!messagesProvider.isInitialized ||
|
||||||
|
!drawingProvider.isInitialized) &&
|
||||||
attempts < 40) {
|
attempts < 40) {
|
||||||
await Future.delayed(const Duration(milliseconds: 50));
|
await Future.delayed(const Duration(milliseconds: 50));
|
||||||
attempts++;
|
attempts++;
|
||||||
@@ -649,30 +735,6 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load auto-add discovered contacts setting from shared preferences.
|
|
||||||
Future<void> _loadAutoAddDiscoveredContacts() async {
|
|
||||||
try {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
_autoAddDiscoveredContacts =
|
|
||||||
prefs.getBool('auto_add_discovered_contacts') ?? false;
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error loading auto-add discovered contacts setting: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle auto-add discovered contacts on/off.
|
|
||||||
Future<void> toggleAutoAddDiscoveredContacts(bool enabled) async {
|
|
||||||
try {
|
|
||||||
_autoAddDiscoveredContacts = enabled;
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setBool('auto_add_discovered_contacts', enabled);
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error saving auto-add discovered contacts setting: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadMessagingRouteSettings() async {
|
Future<void> _loadMessagingRouteSettings() async {
|
||||||
try {
|
try {
|
||||||
_autoRouteRotationEnabled =
|
_autoRouteRotationEnabled =
|
||||||
@@ -1432,20 +1494,22 @@ class AppProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (_autoAddDiscoveredContacts) {
|
final isNewPendingAdvert = contactsProvider.addPendingAdvert(
|
||||||
debugPrint(' Unknown contact - auto-add enabled, fetching details');
|
|
||||||
Future.delayed(const Duration(milliseconds: 100), () {
|
|
||||||
if (connectionProvider.deviceInfo.isConnected) {
|
|
||||||
connectionProvider.getContact(publicKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
contactsProvider.addPendingAdvert(
|
|
||||||
publicKey,
|
publicKey,
|
||||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' Unknown contact - added to pending adverts list and waiting for details',
|
' Unknown contact - added to pending adverts list and waiting for manual resolution',
|
||||||
|
);
|
||||||
|
if (isNewPendingAdvert) {
|
||||||
|
final keyHex = publicKey
|
||||||
|
.take(6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
unawaited(
|
||||||
|
_notificationService.showContactDiscoveredNotification(
|
||||||
|
contactKey: keyHex,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3074,6 +3138,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
_imageMissingRetryAttempts.clear();
|
_imageMissingRetryAttempts.clear();
|
||||||
_voiceSessionSenderKey6.clear();
|
_voiceSessionSenderKey6.clear();
|
||||||
_imageSessionSenderKey6.clear();
|
_imageSessionSenderKey6.clear();
|
||||||
|
_lowBatteryNotifiedNodeIds.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3094,6 +3159,7 @@ class AppProvider with ChangeNotifier {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_packetCaptureFlushTimer?.cancel();
|
_packetCaptureFlushTimer?.cancel();
|
||||||
|
_lowBatteryCheckTimer?.cancel();
|
||||||
unawaited(_flushPacketCaptureLogs());
|
unawaited(_flushPacketCaptureLogs());
|
||||||
// Remove connection state listener
|
// Remove connection state listener
|
||||||
connectionProvider.removeListener(_handleConnectionStateChange);
|
connectionProvider.removeListener(_handleConnectionStateChange);
|
||||||
|
|||||||
@@ -2255,7 +2255,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
if (!_activeService.isConnected) {
|
if (!_activeService.isConnected) {
|
||||||
_error = 'Not connected to device';
|
_error = 'Not connected to device';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
throw Exception(_error);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -2263,6 +2263,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to remove contact: $e';
|
_error = 'Failed to remove contact: $e';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
@@ -66,6 +67,8 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _isPersisting = false;
|
bool _isPersisting = false;
|
||||||
bool _persistRequested = false;
|
bool _persistRequested = false;
|
||||||
|
bool _isPersistingPendingAdverts = false;
|
||||||
|
bool _persistPendingAdvertsRequested = false;
|
||||||
|
|
||||||
// Add default public channel on initialization
|
// Add default public channel on initialization
|
||||||
ContactsProvider() {
|
ContactsProvider() {
|
||||||
@@ -85,6 +88,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
final storedContacts = await _storageService.loadContacts();
|
final storedContacts = await _storageService.loadContacts();
|
||||||
final storedGroups = await _storageService.loadContactGroups();
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
final storedPendingAdverts = await _storageService.loadPendingAdverts();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -101,6 +105,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_savedContactGroups
|
_savedContactGroups
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedGroups);
|
..addAll(storedGroups);
|
||||||
|
_restorePendingAdverts(storedPendingAdverts);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
@@ -133,6 +138,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
excludePublicKey: devicePublicKey,
|
excludePublicKey: devicePublicKey,
|
||||||
);
|
);
|
||||||
final storedGroups = await _storageService.loadContactGroups();
|
final storedGroups = await _storageService.loadContactGroups();
|
||||||
|
final storedPendingAdverts = await _storageService.loadPendingAdverts();
|
||||||
|
|
||||||
// Add stored contacts (excluding any with all-zeros public key)
|
// Add stored contacts (excluding any with all-zeros public key)
|
||||||
const publicChannelKey =
|
const publicChannelKey =
|
||||||
@@ -149,6 +155,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_savedContactGroups
|
_savedContactGroups
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedGroups);
|
..addAll(storedGroups);
|
||||||
|
_restorePendingAdverts(
|
||||||
|
storedPendingAdverts,
|
||||||
|
devicePublicKey: devicePublicKey,
|
||||||
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
|
||||||
);
|
);
|
||||||
@@ -212,14 +222,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
while (_persistRequested) {
|
while (_persistRequested) {
|
||||||
_persistRequested = false;
|
_persistRequested = false;
|
||||||
// Don't persist the public channel pseudo-contact (all zeros key)
|
await _storageService.saveContacts(_contactsForStorage());
|
||||||
const publicChannelKey =
|
|
||||||
'0000000000000000000000000000000000000000000000000000000000000000';
|
|
||||||
final contactsToSave = _contacts.entries
|
|
||||||
.where((entry) => entry.key != publicChannelKey)
|
|
||||||
.map((entry) => entry.value)
|
|
||||||
.toList();
|
|
||||||
await _storageService.saveContacts(contactsToSave);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
|
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
|
||||||
@@ -228,6 +231,24 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _persistPendingAdverts() async {
|
||||||
|
_persistPendingAdvertsRequested = true;
|
||||||
|
if (_isPersistingPendingAdverts) return;
|
||||||
|
_isPersistingPendingAdverts = true;
|
||||||
|
try {
|
||||||
|
while (_persistPendingAdvertsRequested) {
|
||||||
|
_persistPendingAdvertsRequested = false;
|
||||||
|
await _storageService.savePendingAdverts(
|
||||||
|
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e');
|
||||||
|
} finally {
|
||||||
|
_isPersistingPendingAdverts = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<Contact> get contacts => _contacts.values.toList();
|
List<Contact> get contacts => _contacts.values.toList();
|
||||||
List<SavedContactGroup> get savedContactGroups =>
|
List<SavedContactGroup> get savedContactGroups =>
|
||||||
List<SavedContactGroup>.from(_savedContactGroups)
|
List<SavedContactGroup>.from(_savedContactGroups)
|
||||||
@@ -450,6 +471,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
||||||
);
|
);
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
debugPrint(' 🔔 notifyListeners() called');
|
debugPrint(' 🔔 notifyListeners() called');
|
||||||
}
|
}
|
||||||
@@ -481,6 +503,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1073,9 +1096,9 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// 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}) {
|
bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
|
||||||
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final keyHex = publicKey
|
final keyHex = publicKey
|
||||||
@@ -1083,20 +1106,26 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
.join('');
|
.join('');
|
||||||
if (_contacts.containsKey(keyHex)) {
|
if (_contacts.containsKey(keyHex)) {
|
||||||
_pendingAdverts.remove(keyHex);
|
_pendingAdverts.remove(keyHex);
|
||||||
return;
|
_persistPendingAdverts();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final existing = _pendingAdverts[keyHex];
|
final existing = _pendingAdverts[keyHex];
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
|
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
|
||||||
|
_persistPendingAdverts();
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
} else {
|
} else {
|
||||||
_pendingAdverts[keyHex] = PendingAdvert(
|
_pendingAdverts[keyHex] = PendingAdvert(
|
||||||
publicKey: Uint8List.fromList(publicKey),
|
publicKey: Uint8List.fromList(publicKey),
|
||||||
receivedAt: now,
|
receivedAt: now,
|
||||||
);
|
);
|
||||||
}
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Find contact by name
|
/// Find contact by name
|
||||||
@@ -1155,6 +1184,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_pendingAdverts.clear();
|
_pendingAdverts.clear();
|
||||||
_ensurePublicChannelExists();
|
_ensurePublicChannelExists();
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,6 +1207,7 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_contacts.remove(publicKeyHex);
|
_contacts.remove(publicKeyHex);
|
||||||
_pendingAdverts.remove(publicKeyHex);
|
_pendingAdverts.remove(publicKeyHex);
|
||||||
_persistContacts();
|
_persistContacts();
|
||||||
|
_persistPendingAdverts();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1185,6 +1216,74 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return await _storageService.getStorageStats();
|
return await _storageService.getStorageStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> persistNow() async {
|
||||||
|
await _storageService.saveContacts(_contactsForStorage());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Contact> _contactsForStorage() {
|
||||||
|
// Don't persist the public channel pseudo-contact (all zeros key)
|
||||||
|
const publicChannelKey =
|
||||||
|
'0000000000000000000000000000000000000000000000000000000000000000';
|
||||||
|
return _contacts.entries
|
||||||
|
.where((entry) => entry.key != publicChannelKey)
|
||||||
|
.map((entry) => entry.value)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restorePendingAdverts(
|
||||||
|
List<Map<String, dynamic>> storedPendingAdverts, {
|
||||||
|
Uint8List? devicePublicKey,
|
||||||
|
}) {
|
||||||
|
_pendingAdverts.clear();
|
||||||
|
for (final json in storedPendingAdverts) {
|
||||||
|
final advert = _pendingAdvertFromJson(json);
|
||||||
|
if (advert == null) continue;
|
||||||
|
if (devicePublicKey != null &&
|
||||||
|
advert.publicKey.matches(devicePublicKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (_contacts.containsKey(advert.publicKeyHex)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_pendingAdverts[advert.publicKeyHex] = advert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
|
||||||
|
return {
|
||||||
|
'publicKey': base64Encode(advert.publicKey),
|
||||||
|
'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch,
|
||||||
|
'signedEncodedPathLen': advert.signedEncodedPathLen,
|
||||||
|
'paddedPathBytes': advert.paddedPathBytes == null
|
||||||
|
? null
|
||||||
|
: base64Encode(advert.paddedPathBytes!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingAdvert? _pendingAdvertFromJson(Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return PendingAdvert(
|
||||||
|
publicKey: Uint8List.fromList(
|
||||||
|
base64Decode(json['publicKey'] as String),
|
||||||
|
),
|
||||||
|
receivedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['receivedAtMillis'] as int,
|
||||||
|
),
|
||||||
|
signedEncodedPathLen: json['signedEncodedPathLen'] as int?,
|
||||||
|
paddedPathBytes: json['paddedPathBytes'] == null
|
||||||
|
? null
|
||||||
|
: Uint8List.fromList(
|
||||||
|
base64Decode(json['paddedPathBytes'] as String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [ContactsProvider] Error parsing pending advert from JSON: $e',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get contact count by type
|
/// Get contact count by type
|
||||||
Map<String, int> get contactCounts {
|
Map<String, int> get contactCounts {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import '../utils/image_message_parser.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'helpers/message_retry_manager.dart';
|
import 'helpers/message_retry_manager.dart';
|
||||||
|
|
||||||
|
typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
|
||||||
|
|
||||||
/// Messages Provider - manages message history and SAR markers
|
/// Messages Provider - manages message history and SAR markers
|
||||||
class MessagesProvider with ChangeNotifier {
|
class MessagesProvider with ChangeNotifier {
|
||||||
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
|
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
|
||||||
@@ -591,8 +593,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
if (contactLocationSnapshot != null) {
|
if (contactLocationSnapshot != null) {
|
||||||
_messageContactLocations[existingId] = contactLocationSnapshot;
|
_messageContactLocations[existingId] = contactLocationSnapshot;
|
||||||
}
|
}
|
||||||
|
_messageReceptionDetails[existingId] =
|
||||||
|
MessageReceptionDetails.mergeDuplicate(
|
||||||
|
existing: _messageReceptionDetails[existingId],
|
||||||
|
incoming: receptionDetailsSnapshot,
|
||||||
|
);
|
||||||
if (receptionDetailsSnapshot != null) {
|
if (receptionDetailsSnapshot != null) {
|
||||||
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
|
|
||||||
final existingMessage = _messages[duplicateIndex];
|
final existingMessage = _messages[duplicateIndex];
|
||||||
final duplicatePathBytes = receptionDetailsSnapshot.pathBytes;
|
final duplicatePathBytes = receptionDetailsSnapshot.pathBytes;
|
||||||
if (existingMessage.pathBytes == null &&
|
if (existingMessage.pathBytes == null &&
|
||||||
@@ -775,7 +781,13 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
|
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
|
||||||
|
|
||||||
// Check for duplicates
|
// Check for duplicates
|
||||||
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
|
final duplicateIndex = _findDuplicateMessageIndex(enhancedMessage);
|
||||||
|
if (duplicateIndex != -1) {
|
||||||
|
final existingId = _messages[duplicateIndex].id;
|
||||||
|
_messageReceptionDetails[existingId] =
|
||||||
|
MessageReceptionDetails.mergeDuplicate(
|
||||||
|
existing: _messageReceptionDetails[existingId],
|
||||||
|
);
|
||||||
duplicateCount++;
|
duplicateCount++;
|
||||||
continue; // Skip duplicate
|
continue; // Skip duplicate
|
||||||
}
|
}
|
||||||
@@ -1026,7 +1038,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
/// overlapping serialization and redundant SharedPreferences writes.
|
/// overlapping serialization and redundant SharedPreferences writes.
|
||||||
Future<void> _persistMessages() async {
|
Future<void> _persistMessages() async {
|
||||||
_persistRequested = true;
|
_persistRequested = true;
|
||||||
if (_isPersisting) return; // A write is in flight; it will pick up our changes.
|
if (_isPersisting) {
|
||||||
|
return; // A write is in flight; it will pick up our changes.
|
||||||
|
}
|
||||||
_isPersisting = true;
|
_isPersisting = true;
|
||||||
try {
|
try {
|
||||||
while (_persistRequested) {
|
while (_persistRequested) {
|
||||||
@@ -1121,6 +1135,35 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return sorted.take(count).toList();
|
return sorted.take(count).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<DisplayMessageEntry> buildDisplayMessages(Iterable<Message> messages) {
|
||||||
|
final entries = <DisplayMessageEntry>[];
|
||||||
|
|
||||||
|
for (final message in messages) {
|
||||||
|
final occurrenceCount = _messageOccurrenceCount(message);
|
||||||
|
final existingIndex = entries.indexWhere(
|
||||||
|
(entry) =>
|
||||||
|
entry.message.text == message.text &&
|
||||||
|
_matchesDuplicateScope(entry.message, message),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingIndex == -1) {
|
||||||
|
entries.add((message: message, occurrenceCount: occurrenceCount));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existingEntry = entries[existingIndex];
|
||||||
|
entries[existingIndex] = (
|
||||||
|
message: existingEntry.message,
|
||||||
|
occurrenceCount: existingEntry.occurrenceCount + occurrenceCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _messageOccurrenceCount(Message message) =>
|
||||||
|
_messageReceptionDetails[message.id]?.receivedCopies ?? 1;
|
||||||
|
|
||||||
/// Get messages from last N hours
|
/// Get messages from last N hours
|
||||||
List<Message> getMessagesSince(Duration duration) {
|
List<Message> getMessagesSince(Duration duration) {
|
||||||
final cutoff = DateTime.now().subtract(duration);
|
final cutoff = DateTime.now().subtract(duration);
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ class ContactsTab extends StatefulWidget {
|
|||||||
|
|
||||||
class _ContactsTabState extends State<ContactsTab> {
|
class _ContactsTabState extends State<ContactsTab> {
|
||||||
Position? _currentPosition;
|
Position? _currentPosition;
|
||||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
|
||||||
bool _isResolvingPendingBatch = false;
|
|
||||||
final Map<ContactSection, String> _sectionFilters = {
|
final Map<ContactSection, String> _sectionFilters = {
|
||||||
ContactSection.teamMembers: '',
|
ContactSection.teamMembers: '',
|
||||||
ContactSection.repeaters: '',
|
ContactSection.repeaters: '',
|
||||||
@@ -99,57 +97,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
await _getCurrentLocation();
|
await _getCurrentLocation();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleResolveAdvert(PendingAdvert advert) async {
|
|
||||||
final keyHex = advert.publicKeyHex;
|
|
||||||
if (_resolvingAdvertKeys.contains(keyHex)) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_resolvingAdvertKeys.add(keyHex);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await context.read<ConnectionProvider>().getContact(advert.publicKey);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_resolvingAdvertKeys.remove(keyHex);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _schedulePendingAdvertResolution(
|
|
||||||
List<PendingAdvert> pendingAdverts,
|
|
||||||
ConnectionProvider connectionProvider,
|
|
||||||
) {
|
|
||||||
if (_isResolvingPendingBatch ||
|
|
||||||
!connectionProvider.deviceInfo.isConnected ||
|
|
||||||
pendingAdverts.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final advertsToResolve = pendingAdverts
|
|
||||||
.where((advert) => !_resolvingAdvertKeys.contains(advert.publicKeyHex))
|
|
||||||
.toList();
|
|
||||||
if (advertsToResolve.isEmpty) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
||||||
if (!mounted || _isResolvingPendingBatch) return;
|
|
||||||
|
|
||||||
_isResolvingPendingBatch = true;
|
|
||||||
try {
|
|
||||||
for (final advert in advertsToResolve) {
|
|
||||||
if (!mounted) break;
|
|
||||||
await _handleResolveAdvert(advert);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
_isResolvingPendingBatch = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate distance between two points in meters
|
/// Calculate distance between two points in meters
|
||||||
double _calculateDistanceInMeters(
|
double _calculateDistanceInMeters(
|
||||||
double lat1,
|
double lat1,
|
||||||
@@ -181,15 +128,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatRelativeTime(BuildContext context, DateTime when) {
|
|
||||||
final l10n = AppLocalizations.of(context)!;
|
|
||||||
final diff = DateTime.now().difference(when);
|
|
||||||
if (diff.inMinutes < 1) return l10n.justNow;
|
|
||||||
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
|
|
||||||
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
|
||||||
return l10n.daysAgo(diff.inDays);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Contact> _filterContactsForSection(
|
List<Contact> _filterContactsForSection(
|
||||||
List<Contact> contacts,
|
List<Contact> contacts,
|
||||||
ContactSection section,
|
ContactSection section,
|
||||||
@@ -593,7 +531,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
body: Consumer<ContactsProvider>(
|
body: Consumer<ContactsProvider>(
|
||||||
builder: (context, contactsProvider, child) {
|
builder: (context, contactsProvider, child) {
|
||||||
final messagesProvider = context.watch<MessagesProvider>();
|
final messagesProvider = context.watch<MessagesProvider>();
|
||||||
final connectionProvider = context.watch<ConnectionProvider>();
|
|
||||||
final allChatContacts = _sortContacts(
|
final allChatContacts = _sortContacts(
|
||||||
contactsProvider.chatContacts,
|
contactsProvider.chatContacts,
|
||||||
ContactSection.teamMembers,
|
ContactSection.teamMembers,
|
||||||
@@ -673,17 +610,12 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
final showRepeatersSection = allRepeaters.isNotEmpty;
|
final showRepeatersSection = allRepeaters.isNotEmpty;
|
||||||
final showRoomsSection = allRooms.isNotEmpty;
|
final showRoomsSection = allRooms.isNotEmpty;
|
||||||
final showChannelsSection = allChannels.isNotEmpty;
|
final showChannelsSection = allChannels.isNotEmpty;
|
||||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
|
||||||
|
|
||||||
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
|
|
||||||
|
|
||||||
// Check if there are any displayable contacts
|
// Check if there are any displayable contacts
|
||||||
final hasDisplayableContacts =
|
final hasDisplayableContacts =
|
||||||
allChatContacts.isNotEmpty ||
|
allChatContacts.isNotEmpty ||
|
||||||
allRepeaters.isNotEmpty ||
|
allRepeaters.isNotEmpty ||
|
||||||
allRooms.isNotEmpty ||
|
allRooms.isNotEmpty ||
|
||||||
allChannels.isNotEmpty ||
|
allChannels.isNotEmpty;
|
||||||
pendingAdverts.isNotEmpty;
|
|
||||||
|
|
||||||
if (!hasDisplayableContacts) {
|
if (!hasDisplayableContacts) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -838,27 +770,6 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Pending adverts are kept below resolved sections while we load details.
|
|
||||||
if (pendingAdverts.isNotEmpty) ...[
|
|
||||||
_SectionHeader(
|
|
||||||
title: l10n.pending,
|
|
||||||
count: pendingAdverts.length,
|
|
||||||
icon: Icons.person_search,
|
|
||||||
),
|
|
||||||
...pendingAdverts.map(
|
|
||||||
(advert) => _PendingAdvertTile(
|
|
||||||
advert: advert,
|
|
||||||
subtitle:
|
|
||||||
'${l10n.publicKey}: ${advert.shortDisplayKey}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
|
||||||
isResolving: _resolvingAdvertKeys.contains(
|
|
||||||
advert.publicKeyHex,
|
|
||||||
),
|
|
||||||
onResolve: () => _handleResolveAdvert(advert),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Channels (visible in both simple and advanced mode)
|
// Channels (visible in both simple and advanced mode)
|
||||||
if (showChannelsSection) ...[
|
if (showChannelsSection) ...[
|
||||||
_SectionHeader(
|
_SectionHeader(
|
||||||
@@ -1263,46 +1174,6 @@ enum ContactSortMode { lastSeen, distance }
|
|||||||
|
|
||||||
enum ContactSection { teamMembers, repeaters, rooms, channels }
|
enum ContactSection { teamMembers, repeaters, rooms, channels }
|
||||||
|
|
||||||
class _PendingAdvertTile extends StatelessWidget {
|
|
||||||
final PendingAdvert advert;
|
|
||||||
final String subtitle;
|
|
||||||
final bool isResolving;
|
|
||||||
final VoidCallback onResolve;
|
|
||||||
|
|
||||||
const _PendingAdvertTile({
|
|
||||||
required this.advert,
|
|
||||||
required this.subtitle,
|
|
||||||
required this.isResolving,
|
|
||||||
required this.onResolve,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: ListTile(
|
|
||||||
leading: const CircleAvatar(child: Icon(Icons.campaign_outlined)),
|
|
||||||
title: Text(
|
|
||||||
advert.shortDisplayKey,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: Text(subtitle),
|
|
||||||
trailing: isResolving
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: IconButton(
|
|
||||||
icon: const Icon(Icons.person_add_alt_1),
|
|
||||||
tooltip: 'Resolve contact',
|
|
||||||
onPressed: onResolve,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RenderedSavedGroup {
|
class _RenderedSavedGroup {
|
||||||
final SavedContactGroup group;
|
final SavedContactGroup group;
|
||||||
final List<Contact> contacts;
|
final List<Contact> contacts;
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/device_info.dart';
|
import '../models/device_info.dart';
|
||||||
|
import '../models/channel.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../providers/channels_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
import '../services/validation_service.dart';
|
import '../services/validation_service.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -14,6 +20,10 @@ class DeviceConfigScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
||||||
|
static const int _bulkDeleteBatchSize = 8;
|
||||||
|
static const Duration _bulkDeleteInterItemDelay = Duration(milliseconds: 120);
|
||||||
|
static const Duration _bulkDeleteBatchDelay = Duration(milliseconds: 700);
|
||||||
|
static const Duration _bulkDeleteFinalSyncDelay = Duration(milliseconds: 900);
|
||||||
static const List<_RadioPreset> _radioPresets = [
|
static const List<_RadioPreset> _radioPresets = [
|
||||||
_RadioPreset(
|
_RadioPreset(
|
||||||
id: 'australia',
|
id: 'australia',
|
||||||
@@ -169,9 +179,12 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
|
|
||||||
bool _telemetryEnabled = false;
|
bool _telemetryEnabled = false;
|
||||||
bool _repeatEnabled = false;
|
bool _repeatEnabled = false;
|
||||||
|
bool _autoAddDiscoveredContactsEnabled = true;
|
||||||
bool _showCustomRadioSettings = false;
|
bool _showCustomRadioSettings = false;
|
||||||
bool _isSavingPublicInfo = false;
|
bool _isSavingPublicInfo = false;
|
||||||
bool _isSavingRadioSettings = false;
|
bool _isSavingRadioSettings = false;
|
||||||
|
bool _isClearingContacts = false;
|
||||||
|
bool _isClearingChannels = false;
|
||||||
bool _publicInfoSaved = false;
|
bool _publicInfoSaved = false;
|
||||||
bool _radioSettingsSaved = false;
|
bool _radioSettingsSaved = false;
|
||||||
String? _publicInfoError;
|
String? _publicInfoError;
|
||||||
@@ -252,6 +265,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
|
|
||||||
// Initialize repeat mode from device info (firmware v9+)
|
// Initialize repeat mode from device info (firmware v9+)
|
||||||
_repeatEnabled = deviceInfo.clientRepeat ?? false;
|
_repeatEnabled = deviceInfo.clientRepeat ?? false;
|
||||||
|
_autoAddDiscoveredContactsEnabled =
|
||||||
|
!(deviceInfo.manualAddContacts ?? false);
|
||||||
|
|
||||||
// Fetch allowed repeat frequencies on open if device supports repeat mode
|
// Fetch allowed repeat frequencies on open if device supports repeat mode
|
||||||
if (deviceInfo.clientRepeat != null &&
|
if (deviceInfo.clientRepeat != null &&
|
||||||
@@ -377,7 +392,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
|
|
||||||
Future<void> _savePublicInfo() async {
|
Future<void> _savePublicInfo() async {
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
final deviceInfo = connectionProvider.deviceInfo;
|
|
||||||
final validator = ValidationService();
|
final validator = ValidationService();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -387,6 +401,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final manualAddContacts = _autoAddDiscoveredContactsEnabled ? 0 : 1;
|
||||||
|
|
||||||
// Save name
|
// Save name
|
||||||
if (_nameController.text.isNotEmpty) {
|
if (_nameController.text.isNotEmpty) {
|
||||||
await connectionProvider.setAdvertName(_nameController.text);
|
await connectionProvider.setAdvertName(_nameController.text);
|
||||||
@@ -425,7 +441,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
|
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
|
||||||
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
|
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
|
||||||
await connectionProvider.setOtherParams(
|
await connectionProvider.setOtherParams(
|
||||||
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
|
manualAddContacts: manualAddContacts,
|
||||||
telemetryModes: telemetryModes,
|
telemetryModes: telemetryModes,
|
||||||
advertLocationPolicy: 1,
|
advertLocationPolicy: 1,
|
||||||
);
|
);
|
||||||
@@ -436,7 +452,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
// Set telemetry modes to "Deny" (mode 0)
|
// Set telemetry modes to "Deny" (mode 0)
|
||||||
final telemetryModes = 0x00;
|
final telemetryModes = 0x00;
|
||||||
await connectionProvider.setOtherParams(
|
await connectionProvider.setOtherParams(
|
||||||
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
|
manualAddContacts: manualAddContacts,
|
||||||
telemetryModes: telemetryModes,
|
telemetryModes: telemetryModes,
|
||||||
advertLocationPolicy: 0,
|
advertLocationPolicy: 0,
|
||||||
);
|
);
|
||||||
@@ -687,6 +703,205 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmClearAllContacts() async {
|
||||||
|
final contacts = context
|
||||||
|
.read<ContactsProvider>()
|
||||||
|
.contacts
|
||||||
|
.where((contact) => !contact.isChannel)
|
||||||
|
.toList();
|
||||||
|
if (contacts.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No device contacts to clear.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Clear all contacts'),
|
||||||
|
content: Text(
|
||||||
|
'This will remove ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the connected device. Channels and radio settings will not be changed.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Clear contacts'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
connectionProvider.clearError();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isClearingContacts = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var processed = 0;
|
||||||
|
for (final contact in List<Contact>.from(contacts)) {
|
||||||
|
await contactsProvider.removeContact(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
onRemoveFromDevice: connectionProvider.removeContact,
|
||||||
|
);
|
||||||
|
processed++;
|
||||||
|
await Future.delayed(_bulkDeleteInterItemDelay);
|
||||||
|
if (processed % _bulkDeleteBatchSize == 0) {
|
||||||
|
await Future.delayed(_bulkDeleteBatchDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the updated local contact set to storage immediately.
|
||||||
|
await contactsProvider.persistNow();
|
||||||
|
await Future.delayed(_bulkDeleteFinalSyncDelay);
|
||||||
|
await connectionProvider.getContacts();
|
||||||
|
if (connectionProvider.error != null) {
|
||||||
|
throw Exception(connectionProvider.error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Cleared ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the device.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to clear contacts: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isClearingContacts = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmClearAllChannels() async {
|
||||||
|
final channels = context
|
||||||
|
.read<ChannelsProvider>()
|
||||||
|
.channels
|
||||||
|
.where((channel) => !channel.isPublicChannel && channel.name.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (channels.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No custom channels to clear.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Clear all channels'),
|
||||||
|
content: Text(
|
||||||
|
'This will remove ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the connected device. Contacts and radio settings will not be changed.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: Text(AppLocalizations.of(context)!.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('Clear channels'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
final channelsProvider = context.read<ChannelsProvider>();
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
connectionProvider.clearError();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isClearingChannels = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var processed = 0;
|
||||||
|
for (final channel in List<Channel>.from(channels)) {
|
||||||
|
await connectionProvider.deleteChannel(channel.index);
|
||||||
|
processed++;
|
||||||
|
await Future.delayed(_bulkDeleteInterItemDelay);
|
||||||
|
if (processed % _bulkDeleteBatchSize == 0) {
|
||||||
|
await Future.delayed(_bulkDeleteBatchDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force local channel/contact cache cleanup before the device resync.
|
||||||
|
for (final channel in channels) {
|
||||||
|
channelsProvider.removeChannel(channel.index);
|
||||||
|
final publicKeyBytes = Uint8List(32);
|
||||||
|
publicKeyBytes[0] = 0xFF;
|
||||||
|
publicKeyBytes[1] = channel.index;
|
||||||
|
final publicKeyHex = publicKeyBytes
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join('');
|
||||||
|
await contactsProvider.removeContact(publicKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
await contactsProvider.persistNow();
|
||||||
|
await Future.delayed(_bulkDeleteFinalSyncDelay);
|
||||||
|
await connectionProvider.syncChannels();
|
||||||
|
if (connectionProvider.error != null) {
|
||||||
|
throw Exception(connectionProvider.error!);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Cleared ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the device.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to clear channels: $e'),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isClearingChannels = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
||||||
@@ -788,6 +1003,28 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
_SettingHighlightCard(
|
||||||
|
icon: _autoAddDiscoveredContactsEnabled
|
||||||
|
? Icons.person_add_alt_1
|
||||||
|
: Icons.person_add_disabled,
|
||||||
|
title: 'Auto-add discovered contacts',
|
||||||
|
description:
|
||||||
|
'Control whether the device automatically stores newly discovered contacts.',
|
||||||
|
accentColor: _autoAddDiscoveredContactsEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
trailing: Switch(
|
||||||
|
value: _autoAddDiscoveredContactsEnabled,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_autoAddDiscoveredContactsEnabled = value;
|
||||||
|
_publicInfoSaved = false;
|
||||||
|
_publicInfoError = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
_SettingHighlightCard(
|
_SettingHighlightCard(
|
||||||
icon: _telemetryEnabled
|
icon: _telemetryEnabled
|
||||||
? Icons.travel_explore
|
? Icons.travel_explore
|
||||||
@@ -1230,10 +1467,60 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isClearingContacts || _isClearingChannels
|
||||||
|
? null
|
||||||
|
: _confirmClearAllContacts,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
|
side: BorderSide(color: colorScheme.error),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
icon: _isClearingContacts
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.people_alt_outlined),
|
||||||
|
label: const Text('Clear all contacts'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _isClearingContacts || _isClearingChannels
|
||||||
|
? null
|
||||||
|
: _confirmClearAllChannels,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: colorScheme.error,
|
||||||
|
side: BorderSide(color: colorScheme.error),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
),
|
||||||
|
icon: _isClearingChannels
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.forum_outlined),
|
||||||
|
label: const Text('Clear all channels'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _confirmFactoryReset,
|
onPressed: _isClearingContacts || _isClearingChannels
|
||||||
|
? null
|
||||||
|
: _confirmFactoryReset,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: colorScheme.error,
|
backgroundColor: colorScheme.error,
|
||||||
foregroundColor: colorScheme.onError,
|
foregroundColor: colorScheme.onError,
|
||||||
|
|||||||
231
lib/screens/discovery_screen.dart
Normal file
231
lib/screens/discovery_screen.dart
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
|
class DiscoveryScreen extends StatefulWidget {
|
||||||
|
const DiscoveryScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||||
|
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||||
|
bool _isResolvingAll = false;
|
||||||
|
late final Future<List<MeshMapNode>> _cachedNodesFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolveAdvert(PendingAdvert advert) async {
|
||||||
|
final keyHex = advert.publicKeyHex;
|
||||||
|
if (_resolvingAdvertKeys.contains(keyHex)) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_resolvingAdvertKeys.add(keyHex);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await context.read<ConnectionProvider>().getContact(advert.publicKey);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_resolvingAdvertKeys.remove(keyHex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _resolveAll(List<PendingAdvert> adverts) async {
|
||||||
|
if (_isResolvingAll || adverts.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isResolvingAll = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (final advert in adverts) {
|
||||||
|
if (!mounted) break;
|
||||||
|
await _resolveAdvert(advert);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isResolvingAll = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatRelativeTime(BuildContext context, DateTime when) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final diff = DateTime.now().difference(when);
|
||||||
|
if (diff.inMinutes < 1) return l10n.justNow;
|
||||||
|
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
|
||||||
|
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
||||||
|
return l10n.daysAgo(diff.inDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayNameForAdvert(
|
||||||
|
PendingAdvert advert,
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
List<MeshMapNode> cachedNodes,
|
||||||
|
) {
|
||||||
|
Contact? existingMatch;
|
||||||
|
for (final contact in contactsProvider.contacts) {
|
||||||
|
if (contact.publicKeyHex == advert.publicKeyHex) {
|
||||||
|
existingMatch = contact;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) {
|
||||||
|
return existingMatch.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
MeshMapNode? cachedMatch;
|
||||||
|
for (final node in cachedNodes) {
|
||||||
|
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
||||||
|
cachedMatch = node;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) {
|
||||||
|
return cachedMatch.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return advert.shortDisplayKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Discovery')),
|
||||||
|
body: FutureBuilder<List<MeshMapNode>>(
|
||||||
|
future: _cachedNodesFuture,
|
||||||
|
builder: (context, nodesSnapshot) =>
|
||||||
|
Consumer2<ContactsProvider, ConnectionProvider>(
|
||||||
|
builder: (context, contactsProvider, connectionProvider, child) {
|
||||||
|
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||||
|
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
|
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||||
|
|
||||||
|
if (pendingAdverts.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.person_search_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).disabledColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No pending discoveries',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Unknown adverts will appear here until you choose to resolve them.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.person_search),
|
||||||
|
title: Text(
|
||||||
|
'Pending discoveries (${pendingAdverts.length})',
|
||||||
|
),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Resolve entries manually so they do not auto-populate contacts.',
|
||||||
|
),
|
||||||
|
trailing: FilledButton.icon(
|
||||||
|
onPressed: isConnected && !_isResolvingAll
|
||||||
|
? () => _resolveAll(pendingAdverts)
|
||||||
|
: null,
|
||||||
|
icon: _isResolvingAll
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download_for_offline_outlined),
|
||||||
|
label: const Text('Resolve all'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...pendingAdverts.map((advert) {
|
||||||
|
final isResolving = _resolvingAdvertKeys.contains(
|
||||||
|
advert.publicKeyHex,
|
||||||
|
);
|
||||||
|
final displayName = _displayNameForAdvert(
|
||||||
|
advert,
|
||||||
|
contactsProvider,
|
||||||
|
cachedNodes,
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: const CircleAvatar(
|
||||||
|
child: Icon(Icons.campaign_outlined),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
displayName,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${l10n.publicKey}: ${advert.shortDisplayKey}\n'
|
||||||
|
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||||
|
),
|
||||||
|
trailing: isResolving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.person_add_alt_1),
|
||||||
|
tooltip: 'Resolve contact',
|
||||||
|
onPressed: isConnected
|
||||||
|
? () => _resolveAdvert(advert)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import '../providers/contacts_provider.dart';
|
|||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'messages_tab.dart';
|
import 'messages_tab.dart';
|
||||||
import 'contacts_tab.dart';
|
import 'contacts_tab.dart';
|
||||||
|
import 'discovery_screen.dart';
|
||||||
import 'sensors_tab.dart';
|
import 'sensors_tab.dart';
|
||||||
import 'map_tab.dart';
|
import 'map_tab.dart';
|
||||||
import 'repeaters_map_screen.dart';
|
import 'repeaters_map_screen.dart';
|
||||||
@@ -712,6 +713,39 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_search),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Consumer<ContactsProvider>(
|
||||||
|
builder: (context, contactsProvider, child) {
|
||||||
|
final pendingCount =
|
||||||
|
contactsProvider.pendingAdverts.length;
|
||||||
|
return Text(
|
||||||
|
pendingCount > 0
|
||||||
|
? 'Discovery ($pendingCount)'
|
||||||
|
: 'Discovery',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const DiscoveryScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -163,9 +163,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
void _scrollToMessage(String messageId) {
|
void _scrollToMessage(String messageId) {
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final messages = _getFilteredMessages(messagesProvider);
|
final messages = messagesProvider.buildDisplayMessages(
|
||||||
|
_getFilteredMessages(messagesProvider),
|
||||||
|
);
|
||||||
|
|
||||||
final messageIndex = messages.indexWhere((m) => m.id == messageId);
|
final messageIndex = messages.indexWhere(
|
||||||
|
(entry) => entry.message.id == messageId,
|
||||||
|
);
|
||||||
|
|
||||||
if (messageIndex != -1 && _scrollController.hasClients) {
|
if (messageIndex != -1 && _scrollController.hasClients) {
|
||||||
// Calculate position - accounting for reverse list
|
// Calculate position - accounting for reverse list
|
||||||
@@ -2019,7 +2023,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return Consumer<MessagesProvider>(
|
return Consumer<MessagesProvider>(
|
||||||
builder: (context, messagesProvider, child) {
|
builder: (context, messagesProvider, child) {
|
||||||
_syncChannelAutoReadTimer(messagesProvider);
|
_syncChannelAutoReadTimer(messagesProvider);
|
||||||
final messages = _getFilteredMessages(messagesProvider);
|
final messages = messagesProvider.buildDisplayMessages(
|
||||||
|
_getFilteredMessages(messagesProvider),
|
||||||
|
);
|
||||||
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
|
||||||
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
|
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
|
||||||
|
|
||||||
|
|||||||
@@ -777,13 +777,7 @@ class _DecodedRouteSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first;
|
return _RouteSection(route: decodedRoute, path: resolvedPath);
|
||||||
|
|
||||||
return _RouteSection(
|
|
||||||
route: decodedRoute,
|
|
||||||
path: resolvedPath,
|
|
||||||
originalSender: originalSender,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -827,13 +821,8 @@ class _DecodedRouteSection extends StatelessWidget {
|
|||||||
class _RouteSection extends StatelessWidget {
|
class _RouteSection extends StatelessWidget {
|
||||||
final DecodedLogRxRoute route;
|
final DecodedLogRxRoute route;
|
||||||
final List<ResolvedNodeHash> path;
|
final List<ResolvedNodeHash> path;
|
||||||
final ResolvedNodeHash? originalSender;
|
|
||||||
|
|
||||||
const _RouteSection({
|
const _RouteSection({required this.route, required this.path});
|
||||||
required this.route,
|
|
||||||
required this.path,
|
|
||||||
required this.originalSender,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -879,12 +868,6 @@ class _RouteSection extends StatelessWidget {
|
|||||||
value:
|
value:
|
||||||
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
||||||
),
|
),
|
||||||
if (originalSender != null)
|
|
||||||
_FactCard(
|
|
||||||
icon: Icons.person_pin_circle,
|
|
||||||
label: 'Original sender',
|
|
||||||
value: _nodeLabel(originalSender!),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -1130,19 +1130,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
|
|
||||||
_buildSectionHeader('Messaging'),
|
_buildSectionHeader('Messaging'),
|
||||||
_buildSettingsCard([
|
_buildSettingsCard([
|
||||||
Consumer<AppProvider>(
|
|
||||||
builder: (context, appProvider, child) => SwitchListTile(
|
|
||||||
secondary: const Icon(Icons.person_add_alt_1),
|
|
||||||
title: const Text('Auto-add discovered contacts'),
|
|
||||||
subtitle: const Text(
|
|
||||||
'Automatically fetch and add new contacts when they are discovered',
|
|
||||||
),
|
|
||||||
value: appProvider.autoAddDiscoveredContacts,
|
|
||||||
onChanged: (value) async {
|
|
||||||
await appProvider.toggleAutoAddDiscoveredContacts(value);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.alt_route),
|
leading: const Icon(Icons.alt_route),
|
||||||
title: const Text('Route path byte size'),
|
title: const Text('Route path byte size'),
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import 'package:latlong2/latlong.dart';
|
|||||||
class ContactStorageService {
|
class ContactStorageService {
|
||||||
static const String _contactsKey = 'stored_contacts';
|
static const String _contactsKey = 'stored_contacts';
|
||||||
static const String _contactGroupsKey = 'stored_contact_groups';
|
static const String _contactGroupsKey = 'stored_contact_groups';
|
||||||
|
static const String _pendingAdvertsKey = 'stored_pending_adverts';
|
||||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||||
|
static const int _maxStoredPendingAdverts = 500;
|
||||||
|
|
||||||
/// Save contacts to persistent storage
|
/// Save contacts to persistent storage
|
||||||
Future<void> saveContacts(List<Contact> contacts) async {
|
Future<void> saveContacts(List<Contact> contacts) async {
|
||||||
@@ -126,6 +128,47 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final limitedList = adverts.length > _maxStoredPendingAdverts
|
||||||
|
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
|
||||||
|
: adverts;
|
||||||
|
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList));
|
||||||
|
debugPrint(
|
||||||
|
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> loadPendingAdverts() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_pendingAdvertsKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList.whereType<Map<String, dynamic>>().toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPendingAdverts() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_pendingAdvertsKey);
|
||||||
|
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class NotificationService {
|
|||||||
static const int _sarNotificationId = 1000;
|
static const int _sarNotificationId = 1000;
|
||||||
static const int _messageNotificationId = 2000;
|
static const int _messageNotificationId = 2000;
|
||||||
static const int _updateNotificationId = 3000;
|
static const int _updateNotificationId = 3000;
|
||||||
|
static const int _batteryNotificationId = 4000;
|
||||||
|
static const int _discoveryNotificationId = 5000;
|
||||||
|
|
||||||
// Notification channels
|
// Notification channels
|
||||||
static const String _urgentChannelId = 'sar_urgent';
|
static const String _urgentChannelId = 'sar_urgent';
|
||||||
@@ -48,6 +50,16 @@ class NotificationService {
|
|||||||
static const String _updateChannelDescription =
|
static const String _updateChannelDescription =
|
||||||
'Notifications for available app updates';
|
'Notifications for available app updates';
|
||||||
|
|
||||||
|
static const String _batteryChannelId = 'battery_alerts';
|
||||||
|
static const String _batteryChannelName = 'Battery Alerts';
|
||||||
|
static const String _batteryChannelDescription =
|
||||||
|
'Notifications when a device or contact battery is low';
|
||||||
|
|
||||||
|
static const String _discoveryChannelId = 'discovery_alerts';
|
||||||
|
static const String _discoveryChannelName = 'Discovery Alerts';
|
||||||
|
static const String _discoveryChannelDescription =
|
||||||
|
'Notifications when new contacts are discovered';
|
||||||
|
|
||||||
bool get messageNotificationsEnabled => _messageNotificationsEnabled;
|
bool get messageNotificationsEnabled => _messageNotificationsEnabled;
|
||||||
bool get sarNotificationsEnabled => _sarNotificationsEnabled;
|
bool get sarNotificationsEnabled => _sarNotificationsEnabled;
|
||||||
bool get updateNotificationsEnabled => _updateNotificationsEnabled;
|
bool get updateNotificationsEnabled => _updateNotificationsEnabled;
|
||||||
@@ -237,9 +249,31 @@ class NotificationService {
|
|||||||
showBadge: true,
|
showBadge: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const batteryChannel = AndroidNotificationChannel(
|
||||||
|
_batteryChannelId,
|
||||||
|
_batteryChannelName,
|
||||||
|
description: _batteryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showBadge: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const discoveryChannel = AndroidNotificationChannel(
|
||||||
|
_discoveryChannelId,
|
||||||
|
_discoveryChannelName,
|
||||||
|
description: _discoveryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showBadge: true,
|
||||||
|
);
|
||||||
|
|
||||||
await androidPlugin.createNotificationChannel(urgentChannel);
|
await androidPlugin.createNotificationChannel(urgentChannel);
|
||||||
await androidPlugin.createNotificationChannel(messagesChannel);
|
await androidPlugin.createNotificationChannel(messagesChannel);
|
||||||
await androidPlugin.createNotificationChannel(updateChannel);
|
await androidPlugin.createNotificationChannel(updateChannel);
|
||||||
|
await androidPlugin.createNotificationChannel(batteryChannel);
|
||||||
|
await androidPlugin.createNotificationChannel(discoveryChannel);
|
||||||
debugPrint('✅ [NotificationService] Created notification channels');
|
debugPrint('✅ [NotificationService] Created notification channels');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
|
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
|
||||||
@@ -707,6 +741,156 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> showLowBatteryNotification({
|
||||||
|
required String nodeId,
|
||||||
|
required String nodeName,
|
||||||
|
required double batteryPercent,
|
||||||
|
required bool isCurrentDevice,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [NotificationService] Not initialized, skipping notification',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_permissionGranted) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [NotificationService] Permission not granted, skipping notification',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!_messageNotificationsEnabled) {
|
||||||
|
debugPrint('ℹ️ [NotificationService] Message notifications disabled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (_shouldSuppressForegroundNotifications()) {
|
||||||
|
debugPrint(
|
||||||
|
'ℹ️ [NotificationService] App in foreground, skipping low battery notification',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final roundedPercent = batteryPercent.round().clamp(0, 100);
|
||||||
|
final title = isCurrentDevice
|
||||||
|
? 'Device battery low'
|
||||||
|
: 'Contact battery low';
|
||||||
|
final body = isCurrentDevice
|
||||||
|
? '$nodeName battery is at $roundedPercent%.'
|
||||||
|
: '$nodeName is at $roundedPercent% battery.';
|
||||||
|
final notificationId =
|
||||||
|
_batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
_batteryChannelId,
|
||||||
|
_batteryChannelName,
|
||||||
|
channelDescription: _batteryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
ticker: title,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showWhen: true,
|
||||||
|
when: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
styleInformation: BigTextStyleInformation(
|
||||||
|
body,
|
||||||
|
contentTitle: title,
|
||||||
|
summaryText: '$roundedPercent%',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final darwinDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
sound: 'default',
|
||||||
|
threadIdentifier: 'battery_alerts',
|
||||||
|
subtitle: '$roundedPercent%',
|
||||||
|
);
|
||||||
|
|
||||||
|
final notificationDetails = NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: darwinDetails,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notificationsPlugin.show(
|
||||||
|
id: notificationId,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: notificationDetails,
|
||||||
|
payload: 'battery:$nodeId',
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint(
|
||||||
|
'✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [NotificationService] Error showing low battery notification: $e',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> showContactDiscoveredNotification({
|
||||||
|
required String contactKey,
|
||||||
|
}) async {
|
||||||
|
if (!_isInitialized) return false;
|
||||||
|
if (!_permissionGranted) return false;
|
||||||
|
if (!_messageNotificationsEnabled) return false;
|
||||||
|
if (_shouldSuppressForegroundNotifications()) return false;
|
||||||
|
|
||||||
|
final shortKey = contactKey.length > 12
|
||||||
|
? contactKey.substring(0, 12).toUpperCase()
|
||||||
|
: contactKey.toUpperCase();
|
||||||
|
final title = 'New contact discovered';
|
||||||
|
final body = 'New contact $shortKey is available in Discovery.';
|
||||||
|
final notificationId =
|
||||||
|
_discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final androidDetails = AndroidNotificationDetails(
|
||||||
|
_discoveryChannelId,
|
||||||
|
_discoveryChannelName,
|
||||||
|
channelDescription: _discoveryChannelDescription,
|
||||||
|
importance: Importance.high,
|
||||||
|
priority: Priority.high,
|
||||||
|
ticker: title,
|
||||||
|
playSound: true,
|
||||||
|
enableVibration: true,
|
||||||
|
showWhen: true,
|
||||||
|
when: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
|
||||||
|
final darwinDetails = DarwinNotificationDetails(
|
||||||
|
presentAlert: true,
|
||||||
|
presentBadge: true,
|
||||||
|
presentSound: true,
|
||||||
|
sound: 'default',
|
||||||
|
threadIdentifier: 'discovery_alerts',
|
||||||
|
);
|
||||||
|
|
||||||
|
await _notificationsPlugin.show(
|
||||||
|
id: notificationId,
|
||||||
|
title: title,
|
||||||
|
body: body,
|
||||||
|
notificationDetails: NotificationDetails(
|
||||||
|
android: androidDetails,
|
||||||
|
iOS: darwinDetails,
|
||||||
|
),
|
||||||
|
payload: 'discovery:$contactKey',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [NotificationService] Error showing discovery notification: $e',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LifecycleObserver with WidgetsBindingObserver {
|
class _LifecycleObserver with WidgetsBindingObserver {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../providers/connection_provider.dart';
|
import '../../models/path_history.dart';
|
||||||
import '../../providers/app_provider.dart';
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../../providers/connection_provider.dart';
|
||||||
import '../../services/contact_route_resolver.dart';
|
import '../../services/contact_route_resolver.dart';
|
||||||
import '../../services/path_history_service.dart';
|
import '../../services/path_history_service.dart';
|
||||||
import '../../services/route_hash_preferences.dart';
|
import '../../services/route_hash_preferences.dart';
|
||||||
import '../../models/path_history.dart';
|
|
||||||
|
|
||||||
class ContactRouteDialogResult {
|
class ContactRouteDialogResult {
|
||||||
final ParsedContactRoute? route;
|
final ParsedContactRoute? route;
|
||||||
@@ -53,21 +54,13 @@ class ContactRouteDialog extends StatefulWidget {
|
|||||||
required Contact contact,
|
required Contact contact,
|
||||||
required List<Contact> availableContacts,
|
required List<Contact> availableContacts,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<ContactRouteDialogResult>(
|
return Navigator.of(context).push<ContactRouteDialogResult>(
|
||||||
context: context,
|
MaterialPageRoute(
|
||||||
isScrollControlled: true,
|
builder: (context) => ContactRouteDialog(
|
||||||
showDragHandle: true,
|
|
||||||
builder: (context) => SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
|
||||||
child: ContactRouteDialog(
|
|
||||||
contact: contact,
|
contact: contact,
|
||||||
availableContacts: availableContacts,
|
availableContacts: availableContacts,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +70,15 @@ class ContactRouteDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||||
late final TextEditingController _controller;
|
late final TextEditingController _controller;
|
||||||
|
late final TextEditingController _relaySearchController;
|
||||||
final PathHistoryService _pathHistoryService = PathHistoryService();
|
final PathHistoryService _pathHistoryService = PathHistoryService();
|
||||||
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
||||||
ParsedContactRoute? _parsedRoute;
|
ParsedContactRoute? _parsedRoute;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
bool _showRoutingInfo = false;
|
bool _showRoutingInfo = false;
|
||||||
|
bool _showManualEditor = false;
|
||||||
List<Contact> _selectedMapHops = const [];
|
List<Contact> _selectedMapHops = const [];
|
||||||
ContactPathHistory? _pathHistory;
|
ContactPathHistory? _pathHistory;
|
||||||
_RouteEntryMode _entryMode = _RouteEntryMode.map;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -92,10 +86,9 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
_controller = TextEditingController(
|
_controller = TextEditingController(
|
||||||
text: widget.contact.routeCanonicalText,
|
text: widget.contact.routeCanonicalText,
|
||||||
);
|
);
|
||||||
|
_relaySearchController = TextEditingController();
|
||||||
_controller.addListener(_reparse);
|
_controller.addListener(_reparse);
|
||||||
_entryMode = widget.contact.routeCanonicalText.isNotEmpty
|
_showManualEditor = widget.contact.routeCanonicalText.isNotEmpty;
|
||||||
? _RouteEntryMode.manual
|
|
||||||
: _RouteEntryMode.map;
|
|
||||||
_loadHashSizePreference();
|
_loadHashSizePreference();
|
||||||
_loadPathHistory();
|
_loadPathHistory();
|
||||||
_reparse();
|
_reparse();
|
||||||
@@ -103,6 +96,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_relaySearchController.dispose();
|
||||||
_controller
|
_controller
|
||||||
..removeListener(_reparse)
|
..removeListener(_reparse)
|
||||||
..dispose();
|
..dispose();
|
||||||
@@ -231,7 +225,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
TextPosition(offset: _controller.text.length),
|
TextPosition(offset: _controller.text.length),
|
||||||
);
|
);
|
||||||
_errorText = null;
|
_errorText = null;
|
||||||
_entryMode = _RouteEntryMode.map;
|
_showManualEditor = false;
|
||||||
});
|
});
|
||||||
_reparse();
|
_reparse();
|
||||||
}
|
}
|
||||||
@@ -247,7 +241,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
TextPosition(offset: _controller.text.length),
|
TextPosition(offset: _controller.text.length),
|
||||||
);
|
);
|
||||||
_errorText = null;
|
_errorText = null;
|
||||||
_entryMode = _RouteEntryMode.manual;
|
_showManualEditor = true;
|
||||||
});
|
});
|
||||||
_reparse();
|
_reparse();
|
||||||
}
|
}
|
||||||
@@ -416,17 +410,31 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPreviewSection() {
|
Widget _buildPreviewSection() {
|
||||||
return Column(
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Text(
|
||||||
|
_parsedRoute == null ? 'Route preview' : _parsedRoute!.summary,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
_parsedRoute == null
|
_parsedRoute == null
|
||||||
? 'Preview: enter or pick a route to validate it.'
|
? 'Pick relays from the list below or open manual edit if you need exact hop tokens.'
|
||||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
: '${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
if (_parsedRoute != null) ...[
|
if (_parsedRoute != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 10),
|
||||||
SelectableText(
|
SelectableText(
|
||||||
_parsedRoute!.canonicalText,
|
_parsedRoute!.canonicalText,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
@@ -435,40 +443,146 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectedHopSection() {
|
||||||
|
if (_selectedMapHops.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No relays selected yet. Add repeaters below or let auto resolve build a route.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBuilderTab(
|
|
||||||
BuildContext context, {
|
|
||||||
required List<Contact> routeCandidates,
|
|
||||||
required List<LatLng> mapPoints,
|
|
||||||
required List<LatLng> routePoints,
|
|
||||||
}) {
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SegmentedButton<_RouteEntryMode>(
|
Text('Selected relays', style: Theme.of(context).textTheme.titleSmall),
|
||||||
segments: const [
|
const SizedBox(height: 8),
|
||||||
ButtonSegment<_RouteEntryMode>(
|
..._selectedMapHops.asMap().entries.map((entry) {
|
||||||
value: _RouteEntryMode.map,
|
final index = entry.key;
|
||||||
icon: Icon(Icons.map_outlined),
|
final contact = entry.value;
|
||||||
label: Text('Map'),
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(child: Text('${index + 1}')),
|
||||||
|
title: Text(contact.displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
_tokenFor(contact, _selectedHashSize),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||||
),
|
),
|
||||||
ButtonSegment<_RouteEntryMode>(
|
trailing: IconButton(
|
||||||
value: _RouteEntryMode.manual,
|
tooltip: 'Remove relay',
|
||||||
icon: Icon(Icons.tune),
|
onPressed: () => _toggleHop(contact),
|
||||||
label: Text('Manual'),
|
icon: const Icon(Icons.close),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
selected: {_entryMode},
|
);
|
||||||
onSelectionChanged: (selection) {
|
}
|
||||||
|
|
||||||
|
Widget _buildRelayPicker(List<Contact> routeCandidates) {
|
||||||
|
final query = _relaySearchController.text.trim().toLowerCase();
|
||||||
|
final filteredCandidates = routeCandidates.where((contact) {
|
||||||
|
if (query.isEmpty) return true;
|
||||||
|
return contact.displayName.toLowerCase().contains(query) ||
|
||||||
|
_tokenFor(contact, _selectedHashSize).toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _relaySearchController,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
labelText: 'Find relay',
|
||||||
|
hintText: 'Search by name or token',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (filteredCandidates.isEmpty)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No visible relays match this search.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...filteredCandidates.map((candidate) {
|
||||||
|
final isSelected = _selectedMapHops.any(
|
||||||
|
(item) => item.publicKeyHex == candidate.publicKeyHex,
|
||||||
|
);
|
||||||
|
final location = candidate.displayLocation;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
isSelected
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.radio_button_unchecked,
|
||||||
|
color: isSelected
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
title: Text(candidate.displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
_tokenFor(candidate, _selectedHashSize),
|
||||||
|
if (location != null)
|
||||||
|
'${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
|
||||||
|
].join(' • '),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => _toggleHop(candidate),
|
||||||
|
child: Text(isSelected ? 'Remove' : 'Add'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildManualEditor() {
|
||||||
|
return ExpansionTile(
|
||||||
|
tilePadding: EdgeInsets.zero,
|
||||||
|
childrenPadding: EdgeInsets.zero,
|
||||||
|
initiallyExpanded: _showManualEditor,
|
||||||
|
onExpansionChanged: (expanded) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_entryMode = selection.first;
|
_showManualEditor = expanded;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
title: const Text('Manual route edit'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Use this when you need to paste or tweak hop tokens directly.',
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
if (_entryMode == _RouteEntryMode.manual) ...[
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
@@ -480,48 +594,35 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
? 'AABB,CCDD'
|
? 'AABB,CCDD'
|
||||||
: 'AABBCC,DDEEFF',
|
: 'AABBCC,DDEEFF',
|
||||||
helperText:
|
helperText:
|
||||||
'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
'Comma-separated hops. Hash size follows global Settings. Colon form like AA:BB is also accepted.',
|
||||||
errorText: _errorText,
|
errorText: _errorText,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildPreviewSection(),
|
|
||||||
] else ...[
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _resolvePathAutomatically,
|
|
||||||
icon: const Icon(Icons.auto_fix_high),
|
|
||||||
label: const Text('Resolve Path'),
|
|
||||||
),
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 280),
|
|
||||||
child: Text(
|
|
||||||
'Tap repeaters on the map to build the path, then review the generated route below.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
const SizedBox(height: 16),
|
}
|
||||||
SizedBox(
|
|
||||||
height: 260,
|
Widget _buildMapPreview({
|
||||||
child: ClipRRect(
|
required List<Contact> routeCandidates,
|
||||||
borderRadius: BorderRadius.circular(12),
|
required List<LatLng> mapPoints,
|
||||||
child: DecoratedBox(
|
required List<LatLng> routePoints,
|
||||||
|
}) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Theme.of(context).dividerColor),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 220,
|
||||||
child: mapPoints.length < 2
|
child: mapPoints.length < 2
|
||||||
? const Center(
|
? const Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
|
'Map preview needs your advertised location, the contact location, and visible repeater locations.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -529,9 +630,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
: flutter_map.FlutterMap(
|
: flutter_map.FlutterMap(
|
||||||
options: flutter_map.MapOptions(
|
options: flutter_map.MapOptions(
|
||||||
initialCameraFit: flutter_map.CameraFit.bounds(
|
initialCameraFit: flutter_map.CameraFit.bounds(
|
||||||
bounds: flutter_map.LatLngBounds.fromPoints(
|
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
|
||||||
mapPoints,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -547,7 +646,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
flutter_map.Polyline(
|
flutter_map.Polyline(
|
||||||
points: routePoints,
|
points: routePoints,
|
||||||
strokeWidth: 4,
|
strokeWidth: 4,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -555,9 +654,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
markers: [
|
markers: [
|
||||||
...routeCandidates.map((candidate) {
|
...routeCandidates.map((candidate) {
|
||||||
final isSelected = _selectedMapHops.any(
|
final isSelected = _selectedMapHops.any(
|
||||||
(item) =>
|
(item) => item.publicKeyHex == candidate.publicKeyHex,
|
||||||
item.publicKeyHex ==
|
|
||||||
candidate.publicKeyHex,
|
|
||||||
);
|
);
|
||||||
return flutter_map.Marker(
|
return flutter_map.Marker(
|
||||||
point: LatLng(
|
point: LatLng(
|
||||||
@@ -569,14 +666,9 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => _toggleHop(candidate),
|
onTap: () => _toggleHop(candidate),
|
||||||
child: _RouteMarkerDot(
|
child: _RouteMarkerDot(
|
||||||
label: _tokenFor(
|
label: _tokenFor(candidate, _selectedHashSize),
|
||||||
candidate,
|
|
||||||
_selectedHashSize,
|
|
||||||
),
|
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? Theme.of(
|
? colorScheme.primary
|
||||||
context,
|
|
||||||
).colorScheme.primary
|
|
||||||
: Colors.blueGrey,
|
: Colors.blueGrey,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -587,34 +679,54 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
const SizedBox(height: 12),
|
|
||||||
if (_selectedMapHops.isNotEmpty)
|
Widget _buildBuilderTab({
|
||||||
|
required List<Contact> routeCandidates,
|
||||||
|
required List<LatLng> mapPoints,
|
||||||
|
required List<LatLng> routePoints,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: _selectedMapHops.map((contact) {
|
children: [
|
||||||
return InputChip(
|
FilledButton.tonalIcon(
|
||||||
label: Text(contact.displayName),
|
onPressed: _resolvePathAutomatically,
|
||||||
onDeleted: () => _toggleHop(contact),
|
icon: const Icon(Icons.auto_fix_high),
|
||||||
);
|
label: const Text('Auto resolve'),
|
||||||
}).toList(),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
OutlinedButton.icon(
|
||||||
TextField(
|
onPressed: _selectedMapHops.isEmpty
|
||||||
controller: _controller,
|
? null
|
||||||
readOnly: true,
|
: () {
|
||||||
decoration: InputDecoration(
|
setState(() {
|
||||||
labelText: 'Generated route',
|
_selectedMapHops = const [];
|
||||||
helperText: 'Switch to Manual if you want to edit the hop list.',
|
_syncControllerFromSelectedHops();
|
||||||
errorText: _errorText,
|
});
|
||||||
border: const OutlineInputBorder(),
|
},
|
||||||
|
icon: const Icon(Icons.clear_all),
|
||||||
|
label: const Text('Clear relays'),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildPreviewSection(),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildPreviewSection(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildSelectedHopSection(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildRelayPicker(routeCandidates),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildMapPreview(
|
||||||
|
routeCandidates: routeCandidates,
|
||||||
|
mapPoints: mapPoints,
|
||||||
|
routePoints: routePoints,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildManualEditor(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -720,39 +832,33 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
|
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 2,
|
length: 2,
|
||||||
child: FractionallySizedBox(
|
child: Scaffold(
|
||||||
heightFactor: 0.85,
|
appBar: AppBar(
|
||||||
child: Padding(
|
title: Text('Set Path for ${widget.contact.displayName}'),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
bottom: const TabBar(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Set Route for ${widget.contact.displayName}',
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Choose how to build the route, or reuse one from history.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const TabBar(
|
|
||||||
tabs: [
|
tabs: [
|
||||||
Tab(text: 'Build'),
|
Tab(text: 'Build'),
|
||||||
Tab(text: 'History'),
|
Tab(text: 'History'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Plan the route on its own screen, then save it once the preview looks right.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
children: [
|
children: [
|
||||||
SingleChildScrollView(
|
ListView(
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
_buildBuilderTab(
|
_buildBuilderTab(
|
||||||
context,
|
|
||||||
routeCandidates: routeCandidates,
|
routeCandidates: routeCandidates,
|
||||||
mapPoints: mapPoints,
|
mapPoints: mapPoints,
|
||||||
routePoints: routePoints,
|
routePoints: routePoints,
|
||||||
@@ -772,29 +878,40 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
clearPathOnMaxRetry:
|
clearPathOnMaxRetry:
|
||||||
appProvider.clearPathOnMaxRetry,
|
appProvider.clearPathOnMaxRetry,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ListView(
|
||||||
|
children: [
|
||||||
|
_buildHistoryTab(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SingleChildScrollView(child: _buildHistoryTab()),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
OverflowBar(
|
),
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: OverflowBar(
|
||||||
alignment: MainAxisAlignment.spaceBetween,
|
alignment: MainAxisAlignment.spaceBetween,
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
overflowSpacing: 8,
|
overflowSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
if (widget.contact.routeHasPath)
|
if (widget.contact.routeHasPath)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(
|
onPressed: () => Navigator.of(
|
||||||
context,
|
context,
|
||||||
).pop(const ContactRouteDialogResult.clear()),
|
).pop(const ContactRouteDialogResult.clear()),
|
||||||
child: const Text('Clear Route'),
|
child: const Text('Clear Route'),
|
||||||
),
|
)
|
||||||
|
else
|
||||||
|
const SizedBox.shrink(),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _parsedRoute == null
|
onPressed: _parsedRoute == null
|
||||||
? null
|
? null
|
||||||
@@ -805,11 +922,10 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
_buildSyntheticFallbackLocation(),
|
_buildSyntheticFallbackLocation(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text('Set Route'),
|
child: const Text('Save Path'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -817,8 +933,6 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _RouteEntryMode { map, manual }
|
|
||||||
|
|
||||||
class _RouteMarkerDot extends StatelessWidget {
|
class _RouteMarkerDot extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|||||||
@@ -325,7 +325,9 @@ class ContactTile extends StatelessWidget {
|
|||||||
contact.type == ContactType.room ||
|
contact.type == ContactType.room ||
|
||||||
contact.type == ContactType.channel;
|
contact.type == ContactType.channel;
|
||||||
final canSetPath =
|
final canSetPath =
|
||||||
contact.type == ContactType.chat || contact.type == ContactType.room;
|
contact.type == ContactType.chat ||
|
||||||
|
contact.type == ContactType.room ||
|
||||||
|
contact.type == ContactType.repeater;
|
||||||
final canAddToSensors =
|
final canAddToSensors =
|
||||||
contact.type == ContactType.chat ||
|
contact.type == ContactType.chat ||
|
||||||
contact.type == ContactType.repeater;
|
contact.type == ContactType.repeater;
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
trace.routeHashes.isEmpty
|
trace.routeHashes.isEmpty
|
||||||
? 'Direct route to ${widget.contact.displayName}'
|
? 'No relay path saved for ${widget.contact.displayName}'
|
||||||
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
|
: 'Route from saved contact path (${trace.routeHashes.length} hop${trace.routeHashes.length == 1 ? '' : 's'})',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
@@ -199,14 +199,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
entry.key == 0
|
Colors.blue,
|
||||||
? Colors.green
|
|
||||||
: (entry.key ==
|
|
||||||
concreteNodes
|
|
||||||
.length -
|
|
||||||
1
|
|
||||||
? Colors.red
|
|
||||||
: Colors.blue),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -236,7 +229,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Route',
|
'Relay path',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -263,11 +256,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
: null,
|
: null,
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 14,
|
radius: 14,
|
||||||
backgroundColor: entry.key == 0
|
backgroundColor: Colors.blue,
|
||||||
? Colors.green
|
|
||||||
: (entry.key == routeEntries.length - 1
|
|
||||||
? Colors.red
|
|
||||||
: Colors.blue),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -279,7 +268,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
),
|
),
|
||||||
title: Text(entry.value.label),
|
title: Text(entry.value.label),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
'Relay${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||||
),
|
),
|
||||||
trailing: entry.value.resolved.canCycle
|
trailing: entry.value.resolved.canCycle
|
||||||
? const Icon(Icons.sync_alt)
|
? const Icon(Icons.sync_alt)
|
||||||
@@ -326,17 +315,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
|
List<_RouteDisplayEntry> _displayRouteEntries(_ContactTraceResult trace) {
|
||||||
final entries = <_RouteDisplayEntry>[];
|
return trace.matchedRelayNodes.asMap().entries.map((entry) {
|
||||||
if (trace.sender.node != null) {
|
|
||||||
entries.add(
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.sender,
|
|
||||||
target: const _RouteEntryTarget.sender(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
entries.addAll(
|
|
||||||
trace.matchedRelayNodes.asMap().entries.map((entry) {
|
|
||||||
final resolved = entry.value;
|
final resolved = entry.value;
|
||||||
final node = resolved.node;
|
final node = resolved.node;
|
||||||
final hashHex = trace.routeHashes[entry.key].toUpperCase();
|
final hashHex = trace.routeHashes[entry.key].toUpperCase();
|
||||||
@@ -347,23 +326,7 @@ class _ContactTraceSheetState extends State<ContactTraceSheet> {
|
|||||||
matchSummary: resolved.matchSummary,
|
matchSummary: resolved.matchSummary,
|
||||||
target: _RouteEntryTarget.relayNode(entry.key),
|
target: _RouteEntryTarget.relayNode(entry.key),
|
||||||
);
|
);
|
||||||
}),
|
}).toList();
|
||||||
);
|
|
||||||
if (trace.recipient.node != null) {
|
|
||||||
entries.add(
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.recipient,
|
|
||||||
target: const _RouteEntryTarget.recipient(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _routeRoleLabel(int index, int total) {
|
|
||||||
if (index == 0) return 'Sender';
|
|
||||||
if (index == total - 1) return 'Recipient';
|
|
||||||
return 'Relay';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _prefixKeyLabel(String publicKey) =>
|
String _prefixKeyLabel(String publicKey) =>
|
||||||
@@ -527,20 +490,6 @@ class _ContactTraceResult {
|
|||||||
|
|
||||||
_ContactTraceResult cycleEntry(_RouteEntryTarget target) {
|
_ContactTraceResult cycleEntry(_RouteEntryTarget target) {
|
||||||
switch (target.kind) {
|
switch (target.kind) {
|
||||||
case _RouteEntryKind.sender:
|
|
||||||
return _ContactTraceResult(
|
|
||||||
sender: sender.cycle(),
|
|
||||||
recipient: recipient,
|
|
||||||
routeHashes: routeHashes,
|
|
||||||
matchedRelayNodes: matchedRelayNodes,
|
|
||||||
);
|
|
||||||
case _RouteEntryKind.recipient:
|
|
||||||
return _ContactTraceResult(
|
|
||||||
sender: sender,
|
|
||||||
recipient: recipient.cycle(),
|
|
||||||
routeHashes: routeHashes,
|
|
||||||
matchedRelayNodes: matchedRelayNodes,
|
|
||||||
);
|
|
||||||
case _RouteEntryKind.relayNode:
|
case _RouteEntryKind.relayNode:
|
||||||
final updated = matchedRelayNodes.toList();
|
final updated = matchedRelayNodes.toList();
|
||||||
updated[target.index] = updated[target.index].cycle();
|
updated[target.index] = updated[target.index].cycle();
|
||||||
@@ -570,26 +519,9 @@ class _RouteDisplayEntry {
|
|||||||
});
|
});
|
||||||
|
|
||||||
MeshMapNode? get node => resolved.node;
|
MeshMapNode? get node => resolved.node;
|
||||||
|
|
||||||
factory _RouteDisplayEntry.fromResolved(
|
|
||||||
ResolvedTraceNode resolved, {
|
|
||||||
required _RouteEntryTarget target,
|
|
||||||
}) {
|
|
||||||
final node = resolved.node!;
|
|
||||||
return _RouteDisplayEntry(
|
|
||||||
resolved: resolved,
|
|
||||||
label: node.name,
|
|
||||||
keyLabel: node.publicKey.substring(
|
|
||||||
0,
|
|
||||||
math.min(12, node.publicKey.length),
|
|
||||||
),
|
|
||||||
matchSummary: resolved.matchSummary,
|
|
||||||
target: target,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _RouteEntryKind { sender, recipient, relayNode }
|
enum _RouteEntryKind { relayNode }
|
||||||
|
|
||||||
class _RouteEntryTarget {
|
class _RouteEntryTarget {
|
||||||
final _RouteEntryKind kind;
|
final _RouteEntryKind kind;
|
||||||
@@ -597,8 +529,6 @@ class _RouteEntryTarget {
|
|||||||
|
|
||||||
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
||||||
|
|
||||||
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
|
|
||||||
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
|
|
||||||
const _RouteEntryTarget.relayNode(int index)
|
const _RouteEntryTarget.relayNode(int index)
|
||||||
: this._(_RouteEntryKind.relayNode, index);
|
: this._(_RouteEntryKind.relayNode, index);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import 'system_message_bubble.dart';
|
|||||||
/// - Grouped messages (expandable recipient list)
|
/// - Grouped messages (expandable recipient list)
|
||||||
class MessageBubble extends StatefulWidget {
|
class MessageBubble extends StatefulWidget {
|
||||||
final Message message;
|
final Message message;
|
||||||
|
final int receivedCopies;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onReply;
|
final VoidCallback? onReply;
|
||||||
final bool isHighlighted;
|
final bool isHighlighted;
|
||||||
@@ -56,6 +57,7 @@ class MessageBubble extends StatefulWidget {
|
|||||||
const MessageBubble({
|
const MessageBubble({
|
||||||
super.key,
|
super.key,
|
||||||
required this.message,
|
required this.message,
|
||||||
|
this.receivedCopies = 1,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onReply,
|
this.onReply,
|
||||||
this.isHighlighted = false,
|
this.isHighlighted = false,
|
||||||
@@ -576,6 +578,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}',
|
'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}',
|
||||||
'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}',
|
'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}',
|
||||||
'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}',
|
'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}',
|
||||||
|
'Received copies: ${widget.receivedCopies}',
|
||||||
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
|
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
|
||||||
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
|
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
|
||||||
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
|
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
|
||||||
@@ -846,6 +849,12 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
receptionDetails!.postTransmitDelayMs!,
|
receptionDetails!.postTransmitDelayMs!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.receivedCopies > 1)
|
||||||
|
_detailRow(
|
||||||
|
context,
|
||||||
|
label: 'Received copies',
|
||||||
|
value: '${widget.receivedCopies}',
|
||||||
|
),
|
||||||
if (widget.message.suggestedTimeoutMs != null)
|
if (widget.message.suggestedTimeoutMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -154,8 +154,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
trace.mode == TraceMode.packetPath
|
trace.mode == TraceMode.packetPath
|
||||||
? 'Route from packet path bytes'
|
? 'Relay path from packet path bytes'
|
||||||
: 'Route inferred from hop count (${widget.message.pathLen})',
|
: 'Relay path inferred from hop count (${widget.message.pathLen})',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -222,14 +222,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 16,
|
radius: 16,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
entry.key == 0
|
Colors.blue,
|
||||||
? Colors.green
|
|
||||||
: (entry.key ==
|
|
||||||
concretePathNodes
|
|
||||||
.length -
|
|
||||||
1
|
|
||||||
? Colors.red
|
|
||||||
: Colors.blue),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -259,7 +252,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Route',
|
'Relay path',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -286,11 +279,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
: null,
|
: null,
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
radius: 14,
|
radius: 14,
|
||||||
backgroundColor: entry.key == 0
|
backgroundColor: Colors.blue,
|
||||||
? Colors.green
|
|
||||||
: (entry.key == routeEntries.length - 1
|
|
||||||
? Colors.red
|
|
||||||
: Colors.blue),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
'${entry.key + 1}',
|
'${entry.key + 1}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -302,7 +291,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
),
|
),
|
||||||
title: Text(entry.value.label),
|
title: Text(entry.value.label),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
'Relay${entry.value.keyLabel == null ? '' : ' • ${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : ' • ${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : ' • ${entry.value.resolved.cycleSummary}'}',
|
||||||
),
|
),
|
||||||
trailing: entry.value.resolved.canCycle
|
trailing: entry.value.resolved.canCycle
|
||||||
? const Icon(Icons.sync_alt)
|
? const Icon(Icons.sync_alt)
|
||||||
@@ -365,28 +354,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
|
||||||
final pathNodes = trace.matchedPathNodes
|
|
||||||
.map((entry) => entry.node)
|
|
||||||
.whereType<MeshMapNode>()
|
|
||||||
.toList();
|
|
||||||
if (pathNodes.isEmpty) {
|
|
||||||
return [
|
|
||||||
if (trace.sender.node != null)
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.sender,
|
|
||||||
target: const _RouteEntryTarget.sender(),
|
|
||||||
),
|
|
||||||
if (trace.recipient.node != null &&
|
|
||||||
trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.recipient,
|
|
||||||
target: const _RouteEntryTarget.recipient(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trace.mode == TraceMode.packetPath) {
|
if (trace.mode == TraceMode.packetPath) {
|
||||||
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
|
return trace.matchedPathNodes.asMap().entries.map((entry) {
|
||||||
final hashHex = trace.pathHashes[entry.key].toUpperCase();
|
final hashHex = trace.pathHashes[entry.key].toUpperCase();
|
||||||
return _RouteDisplayEntry(
|
return _RouteDisplayEntry(
|
||||||
resolved: entry.value,
|
resolved: entry.value,
|
||||||
@@ -398,27 +367,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
target: _RouteEntryTarget.pathNode(entry.key),
|
target: _RouteEntryTarget.pathNode(entry.key),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
final lastKey = pathNodes.last.publicKey;
|
|
||||||
return [
|
|
||||||
...entries,
|
|
||||||
if (trace.recipient.node != null &&
|
|
||||||
trace.recipient.node!.publicKey != lastKey)
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.recipient,
|
|
||||||
target: const _RouteEntryTarget.recipient(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final firstKey = pathNodes.first.publicKey;
|
return trace.matchedPathNodes
|
||||||
final lastKey = pathNodes.last.publicKey;
|
|
||||||
return [
|
|
||||||
if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.sender,
|
|
||||||
target: const _RouteEntryTarget.sender(),
|
|
||||||
),
|
|
||||||
...trace.matchedPathNodes
|
|
||||||
.asMap()
|
.asMap()
|
||||||
.entries
|
.entries
|
||||||
.where((entry) => entry.value.node != null)
|
.where((entry) => entry.value.node != null)
|
||||||
@@ -427,25 +378,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
entry.value,
|
entry.value,
|
||||||
target: _RouteEntryTarget.pathNode(entry.key),
|
target: _RouteEntryTarget.pathNode(entry.key),
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
if (trace.recipient.node != null &&
|
.toList();
|
||||||
trace.recipient.node!.publicKey != lastKey)
|
|
||||||
_RouteDisplayEntry.fromResolved(
|
|
||||||
trace.recipient,
|
|
||||||
target: const _RouteEntryTarget.recipient(),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _prefixKeyLabel(String publicKey) =>
|
String _prefixKeyLabel(String publicKey) =>
|
||||||
publicKey.substring(0, math.min(12, publicKey.length));
|
publicKey.substring(0, math.min(12, publicKey.length));
|
||||||
|
|
||||||
String _routeRoleLabel(int index, int total) {
|
|
||||||
if (index == 0) return 'Sender';
|
|
||||||
if (index == total - 1) return 'Recipient';
|
|
||||||
return 'Relay';
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _toPrefixHex(List<int>? key) {
|
String? _toPrefixHex(List<int>? key) {
|
||||||
if (key == null || key.isEmpty) return null;
|
if (key == null || key.isEmpty) return null;
|
||||||
final take = key.length < 6 ? key.length : 6;
|
final take = key.length < 6 ? key.length : 6;
|
||||||
@@ -521,7 +460,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
|
|||||||
final hopHashes = LogRxRouteDecoder.splitHopHashes(
|
final hopHashes = LogRxRouteDecoder.splitHopHashes(
|
||||||
packetPath,
|
packetPath,
|
||||||
hashSize: hashSize,
|
hashSize: hashSize,
|
||||||
).reversed.toList();
|
);
|
||||||
final matched = _matchNodesFromPathHashes(
|
final matched = _matchNodesFromPathHashes(
|
||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
localPublicKeys: localPublicKeys,
|
localPublicKeys: localPublicKeys,
|
||||||
@@ -731,22 +670,6 @@ class _TraceResult {
|
|||||||
|
|
||||||
_TraceResult cycleEntry(_RouteEntryTarget target) {
|
_TraceResult cycleEntry(_RouteEntryTarget target) {
|
||||||
switch (target.kind) {
|
switch (target.kind) {
|
||||||
case _RouteEntryKind.sender:
|
|
||||||
return _TraceResult(
|
|
||||||
mode: mode,
|
|
||||||
sender: sender.cycle(),
|
|
||||||
recipient: recipient,
|
|
||||||
pathHashes: pathHashes,
|
|
||||||
matchedPathNodes: matchedPathNodes,
|
|
||||||
);
|
|
||||||
case _RouteEntryKind.recipient:
|
|
||||||
return _TraceResult(
|
|
||||||
mode: mode,
|
|
||||||
sender: sender,
|
|
||||||
recipient: recipient.cycle(),
|
|
||||||
pathHashes: pathHashes,
|
|
||||||
matchedPathNodes: matchedPathNodes,
|
|
||||||
);
|
|
||||||
case _RouteEntryKind.pathNode:
|
case _RouteEntryKind.pathNode:
|
||||||
final updated = matchedPathNodes.toList();
|
final updated = matchedPathNodes.toList();
|
||||||
updated[target.index] = updated[target.index].cycle();
|
updated[target.index] = updated[target.index].cycle();
|
||||||
@@ -796,7 +719,7 @@ class _RouteDisplayEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _RouteEntryKind { sender, recipient, pathNode }
|
enum _RouteEntryKind { pathNode }
|
||||||
|
|
||||||
class _RouteEntryTarget {
|
class _RouteEntryTarget {
|
||||||
final _RouteEntryKind kind;
|
final _RouteEntryKind kind;
|
||||||
@@ -804,8 +727,6 @@ class _RouteEntryTarget {
|
|||||||
|
|
||||||
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
const _RouteEntryTarget._(this.kind, [this.index = 0]);
|
||||||
|
|
||||||
const _RouteEntryTarget.sender() : this._(_RouteEntryKind.sender);
|
|
||||||
const _RouteEntryTarget.recipient() : this._(_RouteEntryKind.recipient);
|
|
||||||
const _RouteEntryTarget.pathNode(int index)
|
const _RouteEntryTarget.pathNode(int index)
|
||||||
: this._(_RouteEntryKind.pathNode, index);
|
: this._(_RouteEntryKind.pathNode, index);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
|
import '../../providers/messages_provider.dart';
|
||||||
import '../common/bidirectional_refresh.dart';
|
import '../common/bidirectional_refresh.dart';
|
||||||
import '../../widgets/messages/message_bubble.dart';
|
import '../../widgets/messages/message_bubble.dart';
|
||||||
|
|
||||||
class MessagesContent extends StatelessWidget {
|
class MessagesContent extends StatelessWidget {
|
||||||
static const double defaultPadding = 8;
|
static const double defaultPadding = 8;
|
||||||
|
|
||||||
final List<Message> messages;
|
final List<DisplayMessageEntry> messages;
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
final String? highlightedMessageId;
|
final String? highlightedMessageId;
|
||||||
final double bottomContentPadding;
|
final double bottomContentPadding;
|
||||||
@@ -84,11 +85,13 @@ class MessagesContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final message = messages[index];
|
final entry = messages[index];
|
||||||
|
final message = entry.message;
|
||||||
|
|
||||||
return MessageBubble(
|
return MessageBubble(
|
||||||
key: ValueKey(message.id),
|
key: ValueKey(message.id),
|
||||||
message: message,
|
message: message,
|
||||||
|
receivedCopies: entry.occurrenceCount,
|
||||||
isHighlighted: message.id == highlightedMessageId,
|
isHighlighted: message.id == highlightedMessageId,
|
||||||
onNavigateToMap: onNavigateToMap,
|
onNavigateToMap: onNavigateToMap,
|
||||||
onTap: onMessageTap == null
|
onTap: onMessageTap == null
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:fake_async/fake_async.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:meshcore_sar_app/models/contact.dart';
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
import 'package:meshcore_sar_app/models/message.dart';
|
import 'package:meshcore_sar_app/models/message.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/message_reception_details.dart';
|
||||||
import 'package:meshcore_sar_app/models/path_selection.dart';
|
import 'package:meshcore_sar_app/models/path_selection.dart';
|
||||||
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
|
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
|
||||||
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
import 'package:meshcore_sar_app/providers/messages_provider.dart';
|
||||||
@@ -347,6 +348,88 @@ void main() {
|
|||||||
expect(provider.messages.single.id, equals('c-prefix'));
|
expect(provider.messages.single.id, equals('c-prefix'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('duplicate incoming message increments received copy count', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final sender = Uint8List.fromList([7, 7, 4, 1, 7, 2]);
|
||||||
|
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'copy-1',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000600,
|
||||||
|
text: 'same payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
receptionDetailsSnapshot: MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
provider.addMessage(
|
||||||
|
Message(
|
||||||
|
id: 'copy-2',
|
||||||
|
messageType: MessageType.contact,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000601,
|
||||||
|
text: 'same payload',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
receptionDetailsSnapshot: MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(provider.messages, hasLength(1));
|
||||||
|
expect(
|
||||||
|
provider.getMessageReceptionDetails('copy-1')?.receivedCopies,
|
||||||
|
equals(2),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display list collapses stored duplicates and sums copy counts', () {
|
||||||
|
final provider = MessagesProvider();
|
||||||
|
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]);
|
||||||
|
|
||||||
|
provider.addMessages([
|
||||||
|
Message(
|
||||||
|
id: 'dup-a',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 1,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000705,
|
||||||
|
text: 'same payload',
|
||||||
|
senderName: 'Klemen',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
Message(
|
||||||
|
id: 'dup-b',
|
||||||
|
messageType: MessageType.channel,
|
||||||
|
channelIdx: 0,
|
||||||
|
pathLen: 2,
|
||||||
|
textType: MessageTextType.plain,
|
||||||
|
senderTimestamp: 1700000700,
|
||||||
|
text: 'same payload',
|
||||||
|
senderName: 'Klemen',
|
||||||
|
receivedAt: DateTime.now(),
|
||||||
|
senderPublicKeyPrefix: sender,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final display = provider.buildDisplayMessages(
|
||||||
|
provider.getRecentMessages(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(display, hasLength(1));
|
||||||
|
expect(display.single.message.id, equals('dup-a'));
|
||||||
|
expect(display.single.occurrenceCount, equals(2));
|
||||||
|
});
|
||||||
|
|
||||||
test('missing ACK schedules a delayed retransmission', () {
|
test('missing ACK schedules a delayed retransmission', () {
|
||||||
fakeAsync((async) {
|
fakeAsync((async) {
|
||||||
final provider = MessagesProvider();
|
final provider = MessagesProvider();
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ void main() {
|
|||||||
senderToReceiptMs: 1200,
|
senderToReceiptMs: 1200,
|
||||||
estimatedTransmitMs: 800,
|
estimatedTransmitMs: 800,
|
||||||
postTransmitDelayMs: 400,
|
postTransmitDelayMs: 400,
|
||||||
|
receivedCopies: 3,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -95,6 +96,7 @@ void main() {
|
|||||||
expect(restored.senderToReceiptMs, 1200);
|
expect(restored.senderToReceiptMs, 1200);
|
||||||
expect(restored.estimatedTransmitMs, 800);
|
expect(restored.estimatedTransmitMs, 800);
|
||||||
expect(restored.postTransmitDelayMs, 400);
|
expect(restored.postTransmitDelayMs, 400);
|
||||||
|
expect(restored.receivedCopies, 3);
|
||||||
expect(
|
expect(
|
||||||
restored.packetLoggedAt,
|
restored.packetLoggedAt,
|
||||||
DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
DateTime.fromMillisecondsSinceEpoch(1700000100400),
|
||||||
|
|||||||
Reference in New Issue
Block a user