Fix BottomSheet ancestor lookup

This commit is contained in:
Janez T
2026-03-16 21:46:37 +01:00
parent 1fe260b95e
commit 434d3df53e
11 changed files with 377 additions and 250 deletions

View File

@@ -84,6 +84,8 @@ PODS:
- Flutter
- vibration (3.0.0):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
@@ -108,6 +110,7 @@ DEPENDENCIES:
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- vibration (from `.symlinks/plugins/vibration/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
@@ -161,6 +164,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
vibration:
:path: ".symlinks/plugins/vibration/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
@@ -189,6 +194,7 @@ SPEC CHECKSUMS:
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878

View File

@@ -1723,6 +1723,16 @@ class AppProvider with ChangeNotifier {
connectionProvider.resolveContactForDmCallback = (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 =
({required messageId, required contact, required message}) async {
return _sendWithFinalNearestRouterFallback(

View File

@@ -586,7 +586,12 @@ class ContactsProvider with ChangeNotifier {
);
_contacts[contact.publicKeyHex] = updatedContact;
// 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(
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
);
@@ -615,8 +620,11 @@ class ContactsProvider with ChangeNotifier {
incomingContact: contact,
existingContact: existingContact,
);
if (contact.type != ContactType.repeater &&
contact.type != ContactType.sensor) {
_pendingAdverts.remove(contact.publicKeyHex);
}
}
if (excluded > 0) {
debugPrint(
' [ContactsProvider] Excluded $excluded contact(s) matching device public key',

View File

@@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:math' as math;
import '../../models/message.dart';
import '../../models/contact.dart';
@@ -19,10 +20,18 @@ class MessageRetryManager {
final Map<String, DateTime> _lastRetryTimes = {};
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.
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 _defaultLoRaCr = 5;
static const int _defaultLoRaBwHz = 250000;
@@ -38,17 +47,21 @@ class MessageRetryManager {
return _retryDelays[attempt];
}
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
/// provide one or returns an invalid value.
static final math.Random _rng = math.Random();
/// 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({
required String text,
required Contact? contact,
int? suggestedTimeoutMs,
}) {
int baseTimeout;
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
@@ -56,14 +69,32 @@ class MessageRetryManager {
: -1;
if (hopCount < 0) {
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000);
} else {
baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
}
}
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
// Add random jitter: 1000-8000ms (matches official app)
final jitterMs = 1000 + _rng.nextInt(7001);
return baseTimeout + jitterMs;
}
/// Max attempts for a given contact based on whether it has a known path.
static int maxRetryAttemptsForContact(Contact? contact) {
final hasPath = contact?.routeHasPath ?? false;
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
}
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

View File

@@ -96,6 +96,10 @@ class MessagesProvider with ChangeNotifier {
Future<void> Function({required Contact contact, required int failureStreak})?
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;
Future<bool> Function({
required String messageId,
@@ -2316,7 +2320,7 @@ class MessagesProvider with ChangeNotifier {
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
debugPrint(
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId',
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttemptsForContact(contact)} for message $messageId',
);
debugPrint(' Delay: ${delayMs}ms');
@@ -2356,6 +2360,17 @@ class MessagesProvider with ChangeNotifier {
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) {
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,

View File

@@ -154,7 +154,7 @@ class _AddContactScreenState extends State<AddContactScreen> {
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Import Contact')),
appBar: AppBar(title: const Text('Add Contact')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
@@ -315,7 +315,7 @@ class _AddContactScreenState extends State<AddContactScreen> {
? 'Importing...'
: _importSucceeded
? 'Imported'
: 'Import Contact',
: 'Add Contact',
),
),
const SizedBox(height: 12),

View File

@@ -675,7 +675,7 @@ class _ContactsTabState extends State<ContactsTab> {
child: OutlinedButton.icon(
onPressed: () => _openAddContactScreen(context),
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,
count: repeaters.length,
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(
context,
@@ -789,7 +806,24 @@ class _ContactsTabState extends State<ContactsTab> {
title: 'Sensors',
count: sensors.length,
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(
context,
@@ -887,7 +921,7 @@ class _ContactsTabState extends State<ContactsTab> {
child: OutlinedButton.icon(
onPressed: () => _openAddContactScreen(context),
icon: const Icon(Icons.person_add_alt_1_outlined),
label: const Text('Import Contact'),
label: const Text('Add Contact'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,

View File

@@ -426,6 +426,16 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final isConnected = connectionProvider.deviceInfo.isConnected;
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(
padding: const EdgeInsets.all(16),
children: [
@@ -442,7 +452,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
const SizedBox(width: 12),
Expanded(
child: Text(
'Pending discoveries (${pendingAdverts.length})',
'Discovered nodes ($totalCount)',
style: Theme.of(context).textTheme.titleMedium,
),
),
@@ -495,7 +505,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
),
),
const SizedBox(height: 16),
if (pendingAdverts.isEmpty)
if (totalCount == 0)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
@@ -507,31 +517,49 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
),
const SizedBox(height: 16),
Text(
'No pending discoveries',
'No discovered nodes',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
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,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
...pendingAdverts.map((advert) {
final isResolving = _resolvingAdvertKeys.contains(
advert.publicKeyHex,
...pendingAdverts.map((advert) =>
_buildPendingAdvertCard(
context,
advert: advert,
contactsProvider: contactsProvider,
isConnected: isConnected,
isResolved: resolvedKeySet.contains(advert.publicKeyHex),
cachedNodes: cachedNodes,
l10n: l10n,
),
),
],
);
final displayName = _displayNameForAdvert(
advert,
contactsProvider,
cachedNodes,
);
final typeLabel = _resolvedTypeLabelForAdvert(
advert,
cachedNodes,
},
),
),
);
}
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,
@@ -585,15 +613,20 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
),
],
const SizedBox(width: 8),
isResolving
? const SizedBox(
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,
),
child: CircularProgressIndicator(strokeWidth: 2),
)
: IconButton(
else
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Resolve contact',
@@ -612,13 +645,6 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
),
),
);
}),
],
);
},
),
),
);
}
Widget _buildSignalSummary(

View File

@@ -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({
required bool rxActive,
required bool txActive,
@@ -1186,48 +1147,13 @@ class _HomeScreenState extends State<HomeScreen>
),
);
},
child: isTight
? _buildCompactActivityIndicator(
child: _buildCompactActivityIndicator(
rxActive: provider.rxActivity,
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
SizedBox(width: isTight ? 24 : 74),
const SizedBox(width: 24),
],
);
},

View File

@@ -867,11 +867,63 @@ class _SensorCandidatePreview extends StatelessWidget {
}
}
class _EmptySensorsState extends StatelessWidget {
class _EmptySensorsState extends StatefulWidget {
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
Widget build(BuildContext context) {
final isConnected =
context.watch<ConnectionProvider>().deviceInfo.isConnected;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 64),
child: Column(
@@ -902,6 +954,25 @@ class _EmptySensorsState extends StatelessWidget {
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'),
),
],
),
);

View File

@@ -731,25 +731,25 @@ class _MessageBubbleState extends State<MessageBubble> {
runSpacing: 8,
children: [
_techBadge(
context,
sheetContext,
icon: Icons.message,
label: widget.message.messageType.name
.toUpperCase(),
),
_techBadge(
context,
sheetContext,
icon: Icons.route,
label: hopDisplayLabel(widget.message),
),
_techBadge(
context,
sheetContext,
icon: Icons.account_tree_outlined,
label:
'${widget.message.echoCount} node${widget.message.echoCount == 1 ? '' : 's'}',
),
if (widget.message.channelIdx != null)
_techBadge(
context,
sheetContext,
icon: Icons.group_work,
label: 'CH ${widget.message.channelIdx}',
),
@@ -760,14 +760,14 @@ class _MessageBubbleState extends State<MessageBubble> {
rssiDbm != null) ...[
const SizedBox(height: 12),
_techSection(
context,
sheetContext,
icon: Icons.network_check,
title: l10n.linkQuality,
child: Column(
children: [
if (rssiDbm != null)
_signalRow(
context,
sheetContext,
label: 'RSSI',
valueLabel: '$rssiDbm dBm',
normalized:
@@ -782,7 +782,7 @@ class _MessageBubbleState extends State<MessageBubble> {
if (snrDb != null) ...[
const SizedBox(height: 8),
_signalRow(
context,
sheetContext,
label: 'SNR',
valueLabel: '${snrDb.toStringAsFixed(1)} dB',
normalized: ((snrDb + 20.0) / 40.0).clamp(
@@ -802,18 +802,18 @@ class _MessageBubbleState extends State<MessageBubble> {
],
const SizedBox(height: 12),
_techSection(
context,
sheetContext,
icon: Icons.tune,
title: l10n.delivery,
child: Column(
children: [
_detailRow(
context,
sheetContext,
label: l10n.status,
value: widget.message.deliveryStatus.name,
),
_detailRow(
context,
sheetContext,
label: 'Received (RFC3339)',
value: _formatRfc3339(widget.message.receivedAt),
onCopy: () => copyField(
@@ -822,14 +822,14 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (widget.message.expectedAckTag != null)
_detailRow(
context,
sheetContext,
label: l10n.expectedAckTag,
value: widget.message.expectedAckTag!
.toString(),
),
if (receptionDetails?.senderToReceiptMs != null)
_detailRow(
context,
sheetContext,
label: 'Sender to receipt',
value: _formatDurationMs(
receptionDetails!.senderToReceiptMs!,
@@ -837,7 +837,7 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (receptionDetails?.estimatedTransmitMs != null)
_detailRow(
context,
sheetContext,
label: 'Estimated tx',
value: _formatDurationMs(
receptionDetails!.estimatedTransmitMs!,
@@ -845,7 +845,7 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (receptionDetails?.postTransmitDelayMs != null)
_detailRow(
context,
sheetContext,
label: 'Post-tx delay',
value: _formatDurationMs(
receptionDetails!.postTransmitDelayMs!,
@@ -853,44 +853,44 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (widget.receivedCopies > 1)
_detailRow(
context,
sheetContext,
label: 'Received copies',
value: '${widget.receivedCopies}',
),
if (widget.message.suggestedTimeoutMs != null)
_detailRow(
context,
sheetContext,
label: 'ACK timeout',
value:
'${widget.message.suggestedTimeoutMs} ms',
),
if (retryCause != null)
_detailRow(
context,
sheetContext,
label: 'Retry cause',
value: retryCause,
),
if (retryMode != null)
_detailRow(
context,
sheetContext,
label: 'Retry mode',
value: retryMode,
),
if (widget.message.roundTripTimeMs != null)
_detailRow(
context,
sheetContext,
label: l10n.roundTrip,
value: '${widget.message.roundTripTimeMs} ms',
),
if (widget.message.retryAttempt > 0)
_detailRow(
context,
sheetContext,
label: l10n.retryAttempt,
value: '${widget.message.retryAttempt}/4',
),
if (widget.message.lastRetryAt != null)
_detailRow(
context,
sheetContext,
label: 'Last retry',
value: _formatRfc3339(
widget.message.lastRetryAt!,
@@ -901,33 +901,33 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (widget.message.usedFloodFallback)
_detailRow(
context,
sheetContext,
label: l10n.floodFallback,
value: l10n.yes,
),
if (routeMetadata?.relayName case final relayName?)
_detailRow(
context,
sheetContext,
label: 'Relay',
value: relayName,
),
if (routeMetadata?.canonicalPath
case final routePath?)
_detailRow(
context,
sheetContext,
label: 'Selected path',
value: routePath,
onCopy: () => copyField(routePath),
),
if (retryResult != null)
_detailRow(
context,
sheetContext,
label: 'Retry result',
value: retryResult,
),
if (packetPathHex != null)
_detailRow(
context,
sheetContext,
label: 'Path bytes',
value: packetPathHex,
onCopy: () => copyField(packetPathHex),
@@ -937,19 +937,19 @@ class _MessageBubbleState extends State<MessageBubble> {
),
const SizedBox(height: 12),
_techSection(
context,
sheetContext,
icon: Icons.badge,
title: l10n.identity,
child: Column(
children: [
_detailRow(
context,
sheetContext,
label: l10n.messageId,
value: widget.message.id,
onCopy: () => copyField(widget.message.id),
),
_detailRow(
context,
sheetContext,
label: l10n.sender,
value:
senderName ??
@@ -958,20 +958,20 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (senderPrefixHex != null)
_detailRow(
context,
sheetContext,
label: l10n.senderKey,
value: senderPrefixHex,
onCopy: () => copyField(senderPrefixHex),
),
if (recipientName != null)
_detailRow(
context,
sheetContext,
label: l10n.recipient,
value: recipientName,
),
if (recipientPrefixHex != null)
_detailRow(
context,
sheetContext,
label: l10n.recipientKey,
value: recipientPrefixHex,
onCopy: () => copyField(recipientPrefixHex),
@@ -982,18 +982,18 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.message.isVoice) ...[
const SizedBox(height: 12),
_techSection(
context,
sheetContext,
icon: Icons.graphic_eq,
title: l10n.voice,
child: Column(
children: [
_detailRow(
context,
sheetContext,
label: l10n.voiceId,
value: widget.message.voiceId ?? '-',
),
_detailRow(
context,
sheetContext,
label: l10n.envelope,
value: envelope != null
? 'VE3 compact'
@@ -1001,14 +1001,14 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (voiceSession != null)
_detailRow(
context,
sheetContext,
label: l10n.sessionProgress,
value:
'${voiceSession.receivedCount}/${voiceSession.total} segments',
),
if (voiceSession != null)
_detailRow(
context,
sheetContext,
label: l10n.complete,
value: voiceSession.isComplete
? l10n.yes
@@ -1016,14 +1016,14 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (transferDetails != null)
_detailRow(
context,
sheetContext,
label: 'Transfers',
value: '${transferDetails.totalTransfers}',
),
if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty)
_detailRow(
context,
sheetContext,
label: 'Downloaded by',
value: _formatDownloaderSummary(
transferDetails,
@@ -1031,7 +1031,7 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (voiceTxEstimate > Duration.zero)
_detailRow(
context,
sheetContext,
label: 'Estimated tx',
value: voiceTxEstimate.inSeconds < 60
? '~${voiceTxEstimate.inSeconds}s'
@@ -1044,29 +1044,29 @@ class _MessageBubbleState extends State<MessageBubble> {
if (imageEnvelope != null) ...[
const SizedBox(height: 12),
_techSection(
context,
sheetContext,
icon: Icons.image_outlined,
title: 'Image',
child: Column(
children: [
_detailRow(
context,
sheetContext,
label: l10n.envelope,
value: 'IE1',
),
_detailRow(
context,
sheetContext,
label: 'Format',
value: imageEnvelope.format.label,
),
_detailRow(
context,
sheetContext,
label: 'Dimensions',
value:
'${imageEnvelope.width}×${imageEnvelope.height}',
),
_detailRow(
context,
sheetContext,
label: 'Segments',
value: imageSession != null
? '${imageSession.receivedCount}/${imageSession.total}'
@@ -1074,7 +1074,7 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (imageSession != null)
_detailRow(
context,
sheetContext,
label: l10n.complete,
value: imageSession.isComplete
? l10n.yes
@@ -1082,14 +1082,14 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (transferDetails != null)
_detailRow(
context,
sheetContext,
label: 'Transfers',
value: '${transferDetails.totalTransfers}',
),
if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty)
_detailRow(
context,
sheetContext,
label: 'Downloaded by',
value: _formatDownloaderSummary(
transferDetails,
@@ -1097,7 +1097,7 @@ class _MessageBubbleState extends State<MessageBubble> {
),
if (imageTxEstimate > Duration.zero)
_detailRow(
context,
sheetContext,
label: 'Estimated tx',
value: imageTxEstimate.inSeconds < 60
? '~${imageTxEstimate.inSeconds}s'
@@ -1124,7 +1124,7 @@ class _MessageBubbleState extends State<MessageBubble> {
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context)
color: Theme.of(sheetContext)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.35),