Summarize repo changes

This commit is contained in:
Janez T
2026-03-14 10:39:27 +01:00
parent 5e1912d9f2
commit a3147e9d7c
9 changed files with 676 additions and 160 deletions

View File

@@ -264,7 +264,6 @@ class ConnectionProvider with ChangeNotifier {
try { try {
await _activeService.addOrUpdateContact(pendingOp.contact!); await _activeService.addOrUpdateContact(pendingOp.contact!);
await Future.delayed(const Duration(milliseconds: 300));
if (pendingOp.messageId != null) { if (pendingOp.messageId != null) {
_messageDeliveryTracker.trackPendingDirectMessage( _messageDeliveryTracker.trackPendingDirectMessage(
@@ -1272,12 +1271,11 @@ class ConnectionProvider with ChangeNotifier {
'⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)', '⚠️ [ConnectionProvider] Rate limit hit: $pendingCount pending ACKs (max 7)',
); );
debugPrint( debugPrint(
'⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmations...', '⚠️ Firmware only tracks 8 ACKs - waiting for delivery confirmation...',
); );
// Wait briefly for some ACKs to arrive, then proceed anyway // Wait for a slot to free up (delivery/timeout), max 500ms
// (User action shouldn't be blocked forever) await _messageDeliveryTracker.waitForSlot();
await Future.delayed(const Duration(milliseconds: 500));
if (_messageDeliveryTracker.shouldRateLimit) { if (_messageDeliveryTracker.shouldRateLimit) {
debugPrint( debugPrint(
@@ -1353,30 +1351,14 @@ class ConnectionProvider with ChangeNotifier {
attempt: retryAttempt, attempt: retryAttempt,
); );
if (messageId != null) { // Clear pending operation — any ERR_CODE_NOT_FOUND has already been
Future.delayed(const Duration(milliseconds: 350), () { // handled synchronously by onContactNotFound before sendTextMessage returns.
if (_messageDeliveryTracker.hasAckForMessage(messageId)) {
return;
}
debugPrint(
' [ConnectionProvider] Missing RESP_CODE_SENT for $messageId; promoting to sent via fallback',
);
onMessageSent?.call(messageId, 0, 0);
});
}
// Clear pending operation after successful send (no error)
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
if (effectiveContact != null) { if (effectiveContact != null) {
final operationId = contactPublicKey final operationId = contactPublicKey
.sublist(0, 6) .sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(':'); .join(':');
// Use a small delay to allow error response to arrive before clearing _pendingSendOperations.remove(operationId);
Future.delayed(const Duration(milliseconds: 500), () {
_pendingSendOperations.remove(operationId);
});
} }
return true; return true;

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
/// Message delivery tracking helper /// Message delivery tracking helper
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
/// Map of ACK tag to timestamp for timeout cleanup /// Map of ACK tag to timestamp for timeout cleanup
final Map<int, DateTime> _ackTagTimestamps = {}; final Map<int, DateTime> _ackTagTimestamps = {};
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
Completer<void>? _slotFreedCompleter;
/// Track a pending message ID before sending /// Track a pending message ID before sending
/// ///
/// This is called BEFORE sending the message. When RESP_CODE_SENT /// This is called BEFORE sending the message. When RESP_CODE_SENT
@@ -112,6 +116,7 @@ class MessageDeliveryTracker {
_messageIdToAckTag.remove(messageId); _messageIdToAckTag.remove(messageId);
} }
_ackTagTimestamps.remove(ackCode); _ackTagTimestamps.remove(ackCode);
_notifySlotFreed();
} }
/// Remove ACK tag mapping by message ID /// Remove ACK tag mapping by message ID
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
if (ackTag != null) { if (ackTag != null) {
_ackTagToMessageId.remove(ackTag); _ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag); _ackTagTimestamps.remove(ackTag);
_notifySlotFreed();
} }
_pendingMessageIds.remove(messageId); _pendingMessageIds.remove(messageId);
final emptyKeys = <String>[]; final emptyKeys = <String>[];
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
_ackTagToMessageId.clear(); _ackTagToMessageId.clear();
_messageIdToAckTag.clear(); _messageIdToAckTag.clear();
_ackTagTimestamps.clear(); _ackTagTimestamps.clear();
_notifySlotFreed();
} }
/// Get count of pending ACK tags /// Get count of pending ACK tags
@@ -179,6 +186,27 @@ class MessageDeliveryTracker {
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8) /// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
bool get shouldRateLimit => pendingCount >= 7; bool get shouldRateLimit => pendingCount >= 7;
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
///
/// Returns immediately if not at the rate limit.
Future<void> waitForSlot({
Duration timeout = const Duration(milliseconds: 500),
}) async {
if (!shouldRateLimit) return;
_slotFreedCompleter ??= Completer<void>();
await _slotFreedCompleter!.future.timeout(
timeout,
onTimeout: () {},
);
}
void _notifySlotFreed() {
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
_slotFreedCompleter!.complete();
}
_slotFreedCompleter = null;
}
/// Get oldest pending ACK timestamp (for debugging) /// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp { DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null; if (_ackTagTimestamps.isEmpty) return null;

View File

@@ -134,11 +134,12 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
final routeHashCounts = _RouteHashCounts.fromContacts( final routeHashCounts = _RouteHashCounts.fromContacts(
contactsProvider?.contacts ?? const <Contact>[], contactsProvider?.contacts ?? const <Contact>[],
); );
final packetTypes = unfilteredSnapshot.visibleEntries final packetTypes =
.map((entry) => entry.payloadLabel) unfilteredSnapshot.visibleEntries
.toSet() .map((entry) => entry.payloadLabel)
.toList() .toSet()
..sort(); .toList()
..sort();
final filteredEntries = snapshot.visibleEntries; final filteredEntries = snapshot.visibleEntries;
return Scaffold( return Scaffold(
@@ -202,7 +203,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
Icon( Icon(
Icons.radar_rounded, Icons.radar_rounded,
size: 64, size: 64,
color: theme.colorScheme.primary.withValues(alpha: 0.45), color: theme.colorScheme.primary.withValues(
alpha: 0.45,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
@@ -218,7 +221,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
? 'This view only shows in-memory traffic while it is active.' ? 'This view only shows in-memory traffic while it is active.'
: 'Try a different packet type or switch back to All.', : 'Try a different packet type or switch back to All.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: theme.colorScheme.onSurfaceVariant), style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
),
), ),
], ],
), ),
@@ -488,7 +493,9 @@ class _FilterChip extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: selected ? 0.14 : 0.08), color: color.withValues(alpha: selected ? 0.14 : 0.08),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: selected ? 0.45 : 0.22)), border: Border.all(
color: color.withValues(alpha: selected ? 0.45 : 0.22),
),
), ),
child: Text( child: Text(
label, label,
@@ -641,8 +648,9 @@ class _LiveTrafficCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx; final isRx = log.direction == PacketDirection.rx;
final accent = isRx ? Colors.green : Colors.blue; final accent = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo; final rxInfo = log.logRxDataInfo;
final routePreview = _resolvedRoutePreview(context, entry);
final originDistance = _originDistanceLabel(context, entry); final originDistance = _originDistanceLabel(context, entry);
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry);
final signalMetric = _SignalMetric.fromRxInfo(rxInfo);
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
@@ -657,7 +665,10 @@ class _LiveTrafficCard extends StatelessWidget {
Row( Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14), color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@@ -677,7 +688,7 @@ class _LiveTrafficCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
entry.payloadLabel, packetDetails.title,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -696,16 +707,32 @@ class _LiveTrafficCard extends StatelessWidget {
], ],
), ),
), ),
Text( if (signalMetric != null) ...[
_timeAgo(log.timestamp, now), const SizedBox(width: 12),
style: TextStyle( _CompactSignalIndicator(metric: signalMetric),
fontSize: 12, ] else
color: Theme.of(context).colorScheme.onSurfaceVariant, Text(
_timeAgo(log.timestamp, now),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
), ),
),
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
_PacketInfoLine(
text:
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes',
),
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'),
if (packetDetails.pathLine != null)
_PacketInfoLine(text: packetDetails.pathLine!),
if (packetDetails.pathHashLine != null)
_PacketInfoLine(text: packetDetails.pathHashLine!),
if (packetDetails.endpointLine != null)
_PacketInfoLine(text: packetDetails.endpointLine!),
const SizedBox(height: 10),
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
@@ -714,29 +741,18 @@ class _LiveTrafficCard extends StatelessWidget {
label: '${log.rawData.length} bytes', label: '${log.rawData.length} bytes',
onTap: () => _showPacketBytesSheet(context, log.rawData), onTap: () => _showPacketBytesSheet(context, log.rawData),
), ),
if (entry.isMultiHop)
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
if (originDistance != null)
_PacketMetaChip(label: 'Origin $originDistance'),
if (rxInfo?.rssiDbm != null) if (rxInfo?.rssiDbm != null)
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'), _PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
if (rxInfo?.snrDb != null) if (rxInfo?.snrDb != null)
_PacketMetaChip( _PacketMetaChip(
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB', label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
), ),
if (entry.hopCount != null)
_PacketMetaChip(label: '${entry.hopCount} hops'),
if (entry.isMultiHop)
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
if (originDistance != null)
_PacketMetaChip(label: 'Origin $originDistance'),
], ],
), ),
const SizedBox(height: 10),
Text(
routePreview,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
], ],
), ),
); );
@@ -748,6 +764,13 @@ class _LiveTrafficCard extends StatelessWidget {
return '${diff.inMinutes}m ago'; return '${diff.inMinutes}m ago';
} }
static String _formatClock(DateTime timestamp) {
final hour = timestamp.hour.toString().padLeft(2, '0');
final minute = timestamp.minute.toString().padLeft(2, '0');
final second = timestamp.second.toString().padLeft(2, '0');
return '$hour:$minute:$second';
}
static String _resolvedRoutePreview( static String _resolvedRoutePreview(
BuildContext context, BuildContext context,
LiveTrafficEntry entry, LiveTrafficEntry entry,
@@ -856,11 +879,16 @@ class _LiveTrafficCard extends StatelessWidget {
final hex = chunk final hex = chunk
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase()) .map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(' '); .join(' ');
hexLines.add('${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex'); hexLines.add(
'${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex',
);
} }
final ascii = data final ascii = data
.map((byte) => (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.') .map(
(byte) =>
(byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.',
)
.join(); .join();
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
@@ -886,10 +914,7 @@ class _LiveTrafficCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant), style: TextStyle(color: scheme.onSurfaceVariant),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text('Hex', style: Theme.of(context).textTheme.titleSmall),
'Hex',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
width: double.infinity, width: double.infinity,
@@ -909,10 +934,7 @@ class _LiveTrafficCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text('ASCII', style: Theme.of(context).textTheme.titleSmall),
'ASCII',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
width: double.infinity, width: double.infinity,
@@ -951,6 +973,253 @@ class _LiveTrafficCard extends StatelessWidget {
} }
} }
class _LiveTrafficPacketDetails {
final String title;
final String packetHashHex;
final String? pathLine;
final String? pathHashLine;
final String? endpointLine;
const _LiveTrafficPacketDetails({
required this.title,
required this.packetHashHex,
required this.pathLine,
required this.pathHashLine,
required this.endpointLine,
});
factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) {
final route = entry.route;
final payloadType = route?.payloadType;
final parsedPayload = _ParsedTrafficPayload.tryParse(
entry.log.rawData,
route,
);
final title = switch (payloadType) {
0x00 => 'FLOOD REQUEST',
0x01 => 'FLOOD RESPONSE',
0x02 => 'FLOOD TEXT',
0x03 => 'FLOOD ACK',
0x04 => 'FLOOD ADVERTISEMENT',
0x05 => 'FLOOD GROUP_TEXT',
0x06 => 'FLOOD GROUP_DATA',
0x07 => 'FLOOD ANON_REQUEST',
0x08 => 'FLOOD RETURNED_PATH',
0x09 => 'FLOOD TRACE_PATH',
0x0A => 'FLOOD MULTIPART',
0x0B => 'FLOOD CONTROL',
_ => entry.payloadLabel.toUpperCase(),
};
final hopHashes = route?.hopHashes ?? const <String>[];
final pathLine = hopHashes.isEmpty
? null
: 'Path: ${hopHashes.length} hop${hopHashes.length == 1 ? '' : 's'} [${hopHashes.join(',')}]';
final pathHashLine = route == null
? null
: 'Path Hashes: ${route.hashSize}-byte per hop';
return _LiveTrafficPacketDetails(
title: title,
packetHashHex: _packetHash(entry.log.rawData),
pathLine: pathLine,
pathHashLine: pathHashLine,
endpointLine: parsedPayload?.endpointLine,
);
}
static String _packetHash(List<int> bytes) {
const fnvOffset = 0xcbf29ce484222325;
const fnvPrime = 0x100000001b3;
const mask = 0xFFFFFFFFFFFFFFFF;
var hash = fnvOffset;
for (final byte in bytes) {
hash ^= byte & 0xFF;
hash = (hash * fnvPrime) & mask;
}
return hash.toRadixString(16).padLeft(16, '0').toUpperCase();
}
}
class _ParsedTrafficPayload {
final String? endpointLine;
const _ParsedTrafficPayload({this.endpointLine});
static _ParsedTrafficPayload? tryParse(
List<int> rawData,
DecodedLogRxRoute? route,
) {
if (rawData.length < 5 ||
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
return null;
}
final rawPacketData = rawData.sublist(3);
if (rawPacketData.length < 2) return null;
final header = rawPacketData[0];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
var index = 1;
if (routeType == 0x00 || routeType == 0x03) {
if (rawPacketData.length < index + 5) return null;
index += 4;
}
if (rawPacketData.length <= index) return null;
final pathDescriptor = rawPacketData[index++];
final pathByteLen =
LogRxRouteDecoder.descriptorByteLength(pathDescriptor) ??
(pathDescriptor == 0xFF ? 0 : null);
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
return null;
}
index += pathByteLen;
final payload = rawPacketData.sublist(index);
final senderHash = route?.hopHashes.isNotEmpty == true
? route!.hopHashes.first
: null;
switch (payloadType) {
case 0x05:
case 0x06:
if (payload.isEmpty) return const _ParsedTrafficPayload();
return _ParsedTrafficPayload(
endpointLine:
'Channel Hash: ${payload.first.toRadixString(16).padLeft(2, '0').toUpperCase()}',
);
case 0x00:
case 0x01:
case 0x07:
if (payload.isEmpty) return const _ParsedTrafficPayload();
final destinationHash = payload.first
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return _ParsedTrafficPayload(
endpointLine: senderHash == null
? 'To: <$destinationHash>'
: 'From: <${senderHash.toUpperCase()}> To: <$destinationHash>',
);
default:
return const _ParsedTrafficPayload();
}
}
}
class _PacketInfoLine extends StatelessWidget {
final String text;
const _PacketInfoLine({required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
text,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
}
class _SignalMetric {
final String valueLabel;
final Color color;
final int activeBars;
const _SignalMetric({
required this.valueLabel,
required this.color,
required this.activeBars,
});
static _SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) {
if (rxInfo == null) return null;
if (rxInfo?.snrDb != null) {
final snr = rxInfo.snrDb!;
return _SignalMetric(
valueLabel: '${snr.toStringAsFixed(1)}dB',
color: snr >= 10
? Colors.green
: snr >= 0
? Colors.amber
: Colors.redAccent,
activeBars: snr >= 10
? 3
: snr >= 0
? 2
: 1,
);
}
if (rxInfo?.rssiDbm != null) {
final rssi = rxInfo.rssiDbm!;
return _SignalMetric(
valueLabel: '$rssi dBm',
color: rssi >= -80
? Colors.green
: rssi >= -95
? Colors.amber
: Colors.redAccent,
activeBars: rssi >= -80
? 3
: rssi >= -95
? 2
: 1,
);
}
return null;
}
}
class _CompactSignalIndicator extends StatelessWidget {
final _SignalMetric metric;
const _CompactSignalIndicator({required this.metric});
@override
Widget build(BuildContext context) {
final inactive = Theme.of(context).colorScheme.outlineVariant;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var index = 0; index < 3; index++) ...[
if (index > 0) const SizedBox(width: 3),
Container(
width: 5,
height: 10.0 + (index * 8),
decoration: BoxDecoration(
color: index < metric.activeBars ? metric.color : inactive,
borderRadius: BorderRadius.circular(2),
),
),
],
],
),
const SizedBox(height: 6),
Text(
metric.valueLabel,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
);
}
}
class _GeoPoint { class _GeoPoint {
final double latitude; final double latitude;
final double longitude; final double longitude;

View File

@@ -61,9 +61,10 @@ class _MessagesTabState extends State<MessagesTab> {
Timer? _highlightTimer; // Timer for clearing message highlight Timer? _highlightTimer; // Timer for clearing message highlight
Timer? _channelReadTimer; Timer? _channelReadTimer;
String? _pendingChannelReadKey; String? _pendingChannelReadKey;
TextEditingValue _lastComposerValue = const TextEditingValue();
bool _isMentionPickerOpen = false;
bool _suppressMentionTrigger = false; bool _suppressMentionTrigger = false;
TextRange? _activeMentionRange;
String _mentionQuery = '';
List<Contact> _mentionSuggestions = const [];
// Message destination state // Message destination state
String _destinationType = String _destinationType =
@@ -92,8 +93,8 @@ class _MessagesTabState extends State<MessagesTab> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_lastComposerValue = _textController.value;
_textController.addListener(_handleComposerChanged); _textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceSettings(); _loadVoiceSettings();
@@ -126,6 +127,7 @@ class _MessagesTabState extends State<MessagesTab> {
_channelReadTimer?.cancel(); _channelReadTimer?.cancel();
_voiceStreamSub?.cancel(); _voiceStreamSub?.cancel();
_voiceRecorder.dispose(); _voiceRecorder.dispose();
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose(); _textController.dispose();
_focusNode.dispose(); _focusNode.dispose();
_scrollController.dispose(); _scrollController.dispose();
@@ -195,25 +197,13 @@ class _MessagesTabState extends State<MessagesTab> {
} }
void _handleComposerChanged() { void _handleComposerChanged() {
final previousValue = _lastComposerValue;
final currentValue = _textController.value;
_lastComposerValue = currentValue;
_updateCharacterCount(); _updateCharacterCount();
if (_suppressMentionTrigger || _isMentionPickerOpen) { if (_suppressMentionTrigger) {
return; return;
} }
final mentionTriggerRange = _getMentionTriggerRange( _updateMentionSuggestions(_textController.value);
previousValue: previousValue,
currentValue: currentValue,
);
if (mentionTriggerRange == null) {
return;
}
unawaited(_showMentionSelectorForRange(mentionTriggerRange));
} }
void _updateCharacterCount() { void _updateCharacterCount() {
@@ -222,43 +212,112 @@ class _MessagesTabState extends State<MessagesTab> {
}); });
} }
TextRange? _getMentionTriggerRange({ void _handleFocusChanged() {
required TextEditingValue previousValue, if (!_focusNode.hasFocus) {
required TextEditingValue currentValue, _clearMentionSuggestions();
}) { return;
if (!previousValue.selection.isValid || }
!currentValue.selection.isValid ||
!previousValue.selection.isCollapsed || if (_suppressMentionTrigger) {
!currentValue.selection.isCollapsed) { return;
}
_updateMentionSuggestions(_textController.value);
}
void _updateMentionSuggestions(TextEditingValue value) {
final triggerRange = _getMentionTriggerRange(value);
if (triggerRange == null) {
_clearMentionSuggestions();
return;
}
final query = value.text.substring(
triggerRange.start + 1,
triggerRange.end,
);
final contacts = _buildMentionSuggestions(query);
if (!mounted) {
return;
}
setState(() {
_activeMentionRange = triggerRange;
_mentionQuery = query;
_mentionSuggestions = contacts;
});
}
void _clearMentionSuggestions() {
if (_activeMentionRange == null &&
_mentionQuery.isEmpty &&
_mentionSuggestions.isEmpty) {
return;
}
setState(() {
_activeMentionRange = null;
_mentionQuery = '';
_mentionSuggestions = const [];
});
}
TextRange? _getMentionTriggerRange(TextEditingValue value) {
if (!value.selection.isValid || !value.selection.isCollapsed) {
return null; return null;
} }
final previousOffset = previousValue.selection.baseOffset; final cursorOffset = value.selection.baseOffset;
final currentOffset = currentValue.selection.baseOffset; if (cursorOffset <= 0) {
if (previousOffset < 0 || currentOffset < 0) {
return null; return null;
} }
if (currentValue.text.length != previousValue.text.length + 1 || final text = value.text;
currentOffset != previousOffset + 1) { final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
if (triggerStart == -1) {
return null; return null;
} }
if (currentValue.text.substring(0, previousOffset) != final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
previousValue.text.substring(0, previousOffset)) { if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
return null; return null;
} }
if (currentValue.text.substring(currentOffset) != final query = text.substring(triggerStart + 1, cursorOffset);
previousValue.text.substring(previousOffset)) { if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
return null; return null;
} }
if (currentValue.text[previousOffset] != '@') { return TextRange(start: triggerStart, end: cursorOffset);
return null; }
}
return TextRange(start: previousOffset, end: currentOffset); List<Contact> _buildMentionSuggestions(String query) {
final contactsProvider = context.read<ContactsProvider>();
final normalizedQuery = query.trim().toLowerCase();
final contacts =
contactsProvider.contacts
.where((contact) => contact.type == ContactType.chat)
.where((contact) {
if (normalizedQuery.isEmpty) return true;
return contact.displayName.toLowerCase().contains(
normalizedQuery,
);
})
.toList()
..sort((a, b) {
final aName = a.displayName.toLowerCase();
final bName = b.displayName.toLowerCase();
final aStarts =
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
final bStarts =
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
if (aStarts != bStarts) {
return aStarts ? -1 : 1;
}
return aName.compareTo(bName);
});
return contacts.take(8).toList(growable: false);
} }
int get _maxMessageBytes => int get _maxMessageBytes =>
@@ -378,56 +437,6 @@ class _MessagesTabState extends State<MessagesTab> {
); );
} }
Future<void> _showMentionSelectorForRange(TextRange triggerRange) async {
final contactsProvider = context.read<ContactsProvider>();
final contacts = contactsProvider.contacts
.where((contact) => contact.type == ContactType.chat)
.toList();
if (contacts.isEmpty || !mounted) {
return;
}
_isMentionPickerOpen = true;
Contact? selectedContact;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => RecipientSelectorSheet(
contacts: contacts,
rooms: const [],
channels: const [],
unreadCount: 0,
unreadCountsByPublicKey: {
for (final contact in contacts) contact.publicKeyHex: 0,
},
currentDestinationType: null,
currentRecipientPublicKey: null,
showAllOption: false,
onSelect: (_, recipient) {
selectedContact = recipient;
},
),
);
_isMentionPickerOpen = false;
if (!mounted) {
return;
}
if (selectedContact != null) {
_insertReplyMention(
selectedContact!.displayName,
replacementRange: triggerRange,
);
}
_focusNode.requestFocus();
}
/// Handle recipient selection /// Handle recipient selection
Future<void> _onRecipientSelected(String type, Contact? recipient) async { Future<void> _onRecipientSelected(String type, Contact? recipient) async {
setState(() { setState(() {
@@ -489,11 +498,19 @@ class _MessagesTabState extends State<MessagesTab> {
selection: TextSelection.collapsed(offset: nextOffset), selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty, composing: TextRange.empty,
); );
_lastComposerValue = _textController.value;
_suppressMentionTrigger = false; _suppressMentionTrigger = false;
_clearMentionSuggestions();
_enforceMessageByteLimit(); _enforceMessageByteLimit();
} }
void _selectMention(Contact contact) {
_insertReplyMention(
contact.displayName,
replacementRange: _activeMentionRange,
);
_focusNode.requestFocus();
}
Future<void> _replyToMessage(Message message) async { Future<void> _replyToMessage(Message message) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
@@ -2040,6 +2057,9 @@ class _MessagesTabState extends State<MessagesTab> {
bottomPadding: composerBottomPadding, bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(), destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context), destinationAvatar: _buildDestinationAvatar(context),
mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery,
onMentionSelected: _selectMention,
onShowComposerActions: _showComposerActions, onShowComposerActions: _showComposerActions,
onShowRecipientSelector: _showRecipientSelector, onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording, onStartVoiceRecording: _startVoiceRecording,

View File

@@ -3,7 +3,9 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../common/contact_avatar.dart';
class MessagesComposer extends StatelessWidget { class MessagesComposer extends StatelessWidget {
final TextEditingController textController; final TextEditingController textController;
@@ -17,6 +19,9 @@ class MessagesComposer extends StatelessWidget {
final double bottomPadding; final double bottomPadding;
final String destinationLabel; final String destinationLabel;
final Widget destinationAvatar; final Widget destinationAvatar;
final List<Contact> mentionSuggestions;
final String mentionQuery;
final ValueChanged<Contact> onMentionSelected;
final VoidCallback onShowComposerActions; final VoidCallback onShowComposerActions;
final VoidCallback onShowRecipientSelector; final VoidCallback onShowRecipientSelector;
final Future<void> Function() onStartVoiceRecording; final Future<void> Function() onStartVoiceRecording;
@@ -36,6 +41,9 @@ class MessagesComposer extends StatelessWidget {
required this.bottomPadding, required this.bottomPadding,
required this.destinationLabel, required this.destinationLabel,
required this.destinationAvatar, required this.destinationAvatar,
required this.mentionSuggestions,
required this.mentionQuery,
required this.onMentionSelected,
required this.onShowComposerActions, required this.onShowComposerActions,
required this.onShowRecipientSelector, required this.onShowRecipientSelector,
required this.onStartVoiceRecording, required this.onStartVoiceRecording,
@@ -50,6 +58,15 @@ class MessagesComposer extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (mentionSuggestions.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 8),
child: _MentionSuggestionsCard(
suggestions: mentionSuggestions,
query: mentionQuery,
onSelected: onMentionSelected,
),
),
SafeArea( SafeArea(
top: false, top: false,
child: Padding( child: Padding(
@@ -153,6 +170,96 @@ class MessagesComposer extends StatelessWidget {
} }
} }
class _MentionSuggestionsCard extends StatelessWidget {
final List<Contact> suggestions;
final String query;
final ValueChanged<Contact> onSelected;
const _MentionSuggestionsCard({
required this.suggestions,
required this.query,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final title = query.isEmpty ? '@' : '@$query';
return Material(
elevation: 10,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
child: Container(
constraints: const BoxConstraints(maxHeight: 360),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.30),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 14, 18, 8),
child: Text(
title,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(10, 0, 10, 10),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final contact = suggestions[index];
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => onSelected(contact),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 10,
),
child: Row(
children: [
ContactAvatar(contact: contact, radius: 19),
const SizedBox(width: 12),
Expanded(
child: Text(
contact.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
},
),
),
],
),
),
);
}
}
class _ComposerActionButton extends StatelessWidget { class _ComposerActionButton extends StatelessWidget {
final bool isRecording; final bool isRecording;
final VoidCallback onPressed; final VoidCallback onPressed;

View File

@@ -795,8 +795,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: "27e65f4" ref: "55e303a"
resolved-ref: "27e65f48b1b5dc3dbcafc0167c4c5755770116ba" resolved-ref: "55e303aa71d9fd847cedd11bc1243504fbe363e0"
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" version: "0.1.0"

View File

@@ -44,7 +44,7 @@ dependencies:
meshcore_client: meshcore_client:
git: git:
url: https://github.com/dz0ny/meshcore_client.git url: https://github.com/dz0ny/meshcore_client.git
ref: "27e65f4" ref: "55e303a"
# Codec2 ultra-low-bitrate speech codec (FFI plugin) # Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter: codec2_flutter:

View File

@@ -115,9 +115,15 @@ void main() {
expect(find.text('1 pkt/min'), findsOneWidget); expect(find.text('1 pkt/min'), findsOneWidget);
expect(find.text('Device total 7'), findsOneWidget); expect(find.text('Device total 7'), findsOneWidget);
expect(find.text('Response'), findsWidgets); expect(find.text('FLOOD RESPONSE'), findsOneWidget);
expect(find.text('MULTI-HOP'), findsOneWidget); expect(find.text('MULTI-HOP'), findsOneWidget);
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget); expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
expect(find.textContaining('Hash:'), findsOneWidget);
expect(
find.textContaining('Path: 3 hops [c010,6301,68d9]'),
findsOneWidget,
);
expect(find.textContaining('Path Hashes: 2-byte per hop'), findsOneWidget);
}); });
testWidgets('clear live view only resets transient screen state', ( testWidgets('clear live view only resets transient screen state', (
@@ -174,6 +180,6 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.text('No packets for this filter'), findsNothing); expect(find.text('No packets for this filter'), findsNothing);
expect(find.textContaining('3 bytes'), findsOneWidget); expect(find.textContaining('Size: 3 bytes'), findsOneWidget);
}); });
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.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/providers/app_provider.dart'; import 'package:meshcore_sar_app/providers/app_provider.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart'; import 'package:meshcore_sar_app/providers/channels_provider.dart';
@@ -16,10 +17,34 @@ import 'package:meshcore_sar_app/services/voice_codec_service.dart';
import 'package:meshcore_sar_app/services/voice_player_service.dart'; import 'package:meshcore_sar_app/services/voice_player_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'dart:typed_data';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
Contact buildContact({
required String name,
required ContactType type,
int secondByte = 1,
}) {
final publicKey = Uint8List(32);
publicKey[0] = secondByte;
publicKey[1] = secondByte;
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
}); });
@@ -104,4 +129,83 @@ void main() {
connectionProvider.dispose(); connectionProvider.dispose();
channelsProvider.dispose(); channelsProvider.dispose();
}); });
testWidgets('shows mention overlay above composer and inserts selection', (
tester,
) async {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final mapProvider = MapProvider();
final drawingProvider = DrawingProvider();
await messagesProvider.initialize();
await drawingProvider.initialize();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
final appProvider = AppProvider(
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
drawingProvider: drawingProvider,
channelsProvider: channelsProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
);
contactsProvider.addContacts([
buildContact(name: 'Tim', type: ContactType.chat, secondByte: 2),
buildContact(name: 'Slane', type: ContactType.chat, secondByte: 3),
]);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: connectionProvider),
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider.value(value: messagesProvider),
ChangeNotifierProvider.value(value: mapProvider),
ChangeNotifierProvider.value(value: drawingProvider),
ChangeNotifierProvider.value(value: channelsProvider),
ChangeNotifierProvider.value(value: voiceProvider),
ChangeNotifierProvider.value(value: imageProvider),
ChangeNotifierProvider.value(value: appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: MessagesTab(isActive: true)),
),
),
);
await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 60));
await tester.tap(find.byType(TextField).first);
await tester.enterText(find.byType(TextField).first, '@t');
await tester.pumpAndSettle();
expect(find.text('Tim'), findsOneWidget);
expect(find.text('Slane'), findsNothing);
await tester.tap(find.text('Tim'));
await tester.pumpAndSettle();
expect(find.text('@[Tim] '), findsOneWidget);
expect(find.text('Tim'), findsNothing);
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
mapProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
});
} }