From d2e6b692f3b24588298d9d5065be287cb408cd0e Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 12 Mar 2026 19:38:41 +0100 Subject: [PATCH] Fix voice quality regression --- lib/providers/app_provider.dart | 136 +++- lib/providers/connection_provider.dart | 25 +- lib/providers/contacts_provider.dart | 2 +- lib/providers/voice_provider.dart | 29 +- lib/screens/contacts_tab.dart | 21 +- lib/screens/device_config_screen.dart | 111 +++ lib/screens/messages_tab.dart | 90 ++- lib/screens/settings_screen.dart | 75 ++- lib/services/voice_codec_service_io.dart | 37 + lib/services/voice_recorder_service.dart | 88 ++- .../contacts/contact_route_dialog.dart | 634 ++++++++++++------ lib/widgets/contacts/contact_tile.dart | 28 +- lib/widgets/messages/message_bubble.dart | 14 +- .../messages/message_bubble_header.dart | 34 +- 14 files changed, 1023 insertions(+), 301 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 41debd7..33d4bb2 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -91,6 +91,12 @@ class AppProvider with ChangeNotifier { bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled; bool _isVoiceLimiterEnabled = true; bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled; + bool _isVoiceAutoGainEnabled = false; + bool get isVoiceAutoGainEnabled => _isVoiceAutoGainEnabled; + bool _isVoiceEchoCancellationEnabled = false; + bool get isVoiceEchoCancellationEnabled => _isVoiceEchoCancellationEnabled; + bool _isVoiceNoiseSuppressionEnabled = false; + bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled; double _messageFontScale = 1.0; double get messageFontScale => _messageFontScale; bool _autoAddDiscoveredContacts = false; @@ -142,6 +148,8 @@ class AppProvider with ChangeNotifier { required this.imageProvider, }) { _setupCallbacks(); + connectionProvider.canStartAutomaticMessageSyncCallback = + _canStartAutomaticMessageSync; _wasDeviceConnected = connectionProvider.deviceInfo.isConnected; _initializeLocationTracking(); _loadMapEnabled(); @@ -151,6 +159,9 @@ class AppProvider with ChangeNotifier { _loadVoiceBandPassFilterEnabled(); _loadVoiceCompressorEnabled(); _loadVoiceLimiterEnabled(); + _loadVoiceAutoGainEnabled(); + _loadVoiceEchoCancellationEnabled(); + _loadVoiceNoiseSuppressionEnabled(); _loadMessageFontScale(); _loadAutoAddDiscoveredContacts(); _loadMessagingRouteSettings(); @@ -439,6 +450,72 @@ class AppProvider with ChangeNotifier { } } + Future _loadVoiceAutoGainEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isVoiceAutoGainEnabled = + prefs.getBool('voice_auto_gain_enabled') ?? false; + notifyListeners(); + } catch (e) { + debugPrint('Error loading voice auto gain setting: $e'); + } + } + + Future toggleVoiceAutoGainEnabled(bool enabled) async { + try { + _isVoiceAutoGainEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('voice_auto_gain_enabled', enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving voice auto gain setting: $e'); + } + } + + Future _loadVoiceEchoCancellationEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isVoiceEchoCancellationEnabled = + prefs.getBool('voice_echo_cancellation_enabled') ?? false; + notifyListeners(); + } catch (e) { + debugPrint('Error loading voice echo cancellation setting: $e'); + } + } + + Future toggleVoiceEchoCancellationEnabled(bool enabled) async { + try { + _isVoiceEchoCancellationEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('voice_echo_cancellation_enabled', enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving voice echo cancellation setting: $e'); + } + } + + Future _loadVoiceNoiseSuppressionEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + _isVoiceNoiseSuppressionEnabled = + prefs.getBool('voice_noise_suppression_enabled') ?? false; + notifyListeners(); + } catch (e) { + debugPrint('Error loading voice noise suppression setting: $e'); + } + } + + Future toggleVoiceNoiseSuppressionEnabled(bool enabled) async { + try { + _isVoiceNoiseSuppressionEnabled = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('voice_noise_suppression_enabled', enabled); + notifyListeners(); + } catch (e) { + debugPrint('Error saving voice noise suppression setting: $e'); + } + } + Future _loadMessageFontScale() async { try { final prefs = await SharedPreferences.getInstance(); @@ -1642,6 +1719,8 @@ class AppProvider with ChangeNotifier { if (!connectionProvider.deviceInfo.isConnected) return; try { + _isReconnectSyncInProgress = true; + _hasCompletedConnectionBootstrap = false; // Initialize contacts provider with device public key to exclude self // If already initialized (from early load), this will just filter out self-contact // This must happen before getContacts to ensure proper filtering @@ -1693,7 +1772,9 @@ class AppProvider with ChangeNotifier { debugPrint( 'πŸ”„ [AppProvider] Performing initial message sync (fallback for missed pushes)', ); - final initialMessageCount = await connectionProvider.syncAllMessages(); + final initialMessageCount = await connectionProvider.syncAllMessages( + force: true, + ); debugPrint( 'πŸ“₯ [AppProvider] Initial sync retrieved $initialMessageCount message(s)', ); @@ -1715,19 +1796,26 @@ class AppProvider with ChangeNotifier { _hasCompletedConnectionBootstrap = true; _wasDeviceConnected = connectionProvider.deviceInfo.isConnected; + await _flushDeferredAutomaticMessageSync(); notifyListeners(); } catch (e) { debugPrint('Initialization error: $e'); + } finally { + _isReconnectSyncInProgress = false; } } - Future _syncAfterReconnect() async { - if (_isReconnectSyncInProgress || - !connectionProvider.deviceInfo.isConnected) { + Future _syncAfterReconnect({bool started = false}) async { + if (!connectionProvider.deviceInfo.isConnected) { return; } - _isReconnectSyncInProgress = true; + if (!started) { + if (_isReconnectSyncInProgress) { + return; + } + _isReconnectSyncInProgress = true; + } try { debugPrint( 'πŸ”„ [AppProvider] Device reconnected - syncing contacts and missed messages', @@ -1738,14 +1826,19 @@ class AppProvider with ChangeNotifier { ); await connectionProvider.getContacts(); - final messageCount = await connectionProvider.syncAllMessages(); + final messageCount = await connectionProvider.syncAllMessages( + force: true, + ); debugPrint( 'πŸ“₯ [AppProvider] Reconnect sync retrieved $messageCount message(s)', ); } catch (e) { debugPrint('❌ [AppProvider] Reconnect sync error: $e'); } finally { + _hasCompletedConnectionBootstrap = + connectionProvider.deviceInfo.isConnected; _isReconnectSyncInProgress = false; + await _flushDeferredAutomaticMessageSync(); } } @@ -2737,7 +2830,9 @@ class AppProvider with ChangeNotifier { debugPrint( 'πŸ”„ [AppProvider] Manual message sync requested (user initiated)', ); - final messageCount = await connectionProvider.syncAllMessages(); + final messageCount = await connectionProvider.syncAllMessages( + force: true, + ); debugPrint( 'βœ… [AppProvider] Manual sync completed: $messageCount messages', ); @@ -2766,9 +2861,32 @@ class AppProvider with ChangeNotifier { _stopLocationTracking(); } - if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) { - unawaited(_syncAfterReconnect()); + if (!isConnected) { + connectionProvider.clearPendingAutomaticMessageSync(); } + + if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) { + _isReconnectSyncInProgress = true; + unawaited(_syncAfterReconnect(started: true)); + } + } + + bool _canStartAutomaticMessageSync() { + return connectionProvider.deviceInfo.isConnected && + _hasCompletedConnectionBootstrap && + !_isReconnectSyncInProgress; + } + + Future _flushDeferredAutomaticMessageSync() async { + if (!connectionProvider.hasPendingAutomaticMessageSync || + !_canStartAutomaticMessageSync()) { + return; + } + + debugPrint( + 'πŸ”„ [AppProvider] Running deferred automatic message sync after bootstrap', + ); + await connectionProvider.syncAllMessages(force: true); } /// Start location tracking diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index b9620bd..89f439f 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -186,9 +186,11 @@ class ConnectionProvider with ChangeNotifier { Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback; + bool Function()? canStartAutomaticMessageSyncCallback; // Track pending send operations for auto-recovery final Map _pendingSendOperations = {}; + bool _pendingAutomaticMessageSync = false; ConnectionProvider() { _wireServiceCallbacks(_bleService); @@ -333,6 +335,13 @@ class ConnectionProvider with ChangeNotifier { debugPrint('πŸ“₯ [Provider] MSG_WAITING ignored during spectrum scan'); return; } + if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) { + _pendingAutomaticMessageSync = true; + debugPrint( + 'πŸ“₯ [Provider] MSG_WAITING deferred until connection bootstrap completes', + ); + return; + } debugPrint('πŸ“₯ [Provider] MSG_WAITING - auto-syncing'); if (_isSyncingMessages) { _syncRequestedWhileBusy = true; @@ -1894,11 +1903,18 @@ class ConnectionProvider with ChangeNotifier { } /// Sync all waiting messages from device - Future syncAllMessages() async { + Future syncAllMessages({bool force = false}) async { if (_isSpectrumScanActive) { debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan'); return 0; } + if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) { + _pendingAutomaticMessageSync = true; + debugPrint( + '⏸️ [Provider] Message sync deferred until connection bootstrap completes', + ); + return 0; + } if (_isSyncingMessages) { // Already syncing; avoid overlapping loops _syncRequestedWhileBusy = true; @@ -1914,6 +1930,7 @@ class ConnectionProvider with ChangeNotifier { int totalCount = 0; try { + _pendingAutomaticMessageSync = false; _isSyncingMessages = true; do { _syncRequestedWhileBusy = false; @@ -2005,6 +2022,12 @@ class ConnectionProvider with ChangeNotifier { } } + bool get hasPendingAutomaticMessageSync => _pendingAutomaticMessageSync; + + void clearPendingAutomaticMessageSync() { + _pendingAutomaticMessageSync = false; + } + /// Login to a room or repeater /// /// Sends login request with password. Results will be delivered via diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index e06f41b..d2c62fb 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -416,7 +416,7 @@ class ContactsProvider with ChangeNotifier { final existingAdvertLocation = existingContact.advertLocation; var updatedContact = incomingContact.copyWith( - isNew: existingContact.isNew, + isNew: false, advertHistory: existingContact.advertHistory, telemetry: mergedTelemetry, outPathLen: diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 4ebdf81..abbd20a 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; @@ -299,7 +300,11 @@ class VoiceProvider with ChangeNotifier { ); try { - final pcm = await _codec.decodePackets(session.packets, session.mode); + final decodedPcm = await _codec.decodePackets( + session.packets, + session.mode, + ); + final pcm = _preparePlaybackPcm(decodedPcm, session.mode); debugPrint('πŸŽ™οΈ [VoiceProvider] decoded ${pcm.length} PCM samples'); _playingSessionId = sessionId; notifyListeners(); @@ -319,6 +324,28 @@ class VoiceProvider with ChangeNotifier { notifyListeners(); } + Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) { + if (pcm.isEmpty) { + return pcm; + } + + if (mode.codec != VoiceCodecKind.lpcnet) { + return pcm; + } + + final output = Int16List(pcm.length); + var dc = 0.0; + const dcAlpha = 0.995; + + for (var i = 0; i < pcm.length; i++) { + final sample = pcm[i].toDouble(); + dc = (dcAlpha * dc) + ((1.0 - dcAlpha) * sample); + final filtered = sample - dc; + output[i] = filtered.clamp(-32768.0, 32767.0).round(); + } + return output; + } + Future clearStoredVoiceData() async { _sessions.clear(); _outgoingSessions.clear(); diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index 9d2d546..cfa123b 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -39,6 +39,7 @@ class _ContactsTabState extends State { ContactSection.rooms: '', ContactSection.channels: '', }; + late final Map _filterControllers; final Map _sortModes = { ContactSection.teamMembers: ContactSortMode.lastSeen, ContactSection.repeaters: ContactSortMode.lastSeen, @@ -48,6 +49,10 @@ class _ContactsTabState extends State { @override void initState() { super.initState(); + _filterControllers = { + for (final section in ContactSection.values) + section: TextEditingController(text: _sectionFilters[section] ?? ''), + }; _getCurrentLocation(); // Mark all contacts as viewed when tab is opened WidgetsBinding.instance.addPostFrameCallback((_) { @@ -55,6 +60,14 @@ class _ContactsTabState extends State { }); } + @override + void dispose() { + for (final controller in _filterControllers.values) { + controller.dispose(); + } + super.dispose(); + } + Future _getCurrentLocation() async { try { final position = await Geolocator.getCurrentPosition( @@ -513,6 +526,7 @@ class _ContactsTabState extends State { ) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + final controller = _filterControllers[section]!; final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty; return Padding( @@ -555,10 +569,7 @@ class _ContactsTabState extends State { ), Expanded( child: TextFormField( - key: ValueKey( - '${section.name}:${_sectionFilters[section] ?? ''}', - ), - initialValue: _sectionFilters[section] ?? '', + controller: controller, onChanged: (value) { setState(() { _sectionFilters[section] = value; @@ -598,6 +609,7 @@ class _ContactsTabState extends State { child: InkWell( customBorder: const CircleBorder(), onTap: () { + controller.clear(); setState(() { _sectionFilters[section] = ''; }); @@ -856,6 +868,7 @@ class _InferredContactGroupCard extends StatelessWidget { ...contacts.map( (contact) => ContactTile( contact: contact, + groupLabel: label, currentPosition: currentPosition, calculateDistance: calculateDistance, formatDistance: formatDistance, diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index 1a13b84..323419e 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; +import '../models/device_info.dart'; import '../providers/connection_provider.dart'; import '../services/validation_service.dart'; import '../l10n/app_localizations.dart'; @@ -97,6 +98,10 @@ class _DeviceConfigScreenState extends State { context.read().getAllowedRepeatFreq(); }); } + + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().getBatteryAndStorage(); + }); } @override @@ -432,6 +437,13 @@ class _DeviceConfigScreenState extends State { icon: Icons.key, label: 'FW v${deviceInfo.firmwareVersion?.toString() ?? "?"}', ), + if (deviceInfo.storageUsedKb != null && + deviceInfo.storageTotalKb != null) + _StatusChipData( + icon: Icons.storage_rounded, + label: + '${_formatStorage(deviceInfo.storageUsedKb!)} / ${_formatStorage(deviceInfo.storageTotalKb!)}', + ), ], ), const SizedBox(height: 20), @@ -485,6 +497,19 @@ class _DeviceConfigScreenState extends State { deviceInfo.maxChannels?.toString() ?? AppLocalizations.of(context)!.unknown, ), + _InfoRow( + 'Storage used', + _formatStorageValue(deviceInfo.storageUsedKb), + ), + _InfoRow( + 'Storage limit', + _formatStorageValue(deviceInfo.storageTotalKb), + ), + _InfoRow('Storage status', _formatStorageStatus(deviceInfo)), + if (deviceInfo.storageUsedPercent != null) ...[ + const SizedBox(height: 10), + _StorageUsageMeter(deviceInfo: deviceInfo), + ], _CopyableInfoRow( AppLocalizations.of(context)!.publicKey, _getPublicKeyHex(deviceInfo.publicKey), @@ -793,6 +818,35 @@ class _DeviceConfigScreenState extends State { if (publicKey == null || publicKey.isEmpty) return 'N/A'; return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); } + + String _formatStorageValue(int? storageKb) { + if (storageKb == null) { + return AppLocalizations.of(context)!.unknown; + } + return _formatStorage(storageKb); + } + + String _formatStorageStatus(DeviceInfo deviceInfo) { + final used = deviceInfo.storageUsedKb; + final total = deviceInfo.storageTotalKb; + final percent = deviceInfo.storageUsedPercent; + + if (used == null || total == null || percent == null) { + return AppLocalizations.of(context)!.unknown; + } + + return '${percent.toStringAsFixed(0)}% full (${_formatStorage(total - used)} free)'; + } + + String _formatStorage(int storageKb) { + if (storageKb >= 1024 * 1024) { + return '${(storageKb / (1024 * 1024)).toStringAsFixed(2)} GB'; + } + if (storageKb >= 1024) { + return '${(storageKb / 1024).toStringAsFixed(1)} MB'; + } + return '$storageKb KB'; + } } class _ConfigHeroCard extends StatelessWidget { @@ -1162,3 +1216,60 @@ class _CopyableInfoRow extends StatelessWidget { ); } } + +class _StorageUsageMeter extends StatelessWidget { + final DeviceInfo deviceInfo; + + const _StorageUsageMeter({required this.deviceInfo}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final percent = ((deviceInfo.storageUsedPercent ?? 0) / 100).clamp( + 0.0, + 1.0, + ); + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.storage_rounded, color: colorScheme.primary, size: 18), + const SizedBox(width: 8), + Text( + 'Device storage', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: percent, + minHeight: 10, + backgroundColor: colorScheme.surface, + ), + ), + const SizedBox(height: 10), + Text( + '${(deviceInfo.storageUsedPercent ?? 0).toStringAsFixed(0)}% used', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index a7c3fea..94874a7 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -72,7 +72,7 @@ class _MessagesTabState extends State { final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); bool _isRecording = false; bool _isSendingVoice = false; - static const int _maxVoicePackets = 10; + static const Duration _maxVoiceRecordingDuration = Duration(seconds: 4); static const double _silenceRmsThreshold = 500.0; static const double _silencePeakThreshold = 1400.0; static const int _maxInteriorSilentChunks = 2; @@ -865,9 +865,10 @@ class _MessagesTabState extends State { final packetDuration = Duration( milliseconds: _activeVoiceMode!.packetDurationMs, ); + final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!); debugPrint( - 'πŸŽ™οΈ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms', + 'πŸŽ™οΈ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets', ); _recordedChunks.clear(); @@ -877,9 +878,13 @@ class _MessagesTabState extends State { final stream = _voiceRecorder.startCapture( chunkDuration: packetDuration, sampleRateHz: _activeVoiceMode!.sampleRateHz, + codecKind: _activeVoiceMode!.codec, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled, + enableAutoGain: appProvider.isVoiceAutoGainEnabled, + enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled, + enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled, ); debugPrint('πŸŽ™οΈ [Voice] capture started, listening for chunks...'); _voiceStreamSub = stream.listen( @@ -890,7 +895,7 @@ class _MessagesTabState extends State { 'πŸŽ™οΈ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples', ); setState(() {}); - if (_recordedChunks.length >= _maxVoicePackets) { + if (_recordedChunks.length >= maxVoicePackets) { debugPrint('πŸŽ™οΈ [Voice] max packets reached, stopping'); _stopAndSendVoice(); } @@ -907,6 +912,12 @@ class _MessagesTabState extends State { } } + int _maxVoicePacketsForMode(VoicePacketMode mode) { + final packets = + _maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs; + return packets < 1 ? 1 : packets; + } + Future _stopAndSendVoice() async { if (!_isRecording) return; final trimSilenceEnabled = context @@ -921,9 +932,14 @@ class _MessagesTabState extends State { await _voiceRecorder.stopCapture(); final rawChunks = List.from(_recordedChunks); - final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks; + final trimmedChunks = trimSilenceEnabled + ? _trimSilence(rawChunks) + : rawChunks; final sessionId = _currentVoiceSessionId; final mode = _activeVoiceMode; + final chunks = mode == null + ? trimmedChunks + : _prepareChunksForSending(trimmedChunks, mode); _recordedChunks.clear(); debugPrint( @@ -1005,10 +1021,15 @@ class _MessagesTabState extends State { debugPrint( 'πŸŽ™οΈ [Voice] encoding $total packets for deferred voice fetch, mode=${mode.label}, session=$sessionId', ); + final encodedChunks = mode.codec == VoiceCodecKind.lpcnet + ? await _encodeLpcNetChunks(codec, chunks, mode) + : []; for (var i = 0; i < total; i++) { if (!mounted) return; try { - final codec2Data = await codec.encode(chunks[i], mode); + final codec2Data = mode.codec == VoiceCodecKind.lpcnet + ? encodedChunks[i] + : await codec.encode(chunks[i], mode); debugPrint( 'πŸŽ™οΈ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes', ); @@ -1112,6 +1133,43 @@ class _MessagesTabState extends State { messagesProvider.markMessageSent(msgId, 0, 0); } + Future> _encodeLpcNetChunks( + VoiceCodecService codec, + List chunks, + VoicePacketMode mode, + ) async { + final totalSamples = chunks.fold( + 0, + (sum, chunk) => sum + chunk.length, + ); + final merged = Int16List(totalSamples); + final encodedByteLengths = []; + var sampleOffset = 0; + for (final chunk in chunks) { + merged.setRange(sampleOffset, sampleOffset + chunk.length, chunk); + sampleOffset += chunk.length; + encodedByteLengths.add((chunk.length ~/ 640) * 8); + } + + final encoded = await codec.encode(merged, mode); + final encodedChunks = []; + var byteOffset = 0; + for (final length in encodedByteLengths) { + encodedChunks.add( + Uint8List.sublistView(encoded, byteOffset, byteOffset + length), + ); + byteOffset += length; + } + return encodedChunks; + } + + List _prepareChunksForSending( + List chunks, + VoicePacketMode mode, + ) { + return chunks; + } + List _trimSilence(List chunks) { if (chunks.isEmpty) return chunks; @@ -1141,16 +1199,28 @@ class _MessagesTabState extends State { bool _isSilentChunk(Int16List chunk) { if (chunk.isEmpty) return true; + final rms = _chunkRms(chunk); + final peak = _chunkPeak(chunk); + return rms < _silenceRmsThreshold && peak < _silencePeakThreshold; + } + + double _chunkRms(Int16List chunk) { var sumSquares = 0.0; + for (final sample in chunk) { + sumSquares += sample * sample; + } + return math.sqrt(sumSquares / chunk.length); + } + + int _chunkPeak(Int16List chunk) { var peak = 0; for (final sample in chunk) { final absSample = sample.abs(); - if (absSample > peak) peak = absSample; - sumSquares += sample * sample; + if (absSample > peak) { + peak = absSample; + } } - - final rms = math.sqrt(sumSquares / chunk.length); - return rms < _silenceRmsThreshold && peak < _silencePeakThreshold; + return peak; } bool _isPublicChannelSelected() { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e8bb09d..63a5d59 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1185,6 +1185,11 @@ class _SettingsScreenState extends State { bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, compressorEnabled: appProvider.isVoiceCompressorEnabled, limiterEnabled: appProvider.isVoiceLimiterEnabled, + autoGainEnabled: appProvider.isVoiceAutoGainEnabled, + echoCancellationEnabled: + appProvider.isVoiceEchoCancellationEnabled, + noiseSuppressionEnabled: + appProvider.isVoiceNoiseSuppressionEnabled, silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, ), ), @@ -1241,6 +1246,43 @@ class _SettingsScreenState extends State { }, ), ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.auto_fix_high), + title: const Text('Mic auto gain'), + subtitle: const Text('Lets the recorder adjust input level'), + value: appProvider.isVoiceAutoGainEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceAutoGainEnabled(value); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.hearing_disabled), + title: const Text('Echo cancellation'), + subtitle: const Text( + 'Uses recorder echo cancellation if available', + ), + value: appProvider.isVoiceEchoCancellationEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceEchoCancellationEnabled(value); + }, + ), + ), + Consumer( + builder: (context, appProvider, child) => SwitchListTile( + secondary: const Icon(Icons.noise_control_off), + title: const Text('Noise suppression'), + subtitle: const Text( + 'Uses recorder noise suppression if available', + ), + value: appProvider.isVoiceNoiseSuppressionEnabled, + onChanged: (value) async { + await appProvider.toggleVoiceNoiseSuppressionEnabled(value); + }, + ), + ), Consumer( builder: (context, appProvider, child) => SwitchListTile( secondary: const Icon(Icons.content_cut), @@ -1761,6 +1803,9 @@ class _SettingsScreenState extends State { required bool bandPassEnabled, required bool compressorEnabled, required bool limiterEnabled, + required bool autoGainEnabled, + required bool echoCancellationEnabled, + required bool noiseSuppressionEnabled, required bool silenceTrimEnabled, }) { final supported = VoiceBitratePreferences.supportedBitrates; @@ -1773,6 +1818,9 @@ class _SettingsScreenState extends State { (bandPassEnabled ? 1 : 0) + (compressorEnabled ? 1 : 0) + (limiterEnabled ? 1 : 0) + + (autoGainEnabled ? 1 : 0) + + (echoCancellationEnabled ? 1 : 0) + + (noiseSuppressionEnabled ? 1 : 0) + (silenceTrimEnabled ? 1 : 0); final radioBw = connectionProvider.deviceInfo.radioBw; final radioSf = connectionProvider.deviceInfo.radioSf; @@ -1864,6 +1912,31 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _voiceStatChip( + label: 'Auto gain', + enabled: autoGainEnabled, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _voiceStatChip( + label: 'Echo cancel', + enabled: echoCancellationEnabled, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _voiceStatChip( + label: 'Noise suppress', + enabled: noiseSuppressionEnabled, + ), + ), + ], + ), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -1876,7 +1949,7 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Processing enabled: $enabledCount/4', + 'Processing enabled: $enabledCount/7', style: Theme.of(context).textTheme.bodySmall, ), ], diff --git a/lib/services/voice_codec_service_io.dart b/lib/services/voice_codec_service_io.dart index 2f41a5f..87e8d30 100644 --- a/lib/services/voice_codec_service_io.dart +++ b/lib/services/voice_codec_service_io.dart @@ -73,6 +73,9 @@ class VoiceCodecService { VoicePacketMode mode, ) async { _ensureCodec2Supported(); + if (mode.codec == VoiceCodecKind.lpcnet) { + return _decodeLpcNetPackets(packets, mode); + } final all = []; for (final pkt in packets) { if (pkt == null || pkt.codec2Data.isEmpty) { @@ -90,4 +93,38 @@ class VoiceCodecService { } return result; } + + Future _decodeLpcNetPackets( + List packets, + VoicePacketMode mode, + ) async { + final segments = []; + final run = []; + + Future flushRun() async { + if (run.isEmpty) return; + final decoded = await LpcNet.decodeInIsolate(Uint8List.fromList(run)); + segments.add(decoded); + run.clear(); + } + + for (final pkt in packets) { + if (pkt == null || pkt.codec2Data.isEmpty) { + await flushRun(); + segments.add(Int16List(mode.samplesPerPacket)); + continue; + } + run.addAll(pkt.codec2Data); + } + await flushRun(); + + final total = segments.fold(0, (sum, chunk) => sum + chunk.length); + final result = Int16List(total); + var offset = 0; + for (final chunk in segments) { + result.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + return result; + } } diff --git a/lib/services/voice_recorder_service.dart b/lib/services/voice_recorder_service.dart index 0961c2f..4685707 100644 --- a/lib/services/voice_recorder_service.dart +++ b/lib/services/voice_recorder_service.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:record/record.dart'; +import '../utils/voice_message_parser.dart'; /// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono. /// @@ -31,9 +32,13 @@ class VoiceRecorderService { Stream startCapture({ Duration chunkDuration = const Duration(seconds: 1), int sampleRateHz = 8000, + VoiceCodecKind codecKind = VoiceCodecKind.codec2, bool enableBandPassFilter = true, bool enableCompressor = true, bool enableLimiter = true, + bool enableAutoGain = false, + bool enableEchoCancellation = false, + bool enableNoiseSuppression = false, }) { if (_isRecording) { throw StateError('VoiceRecorderService: already recording'); @@ -45,9 +50,13 @@ class VoiceRecorderService { _startRecording( chunkDuration, sampleRateHz: sampleRateHz, + codecKind: codecKind, enableBandPassFilter: enableBandPassFilter, enableCompressor: enableCompressor, enableLimiter: enableLimiter, + enableAutoGain: enableAutoGain, + enableEchoCancellation: enableEchoCancellation, + enableNoiseSuppression: enableNoiseSuppression, ); return _controller!.stream; } @@ -55,29 +64,49 @@ class VoiceRecorderService { Future _startRecording( Duration chunkDuration, { required int sampleRateHz, + required VoiceCodecKind codecKind, required bool enableBandPassFilter, required bool enableCompressor, required bool enableLimiter, + required bool enableAutoGain, + required bool enableEchoCancellation, + required bool enableNoiseSuppression, }) async { + final bypassProcessing = codecKind == VoiceCodecKind.lpcnet; + final useBandPassFilter = !bypassProcessing && enableBandPassFilter; + final useCompressor = !bypassProcessing && enableCompressor; + final useLimiter = !bypassProcessing && enableLimiter; final config = RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: sampleRateHz, numChannels: 1, bitRate: 128000, // ignored for PCM, but required by API + autoGain: enableAutoGain, + echoCancel: enableEchoCancellation, + noiseSuppress: enableNoiseSuppression, ); try { final stream = await _recorder.startStream(config); - final voiceFilter = _VoiceBandPassFilter( - sampleRate: sampleRateHz, - lowCutHz: 250.0, - highCutHz: 3400.0, - ); - final dynamics = _VoiceDynamicsProcessor( - sampleRate: sampleRateHz, - enableCompressor: enableCompressor, - enableLimiter: enableLimiter, - ); + final voiceFilter = bypassProcessing + ? null + : _VoiceBandPassFilter( + sampleRate: sampleRateHz, + lowCutHz: 250.0, + highCutHz: 3400.0, + ); + final dynamics = bypassProcessing + ? null + : _VoiceDynamicsProcessor( + sampleRate: sampleRateHz, + thresholdDb: -18.0, + ratio: 2.5, + attackMs: 8.0, + releaseMs: 120.0, + makeupGainDb: 4.0, + enableCompressor: useCompressor, + enableLimiter: useLimiter, + ); final chunkBytes = sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000; final buffer = []; @@ -89,20 +118,28 @@ class VoiceRecorderService { final chunk = buffer.sublist(0, chunkBytes); buffer.removeRange(0, chunkBytes); final pcm = _bytesToInt16(Uint8List.fromList(chunk)); - final filtered = enableBandPassFilter - ? voiceFilter.process(pcm) - : pcm; - _controller?.add(dynamics.process(filtered)); + if (bypassProcessing) { + _controller?.add(pcm); + } else { + final filtered = useBandPassFilter + ? voiceFilter!.process(pcm) + : pcm; + _controller?.add(dynamics!.process(filtered)); + } } }, onDone: () { if (buffer.isNotEmpty) { final padded = _padToEven(buffer); final pcm = _bytesToInt16(Uint8List.fromList(padded)); - final filtered = enableBandPassFilter - ? voiceFilter.process(pcm) - : pcm; - _controller?.add(dynamics.process(filtered)); + if (bypassProcessing) { + _controller?.add(pcm); + } else { + final filtered = useBandPassFilter + ? voiceFilter!.process(pcm) + : pcm; + _controller?.add(dynamics!.process(filtered)); + } } _controller?.close(); }, @@ -167,17 +204,22 @@ class _VoiceDynamicsProcessor { _VoiceDynamicsProcessor({ required int sampleRate, + required double thresholdDb, + required double ratio, + required double attackMs, + required double releaseMs, + required double makeupGainDb, required bool enableCompressor, required bool enableLimiter, }) : _enableCompressor = enableCompressor, _enableLimiter = enableLimiter, _compressor = _SimpleCompressor( sampleRate: sampleRate.toDouble(), - thresholdDb: -18.0, - ratio: 2.5, - attackMs: 8.0, - releaseMs: 120.0, - makeupGainDb: 4.0, + thresholdDb: thresholdDb, + ratio: ratio, + attackMs: attackMs, + releaseMs: releaseMs, + makeupGainDb: makeupGainDb, ), _limiter = _PeakLimiter(ceilingDb: -1.0); diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index 80db51d..c27282c 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -8,7 +8,9 @@ import '../../models/contact.dart'; import '../../providers/connection_provider.dart'; import '../../providers/app_provider.dart'; import '../../services/contact_route_resolver.dart'; +import '../../services/path_history_service.dart'; import '../../services/route_hash_preferences.dart'; +import '../../models/path_history.dart'; class ContactRouteDialogResult { final ParsedContactRoute? route; @@ -75,11 +77,14 @@ class ContactRouteDialog extends StatefulWidget { class _ContactRouteDialogState extends State { late final TextEditingController _controller; + final PathHistoryService _pathHistoryService = PathHistoryService(); int _selectedHashSize = RouteHashPreferences.defaultHashSize; ParsedContactRoute? _parsedRoute; String? _errorText; bool _showRoutingInfo = false; List _selectedMapHops = const []; + ContactPathHistory? _pathHistory; + _RouteEntryMode _entryMode = _RouteEntryMode.map; @override void initState() { @@ -88,7 +93,11 @@ class _ContactRouteDialogState extends State { text: widget.contact.routeCanonicalText, ); _controller.addListener(_reparse); + _entryMode = widget.contact.routeCanonicalText.isNotEmpty + ? _RouteEntryMode.manual + : _RouteEntryMode.map; _loadHashSizePreference(); + _loadPathHistory(); _reparse(); } @@ -106,6 +115,7 @@ class _ContactRouteDialogState extends State { setState(() { _parsedRoute = null; _errorText = null; + _selectedMapHops = const []; }); return; } @@ -115,9 +125,11 @@ class _ContactRouteDialogState extends State { input, expectedHashSize: _selectedHashSize, ); + final selectedMapHops = _mapSelectionForText(input); setState(() { _parsedRoute = parsed; _errorText = null; + _selectedMapHops = selectedMapHops; }); } on ContactRouteFormatException catch (error) { setState(() { @@ -135,8 +147,8 @@ class _ContactRouteDialogState extends State { .toList() ..sort((a, b) => a.displayName.compareTo(b.displayName)); - void _syncMapSelectionFromController() { - final tokens = _controller.text + List _mapSelectionForText(String text) { + final tokens = text .trim() .split(',') .map((token) => token.trim().toUpperCase()) @@ -154,7 +166,7 @@ class _ContactRouteDialogState extends State { selected.add(match); } } - _selectedMapHops = selected; + return selected; } Future _loadHashSizePreference() async { @@ -164,7 +176,16 @@ class _ContactRouteDialogState extends State { _selectedHashSize = hashSize; }); _reparse(); - _syncMapSelectionFromController(); + } + + Future _loadPathHistory() async { + await _pathHistoryService.initialize(); + if (!mounted) return; + setState(() { + _pathHistory = _pathHistoryService.historyFor( + widget.contact.publicKeyHex, + ); + }); } String _tokenFor(Contact contact, int hashSize) { @@ -210,6 +231,23 @@ class _ContactRouteDialogState extends State { TextPosition(offset: _controller.text.length), ); _errorText = null; + _entryMode = _RouteEntryMode.map; + }); + _reparse(); + } + + void _applyHistoryRecord(PathRecord record) { + final canonicalText = _canonicalRouteFromBytes( + record.pathBytes, + hashSize: record.hashSize, + ); + setState(() { + _controller.text = canonicalText; + _controller.selection = TextSelection.fromPosition( + TextPosition(offset: _controller.text.length), + ); + _errorText = null; + _entryMode = _RouteEntryMode.manual; }); _reparse(); } @@ -297,6 +335,295 @@ class _ContactRouteDialogState extends State { _applyResolvedPlan(plan); } + String _canonicalRouteFromBytes( + List pathBytes, { + required int hashSize, + }) { + final hops = []; + for (var i = 0; i < pathBytes.length; i += hashSize) { + final hop = pathBytes.sublist(i, i + hashSize); + hops.add( + hop + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join() + .toUpperCase(), + ); + } + return hops.join(','); + } + + String _historySubtitle(PathRecord record) { + final attempts = record.successCount + record.failureCount; + final lastSeen = MaterialLocalizations.of( + context, + ).formatShortDate(record.lastUsedAt); + final successRate = attempts == 0 + ? 'No send stats yet' + : '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}'; + final latency = record.lastRoundTripTimeMs > 0 + ? ' β€’ ${record.lastRoundTripTimeMs} ms' + : ''; + return '$successRate β€’ Last used $lastSeen$latency'; + } + + Widget _buildPreviewSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _parsedRoute == null + ? 'Preview: enter or pick a route to validate it.' + : 'Preview: ${_parsedRoute!.summary} β€’ ${_parsedRoute!.byteLength} bytes β€’ descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (_parsedRoute != null) ...[ + const SizedBox(height: 4), + SelectableText( + _parsedRoute!.canonicalText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ), + ], + ], + ); + } + + Widget _buildBuilderTab( + BuildContext context, { + required List routeCandidates, + required List mapPoints, + required List routePoints, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SegmentedButton<_RouteEntryMode>( + segments: const [ + ButtonSegment<_RouteEntryMode>( + value: _RouteEntryMode.map, + icon: Icon(Icons.map_outlined), + label: Text('Map'), + ), + ButtonSegment<_RouteEntryMode>( + value: _RouteEntryMode.manual, + icon: Icon(Icons.tune), + label: Text('Manual'), + ), + ], + selected: {_entryMode}, + onSelectionChanged: (selection) { + setState(() { + _entryMode = selection.first; + }); + }, + ), + const SizedBox(height: 16), + if (_entryMode == _RouteEntryMode.manual) ...[ + TextField( + controller: _controller, + textCapitalization: TextCapitalization.characters, + decoration: InputDecoration( + labelText: 'Route', + hintText: _selectedHashSize == 1 + ? 'AA,BB,CC' + : _selectedHashSize == 2 + ? 'AABB,CCDD' + : 'AABBCC,DDEEFF', + helperText: + 'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.', + errorText: _errorText, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + _buildPreviewSection(), + ] else ...[ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: _resolvePathAutomatically, + icon: const Icon(Icons.auto_fix_high), + label: const Text('Resolve Path'), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + 'Tap repeaters on the map to build the path, then review the generated route below.', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + const SizedBox(height: 16), + SizedBox( + height: 260, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + ), + child: mapPoints.length < 2 + ? const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: Text( + 'Map path builder needs your advertised location, the contact location, and visible repeater locations.', + textAlign: TextAlign.center, + ), + ), + ) + : flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCameraFit: flutter_map.CameraFit.bounds( + bounds: flutter_map.LatLngBounds.fromPoints( + mapPoints, + ), + padding: const EdgeInsets.all(32), + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.meshcore.sar', + ), + if (routePoints.length >= 2) + flutter_map.PolylineLayer( + polylines: [ + flutter_map.Polyline( + points: routePoints, + strokeWidth: 4, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + flutter_map.MarkerLayer( + markers: [ + ...routeCandidates.map((candidate) { + final isSelected = _selectedMapHops.any( + (item) => + item.publicKeyHex == + candidate.publicKeyHex, + ); + return flutter_map.Marker( + point: LatLng( + candidate.displayLocation!.latitude, + candidate.displayLocation!.longitude, + ), + width: 64, + height: 70, + child: GestureDetector( + onTap: () => _toggleHop(candidate), + child: _RouteMarkerDot( + label: _tokenFor( + candidate, + _selectedHashSize, + ), + color: isSelected + ? Theme.of( + context, + ).colorScheme.primary + : Colors.blueGrey, + ), + ), + ); + }), + ], + ), + ], + ), + ), + ), + ), + const SizedBox(height: 12), + if (_selectedMapHops.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedMapHops.map((contact) { + return InputChip( + label: Text(contact.displayName), + onDeleted: () => _toggleHop(contact), + ); + }).toList(), + ), + const SizedBox(height: 12), + TextField( + controller: _controller, + readOnly: true, + decoration: InputDecoration( + labelText: 'Generated route', + helperText: 'Switch to Manual if you want to edit the hop list.', + errorText: _errorText, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + _buildPreviewSection(), + ], + ], + ); + } + + Widget _buildHistoryTab() { + final records = List.from(_pathHistory?.directPaths ?? const []) + ..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt)); + if (records.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No historical paths for this contact yet.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ), + ); + } + + return ListView.separated( + itemCount: records.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final record = records[index]; + final canonicalText = _canonicalRouteFromBytes( + record.pathBytes, + hashSize: record.hashSize, + ); + return Card( + margin: EdgeInsets.zero, + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + title: Text( + canonicalText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(_historySubtitle(record)), + ), + trailing: FilledButton.tonal( + onPressed: () => _applyHistoryRecord(record), + child: const Text('Use'), + ), + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { final appProvider = context.watch(); @@ -339,238 +666,107 @@ class _ContactRouteDialogState extends State { ), ]; - return FractionallySizedBox( - heightFactor: 0.85, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Set Route for ${widget.contact.displayName}', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return DefaultTabController( + length: 2, + child: FractionallySizedBox( + heightFactor: 0.85, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Set Route for ${widget.contact.displayName}', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Choose how to build the route, or reuse one from history.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + const TabBar( + tabs: [ + Tab(text: 'Build'), + Tab(text: 'History'), + ], + ), + const SizedBox(height: 16), + Expanded( + child: TabBarView( children: [ - TextField( - controller: _controller, - textCapitalization: TextCapitalization.characters, - decoration: InputDecoration( - labelText: 'Route', - hintText: _selectedHashSize == 1 - ? 'AA,BB,CC' - : _selectedHashSize == 2 - ? 'AABB,CCDD' - : 'AABBCC,DDEEFF', - helperText: - 'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.', - errorText: _errorText, - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - Text( - _parsedRoute == null - ? 'Preview: enter a route to validate it.' - : 'Preview: ${_parsedRoute!.summary} β€’ ${_parsedRoute!.byteLength} bytes β€’ descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', - style: Theme.of(context).textTheme.bodySmall, - ), - if (_parsedRoute != null) ...[ - const SizedBox(height: 4), - SelectableText( - _parsedRoute!.canonicalText, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontFamily: 'monospace', - ), - ), - ], - const SizedBox(height: 16), - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - OutlinedButton.icon( - onPressed: _resolvePathAutomatically, - icon: const Icon(Icons.auto_fix_high), - label: const Text('Resolve Path'), - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 260), - child: Text( - 'Tap repeaters on the map to build the path.', - style: Theme.of(context).textTheme.bodySmall, + SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildBuilderTab( + context, + routeCandidates: routeCandidates, + mapPoints: mapPoints, + routePoints: routePoints, ), - ), - ], - ), - const SizedBox(height: 16), - SizedBox( - height: 260, - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: DecoratedBox( - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).dividerColor, - ), + const SizedBox(height: 16), + _AutomationRoutingInfo( + isExpanded: _showRoutingInfo, + onToggle: () { + setState(() { + _showRoutingInfo = !_showRoutingInfo; + }); + }, + autoRouteRotationEnabled: + appProvider.autoRouteRotationEnabled, + nearestRelayFallbackEnabled: + appProvider.nearestRelayFallbackEnabled, + clearPathOnMaxRetry: + appProvider.clearPathOnMaxRetry, ), - child: mapPoints.length < 2 - ? const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: Text( - 'Map path builder needs your advertised location, the contact location, and visible repeater locations.', - textAlign: TextAlign.center, - ), - ), - ) - : flutter_map.FlutterMap( - options: flutter_map.MapOptions( - initialCameraFit: - flutter_map.CameraFit.bounds( - bounds: - flutter_map - .LatLngBounds.fromPoints( - mapPoints, - ), - padding: const EdgeInsets.all(32), - ), - ), - children: [ - flutter_map.TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.meshcore.sar', - ), - if (routePoints.length >= 2) - flutter_map.PolylineLayer( - polylines: [ - flutter_map.Polyline( - points: routePoints, - strokeWidth: 4, - color: Theme.of( - context, - ).colorScheme.primary, - ), - ], - ), - flutter_map.MarkerLayer( - markers: [ - ...routeCandidates.map((candidate) { - final isSelected = _selectedMapHops - .any( - (item) => - item.publicKeyHex == - candidate.publicKeyHex, - ); - return flutter_map.Marker( - point: LatLng( - candidate - .displayLocation! - .latitude, - candidate - .displayLocation! - .longitude, - ), - width: 64, - height: 70, - child: GestureDetector( - onTap: () => - _toggleHop(candidate), - child: _RouteMarkerDot( - label: _tokenFor( - candidate, - _selectedHashSize, - ), - color: isSelected - ? Theme.of( - context, - ).colorScheme.primary - : Colors.blueGrey, - ), - ), - ); - }), - ], - ), - ], - ), - ), + ], ), ), - const SizedBox(height: 12), - if (_selectedMapHops.isNotEmpty) - Wrap( - spacing: 8, - runSpacing: 8, - children: _selectedMapHops.map((contact) { - return InputChip( - label: Text(contact.displayName), - onDeleted: () => _toggleHop(contact), - ); - }).toList(), - ), - const SizedBox(height: 16), - _AutomationRoutingInfo( - isExpanded: _showRoutingInfo, - onToggle: () { - setState(() { - _showRoutingInfo = !_showRoutingInfo; - }); - }, - autoRouteRotationEnabled: - appProvider.autoRouteRotationEnabled, - nearestRelayFallbackEnabled: - appProvider.nearestRelayFallbackEnabled, - clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, - ), - const SizedBox(height: 16), + SingleChildScrollView(child: _buildHistoryTab()), ], ), ), - ), - OverflowBar( - alignment: MainAxisAlignment.spaceBetween, - spacing: 8, - overflowSpacing: 8, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - if (widget.contact.routeHasPath) + OverflowBar( + alignment: MainAxisAlignment.spaceBetween, + spacing: 8, + overflowSpacing: 8, + children: [ TextButton( - onPressed: () => Navigator.of( - context, - ).pop(const ContactRouteDialogResult.clear()), - child: const Text('Clear Route'), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), ), - FilledButton( - onPressed: _parsedRoute == null - ? null - : () => Navigator.of(context).pop( - ContactRouteDialogResult.setWithFallback( - _parsedRoute!, - inferredFallbackLocation: - _buildSyntheticFallbackLocation(), + if (widget.contact.routeHasPath) + TextButton( + onPressed: () => Navigator.of( + context, + ).pop(const ContactRouteDialogResult.clear()), + child: const Text('Clear Route'), + ), + FilledButton( + onPressed: _parsedRoute == null + ? null + : () => Navigator.of(context).pop( + ContactRouteDialogResult.setWithFallback( + _parsedRoute!, + inferredFallbackLocation: + _buildSyntheticFallbackLocation(), + ), ), - ), - child: const Text('Set Route'), - ), - ], - ), - ], + child: const Text('Set Route'), + ), + ], + ), + ], + ), ), ), ); } } +enum _RouteEntryMode { map, manual } + class _RouteMarkerDot extends StatelessWidget { final String label; final Color color; diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 0fde571..29a757f 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -21,6 +21,7 @@ import '../../l10n/app_localizations.dart'; class ContactTile extends StatelessWidget { final Contact contact; + final String? groupLabel; final Position? currentPosition; final double Function(double, double, double, double)? calculateDistance; final String Function(double)? formatDistance; @@ -30,6 +31,7 @@ class ContactTile extends StatelessWidget { const ContactTile({ super.key, required this.contact, + this.groupLabel, this.currentPosition, this.calculateDistance, this.formatDistance, @@ -133,11 +135,12 @@ class ContactTile extends StatelessWidget { spacing: 6, runSpacing: 6, children: [ - _buildMetaPill( - context, - icon: _contactTypeIcon(contact), - label: contact.type.displayName, - ), + if (groupLabel case final label?) + _buildMetaPill( + context, + icon: Icons.folder_copy_outlined, + label: label, + ), _buildMetaPill( context, icon: Icons.key_outlined, @@ -722,21 +725,6 @@ class ContactTile extends StatelessWidget { ); } - IconData _contactTypeIcon(Contact contact) { - switch (contact.type) { - case ContactType.chat: - return Icons.person_outline; - case ContactType.repeater: - return Icons.router_outlined; - case ContactType.room: - return Icons.meeting_room_outlined; - case ContactType.channel: - return Icons.campaign_outlined; - case ContactType.none: - return Icons.help_outline; - } - } - Widget _buildLocationLine( BuildContext context, { required double latitude, diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index 69feaaa..8011604 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -104,6 +104,7 @@ class _MessageBubbleState extends State { final textColor = baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface; + final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1; final backgroundColor = Theme.of( context, ).colorScheme.primary.withValues(alpha: 0.12); @@ -130,8 +131,8 @@ class _MessageBubbleState extends State { WidgetSpan( alignment: PlaceholderAlignment.middle, child: Container( - margin: const EdgeInsets.symmetric(horizontal: 1, vertical: 1), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + margin: const EdgeInsets.symmetric(horizontal: 1), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(999), @@ -141,8 +142,9 @@ class _MessageBubbleState extends State { '@$mentionName', style: baseBodyStyle?.copyWith( color: textColor, - fontWeight: FontWeight.w700, - height: 1.1, + fontSize: mentionFontSize, + fontWeight: FontWeight.w600, + height: 1.0, ), ), ), @@ -2072,9 +2074,11 @@ class _MessageBubbleState extends State { Expanded( child: Text( displayName, - style: Theme.of(context).textTheme.labelMedium + style: Theme.of(context).textTheme.bodySmall ?.copyWith( + fontSize: 12, fontWeight: FontWeight.bold, + height: 1.1, color: isOwnMessage ? Theme.of(context).colorScheme.primary : null, diff --git a/lib/widgets/messages/message_bubble_header.dart b/lib/widgets/messages/message_bubble_header.dart index bf7d32a..790e909 100644 --- a/lib/widgets/messages/message_bubble_header.dart +++ b/lib/widgets/messages/message_bubble_header.dart @@ -134,13 +134,20 @@ Widget buildChannelHeaderPill( BuildContext context, { required String label, IconData icon = Icons.campaign_outlined, + EdgeInsetsGeometry padding = const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + double iconSize = 11, + double iconSpacing = 5, + TextStyle? textStyle, }) { final labelColor = Theme.of( context, ).textTheme.labelSmall?.color?.withValues(alpha: 0.82); return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: padding, decoration: BoxDecoration( color: Theme.of( context, @@ -152,19 +159,21 @@ Widget buildChannelHeaderPill( children: [ Icon( icon, - size: 11, + size: iconSize, color: Theme.of( context, ).textTheme.labelSmall?.color?.withValues(alpha: 0.7), ), - const SizedBox(width: 5), + SizedBox(width: iconSpacing), Flexible( child: Text( label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: labelColor, - fontWeight: FontWeight.w600, - ), + style: + textStyle ?? + Theme.of(context).textTheme.labelSmall?.copyWith( + color: labelColor, + fontWeight: FontWeight.w600, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -182,5 +191,16 @@ Widget buildDirectHeaderCounterpart( context, label: label, icon: Icons.alternate_email, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + iconSize: 10, + iconSpacing: 4, + textStyle: Theme.of(context).textTheme.labelSmall?.copyWith( + fontSize: 10, + height: 1.0, + color: Theme.of( + context, + ).textTheme.labelSmall?.color?.withValues(alpha: 0.82), + fontWeight: FontWeight.w600, + ), ); }