diff --git a/ios/Podfile b/ios/Podfile index 3c20425..69ed111 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -26,8 +26,6 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup target 'Runner' do - pod 'codec2', :path => '../third_party/codec2' - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 40f9b11..166c98f 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -14,6 +14,7 @@ import '../models/message.dart'; import '../models/path_selection.dart'; import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; +import '../services/app_debug_log_service.dart'; import '../services/ble_debug_log_service.dart'; import '../services/message_retry_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/message_store.dart'; import '../storage/unread_store.dart'; +import '../utils/app_logger.dart'; import 'meshcore_protocol.dart'; class MeshCoreUuids { @@ -106,6 +108,17 @@ class MeshCoreConnector extends ChangeNotifier { static const int _maxQueueSyncRetries = 3; 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 _previousChannelsCache = []; + static const int _maxChannelSyncRetries = 3; + static const int _channelSyncTimeoutMs = 2000; // 2 second timeout per channel + // Services MessageRetryService? _retryService; PathHistoryService? _pathHistoryService; @@ -113,6 +126,7 @@ class MeshCoreConnector extends ChangeNotifier { BackgroundService? _backgroundService; final NotificationService _notificationService = NotificationService(); BleDebugLogService? _bleDebugLogService; + AppDebugLogService? _appDebugLogService; final ChannelMessageStore _channelMessageStore = ChannelMessageStore(); final MessageStore _messageStore = MessageStore(); final ChannelOrderStore _channelOrderStore = ChannelOrderStore(); @@ -126,6 +140,7 @@ class MeshCoreConnector extends ChangeNotifier { final Set _knownContactKeys = {}; final Map _contactLastReadMs = {}; final Map _channelLastReadMs = {}; + final Map _pendingRepeaterAcks = {}; String? _activeContactKey; int? _activeChannelIndex; List _channelOrder = []; @@ -178,6 +193,10 @@ class MeshCoreConnector extends ChangeNotifier { int get maxContacts => _maxContacts; int get maxChannels => _maxChannels; bool get isSyncingQueuedMessages => _isSyncingQueuedMessages; + bool get isSyncingChannels => _isSyncingChannels; + int get channelSyncProgress => _isSyncingChannels && _totalChannelsToRequest > 0 + ? ((_nextChannelIndexToRequest / _totalChannelsToRequest) * 100).round() + : 0; int? get batteryPercent => _batteryMillivolts == null ? null : _estimateBatteryPercent( @@ -459,12 +478,14 @@ class MeshCoreConnector extends ChangeNotifier { required PathHistoryService pathHistoryService, AppSettingsService? appSettingsService, BleDebugLogService? bleDebugLogService, + AppDebugLogService? appDebugLogService, BackgroundService? backgroundService, }) { _retryService = retryService; _pathHistoryService = pathHistoryService; _appSettingsService = appSettingsService; _bleDebugLogService = bleDebugLogService; + _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; // Initialize notification service @@ -480,7 +501,10 @@ class MeshCoreConnector extends ChangeNotifier { setContactPathCallback: setContactPath, calculateTimeoutCallback: (pathLength, messageBytes) => calculateTimeout(pathLength: pathLength, messageBytes: messageBytes), + getSelfPublicKeyCallback: () => _selfPublicKey, + prepareContactOutboundTextCallback: prepareContactOutboundText, appSettingsService: appSettingsService, + debugLogService: _appDebugLogService, recordPathResultCallback: _recordPathResult, ); } @@ -510,7 +534,7 @@ class MeshCoreConnector extends ChangeNotifier { int timestampSeconds, ) async { if (!isConnected || text.isEmpty) return; - final outboundText = _prepareContactOutboundText(contact, text); + final outboundText = prepareContactOutboundText(contact, text); await sendFrame( buildSendTextMsgFrame( contact.publicKey, @@ -811,6 +835,9 @@ class MeshCoreConnector extends ChangeNotifier { _queueSyncTimeout?.cancel(); _queueSyncTimeout = null; _queueSyncRetries = 0; + _channelSyncTimeout?.cancel(); + _channelSyncTimeout = null; + _channelSyncRetries = 0; try { // Skip queued BLE operations so disconnect doesn't get stuck behind them. @@ -840,8 +867,8 @@ class MeshCoreConnector extends ChangeNotifier { _queuedMessageSyncInFlight = false; _didInitialQueueSync = false; _pendingQueueSync = false; - _pendingQueueSync = false; - _didInitialQueueSync = false; + _isSyncingChannels = false; + _channelSyncInFlight = false; _setState(MeshCoreConnectionState.disconnected); if (!manual) { @@ -988,7 +1015,7 @@ class MeshCoreConnector extends ChangeNotifier { ); _addMessage(contact.publicKeyHex, message); notifyListeners(); - final outboundText = _prepareContactOutboundText(contact, text); + final outboundText = prepareContactOutboundText(contact, text); await sendFrame( buildSendTextMsgFrame( contact.publicKey, @@ -1021,9 +1048,16 @@ class MeshCoreConnector extends ChangeNotifier { int? pathLen, Uint8List? pathBytes, }) async { + appLogger.info('setPathOverride called for ${contact.name}: pathLen=$pathLen, bytesLen=${pathBytes?.length ?? 0}', tag: 'Connector'); + // Find contact in list 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 _contacts[index] = _contacts[index].copyWith( @@ -1032,18 +1066,126 @@ class MeshCoreConnector extends ChangeNotifier { 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 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 (pathLen != null && pathLen >= 0 && pathBytes != null) { + appLogger.info('Sending path to device...', tag: 'Connector'); 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}'); notifyListeners(); } + Future 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 verifyContactPathOnDevice( + Contact contact, + Uint8List expectedPath, { + Duration timeout = const Duration(seconds: 3), + }) async { + if (!isConnected) return false; + + final expectedLength = expectedPath.length; + final completer = Completer(); + + 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 sendChannelMessage(Channel channel, String text) async{ if (!isConnected || text.isEmpty) return; @@ -1246,55 +1388,125 @@ class MeshCoreConnector extends ChangeNotifier { await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}'); } - final Set _expectedChannelIndices = {}; - Future getChannels({int? maxChannels}) async { if (!isConnected) return; + if (_isSyncingChannels) { + debugPrint('[ChannelSync] Already syncing channels, ignoring request'); + return; + } _isLoadingChannels = true; - final previousChannels = List.from(_channels); + _isSyncingChannels = true; + _previousChannelsCache = List.from(_channels); _channels.clear(); - _expectedChannelIndices.clear(); + _nextChannelIndexToRequest = 0; + _totalChannelsToRequest = maxChannels ?? _maxChannels; + _channelSyncRetries = 0; notifyListeners(); - // Request each channel index (send all requests in parallel) - final channelCount = maxChannels ?? _maxChannels; - for (int i = 0; i < channelCount; i++) { - _expectedChannelIndices.add(i); - sendFrame(buildGetChannelFrame(i)); // No await - send all at once + debugPrint('[ChannelSync] Starting sync for $_totalChannelsToRequest channels'); + + // Start sequential sync + await _requestNextChannel(); + } + + Future _requestNextChannel() async { + if (!isConnected) { + _cleanupChannelSync(completed: false); + return; } - // Wait for responses with timeout - final stopwatch = Stopwatch()..start(); - const maxWaitTime = Duration(seconds: 5); - const checkInterval = Duration(milliseconds: 100); + if (_channelSyncInFlight) return; - while (_expectedChannelIndices.isNotEmpty && stopwatch.elapsed < maxWaitTime) { - await Future.delayed(checkInterval); + // Check if we've requested all channels + 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 - if (_expectedChannelIndices.isNotEmpty) { - debugPrint('Channel loading timeout - missing ${_expectedChannelIndices.length} channels, restoring from cache'); - for (final prevChannel in previousChannels) { - if (_expectedChannelIndices.contains(prevChannel.index) && - !_channels.any((c) => c.index == prevChannel.index)) { - _channels.add(prevChannel); - debugPrint('Restored channel ${prevChannel.index} (${prevChannel.name}) from cache'); + // Cancel any existing timeout + _channelSyncTimeout?.cancel(); + + // Set up timeout for this channel request + _channelSyncTimeout = Timer( + Duration(milliseconds: _channelSyncTimeoutMs), + () => _handleChannelSyncTimeout(channelIndex), + ); + + 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; - _expectedChannelIndices.clear(); + debugPrint('[ChannelSync] Sync complete: received ${_channels.length}/$_totalChannelsToRequest channels'); + + _cleanupChannelSync(completed: true); + + // Apply ordering and notify UI _applyChannelOrder(); 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 setChannel(int index, String name, Uint8List psk) async { if (!isConnected) return; @@ -1588,14 +1800,20 @@ class MeshCoreConnector extends ChangeNotifier { final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt) ? existing.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 _contacts[existingIndex] = contact.copyWith( lastMessageAt: mergedLastMessageAt, pathOverride: existing.pathOverride, // Preserve user's path choice pathOverrideBytes: existing.pathOverrideBytes, ); + + appLogger.info('After merge: pathOverride=${_contacts[existingIndex].pathOverride}, devicePath=${_contacts[existingIndex].pathLength}', tag: 'Connector'); } else { _contacts.add(contact); + appLogger.info('Added new contact ${contact.name}: pathLen=${contact.pathLength}', tag: 'Connector'); } _knownContactKeys.add(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 isStructuredPayload = trimmed.startsWith('g:') || trimmed.startsWith('m:') || trimmed.startsWith('V1|'); @@ -2029,6 +2249,10 @@ class MeshCoreConnector extends ChangeNotifier { return; } + if (_handleRepeaterCommandSent(ackHash, timeoutMs)) { + return; + } + if (_retryService != null) { _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 + if (_handleRepeaterCommandAck(ackHash, tripTimeMs)) { + return; + } + // Handle ACK in retry service if (_retryService != null) { _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) { final channel = Channel.fromFrame(frame); - if (channel != null) { - // Mark this channel index as received - _expectedChannelIndices.remove(channel.index); + if (channel == null) return; - // Only add non-empty channels to the list - if (!channel.isEmpty) { + debugPrint('[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}'); + + // 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); } + } - // Only sort and notify if we're not currently loading channels - // This prevents the list from jumping around as channels arrive during refresh - if (!_isLoadingChannels) { - _applyChannelOrder(); - notifyListeners(); - } + // Only notify if not in loading state + if (!_isLoadingChannels) { + _applyChannelOrder(); + notifyListeners(); } } @@ -2370,6 +2659,17 @@ class MeshCoreConnector extends ChangeNotifier { 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) { _channelMessages.putIfAbsent(channelIndex, () => []); final messages = _channelMessages[channelIndex]!; @@ -2599,6 +2899,11 @@ class MeshCoreConnector extends ChangeNotifier { // Disable wake lock when connection is lost WakelockPlus.disable(); + for (final entry in _pendingRepeaterAcks.values) { + entry.timeout?.cancel(); + } + _pendingRepeaterAcks.clear(); + _notifySubscription?.cancel(); _notifySubscription = null; _connectionSubscription?.cancel(); @@ -2613,6 +2918,8 @@ class MeshCoreConnector extends ChangeNotifier { _maxChannels = _defaultMaxChannels; _isSyncingQueuedMessages = false; _queuedMessageSyncInFlight = false; + _isSyncingChannels = false; + _channelSyncInFlight = false; _setState(MeshCoreConnectionState.disconnected); _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, + }); +} diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 2c57d1d..ac4b6de 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -592,16 +592,18 @@ Uint8List buildSendCliCommandFrame( Uint8List repeaterPubKey, String command, { int attempt = 0, + int? timestampSeconds, }) { final textBytes = utf8.encode(command); - final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); const prefixSize = 6; + final safeAttempt = attempt.clamp(0, 3); final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1); int offset = 0; frame[offset++] = cmdSendTxtMsg; frame[offset++] = txtTypeCliData; - frame[offset++] = attempt & 0xFF; + frame[offset++] = safeAttempt; writeUint32LE(frame, offset, timestamp); offset += 4; diff --git a/lib/main.dart b/lib/main.dart index 541b1f6..46e1641 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,9 +9,11 @@ import 'services/path_history_service.dart'; import 'services/app_settings_service.dart'; import 'services/notification_service.dart'; import 'services/ble_debug_log_service.dart'; +import 'services/app_debug_log_service.dart'; import 'services/background_service.dart'; import 'services/map_tile_cache_service.dart'; import 'storage/prefs_manager.dart'; +import 'utils/app_logger.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -26,12 +28,19 @@ void main() async { final retryService = MessageRetryService(storage); final appSettingsService = AppSettingsService(); final bleDebugLogService = BleDebugLogService(); + final appDebugLogService = AppDebugLogService(); final backgroundService = BackgroundService(); final mapTileCacheService = MapTileCacheService(); // Load settings await appSettingsService.loadSettings(); + // Initialize app logger + appLogger.initialize( + appDebugLogService, + enabled: appSettingsService.settings.appDebugLogEnabled, + ); + // Initialize notification service final notificationService = NotificationService(); await notificationService.initialize(); @@ -43,6 +52,7 @@ void main() async { pathHistoryService: pathHistoryService, appSettingsService: appSettingsService, bleDebugLogService: bleDebugLogService, + appDebugLogService: appDebugLogService, backgroundService: backgroundService, ); @@ -60,6 +70,7 @@ void main() async { storage: storage, appSettingsService: appSettingsService, bleDebugLogService: bleDebugLogService, + appDebugLogService: appDebugLogService, mapTileCacheService: mapTileCacheService, )); } @@ -71,6 +82,7 @@ class MeshCoreApp extends StatelessWidget { final StorageService storage; final AppSettingsService appSettingsService; final BleDebugLogService bleDebugLogService; + final AppDebugLogService appDebugLogService; final MapTileCacheService mapTileCacheService; const MeshCoreApp({ @@ -81,6 +93,7 @@ class MeshCoreApp extends StatelessWidget { required this.storage, required this.appSettingsService, required this.bleDebugLogService, + required this.appDebugLogService, required this.mapTileCacheService, }); @@ -93,6 +106,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: pathHistoryService), ChangeNotifierProvider.value(value: appSettingsService), ChangeNotifierProvider.value(value: bleDebugLogService), + ChangeNotifierProvider.value(value: appDebugLogService), Provider.value(value: storage), Provider.value(value: mapTileCacheService), ], diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index e494344..a1769e0 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -18,6 +18,7 @@ class AppSettings { final bool notifyOnNewAdvert; final bool autoRouteRotationEnabled; final String themeMode; + final bool appDebugLogEnabled; final Map batteryChemistryByDeviceId; AppSettings({ @@ -38,6 +39,7 @@ class AppSettings { this.notifyOnNewAdvert = true, this.autoRouteRotationEnabled = false, this.themeMode = 'system', + this.appDebugLogEnabled = false, Map? batteryChemistryByDeviceId, }) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {}; @@ -60,6 +62,7 @@ class AppSettings { 'notify_on_new_advert': notifyOnNewAdvert, 'auto_route_rotation_enabled': autoRouteRotationEnabled, 'theme_mode': themeMode, + 'app_debug_log_enabled': appDebugLogEnabled, 'battery_chemistry_by_device_id': batteryChemistryByDeviceId, }; } @@ -86,6 +89,7 @@ class AppSettings { notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true, autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false, 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( (key, value) => MapEntry(key.toString(), value.toString()), ) ?? @@ -111,6 +115,7 @@ class AppSettings { bool? notifyOnNewAdvert, bool? autoRouteRotationEnabled, String? themeMode, + bool? appDebugLogEnabled, Map? batteryChemistryByDeviceId, }) { return AppSettings( @@ -133,6 +138,7 @@ class AppSettings { notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert, autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled, themeMode: themeMode ?? this.themeMode, + appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled, batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId, ); } diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 389a829..364deff 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -46,6 +46,11 @@ class Contact { } 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 'Direct'; return '$pathLength hops'; @@ -83,12 +88,13 @@ class Contact { } String get pathIdList { - if (path.isEmpty) return ''; + final pathBytes = _pathBytesForDisplay; + if (pathBytes.isEmpty) return ''; final parts = []; final groupSize = pathHashSize; - for (int i = 0; i < path.length; i += groupSize) { - final end = (i + groupSize) <= path.length ? (i + groupSize) : path.length; - final chunk = path.sublist(i, end); + for (int i = 0; i < pathBytes.length; i += groupSize) { + final end = (i + groupSize) <= pathBytes.length ? (i + groupSize) : pathBytes.length; + final chunk = pathBytes.sublist(i, end); parts.add( chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(), ); @@ -96,6 +102,14 @@ class Contact { 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) { if (data.length < contactFrameSize) return null; if (data[0] != respCodeContact) return null; diff --git a/lib/screens/app_debug_log_screen.dart b/lib/screens/app_debug_log_screen.dart new file mode 100644 index 0000000..feaec7b --- /dev/null +++ b/lib/screens/app_debug_log_screen.dart @@ -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( + 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); + } + } +} diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index 7c266a3..2f5183b 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -32,6 +32,8 @@ class AppSettingsScreen extends StatelessWidget { _buildBatteryCard(context, settingsService, connector), const SizedBox(height: 16), _buildMapSettingsCard(context, settingsService), + const SizedBox(height: 16), + _buildDebugCard(context, settingsService), ], ); }, @@ -383,43 +385,31 @@ class AppSettingsScreen extends StatelessWidget { context: context, builder: (context) => AlertDialog( title: const Text('Theme'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - RadioListTile( - title: const Text('System default'), - value: 'system', - groupValue: settingsService.settings.themeMode, - onChanged: (value) { - if (value != null) { - settingsService.setThemeMode(value); - Navigator.pop(context); - } - }, - ), - RadioListTile( - title: const Text('Light'), - value: 'light', - groupValue: settingsService.settings.themeMode, - onChanged: (value) { - if (value != null) { - settingsService.setThemeMode(value); - Navigator.pop(context); - } - }, - ), - RadioListTile( - title: const Text('Dark'), - value: 'dark', - groupValue: settingsService.settings.themeMode, - onChanged: (value) { - if (value != null) { - settingsService.setThemeMode(value); - Navigator.pop(context); - } - }, - ), - ], + content: RadioGroup( + groupValue: settingsService.settings.themeMode, + onChanged: (value) { + if (value != null) { + settingsService.setThemeMode(value); + Navigator.pop(context); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + title: const Text('System default'), + value: 'system', + ), + RadioListTile( + title: const Text('Light'), + value: 'light', + ), + RadioListTile( + title: const Text('Dark'), + value: 'dark', + ), + ], + ), ), actions: [ TextButton( @@ -447,77 +437,51 @@ class AppSettingsScreen extends StatelessWidget { context: context, builder: (context) => AlertDialog( title: const Text('Map Time Filter'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Show nodes discovered within:'), - const SizedBox(height: 16), - ListTile( - title: const Text('All time'), - leading: Radio( - value: 0, - groupValue: settingsService.settings.mapTimeFilterHours, - onChanged: (value) { - if (value != null) { - settingsService.setMapTimeFilterHours(value); - Navigator.pop(context); - } - }, + content: RadioGroup( + groupValue: settingsService.settings.mapTimeFilterHours, + onChanged: (value) { + if (value != null) { + settingsService.setMapTimeFilterHours(value); + Navigator.pop(context); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Show nodes discovered within:'), + const SizedBox(height: 16), + ListTile( + title: const Text('All time'), + leading: Radio( + value: 0, + ), ), - ), - ListTile( - title: const Text('Last hour'), - leading: Radio( - value: 1, - groupValue: settingsService.settings.mapTimeFilterHours, - onChanged: (value) { - if (value != null) { - settingsService.setMapTimeFilterHours(value); - Navigator.pop(context); - } - }, + ListTile( + title: const Text('Last hour'), + leading: Radio( + value: 1, + ), ), - ), - ListTile( - title: const Text('Last 6 hours'), - leading: Radio( - value: 6, - groupValue: settingsService.settings.mapTimeFilterHours, - onChanged: (value) { - if (value != null) { - settingsService.setMapTimeFilterHours(value); - Navigator.pop(context); - } - }, + ListTile( + title: const Text('Last 6 hours'), + leading: Radio( + value: 6, + ), ), - ), - ListTile( - title: const Text('Last 24 hours'), - leading: Radio( - value: 24, - groupValue: settingsService.settings.mapTimeFilterHours, - onChanged: (value) { - if (value != null) { - settingsService.setMapTimeFilterHours(value); - Navigator.pop(context); - } - }, + ListTile( + title: const Text('Last 24 hours'), + leading: Radio( + value: 24, + ), ), - ), - ListTile( - title: const Text('Last week'), - leading: Radio( - value: 168, - groupValue: settingsService.settings.mapTimeFilterHours, - onChanged: (value) { - if (value != null) { - settingsService.setMapTimeFilterHours(value); - Navigator.pop(context); - } - }, + ListTile( + title: const Text('Last week'), + leading: Radio( + value: 168, + ), ), - ), - ], + ], + ), ), actions: [ 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), + ), + ); + }, + ), + ], + ), + ); + } } diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 9244dd8..0a5484e 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; @@ -38,11 +39,11 @@ class _ChannelChatScreenState extends State { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { + SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; context.read().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) { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } @@ -151,7 +152,7 @@ class _ChannelChatScreenState extends State { builder: (context, connector, child) { final messages = connector.getChannelMessages(widget.channel); - WidgetsBinding.instance.addPostFrameCallback((_) { + SchedulerBinding.instance.addPostFrameCallback((_) { _scrollToBottom(); }); diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index debd1cf..d97cfbf 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -10,6 +10,8 @@ import '../models/channel.dart'; import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/route_transitions.dart'; +import '../widgets/battery_indicator.dart'; +import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/unread_badge.dart'; @@ -18,6 +20,13 @@ import 'contacts_screen.dart'; import 'map_screen.dart'; import 'settings_screen.dart'; +enum ChannelSortOption { + manual, + name, + latestMessages, + unread, +} + class ChannelsScreen extends StatefulWidget { final bool hideBackButton; @@ -32,6 +41,11 @@ class ChannelsScreen extends StatefulWidget { class _ChannelsScreenState extends State with DisconnectNavigationMixin { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + Timer? _searchDebounce; + ChannelSortOption _sortOption = ChannelSortOption.manual; + @override void initState() { super.initState(); @@ -40,6 +54,13 @@ class _ChannelsScreenState extends State }); } + @override + void dispose() { + _searchDebounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final connector = context.watch(); @@ -55,10 +76,16 @@ class _ChannelsScreenState extends State canPop: allowBack, child: Scaffold( appBar: AppBar( + leading: BatteryIndicator(connector: connector), title: const Text('Channels'), centerTitle: true, - automaticallyImplyLeading: !widget.hideBackButton && allowBack, + automaticallyImplyLeading: false, actions: [ + IconButton( + icon: const Icon(Icons.bluetooth_disabled), + tooltip: 'Disconnect', + onPressed: () => _disconnect(context), + ), IconButton( icon: const Icon(Icons.tune), tooltip: 'Settings', @@ -67,57 +94,154 @@ class _ChannelsScreenState extends State 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().getChannels(), - ), ], ), - body: () { - if (connector.isLoadingChannels) { - return const Center(child: CircularProgressIndicator()); - } + body: RefreshIndicator( + onRefresh: () async { + await context.read().getChannels(); + }, + child: () { + if (connector.isLoadingChannels) { + return const Center(child: CircularProgressIndicator()); + } - final channels = connector.channels; + final channels = connector.channels; - if (channels.isEmpty) { - return EmptyState( - icon: Icons.tag, - 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.from(channels); - final item = reordered.removeAt(oldIndex); - reordered.insert(newIndex, item); - unawaited( - connector.setChannelOrder( - reordered.map((c) => c.index).toList(), - ), + if (channels.isEmpty) { + return ListView( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height - 200, + child: EmptyState( + icon: Icons.tag, + title: 'No channels configured', + action: FilledButton.icon( + onPressed: () => _addPublicChannel(context, connector), + icon: const Icon(Icons.public), + label: const Text('Add Public Channel'), + ), + ), + ), + ], ); - }, - itemBuilder: (context, index) { - final channel = channels[index]; - return _buildChannelTile(context, connector, channel, index); - }, - ); - }(), + } + + final filteredChannels = _filterAndSortChannels(channels, connector); + + 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.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( onPressed: () => _showAddChannelDialog(context), child: const Icon(Icons.add), @@ -137,7 +261,10 @@ class _ChannelsScreenState extends State BuildContext context, MeshCoreConnector connector, Channel channel, - int index, + { + bool showDragHandle = false, + int? dragIndex, + } ) { final unreadCount = connector.getUnreadCountForChannel(channel); return Card( @@ -179,13 +306,14 @@ class _ChannelsScreenState extends State UnreadBadge(count: unreadCount), const SizedBox(width: 4), ], - ReorderableDelayedDragStartListener( - index: index, - child: Icon( - Icons.drag_handle, - color: Theme.of(context).colorScheme.onSurfaceVariant, + if (showDragHandle && dragIndex != null) + ReorderableDelayedDragStartListener( + index: dragIndex, + child: Icon( + Icons.drag_handle, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), - ), ], ), onTap: () async { @@ -271,6 +399,118 @@ class _ChannelsScreenState extends State 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 _filterAndSortChannels( + List 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) { final connector = context.read(); final nameController = TextEditingController(); diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index c48d7b8..62da512 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:latlong2/latlong.dart'; @@ -19,6 +20,8 @@ import '../utils/emoji_utils.dart'; import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; import '../widgets/gif_picker.dart'; +import '../widgets/path_selection_dialog.dart'; +import '../utils/app_logger.dart'; class ChatScreen extends StatefulWidget { final Contact contact; @@ -36,11 +39,11 @@ class _ChatScreenState extends State { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { + SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; context.read().setActiveContact(widget.contact.publicKeyHex); - // Scroll to bottom when opening chat + // Scroll to bottom when opening chat use SchedulerBinding for next frame if (_scrollController.hasClients) { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } @@ -438,13 +441,13 @@ class _ChatScreenState extends State { ); 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); + await _notifyPathSet( + connector, + widget.contact, + pathBytes, + path.hopCount, + ); }, ), ); @@ -590,6 +593,30 @@ class _ChatScreenState extends State { return '${contact.pathLength} hops'; } + Future _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) { final connector = Provider.of(context, listen: false); connector.ensureContactSmazSettingLoaded(widget.contact.publicKeyHex); @@ -657,7 +684,7 @@ class _ChatScreenState extends State { ); } - void _showCustomPathDialog(BuildContext context) { + Future _showCustomPathDialog(BuildContext context) async { final connector = Provider.of(context, listen: false); final currentContact = _resolveContact(connector); @@ -665,385 +692,48 @@ class _ChatScreenState extends State { connector.getContacts(); } - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Row( - children: [ - Icon(Icons.edit_road), - SizedBox(width: 8), - Text('Set Custom Path'), - ], - ), - content: Consumer( - builder: (context, connector, _) { - final contact = _resolveContact(connector); - final pathForInput = contact.pathIdList; - final currentPathLabel = _currentPathLabel(contact); + final pathForInput = currentContact.pathIdList; + final currentPathLabel = _currentPathLabel(currentContact); - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - 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(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 = []; - final invalidPrefixes = []; - - 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(context, listen: false); - final selectedContacts = []; - - // Filter to only repeaters and room servers - final validContacts = connector.contacts - .where((c) => (c.type == 2 || c.type == 3) && c != widget.contact) + // Filter out the current contact from available contacts + final availableContacts = connector.contacts + .where((c) => c != widget.contact) .toList(); - showDialog( - context: context, - builder: (context) => StatefulBuilder( - builder: (context, setDialogState) => AlertDialog( - title: const Text('Build Path from Contacts'), - content: SizedBox( - width: double.maxFinite, - 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 = []; - 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'), - ), - ], - ), - ), + final result = await PathSelectionDialog.show( + context, + availableContacts: availableContacts, + initialPath: pathForInput.isEmpty ? null : pathForInput, + title: 'Set Custom Path', + currentPathLabel: currentPathLabel, + onRefresh: connector.isConnected ? connector.getContacts : null, ); + + 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) { final connector = context.read(); final senderName = diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index e4a29f3..e16da6b 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -13,6 +13,8 @@ import '../utils/dialog_utils.dart'; import '../utils/disconnect_navigation_mixin.dart'; import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; +import '../widgets/battery_indicator.dart'; +import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/repeater_login_dialog.dart'; @@ -23,22 +25,6 @@ import 'map_screen.dart'; import 'repeater_hub_screen.dart'; import 'settings_screen.dart'; -enum ContactSortOption { - lastSeen, - recentMessages, - name, - type, -} - -enum _ContactMenuAction { - sortRecentMessages, - sortName, - sortType, - toggleLastSeenFilter, - toggleUnreadOnly, - newGroup, -} - class ContactsScreen extends StatefulWidget { final bool hideBackButton; @@ -56,8 +42,8 @@ class _ContactsScreenState extends State final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; ContactSortOption _sortOption = ContactSortOption.lastSeen; - bool _forceLastSeenSort = true; bool _showUnreadOnly = false; + ContactTypeFilter _typeFilter = ContactTypeFilter.all; final ContactGroupStore _groupStore = ContactGroupStore(); List _groups = []; Timer? _searchDebounce; @@ -97,41 +83,15 @@ class _ContactsScreenState extends State } final allowBack = !connector.isConnected; - final theme = Theme.of(context); - return PopScope( canPop: allowBack, child: Scaffold( appBar: AppBar( - titleSpacing: 16, - centerTitle: false, - automaticallyImplyLeading: !widget.hideBackButton && allowBack, - title: Column( - 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, - ), - ), - ], - ), + leading: BatteryIndicator(connector: connector), + title: const Text('Contacts'), + centerTitle: true, + automaticallyImplyLeading: false, 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( icon: const Icon(Icons.bluetooth_disabled), tooltip: 'Disconnect', @@ -145,93 +105,6 @@ class _ContactsScreenState extends State 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), @@ -253,6 +126,30 @@ class _ContactsScreenState extends State 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) { final contacts = connector.contacts; @@ -281,8 +178,11 @@ class _ContactsScreenState extends State decoration: InputDecoration( hintText: 'Search contacts...', prefixIcon: const Icon(Icons.search), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_searchQuery.isNotEmpty) + IconButton( icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); @@ -290,8 +190,10 @@ class _ContactsScreenState extends State _searchQuery = ''; }); }, - ) - : null, + ), + _buildFilterButton(context, connector), + ], + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), @@ -366,6 +268,13 @@ class _ContactsScreenState extends State if (contact != null && matchesContactQuery(contact, query)) return true; } 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(); filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); @@ -378,14 +287,17 @@ class _ContactsScreenState extends State return matchesContactQuery(contact, _searchQuery); }).toList(); + if (_typeFilter != ContactTypeFilter.all) { + filtered = filtered.where(_matchesTypeFilter).toList(); + } + if (_showUnreadOnly) { filtered = filtered.where((contact) { return connector.getUnreadCountForContact(contact) > 0; }).toList(); } - final sortOption = _forceLastSeenSort ? ContactSortOption.lastSeen : _sortOption; - switch (sortOption) { + switch (_sortOption) { case ContactSortOption.lastSeen: filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a))); break; @@ -401,18 +313,24 @@ class _ContactsScreenState extends State case ContactSortOption.name: filtered.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); 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; } + 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) { if (contact.type != advTypeChat) return contact.lastSeen; return contact.lastMessageAt.isAfter(contact.lastSeen) diff --git a/lib/screens/device_screen.dart b/lib/screens/device_screen.dart index f844f84..e288961 100644 --- a/lib/screens/device_screen.dart +++ b/lib/screens/device_screen.dart @@ -39,10 +39,17 @@ class _DeviceScreenState extends State canPop: false, child: Scaffold( appBar: AppBar( + leading: _buildBatteryIndicator(connector, context), titleSpacing: 16, centerTitle: false, title: _buildAppBarTitle(connector, theme), + automaticallyImplyLeading: false, actions: [ + IconButton( + icon: const Icon(Icons.bluetooth_disabled), + tooltip: 'Disconnect', + onPressed: () => _disconnect(context, connector), + ), IconButton( icon: const Icon(Icons.tune), tooltip: 'Settings', @@ -53,11 +60,6 @@ class _DeviceScreenState extends State ), ), ), - IconButton( - icon: const Icon(Icons.bluetooth_disabled), - tooltip: 'Disconnect', - onPressed: () => _disconnect(context, connector), - ), ], ), body: SafeArea( diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index b15fc88..11fe4e2 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -12,6 +12,7 @@ import '../services/map_marker_service.dart'; import '../services/map_tile_cache_service.dart'; import '../utils/contact_search.dart'; import '../utils/route_transitions.dart'; +import '../widgets/battery_indicator.dart'; import '../widgets/quick_switch_bar.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; @@ -136,10 +137,16 @@ class _MapScreenState extends State { canPop: allowBack, child: Scaffold( appBar: AppBar( + leading: BatteryIndicator(connector: connector), title: const Text('Node Map'), centerTitle: true, - automaticallyImplyLeading: !widget.hideBackButton && allowBack, + automaticallyImplyLeading: false, actions: [ + IconButton( + icon: const Icon(Icons.bluetooth_disabled), + tooltip: 'Disconnect', + onPressed: () => _disconnect(context, connector), + ), IconButton( icon: const Icon(Icons.tune), tooltip: 'Settings', @@ -148,11 +155,6 @@ class _MapScreenState extends State { MaterialPageRoute(builder: (context) => const SettingsScreen()), ), ), - IconButton( - icon: const Icon(Icons.bluetooth_disabled), - tooltip: 'Disconnect', - onPressed: () => _disconnect(context, connector), - ), ], ), body: !hasMapContent diff --git a/lib/screens/repeater_cli_screen.dart b/lib/screens/repeater_cli_screen.dart index 297cca1..3487ebc 100644 --- a/lib/screens/repeater_cli_screen.dart +++ b/lib/screens/repeater_cli_screen.dart @@ -7,6 +7,7 @@ import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../widgets/debug_frame_viewer.dart'; import '../services/repeater_command_service.dart'; +import '../widgets/path_management_dialog.dart'; class RepeaterCliScreen extends StatefulWidget { final Contact repeater; @@ -75,6 +76,13 @@ class _RepeaterCliScreenState extends State { }); } + Contact _resolveRepeater(MeshCoreConnector connector) { + return connector.contacts.firstWhere( + (c) => c.publicKeyHex == widget.repeater.publicKeyHex, + orElse: () => widget.repeater, + ); + } + void _handleTextMessageResponse(Uint8List frame) { final parsed = parseContactMessageText(frame); if (parsed == null) return; @@ -117,9 +125,12 @@ class _RepeaterCliScreenState extends State { // Send CLI command to repeater with retry try { if (_commandService != null) { + final connector = Provider.of(context, listen: false); + final repeater = _resolveRepeater(connector); final response = await _commandService!.sendCommand( - widget.repeater, + repeater, command, + retries: 1, ); if (mounted) { @@ -204,6 +215,10 @@ class _RepeaterCliScreenState extends State { @override Widget build(BuildContext context) { + final connector = context.watch(); + final repeater = _resolveRepeater(connector); + final isFloodMode = repeater.pathOverride == -1; + return Scaffold( appBar: AppBar( title: Column( @@ -212,13 +227,61 @@ class _RepeaterCliScreenState extends State { children: [ const Text('Repeater CLI'), Text( - widget.repeater.name, + repeater.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), ), ], ), centerTitle: false, actions: [ + PopupMenuButton( + 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( icon: const Icon(Icons.bug_report), tooltip: 'Debug Next Command', diff --git a/lib/screens/repeater_hub_screen.dart b/lib/screens/repeater_hub_screen.dart index a6125e2..f9ba230 100644 --- a/lib/screens/repeater_hub_screen.dart +++ b/lib/screens/repeater_hub_screen.dart @@ -33,11 +33,9 @@ class RepeaterHubScreen extends StatelessWidget { ), body: SafeArea( top: false, - child: Padding( + child: ListView( padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ + children: [ // Repeater info card Card( child: Padding( @@ -142,8 +140,7 @@ class RepeaterHubScreen extends StatelessWidget { ); }, ), - ], - ), + ], ), ), ); diff --git a/lib/screens/repeater_settings_screen.dart b/lib/screens/repeater_settings_screen.dart index cc02955..deaa245 100644 --- a/lib/screens/repeater_settings_screen.dart +++ b/lib/screens/repeater_settings_screen.dart @@ -6,6 +6,7 @@ import '../models/contact.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../services/repeater_command_service.dart'; +import '../widgets/path_management_dialog.dart'; class RepeaterSettingsScreen extends StatefulWidget { final Contact repeater; @@ -121,6 +122,13 @@ class _RepeaterSettingsScreenState extends State { _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) { final target = widget.repeater.publicKey; if (target.length < 6 || prefix.length < 6) return false; @@ -326,9 +334,15 @@ class _RepeaterSettingsScreenState extends State { }); var successCount = 0; + final connector = Provider.of(context, listen: false); + final repeater = _resolveRepeater(connector); for (final command in commands) { try { - final response = await _commandService!.sendCommand(widget.repeater, command); + final response = await _commandService!.sendCommand( + repeater, + command, + retries: 1, + ); _applySettingResponse(command, response); successCount += 1; await Future.delayed(const Duration(milliseconds: 200)); @@ -422,12 +436,14 @@ class _RepeaterSettingsScreenState extends State { Future _saveSettings() async { final connector = Provider.of(context, listen: false); + final repeater = _resolveRepeater(connector); setState(() { _isLoading = true; }); try { + final selection = await connector.preparePathForContactSend(repeater); final commands = []; // Build set commands for each setting @@ -470,7 +486,18 @@ class _RepeaterSettingsScreenState extends State { // Send all 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 Future.delayed(const Duration(milliseconds: 200)); // Delay between commands } @@ -544,6 +571,10 @@ class _RepeaterSettingsScreenState extends State { @override Widget build(BuildContext context) { + final connector = context.watch(); + final repeater = _resolveRepeater(connector); + final isFloodMode = repeater.pathOverride == -1; + return Scaffold( appBar: AppBar( title: Column( @@ -552,13 +583,64 @@ class _RepeaterSettingsScreenState extends State { children: [ const Text('Repeater Settings'), Text( - widget.repeater.name, + repeater.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), ), ], ), centerTitle: false, actions: [ + PopupMenuButton( + 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) TextButton.icon( onPressed: _isLoading ? null : _saveSettings, @@ -995,6 +1077,7 @@ class _RepeaterSettingsScreenState extends State { Future _sendDangerCommand(String command) async { final connector = Provider.of(context, listen: false); + final repeater = _resolveRepeater(connector); if (command == 'erase') { if (mounted) { @@ -1006,7 +1089,19 @@ class _RepeaterSettingsScreenState extends State { } 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); if (mounted) { diff --git a/lib/screens/repeater_status_screen.dart b/lib/screens/repeater_status_screen.dart index 447b3dd..c9a592f 100644 --- a/lib/screens/repeater_status_screen.dart +++ b/lib/screens/repeater_status_screen.dart @@ -4,9 +4,11 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/contact.dart'; +import '../models/path_selection.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../services/repeater_command_service.dart'; +import '../widgets/path_management_dialog.dart'; class RepeaterStatusScreen extends StatefulWidget { final Contact repeater; @@ -23,6 +25,10 @@ class RepeaterStatusScreen extends StatefulWidget { } class _RepeaterStatusScreenState extends State { + static const int _statusPayloadOffset = 8; + static const int _statusStatsSize = 52; + static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize; + bool _isLoading = false; StreamSubscription? _frameSubscription; RepeaterCommandService? _commandService; @@ -45,6 +51,7 @@ class _RepeaterStatusScreenState extends State { int? _directRx; int? _dupFlood; int? _dupDirect; + PathSelection? _pendingStatusSelection; @override void initState() { @@ -80,6 +87,13 @@ class _RepeaterStatusScreenState extends State { }); } + Contact _resolveRepeater(MeshCoreConnector connector) { + return connector.contacts.firstWhere( + (c) => c.publicKeyHex == widget.repeater.publicKeyHex, + orElse: () => widget.repeater, + ); + } + void _handleTextMessageResponse(Uint8List frame) { final parsed = parseContactMessageText(frame); if (parsed == null) return; @@ -90,6 +104,7 @@ class _RepeaterStatusScreenState extends State { // Parse status responses _parseStatusResponse(parsed.text); + _recordStatusResult(true); } void _handleStatusResponse(Uint8List frame) { @@ -97,11 +112,13 @@ class _RepeaterStatusScreenState extends State { final prefix = frame.sublist(2, 8); if (!_matchesRepeaterPrefix(prefix)) return; - const payloadOffset = 8; - const statsSize = 52; - if (frame.length < payloadOffset + statsSize) return; + if (frame.length < _statusResponseBytes) return; - final data = ByteData.sublistView(frame, payloadOffset, payloadOffset + statsSize); + final data = ByteData.sublistView( + frame, + _statusPayloadOffset, + _statusResponseBytes, + ); int offset = 0; final batteryMv = data.getUint16(offset, Endian.little); @@ -160,6 +177,7 @@ class _RepeaterStatusScreenState extends State { _dupDirect = directDups; _dupFlood = floodDups; }); + _recordStatusResult(true); } bool _matchesRepeaterPrefix(Uint8List prefix) { @@ -213,6 +231,7 @@ class _RepeaterStatusScreenState extends State { setState(() { _isLoading = true; _statusRequestedAt = DateTime.now(); + _pendingStatusSelection = null; _batteryMv = null; _uptimeSecs = null; _queueLen = null; @@ -234,11 +253,22 @@ class _RepeaterStatusScreenState extends State { try { final connector = Provider.of(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); + 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 = Timer(const Duration(seconds: 12), () { + _statusTimeout = Timer(Duration(milliseconds: timeoutMs), () { if (!mounted) return; setState(() { _isLoading = false; @@ -249,6 +279,7 @@ class _RepeaterStatusScreenState extends State { backgroundColor: Colors.red, ), ); + _recordStatusResult(false); }); } catch (e) { if (mounted) { @@ -263,11 +294,25 @@ class _RepeaterStatusScreenState extends State { ), ); } + _recordStatusResult(false); } } + void _recordStatusResult(bool success) { + final selection = _pendingStatusSelection; + if (selection == null) return; + final connector = Provider.of(context, listen: false); + final repeater = _resolveRepeater(connector); + connector.recordRepeaterPathResult(repeater, selection, success, null); + _pendingStatusSelection = null; + } + @override Widget build(BuildContext context) { + final connector = context.watch(); + final repeater = _resolveRepeater(connector); + final isFloodMode = repeater.pathOverride == -1; + return Scaffold( appBar: AppBar( title: Column( @@ -276,13 +321,61 @@ class _RepeaterStatusScreenState extends State { children: [ const Text('Repeater Status'), Text( - widget.repeater.name, + repeater.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), ), ], ), centerTitle: false, actions: [ + PopupMenuButton( + 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( icon: _isLoading ? const SizedBox( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f9ae066..c30cbb8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -5,11 +5,19 @@ import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../models/radio_settings.dart'; import 'app_settings_screen.dart'; +import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; -class SettingsScreen extends StatelessWidget { +class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + bool _showBatteryVoltage = false; + @override Widget build(BuildContext context) { return Scaffold( @@ -58,6 +66,7 @@ class SettingsScreen extends StatelessWidget { _buildInfoRow('Name', connector.deviceDisplayName), _buildInfoRow('ID', connector.deviceIdLabel), _buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'), + _buildBatteryInfoRow(connector), if (connector.selfName != null) _buildInfoRow('Node Name', connector.selfName!), if (connector.selfPublicKey != null) @@ -70,6 +79,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) { return Card( child: ListTile( @@ -192,38 +248,89 @@ class SettingsScreen extends StatelessWidget { Widget _buildDebugCard(BuildContext context) { return Card( - child: ListTile( - leading: const Icon(Icons.bug_report_outlined), - title: const Text('BLE Debug Log'), - subtitle: const Text('Commands, responses, and status'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const BleDebugLogScreen()), - ); - }, + 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), + ), + ), + 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) { - return Padding( + Widget _buildInfoRow( + String label, + String value, { + Widget? leading, + Color? valueColor, + VoidCallback? onTap, + }) { + final row = Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, 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( child: Text( value, - style: const TextStyle(fontWeight: FontWeight.w500), + style: TextStyle( + fontWeight: FontWeight.w500, + color: valueColor, + ), overflow: TextOverflow.ellipsis, ), ), ], ), ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: row, + ); + } + return row; } void _editNodeName(BuildContext context, MeshCoreConnector connector) { diff --git a/lib/services/app_debug_log_service.dart b/lib/services/app_debug_log_service.dart new file mode 100644 index 0000000..0f4ed7d --- /dev/null +++ b/lib/services/app_debug_log_service.dart @@ -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 _entries = []; + bool _enabled = false; + + List 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(); + } +} diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 36c8651..3c7433b 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../models/app_settings.dart'; import '../storage/prefs_manager.dart'; +import '../utils/app_logger.dart'; class AppSettingsService extends ChangeNotifier { static const String _settingsKey = 'app_settings'; @@ -112,6 +113,12 @@ class AppSettingsService extends ChangeNotifier { await updateSettings(_settings.copyWith(themeMode: value)); } + Future setAppDebugLogEnabled(bool value) async { + await updateSettings(_settings.copyWith(appDebugLogEnabled: value)); + // Update the global logger + appLogger.setEnabled(value); + } + Future setBatteryChemistryForDevice(String deviceId, String chemistry) async { final updated = Map.from(_settings.batteryChemistryByDeviceId); updated[deviceId] = chemistry; diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index e2e66a6..0b96ba4 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -1,11 +1,14 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; +import 'package:crypto/crypto.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../models/path_selection.dart'; import 'storage_service.dart'; import 'app_settings_service.dart'; +import 'app_debug_log_service.dart'; class _AckHistoryEntry { final String messageId; @@ -41,7 +44,8 @@ class MessageRetryService extends ChangeNotifier { final Map _ackHashToMessageId = {}; // ackHashHex → messageId + timestamp for O(1) lookup final Map> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history) final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes - final Map> _pendingMessageQueuePerContact = {}; // contactPubKeyHex → FIFO queue of messageIds + final Map> _pendingMessageQueuePerContact = {}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed) + final Map _expectedHashToMessageId = {}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash) Function(Contact, String, int, int)? _sendMessageCallback; Function(String, Message)? _addMessageCallback; @@ -49,7 +53,10 @@ class MessageRetryService extends ChangeNotifier { Function(Contact)? _clearContactPathCallback; Function(Contact, Uint8List, int)? _setContactPathCallback; Function(int, int)? _calculateTimeoutCallback; + Uint8List? Function()? _getSelfPublicKeyCallback; + String Function(Contact, String)? _prepareContactOutboundTextCallback; AppSettingsService? _appSettingsService; + AppDebugLogService? _debugLogService; Function(String, PathSelection, bool, int?)? _recordPathResultCallback; MessageRetryService(this._storage); @@ -61,7 +68,10 @@ class MessageRetryService extends ChangeNotifier { Function(Contact)? clearContactPathCallback, Function(Contact, Uint8List, int)? setContactPathCallback, Function(int pathLength, int messageBytes)? calculateTimeoutCallback, + Uint8List? Function()? getSelfPublicKeyCallback, + String Function(Contact, String)? prepareContactOutboundTextCallback, AppSettingsService? appSettingsService, + AppDebugLogService? debugLogService, Function(String, PathSelection, bool, int?)? recordPathResultCallback, }) { _sendMessageCallback = sendMessageCallback; @@ -70,10 +80,46 @@ class MessageRetryService extends ChangeNotifier { _clearContactPathCallback = clearContactPathCallback; _setContactPathCallback = setContactPathCallback; _calculateTimeoutCallback = calculateTimeoutCallback; + _getSelfPublicKeyCallback = getSelfPublicKeyCallback; + _prepareContactOutboundTextCallback = prepareContactOutboundTextCallback; _appSettingsService = appSettingsService; + _debugLogService = debugLogService; _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 sendMessageWithRetry({ required Contact contact, required String text, @@ -136,14 +182,35 @@ class MessageRetryService extends ChangeNotifier { } 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]!.add(messageId); - debugPrint('Enqueued message $messageId for ${contact.name} (queue size: ${_pendingMessageQueuePerContact[contact.publicKeyHex]!.length})'); if (_sendMessageCallback != null) { - final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; _sendMessageCallback!( contact, message.text, @@ -156,35 +223,68 @@ class MessageRetryService extends ChangeNotifier { void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { 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 - // We iterate through contacts to find which one has a pending message in their queue - String? messageId; + // NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches) + String? messageId = _expectedHashToMessageId.remove(ackHashHex); Contact? contact; - for (var entry in _pendingMessageQueuePerContact.entries) { - final contactKey = entry.key; - final queue = entry.value; + if (messageId != null) { + contact = _pendingContacts[messageId]; + final message = _pendingMessages[messageId]; - if (queue.isNotEmpty) { - // Dequeue the first (oldest) message from this contact's queue - final candidateMessageId = queue.removeAt(0); + if (contact != null && message != null) { + final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text; + _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 - if (_pendingMessages.containsKey(candidateMessageId)) { - messageId = candidateMessageId; - contact = _pendingContacts[candidateMessageId]; - debugPrint('Dequeued message $messageId for $contactKey (remaining in queue: ${queue.length})'); - break; - } else { - debugPrint('Dequeued stale message $candidateMessageId - skipping'); - // Continue to next message in queue - if (queue.isNotEmpty) { - final nextMessageId = queue.removeAt(0); - if (_pendingMessages.containsKey(nextMessageId)) { - messageId = nextMessageId; - contact = _pendingContacts[nextMessageId]; - debugPrint('Dequeued next message $messageId for $contactKey (remaining: ${queue.length})'); - break; + // Remove from old queue since we matched + _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId); + if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) { + _pendingMessageQueuePerContact.remove(contact.publicKeyHex); + } + } else { + _debugLogService?.warn( + 'RESP_CODE_SENT: ACK hash $ackHashHex matched but message no longer pending', + tag: 'AckHash', + ); + debugPrint('Hash matched $messageId but message no longer pending'); + messageId = null; + contact = null; + } + } + + // 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) { - debugPrint('No pending message found for ACK hash: $ackHashHex (all queues empty or stale)'); + debugPrint('No pending message found for ACK hash: $ackHashHex'); return; } @@ -270,6 +370,11 @@ class MessageRetryService extends ChangeNotifier { 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})'); if (message.retryCount < maxRetries - 1) { @@ -287,8 +392,14 @@ class MessageRetryService extends ChangeNotifier { _updateMessageCallback!(updatedMessage); } + _debugLogService?.info( + 'Scheduling retry for "$shortText" to ${contact.name} after ${backoffMs}ms backoff', + tag: 'AckHash', + ); 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 if (_pendingMessages.containsKey(messageId)) { _attemptSend(messageId); @@ -388,6 +499,10 @@ class MessageRetryService extends ChangeNotifier { matchedMessageId = mapping.messageId; debugPrint('Matched ACK to message via direct lookup: $matchedMessageId'); } 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) debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)'); for (var entry in _expectedAckHashes.entries) { @@ -411,6 +526,12 @@ class MessageRetryService extends ChangeNotifier { final contact = _pendingContacts[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 _timeoutTimers[matchedMessageId]?.cancel(); _timeoutTimers.remove(matchedMessageId); @@ -448,8 +569,16 @@ class MessageRetryService extends ChangeNotifier { } else { // Check ACK history for recently completed messages 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'); } else { + _debugLogService?.error( + 'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex has no matching message!', + tag: 'AckHash', + ); debugPrint('No matching message found for ACK: $ackHashHex'); } } diff --git a/lib/services/repeater_command_service.dart b/lib/services/repeater_command_service.dart index 9024dcc..97a8a5a 100644 --- a/lib/services/repeater_command_service.dart +++ b/lib/services/repeater_command_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import '../models/contact.dart'; +import '../models/path_selection.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; @@ -11,7 +12,6 @@ class RepeaterCommandService { final Map _pendingByPrefix = {}; int _prefixCounter = 0; - static const int timeoutSeconds = 10; // Flood mode timeout static const int maxRetries = 5; RepeaterCommandService(this._connector); @@ -23,6 +23,7 @@ class RepeaterCommandService { String command, { Function(String)? onResponse, Function(int)? onAttempt, + int retries = maxRetries, }) async { final repeaterKey = repeater.publicKeyHex; final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey)); @@ -30,43 +31,83 @@ class RepeaterCommandService { 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 _sendCommandAttempt( + Contact repeater, + String command, + PathSelection selection, + int attempt, + ) async { + final repeaterKey = repeater.publicKeyHex; final commandId = '${repeaterKey}_${DateTime.now().millisecondsSinceEpoch}'; final completer = Completer(); _pendingCommands[commandId] = completer; - onAttempt?.call(0); - - // Send frame once (no retries) try { final prefix = _nextPrefixToken(); _commandPrefixes[commandId] = prefix; _pendingByPrefix[prefix] = commandId; 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); + _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) { _cleanup(commandId); 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 { - final response = await completer.future; - return response; + return await completer.future; } finally { _cleanup(commandId); } diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart new file mode 100644 index 0000000..6ada39b --- /dev/null +++ b/lib/utils/app_logger.dart @@ -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(); diff --git a/lib/widgets/battery_indicator.dart b/lib/widgets/battery_indicator.dart new file mode 100644 index 0000000..0b0da06 --- /dev/null +++ b/lib/widgets/battery_indicator.dart @@ -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 createState() => _BatteryIndicatorState(); +} + +class _BatteryIndicatorState extends State { + 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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/list_filter_widget.dart b/lib/widgets/list_filter_widget.dart new file mode 100644 index 0000000..fad54b3 --- /dev/null +++ b/lib/widgets/list_filter_widget.dart @@ -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 options; + + const SortFilterMenuSection({ + required this.title, + required this.options, + }); +} + +class SortFilterMenu extends StatelessWidget { + final List sections; + final ValueChanged 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( + 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 = >[]; + for (int i = 0; i < visibleSections.length; i++) { + final section = visibleSections[i]; + entries.add( + PopupMenuItem( + enabled: false, + child: Text(section.title, style: labelStyle), + ), + ); + for (final option in section.options) { + if (option.checked == null) { + entries.add( + PopupMenuItem( + value: option.value, + child: Text(option.label), + ), + ); + } else { + entries.add( + CheckedPopupMenuItem( + 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 onSortChanged; + final ValueChanged onTypeFilterChanged; + final ValueChanged 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; + } + }, + ); + } +} diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart new file mode 100644 index 0000000..0a25e31 --- /dev/null +++ b/lib/widgets/path_management_dialog.dart @@ -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 show( + BuildContext context, { + required Contact contact, + String title = 'Path Management', + }) { + return showDialog( + 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 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 _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( + 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'), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/path_selection_dialog.dart b/lib/widgets/path_selection_dialog.dart new file mode 100644 index 0000000..6496635 --- /dev/null +++ b/lib/widgets/path_selection_dialog.dart @@ -0,0 +1,312 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import '../models/contact.dart'; + +class PathSelectionDialog extends StatefulWidget { + final List 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 createState() => _PathSelectionDialogState(); + + static Future show( + BuildContext context, { + required List availableContacts, + String? initialPath, + String title = 'Enter Custom Path', + String? currentPathLabel, + VoidCallback? onRefresh, + }) { + return showDialog( + context: context, + builder: (context) => PathSelectionDialog( + availableContacts: availableContacts, + initialPath: initialPath, + title: title, + currentPathLabel: currentPathLabel, + onRefresh: onRefresh, + ), + ); + } +} + +class _PathSelectionDialogState extends State { + late TextEditingController _controller; + final List _selectedContacts = []; + List _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 _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 = []; + final invalidPrefixes = []; + + 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'), + ), + ], + ); + } +} diff --git a/lib/widgets/repeater_login_dialog.dart b/lib/widgets/repeater_login_dialog.dart index 2a54b47..91d6785 100644 --- a/lib/widgets/repeater_login_dialog.dart +++ b/lib/widgets/repeater_login_dialog.dart @@ -5,9 +5,10 @@ import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import '../models/contact.dart'; import '../services/storage_service.dart'; -import '../services/repeater_command_service.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; +import '../utils/app_logger.dart'; +import 'path_management_dialog.dart'; class RepeaterLoginDialog extends StatefulWidget { final Contact repeater; @@ -31,8 +32,7 @@ class _RepeaterLoginDialogState extends State { bool _obscurePassword = true; late MeshCoreConnector _connector; int _currentAttempt = 0; - final int _maxAttempts = RepeaterCommandService.maxRetries; - static const int _loginTimeoutSeconds = 10; + static const int _maxAttempts = 5; @override void initState() { @@ -65,6 +65,13 @@ class _RepeaterLoginDialogState extends State { bool _isLoggingIn = false; + Contact _resolveRepeater(MeshCoreConnector connector) { + return connector.contacts.firstWhere( + (c) => c.publicKeyHex == widget.repeater.publicKeyHex, + orElse: () => widget.repeater, + ); + } + Future _handleLogin() async { if (_isLoggingIn) return; @@ -75,6 +82,26 @@ class _RepeaterLoginDialogState extends State { try { 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; for (int attempt = 0; attempt < _maxAttempts; attempt++) { if (!mounted) return; @@ -82,17 +109,46 @@ class _RepeaterLoginDialogState extends State { _currentAttempt = attempt + 1; }); + appLogger.info( + 'Sending login attempt ${attempt + 1}/$_maxAttempts', + tag: 'RepeaterLogin', + ); await _connector.sendFrame( - buildSendLoginFrame(widget.repeater.publicKey, password), + loginFrame, ); - loginResult = await _awaitLoginResponse(); + loginResult = await _awaitLoginResponse(timeout); if (loginResult == true) { + appLogger.info( + 'Login succeeded for ${repeater.name}', + tag: 'RepeaterLogin', + ); break; } if (loginResult == false) { + appLogger.warn( + 'Login failed for ${repeater.name}', + tag: 'RepeaterLogin', + ); 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) { @@ -114,6 +170,11 @@ class _RepeaterLoginDialogState extends State { Future.microtask(() => widget.onLogin(password)); } } catch (e) { + final repeater = _resolveRepeater(_connector); + appLogger.warn( + 'Login error for ${repeater.name}: $e', + tag: 'RepeaterLogin', + ); if (mounted) { setState(() { _isLoggingIn = false; @@ -128,7 +189,7 @@ class _RepeaterLoginDialogState extends State { } } - Future _awaitLoginResponse() async { + Future _awaitLoginResponse(Duration timeout) async { final completer = Completer(); Timer? timer; StreamSubscription? subscription; @@ -147,7 +208,7 @@ class _RepeaterLoginDialogState extends State { timer?.cancel(); }); - timer = Timer(const Duration(seconds: _loginTimeoutSeconds), () { + timer = Timer(timeout, () { if (!completer.isCompleted) { completer.complete(null); subscription?.cancel(); @@ -162,6 +223,9 @@ class _RepeaterLoginDialogState extends State { @override Widget build(BuildContext context) { + final connector = context.watch(); + final repeater = _resolveRepeater(connector); + final isFloodMode = repeater.pathOverride == -1; return AlertDialog( title: Row( children: [ @@ -173,7 +237,7 @@ class _RepeaterLoginDialogState extends State { children: [ const Text('Repeater Login'), Text( - widget.repeater.name, + repeater.name, style: TextStyle( fontSize: 14, fontWeight: FontWeight.normal, @@ -244,6 +308,73 @@ class _RepeaterLoginDialogState extends State { controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), + const Divider(), + Row( + children: [ + const Text( + 'Routing', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), + ), + const Spacer(), + PopupMenuButton( + 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: [ @@ -268,7 +399,7 @@ class _RepeaterLoginDialogState extends State { ), ), const SizedBox(width: 12), - Text('Retries $_currentAttempt/$_maxAttempts'), + Text('Attempt $_currentAttempt/$_maxAttempts'), ], ), ), diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index dddb8a3..8f679c5 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.server + com.apple.security.device.bluetooth + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index 4789daa..d054e5d 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -28,5 +28,7 @@ MainMenu NSPrincipalClass NSApplication + NSBluetoothAlwaysUsageDescription + MeshCore needs Bluetooth to communicate with LoRa mesh devices diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a..c269f52 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,7 @@ com.apple.security.app-sandbox + com.apple.security.device.bluetooth +