Compare commits

...

5 Commits

Author SHA1 Message Date
zach cd9f14dd09 update version 2026-01-02 14:50:11 -07:00
zach ad911a1d80 Add advanced path management, debug logging, and fix channel sync
New features:
- In-app debug log viewer with copy/clear functionality
- Advanced path management UI with history and custom path builder
- Battery indicator widget with voltage/percentage toggle
- Contact/channel filtering and sorting improvements
- Repeater command ACK tracking with path history integration

Fixes:
- Switch channel sync from parallel to sequential to prevent timeouts
- Preserve path overrides when contacts refresh from device
- Fix ACK hash computation for SMAZ-encoded messages
- Proper cleanup of pending operations on disconnect
2026-01-02 14:22:39 -07:00
zach 361dfb7808 update readme 2025-12-31 23:19:12 -07:00
zach ad187962c9 add imgs 2025-12-31 23:17:34 -07:00
zjs81 b7eec5627f Remove duplicate acknowledgment 2025-12-31 22:48:33 -07:00
40 changed files with 2950 additions and 858 deletions
+12 -1
View File
@@ -6,6 +6,18 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices.
MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities. MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
## Screenshots
<table>
<tr>
<td><img src="docs/screenshots/contacts.jpg" width="200"/><br/><p align="center"><b>Contacts</b></p></td>
<td><img src="docs/screenshots/chat1.jpg" width="200"/><br/><p align="center"><b>Chat</b></p></td>
<td><img src="docs/screenshots/chat2.jpg" width="200"/><br/><p align="center"><b>Reactions</b></p></td>
<td><img src="docs/screenshots/map.jpg" width="200"/><br/><p align="center"><b>Map</b></p></td>
<td><img src="docs/screenshots/channels.jpg" width="200"/><br/><p align="center"><b>Channels</b></p></td>
</tr>
</table>
## Features ## Features
### Core Functionality ### Core Functionality
@@ -199,4 +211,3 @@ Your support helps maintain and improve this open-source project!
- Built with [Flutter](https://flutter.dev/) - Built with [Flutter](https://flutter.dev/)
- Map tiles from [OpenStreetMap](https://www.openstreetmap.org/) - Map tiles from [OpenStreetMap](https://www.openstreetmap.org/)
- Voice codec support via [Codec2](https://github.com/drowe67/codec2)
Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

-2
View File
@@ -26,8 +26,6 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe
flutter_ios_podfile_setup flutter_ios_podfile_setup
target 'Runner' do target 'Runner' do
pod 'codec2', :path => '../third_party/codec2'
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end end
+364 -44
View File
@@ -14,6 +14,7 @@ import '../models/message.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../helpers/reaction_helper.dart'; import '../helpers/reaction_helper.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import '../services/app_debug_log_service.dart';
import '../services/ble_debug_log_service.dart'; import '../services/ble_debug_log_service.dart';
import '../services/message_retry_service.dart'; import '../services/message_retry_service.dart';
import '../services/path_history_service.dart'; import '../services/path_history_service.dart';
@@ -27,6 +28,7 @@ import '../storage/contact_settings_store.dart';
import '../storage/contact_store.dart'; import '../storage/contact_store.dart';
import '../storage/message_store.dart'; import '../storage/message_store.dart';
import '../storage/unread_store.dart'; import '../storage/unread_store.dart';
import '../utils/app_logger.dart';
import 'meshcore_protocol.dart'; import 'meshcore_protocol.dart';
class MeshCoreUuids { class MeshCoreUuids {
@@ -106,6 +108,17 @@ class MeshCoreConnector extends ChangeNotifier {
static const int _maxQueueSyncRetries = 3; static const int _maxQueueSyncRetries = 3;
static const int _queueSyncTimeoutMs = 5000; // 5 second timeout static const int _queueSyncTimeoutMs = 5000; // 5 second timeout
// Channel syncing state (sequential pattern)
bool _isSyncingChannels = false;
bool _channelSyncInFlight = false;
Timer? _channelSyncTimeout;
int _channelSyncRetries = 0;
int _nextChannelIndexToRequest = 0;
int _totalChannelsToRequest = 0;
List<Channel> _previousChannelsCache = [];
static const int _maxChannelSyncRetries = 3;
static const int _channelSyncTimeoutMs = 2000; // 2 second timeout per channel
// Services // Services
MessageRetryService? _retryService; MessageRetryService? _retryService;
PathHistoryService? _pathHistoryService; PathHistoryService? _pathHistoryService;
@@ -113,6 +126,7 @@ class MeshCoreConnector extends ChangeNotifier {
BackgroundService? _backgroundService; BackgroundService? _backgroundService;
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
BleDebugLogService? _bleDebugLogService; BleDebugLogService? _bleDebugLogService;
AppDebugLogService? _appDebugLogService;
final ChannelMessageStore _channelMessageStore = ChannelMessageStore(); final ChannelMessageStore _channelMessageStore = ChannelMessageStore();
final MessageStore _messageStore = MessageStore(); final MessageStore _messageStore = MessageStore();
final ChannelOrderStore _channelOrderStore = ChannelOrderStore(); final ChannelOrderStore _channelOrderStore = ChannelOrderStore();
@@ -126,6 +140,7 @@ class MeshCoreConnector extends ChangeNotifier {
final Set<String> _knownContactKeys = {}; final Set<String> _knownContactKeys = {};
final Map<String, int> _contactLastReadMs = {}; final Map<String, int> _contactLastReadMs = {};
final Map<int, int> _channelLastReadMs = {}; final Map<int, int> _channelLastReadMs = {};
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
String? _activeContactKey; String? _activeContactKey;
int? _activeChannelIndex; int? _activeChannelIndex;
List<int> _channelOrder = []; List<int> _channelOrder = [];
@@ -178,6 +193,10 @@ class MeshCoreConnector extends ChangeNotifier {
int get maxContacts => _maxContacts; int get maxContacts => _maxContacts;
int get maxChannels => _maxChannels; int get maxChannels => _maxChannels;
bool get isSyncingQueuedMessages => _isSyncingQueuedMessages; bool get isSyncingQueuedMessages => _isSyncingQueuedMessages;
bool get isSyncingChannels => _isSyncingChannels;
int get channelSyncProgress => _isSyncingChannels && _totalChannelsToRequest > 0
? ((_nextChannelIndexToRequest / _totalChannelsToRequest) * 100).round()
: 0;
int? get batteryPercent => _batteryMillivolts == null int? get batteryPercent => _batteryMillivolts == null
? null ? null
: _estimateBatteryPercent( : _estimateBatteryPercent(
@@ -459,12 +478,14 @@ class MeshCoreConnector extends ChangeNotifier {
required PathHistoryService pathHistoryService, required PathHistoryService pathHistoryService,
AppSettingsService? appSettingsService, AppSettingsService? appSettingsService,
BleDebugLogService? bleDebugLogService, BleDebugLogService? bleDebugLogService,
AppDebugLogService? appDebugLogService,
BackgroundService? backgroundService, BackgroundService? backgroundService,
}) { }) {
_retryService = retryService; _retryService = retryService;
_pathHistoryService = pathHistoryService; _pathHistoryService = pathHistoryService;
_appSettingsService = appSettingsService; _appSettingsService = appSettingsService;
_bleDebugLogService = bleDebugLogService; _bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService; _backgroundService = backgroundService;
// Initialize notification service // Initialize notification service
@@ -480,7 +501,10 @@ class MeshCoreConnector extends ChangeNotifier {
setContactPathCallback: setContactPath, setContactPathCallback: setContactPath,
calculateTimeoutCallback: (pathLength, messageBytes) => calculateTimeoutCallback: (pathLength, messageBytes) =>
calculateTimeout(pathLength: pathLength, messageBytes: messageBytes), calculateTimeout(pathLength: pathLength, messageBytes: messageBytes),
getSelfPublicKeyCallback: () => _selfPublicKey,
prepareContactOutboundTextCallback: prepareContactOutboundText,
appSettingsService: appSettingsService, appSettingsService: appSettingsService,
debugLogService: _appDebugLogService,
recordPathResultCallback: _recordPathResult, recordPathResultCallback: _recordPathResult,
); );
} }
@@ -510,7 +534,7 @@ class MeshCoreConnector extends ChangeNotifier {
int timestampSeconds, int timestampSeconds,
) async { ) async {
if (!isConnected || text.isEmpty) return; if (!isConnected || text.isEmpty) return;
final outboundText = _prepareContactOutboundText(contact, text); final outboundText = prepareContactOutboundText(contact, text);
await sendFrame( await sendFrame(
buildSendTextMsgFrame( buildSendTextMsgFrame(
contact.publicKey, contact.publicKey,
@@ -811,6 +835,9 @@ class MeshCoreConnector extends ChangeNotifier {
_queueSyncTimeout?.cancel(); _queueSyncTimeout?.cancel();
_queueSyncTimeout = null; _queueSyncTimeout = null;
_queueSyncRetries = 0; _queueSyncRetries = 0;
_channelSyncTimeout?.cancel();
_channelSyncTimeout = null;
_channelSyncRetries = 0;
try { try {
// Skip queued BLE operations so disconnect doesn't get stuck behind them. // Skip queued BLE operations so disconnect doesn't get stuck behind them.
@@ -840,8 +867,8 @@ class MeshCoreConnector extends ChangeNotifier {
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_didInitialQueueSync = false; _didInitialQueueSync = false;
_pendingQueueSync = false; _pendingQueueSync = false;
_pendingQueueSync = false; _isSyncingChannels = false;
_didInitialQueueSync = false; _channelSyncInFlight = false;
_setState(MeshCoreConnectionState.disconnected); _setState(MeshCoreConnectionState.disconnected);
if (!manual) { if (!manual) {
@@ -988,7 +1015,7 @@ class MeshCoreConnector extends ChangeNotifier {
); );
_addMessage(contact.publicKeyHex, message); _addMessage(contact.publicKeyHex, message);
notifyListeners(); notifyListeners();
final outboundText = _prepareContactOutboundText(contact, text); final outboundText = prepareContactOutboundText(contact, text);
await sendFrame( await sendFrame(
buildSendTextMsgFrame( buildSendTextMsgFrame(
contact.publicKey, contact.publicKey,
@@ -1021,9 +1048,16 @@ class MeshCoreConnector extends ChangeNotifier {
int? pathLen, int? pathLen,
Uint8List? pathBytes, Uint8List? pathBytes,
}) async { }) async {
appLogger.info('setPathOverride called for ${contact.name}: pathLen=$pathLen, bytesLen=${pathBytes?.length ?? 0}', tag: 'Connector');
// Find contact in list // Find contact in list
final index = _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex); final index = _contacts.indexWhere((c) => c.publicKeyHex == contact.publicKeyHex);
if (index == -1) return; if (index == -1) {
appLogger.warn('setPathOverride: Contact not found in list: ${contact.name}', tag: 'Connector');
return;
}
appLogger.info('Found contact at index $index. Current override: ${_contacts[index].pathOverride}', tag: 'Connector');
// Update contact with new path override // Update contact with new path override
_contacts[index] = _contacts[index].copyWith( _contacts[index] = _contacts[index].copyWith(
@@ -1032,18 +1066,126 @@ class MeshCoreConnector extends ChangeNotifier {
clearPathOverride: pathLen == null, // Clear if pathLen is null clearPathOverride: pathLen == null, // Clear if pathLen is null
); );
appLogger.info('Updated contact. New override: ${_contacts[index].pathOverride}, bytesLen: ${_contacts[index].pathOverrideBytes?.length}', tag: 'Connector');
// Save to storage // Save to storage
await _contactStore.saveContacts(_contacts); await _contactStore.saveContacts(_contacts);
appLogger.info('Saved contacts to storage', tag: 'Connector');
// If setting a specific path (not flood, not auto), also sync with device // If setting a specific path (not flood, not auto), also sync with device
if (pathLen != null && pathLen >= 0 && pathBytes != null) { if (pathLen != null && pathLen >= 0 && pathBytes != null) {
appLogger.info('Sending path to device...', tag: 'Connector');
await setContactPath(contact, pathBytes, pathLen); await setContactPath(contact, pathBytes, pathLen);
appLogger.info('Path sent to device', tag: 'Connector');
} }
debugPrint('Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}'); debugPrint('Set path override for ${contact.name}: pathLen=$pathLen, bytes=${pathBytes?.length ?? 0}');
notifyListeners(); notifyListeners();
} }
Future<PathSelection> preparePathForContactSend(Contact contact) async {
PathSelection? autoSelection;
final autoRotationEnabled =
_appSettingsService?.settings.autoRouteRotationEnabled == true;
if (autoRotationEnabled && contact.pathOverride == null) {
autoSelection = _pathHistoryService?.getNextAutoPathSelection(
contact.publicKeyHex,
);
if (autoSelection != null) {
_pathHistoryService?.recordPathAttempt(
contact.publicKeyHex,
autoSelection,
);
}
}
final pathBytes = _resolveOutgoingPathBytes(contact, autoSelection);
final pathLength = _resolveOutgoingPathLength(contact, autoSelection) ?? -1;
if (pathLength < 0) {
await clearContactPath(contact);
} else {
await setContactPath(contact, pathBytes, pathLength);
}
return _selectionFromPath(pathLength, pathBytes);
}
void trackRepeaterAck({
required Contact contact,
required PathSelection selection,
required String text,
required int timestampSeconds,
int attempt = 0,
}) {
final selfKey = _selfPublicKey;
if (selfKey == null) return;
// Use transformed text to match device's ACK hash computation
final outboundText = prepareContactOutboundText(contact, text);
final ackHash = MessageRetryService.computeExpectedAckHash(
timestampSeconds,
attempt,
outboundText,
selfKey,
);
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final messageBytes = utf8.encode(outboundText).length;
_pendingRepeaterAcks[ackHashHex]?.timeout?.cancel();
_pendingRepeaterAcks[ackHashHex] = _RepeaterAckContext(
contactKeyHex: contact.publicKeyHex,
selection: selection,
pathLength: selection.useFlood ? -1 : selection.hopCount,
messageBytes: messageBytes,
);
}
void recordRepeaterPathResult(
Contact contact,
PathSelection selection,
bool success,
int? tripTimeMs,
) {
_recordPathResult(contact.publicKeyHex, selection, success, tripTimeMs);
}
Future<bool> verifyContactPathOnDevice(
Contact contact,
Uint8List expectedPath, {
Duration timeout = const Duration(seconds: 3),
}) async {
if (!isConnected) return false;
final expectedLength = expectedPath.length;
final completer = Completer<bool>();
void finish(bool result) {
if (!completer.isCompleted) {
completer.complete(result);
}
}
final subscription = receivedFrames.listen((frame) {
if (frame.isEmpty || frame[0] != respCodeContact) return;
final updated = Contact.fromFrame(frame);
if (updated == null) return;
if (updated.publicKeyHex != contact.publicKeyHex) return;
final matchesLength = updated.pathLength == expectedLength;
final matchesBytes = _pathsEqual(updated.path, expectedPath);
if (matchesLength && matchesBytes) {
finish(true);
}
});
final timer = Timer(timeout, () => finish(false));
try {
await getContactByKey(contact.publicKey);
return await completer.future;
} finally {
await subscription.cancel();
timer.cancel();
}
}
Future<void> sendChannelMessage(Channel channel, String text) async{ Future<void> sendChannelMessage(Channel channel, String text) async{
if (!isConnected || text.isEmpty) return; if (!isConnected || text.isEmpty) return;
@@ -1246,55 +1388,125 @@ class MeshCoreConnector extends ChangeNotifier {
await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}'); await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}');
} }
final Set<int> _expectedChannelIndices = {};
Future<void> getChannels({int? maxChannels}) async { Future<void> getChannels({int? maxChannels}) async {
if (!isConnected) return; if (!isConnected) return;
if (_isSyncingChannels) {
debugPrint('[ChannelSync] Already syncing channels, ignoring request');
return;
}
_isLoadingChannels = true; _isLoadingChannels = true;
final previousChannels = List<Channel>.from(_channels); _isSyncingChannels = true;
_previousChannelsCache = List<Channel>.from(_channels);
_channels.clear(); _channels.clear();
_expectedChannelIndices.clear(); _nextChannelIndexToRequest = 0;
_totalChannelsToRequest = maxChannels ?? _maxChannels;
_channelSyncRetries = 0;
notifyListeners(); notifyListeners();
// Request each channel index (send all requests in parallel) debugPrint('[ChannelSync] Starting sync for $_totalChannelsToRequest channels');
final channelCount = maxChannels ?? _maxChannels;
for (int i = 0; i < channelCount; i++) { // Start sequential sync
_expectedChannelIndices.add(i); await _requestNextChannel();
sendFrame(buildGetChannelFrame(i)); // No await - send all at once }
Future<void> _requestNextChannel() async {
if (!isConnected) {
_cleanupChannelSync(completed: false);
return;
} }
// Wait for responses with timeout if (_channelSyncInFlight) return;
final stopwatch = Stopwatch()..start();
const maxWaitTime = Duration(seconds: 5);
const checkInterval = Duration(milliseconds: 100);
while (_expectedChannelIndices.isNotEmpty && stopwatch.elapsed < maxWaitTime) { // Check if we've requested all channels
await Future.delayed(checkInterval); if (_nextChannelIndexToRequest >= _totalChannelsToRequest) {
_completeChannelSync();
return;
} }
stopwatch.stop(); _channelSyncInFlight = true;
final channelIndex = _nextChannelIndexToRequest;
// If timeout expired and we're still missing channels, restore them from previous load // Cancel any existing timeout
if (_expectedChannelIndices.isNotEmpty) { _channelSyncTimeout?.cancel();
debugPrint('Channel loading timeout - missing ${_expectedChannelIndices.length} channels, restoring from cache');
for (final prevChannel in previousChannels) { // Set up timeout for this channel request
if (_expectedChannelIndices.contains(prevChannel.index) && _channelSyncTimeout = Timer(
!_channels.any((c) => c.index == prevChannel.index)) { Duration(milliseconds: _channelSyncTimeoutMs),
_channels.add(prevChannel); () => _handleChannelSyncTimeout(channelIndex),
debugPrint('Restored channel ${prevChannel.index} (${prevChannel.name}) from cache'); );
debugPrint('[ChannelSync] Requesting channel $channelIndex/$_totalChannelsToRequest (retry: $_channelSyncRetries/$_maxChannelSyncRetries)');
try {
await sendFrame(buildGetChannelFrame(channelIndex));
} catch (e) {
debugPrint('[ChannelSync] Error sending channel request: $e');
_channelSyncInFlight = false;
_cleanupChannelSync(completed: false);
}
}
void _handleChannelSyncTimeout(int channelIndex) {
debugPrint('[ChannelSync] Timeout waiting for channel $channelIndex (retry: $_channelSyncRetries/$_maxChannelSyncRetries)');
if (_channelSyncRetries < _maxChannelSyncRetries) {
// Retry the same channel
_channelSyncRetries++;
_channelSyncInFlight = false;
unawaited(_requestNextChannel());
} else {
// Max retries reached for this channel, restore from cache and move to next
debugPrint('[ChannelSync] Max retries reached for channel $channelIndex, attempting cache restore');
// Try to restore this channel from cache
try {
final cachedChannel = _previousChannelsCache.firstWhere(
(c) => c.index == channelIndex
);
if (!cachedChannel.isEmpty) {
_channels.add(cachedChannel);
debugPrint('[ChannelSync] Restored channel $channelIndex (${cachedChannel.name}) from cache');
} }
} catch (e) {
// No cached channel found, that's okay
} }
// Move to next channel
_nextChannelIndexToRequest++;
_channelSyncRetries = 0;
_channelSyncInFlight = false;
unawaited(_requestNextChannel());
} }
}
debugPrint('Channel loading completed: received ${_channels.length}/$channelCount channels in ${stopwatch.elapsedMilliseconds}ms'); void _completeChannelSync() {
_channelSyncTimeout?.cancel();
_isLoadingChannels = false; debugPrint('[ChannelSync] Sync complete: received ${_channels.length}/$_totalChannelsToRequest channels');
_expectedChannelIndices.clear();
_cleanupChannelSync(completed: true);
// Apply ordering and notify UI
_applyChannelOrder(); _applyChannelOrder();
notifyListeners(); notifyListeners();
} }
void _cleanupChannelSync({required bool completed}) {
_isSyncingChannels = false;
_channelSyncInFlight = false;
_isLoadingChannels = false;
_channelSyncTimeout?.cancel();
_channelSyncRetries = 0;
_nextChannelIndexToRequest = 0;
_totalChannelsToRequest = 0;
if (completed) {
_previousChannelsCache.clear();
}
// Keep cache on failure/disconnection for future attempts
}
Future<void> setChannel(int index, String name, Uint8List psk) async { Future<void> setChannel(int index, String name, Uint8List psk) async {
if (!isConnected) return; if (!isConnected) return;
@@ -1588,14 +1800,20 @@ class MeshCoreConnector extends ChangeNotifier {
final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt) final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt)
? existing.lastMessageAt ? existing.lastMessageAt
: contact.lastMessageAt; : contact.lastMessageAt;
appLogger.info('Refreshing contact ${contact.name}: devicePath=${contact.pathLength}, existingOverride=${existing.pathOverride}', tag: 'Connector');
// CRITICAL: Preserve user's path override when contact is refreshed from device // CRITICAL: Preserve user's path override when contact is refreshed from device
_contacts[existingIndex] = contact.copyWith( _contacts[existingIndex] = contact.copyWith(
lastMessageAt: mergedLastMessageAt, lastMessageAt: mergedLastMessageAt,
pathOverride: existing.pathOverride, // Preserve user's path choice pathOverride: existing.pathOverride, // Preserve user's path choice
pathOverrideBytes: existing.pathOverrideBytes, pathOverrideBytes: existing.pathOverrideBytes,
); );
appLogger.info('After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', tag: 'Connector');
} else { } else {
_contacts.add(contact); _contacts.add(contact);
appLogger.info('Added new contact ${contact.name}: pathLen=${contact.pathLength}', tag: 'Connector');
} }
_knownContactKeys.add(contact.publicKeyHex); _knownContactKeys.add(contact.publicKeyHex);
_loadMessagesForContact(contact.publicKeyHex); _loadMessagesForContact(contact.publicKeyHex);
@@ -1882,7 +2100,9 @@ class MeshCoreConnector extends ChangeNotifier {
}); });
} }
String _prepareContactOutboundText(Contact contact, String text) { /// Prepares contact outbound text by applying SMAZ encoding if enabled.
/// This should be used to transform text before computing ACK hashes.
String prepareContactOutboundText(Contact contact, String text) {
final trimmed = text.trim(); final trimmed = text.trim();
final isStructuredPayload = final isStructuredPayload =
trimmed.startsWith('g:') || trimmed.startsWith('m:') || trimmed.startsWith('V1|'); trimmed.startsWith('g:') || trimmed.startsWith('m:') || trimmed.startsWith('V1|');
@@ -2029,6 +2249,10 @@ class MeshCoreConnector extends ChangeNotifier {
return; return;
} }
if (_handleRepeaterCommandSent(ackHash, timeoutMs)) {
return;
}
if (_retryService != null) { if (_retryService != null) {
_retryService!.updateMessageFromSent(ackHash, timeoutMs); _retryService!.updateMessageFromSent(ackHash, timeoutMs);
} }
@@ -2058,6 +2282,10 @@ class MeshCoreConnector extends ChangeNotifier {
// CLI command ACKs are already filtered in _handleMessageSent, so this should only see real messages // CLI command ACKs are already filtered in _handleMessageSent, so this should only see real messages
if (_handleRepeaterCommandAck(ackHash, tripTimeMs)) {
return;
}
// Handle ACK in retry service // Handle ACK in retry service
if (_retryService != null) { if (_retryService != null) {
_retryService!.handleAckReceived(ackHash, tripTimeMs); _retryService!.handleAckReceived(ackHash, tripTimeMs);
@@ -2076,23 +2304,84 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
bool _handleRepeaterCommandSent(Uint8List ackHash, int timeoutMs) {
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final entry = _pendingRepeaterAcks[ackHashHex];
if (entry == null) return false;
entry.timeout?.cancel();
final effectiveTimeoutMs = timeoutMs > 0
? timeoutMs
: calculateTimeout(
pathLength: entry.pathLength,
messageBytes: entry.messageBytes,
);
entry.timeout = Timer(Duration(milliseconds: effectiveTimeoutMs), () {
_recordPathResult(entry.contactKeyHex, entry.selection, false, null);
_pendingRepeaterAcks.remove(ackHashHex);
});
return true;
}
bool _handleRepeaterCommandAck(Uint8List ackHash, int tripTimeMs) {
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final entry = _pendingRepeaterAcks.remove(ackHashHex);
if (entry == null) return false;
entry.timeout?.cancel();
_recordPathResult(entry.contactKeyHex, entry.selection, true, tripTimeMs);
return true;
}
void _handleChannelInfo(Uint8List frame) { void _handleChannelInfo(Uint8List frame) {
final channel = Channel.fromFrame(frame); final channel = Channel.fromFrame(frame);
if (channel != null) { if (channel == null) return;
// Mark this channel index as received
_expectedChannelIndices.remove(channel.index);
// Only add non-empty channels to the list debugPrint('[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}');
if (!channel.isEmpty) {
// If we're syncing and this is the channel we're waiting for
if (_isSyncingChannels && _channelSyncInFlight) {
if (channel.index == _nextChannelIndexToRequest) {
// Expected channel arrived
_channelSyncTimeout?.cancel();
_channelSyncInFlight = false;
_channelSyncRetries = 0; // Reset retry counter on success
// Only add non-empty channels
if (!channel.isEmpty) {
_channels.add(channel);
}
// Move to next channel
_nextChannelIndexToRequest++;
unawaited(_requestNextChannel());
return;
} else {
// Received a channel but not the one we're waiting for
// This can happen if device sends unsolicited updates
debugPrint('[ChannelSync] Received unexpected channel ${channel.index}, expected $_nextChannelIndexToRequest');
// Add it anyway but don't advance sync
if (!channel.isEmpty && !_channels.any((c) => c.index == channel.index)) {
_channels.add(channel);
}
return;
}
}
// Not syncing, or received unsolicited update - handle normally
if (!channel.isEmpty) {
// Update or add channel
final existingIndex = _channels.indexWhere((c) => c.index == channel.index);
if (existingIndex >= 0) {
_channels[existingIndex] = channel;
} else {
_channels.add(channel); _channels.add(channel);
} }
}
// Only sort and notify if we're not currently loading channels // Only notify if not in loading state
// This prevents the list from jumping around as channels arrive during refresh if (!_isLoadingChannels) {
if (!_isLoadingChannels) { _applyChannelOrder();
_applyChannelOrder(); notifyListeners();
notifyListeners();
}
} }
} }
@@ -2370,6 +2659,17 @@ class MeshCoreConnector extends ChangeNotifier {
return contact.pathLength; return contact.pathLength;
} }
PathSelection _selectionFromPath(int pathLength, Uint8List pathBytes) {
if (pathLength < 0) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
return PathSelection(
pathBytes: pathBytes,
hopCount: pathLength,
useFlood: false,
);
}
bool _addChannelMessage(int channelIndex, ChannelMessage message) { bool _addChannelMessage(int channelIndex, ChannelMessage message) {
_channelMessages.putIfAbsent(channelIndex, () => []); _channelMessages.putIfAbsent(channelIndex, () => []);
final messages = _channelMessages[channelIndex]!; final messages = _channelMessages[channelIndex]!;
@@ -2599,6 +2899,11 @@ class MeshCoreConnector extends ChangeNotifier {
// Disable wake lock when connection is lost // Disable wake lock when connection is lost
WakelockPlus.disable(); WakelockPlus.disable();
for (final entry in _pendingRepeaterAcks.values) {
entry.timeout?.cancel();
}
_pendingRepeaterAcks.clear();
_notifySubscription?.cancel(); _notifySubscription?.cancel();
_notifySubscription = null; _notifySubscription = null;
_connectionSubscription?.cancel(); _connectionSubscription?.cancel();
@@ -2613,6 +2918,8 @@ class MeshCoreConnector extends ChangeNotifier {
_maxChannels = _defaultMaxChannels; _maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false; _isSyncingQueuedMessages = false;
_queuedMessageSyncInFlight = false; _queuedMessageSyncInFlight = false;
_isSyncingChannels = false;
_channelSyncInFlight = false;
_setState(MeshCoreConnectionState.disconnected); _setState(MeshCoreConnectionState.disconnected);
_scheduleReconnect(); _scheduleReconnect();
@@ -2684,4 +2991,17 @@ class _ParsedText {
}); });
} }
class _RepeaterAckContext {
final String contactKeyHex;
final PathSelection selection;
final int pathLength;
final int messageBytes;
Timer? timeout;
_RepeaterAckContext({
required this.contactKeyHex,
required this.selection,
required this.pathLength,
required this.messageBytes,
});
}
+4 -2
View File
@@ -592,16 +592,18 @@ Uint8List buildSendCliCommandFrame(
Uint8List repeaterPubKey, Uint8List repeaterPubKey,
String command, { String command, {
int attempt = 0, int attempt = 0,
int? timestampSeconds,
}) { }) {
final textBytes = utf8.encode(command); final textBytes = utf8.encode(command);
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
const prefixSize = 6; const prefixSize = 6;
final safeAttempt = attempt.clamp(0, 3);
final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1); final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
int offset = 0; int offset = 0;
frame[offset++] = cmdSendTxtMsg; frame[offset++] = cmdSendTxtMsg;
frame[offset++] = txtTypeCliData; frame[offset++] = txtTypeCliData;
frame[offset++] = attempt & 0xFF; frame[offset++] = safeAttempt;
writeUint32LE(frame, offset, timestamp); writeUint32LE(frame, offset, timestamp);
offset += 4; offset += 4;
+14
View File
@@ -9,9 +9,11 @@ import 'services/path_history_service.dart';
import 'services/app_settings_service.dart'; import 'services/app_settings_service.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'services/ble_debug_log_service.dart'; import 'services/ble_debug_log_service.dart';
import 'services/app_debug_log_service.dart';
import 'services/background_service.dart'; import 'services/background_service.dart';
import 'services/map_tile_cache_service.dart'; import 'services/map_tile_cache_service.dart';
import 'storage/prefs_manager.dart'; import 'storage/prefs_manager.dart';
import 'utils/app_logger.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -26,12 +28,19 @@ void main() async {
final retryService = MessageRetryService(storage); final retryService = MessageRetryService(storage);
final appSettingsService = AppSettingsService(); final appSettingsService = AppSettingsService();
final bleDebugLogService = BleDebugLogService(); final bleDebugLogService = BleDebugLogService();
final appDebugLogService = AppDebugLogService();
final backgroundService = BackgroundService(); final backgroundService = BackgroundService();
final mapTileCacheService = MapTileCacheService(); final mapTileCacheService = MapTileCacheService();
// Load settings // Load settings
await appSettingsService.loadSettings(); await appSettingsService.loadSettings();
// Initialize app logger
appLogger.initialize(
appDebugLogService,
enabled: appSettingsService.settings.appDebugLogEnabled,
);
// Initialize notification service // Initialize notification service
final notificationService = NotificationService(); final notificationService = NotificationService();
await notificationService.initialize(); await notificationService.initialize();
@@ -43,6 +52,7 @@ void main() async {
pathHistoryService: pathHistoryService, pathHistoryService: pathHistoryService,
appSettingsService: appSettingsService, appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService, bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
backgroundService: backgroundService, backgroundService: backgroundService,
); );
@@ -60,6 +70,7 @@ void main() async {
storage: storage, storage: storage,
appSettingsService: appSettingsService, appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService, bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService, mapTileCacheService: mapTileCacheService,
)); ));
} }
@@ -71,6 +82,7 @@ class MeshCoreApp extends StatelessWidget {
final StorageService storage; final StorageService storage;
final AppSettingsService appSettingsService; final AppSettingsService appSettingsService;
final BleDebugLogService bleDebugLogService; final BleDebugLogService bleDebugLogService;
final AppDebugLogService appDebugLogService;
final MapTileCacheService mapTileCacheService; final MapTileCacheService mapTileCacheService;
const MeshCoreApp({ const MeshCoreApp({
@@ -81,6 +93,7 @@ class MeshCoreApp extends StatelessWidget {
required this.storage, required this.storage,
required this.appSettingsService, required this.appSettingsService,
required this.bleDebugLogService, required this.bleDebugLogService,
required this.appDebugLogService,
required this.mapTileCacheService, required this.mapTileCacheService,
}); });
@@ -93,6 +106,7 @@ class MeshCoreApp extends StatelessWidget {
ChangeNotifierProvider.value(value: pathHistoryService), ChangeNotifierProvider.value(value: pathHistoryService),
ChangeNotifierProvider.value(value: appSettingsService), ChangeNotifierProvider.value(value: appSettingsService),
ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: bleDebugLogService),
ChangeNotifierProvider.value(value: appDebugLogService),
Provider.value(value: storage), Provider.value(value: storage),
Provider.value(value: mapTileCacheService), Provider.value(value: mapTileCacheService),
], ],
+6
View File
@@ -18,6 +18,7 @@ class AppSettings {
final bool notifyOnNewAdvert; final bool notifyOnNewAdvert;
final bool autoRouteRotationEnabled; final bool autoRouteRotationEnabled;
final String themeMode; final String themeMode;
final bool appDebugLogEnabled;
final Map<String, String> batteryChemistryByDeviceId; final Map<String, String> batteryChemistryByDeviceId;
AppSettings({ AppSettings({
@@ -38,6 +39,7 @@ class AppSettings {
this.notifyOnNewAdvert = true, this.notifyOnNewAdvert = true,
this.autoRouteRotationEnabled = false, this.autoRouteRotationEnabled = false,
this.themeMode = 'system', this.themeMode = 'system',
this.appDebugLogEnabled = false,
Map<String, String>? batteryChemistryByDeviceId, Map<String, String>? batteryChemistryByDeviceId,
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}; }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
@@ -60,6 +62,7 @@ class AppSettings {
'notify_on_new_advert': notifyOnNewAdvert, 'notify_on_new_advert': notifyOnNewAdvert,
'auto_route_rotation_enabled': autoRouteRotationEnabled, 'auto_route_rotation_enabled': autoRouteRotationEnabled,
'theme_mode': themeMode, 'theme_mode': themeMode,
'app_debug_log_enabled': appDebugLogEnabled,
'battery_chemistry_by_device_id': batteryChemistryByDeviceId, 'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
}; };
} }
@@ -86,6 +89,7 @@ class AppSettings {
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true, notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false, autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
themeMode: json['theme_mode'] as String? ?? 'system', themeMode: json['theme_mode'] as String? ?? 'system',
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map( batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()), (key, value) => MapEntry(key.toString(), value.toString()),
) ?? ) ??
@@ -111,6 +115,7 @@ class AppSettings {
bool? notifyOnNewAdvert, bool? notifyOnNewAdvert,
bool? autoRouteRotationEnabled, bool? autoRouteRotationEnabled,
String? themeMode, String? themeMode,
bool? appDebugLogEnabled,
Map<String, String>? batteryChemistryByDeviceId, Map<String, String>? batteryChemistryByDeviceId,
}) { }) {
return AppSettings( return AppSettings(
@@ -133,6 +138,7 @@ class AppSettings {
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert, notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled, autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
themeMode: themeMode ?? this.themeMode, themeMode: themeMode ?? this.themeMode,
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId, batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
); );
} }
+18 -4
View File
@@ -46,6 +46,11 @@ class Contact {
} }
String get pathLabel { String get pathLabel {
if (pathOverride != null) {
if (pathOverride! < 0) return 'Flood (forced)';
if (pathOverride == 0) return 'Direct (forced)';
return '$pathOverride hops (forced)';
}
if (pathLength < 0) return 'Flood'; if (pathLength < 0) return 'Flood';
if (pathLength == 0) return 'Direct'; if (pathLength == 0) return 'Direct';
return '$pathLength hops'; return '$pathLength hops';
@@ -83,12 +88,13 @@ class Contact {
} }
String get pathIdList { String get pathIdList {
if (path.isEmpty) return ''; final pathBytes = _pathBytesForDisplay;
if (pathBytes.isEmpty) return '';
final parts = <String>[]; final parts = <String>[];
final groupSize = pathHashSize; final groupSize = pathHashSize;
for (int i = 0; i < path.length; i += groupSize) { for (int i = 0; i < pathBytes.length; i += groupSize) {
final end = (i + groupSize) <= path.length ? (i + groupSize) : path.length; final end = (i + groupSize) <= pathBytes.length ? (i + groupSize) : pathBytes.length;
final chunk = path.sublist(i, end); final chunk = pathBytes.sublist(i, end);
parts.add( parts.add(
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(), chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
); );
@@ -96,6 +102,14 @@ class Contact {
return parts.join(','); return parts.join(',');
} }
Uint8List get _pathBytesForDisplay {
if (pathOverride != null) {
if (pathOverride! < 0) return Uint8List(0);
return pathOverrideBytes ?? Uint8List(0);
}
return path;
}
static Contact? fromFrame(Uint8List data) { static Contact? fromFrame(Uint8List data) {
if (data.length < contactFrameSize) return null; if (data.length < contactFrameSize) return null;
if (data[0] != respCodeContact) return null; if (data[0] != respCodeContact) return null;
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../services/app_debug_log_service.dart';
class AppDebugLogScreen extends StatelessWidget {
const AppDebugLogScreen({super.key});
@override
Widget build(BuildContext context) {
return Consumer<AppDebugLogService>(
builder: (context, logService, _) {
final entries = logService.entries.reversed.toList();
final hasEntries = entries.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const Text('App Debug Log'),
centerTitle: true,
actions: [
IconButton(
tooltip: 'Copy log',
icon: const Icon(Icons.copy),
onPressed: hasEntries
? () async {
final text = entries
.map((entry) =>
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}')
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Debug log copied')),
);
}
: null,
),
IconButton(
tooltip: 'Clear log',
icon: const Icon(Icons.delete_outline),
onPressed: hasEntries
? () {
logService.clear();
}
: null,
),
],
),
body: SafeArea(
top: false,
child: hasEntries
? ListView.separated(
itemCount: entries.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final entry = entries[index];
return ListTile(
dense: true,
leading: _buildLevelIcon(entry.level),
title: Text(
'[${entry.tag}] ${entry.message}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
),
subtitle: Text(
entry.formattedTime,
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
),
);
},
)
: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No debug logs yet',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
'Enable app debug logging in settings',
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
],
),
),
),
);
},
);
}
Widget _buildLevelIcon(AppDebugLogLevel level) {
switch (level) {
case AppDebugLogLevel.info:
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
case AppDebugLogLevel.warning:
return const Icon(Icons.warning_amber_outlined, size: 18, color: Colors.orange);
case AppDebugLogLevel.error:
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
}
}
}
+102 -103
View File
@@ -32,6 +32,8 @@ class AppSettingsScreen extends StatelessWidget {
_buildBatteryCard(context, settingsService, connector), _buildBatteryCard(context, settingsService, connector),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildMapSettingsCard(context, settingsService), _buildMapSettingsCard(context, settingsService),
const SizedBox(height: 16),
_buildDebugCard(context, settingsService),
], ],
); );
}, },
@@ -383,43 +385,31 @@ class AppSettingsScreen extends StatelessWidget {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Theme'), title: const Text('Theme'),
content: Column( content: RadioGroup<String>(
mainAxisSize: MainAxisSize.min, groupValue: settingsService.settings.themeMode,
children: [ onChanged: (value) {
RadioListTile<String>( if (value != null) {
title: const Text('System default'), settingsService.setThemeMode(value);
value: 'system', Navigator.pop(context);
groupValue: settingsService.settings.themeMode, }
onChanged: (value) { },
if (value != null) { child: Column(
settingsService.setThemeMode(value); mainAxisSize: MainAxisSize.min,
Navigator.pop(context); children: [
} RadioListTile<String>(
}, title: const Text('System default'),
), value: 'system',
RadioListTile<String>( ),
title: const Text('Light'), RadioListTile<String>(
value: 'light', title: const Text('Light'),
groupValue: settingsService.settings.themeMode, value: 'light',
onChanged: (value) { ),
if (value != null) { RadioListTile<String>(
settingsService.setThemeMode(value); title: const Text('Dark'),
Navigator.pop(context); value: 'dark',
} ),
}, ],
), ),
RadioListTile<String>(
title: const Text('Dark'),
value: 'dark',
groupValue: settingsService.settings.themeMode,
onChanged: (value) {
if (value != null) {
settingsService.setThemeMode(value);
Navigator.pop(context);
}
},
),
],
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -447,77 +437,51 @@ class AppSettingsScreen extends StatelessWidget {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Map Time Filter'), title: const Text('Map Time Filter'),
content: Column( content: RadioGroup<double>(
mainAxisSize: MainAxisSize.min, groupValue: settingsService.settings.mapTimeFilterHours,
children: [ onChanged: (value) {
const Text('Show nodes discovered within:'), if (value != null) {
const SizedBox(height: 16), settingsService.setMapTimeFilterHours(value);
ListTile( Navigator.pop(context);
title: const Text('All time'), }
leading: Radio<double>( },
value: 0, child: Column(
groupValue: settingsService.settings.mapTimeFilterHours, mainAxisSize: MainAxisSize.min,
onChanged: (value) { children: [
if (value != null) { const Text('Show nodes discovered within:'),
settingsService.setMapTimeFilterHours(value); const SizedBox(height: 16),
Navigator.pop(context); ListTile(
} title: const Text('All time'),
}, leading: Radio<double>(
value: 0,
),
), ),
), ListTile(
ListTile( title: const Text('Last hour'),
title: const Text('Last hour'), leading: Radio<double>(
leading: Radio<double>( value: 1,
value: 1, ),
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
), ),
), ListTile(
ListTile( title: const Text('Last 6 hours'),
title: const Text('Last 6 hours'), leading: Radio<double>(
leading: Radio<double>( value: 6,
value: 6, ),
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
), ),
), ListTile(
ListTile( title: const Text('Last 24 hours'),
title: const Text('Last 24 hours'), leading: Radio<double>(
leading: Radio<double>( value: 24,
value: 24, ),
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
), ),
), ListTile(
ListTile( title: const Text('Last week'),
title: const Text('Last week'), leading: Radio<double>(
leading: Radio<double>( value: 168,
value: 168, ),
groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) {
if (value != null) {
settingsService.setMapTimeFilterHours(value);
Navigator.pop(context);
}
},
), ),
), ],
], ),
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -528,4 +492,39 @@ class AppSettingsScreen extends StatelessWidget {
), ),
); );
} }
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Debug',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
SwitchListTile(
secondary: const Icon(Icons.bug_report_outlined),
title: const Text('App Debug Logging'),
subtitle: const Text('Log app debug messages for troubleshooting'),
value: settingsService.settings.appDebugLogEnabled,
onChanged: (value) async {
await settingsService.setAppDebugLogEnabled(value);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? 'App debug logging enabled'
: 'App debug logging disabled'),
duration: const Duration(seconds: 2),
),
);
},
),
],
),
);
}
} }
+4 -3
View File
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -38,11 +39,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index); context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index);
// Scroll to bottom when opening channel chat // Scroll to bottom when opening channel chat - use SchedulerBinding for next frame
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent); _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
} }
@@ -151,7 +152,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
builder: (context, connector, child) { builder: (context, connector, child) {
final messages = connector.getChannelMessages(widget.channel); final messages = connector.getChannelMessages(widget.channel);
WidgetsBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
_scrollToBottom(); _scrollToBottom();
}); });
+294 -54
View File
@@ -10,6 +10,8 @@ import '../models/channel.dart';
import '../utils/dialog_utils.dart'; import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart'; import '../utils/disconnect_navigation_mixin.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/battery_indicator.dart';
import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import '../widgets/unread_badge.dart'; import '../widgets/unread_badge.dart';
@@ -18,6 +20,13 @@ import 'contacts_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
enum ChannelSortOption {
manual,
name,
latestMessages,
unread,
}
class ChannelsScreen extends StatefulWidget { class ChannelsScreen extends StatefulWidget {
final bool hideBackButton; final bool hideBackButton;
@@ -32,6 +41,11 @@ class ChannelsScreen extends StatefulWidget {
class _ChannelsScreenState extends State<ChannelsScreen> class _ChannelsScreenState extends State<ChannelsScreen>
with DisconnectNavigationMixin { with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
Timer? _searchDebounce;
ChannelSortOption _sortOption = ChannelSortOption.manual;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -40,6 +54,13 @@ class _ChannelsScreenState extends State<ChannelsScreen>
}); });
} }
@override
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>(); final connector = context.watch<MeshCoreConnector>();
@@ -55,10 +76,16 @@ class _ChannelsScreenState extends State<ChannelsScreen>
canPop: allowBack, canPop: allowBack,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BatteryIndicator(connector: connector),
title: const Text('Channels'), title: const Text('Channels'),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: !widget.hideBackButton && allowBack, automaticallyImplyLeading: false,
actions: [ actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context),
),
IconButton( IconButton(
icon: const Icon(Icons.tune), icon: const Icon(Icons.tune),
tooltip: 'Settings', tooltip: 'Settings',
@@ -67,57 +94,154 @@ class _ChannelsScreenState extends State<ChannelsScreen>
MaterialPageRoute(builder: (context) => const SettingsScreen()), MaterialPageRoute(builder: (context) => const SettingsScreen()),
), ),
), ),
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => context.read<MeshCoreConnector>().getChannels(),
),
], ],
), ),
body: () { body: RefreshIndicator(
if (connector.isLoadingChannels) { onRefresh: () async {
return const Center(child: CircularProgressIndicator()); await context.read<MeshCoreConnector>().getChannels();
} },
child: () {
if (connector.isLoadingChannels) {
return const Center(child: CircularProgressIndicator());
}
final channels = connector.channels; final channels = connector.channels;
if (channels.isEmpty) { if (channels.isEmpty) {
return EmptyState( return ListView(
icon: Icons.tag, children: [
title: 'No channels configured', SizedBox(
action: FilledButton.icon( height: MediaQuery.of(context).size.height - 200,
onPressed: () => _addPublicChannel(context, connector), child: EmptyState(
icon: const Icon(Icons.public), icon: Icons.tag,
label: const Text('Add Public Channel'), title: 'No channels configured',
), action: FilledButton.icon(
); onPressed: () => _addPublicChannel(context, connector),
} icon: const Icon(Icons.public),
label: const Text('Add Public Channel'),
return ReorderableListView.builder( ),
padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 88), ),
buildDefaultDragHandles: false, ),
itemCount: channels.length, ],
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
final reordered = List<Channel>.from(channels);
final item = reordered.removeAt(oldIndex);
reordered.insert(newIndex, item);
unawaited(
connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
),
); );
}, }
itemBuilder: (context, index) {
final channel = channels[index]; final filteredChannels = _filterAndSortChannels(channels, connector);
return _buildChannelTile(context, connector, channel, index);
}, return Column(
); children: [
}(), Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search channels...',
prefixIcon: const Icon(Icons.search),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_searchQuery.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
),
_buildFilterButton(),
],
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
if (!mounted) return;
setState(() {
_searchQuery = value.toLowerCase();
});
});
},
),
),
Expanded(
child: filteredChannels.isEmpty
? ListView(
children: [
SizedBox(
height: MediaQuery.of(context).size.height - 300,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No channels found',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
),
),
],
)
: (_sortOption == ChannelSortOption.manual && _searchQuery.isEmpty)
? ReorderableListView.builder(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: 88,
),
buildDefaultDragHandles: false,
itemCount: filteredChannels.length,
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex -= 1;
final reordered = List<Channel>.from(filteredChannels);
final item = reordered.removeAt(oldIndex);
reordered.insert(newIndex, item);
unawaited(
connector.setChannelOrder(
reordered.map((c) => c.index).toList(),
),
);
},
itemBuilder: (context, index) {
final channel = filteredChannels[index];
return _buildChannelTile(
context,
connector,
channel,
showDragHandle: true,
dragIndex: index,
);
},
)
: ListView.builder(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 8,
bottom: 88,
),
itemCount: filteredChannels.length,
itemBuilder: (context, index) {
final channel = filteredChannels[index];
return _buildChannelTile(context, connector, channel);
},
),
),
],
);
}(),
),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _showAddChannelDialog(context), onPressed: () => _showAddChannelDialog(context),
child: const Icon(Icons.add), child: const Icon(Icons.add),
@@ -137,7 +261,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
BuildContext context, BuildContext context,
MeshCoreConnector connector, MeshCoreConnector connector,
Channel channel, Channel channel,
int index, {
bool showDragHandle = false,
int? dragIndex,
}
) { ) {
final unreadCount = connector.getUnreadCountForChannel(channel); final unreadCount = connector.getUnreadCountForChannel(channel);
return Card( return Card(
@@ -179,13 +306,14 @@ class _ChannelsScreenState extends State<ChannelsScreen>
UnreadBadge(count: unreadCount), UnreadBadge(count: unreadCount),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
ReorderableDelayedDragStartListener( if (showDragHandle && dragIndex != null)
index: index, ReorderableDelayedDragStartListener(
child: Icon( index: dragIndex,
Icons.drag_handle, child: Icon(
color: Theme.of(context).colorScheme.onSurfaceVariant, Icons.drag_handle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
), ),
),
], ],
), ),
onTap: () async { onTap: () async {
@@ -271,6 +399,118 @@ class _ChannelsScreenState extends State<ChannelsScreen>
await showDisconnectDialog(context, connector); await showDisconnectDialog(context, connector);
} }
Widget _buildFilterButton() {
const actionSortManual = 0;
const actionSortName = 1;
const actionSortLatest = 2;
const actionSortUnread = 3;
return SortFilterMenu(
sections: [
SortFilterMenuSection(
title: 'Sort by',
options: [
SortFilterMenuOption(
value: actionSortManual,
label: 'Manual',
checked: _sortOption == ChannelSortOption.manual,
),
SortFilterMenuOption(
value: actionSortName,
label: 'A-Z',
checked: _sortOption == ChannelSortOption.name,
),
SortFilterMenuOption(
value: actionSortLatest,
label: 'Latest messages',
checked: _sortOption == ChannelSortOption.latestMessages,
),
SortFilterMenuOption(
value: actionSortUnread,
label: 'Unread',
checked: _sortOption == ChannelSortOption.unread,
),
],
),
],
onSelected: (action) {
setState(() {
switch (action) {
case actionSortManual:
_sortOption = ChannelSortOption.manual;
break;
case actionSortLatest:
_sortOption = ChannelSortOption.latestMessages;
break;
case actionSortUnread:
_sortOption = ChannelSortOption.unread;
break;
case actionSortName:
default:
_sortOption = ChannelSortOption.name;
break;
}
});
},
);
}
List<Channel> _filterAndSortChannels(
List<Channel> channels,
MeshCoreConnector connector,
) {
var filtered = channels.where((channel) {
if (_searchQuery.isEmpty) return true;
final label = _normalizeChannelName(channel);
return label.toLowerCase().contains(_searchQuery);
}).toList();
int compareByName(Channel a, Channel b) {
final nameA = _normalizeChannelName(a);
final nameB = _normalizeChannelName(b);
return nameA.toLowerCase().compareTo(nameB.toLowerCase());
}
switch (_sortOption) {
case ChannelSortOption.manual:
break;
case ChannelSortOption.latestMessages:
filtered.sort((a, b) {
final aMessages = connector.getChannelMessages(a);
final bMessages = connector.getChannelMessages(b);
final aLast = aMessages.isEmpty ? DateTime(1970) : aMessages.last.timestamp;
final bLast = bMessages.isEmpty ? DateTime(1970) : bMessages.last.timestamp;
final timeCompare = bLast.compareTo(aLast);
if (timeCompare != 0) return timeCompare;
return compareByName(a, b);
});
break;
case ChannelSortOption.unread:
filtered.sort((a, b) {
final aUnread = connector.getUnreadCountForChannel(a);
final bUnread = connector.getUnreadCountForChannel(b);
final unreadCompare = bUnread.compareTo(aUnread);
if (unreadCompare != 0) return unreadCompare;
return compareByName(a, b);
});
break;
case ChannelSortOption.name:
filtered.sort(compareByName);
break;
}
return filtered;
}
String _normalizeChannelName(Channel channel) {
if (channel.name.isEmpty) return 'Channel ${channel.index}';
final trimmed = channel.name.trim();
if (trimmed.startsWith('#') && trimmed.length > 1) {
return trimmed.substring(1);
}
return trimmed;
}
void _showAddChannelDialog(BuildContext context) { void _showAddChannelDialog(BuildContext context) {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
final nameController = TextEditingController(); final nameController = TextEditingController();
+72 -382
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -19,6 +20,8 @@ import '../utils/emoji_utils.dart';
import '../widgets/emoji_picker.dart'; import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart'; import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart'; import '../widgets/gif_picker.dart';
import '../widgets/path_selection_dialog.dart';
import '../utils/app_logger.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
final Contact contact; final Contact contact;
@@ -36,11 +39,11 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex); context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex);
// Scroll to bottom when opening chat // Scroll to bottom when opening chat use SchedulerBinding for next frame
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent); _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
} }
@@ -438,13 +441,13 @@ class _ChatScreenState extends State<ChatScreen> {
); );
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context); Navigator.pop(context);
await _notifyPathSet(
connector,
widget.contact,
pathBytes,
path.hopCount,
);
}, },
), ),
); );
@@ -590,6 +593,30 @@ class _ChatScreenState extends State<ChatScreen> {
return '${contact.pathLength} hops'; return '${contact.pathLength} hops';
} }
Future<void> _notifyPathSet(
MeshCoreConnector connector,
Contact contact,
Uint8List pathBytes,
int hopCount,
) async {
final verified = connector.isConnected
? await connector.verifyContactPathOnDevice(contact, pathBytes)
: false;
if (!mounted) return;
final status = !connector.isConnected
? 'Saved locally. Connect to sync.'
: (verified ? 'Device confirmed.' : 'Device not confirmed yet.');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Path set: $hopCount ${hopCount == 1 ? 'hop' : 'hops'} - $status',
),
duration: const Duration(seconds: 3),
),
);
}
void _showContactInfo(BuildContext context) { void _showContactInfo(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex); connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex);
@@ -657,7 +684,7 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
void _showCustomPathDialog(BuildContext context) { Future<void> _showCustomPathDialog(BuildContext context) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final currentContact = _resolveContact(connector); final currentContact = _resolveContact(connector);
@@ -665,385 +692,48 @@ class _ChatScreenState extends State<ChatScreen> {
connector.getContacts(); connector.getContacts();
} }
showDialog( final pathForInput = currentContact.pathIdList;
context: context, final currentPathLabel = _currentPathLabel(currentContact);
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.edit_road),
SizedBox(width: 8),
Text('Set Custom Path'),
],
),
content: Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final contact = _resolveContact(connector);
final pathForInput = contact.pathIdList;
final currentPathLabel = _currentPathLabel(contact);
return Column( // Filter out the current contact from available contacts
mainAxisSize: MainAxisSize.min, final availableContacts = connector.contacts
crossAxisAlignment: CrossAxisAlignment.start, .where((c) => c != widget.contact)
children: [
Row(
children: [
const Text(
'Current path',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
TextButton.icon(
onPressed: connector.isConnected ? connector.getContacts : null,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reload'),
),
],
),
Text(
currentPathLabel,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 16),
const Text(
'Choose how to set the message path:',
style: TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.blue,
child: Icon(Icons.text_fields, size: 16),
),
title: const Text('Enter Path Manually', style: TextStyle(fontSize: 14)),
subtitle: const Text('Type IDs like: A1B2C3D4,FFEEDDCC', style: TextStyle(fontSize: 11)),
onTap: () {
Navigator.pop(context);
_showManualPathInput(
context,
initialPath: pathForInput.isEmpty ? null : pathForInput,
);
},
),
const SizedBox(height: 8),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.green,
child: Icon(Icons.contacts, size: 16),
),
title: const Text('Select from Contacts', style: TextStyle(fontSize: 14)),
subtitle: const Text('Pick repeaters/rooms as hops', style: TextStyle(fontSize: 11)),
onTap: () {
Navigator.pop(context);
_showContactPathPicker(context);
},
),
],
);
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
),
);
}
void _showManualPathInput(BuildContext context, {String? initialPath}) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final controller = TextEditingController(text: initialPath ?? '');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Enter Custom Path'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Enter 2-character hex prefixes for each hop, separated by commas.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
const Text(
'Example: A1,F2,3C (each node uses first byte of its public key)',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 16),
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Path (hex prefixes)',
hintText: 'A1,F2,3C',
border: OutlineInputBorder(),
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
),
textCapitalization: TextCapitalization.characters,
maxLength: 191, // 64 hops * 2 chars + 63 commas
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () async {
final path = controller.text.trim().toUpperCase();
if (path.isEmpty) {
if (context.mounted) Navigator.pop(context);
return;
}
// Parse comma-separated hex prefixes
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
final pathBytesList = <int>[];
final invalidPrefixes = <String>[];
for (final id in pathIds) {
if (id.length < 2) {
invalidPrefixes.add(id);
continue;
}
final prefix = id.substring(0, 2);
try {
final byte = int.parse(prefix, radix: 16);
pathBytesList.add(byte);
} catch (e) {
invalidPrefixes.add(id);
}
}
if (!context.mounted) return;
// Show error for invalid prefixes
if (invalidPrefixes.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
),
);
return;
}
// Check max path length (64 hops)
if (pathBytesList.length > 64) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path too long. Maximum 64 hops allowed.'),
duration: Duration(seconds: 3),
backgroundColor: Colors.red,
),
);
return;
}
if (pathBytesList.isNotEmpty) {
await connector.setContactPath(
widget.contact,
Uint8List.fromList(pathBytesList),
pathBytesList.length,
);
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Path set: ${pathBytesList.length} ${pathBytesList.length == 1 ? "hop" : "hops"}'),
duration: const Duration(seconds: 2),
),
);
}
}
},
child: const Text('Set Path'),
),
],
),
);
}
void _showContactPathPicker(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final selectedContacts = <Contact>[];
// Filter to only repeaters and room servers
final validContacts = connector.contacts
.where((c) => (c.type == 2 || c.type == 3) && c != widget.contact)
.toList(); .toList();
showDialog( final result = await PathSelectionDialog.show(
context: context, context,
builder: (context) => StatefulBuilder( availableContacts: availableContacts,
builder: (context, setDialogState) => AlertDialog( initialPath: pathForInput.isEmpty ? null : pathForInput,
title: const Text('Build Path from Contacts'), title: 'Set Custom Path',
content: SizedBox( currentPathLabel: currentPathLabel,
width: double.maxFinite, onRefresh: connector.isConnected ? connector.getContacts : null,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (validContacts.isEmpty) ...[
const Icon(Icons.info_outline, size: 48, color: Colors.grey),
const SizedBox(height: 16),
const Text(
'No repeaters or room servers found.',
style: TextStyle(fontSize: 14),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Custom paths require intermediate hops that can relay messages.',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
] else if (selectedContacts.isNotEmpty) ...[
const Text(
'Selected Path:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: selectedContacts.asMap().entries.map((entry) {
final idx = entry.key;
final contact = entry.value;
return Chip(
avatar: CircleAvatar(
child: Text('${idx + 1}'),
),
label: Text(contact.name),
onDeleted: () {
setDialogState(() {
selectedContacts.removeAt(idx);
});
},
);
}).toList(),
),
const Divider(),
] else
const Text(
'Tap repeaters/rooms to add them to the path:',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
if (validContacts.isNotEmpty)
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: validContacts.length,
itemBuilder: (context, index) {
final contact = validContacts[index];
final isSelected = selectedContacts.contains(contact);
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: isSelected ? Colors.green : (contact.type == 2 ? Colors.blue : Colors.purple),
child: Icon(
contact.type == 2 ? Icons.router : Icons.meeting_room,
size: 16,
color: Colors.white,
),
),
title: Text(contact.name, style: const TextStyle(fontSize: 14)),
subtitle: Text(
'${contact.typeLabel}${contact.publicKeyHex.substring(0, 8)}',
style: const TextStyle(fontSize: 10),
),
trailing: isSelected
? const Icon(Icons.check_circle, color: Colors.green)
: const Icon(Icons.add_circle_outline),
onTap: () {
setDialogState(() {
if (isSelected) {
selectedContacts.remove(contact);
} else {
selectedContacts.add(contact);
}
});
},
);
},
),
),
],
),
),
actions: [
if (selectedContacts.isNotEmpty)
TextButton(
onPressed: () {
setDialogState(() {
selectedContacts.clear();
});
},
child: const Text('Clear'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: selectedContacts.isEmpty
? null
: () async {
// Build path bytes from selected contacts (prefix byte of each pub key)
final pathBytesList = <int>[];
for (final contact in selectedContacts) {
if (contact.publicKeyHex.length >= 2) {
try {
pathBytesList.add(int.parse(contact.publicKeyHex.substring(0, 2), radix: 16));
} catch (e) {
// Skip invalid hex
}
}
}
if (pathBytesList.isNotEmpty) {
await connector.setContactPath(
widget.contact,
Uint8List.fromList(pathBytesList),
pathBytesList.length,
);
final pathIds = selectedContacts
.map((c) => c.publicKeyHex.substring(0, 8))
.join(',');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Custom path set: $pathIds'),
duration: const Duration(seconds: 2),
),
);
Navigator.pop(context);
}
}
},
child: const Text('Set Path'),
),
],
),
),
); );
appLogger.info('PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted', tag: 'ChatScreen');
if (result == null) {
appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'ChatScreen');
return;
}
if (!mounted) {
appLogger.warn('Widget not mounted after dialog, cannot set path', tag: 'ChatScreen');
return;
}
appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'ChatScreen');
await connector.setPathOverride(
widget.contact,
pathLen: result.length,
pathBytes: result,
);
appLogger.info('setPathOverride completed', tag: 'ChatScreen');
if (!mounted) return;
await _notifyPathSet(connector, widget.contact, result, result.length);
} }
void _openMessagePath(Message message) { void _openMessagePath(Message message) {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
final senderName = final senderName =
+65 -147
View File
@@ -13,6 +13,8 @@ import '../utils/dialog_utils.dart';
import '../utils/disconnect_navigation_mixin.dart'; import '../utils/disconnect_navigation_mixin.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/battery_indicator.dart';
import '../widgets/list_filter_widget.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart'; import '../widgets/repeater_login_dialog.dart';
@@ -23,22 +25,6 @@ import 'map_screen.dart';
import 'repeater_hub_screen.dart'; import 'repeater_hub_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
enum ContactSortOption {
lastSeen,
recentMessages,
name,
type,
}
enum _ContactMenuAction {
sortRecentMessages,
sortName,
sortType,
toggleLastSeenFilter,
toggleUnreadOnly,
newGroup,
}
class ContactsScreen extends StatefulWidget { class ContactsScreen extends StatefulWidget {
final bool hideBackButton; final bool hideBackButton;
@@ -56,8 +42,8 @@ class _ContactsScreenState extends State<ContactsScreen>
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
String _searchQuery = ''; String _searchQuery = '';
ContactSortOption _sortOption = ContactSortOption.lastSeen; ContactSortOption _sortOption = ContactSortOption.lastSeen;
bool _forceLastSeenSort = true;
bool _showUnreadOnly = false; bool _showUnreadOnly = false;
ContactTypeFilter _typeFilter = ContactTypeFilter.all;
final ContactGroupStore _groupStore = ContactGroupStore(); final ContactGroupStore _groupStore = ContactGroupStore();
List<ContactGroup> _groups = []; List<ContactGroup> _groups = [];
Timer? _searchDebounce; Timer? _searchDebounce;
@@ -97,41 +83,15 @@ class _ContactsScreenState extends State<ContactsScreen>
} }
final allowBack = !connector.isConnected; final allowBack = !connector.isConnected;
final theme = Theme.of(context);
return PopScope( return PopScope(
canPop: allowBack, canPop: allowBack,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
titleSpacing: 16, leading: BatteryIndicator(connector: connector),
centerTitle: false, title: const Text('Contacts'),
automaticallyImplyLeading: !widget.hideBackButton && allowBack, centerTitle: true,
title: Column( automaticallyImplyLeading: false,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Contacts'),
Text(
'${connector.contacts.length} contacts',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
),
actions: [ actions: [
IconButton(
icon: connector.isLoadingContacts
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
tooltip: 'Refresh',
onPressed: connector.isLoadingContacts ? null : () => connector.getContacts(),
),
IconButton( IconButton(
icon: const Icon(Icons.bluetooth_disabled), icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect', tooltip: 'Disconnect',
@@ -145,93 +105,6 @@ class _ContactsScreenState extends State<ContactsScreen>
MaterialPageRoute(builder: (context) => const SettingsScreen()), MaterialPageRoute(builder: (context) => const SettingsScreen()),
), ),
), ),
PopupMenuButton<_ContactMenuAction>(
tooltip: 'Contacts options',
onSelected: (action) {
switch (action) {
case _ContactMenuAction.sortRecentMessages:
setState(() {
_sortOption = ContactSortOption.recentMessages;
_forceLastSeenSort = false;
});
break;
case _ContactMenuAction.sortName:
setState(() {
_sortOption = ContactSortOption.name;
_forceLastSeenSort = false;
});
break;
case _ContactMenuAction.sortType:
setState(() {
_sortOption = ContactSortOption.type;
_forceLastSeenSort = false;
});
break;
case _ContactMenuAction.toggleLastSeenFilter:
setState(() {
_forceLastSeenSort = !_forceLastSeenSort;
if (_forceLastSeenSort) {
_sortOption = ContactSortOption.lastSeen;
}
});
break;
case _ContactMenuAction.toggleUnreadOnly:
setState(() {
_showUnreadOnly = !_showUnreadOnly;
});
break;
case _ContactMenuAction.newGroup:
_showGroupEditor(context, connector.contacts);
break;
}
},
itemBuilder: (context) {
final labelStyle = theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
return [
PopupMenuItem<_ContactMenuAction>(
enabled: false,
child: Text('Sort by', style: labelStyle),
),
CheckedPopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.sortRecentMessages,
checked: _sortOption == ContactSortOption.recentMessages,
child: const Text('Recent messages'),
),
CheckedPopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.sortName,
checked: _sortOption == ContactSortOption.name,
child: const Text('Name'),
),
CheckedPopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.sortType,
checked: _sortOption == ContactSortOption.type,
child: const Text('Type'),
),
const PopupMenuDivider(),
PopupMenuItem<_ContactMenuAction>(
enabled: false,
child: Text('Filters', style: labelStyle),
),
CheckedPopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.toggleLastSeenFilter,
checked: _forceLastSeenSort,
child: const Text('Last seen'),
),
CheckedPopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.toggleUnreadOnly,
checked: _showUnreadOnly,
child: const Text('Unread only'),
),
PopupMenuItem<_ContactMenuAction>(
value: _ContactMenuAction.newGroup,
child: const Text('New group'),
),
];
},
),
], ],
), ),
body: _buildContactsBody(context, connector), body: _buildContactsBody(context, connector),
@@ -253,6 +126,30 @@ class _ContactsScreenState extends State<ContactsScreen>
await showDisconnectDialog(context, connector); await showDisconnectDialog(context, connector);
} }
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
return ContactsFilterMenu(
sortOption: _sortOption,
typeFilter: _typeFilter,
showUnreadOnly: _showUnreadOnly,
onSortChanged: (value) {
setState(() {
_sortOption = value;
});
},
onTypeFilterChanged: (value) {
setState(() {
_typeFilter = value;
});
},
onUnreadOnlyChanged: (value) {
setState(() {
_showUnreadOnly = value;
});
},
onNewGroup: () => _showGroupEditor(context, connector.contacts),
);
}
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) { Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
final contacts = connector.contacts; final contacts = connector.contacts;
@@ -281,8 +178,11 @@ class _ContactsScreenState extends State<ContactsScreen>
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search contacts...', hintText: 'Search contacts...',
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty suffixIcon: Row(
? IconButton( mainAxisSize: MainAxisSize.min,
children: [
if (_searchQuery.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
@@ -290,8 +190,10 @@ class _ContactsScreenState extends State<ContactsScreen>
_searchQuery = ''; _searchQuery = '';
}); });
}, },
) ),
: null, _buildFilterButton(context, connector),
],
),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -366,6 +268,13 @@ class _ContactsScreenState extends State<ContactsScreen>
if (contact != null && matchesContactQuery(contact, query)) return true; if (contact != null && matchesContactQuery(contact, query)) return true;
} }
return false; return false;
}).where((group) {
if (_typeFilter == ContactTypeFilter.all) return true;
for (final key in group.memberKeys) {
final contact = contactsByKey[key];
if (contact != null && _matchesTypeFilter(contact)) return true;
}
return false;
}).toList(); }).toList();
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
@@ -378,14 +287,17 @@ class _ContactsScreenState extends State<ContactsScreen>
return matchesContactQuery(contact, _searchQuery); return matchesContactQuery(contact, _searchQuery);
}).toList(); }).toList();
if (_typeFilter != ContactTypeFilter.all) {
filtered = filtered.where(_matchesTypeFilter).toList();
}
if (_showUnreadOnly) { if (_showUnreadOnly) {
filtered = filtered.where((contact) { filtered = filtered.where((contact) {
return connector.getUnreadCountForContact(contact) > 0; return connector.getUnreadCountForContact(contact) > 0;
}).toList(); }).toList();
} }
final sortOption = _forceLastSeenSort ? ContactSortOption.lastSeen : _sortOption; switch (_sortOption) {
switch (sortOption) {
case ContactSortOption.lastSeen: case ContactSortOption.lastSeen:
filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a))); filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)));
break; break;
@@ -401,18 +313,24 @@ class _ContactsScreenState extends State<ContactsScreen>
case ContactSortOption.name: case ContactSortOption.name:
filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
break; break;
case ContactSortOption.type:
filtered.sort((a, b) {
final typeCompare = a.type.compareTo(b.type);
if (typeCompare != 0) return typeCompare;
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
});
break;
} }
return filtered; return filtered;
} }
bool _matchesTypeFilter(Contact contact) {
switch (_typeFilter) {
case ContactTypeFilter.all:
return true;
case ContactTypeFilter.users:
return contact.type == advTypeChat;
case ContactTypeFilter.repeaters:
return contact.type == advTypeRepeater;
case ContactTypeFilter.rooms:
return contact.type == advTypeRoom;
}
}
DateTime _resolveLastSeen(Contact contact) { DateTime _resolveLastSeen(Contact contact) {
if (contact.type != advTypeChat) return contact.lastSeen; if (contact.type != advTypeChat) return contact.lastSeen;
return contact.lastMessageAt.isAfter(contact.lastSeen) return contact.lastMessageAt.isAfter(contact.lastSeen)
+7 -5
View File
@@ -39,10 +39,17 @@ class _DeviceScreenState extends State<DeviceScreen>
canPop: false, canPop: false,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: _buildBatteryIndicator(connector, context),
titleSpacing: 16, titleSpacing: 16,
centerTitle: false, centerTitle: false,
title: _buildAppBarTitle(connector, theme), title: _buildAppBarTitle(connector, theme),
automaticallyImplyLeading: false,
actions: [ actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context, connector),
),
IconButton( IconButton(
icon: const Icon(Icons.tune), icon: const Icon(Icons.tune),
tooltip: 'Settings', tooltip: 'Settings',
@@ -53,11 +60,6 @@ class _DeviceScreenState extends State<DeviceScreen>
), ),
), ),
), ),
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context, connector),
),
], ],
), ),
body: SafeArea( body: SafeArea(
+8 -6
View File
@@ -12,6 +12,7 @@ import '../services/map_marker_service.dart';
import '../services/map_tile_cache_service.dart'; import '../services/map_tile_cache_service.dart';
import '../utils/contact_search.dart'; import '../utils/contact_search.dart';
import '../utils/route_transitions.dart'; import '../utils/route_transitions.dart';
import '../widgets/battery_indicator.dart';
import '../widgets/quick_switch_bar.dart'; import '../widgets/quick_switch_bar.dart';
import 'channels_screen.dart'; import 'channels_screen.dart';
import 'chat_screen.dart'; import 'chat_screen.dart';
@@ -136,10 +137,16 @@ class _MapScreenState extends State<MapScreen> {
canPop: allowBack, canPop: allowBack,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BatteryIndicator(connector: connector),
title: const Text('Node Map'), title: const Text('Node Map'),
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: !widget.hideBackButton && allowBack, automaticallyImplyLeading: false,
actions: [ actions: [
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context, connector),
),
IconButton( IconButton(
icon: const Icon(Icons.tune), icon: const Icon(Icons.tune),
tooltip: 'Settings', tooltip: 'Settings',
@@ -148,11 +155,6 @@ class _MapScreenState extends State<MapScreen> {
MaterialPageRoute(builder: (context) => const SettingsScreen()), MaterialPageRoute(builder: (context) => const SettingsScreen()),
), ),
), ),
IconButton(
icon: const Icon(Icons.bluetooth_disabled),
tooltip: 'Disconnect',
onPressed: () => _disconnect(context, connector),
),
], ],
), ),
body: !hasMapContent body: !hasMapContent
+65 -2
View File
@@ -7,6 +7,7 @@ import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../widgets/debug_frame_viewer.dart'; import '../widgets/debug_frame_viewer.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart';
class RepeaterCliScreen extends StatefulWidget { class RepeaterCliScreen extends StatefulWidget {
final Contact repeater; final Contact repeater;
@@ -75,6 +76,13 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
}); });
} }
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
void _handleTextMessageResponse(Uint8List frame) { void _handleTextMessageResponse(Uint8List frame) {
final parsed = parseContactMessageText(frame); final parsed = parseContactMessageText(frame);
if (parsed == null) return; if (parsed == null) return;
@@ -117,9 +125,12 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
// Send CLI command to repeater with retry // Send CLI command to repeater with retry
try { try {
if (_commandService != null) { if (_commandService != null) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
final response = await _commandService!.sendCommand( final response = await _commandService!.sendCommand(
widget.repeater, repeater,
command, command,
retries: 1,
); );
if (mounted) { if (mounted) {
@@ -204,6 +215,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Column( title: Column(
@@ -212,13 +227,61 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
children: [ children: [
const Text('Repeater CLI'), const Text('Repeater CLI'),
Text( Text(
widget.repeater.name, repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
), ),
], ],
), ),
centerTitle: false, centerTitle: false,
actions: [ actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
onPressed: () => PathManagementDialog.show(context, contact: repeater),
),
IconButton( IconButton(
icon: const Icon(Icons.bug_report), icon: const Icon(Icons.bug_report),
tooltip: 'Debug Next Command', tooltip: 'Debug Next Command',
+3 -6
View File
@@ -33,11 +33,9 @@ class RepeaterHubScreen extends StatelessWidget {
), ),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: Padding( child: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Repeater info card // Repeater info card
Card( Card(
child: Padding( child: Padding(
@@ -142,8 +140,7 @@ class RepeaterHubScreen extends StatelessWidget {
); );
}, },
), ),
], ],
),
), ),
), ),
); );
+99 -4
View File
@@ -6,6 +6,7 @@ import '../models/contact.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart';
class RepeaterSettingsScreen extends StatefulWidget { class RepeaterSettingsScreen extends StatefulWidget {
final Contact repeater; final Contact repeater;
@@ -121,6 +122,13 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
_commandService?.handleResponse(widget.repeater, parsed.text); _commandService?.handleResponse(widget.repeater, parsed.text);
} }
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
bool _matchesRepeaterPrefix(Uint8List prefix) { bool _matchesRepeaterPrefix(Uint8List prefix) {
final target = widget.repeater.publicKey; final target = widget.repeater.publicKey;
if (target.length < 6 || prefix.length < 6) return false; if (target.length < 6 || prefix.length < 6) return false;
@@ -326,9 +334,15 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
}); });
var successCount = 0; var successCount = 0;
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
for (final command in commands) { for (final command in commands) {
try { try {
final response = await _commandService!.sendCommand(widget.repeater, command); final response = await _commandService!.sendCommand(
repeater,
command,
retries: 1,
);
_applySettingResponse(command, response); _applySettingResponse(command, response);
successCount += 1; successCount += 1;
await Future.delayed(const Duration(milliseconds: 200)); await Future.delayed(const Duration(milliseconds: 200));
@@ -422,12 +436,14 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
setState(() { setState(() {
_isLoading = true; _isLoading = true;
}); });
try { try {
final selection = await connector.preparePathForContactSend(repeater);
final commands = <String>[]; final commands = <String>[];
// Build set commands for each setting // Build set commands for each setting
@@ -470,7 +486,18 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
// Send all commands // Send all commands
for (final command in commands) { for (final command in commands) {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command); final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
connector.trackRepeaterAck(
contact: repeater,
selection: selection,
text: command,
timestampSeconds: timestampSeconds,
);
final frame = buildSendCliCommandFrame(
repeater.publicKey,
command,
timestampSeconds: timestampSeconds,
);
await connector.sendFrame(frame); await connector.sendFrame(frame);
await Future.delayed(const Duration(milliseconds: 200)); // Delay between commands await Future.delayed(const Duration(milliseconds: 200)); // Delay between commands
} }
@@ -544,6 +571,10 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Column( title: Column(
@@ -552,13 +583,64 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
children: [ children: [
const Text('Repeater Settings'), const Text('Repeater Settings'),
Text( Text(
widget.repeater.name, repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
), ),
], ],
), ),
centerTitle: false, centerTitle: false,
actions: [ actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
if (mounted) {
setState(() {});
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
onPressed: () => PathManagementDialog.show(context, contact: repeater),
),
if (_hasChanges) if (_hasChanges)
TextButton.icon( TextButton.icon(
onPressed: _isLoading ? null : _saveSettings, onPressed: _isLoading ? null : _saveSettings,
@@ -995,6 +1077,7 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
Future<void> _sendDangerCommand(String command) async { Future<void> _sendDangerCommand(String command) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
if (command == 'erase') { if (command == 'erase') {
if (mounted) { if (mounted) {
@@ -1006,7 +1089,19 @@ class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
} }
try { try {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command); final selection = await connector.preparePathForContactSend(repeater);
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
connector.trackRepeaterAck(
contact: repeater,
selection: selection,
text: command,
timestampSeconds: timestampSeconds,
);
final frame = buildSendCliCommandFrame(
repeater.publicKey,
command,
timestampSeconds: timestampSeconds,
);
await connector.sendFrame(frame); await connector.sendFrame(frame);
if (mounted) { if (mounted) {
+100 -7
View File
@@ -4,9 +4,11 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../services/repeater_command_service.dart'; import '../services/repeater_command_service.dart';
import '../widgets/path_management_dialog.dart';
class RepeaterStatusScreen extends StatefulWidget { class RepeaterStatusScreen extends StatefulWidget {
final Contact repeater; final Contact repeater;
@@ -23,6 +25,10 @@ class RepeaterStatusScreen extends StatefulWidget {
} }
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> { class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
static const int _statusPayloadOffset = 8;
static const int _statusStatsSize = 52;
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize;
bool _isLoading = false; bool _isLoading = false;
StreamSubscription<Uint8List>? _frameSubscription; StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService; RepeaterCommandService? _commandService;
@@ -45,6 +51,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
int? _directRx; int? _directRx;
int? _dupFlood; int? _dupFlood;
int? _dupDirect; int? _dupDirect;
PathSelection? _pendingStatusSelection;
@override @override
void initState() { void initState() {
@@ -80,6 +87,13 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
}); });
} }
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
void _handleTextMessageResponse(Uint8List frame) { void _handleTextMessageResponse(Uint8List frame) {
final parsed = parseContactMessageText(frame); final parsed = parseContactMessageText(frame);
if (parsed == null) return; if (parsed == null) return;
@@ -90,6 +104,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
// Parse status responses // Parse status responses
_parseStatusResponse(parsed.text); _parseStatusResponse(parsed.text);
_recordStatusResult(true);
} }
void _handleStatusResponse(Uint8List frame) { void _handleStatusResponse(Uint8List frame) {
@@ -97,11 +112,13 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final prefix = frame.sublist(2, 8); final prefix = frame.sublist(2, 8);
if (!_matchesRepeaterPrefix(prefix)) return; if (!_matchesRepeaterPrefix(prefix)) return;
const payloadOffset = 8; if (frame.length < _statusResponseBytes) return;
const statsSize = 52;
if (frame.length < payloadOffset + statsSize) return;
final data = ByteData.sublistView(frame, payloadOffset, payloadOffset + statsSize); final data = ByteData.sublistView(
frame,
_statusPayloadOffset,
_statusResponseBytes,
);
int offset = 0; int offset = 0;
final batteryMv = data.getUint16(offset, Endian.little); final batteryMv = data.getUint16(offset, Endian.little);
@@ -160,6 +177,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
_dupDirect = directDups; _dupDirect = directDups;
_dupFlood = floodDups; _dupFlood = floodDups;
}); });
_recordStatusResult(true);
} }
bool _matchesRepeaterPrefix(Uint8List prefix) { bool _matchesRepeaterPrefix(Uint8List prefix) {
@@ -213,6 +231,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_statusRequestedAt = DateTime.now(); _statusRequestedAt = DateTime.now();
_pendingStatusSelection = null;
_batteryMv = null; _batteryMv = null;
_uptimeSecs = null; _uptimeSecs = null;
_queueLen = null; _queueLen = null;
@@ -234,11 +253,22 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
try { try {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildSendStatusRequestFrame(widget.repeater.publicKey); final repeater = _resolveRepeater(connector);
final selection = await connector.preparePathForContactSend(repeater);
_pendingStatusSelection = selection;
final frame = buildSendStatusRequestFrame(repeater.publicKey);
await connector.sendFrame(frame); await connector.sendFrame(frame);
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
final messageBytes = frame.length >= _statusResponseBytes
? frame.length
: _statusResponseBytes;
final timeoutMs = connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: messageBytes,
);
_statusTimeout?.cancel(); _statusTimeout?.cancel();
_statusTimeout = Timer(const Duration(seconds: 12), () { _statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_isLoading = false; _isLoading = false;
@@ -249,6 +279,7 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
_recordStatusResult(false);
}); });
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -263,11 +294,25 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
), ),
); );
} }
_recordStatusResult(false);
} }
} }
void _recordStatusResult(bool success) {
final selection = _pendingStatusSelection;
if (selection == null) return;
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
connector.recordRepeaterPathResult(repeater, selection, success, null);
_pendingStatusSelection = null;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Column( title: Column(
@@ -276,13 +321,61 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [ children: [
const Text('Repeater Status'), const Text('Repeater Status'),
Text( Text(
widget.repeater.name, repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
), ),
], ],
), ),
centerTitle: false, centerTitle: false,
actions: [ actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: 'Path management',
onPressed: () => PathManagementDialog.show(context, contact: repeater),
),
IconButton( IconButton(
icon: _isLoading icon: _isLoading
? const SizedBox( ? const SizedBox(
+140 -18
View File
@@ -1,15 +1,38 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../models/radio_settings.dart'; import '../models/radio_settings.dart';
import 'app_settings_screen.dart'; import 'app_settings_screen.dart';
import 'app_debug_log_screen.dart';
import 'ble_debug_log_screen.dart'; import 'ble_debug_log_screen.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _showBatteryVoltage = false;
String _appVersion = '...';
@override
void initState() {
super.initState();
_loadVersionInfo();
}
Future<void> _loadVersionInfo() async {
final packageInfo = await PackageInfo.fromPlatform();
setState(() {
_appVersion = packageInfo.version;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -58,6 +81,7 @@ class SettingsScreen extends StatelessWidget {
_buildInfoRow('Name', connector.deviceDisplayName), _buildInfoRow('Name', connector.deviceDisplayName),
_buildInfoRow('ID', connector.deviceIdLabel), _buildInfoRow('ID', connector.deviceIdLabel),
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'), _buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
_buildBatteryInfoRow(connector),
if (connector.selfName != null) if (connector.selfName != null)
_buildInfoRow('Node Name', connector.selfName!), _buildInfoRow('Node Name', connector.selfName!),
if (connector.selfPublicKey != null) if (connector.selfPublicKey != null)
@@ -70,6 +94,53 @@ class SettingsScreen extends StatelessWidget {
); );
} }
Widget _buildBatteryInfoRow(MeshCoreConnector connector) {
final percent = connector.batteryPercent;
final millivolts = connector.batteryMillivolts;
// figure out display value
final String displayValue;
if (millivolts == null) {
displayValue = '';
} else if (_showBatteryVoltage) {
displayValue = '${(millivolts / 1000.0).toStringAsFixed(2)} V';
} else {
displayValue = percent != null ? '$percent%' : '';
}
final IconData icon;
final Color? iconColor;
final Color? valueColor;
if (percent == null) {
icon = Icons.battery_unknown;
iconColor = Colors.grey;
valueColor = null;
} else if (percent <= 15) {
icon = Icons.battery_alert;
iconColor = Colors.orange;
valueColor = Colors.orange;
} else {
icon = Icons.battery_full;
iconColor = null;
valueColor = null;
}
return _buildInfoRow(
'Battery',
displayValue,
leading: Icon(icon, size: 18, color: iconColor),
valueColor: valueColor,
onTap: millivolts != null
? () {
setState(() {
_showBatteryVoltage = !_showBatteryVoltage;
});
}
: null,
);
}
Widget _buildAppSettingsCard(BuildContext context) { Widget _buildAppSettingsCard(BuildContext context) {
return Card( return Card(
child: ListTile( child: ListTile(
@@ -184,7 +255,7 @@ class SettingsScreen extends StatelessWidget {
child: ListTile( child: ListTile(
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.info_outline),
title: const Text('About'), title: const Text('About'),
subtitle: const Text('MeshCore Open v0.1.0'), subtitle: Text('MeshCore Open v$_appVersion'),
onTap: () => _showAbout(context), onTap: () => _showAbout(context),
), ),
); );
@@ -192,38 +263,89 @@ class SettingsScreen extends StatelessWidget {
Widget _buildDebugCard(BuildContext context) { Widget _buildDebugCard(BuildContext context) {
return Card( return Card(
child: ListTile( child: Column(
leading: const Icon(Icons.bug_report_outlined), crossAxisAlignment: CrossAxisAlignment.start,
title: const Text('BLE Debug Log'), children: [
subtitle: const Text('Commands, responses, and status'), const Padding(
trailing: const Icon(Icons.chevron_right), padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
onTap: () { child: Text(
Navigator.push( 'Debug',
context, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()), ),
); ),
}, ListTile(
leading: const Icon(Icons.bluetooth_outlined),
title: const Text('BLE Debug Log'),
subtitle: const Text('BLE commands, responses, and raw data'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()),
);
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.code_outlined),
title: const Text('App Debug Log'),
subtitle: const Text('Application debug messages'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AppDebugLogScreen()),
);
},
),
],
), ),
); );
} }
Widget _buildInfoRow(String label, String value) { Widget _buildInfoRow(
return Padding( String label,
String value, {
Widget? leading,
Color? valueColor,
VoidCallback? onTap,
}) {
final row = Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(label, style: TextStyle(color: Colors.grey[600])), Row(
children: [
if (leading != null) ...[
leading,
const SizedBox(width: 8),
],
Text(label, style: TextStyle(color: Colors.grey[600])),
],
),
Flexible( Flexible(
child: Text( child: Text(
value, value,
style: const TextStyle(fontWeight: FontWeight.w500), style: TextStyle(
fontWeight: FontWeight.w500,
color: valueColor,
),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
), ),
); );
if (onTap != null) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4),
child: row,
);
}
return row;
} }
void _editNodeName(BuildContext context, MeshCoreConnector connector) { void _editNodeName(BuildContext context, MeshCoreConnector connector) {
@@ -426,7 +548,7 @@ class SettingsScreen extends StatelessWidget {
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: 'MeshCore Open', applicationName: 'MeshCore Open',
applicationVersion: '0.1.0', applicationVersion: _appVersion,
applicationLegalese: '2024 MeshCore Open Source Project', applicationLegalese: '2024 MeshCore Open Source Project',
children: [ children: [
const SizedBox(height: 16), const SizedBox(height: 16),
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/foundation.dart';
enum AppDebugLogLevel {
info,
warning,
error,
}
class AppDebugLogEntry {
final DateTime timestamp;
final AppDebugLogLevel level;
final String tag;
final String message;
AppDebugLogEntry({
required this.timestamp,
required this.level,
required this.tag,
required this.message,
});
String get levelLabel {
switch (level) {
case AppDebugLogLevel.info:
return 'INFO';
case AppDebugLogLevel.warning:
return 'WARN';
case AppDebugLogLevel.error:
return 'ERROR';
}
}
String get formattedTime {
return '${timestamp.hour.toString().padLeft(2, '0')}:'
'${timestamp.minute.toString().padLeft(2, '0')}:'
'${timestamp.second.toString().padLeft(2, '0')}.'
'${timestamp.millisecond.toString().padLeft(3, '0')}';
}
}
class AppDebugLogService extends ChangeNotifier {
static const int maxEntries = 1000;
final List<AppDebugLogEntry> _entries = [];
bool _enabled = false;
List<AppDebugLogEntry> get entries => List.unmodifiable(_entries);
bool get enabled => _enabled;
void setEnabled(bool value) {
_enabled = value;
notifyListeners();
}
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
if (!_enabled) return;
_entries.add(
AppDebugLogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
),
);
if (_entries.length > maxEntries) {
_entries.removeRange(0, _entries.length - maxEntries);
}
notifyListeners();
// Also print to console for development
debugPrint('[$tag] $message');
}
void info(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.info);
}
void warn(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.warning);
}
void error(String message, {String tag = 'App'}) {
log(message, tag: tag, level: AppDebugLogLevel.error);
}
void clear() {
_entries.clear();
notifyListeners();
}
}
+7
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/app_settings.dart'; import '../models/app_settings.dart';
import '../storage/prefs_manager.dart'; import '../storage/prefs_manager.dart';
import '../utils/app_logger.dart';
class AppSettingsService extends ChangeNotifier { class AppSettingsService extends ChangeNotifier {
static const String _settingsKey = 'app_settings'; static const String _settingsKey = 'app_settings';
@@ -112,6 +113,12 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(themeMode: value)); await updateSettings(_settings.copyWith(themeMode: value));
} }
Future<void> setAppDebugLogEnabled(bool value) async {
await updateSettings(_settings.copyWith(appDebugLogEnabled: value));
// Update the global logger
appLogger.setEnabled(value);
}
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async { Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId); final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
updated[deviceId] = chemistry; updated[deviceId] = chemistry;
+160 -31
View File
@@ -1,11 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:crypto/crypto.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import 'storage_service.dart'; import 'storage_service.dart';
import 'app_settings_service.dart'; import 'app_settings_service.dart';
import 'app_debug_log_service.dart';
class _AckHistoryEntry { class _AckHistoryEntry {
final String messageId; final String messageId;
@@ -41,7 +44,8 @@ class MessageRetryService extends ChangeNotifier {
final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex messageId + timestamp for O(1) lookup final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex messageId + timestamp for O(1) lookup
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history) final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history)
final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact = {}; // contactPubKeyHex FIFO queue of messageIds final Map<String, List<String>> _pendingMessageQueuePerContact = {}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, String> _expectedHashToMessageId = {}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
Function(Contact, String, int, int)? _sendMessageCallback; Function(Contact, String, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback; Function(String, Message)? _addMessageCallback;
@@ -49,7 +53,10 @@ class MessageRetryService extends ChangeNotifier {
Function(Contact)? _clearContactPathCallback; Function(Contact)? _clearContactPathCallback;
Function(Contact, Uint8List, int)? _setContactPathCallback; Function(Contact, Uint8List, int)? _setContactPathCallback;
Function(int, int)? _calculateTimeoutCallback; Function(int, int)? _calculateTimeoutCallback;
Uint8List? Function()? _getSelfPublicKeyCallback;
String Function(Contact, String)? _prepareContactOutboundTextCallback;
AppSettingsService? _appSettingsService; AppSettingsService? _appSettingsService;
AppDebugLogService? _debugLogService;
Function(String, PathSelection, bool, int?)? _recordPathResultCallback; Function(String, PathSelection, bool, int?)? _recordPathResultCallback;
MessageRetryService(this._storage); MessageRetryService(this._storage);
@@ -61,7 +68,10 @@ class MessageRetryService extends ChangeNotifier {
Function(Contact)? clearContactPathCallback, Function(Contact)? clearContactPathCallback,
Function(Contact, Uint8List, int)? setContactPathCallback, Function(Contact, Uint8List, int)? setContactPathCallback,
Function(int pathLength, int messageBytes)? calculateTimeoutCallback, Function(int pathLength, int messageBytes)? calculateTimeoutCallback,
Uint8List? Function()? getSelfPublicKeyCallback,
String Function(Contact, String)? prepareContactOutboundTextCallback,
AppSettingsService? appSettingsService, AppSettingsService? appSettingsService,
AppDebugLogService? debugLogService,
Function(String, PathSelection, bool, int?)? recordPathResultCallback, Function(String, PathSelection, bool, int?)? recordPathResultCallback,
}) { }) {
_sendMessageCallback = sendMessageCallback; _sendMessageCallback = sendMessageCallback;
@@ -70,10 +80,46 @@ class MessageRetryService extends ChangeNotifier {
_clearContactPathCallback = clearContactPathCallback; _clearContactPathCallback = clearContactPathCallback;
_setContactPathCallback = setContactPathCallback; _setContactPathCallback = setContactPathCallback;
_calculateTimeoutCallback = calculateTimeoutCallback; _calculateTimeoutCallback = calculateTimeoutCallback;
_getSelfPublicKeyCallback = getSelfPublicKeyCallback;
_prepareContactOutboundTextCallback = prepareContactOutboundTextCallback;
_appSettingsService = appSettingsService; _appSettingsService = appSettingsService;
_debugLogService = debugLogService;
_recordPathResultCallback = recordPathResultCallback; _recordPathResultCallback = recordPathResultCallback;
} }
/// Compute expected ACK hash using same algorithm as firmware:
/// SHA256([timestamp(4)][attempt(1)][text][sender_pubkey(32)]) -> first 4 bytes
static Uint8List computeExpectedAckHash(
int timestampSeconds,
int attempt,
String text,
Uint8List senderPubKey,
) {
final textBytes = utf8.encode(text);
final buffer = Uint8List(4 + 1 + textBytes.length + senderPubKey.length);
int offset = 0;
// timestamp (4 bytes, little-endian)
buffer[offset++] = timestampSeconds & 0xFF;
buffer[offset++] = (timestampSeconds >> 8) & 0xFF;
buffer[offset++] = (timestampSeconds >> 16) & 0xFF;
buffer[offset++] = (timestampSeconds >> 24) & 0xFF;
// attempt (1 byte)
buffer[offset++] = attempt & 0x03;
// text
buffer.setRange(offset, offset + textBytes.length, textBytes);
offset += textBytes.length;
// sender public key (32 bytes)
buffer.setRange(offset, offset + senderPubKey.length, senderPubKey);
// Compute SHA256 and return first 4 bytes
final hash = sha256.convert(buffer);
return Uint8List.fromList(hash.bytes.sublist(0, 4));
}
Future<void> sendMessageWithRetry({ Future<void> sendMessageWithRetry({
required Contact contact, required Contact contact,
required String text, required String text,
@@ -136,14 +182,35 @@ class MessageRetryService extends ChangeNotifier {
} }
final attempt = message.retryCount.clamp(0, 3); final attempt = message.retryCount.clamp(0, 3);
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
// Enqueue this message to track send order for ACK hash mapping (FIFO) // Compute expected ACK hash that device will return in RESP_CODE_SENT
// IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
final selfPubKey = _getSelfPublicKeyCallback?.call();
if (selfPubKey != null) {
final outboundText = _prepareContactOutboundTextCallback?.call(contact, message.text) ?? message.text;
final expectedHash = MessageRetryService.computeExpectedAckHash(
timestampSeconds,
attempt,
outboundText,
selfPubKey,
);
final expectedHashHex = expectedHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
_expectedHashToMessageId[expectedHashHex] = messageId;
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
_debugLogService?.info(
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
tag: 'AckHash',
);
debugPrint('Computed expected ACK hash $expectedHashHex for message $messageId');
}
// DEPRECATED: Old queue-based matching (kept for fallback)
_pendingMessageQueuePerContact[contact.publicKeyHex] ??= []; _pendingMessageQueuePerContact[contact.publicKeyHex] ??= [];
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId); _pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
debugPrint('Enqueued message $messageId for ${contact.name} (queue size: ${_pendingMessageQueuePerContact[contact.publicKeyHex]!.length})');
if (_sendMessageCallback != null) { if (_sendMessageCallback != null) {
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
_sendMessageCallback!( _sendMessageCallback!(
contact, contact,
message.text, message.text,
@@ -156,35 +223,68 @@ class MessageRetryService extends ChangeNotifier {
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
// Dequeue the next message from the FIFO queue to match with this RESP_CODE_SENT // NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
// We iterate through contacts to find which one has a pending message in their queue String? messageId = _expectedHashToMessageId.remove(ackHashHex);
String? messageId;
Contact? contact; Contact? contact;
for (var entry in _pendingMessageQueuePerContact.entries) { if (messageId != null) {
final contactKey = entry.key; contact = _pendingContacts[messageId];
final queue = entry.value; final message = _pendingMessages[messageId];
if (queue.isNotEmpty) { if (contact != null && message != null) {
// Dequeue the first (oldest) message from this contact's queue final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
final candidateMessageId = queue.removeAt(0); _debugLogService?.info(
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
tag: 'AckHash',
);
debugPrint('Hash-based match: ACK hash $ackHashHex → message $messageId');
// Verify this message is still pending // Remove from old queue since we matched
if (_pendingMessages.containsKey(candidateMessageId)) { _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
messageId = candidateMessageId; if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
contact = _pendingContacts[candidateMessageId]; _pendingMessageQueuePerContact.remove(contact.publicKeyHex);
debugPrint('Dequeued message $messageId for $contactKey (remaining in queue: ${queue.length})'); }
break; } else {
} else { _debugLogService?.warn(
debugPrint('Dequeued stale message $candidateMessageId - skipping'); 'RESP_CODE_SENT: ACK hash $ackHashHex matched but message no longer pending',
// Continue to next message in queue tag: 'AckHash',
if (queue.isNotEmpty) { );
final nextMessageId = queue.removeAt(0); debugPrint('Hash matched $messageId but message no longer pending');
if (_pendingMessages.containsKey(nextMessageId)) { messageId = null;
messageId = nextMessageId; contact = null;
contact = _pendingContacts[nextMessageId]; }
debugPrint('Dequeued next message $messageId for $contactKey (remaining: ${queue.length})'); }
break;
// FALLBACK: Old queue-based matching (for messages sent before hash computation was added)
if (messageId == null) {
_debugLogService?.warn(
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
tag: 'AckHash',
);
debugPrint('Hash-based match failed for $ackHashHex, falling back to queue-based matching');
for (var entry in _pendingMessageQueuePerContact.entries) {
final contactKey = entry.key;
final queue = entry.value;
if (queue.isNotEmpty) {
final candidateMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId];
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey');
break;
} else {
debugPrint('Dequeued stale message $candidateMessageId - skipping');
if (queue.isNotEmpty) {
final nextMessageId = queue.removeAt(0);
if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId;
contact = _pendingContacts[nextMessageId];
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId');
break;
}
} }
} }
} }
@@ -192,7 +292,7 @@ class MessageRetryService extends ChangeNotifier {
} }
if (messageId == null || contact == null) { if (messageId == null || contact == null) {
debugPrint('No pending message found for ACK hash: $ackHashHex (all queues empty or stale)'); debugPrint('No pending message found for ACK hash: $ackHashHex');
return; return;
} }
@@ -270,6 +370,11 @@ class MessageRetryService extends ChangeNotifier {
return; return;
} }
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
_debugLogService?.warn(
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
tag: 'AckHash',
);
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})'); debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})');
if (message.retryCount < maxRetries - 1) { if (message.retryCount < maxRetries - 1) {
@@ -287,8 +392,14 @@ class MessageRetryService extends ChangeNotifier {
_updateMessageCallback!(updatedMessage); _updateMessageCallback!(updatedMessage);
} }
_debugLogService?.info(
'Scheduling retry for "$shortText" to ${contact.name} after ${backoffMs}ms backoff',
tag: 'AckHash',
);
debugPrint('Scheduling retry after ${backoffMs}ms'); debugPrint('Scheduling retry after ${backoffMs}ms');
Timer(Duration(milliseconds: backoffMs), () {
// Store the backoff timer so it can be canceled if new RESP_CODE_SENT arrives
_timeoutTimers[messageId] = Timer(Duration(milliseconds: backoffMs), () {
// Double-check message is still pending before retry // Double-check message is still pending before retry
if (_pendingMessages.containsKey(messageId)) { if (_pendingMessages.containsKey(messageId)) {
_attemptSend(messageId); _attemptSend(messageId);
@@ -388,6 +499,10 @@ class MessageRetryService extends ChangeNotifier {
matchedMessageId = mapping.messageId; matchedMessageId = mapping.messageId;
debugPrint('Matched ACK to message via direct lookup: $matchedMessageId'); debugPrint('Matched ACK to message via direct lookup: $matchedMessageId');
} else { } else {
_debugLogService?.warn(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex not found in direct mapping, trying fallback',
tag: 'AckHash',
);
// Fallback: Check against ALL expected ACK hashes (from all retry attempts) // Fallback: Check against ALL expected ACK hashes (from all retry attempts)
debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)'); debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)');
for (var entry in _expectedAckHashes.entries) { for (var entry in _expectedAckHashes.entries) {
@@ -411,6 +526,12 @@ class MessageRetryService extends ChangeNotifier {
final contact = _pendingContacts[matchedMessageId]; final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId]; final selection = _pendingPathSelections[matchedMessageId];
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text;
_debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
tag: 'AckHash',
);
// Cancel any pending timeout or retry // Cancel any pending timeout or retry
_timeoutTimers[matchedMessageId]?.cancel(); _timeoutTimers[matchedMessageId]?.cancel();
_timeoutTimers.remove(matchedMessageId); _timeoutTimers.remove(matchedMessageId);
@@ -448,8 +569,16 @@ class MessageRetryService extends ChangeNotifier {
} else { } else {
// Check ACK history for recently completed messages // Check ACK history for recently completed messages
if (_checkAckHistory(ackHash)) { if (_checkAckHistory(ackHash)) {
_debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex matched a recently completed message (duplicate ACK)',
tag: 'AckHash',
);
debugPrint('ACK matched a recently completed message from history'); debugPrint('ACK matched a recently completed message from history');
} else { } else {
_debugLogService?.error(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex has no matching message!',
tag: 'AckHash',
);
debugPrint('No matching message found for ACK: $ackHashHex'); debugPrint('No matching message found for ACK: $ackHashHex');
} }
} }
+63 -22
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_selection.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
@@ -11,7 +12,6 @@ class RepeaterCommandService {
final Map<String, String> _pendingByPrefix = {}; final Map<String, String> _pendingByPrefix = {};
int _prefixCounter = 0; int _prefixCounter = 0;
static const int timeoutSeconds = 10; // Flood mode timeout
static const int maxRetries = 5; static const int maxRetries = 5;
RepeaterCommandService(this._connector); RepeaterCommandService(this._connector);
@@ -23,6 +23,7 @@ class RepeaterCommandService {
String command, { String command, {
Function(String)? onResponse, Function(String)? onResponse,
Function(int)? onAttempt, Function(int)? onAttempt,
int retries = maxRetries,
}) async { }) async {
final repeaterKey = repeater.publicKeyHex; final repeaterKey = repeater.publicKeyHex;
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey)); final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
@@ -30,43 +31,83 @@ class RepeaterCommandService {
throw Exception('Another command is still awaiting a response.'); throw Exception('Another command is still awaiting a response.');
} }
// Create completer for this command final attemptCount = retries < 1 ? 1 : retries;
final selection = await _connector.preparePathForContactSend(repeater);
for (int attempt = 0; attempt < attemptCount; attempt++) {
onAttempt?.call(attempt + 1);
try {
final response = await _sendCommandAttempt(
repeater,
command,
selection,
attempt,
);
onResponse?.call(response);
return response;
} catch (e) {
if (attempt == attemptCount - 1) rethrow;
}
}
throw Exception('Command failed after $attemptCount attempts');
}
Future<String> _sendCommandAttempt(
Contact repeater,
String command,
PathSelection selection,
int attempt,
) async {
final repeaterKey = repeater.publicKeyHex;
final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}'; final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}';
final completer = Completer<String>(); final completer = Completer<String>();
_pendingCommands[commandId] = completer; _pendingCommands[commandId] = completer;
onAttempt?.call(0);
// Send frame once (no retries)
try { try {
final prefix = _nextPrefixToken(); final prefix = _nextPrefixToken();
_commandPrefixes[commandId] = prefix; _commandPrefixes[commandId] = prefix;
_pendingByPrefix[prefix] = commandId; _pendingByPrefix[prefix] = commandId;
final framedCommand = '$prefix$command'; final framedCommand = '$prefix$command';
final frame = buildSendCliCommandFrame(repeater.publicKey, framedCommand, attempt: 0); final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
final timeoutMs = _connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: framedCommand.length,
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
_connector.trackRepeaterAck(
contact: repeater,
selection: selection,
text: framedCommand,
timestampSeconds: timestampSeconds,
attempt: attempt,
);
final frame = buildSendCliCommandFrame(
repeater.publicKey,
framedCommand,
attempt: attempt,
timestampSeconds: timestampSeconds,
);
await _connector.sendFrame(frame); await _connector.sendFrame(frame);
_commandTimeouts[commandId]?.cancel();
_commandTimeouts[commandId] = Timer(
Duration(milliseconds: timeoutMs),
() {
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.completeError('Command timeout after $timeoutSeconds seconds');
_cleanup(commandId);
}
},
);
} catch (e) { } catch (e) {
_cleanup(commandId); _cleanup(commandId);
throw Exception('Failed to send command: $e'); throw Exception('Failed to send command: $e');
} }
// Set timeout for this attempt
_commandTimeouts[commandId]?.cancel();
_commandTimeouts[commandId] = Timer(
Duration(seconds: timeoutSeconds),
() {
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.completeError('Command timeout after $timeoutSeconds seconds');
_cleanup(commandId);
}
},
);
// Wait for response or timeout
try { try {
final response = await completer.future; return await completer.future;
return response;
} finally { } finally {
_cleanup(commandId); _cleanup(commandId);
} }
+55
View File
@@ -0,0 +1,55 @@
import '../services/app_debug_log_service.dart';
/// Global app logger instance
/// Usage: appLogger.info('Message', tag: 'MyClass');
class AppLogger {
AppDebugLogService? _service;
bool _enabled = false;
/// Initialize the logger with the debug log service
void initialize(AppDebugLogService service, {bool enabled = false}) {
_service = service;
_enabled = enabled;
_service?.setEnabled(enabled);
}
/// Update whether logging is enabled
void setEnabled(bool enabled) {
_enabled = enabled;
_service?.setEnabled(enabled);
}
/// Check if logging is currently enabled
bool get isEnabled => _enabled;
/// Log an info message
void info(String message, {String tag = 'App'}) {
if (_enabled && _service != null) {
_service!.info(message, tag: tag);
}
}
/// Log a warning message
void warn(String message, {String tag = 'App'}) {
if (_enabled && _service != null) {
_service!.warn(message, tag: tag);
}
}
/// Log an error message
void error(String message, {String tag = 'App'}) {
if (_enabled && _service != null) {
_service!.error(message, tag: tag);
}
}
/// Log a message with custom level
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) {
if (_enabled && _service != null) {
_service!.log(message, tag: tag, level: level);
}
}
}
/// Global logger instance
final appLogger = AppLogger();
+89
View File
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import '../connector/meshcore_connector.dart';
class BatteryUi {
final IconData icon;
final Color? color;
const BatteryUi(this.icon, this.color);
}
BatteryUi batteryUiForPercent(int? percent) {
if (percent == null) {
return const BatteryUi(Icons.battery_unknown, Colors.grey);
}
final p = percent.clamp(0, 100);
return switch (p) {
<= 5 => const BatteryUi(Icons.battery_alert, Colors.redAccent),
<= 15 => const BatteryUi(Icons.battery_0_bar, Colors.redAccent),
<= 30 => const BatteryUi(Icons.battery_1_bar, Colors.orange),
<= 45 => const BatteryUi(Icons.battery_2_bar, Colors.amber),
<= 60 => const BatteryUi(Icons.battery_3_bar, Colors.lightGreen),
<= 80 => const BatteryUi(Icons.battery_5_bar, Colors.green),
_ => const BatteryUi(Icons.battery_full, Colors.green),
};
}
class BatteryIndicator extends StatefulWidget {
final MeshCoreConnector connector;
const BatteryIndicator({
super.key,
required this.connector,
});
@override
State<BatteryIndicator> createState() => _BatteryIndicatorState();
}
class _BatteryIndicatorState extends State<BatteryIndicator> {
bool _showBatteryVoltage = false;
@override
Widget build(BuildContext context) {
final percent = widget.connector.batteryPercent;
final millivolts = widget.connector.batteryMillivolts;
if (millivolts == null) {
return const SizedBox.shrink();
}
final String displayText;
if (_showBatteryVoltage) {
displayText = '${(millivolts / 1000.0).toStringAsFixed(2)}V';
} else {
displayText = percent != null ? '$percent%' : '';
}
final batteryUi = batteryUiForPercent(percent);
return InkWell(
onTap: () {
setState(() {
_showBatteryVoltage = !_showBatteryVoltage;
});
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(batteryUi.icon, size: 20, color: batteryUi.color),
const SizedBox(width: 4),
Text(
displayText,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: batteryUi.color,
),
),
],
),
),
);
}
}
+224
View File
@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
enum ContactSortOption {
lastSeen,
recentMessages,
name,
}
enum ContactTypeFilter {
all,
users,
repeaters,
rooms,
}
class SortFilterMenuOption {
final int value;
final String label;
final bool? checked;
const SortFilterMenuOption({
required this.value,
required this.label,
this.checked,
});
}
class SortFilterMenuSection {
final String title;
final List<SortFilterMenuOption> options;
const SortFilterMenuSection({
required this.title,
required this.options,
});
}
class SortFilterMenu extends StatelessWidget {
final List<SortFilterMenuSection> sections;
final ValueChanged<int> onSelected;
final String tooltip;
final Widget icon;
const SortFilterMenu({
super.key,
required this.sections,
required this.onSelected,
this.tooltip = 'Filter and sort',
this.icon = const Icon(Icons.filter_list_outlined),
});
@override
Widget build(BuildContext context) {
return PopupMenuButton<int>(
icon: icon,
tooltip: tooltip,
onSelected: onSelected,
itemBuilder: (context) {
final theme = Theme.of(context);
final labelStyle = theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
final visibleSections = sections.where((section) => section.options.isNotEmpty).toList();
final entries = <PopupMenuEntry<int>>[];
for (int i = 0; i < visibleSections.length; i++) {
final section = visibleSections[i];
entries.add(
PopupMenuItem<int>(
enabled: false,
child: Text(section.title, style: labelStyle),
),
);
for (final option in section.options) {
if (option.checked == null) {
entries.add(
PopupMenuItem<int>(
value: option.value,
child: Text(option.label),
),
);
} else {
entries.add(
CheckedPopupMenuItem<int>(
value: option.value,
checked: option.checked ?? false,
child: Text(option.label),
),
);
}
}
if (i < visibleSections.length - 1) {
entries.add(const PopupMenuDivider());
}
}
return entries;
},
);
}
}
const int _actionSortRecentMessages = 1;
const int _actionSortName = 2;
const int _actionSortLastSeen = 3;
const int _actionFilterAll = 4;
const int _actionFilterUsers = 5;
const int _actionFilterRepeaters = 6;
const int _actionFilterRooms = 7;
const int _actionToggleUnreadOnly = 8;
const int _actionNewGroup = 9;
class ContactsFilterMenu extends StatelessWidget {
final ContactSortOption sortOption;
final ContactTypeFilter typeFilter;
final bool showUnreadOnly;
final ValueChanged<ContactSortOption> onSortChanged;
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
final ValueChanged<bool> onUnreadOnlyChanged;
final VoidCallback onNewGroup;
const ContactsFilterMenu({
super.key,
required this.sortOption,
required this.typeFilter,
required this.showUnreadOnly,
required this.onSortChanged,
required this.onTypeFilterChanged,
required this.onUnreadOnlyChanged,
required this.onNewGroup,
});
@override
Widget build(BuildContext context) {
return SortFilterMenu(
sections: [
SortFilterMenuSection(
title: 'Sort by',
options: [
SortFilterMenuOption(
value: _actionSortRecentMessages,
label: 'Latest messages',
checked: sortOption == ContactSortOption.recentMessages,
),
SortFilterMenuOption(
value: _actionSortLastSeen,
label: 'Heard recently',
checked: sortOption == ContactSortOption.lastSeen,
),
SortFilterMenuOption(
value: _actionSortName,
label: 'A-Z',
checked: sortOption == ContactSortOption.name,
),
],
),
SortFilterMenuSection(
title: 'Filters',
options: [
SortFilterMenuOption(
value: _actionFilterAll,
label: 'All',
checked: typeFilter == ContactTypeFilter.all,
),
SortFilterMenuOption(
value: _actionFilterUsers,
label: 'Users',
checked: typeFilter == ContactTypeFilter.users,
),
SortFilterMenuOption(
value: _actionFilterRepeaters,
label: 'Repeaters',
checked: typeFilter == ContactTypeFilter.repeaters,
),
SortFilterMenuOption(
value: _actionFilterRooms,
label: 'Room servers',
checked: typeFilter == ContactTypeFilter.rooms,
),
SortFilterMenuOption(
value: _actionToggleUnreadOnly,
label: 'Unread only',
checked: showUnreadOnly,
),
const SortFilterMenuOption(
value: _actionNewGroup,
label: 'New group',
),
],
),
],
onSelected: (action) {
switch (action) {
case _actionSortRecentMessages:
onSortChanged(ContactSortOption.recentMessages);
break;
case _actionSortName:
onSortChanged(ContactSortOption.name);
break;
case _actionSortLastSeen:
onSortChanged(ContactSortOption.lastSeen);
break;
case _actionFilterAll:
onTypeFilterChanged(ContactTypeFilter.all);
break;
case _actionFilterUsers:
onTypeFilterChanged(ContactTypeFilter.users);
break;
case _actionFilterRepeaters:
onTypeFilterChanged(ContactTypeFilter.repeaters);
break;
case _actionFilterRooms:
onTypeFilterChanged(ContactTypeFilter.rooms);
break;
case _actionToggleUnreadOnly:
onUnreadOnlyChanged(!showUnreadOnly);
break;
case _actionNewGroup:
onNewGroup();
break;
}
},
);
}
}
+312
View File
@@ -0,0 +1,312 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/contact.dart';
import '../services/path_history_service.dart';
import 'path_selection_dialog.dart';
class PathManagementDialog {
static Future<void> show(
BuildContext context, {
required Contact contact,
String title = 'Path Management',
}) {
return showDialog<void>(
context: context,
builder: (context) => _PathManagementDialog(
contact: contact,
title: title,
),
);
}
}
class _PathManagementDialog extends StatelessWidget {
final Contact contact;
final String title;
const _PathManagementDialog({
required this.contact,
required this.title,
});
Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == contact.publicKeyHex,
orElse: () => contact,
);
}
String _formatRelativeTime(DateTime time) {
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}h ago';
return '${diff.inDays}d ago';
}
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
if (pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'),
duration: Duration(seconds: 2),
),
);
return;
}
final formattedPath = pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Full Path'),
content: SelectableText(formattedPath),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Future<void> _setCustomPath(
BuildContext context,
MeshCoreConnector connector,
Contact currentContact,
) async {
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
connector.getContacts();
}
final pathForInput = currentContact.pathIdList;
final availableContacts = connector.contacts
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
.toList();
final result = await PathSelectionDialog.show(
context,
availableContacts: availableContacts,
initialPath: pathForInput.isEmpty ? null : pathForInput,
title: 'Set Custom Path',
currentPathLabel: currentContact.pathLabel,
onRefresh: connector.isConnected ? connector.getContacts : null,
);
if (result != null && context.mounted) {
await connector.setPathOverride(
currentContact,
pathLen: result.length,
pathBytes: result,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Path set: ${result.length} ${result.length == 1 ? "hop" : "hops"}'),
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
return Consumer2<MeshCoreConnector, PathHistoryService>(
builder: (context, connector, pathService, _) {
final currentContact = _resolveContact(connector);
final paths = pathService.getRecentPaths(currentContact.publicKeyHex);
return AlertDialog(
title: Text(title),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Current path: ${currentContact.pathLabel}',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 12),
if (paths.isNotEmpty) ...[
const Text(
'Recent ACK Paths (tap to use):',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
if (paths.length >= 100) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.amberAccent,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Path history is full. Remove entries to add new ones.',
style: TextStyle(fontSize: 12),
),
),
],
const SizedBox(height: 8),
...paths.map((path) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
child: Text(
'${path.hopCount}',
style: const TextStyle(fontSize: 12),
),
),
title: Text(
'${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'}',
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(path.timestamp)}${path.successCount} successes',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.close, size: 16),
tooltip: 'Remove path',
onPressed: () async {
await pathService.removePathRecord(
currentContact.publicKeyHex,
path.pathBytes,
);
},
),
path.wasFloodDiscovery
? const Icon(Icons.waves, size: 16, color: Colors.grey)
: const Icon(Icons.route, size: 16, color: Colors.grey),
],
),
onLongPress: () => _showFullPathDialog(context, path.pathBytes),
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path details not available yet. Try sending a message to refresh.'),
duration: Duration(seconds: 2),
),
);
return;
}
final pathBytes = Uint8List.fromList(path.pathBytes);
final pathLength = path.pathBytes.length;
await connector.setPathOverride(
currentContact,
pathLen: pathLength,
pathBytes: pathBytes,
);
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Using ${path.hopCount} ${path.hopCount == 1 ? 'hop' : 'hops'} path'),
duration: const Duration(seconds: 2),
),
);
},
),
);
}),
const Divider(),
] else ...[
const Text('No path history yet.\nSend a message to discover paths.'),
const Divider(),
],
const SizedBox(height: 8),
const Text(
'Path Actions:',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
const SizedBox(height: 8),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16),
),
title: const Text('Set Custom Path', style: TextStyle(fontSize: 14)),
subtitle: const Text('Manually specify routing path', style: TextStyle(fontSize: 11)),
onTap: () async {
await _setCustomPath(context, connector, currentContact);
},
),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: const Text('Clear Path', style: TextStyle(fontSize: 14)),
subtitle: const Text('Force rediscovery on next send', style: TextStyle(fontSize: 11)),
onTap: () async {
await connector.clearContactPath(currentContact);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path cleared. Next message will rediscover route.'),
duration: Duration(seconds: 2),
),
);
Navigator.pop(context);
},
),
ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: const Text('Force Flood Mode', style: TextStyle(fontSize: 14)),
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
onTap: () async {
await connector.setPathOverride(currentContact, pathLen: -1);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Flood mode enabled. Toggle back via routing icon in app bar.'),
duration: Duration(seconds: 2),
),
);
Navigator.pop(context);
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
);
},
);
}
}
+312
View File
@@ -0,0 +1,312 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../models/contact.dart';
class PathSelectionDialog extends StatefulWidget {
final List<Contact> availableContacts;
final String? initialPath;
final String title;
final String? currentPathLabel;
final VoidCallback? onRefresh;
const PathSelectionDialog({
super.key,
required this.availableContacts,
this.initialPath,
this.title = 'Enter Custom Path',
this.currentPathLabel,
this.onRefresh,
});
@override
State<PathSelectionDialog> createState() => _PathSelectionDialogState();
static Future<Uint8List?> show(
BuildContext context, {
required List<Contact> availableContacts,
String? initialPath,
String title = 'Enter Custom Path',
String? currentPathLabel,
VoidCallback? onRefresh,
}) {
return showDialog<Uint8List?>(
context: context,
builder: (context) => PathSelectionDialog(
availableContacts: availableContacts,
initialPath: initialPath,
title: title,
currentPathLabel: currentPathLabel,
onRefresh: onRefresh,
),
);
}
}
class _PathSelectionDialogState extends State<PathSelectionDialog> {
late TextEditingController _controller;
final List<Contact> _selectedContacts = [];
List<Contact> _validContacts = [];
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialPath ?? '');
_filterValidContacts();
}
@override
void didUpdateWidget(PathSelectionDialog oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.availableContacts != oldWidget.availableContacts) {
_filterValidContacts();
}
}
void _filterValidContacts() {
_validContacts = widget.availableContacts
.where((c) => c.type == 2 || c.type == 3)
.toList();
}
void _updateTextFromContacts() {
final pathParts = _selectedContacts.map((contact) {
if (contact.publicKeyHex.length >= 2) {
return contact.publicKeyHex.substring(0, 2);
}
return '';
}).where((s) => s.isNotEmpty).toList();
_controller.text = pathParts.join(',');
}
void _toggleContact(Contact contact) {
setState(() {
if (_selectedContacts.contains(contact)) {
_selectedContacts.remove(contact);
} else {
_selectedContacts.add(contact);
}
_updateTextFromContacts();
});
}
void _clearSelection() {
setState(() {
_selectedContacts.clear();
_controller.clear();
});
}
Future<void> _validateAndSubmit() async {
final path = _controller.text.trim().toUpperCase();
if (path.isEmpty) {
if (mounted) Navigator.pop(context);
return;
}
// Parse comma-separated hex prefixes
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
final pathBytesList = <int>[];
final invalidPrefixes = <String>[];
for (final id in pathIds) {
if (id.length < 2) {
invalidPrefixes.add(id);
continue;
}
final prefix = id.substring(0, 2);
try {
final byte = int.parse(prefix, radix: 16);
pathBytesList.add(byte);
} catch (e) {
invalidPrefixes.add(id);
}
}
if (!mounted) return;
// Show error for invalid prefixes
if (invalidPrefixes.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
),
);
return;
}
// Check max path length (64 hops)
if (pathBytesList.length > 64) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Path too long. Maximum 64 hops allowed.'),
duration: Duration(seconds: 3),
backgroundColor: Colors.red,
),
);
return;
}
if (pathBytesList.isNotEmpty && mounted) {
Navigator.pop(context, Uint8List.fromList(pathBytesList));
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.currentPathLabel != null) ...[
Row(
children: [
const Text(
'Current path',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
if (widget.onRefresh != null)
TextButton.icon(
onPressed: widget.onRefresh,
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Reload'),
),
],
),
Text(
widget.currentPathLabel!,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 16),
],
const Text(
'Enter 2-character hex prefixes for each hop, separated by commas.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
const SizedBox(height: 8),
const Text(
'Example: A1,F2,3C (each node uses first byte of its public key)',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 16),
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Path (hex prefixes)',
hintText: 'A1,F2,3C',
border: OutlineInputBorder(),
helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
),
textCapitalization: TextCapitalization.characters,
maxLength: 191, // 64 hops * 2 chars + 63 commas
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
Row(
children: [
const Text(
'Or select from contacts:',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
if (_selectedContacts.isNotEmpty)
TextButton(
onPressed: _clearSelection,
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
if (_validContacts.isEmpty) ...[
const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
children: [
Icon(Icons.info_outline, size: 48, color: Colors.grey),
SizedBox(height: 16),
Text(
'No repeaters or room servers found.',
style: TextStyle(fontSize: 14),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
Text(
'Custom paths require intermediate hops that can relay messages.',
style: TextStyle(fontSize: 12, color: Colors.grey),
textAlign: TextAlign.center,
),
],
),
),
),
] else ...[
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
shrinkWrap: true,
itemCount: _validContacts.length,
itemBuilder: (context, index) {
final contact = _validContacts[index];
final isSelected = _selectedContacts.contains(contact);
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: isSelected
? Colors.green
: (contact.type == 2 ? Colors.blue : Colors.purple),
child: Icon(
contact.type == 2 ? Icons.router : Icons.meeting_room,
size: 16,
color: Colors.white,
),
),
title: Text(contact.name, style: const TextStyle(fontSize: 14)),
subtitle: Text(
'${contact.typeLabel}${contact.publicKeyHex.substring(0, 2)}',
style: const TextStyle(fontSize: 10),
),
trailing: isSelected
? const Icon(Icons.check_circle, color: Colors.green)
: const Icon(Icons.add_circle_outline),
onTap: () => _toggleContact(contact),
);
},
),
),
],
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: _validateAndSubmit,
child: const Text('Set Path'),
),
],
);
}
}
+140 -9
View File
@@ -5,9 +5,10 @@ import 'package:flutter/foundation.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
import '../services/repeater_command_service.dart';
import '../connector/meshcore_connector.dart'; import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../utils/app_logger.dart';
import 'path_management_dialog.dart';
class RepeaterLoginDialog extends StatefulWidget { class RepeaterLoginDialog extends StatefulWidget {
final Contact repeater; final Contact repeater;
@@ -31,8 +32,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
bool _obscurePassword = true; bool _obscurePassword = true;
late MeshCoreConnector _connector; late MeshCoreConnector _connector;
int _currentAttempt = 0; int _currentAttempt = 0;
final int _maxAttempts = RepeaterCommandService.maxRetries; static const int _maxAttempts = 5;
static const int _loginTimeoutSeconds = 10;
@override @override
void initState() { void initState() {
@@ -65,6 +65,13 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
bool _isLoggingIn = false; bool _isLoggingIn = false;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
}
Future<void> _handleLogin() async { Future<void> _handleLogin() async {
if (_isLoggingIn) return; if (_isLoggingIn) return;
@@ -75,6 +82,26 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
try { try {
final password = _passwordController.text; final password = _passwordController.text;
final repeater = _resolveRepeater(_connector);
appLogger.info(
'Login started for ${repeater.name} (${repeater.publicKeyHex})',
tag: 'RepeaterLogin',
);
final selection = await _connector.preparePathForContactSend(repeater);
final loginFrame = buildSendLoginFrame(repeater.publicKey, password);
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
final timeoutMs = _connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: loginFrame.length,
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
final timeout = Duration(milliseconds: timeoutMs);
final selectionLabel =
selection.useFlood ? 'flood' : '${selection.hopCount} hops';
appLogger.info(
'Login routing: $selectionLabel',
tag: 'RepeaterLogin',
);
bool? loginResult; bool? loginResult;
for (int attempt = 0; attempt < _maxAttempts; attempt++) { for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return; if (!mounted) return;
@@ -82,17 +109,46 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
_currentAttempt = attempt + 1; _currentAttempt = attempt + 1;
}); });
appLogger.info(
'Sending login attempt ${attempt + 1}/$_maxAttempts',
tag: 'RepeaterLogin',
);
await _connector.sendFrame( await _connector.sendFrame(
buildSendLoginFrame(widget.repeater.publicKey, password), loginFrame,
); );
loginResult = await _awaitLoginResponse(); loginResult = await _awaitLoginResponse(timeout);
if (loginResult == true) { if (loginResult == true) {
appLogger.info(
'Login succeeded for ${repeater.name}',
tag: 'RepeaterLogin',
);
break; break;
} }
if (loginResult == false) { if (loginResult == false) {
appLogger.warn(
'Login failed for ${repeater.name}',
tag: 'RepeaterLogin',
);
throw Exception('Wrong password or node is unreachable'); throw Exception('Wrong password or node is unreachable');
} }
appLogger.warn(
'Login attempt ${attempt + 1} timed out after ${timeoutSeconds}s',
tag: 'RepeaterLogin',
);
}
if (loginResult == null) {
appLogger.warn(
'Login timed out for ${repeater.name}',
tag: 'RepeaterLogin',
);
}
if (loginResult == true) {
_connector.recordRepeaterPathResult(repeater, selection, true, null);
} else {
_connector.recordRepeaterPathResult(repeater, selection, false, null);
} }
if (loginResult != true) { if (loginResult != true) {
@@ -114,6 +170,11 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
Future.microtask(() => widget.onLogin(password)); Future.microtask(() => widget.onLogin(password));
} }
} catch (e) { } catch (e) {
final repeater = _resolveRepeater(_connector);
appLogger.warn(
'Login error for ${repeater.name}: $e',
tag: 'RepeaterLogin',
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_isLoggingIn = false; _isLoggingIn = false;
@@ -128,7 +189,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
} }
} }
Future<bool?> _awaitLoginResponse() async { Future<bool?> _awaitLoginResponse(Duration timeout) async {
final completer = Completer<bool?>(); final completer = Completer<bool?>();
Timer? timer; Timer? timer;
StreamSubscription<Uint8List>? subscription; StreamSubscription<Uint8List>? subscription;
@@ -147,7 +208,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
timer?.cancel(); timer?.cancel();
}); });
timer = Timer(const Duration(seconds: _loginTimeoutSeconds), () { timer = Timer(timeout, () {
if (!completer.isCompleted) { if (!completer.isCompleted) {
completer.complete(null); completer.complete(null);
subscription?.cancel(); subscription?.cancel();
@@ -162,6 +223,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return AlertDialog( return AlertDialog(
title: Row( title: Row(
children: [ children: [
@@ -173,7 +237,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
children: [ children: [
const Text('Repeater Login'), const Text('Repeater Login'),
Text( Text(
widget.repeater.name, repeater.name,
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
@@ -244,6 +308,73 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
), ),
const Divider(),
Row(
children: [
const Text(
'Routing',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: 'Routing mode',
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Auto (use saved path)',
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
'Force Flood Mode',
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
],
),
],
),
const SizedBox(height: 4),
Text(
repeater.pathLabel,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => PathManagementDialog.show(context, contact: repeater),
icon: const Icon(Icons.timeline, size: 18),
label: const Text('Manage Paths'),
),
),
], ],
), ),
actions: [ actions: [
@@ -268,7 +399,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text('Retries $_currentAttempt/$_maxAttempts'), Text('Attempt $_currentAttempt/$_maxAttempts'),
], ],
), ),
), ),
+2
View File
@@ -8,5 +8,7 @@
<true/> <true/>
<key>com.apple.security.network.server</key> <key>com.apple.security.network.server</key>
<true/> <true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
</dict> </dict>
</plist> </plist>
+2
View File
@@ -28,5 +28,7 @@
<string>MainMenu</string> <string>MainMenu</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>MeshCore needs Bluetooth to communicate with LoRa mesh devices</string>
</dict> </dict>
</plist> </plist>
+2
View File
@@ -4,5 +4,7 @@
<dict> <dict>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
</dict> </dict>
</plist> </plist>
+5 -5
View File
@@ -465,13 +465,13 @@ packages:
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
package_info_plus: package_info_plus:
dependency: transitive dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.0.0" version: "8.3.1"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -817,10 +817,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: wakelock_plus name: wakelock_plus
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228" sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.3.3"
wakelock_plus_platform_interface: wakelock_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
+2 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1 version: 0.2.0+2
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2
@@ -49,6 +49,7 @@ dependencies:
flutter_foreground_task: ^6.1.2 flutter_foreground_task: ^6.1.2
wakelock_plus: ^1.2.8 wakelock_plus: ^1.2.8
characters: ^1.4.0 characters: ^1.4.0
package_info_plus: ^8.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: