mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix BottomSheet ancestor lookup
This commit is contained in:
@@ -84,6 +84,8 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- vibration (3.0.0):
|
- vibration (3.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- wakelock_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||||
@@ -108,6 +110,7 @@ DEPENDENCIES:
|
|||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
- vibration (from `.symlinks/plugins/vibration/ios`)
|
- vibration (from `.symlinks/plugins/vibration/ios`)
|
||||||
|
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
@@ -161,6 +164,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
vibration:
|
vibration:
|
||||||
:path: ".symlinks/plugins/vibration/ios"
|
:path: ".symlinks/plugins/vibration/ios"
|
||||||
|
wakelock_plus:
|
||||||
|
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||||
@@ -189,6 +194,7 @@ SPEC CHECKSUMS:
|
|||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||||
|
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||||
|
|
||||||
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||||
|
|
||||||
|
|||||||
@@ -1723,6 +1723,16 @@ class AppProvider with ChangeNotifier {
|
|||||||
connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
|
connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
|
||||||
return contactsProvider.findContactByKey(contactPublicKey);
|
return contactsProvider.findContactByKey(contactPublicKey);
|
||||||
};
|
};
|
||||||
|
// Reset path before the last retry attempt to force flood mode
|
||||||
|
messagesProvider.resetPathBeforeLastRetryCallback = (contact) async {
|
||||||
|
if (connectionProvider.deviceInfo.isConnected) {
|
||||||
|
debugPrint(
|
||||||
|
'🔄 [AppProvider] Resetting path for ${contact.advName} before last retry (flood fallback)',
|
||||||
|
);
|
||||||
|
await connectionProvider.resetPath(contact.publicKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
messagesProvider.onFinalRouterFallbackCallback =
|
messagesProvider.onFinalRouterFallbackCallback =
|
||||||
({required messageId, required contact, required message}) async {
|
({required messageId, required contact, required message}) async {
|
||||||
return _sendWithFinalNearestRouterFallback(
|
return _sendWithFinalNearestRouterFallback(
|
||||||
|
|||||||
@@ -586,7 +586,12 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_contacts[contact.publicKeyHex] = updatedContact;
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
// Keep repeaters and sensors in pending adverts so they remain visible
|
||||||
|
// in the discovery list (with a checkmark). Remove others.
|
||||||
|
if (contact.type != ContactType.repeater &&
|
||||||
|
contact.type != ContactType.sensor) {
|
||||||
|
_pendingAdverts.remove(contact.publicKeyHex);
|
||||||
|
}
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
|
||||||
);
|
);
|
||||||
@@ -615,7 +620,10 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
incomingContact: contact,
|
incomingContact: contact,
|
||||||
existingContact: existingContact,
|
existingContact: existingContact,
|
||||||
);
|
);
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
if (contact.type != ContactType.repeater &&
|
||||||
|
contact.type != ContactType.sensor) {
|
||||||
|
_pendingAdverts.remove(contact.publicKeyHex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (excluded > 0) {
|
if (excluded > 0) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
@@ -19,10 +20,18 @@ class MessageRetryManager {
|
|||||||
final Map<String, DateTime> _lastRetryTimes = {};
|
final Map<String, DateTime> _lastRetryTimes = {};
|
||||||
final Map<String, int> _pathFailureStreaks = {};
|
final Map<String, int> _pathFailureStreaks = {};
|
||||||
|
|
||||||
static const int maxRetryAttempts = 4;
|
/// Max retry attempts when the contact has a known path.
|
||||||
|
/// Official MeshCore app uses 5 (with auto-retry) or 3 (without).
|
||||||
|
static const int maxRetryAttemptsWithPath = 5;
|
||||||
|
|
||||||
|
/// No retries for flood-only contacts (no known path).
|
||||||
|
static const int maxRetryAttemptsFloodOnly = 1;
|
||||||
|
|
||||||
|
@Deprecated('Use maxRetryAttemptsForContact instead')
|
||||||
|
static const int maxRetryAttempts = maxRetryAttemptsWithPath;
|
||||||
|
|
||||||
// Retry backoff values in milliseconds.
|
// Retry backoff values in milliseconds.
|
||||||
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
|
static const List<int> _retryDelays = [1000, 2000, 4000, 8000, 8000];
|
||||||
static const int _defaultLoRaSf = 10;
|
static const int _defaultLoRaSf = 10;
|
||||||
static const int _defaultLoRaCr = 5;
|
static const int _defaultLoRaCr = 5;
|
||||||
static const int _defaultLoRaBwHz = 250000;
|
static const int _defaultLoRaBwHz = 250000;
|
||||||
@@ -38,32 +47,54 @@ class MessageRetryManager {
|
|||||||
return _retryDelays[attempt];
|
return _retryDelays[attempt];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
static final math.Random _rng = math.Random();
|
||||||
/// provide one or returns an invalid value.
|
|
||||||
|
/// Calculate a delivery-ACK timeout with random jitter.
|
||||||
|
///
|
||||||
|
/// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`.
|
||||||
|
/// The jitter prevents collision when multiple messages are in flight.
|
||||||
int calculateAckTimeoutMs({
|
int calculateAckTimeoutMs({
|
||||||
required String text,
|
required String text,
|
||||||
required Contact? contact,
|
required Contact? contact,
|
||||||
int? suggestedTimeoutMs,
|
int? suggestedTimeoutMs,
|
||||||
}) {
|
}) {
|
||||||
|
int baseTimeout;
|
||||||
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
||||||
return suggestedTimeoutMs;
|
baseTimeout = suggestedTimeoutMs;
|
||||||
|
} else {
|
||||||
|
final payloadBytes = utf8.encode(text).length;
|
||||||
|
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||||
|
final hopCount = contact?.routeHasPath == true
|
||||||
|
? contact!.routeHopCount
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (hopCount < 0) {
|
||||||
|
baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
||||||
|
} else {
|
||||||
|
baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final payloadBytes = utf8.encode(text).length;
|
// Add random jitter: 1000-8000ms (matches official app)
|
||||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
final jitterMs = 1000 + _rng.nextInt(7001);
|
||||||
final hopCount = contact?.routeHasPath == true
|
return baseTimeout + jitterMs;
|
||||||
? contact!.routeHopCount
|
}
|
||||||
: -1;
|
|
||||||
|
|
||||||
if (hopCount < 0) {
|
/// Max attempts for a given contact based on whether it has a known path.
|
||||||
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
static int maxRetryAttemptsForContact(Contact? contact) {
|
||||||
}
|
final hasPath = contact?.routeHasPath ?? false;
|
||||||
|
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
|
||||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool canRetry(Message message, Contact contact) {
|
bool canRetry(Message message, Contact contact) {
|
||||||
return message.retryAttempt < maxRetryAttempts;
|
return message.retryAttempt < maxRetryAttemptsForContact(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the next attempt is the last one.
|
||||||
|
/// When true, the caller should reset the path to force flood mode.
|
||||||
|
bool isLastAttempt(Message message, Contact contact) {
|
||||||
|
final maxAttempts = maxRetryAttemptsForContact(contact);
|
||||||
|
return maxAttempts > 1 && message.retryAttempt + 1 >= maxAttempts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track a retry attempt for a message
|
/// Track a retry attempt for a message
|
||||||
|
|||||||
@@ -96,6 +96,10 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> Function({required Contact contact, required int failureStreak})?
|
Future<void> Function({required Contact contact, required int failureStreak})?
|
||||||
onDirectPathFailedCallback;
|
onDirectPathFailedCallback;
|
||||||
|
|
||||||
|
/// Called before the last retry attempt to reset the contact's path,
|
||||||
|
/// forcing the firmware to use flood mode for the final try.
|
||||||
|
Future<void> Function(Contact contact)? resetPathBeforeLastRetryCallback;
|
||||||
void Function(String messageId)? onManualRetryPreparedCallback;
|
void Function(String messageId)? onManualRetryPreparedCallback;
|
||||||
Future<bool> Function({
|
Future<bool> Function({
|
||||||
required String messageId,
|
required String messageId,
|
||||||
@@ -2316,7 +2320,7 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
|
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId',
|
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttemptsForContact(contact)} for message $messageId',
|
||||||
);
|
);
|
||||||
debugPrint(' Delay: ${delayMs}ms');
|
debugPrint(' Delay: ${delayMs}ms');
|
||||||
|
|
||||||
@@ -2356,6 +2360,17 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On the last attempt, reset the path to force flood mode
|
||||||
|
// (matches official MeshCore app behaviour)
|
||||||
|
if (_retryManager.isLastAttempt(currentMessage, contact)) {
|
||||||
|
debugPrint(
|
||||||
|
'🔄 [MessagesProvider] Last attempt — resetting path to flood for $messageId',
|
||||||
|
);
|
||||||
|
if (resetPathBeforeLastRetryCallback != null) {
|
||||||
|
await resetPathBeforeLastRetryCallback!(contact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sendMessageCallback != null) {
|
if (sendMessageCallback != null) {
|
||||||
final queued = await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: contact.publicKey,
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
|||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Import Contact')),
|
appBar: AppBar(title: const Text('Add Contact')),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
@@ -315,7 +315,7 @@ class _AddContactScreenState extends State<AddContactScreen> {
|
|||||||
? 'Importing...'
|
? 'Importing...'
|
||||||
: _importSucceeded
|
: _importSucceeded
|
||||||
? 'Imported'
|
? 'Imported'
|
||||||
: 'Import Contact',
|
: 'Add Contact',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
@@ -675,7 +675,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _openAddContactScreen(context),
|
onPressed: () => _openAddContactScreen(context),
|
||||||
icon: const Icon(Icons.person_add_alt_1_outlined),
|
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||||
label: const Text('Import Contact'),
|
label: const Text('Add Contact'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -737,7 +737,24 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
title: l10n.repeaters,
|
title: l10n.repeaters,
|
||||||
count: repeaters.length,
|
count: repeaters.length,
|
||||||
icon: Icons.router,
|
icon: Icons.router,
|
||||||
trailing: _buildSortMenu(context, ContactSection.repeaters),
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.radar, size: 20),
|
||||||
|
tooltip: 'Discover repeaters',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
context.read<ConnectionProvider>().discoverNodeType(advertType: 2);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Repeater discovery sent')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildSortMenu(context, ContactSection.repeaters),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildSectionFilterField(
|
_buildSectionFilterField(
|
||||||
context,
|
context,
|
||||||
@@ -789,7 +806,24 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
title: 'Sensors',
|
title: 'Sensors',
|
||||||
count: sensors.length,
|
count: sensors.length,
|
||||||
icon: Icons.sensors,
|
icon: Icons.sensors,
|
||||||
trailing: _buildSortMenu(context, ContactSection.sensors),
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (context.watch<ConnectionProvider>().deviceInfo.isConnected)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.radar, size: 20),
|
||||||
|
tooltip: 'Discover sensors',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
onPressed: () {
|
||||||
|
context.read<ConnectionProvider>().discoverNodeType(advertType: 4);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Sensor discovery sent')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildSortMenu(context, ContactSection.sensors),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_buildSectionFilterField(
|
_buildSectionFilterField(
|
||||||
context,
|
context,
|
||||||
@@ -887,7 +921,7 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _openAddContactScreen(context),
|
onPressed: () => _openAddContactScreen(context),
|
||||||
icon: const Icon(Icons.person_add_alt_1_outlined),
|
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||||
label: const Text('Import Contact'),
|
label: const Text('Add Contact'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 24,
|
horizontal: 24,
|
||||||
|
|||||||
@@ -426,6 +426,16 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||||
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||||
|
|
||||||
|
// Track which pending adverts are already in the contacts list
|
||||||
|
final resolvedKeySet = <String>{};
|
||||||
|
for (final advert in pendingAdverts) {
|
||||||
|
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
|
||||||
|
resolvedKeySet.add(advert.publicKeyHex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final totalCount = pendingAdverts.length;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
@@ -442,7 +452,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Pending discoveries (${pendingAdverts.length})',
|
'Discovered nodes ($totalCount)',
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -495,7 +505,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (pendingAdverts.isEmpty)
|
if (totalCount == 0)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -507,112 +517,29 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No pending discoveries',
|
'No discovered nodes',
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Unknown adverts will appear here until you choose to resolve them.',
|
'Use the menu to discover repeaters and sensors on the mesh.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...pendingAdverts.map((advert) {
|
...pendingAdverts.map((advert) =>
|
||||||
final isResolving = _resolvingAdvertKeys.contains(
|
_buildPendingAdvertCard(
|
||||||
advert.publicKeyHex,
|
context,
|
||||||
);
|
advert: advert,
|
||||||
final displayName = _displayNameForAdvert(
|
contactsProvider: contactsProvider,
|
||||||
advert,
|
isConnected: isConnected,
|
||||||
contactsProvider,
|
isResolved: resolvedKeySet.contains(advert.publicKeyHex),
|
||||||
cachedNodes,
|
cachedNodes: cachedNodes,
|
||||||
);
|
l10n: l10n,
|
||||||
final typeLabel = _resolvedTypeLabelForAdvert(
|
),
|
||||||
advert,
|
),
|
||||||
cachedNodes,
|
|
||||||
);
|
|
||||||
final downMetric = SignalMetric.fromValues(
|
|
||||||
rssiDbm: advert.rxRssiDbm,
|
|
||||||
snrDb: advert.rxSnr,
|
|
||||||
);
|
|
||||||
final upMetric = SignalMetric.fromValues(
|
|
||||||
rssiDbm: advert.repeaterLastRssi,
|
|
||||||
snrDb: advert.repeaterLastSnr,
|
|
||||||
);
|
|
||||||
final detailLines = <String>[
|
|
||||||
'${l10n.publicKey}: ${advert.shortDisplayKey}',
|
|
||||||
];
|
|
||||||
final summaryParts = <String>[];
|
|
||||||
final battery = advert.repeaterBatteryPercent;
|
|
||||||
if (battery != null) {
|
|
||||||
summaryParts.add('Battery ${battery.round()}%');
|
|
||||||
}
|
|
||||||
if (advert.repeaterQueueLen != null) {
|
|
||||||
summaryParts.add('Queue ${advert.repeaterQueueLen}');
|
|
||||||
}
|
|
||||||
if (summaryParts.isNotEmpty) {
|
|
||||||
detailLines.add(summaryParts.join(' • '));
|
|
||||||
}
|
|
||||||
detailLines.add(
|
|
||||||
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
|
||||||
);
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
CircleAvatar(child: Icon(_iconForAdvert(advert))),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _buildAdvertTitle(
|
|
||||||
context,
|
|
||||||
displayName: displayName,
|
|
||||||
subtitle: typeLabel,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (downMetric != null || upMetric != null) ...[
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
_buildSignalSummary(
|
|
||||||
context,
|
|
||||||
downMetric: downMetric,
|
|
||||||
upMetric: upMetric,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
isResolving
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: IconButton(
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
icon: const Icon(Icons.person_add_alt_1),
|
|
||||||
tooltip: 'Resolve contact',
|
|
||||||
onPressed: isConnected
|
|
||||||
? () => _resolveAdvert(advert)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
detailLines.join('\n'),
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -621,6 +548,105 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPendingAdvertCard(
|
||||||
|
BuildContext context, {
|
||||||
|
required PendingAdvert advert,
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required bool isConnected,
|
||||||
|
required bool isResolved,
|
||||||
|
required List<MeshMapNode> cachedNodes,
|
||||||
|
required AppLocalizations l10n,
|
||||||
|
}) {
|
||||||
|
final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex);
|
||||||
|
final displayName = _displayNameForAdvert(advert, contactsProvider, cachedNodes);
|
||||||
|
final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes);
|
||||||
|
final downMetric = SignalMetric.fromValues(
|
||||||
|
rssiDbm: advert.rxRssiDbm,
|
||||||
|
snrDb: advert.rxSnr,
|
||||||
|
);
|
||||||
|
final upMetric = SignalMetric.fromValues(
|
||||||
|
rssiDbm: advert.repeaterLastRssi,
|
||||||
|
snrDb: advert.repeaterLastSnr,
|
||||||
|
);
|
||||||
|
final detailLines = <String>[
|
||||||
|
'${l10n.publicKey}: ${advert.shortDisplayKey}',
|
||||||
|
];
|
||||||
|
final summaryParts = <String>[];
|
||||||
|
final battery = advert.repeaterBatteryPercent;
|
||||||
|
if (battery != null) {
|
||||||
|
summaryParts.add('Battery ${battery.round()}%');
|
||||||
|
}
|
||||||
|
if (advert.repeaterQueueLen != null) {
|
||||||
|
summaryParts.add('Queue ${advert.repeaterQueueLen}');
|
||||||
|
}
|
||||||
|
if (summaryParts.isNotEmpty) {
|
||||||
|
detailLines.add(summaryParts.join(' • '));
|
||||||
|
}
|
||||||
|
detailLines.add(
|
||||||
|
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(child: Icon(_iconForAdvert(advert))),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _buildAdvertTitle(
|
||||||
|
context,
|
||||||
|
displayName: displayName,
|
||||||
|
subtitle: typeLabel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (downMetric != null || upMetric != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_buildSignalSummary(
|
||||||
|
context,
|
||||||
|
downMetric: downMetric,
|
||||||
|
upMetric: upMetric,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (isResolved)
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 24,
|
||||||
|
)
|
||||||
|
else if (isResolving)
|
||||||
|
const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
icon: const Icon(Icons.person_add_alt_1),
|
||||||
|
tooltip: 'Resolve contact',
|
||||||
|
onPressed: isConnected
|
||||||
|
? () => _resolveAdvert(advert)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
detailLines.join('\n'),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSignalSummary(
|
Widget _buildSignalSummary(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
SignalMetric? downMetric,
|
SignalMetric? downMetric,
|
||||||
|
|||||||
@@ -402,45 +402,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildActivityBadge({
|
|
||||||
required String label,
|
|
||||||
required int count,
|
|
||||||
required bool isActive,
|
|
||||||
required Color activeColor,
|
|
||||||
bool compact = false,
|
|
||||||
}) {
|
|
||||||
final color = isActive ? activeColor : Colors.grey;
|
|
||||||
return Container(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: compact ? 7 : 8,
|
|
||||||
vertical: compact ? 4 : 5,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: compact ? 6 : 7,
|
|
||||||
height: compact ? 6 : 7,
|
|
||||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
|
||||||
),
|
|
||||||
SizedBox(width: compact ? 5 : 6),
|
|
||||||
Text(
|
|
||||||
'$label:$count',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: compact ? 10 : 11,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildCompactActivityIndicator({
|
Widget _buildCompactActivityIndicator({
|
||||||
required bool rxActive,
|
required bool rxActive,
|
||||||
required bool txActive,
|
required bool txActive,
|
||||||
@@ -1186,48 +1147,13 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: isTight
|
child: _buildCompactActivityIndicator(
|
||||||
? _buildCompactActivityIndicator(
|
|
||||||
rxActive: provider.rxActivity,
|
rxActive: provider.rxActivity,
|
||||||
txActive: provider.txActivity,
|
txActive: provider.txActivity,
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
constraints: const BoxConstraints(minHeight: 48),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHigh
|
|
||||||
.withValues(alpha: 0.85),
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
_buildActivityBadge(
|
|
||||||
label: 'RX',
|
|
||||||
count: provider.rxPacketCount,
|
|
||||||
isActive: provider.rxActivity,
|
|
||||||
activeColor: Colors.green,
|
|
||||||
compact: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
_buildActivityBadge(
|
|
||||||
label: 'TX',
|
|
||||||
count: provider.txPacketCount,
|
|
||||||
isActive: provider.txActivity,
|
|
||||||
activeColor: Colors.blue,
|
|
||||||
compact: true,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
SizedBox(width: isTight ? 24 : 74),
|
const SizedBox(width: 24),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -867,11 +867,63 @@ class _SensorCandidatePreview extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EmptySensorsState extends StatelessWidget {
|
class _EmptySensorsState extends StatefulWidget {
|
||||||
const _EmptySensorsState();
|
const _EmptySensorsState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EmptySensorsState> createState() => _EmptySensorsStateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptySensorsStateState extends State<_EmptySensorsState> {
|
||||||
|
bool _discoveryTriggered = false;
|
||||||
|
bool _discoveryInProgress = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Auto-trigger sensor discovery when the empty state is shown
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_autoDiscover();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _autoDiscover() async {
|
||||||
|
if (_discoveryTriggered || !mounted) return;
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||||
|
|
||||||
|
_discoveryTriggered = true;
|
||||||
|
setState(() => _discoveryInProgress = true);
|
||||||
|
try {
|
||||||
|
await connectionProvider.discoverNodeType(advertType: 4);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _discoveryInProgress = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _discoverSensors() async {
|
||||||
|
if (_discoveryInProgress || !mounted) return;
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) return;
|
||||||
|
|
||||||
|
setState(() => _discoveryInProgress = true);
|
||||||
|
try {
|
||||||
|
await connectionProvider.discoverNodeType(advertType: 4);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Sensor discovery sent')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _discoveryInProgress = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isConnected =
|
||||||
|
context.watch<ConnectionProvider>().deviceInfo.isConnected;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 64),
|
padding: const EdgeInsets.symmetric(vertical: 64),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -902,6 +954,25 @@ class _EmptySensorsState extends StatelessWidget {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: isConnected && !_discoveryInProgress
|
||||||
|
? _discoverSensors
|
||||||
|
: null,
|
||||||
|
icon: _discoveryInProgress
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.sensors_outlined),
|
||||||
|
label: Text(_discoveryInProgress
|
||||||
|
? 'Discovering...'
|
||||||
|
: 'Discover Sensors'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -731,25 +731,25 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
_techBadge(
|
_techBadge(
|
||||||
context,
|
sheetContext,
|
||||||
icon: Icons.message,
|
icon: Icons.message,
|
||||||
label: widget.message.messageType.name
|
label: widget.message.messageType.name
|
||||||
.toUpperCase(),
|
.toUpperCase(),
|
||||||
),
|
),
|
||||||
_techBadge(
|
_techBadge(
|
||||||
context,
|
sheetContext,
|
||||||
icon: Icons.route,
|
icon: Icons.route,
|
||||||
label: hopDisplayLabel(widget.message),
|
label: hopDisplayLabel(widget.message),
|
||||||
),
|
),
|
||||||
_techBadge(
|
_techBadge(
|
||||||
context,
|
sheetContext,
|
||||||
icon: Icons.account_tree_outlined,
|
icon: Icons.account_tree_outlined,
|
||||||
label:
|
label:
|
||||||
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
|
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
|
||||||
),
|
),
|
||||||
if (widget.message.channelIdx != null)
|
if (widget.message.channelIdx != null)
|
||||||
_techBadge(
|
_techBadge(
|
||||||
context,
|
sheetContext,
|
||||||
icon: Icons.group_work,
|
icon: Icons.group_work,
|
||||||
label: 'CH ${widget.message.channelIdx}',
|
label: 'CH ${widget.message.channelIdx}',
|
||||||
),
|
),
|
||||||
@@ -760,14 +760,14 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
rssiDbm != null) ...[
|
rssiDbm != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_techSection(
|
_techSection(
|
||||||
context,
|
sheetContext,
|
||||||
icon: Icons.network_check,
|
icon: Icons.network_check,
|
||||||
title: l10n.linkQuality,
|
title: l10n.linkQuality,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (rssiDbm != null)
|
if (rssiDbm != null)
|
||||||
_signalRow(
|
_signalRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'RSSI',
|
label: 'RSSI',
|
||||||
valueLabel: '$rssiDbm dBm',
|
valueLabel: '$rssiDbm dBm',
|
||||||
normalized:
|
normalized:
|
||||||
@@ -782,7 +782,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (snrDb != null) ...[
|
if (snrDb != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_signalRow(
|
_signalRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'SNR',
|
label: 'SNR',
|
||||||
valueLabel: '${snrDb.toStringAsFixed(1)} dB',
|
valueLabel: '${snrDb.toStringAsFixed(1)} dB',
|
||||||
normalized: ((snrDb + 20.0) / 40.0).clamp(
|
normalized: ((snrDb + 20.0) / 40.0).clamp(
|
||||||
@@ -802,18 +802,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_techSection(
|
_techSection(
|
||||||
context,
|
sheetContext,
|
||||||
icon: Icons.tune,
|
icon: Icons.tune,
|
||||||
title: l10n.delivery,
|
title: l10n.delivery,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.status,
|
label: l10n.status,
|
||||||
value: widget.message.deliveryStatus.name,
|
value: widget.message.deliveryStatus.name,
|
||||||
),
|
),
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Received (RFC3339)',
|
label: 'Received (RFC3339)',
|
||||||
value: _formatRfc3339(widget.message.receivedAt),
|
value: _formatRfc3339(widget.message.receivedAt),
|
||||||
onCopy: () => copyField(
|
onCopy: () => copyField(
|
||||||
@@ -822,14 +822,14 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (widget.message.expectedAckTag != null)
|
if (widget.message.expectedAckTag != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.expectedAckTag,
|
label: l10n.expectedAckTag,
|
||||||
value: widget.message.expectedAckTag!
|
value: widget.message.expectedAckTag!
|
||||||
.toString(),
|
.toString(),
|
||||||
),
|
),
|
||||||
if (receptionDetails?.senderToReceiptMs != null)
|
if (receptionDetails?.senderToReceiptMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Sender to receipt',
|
label: 'Sender to receipt',
|
||||||
value: _formatDurationMs(
|
value: _formatDurationMs(
|
||||||
receptionDetails!.senderToReceiptMs!,
|
receptionDetails!.senderToReceiptMs!,
|
||||||
@@ -837,7 +837,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (receptionDetails?.estimatedTransmitMs != null)
|
if (receptionDetails?.estimatedTransmitMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Estimated tx',
|
label: 'Estimated tx',
|
||||||
value: _formatDurationMs(
|
value: _formatDurationMs(
|
||||||
receptionDetails!.estimatedTransmitMs!,
|
receptionDetails!.estimatedTransmitMs!,
|
||||||
@@ -845,7 +845,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (receptionDetails?.postTransmitDelayMs != null)
|
if (receptionDetails?.postTransmitDelayMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Post-tx delay',
|
label: 'Post-tx delay',
|
||||||
value: _formatDurationMs(
|
value: _formatDurationMs(
|
||||||
receptionDetails!.postTransmitDelayMs!,
|
receptionDetails!.postTransmitDelayMs!,
|
||||||
@@ -853,44 +853,44 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (widget.receivedCopies > 1)
|
if (widget.receivedCopies > 1)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Received copies',
|
label: 'Received copies',
|
||||||
value: '${widget.receivedCopies}',
|
value: '${widget.receivedCopies}',
|
||||||
),
|
),
|
||||||
if (widget.message.suggestedTimeoutMs != null)
|
if (widget.message.suggestedTimeoutMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'ACK timeout',
|
label: 'ACK timeout',
|
||||||
value:
|
value:
|
||||||
'${widget.message.suggestedTimeoutMs} ms',
|
'${widget.message.suggestedTimeoutMs} ms',
|
||||||
),
|
),
|
||||||
if (retryCause != null)
|
if (retryCause != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Retry cause',
|
label: 'Retry cause',
|
||||||
value: retryCause,
|
value: retryCause,
|
||||||
),
|
),
|
||||||
if (retryMode != null)
|
if (retryMode != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Retry mode',
|
label: 'Retry mode',
|
||||||
value: retryMode,
|
value: retryMode,
|
||||||
),
|
),
|
||||||
if (widget.message.roundTripTimeMs != null)
|
if (widget.message.roundTripTimeMs != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.roundTrip,
|
label: l10n.roundTrip,
|
||||||
value: '${widget.message.roundTripTimeMs} ms',
|
value: '${widget.message.roundTripTimeMs} ms',
|
||||||
),
|
),
|
||||||
if (widget.message.retryAttempt > 0)
|
if (widget.message.retryAttempt > 0)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.retryAttempt,
|
label: l10n.retryAttempt,
|
||||||
value: '${widget.message.retryAttempt}/4',
|
value: '${widget.message.retryAttempt}/4',
|
||||||
),
|
),
|
||||||
if (widget.message.lastRetryAt != null)
|
if (widget.message.lastRetryAt != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Last retry',
|
label: 'Last retry',
|
||||||
value: _formatRfc3339(
|
value: _formatRfc3339(
|
||||||
widget.message.lastRetryAt!,
|
widget.message.lastRetryAt!,
|
||||||
@@ -901,33 +901,33 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (widget.message.usedFloodFallback)
|
if (widget.message.usedFloodFallback)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.floodFallback,
|
label: l10n.floodFallback,
|
||||||
value: l10n.yes,
|
value: l10n.yes,
|
||||||
),
|
),
|
||||||
if (routeMetadata?.relayName case final relayName?)
|
if (routeMetadata?.relayName case final relayName?)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Relay',
|
label: 'Relay',
|
||||||
value: relayName,
|
value: relayName,
|
||||||
),
|
),
|
||||||
if (routeMetadata?.canonicalPath
|
if (routeMetadata?.canonicalPath
|
||||||
case final routePath?)
|
case final routePath?)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Selected path',
|
label: 'Selected path',
|
||||||
value: routePath,
|
value: routePath,
|
||||||
onCopy: () => copyField(routePath),
|
onCopy: () => copyField(routePath),
|
||||||
),
|
),
|
||||||
if (retryResult != null)
|
if (retryResult != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Retry result',
|
label: 'Retry result',
|
||||||
value: retryResult,
|
value: retryResult,
|
||||||
),
|
),
|
||||||
if (packetPathHex != null)
|
if (packetPathHex != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Path bytes',
|
label: 'Path bytes',
|
||||||
value: packetPathHex,
|
value: packetPathHex,
|
||||||
onCopy: () => copyField(packetPathHex),
|
onCopy: () => copyField(packetPathHex),
|
||||||
@@ -937,19 +937,19 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_techSection(
|
_techSection(
|
||||||
context,
|
sheetContext,
|
||||||
icon: Icons.badge,
|
icon: Icons.badge,
|
||||||
title: l10n.identity,
|
title: l10n.identity,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.messageId,
|
label: l10n.messageId,
|
||||||
value: widget.message.id,
|
value: widget.message.id,
|
||||||
onCopy: () => copyField(widget.message.id),
|
onCopy: () => copyField(widget.message.id),
|
||||||
),
|
),
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.sender,
|
label: l10n.sender,
|
||||||
value:
|
value:
|
||||||
senderName ??
|
senderName ??
|
||||||
@@ -958,20 +958,20 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (senderPrefixHex != null)
|
if (senderPrefixHex != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.senderKey,
|
label: l10n.senderKey,
|
||||||
value: senderPrefixHex,
|
value: senderPrefixHex,
|
||||||
onCopy: () => copyField(senderPrefixHex),
|
onCopy: () => copyField(senderPrefixHex),
|
||||||
),
|
),
|
||||||
if (recipientName != null)
|
if (recipientName != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.recipient,
|
label: l10n.recipient,
|
||||||
value: recipientName,
|
value: recipientName,
|
||||||
),
|
),
|
||||||
if (recipientPrefixHex != null)
|
if (recipientPrefixHex != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.recipientKey,
|
label: l10n.recipientKey,
|
||||||
value: recipientPrefixHex,
|
value: recipientPrefixHex,
|
||||||
onCopy: () => copyField(recipientPrefixHex),
|
onCopy: () => copyField(recipientPrefixHex),
|
||||||
@@ -982,18 +982,18 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (widget.message.isVoice) ...[
|
if (widget.message.isVoice) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_techSection(
|
_techSection(
|
||||||
context,
|
sheetContext,
|
||||||
icon: Icons.graphic_eq,
|
icon: Icons.graphic_eq,
|
||||||
title: l10n.voice,
|
title: l10n.voice,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.voiceId,
|
label: l10n.voiceId,
|
||||||
value: widget.message.voiceId ?? '-',
|
value: widget.message.voiceId ?? '-',
|
||||||
),
|
),
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.envelope,
|
label: l10n.envelope,
|
||||||
value: envelope != null
|
value: envelope != null
|
||||||
? 'VE3 compact'
|
? 'VE3 compact'
|
||||||
@@ -1001,14 +1001,14 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (voiceSession != null)
|
if (voiceSession != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.sessionProgress,
|
label: l10n.sessionProgress,
|
||||||
value:
|
value:
|
||||||
'${voiceSession.receivedCount}/${voiceSession.total} segments',
|
'${voiceSession.receivedCount}/${voiceSession.total} segments',
|
||||||
),
|
),
|
||||||
if (voiceSession != null)
|
if (voiceSession != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.complete,
|
label: l10n.complete,
|
||||||
value: voiceSession.isComplete
|
value: voiceSession.isComplete
|
||||||
? l10n.yes
|
? l10n.yes
|
||||||
@@ -1016,14 +1016,14 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (transferDetails != null)
|
if (transferDetails != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Transfers',
|
label: 'Transfers',
|
||||||
value: '${transferDetails.totalTransfers}',
|
value: '${transferDetails.totalTransfers}',
|
||||||
),
|
),
|
||||||
if (transferDetails != null &&
|
if (transferDetails != null &&
|
||||||
transferDetails.downloaders.isNotEmpty)
|
transferDetails.downloaders.isNotEmpty)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Downloaded by',
|
label: 'Downloaded by',
|
||||||
value: _formatDownloaderSummary(
|
value: _formatDownloaderSummary(
|
||||||
transferDetails,
|
transferDetails,
|
||||||
@@ -1031,7 +1031,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (voiceTxEstimate > Duration.zero)
|
if (voiceTxEstimate > Duration.zero)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Estimated tx',
|
label: 'Estimated tx',
|
||||||
value: voiceTxEstimate.inSeconds < 60
|
value: voiceTxEstimate.inSeconds < 60
|
||||||
? '~${voiceTxEstimate.inSeconds}s'
|
? '~${voiceTxEstimate.inSeconds}s'
|
||||||
@@ -1044,29 +1044,29 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
if (imageEnvelope != null) ...[
|
if (imageEnvelope != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_techSection(
|
_techSection(
|
||||||
context,
|
sheetContext,
|
||||||
icon: Icons.image_outlined,
|
icon: Icons.image_outlined,
|
||||||
title: 'Image',
|
title: 'Image',
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.envelope,
|
label: l10n.envelope,
|
||||||
value: 'IE1',
|
value: 'IE1',
|
||||||
),
|
),
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Format',
|
label: 'Format',
|
||||||
value: imageEnvelope.format.label,
|
value: imageEnvelope.format.label,
|
||||||
),
|
),
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Dimensions',
|
label: 'Dimensions',
|
||||||
value:
|
value:
|
||||||
'${imageEnvelope.width}×${imageEnvelope.height}',
|
'${imageEnvelope.width}×${imageEnvelope.height}',
|
||||||
),
|
),
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Segments',
|
label: 'Segments',
|
||||||
value: imageSession != null
|
value: imageSession != null
|
||||||
? '${imageSession.receivedCount}/${imageSession.total}'
|
? '${imageSession.receivedCount}/${imageSession.total}'
|
||||||
@@ -1074,7 +1074,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (imageSession != null)
|
if (imageSession != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: l10n.complete,
|
label: l10n.complete,
|
||||||
value: imageSession.isComplete
|
value: imageSession.isComplete
|
||||||
? l10n.yes
|
? l10n.yes
|
||||||
@@ -1082,14 +1082,14 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (transferDetails != null)
|
if (transferDetails != null)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Transfers',
|
label: 'Transfers',
|
||||||
value: '${transferDetails.totalTransfers}',
|
value: '${transferDetails.totalTransfers}',
|
||||||
),
|
),
|
||||||
if (transferDetails != null &&
|
if (transferDetails != null &&
|
||||||
transferDetails.downloaders.isNotEmpty)
|
transferDetails.downloaders.isNotEmpty)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Downloaded by',
|
label: 'Downloaded by',
|
||||||
value: _formatDownloaderSummary(
|
value: _formatDownloaderSummary(
|
||||||
transferDetails,
|
transferDetails,
|
||||||
@@ -1097,7 +1097,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
),
|
),
|
||||||
if (imageTxEstimate > Duration.zero)
|
if (imageTxEstimate > Duration.zero)
|
||||||
_detailRow(
|
_detailRow(
|
||||||
context,
|
sheetContext,
|
||||||
label: 'Estimated tx',
|
label: 'Estimated tx',
|
||||||
value: imageTxEstimate.inSeconds < 60
|
value: imageTxEstimate.inSeconds < 60
|
||||||
? '~${imageTxEstimate.inSeconds}s'
|
? '~${imageTxEstimate.inSeconds}s'
|
||||||
@@ -1124,7 +1124,7 @@ class _MessageBubbleState extends State<MessageBubble> {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context)
|
color: Theme.of(sheetContext)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.surfaceContainerHighest
|
.surfaceContainerHighest
|
||||||
.withValues(alpha: 0.35),
|
.withValues(alpha: 0.35),
|
||||||
|
|||||||
Reference in New Issue
Block a user