Fix voice quality regression

This commit is contained in:
Janez T
2026-03-12 19:38:41 +01:00
parent baa1aa6edd
commit d2e6b692f3
14 changed files with 1023 additions and 301 deletions

View File

@@ -91,6 +91,12 @@ class AppProvider with ChangeNotifier {
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled; bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
bool _isVoiceLimiterEnabled = true; bool _isVoiceLimiterEnabled = true;
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled; 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 _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale; double get messageFontScale => _messageFontScale;
bool _autoAddDiscoveredContacts = false; bool _autoAddDiscoveredContacts = false;
@@ -142,6 +148,8 @@ class AppProvider with ChangeNotifier {
required this.imageProvider, required this.imageProvider,
}) { }) {
_setupCallbacks(); _setupCallbacks();
connectionProvider.canStartAutomaticMessageSyncCallback =
_canStartAutomaticMessageSync;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected; _wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
_initializeLocationTracking(); _initializeLocationTracking();
_loadMapEnabled(); _loadMapEnabled();
@@ -151,6 +159,9 @@ class AppProvider with ChangeNotifier {
_loadVoiceBandPassFilterEnabled(); _loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled(); _loadVoiceCompressorEnabled();
_loadVoiceLimiterEnabled(); _loadVoiceLimiterEnabled();
_loadVoiceAutoGainEnabled();
_loadVoiceEchoCancellationEnabled();
_loadVoiceNoiseSuppressionEnabled();
_loadMessageFontScale(); _loadMessageFontScale();
_loadAutoAddDiscoveredContacts(); _loadAutoAddDiscoveredContacts();
_loadMessagingRouteSettings(); _loadMessagingRouteSettings();
@@ -439,6 +450,72 @@ class AppProvider with ChangeNotifier {
} }
} }
Future<void> _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<void> 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<void> _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<void> 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<void> _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<void> 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<void> _loadMessageFontScale() async { Future<void> _loadMessageFontScale() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -1642,6 +1719,8 @@ class AppProvider with ChangeNotifier {
if (!connectionProvider.deviceInfo.isConnected) return; if (!connectionProvider.deviceInfo.isConnected) return;
try { try {
_isReconnectSyncInProgress = true;
_hasCompletedConnectionBootstrap = false;
// Initialize contacts provider with device public key to exclude self // Initialize contacts provider with device public key to exclude self
// If already initialized (from early load), this will just filter out self-contact // If already initialized (from early load), this will just filter out self-contact
// This must happen before getContacts to ensure proper filtering // This must happen before getContacts to ensure proper filtering
@@ -1693,7 +1772,9 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)', '🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)',
); );
final initialMessageCount = await connectionProvider.syncAllMessages(); final initialMessageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint( debugPrint(
'📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)', '📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)',
); );
@@ -1715,19 +1796,26 @@ class AppProvider with ChangeNotifier {
_hasCompletedConnectionBootstrap = true; _hasCompletedConnectionBootstrap = true;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected; _wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
await _flushDeferredAutomaticMessageSync();
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Initialization error: $e'); debugPrint('Initialization error: $e');
} finally {
_isReconnectSyncInProgress = false;
} }
} }
Future<void> _syncAfterReconnect() async { Future<void> _syncAfterReconnect({bool started = false}) async {
if (_isReconnectSyncInProgress || if (!connectionProvider.deviceInfo.isConnected) {
!connectionProvider.deviceInfo.isConnected) {
return; return;
} }
_isReconnectSyncInProgress = true; if (!started) {
if (_isReconnectSyncInProgress) {
return;
}
_isReconnectSyncInProgress = true;
}
try { try {
debugPrint( debugPrint(
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages', '🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
@@ -1738,14 +1826,19 @@ class AppProvider with ChangeNotifier {
); );
await connectionProvider.getContacts(); await connectionProvider.getContacts();
final messageCount = await connectionProvider.syncAllMessages(); final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint( debugPrint(
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)', '📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
); );
} catch (e) { } catch (e) {
debugPrint('❌ [AppProvider] Reconnect sync error: $e'); debugPrint('❌ [AppProvider] Reconnect sync error: $e');
} finally { } finally {
_hasCompletedConnectionBootstrap =
connectionProvider.deviceInfo.isConnected;
_isReconnectSyncInProgress = false; _isReconnectSyncInProgress = false;
await _flushDeferredAutomaticMessageSync();
} }
} }
@@ -2737,7 +2830,9 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'🔄 [AppProvider] Manual message sync requested (user initiated)', '🔄 [AppProvider] Manual message sync requested (user initiated)',
); );
final messageCount = await connectionProvider.syncAllMessages(); final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint( debugPrint(
'✅ [AppProvider] Manual sync completed: $messageCount messages', '✅ [AppProvider] Manual sync completed: $messageCount messages',
); );
@@ -2766,9 +2861,32 @@ class AppProvider with ChangeNotifier {
_stopLocationTracking(); _stopLocationTracking();
} }
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) { if (!isConnected) {
unawaited(_syncAfterReconnect()); connectionProvider.clearPendingAutomaticMessageSync();
} }
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) {
_isReconnectSyncInProgress = true;
unawaited(_syncAfterReconnect(started: true));
}
}
bool _canStartAutomaticMessageSync() {
return connectionProvider.deviceInfo.isConnected &&
_hasCompletedConnectionBootstrap &&
!_isReconnectSyncInProgress;
}
Future<void> _flushDeferredAutomaticMessageSync() async {
if (!connectionProvider.hasPendingAutomaticMessageSync ||
!_canStartAutomaticMessageSync()) {
return;
}
debugPrint(
'🔄 [AppProvider] Running deferred automatic message sync after bootstrap',
);
await connectionProvider.syncAllMessages(force: true);
} }
/// Start location tracking /// Start location tracking

View File

@@ -186,9 +186,11 @@ class ConnectionProvider with ChangeNotifier {
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback; Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
bool Function()? canStartAutomaticMessageSyncCallback;
// Track pending send operations for auto-recovery // Track pending send operations for auto-recovery
final Map<String, _PendingSendOperation> _pendingSendOperations = {}; final Map<String, _PendingSendOperation> _pendingSendOperations = {};
bool _pendingAutomaticMessageSync = false;
ConnectionProvider() { ConnectionProvider() {
_wireServiceCallbacks(_bleService); _wireServiceCallbacks(_bleService);
@@ -333,6 +335,13 @@ class ConnectionProvider with ChangeNotifier {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan'); debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return; return;
} }
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
'📥 [Provider] MSG_WAITING deferred until connection bootstrap completes',
);
return;
}
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing'); debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
if (_isSyncingMessages) { if (_isSyncingMessages) {
_syncRequestedWhileBusy = true; _syncRequestedWhileBusy = true;
@@ -1894,11 +1903,18 @@ class ConnectionProvider with ChangeNotifier {
} }
/// Sync all waiting messages from device /// Sync all waiting messages from device
Future<int> syncAllMessages() async { Future<int> syncAllMessages({bool force = false}) async {
if (_isSpectrumScanActive) { if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan'); debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0; return 0;
} }
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
'⏸️ [Provider] Message sync deferred until connection bootstrap completes',
);
return 0;
}
if (_isSyncingMessages) { if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops // Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true; _syncRequestedWhileBusy = true;
@@ -1914,6 +1930,7 @@ class ConnectionProvider with ChangeNotifier {
int totalCount = 0; int totalCount = 0;
try { try {
_pendingAutomaticMessageSync = false;
_isSyncingMessages = true; _isSyncingMessages = true;
do { do {
_syncRequestedWhileBusy = false; _syncRequestedWhileBusy = false;
@@ -2005,6 +2022,12 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
bool get hasPendingAutomaticMessageSync => _pendingAutomaticMessageSync;
void clearPendingAutomaticMessageSync() {
_pendingAutomaticMessageSync = false;
}
/// Login to a room or repeater /// Login to a room or repeater
/// ///
/// Sends login request with password. Results will be delivered via /// Sends login request with password. Results will be delivered via

View File

@@ -416,7 +416,7 @@ class ContactsProvider with ChangeNotifier {
final existingAdvertLocation = existingContact.advertLocation; final existingAdvertLocation = existingContact.advertLocation;
var updatedContact = incomingContact.copyWith( var updatedContact = incomingContact.copyWith(
isNew: existingContact.isNew, isNew: false,
advertHistory: existingContact.advertHistory, advertHistory: existingContact.advertHistory,
telemetry: mergedTelemetry, telemetry: mergedTelemetry,
outPathLen: outPathLen:

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
@@ -299,7 +300,11 @@ class VoiceProvider with ChangeNotifier {
); );
try { 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'); debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
_playingSessionId = sessionId; _playingSessionId = sessionId;
notifyListeners(); notifyListeners();
@@ -319,6 +324,28 @@ class VoiceProvider with ChangeNotifier {
notifyListeners(); 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<void> clearStoredVoiceData() async { Future<void> clearStoredVoiceData() async {
_sessions.clear(); _sessions.clear();
_outgoingSessions.clear(); _outgoingSessions.clear();

View File

@@ -39,6 +39,7 @@ class _ContactsTabState extends State<ContactsTab> {
ContactSection.rooms: '', ContactSection.rooms: '',
ContactSection.channels: '', ContactSection.channels: '',
}; };
late final Map<ContactSection, TextEditingController> _filterControllers;
final Map<ContactSection, ContactSortMode> _sortModes = { final Map<ContactSection, ContactSortMode> _sortModes = {
ContactSection.teamMembers: ContactSortMode.lastSeen, ContactSection.teamMembers: ContactSortMode.lastSeen,
ContactSection.repeaters: ContactSortMode.lastSeen, ContactSection.repeaters: ContactSortMode.lastSeen,
@@ -48,6 +49,10 @@ class _ContactsTabState extends State<ContactsTab> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_filterControllers = {
for (final section in ContactSection.values)
section: TextEditingController(text: _sectionFilters[section] ?? ''),
};
_getCurrentLocation(); _getCurrentLocation();
// Mark all contacts as viewed when tab is opened // Mark all contacts as viewed when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -55,6 +60,14 @@ class _ContactsTabState extends State<ContactsTab> {
}); });
} }
@override
void dispose() {
for (final controller in _filterControllers.values) {
controller.dispose();
}
super.dispose();
}
Future<void> _getCurrentLocation() async { Future<void> _getCurrentLocation() async {
try { try {
final position = await Geolocator.getCurrentPosition( final position = await Geolocator.getCurrentPosition(
@@ -513,6 +526,7 @@ class _ContactsTabState extends State<ContactsTab> {
) { ) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final controller = _filterControllers[section]!;
final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty; final hasFilter = (_sectionFilters[section] ?? '').isNotEmpty;
return Padding( return Padding(
@@ -555,10 +569,7 @@ class _ContactsTabState extends State<ContactsTab> {
), ),
Expanded( Expanded(
child: TextFormField( child: TextFormField(
key: ValueKey( controller: controller,
'${section.name}:${_sectionFilters[section] ?? ''}',
),
initialValue: _sectionFilters[section] ?? '',
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_sectionFilters[section] = value; _sectionFilters[section] = value;
@@ -598,6 +609,7 @@ class _ContactsTabState extends State<ContactsTab> {
child: InkWell( child: InkWell(
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
onTap: () { onTap: () {
controller.clear();
setState(() { setState(() {
_sectionFilters[section] = ''; _sectionFilters[section] = '';
}); });
@@ -856,6 +868,7 @@ class _InferredContactGroupCard extends StatelessWidget {
...contacts.map( ...contacts.map(
(contact) => ContactTile( (contact) => ContactTile(
contact: contact, contact: contact,
groupLabel: label,
currentPosition: currentPosition, currentPosition: currentPosition,
calculateDistance: calculateDistance, calculateDistance: calculateDistance,
formatDistance: formatDistance, formatDistance: formatDistance,

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../models/device_info.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../services/validation_service.dart'; import '../services/validation_service.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -97,6 +98,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
context.read<ConnectionProvider>().getAllowedRepeatFreq(); context.read<ConnectionProvider>().getAllowedRepeatFreq();
}); });
} }
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ConnectionProvider>().getBatteryAndStorage();
});
} }
@override @override
@@ -432,6 +437,13 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
icon: Icons.key, icon: Icons.key,
label: 'FW v${deviceInfo.firmwareVersion?.toString() ?? "?"}', 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), const SizedBox(height: 20),
@@ -485,6 +497,19 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
deviceInfo.maxChannels?.toString() ?? deviceInfo.maxChannels?.toString() ??
AppLocalizations.of(context)!.unknown, 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( _CopyableInfoRow(
AppLocalizations.of(context)!.publicKey, AppLocalizations.of(context)!.publicKey,
_getPublicKeyHex(deviceInfo.publicKey), _getPublicKeyHex(deviceInfo.publicKey),
@@ -793,6 +818,35 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
if (publicKey == null || publicKey.isEmpty) return 'N/A'; if (publicKey == null || publicKey.isEmpty) return 'N/A';
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); 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 { 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,
),
),
],
),
);
}
}

View File

@@ -72,7 +72,7 @@ class _MessagesTabState extends State<MessagesTab> {
final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false; bool _isRecording = false;
bool _isSendingVoice = 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 _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0; static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 2; static const int _maxInteriorSilentChunks = 2;
@@ -865,9 +865,10 @@ class _MessagesTabState extends State<MessagesTab> {
final packetDuration = Duration( final packetDuration = Duration(
milliseconds: _activeVoiceMode!.packetDurationMs, milliseconds: _activeVoiceMode!.packetDurationMs,
); );
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
debugPrint( debugPrint(
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms', '🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
); );
_recordedChunks.clear(); _recordedChunks.clear();
@@ -877,9 +878,13 @@ class _MessagesTabState extends State<MessagesTab> {
final stream = _voiceRecorder.startCapture( final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration, chunkDuration: packetDuration,
sampleRateHz: _activeVoiceMode!.sampleRateHz, sampleRateHz: _activeVoiceMode!.sampleRateHz,
codecKind: _activeVoiceMode!.codec,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled,
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
); );
debugPrint('🎙️ [Voice] capture started, listening for chunks...'); debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen( _voiceStreamSub = stream.listen(
@@ -890,7 +895,7 @@ class _MessagesTabState extends State<MessagesTab> {
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples', '🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
); );
setState(() {}); setState(() {});
if (_recordedChunks.length >= _maxVoicePackets) { if (_recordedChunks.length >= maxVoicePackets) {
debugPrint('🎙️ [Voice] max packets reached, stopping'); debugPrint('🎙️ [Voice] max packets reached, stopping');
_stopAndSendVoice(); _stopAndSendVoice();
} }
@@ -907,6 +912,12 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
int _maxVoicePacketsForMode(VoicePacketMode mode) {
final packets =
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
return packets < 1 ? 1 : packets;
}
Future<void> _stopAndSendVoice() async { Future<void> _stopAndSendVoice() async {
if (!_isRecording) return; if (!_isRecording) return;
final trimSilenceEnabled = context final trimSilenceEnabled = context
@@ -921,9 +932,14 @@ class _MessagesTabState extends State<MessagesTab> {
await _voiceRecorder.stopCapture(); await _voiceRecorder.stopCapture();
final rawChunks = List<Int16List>.from(_recordedChunks); final rawChunks = List<Int16List>.from(_recordedChunks);
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks; final trimmedChunks = trimSilenceEnabled
? _trimSilence(rawChunks)
: rawChunks;
final sessionId = _currentVoiceSessionId; final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode; final mode = _activeVoiceMode;
final chunks = mode == null
? trimmedChunks
: _prepareChunksForSending(trimmedChunks, mode);
_recordedChunks.clear(); _recordedChunks.clear();
debugPrint( debugPrint(
@@ -1005,10 +1021,15 @@ class _MessagesTabState extends State<MessagesTab> {
debugPrint( debugPrint(
'🎙️ [Voice] encoding $total packets for deferred voice fetch, mode=${mode.label}, session=$sessionId', '🎙️ [Voice] encoding $total packets for deferred voice fetch, mode=${mode.label}, session=$sessionId',
); );
final encodedChunks = mode.codec == VoiceCodecKind.lpcnet
? await _encodeLpcNetChunks(codec, chunks, mode)
: <Uint8List>[];
for (var i = 0; i < total; i++) { for (var i = 0; i < total; i++) {
if (!mounted) return; if (!mounted) return;
try { try {
final codec2Data = await codec.encode(chunks[i], mode); final codec2Data = mode.codec == VoiceCodecKind.lpcnet
? encodedChunks[i]
: await codec.encode(chunks[i], mode);
debugPrint( debugPrint(
'🎙️ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes', '🎙️ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes',
); );
@@ -1112,6 +1133,43 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.markMessageSent(msgId, 0, 0); messagesProvider.markMessageSent(msgId, 0, 0);
} }
Future<List<Uint8List>> _encodeLpcNetChunks(
VoiceCodecService codec,
List<Int16List> chunks,
VoicePacketMode mode,
) async {
final totalSamples = chunks.fold<int>(
0,
(sum, chunk) => sum + chunk.length,
);
final merged = Int16List(totalSamples);
final encodedByteLengths = <int>[];
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 = <Uint8List>[];
var byteOffset = 0;
for (final length in encodedByteLengths) {
encodedChunks.add(
Uint8List.sublistView(encoded, byteOffset, byteOffset + length),
);
byteOffset += length;
}
return encodedChunks;
}
List<Int16List> _prepareChunksForSending(
List<Int16List> chunks,
VoicePacketMode mode,
) {
return chunks;
}
List<Int16List> _trimSilence(List<Int16List> chunks) { List<Int16List> _trimSilence(List<Int16List> chunks) {
if (chunks.isEmpty) return chunks; if (chunks.isEmpty) return chunks;
@@ -1141,16 +1199,28 @@ class _MessagesTabState extends State<MessagesTab> {
bool _isSilentChunk(Int16List chunk) { bool _isSilentChunk(Int16List chunk) {
if (chunk.isEmpty) return true; 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; 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; var peak = 0;
for (final sample in chunk) { for (final sample in chunk) {
final absSample = sample.abs(); final absSample = sample.abs();
if (absSample > peak) peak = absSample; if (absSample > peak) {
sumSquares += sample * sample; peak = absSample;
}
} }
return peak;
final rms = math.sqrt(sumSquares / chunk.length);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
} }
bool _isPublicChannelSelected() { bool _isPublicChannelSelected() {

View File

@@ -1185,6 +1185,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
compressorEnabled: appProvider.isVoiceCompressorEnabled, compressorEnabled: appProvider.isVoiceCompressorEnabled,
limiterEnabled: appProvider.isVoiceLimiterEnabled, limiterEnabled: appProvider.isVoiceLimiterEnabled,
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
echoCancellationEnabled:
appProvider.isVoiceEchoCancellationEnabled,
noiseSuppressionEnabled:
appProvider.isVoiceNoiseSuppressionEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
), ),
), ),
@@ -1241,6 +1246,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
Consumer<AppProvider>(
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<AppProvider>(
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<AppProvider>(
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<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut), secondary: const Icon(Icons.content_cut),
@@ -1761,6 +1803,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
required bool bandPassEnabled, required bool bandPassEnabled,
required bool compressorEnabled, required bool compressorEnabled,
required bool limiterEnabled, required bool limiterEnabled,
required bool autoGainEnabled,
required bool echoCancellationEnabled,
required bool noiseSuppressionEnabled,
required bool silenceTrimEnabled, required bool silenceTrimEnabled,
}) { }) {
final supported = VoiceBitratePreferences.supportedBitrates; final supported = VoiceBitratePreferences.supportedBitrates;
@@ -1773,6 +1818,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
(bandPassEnabled ? 1 : 0) + (bandPassEnabled ? 1 : 0) +
(compressorEnabled ? 1 : 0) + (compressorEnabled ? 1 : 0) +
(limiterEnabled ? 1 : 0) + (limiterEnabled ? 1 : 0) +
(autoGainEnabled ? 1 : 0) +
(echoCancellationEnabled ? 1 : 0) +
(noiseSuppressionEnabled ? 1 : 0) +
(silenceTrimEnabled ? 1 : 0); (silenceTrimEnabled ? 1 : 0);
final radioBw = connectionProvider.deviceInfo.radioBw; final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
@@ -1864,6 +1912,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
), ),
const SizedBox(height: 8), 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( Row(
children: [ children: [
Expanded( Expanded(
@@ -1876,7 +1949,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Processing enabled: $enabledCount/4', 'Processing enabled: $enabledCount/7',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],

View File

@@ -73,6 +73,9 @@ class VoiceCodecService {
VoicePacketMode mode, VoicePacketMode mode,
) async { ) async {
_ensureCodec2Supported(); _ensureCodec2Supported();
if (mode.codec == VoiceCodecKind.lpcnet) {
return _decodeLpcNetPackets(packets, mode);
}
final all = <Int16List>[]; final all = <Int16List>[];
for (final pkt in packets) { for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) { if (pkt == null || pkt.codec2Data.isEmpty) {
@@ -90,4 +93,38 @@ class VoiceCodecService {
} }
return result; return result;
} }
Future<Int16List> _decodeLpcNetPackets(
List<VoicePacket?> packets,
VoicePacketMode mode,
) async {
final segments = <Int16List>[];
final run = <int>[];
Future<void> 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<int>(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;
}
} }

View File

@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:record/record.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. /// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
/// ///
@@ -31,9 +32,13 @@ class VoiceRecorderService {
Stream<Int16List> startCapture({ Stream<Int16List> startCapture({
Duration chunkDuration = const Duration(seconds: 1), Duration chunkDuration = const Duration(seconds: 1),
int sampleRateHz = 8000, int sampleRateHz = 8000,
VoiceCodecKind codecKind = VoiceCodecKind.codec2,
bool enableBandPassFilter = true, bool enableBandPassFilter = true,
bool enableCompressor = true, bool enableCompressor = true,
bool enableLimiter = true, bool enableLimiter = true,
bool enableAutoGain = false,
bool enableEchoCancellation = false,
bool enableNoiseSuppression = false,
}) { }) {
if (_isRecording) { if (_isRecording) {
throw StateError('VoiceRecorderService: already recording'); throw StateError('VoiceRecorderService: already recording');
@@ -45,9 +50,13 @@ class VoiceRecorderService {
_startRecording( _startRecording(
chunkDuration, chunkDuration,
sampleRateHz: sampleRateHz, sampleRateHz: sampleRateHz,
codecKind: codecKind,
enableBandPassFilter: enableBandPassFilter, enableBandPassFilter: enableBandPassFilter,
enableCompressor: enableCompressor, enableCompressor: enableCompressor,
enableLimiter: enableLimiter, enableLimiter: enableLimiter,
enableAutoGain: enableAutoGain,
enableEchoCancellation: enableEchoCancellation,
enableNoiseSuppression: enableNoiseSuppression,
); );
return _controller!.stream; return _controller!.stream;
} }
@@ -55,29 +64,49 @@ class VoiceRecorderService {
Future<void> _startRecording( Future<void> _startRecording(
Duration chunkDuration, { Duration chunkDuration, {
required int sampleRateHz, required int sampleRateHz,
required VoiceCodecKind codecKind,
required bool enableBandPassFilter, required bool enableBandPassFilter,
required bool enableCompressor, required bool enableCompressor,
required bool enableLimiter, required bool enableLimiter,
required bool enableAutoGain,
required bool enableEchoCancellation,
required bool enableNoiseSuppression,
}) async { }) async {
final bypassProcessing = codecKind == VoiceCodecKind.lpcnet;
final useBandPassFilter = !bypassProcessing && enableBandPassFilter;
final useCompressor = !bypassProcessing && enableCompressor;
final useLimiter = !bypassProcessing && enableLimiter;
final config = RecordConfig( final config = RecordConfig(
encoder: AudioEncoder.pcm16bits, encoder: AudioEncoder.pcm16bits,
sampleRate: sampleRateHz, sampleRate: sampleRateHz,
numChannels: 1, numChannels: 1,
bitRate: 128000, // ignored for PCM, but required by API bitRate: 128000, // ignored for PCM, but required by API
autoGain: enableAutoGain,
echoCancel: enableEchoCancellation,
noiseSuppress: enableNoiseSuppression,
); );
try { try {
final stream = await _recorder.startStream(config); final stream = await _recorder.startStream(config);
final voiceFilter = _VoiceBandPassFilter( final voiceFilter = bypassProcessing
sampleRate: sampleRateHz, ? null
lowCutHz: 250.0, : _VoiceBandPassFilter(
highCutHz: 3400.0, sampleRate: sampleRateHz,
); lowCutHz: 250.0,
final dynamics = _VoiceDynamicsProcessor( highCutHz: 3400.0,
sampleRate: sampleRateHz, );
enableCompressor: enableCompressor, final dynamics = bypassProcessing
enableLimiter: enableLimiter, ? 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 = final chunkBytes =
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000; sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
final buffer = <int>[]; final buffer = <int>[];
@@ -89,20 +118,28 @@ class VoiceRecorderService {
final chunk = buffer.sublist(0, chunkBytes); final chunk = buffer.sublist(0, chunkBytes);
buffer.removeRange(0, chunkBytes); buffer.removeRange(0, chunkBytes);
final pcm = _bytesToInt16(Uint8List.fromList(chunk)); final pcm = _bytesToInt16(Uint8List.fromList(chunk));
final filtered = enableBandPassFilter if (bypassProcessing) {
? voiceFilter.process(pcm) _controller?.add(pcm);
: pcm; } else {
_controller?.add(dynamics.process(filtered)); final filtered = useBandPassFilter
? voiceFilter!.process(pcm)
: pcm;
_controller?.add(dynamics!.process(filtered));
}
} }
}, },
onDone: () { onDone: () {
if (buffer.isNotEmpty) { if (buffer.isNotEmpty) {
final padded = _padToEven(buffer); final padded = _padToEven(buffer);
final pcm = _bytesToInt16(Uint8List.fromList(padded)); final pcm = _bytesToInt16(Uint8List.fromList(padded));
final filtered = enableBandPassFilter if (bypassProcessing) {
? voiceFilter.process(pcm) _controller?.add(pcm);
: pcm; } else {
_controller?.add(dynamics.process(filtered)); final filtered = useBandPassFilter
? voiceFilter!.process(pcm)
: pcm;
_controller?.add(dynamics!.process(filtered));
}
} }
_controller?.close(); _controller?.close();
}, },
@@ -167,17 +204,22 @@ class _VoiceDynamicsProcessor {
_VoiceDynamicsProcessor({ _VoiceDynamicsProcessor({
required int sampleRate, required int sampleRate,
required double thresholdDb,
required double ratio,
required double attackMs,
required double releaseMs,
required double makeupGainDb,
required bool enableCompressor, required bool enableCompressor,
required bool enableLimiter, required bool enableLimiter,
}) : _enableCompressor = enableCompressor, }) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter, _enableLimiter = enableLimiter,
_compressor = _SimpleCompressor( _compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(), sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0, thresholdDb: thresholdDb,
ratio: 2.5, ratio: ratio,
attackMs: 8.0, attackMs: attackMs,
releaseMs: 120.0, releaseMs: releaseMs,
makeupGainDb: 4.0, makeupGainDb: makeupGainDb,
), ),
_limiter = _PeakLimiter(ceilingDb: -1.0); _limiter = _PeakLimiter(ceilingDb: -1.0);

View File

@@ -8,7 +8,9 @@ import '../../models/contact.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../services/contact_route_resolver.dart'; import '../../services/contact_route_resolver.dart';
import '../../services/path_history_service.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
import '../../models/path_history.dart';
class ContactRouteDialogResult { class ContactRouteDialogResult {
final ParsedContactRoute? route; final ParsedContactRoute? route;
@@ -75,11 +77,14 @@ class ContactRouteDialog extends StatefulWidget {
class _ContactRouteDialogState extends State<ContactRouteDialog> { class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller; late final TextEditingController _controller;
final PathHistoryService _pathHistoryService = PathHistoryService();
int _selectedHashSize = RouteHashPreferences.defaultHashSize; int _selectedHashSize = RouteHashPreferences.defaultHashSize;
ParsedContactRoute? _parsedRoute; ParsedContactRoute? _parsedRoute;
String? _errorText; String? _errorText;
bool _showRoutingInfo = false; bool _showRoutingInfo = false;
List<Contact> _selectedMapHops = const []; List<Contact> _selectedMapHops = const [];
ContactPathHistory? _pathHistory;
_RouteEntryMode _entryMode = _RouteEntryMode.map;
@override @override
void initState() { void initState() {
@@ -88,7 +93,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
text: widget.contact.routeCanonicalText, text: widget.contact.routeCanonicalText,
); );
_controller.addListener(_reparse); _controller.addListener(_reparse);
_entryMode = widget.contact.routeCanonicalText.isNotEmpty
? _RouteEntryMode.manual
: _RouteEntryMode.map;
_loadHashSizePreference(); _loadHashSizePreference();
_loadPathHistory();
_reparse(); _reparse();
} }
@@ -106,6 +115,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
setState(() { setState(() {
_parsedRoute = null; _parsedRoute = null;
_errorText = null; _errorText = null;
_selectedMapHops = const [];
}); });
return; return;
} }
@@ -115,9 +125,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
input, input,
expectedHashSize: _selectedHashSize, expectedHashSize: _selectedHashSize,
); );
final selectedMapHops = _mapSelectionForText(input);
setState(() { setState(() {
_parsedRoute = parsed; _parsedRoute = parsed;
_errorText = null; _errorText = null;
_selectedMapHops = selectedMapHops;
}); });
} on ContactRouteFormatException catch (error) { } on ContactRouteFormatException catch (error) {
setState(() { setState(() {
@@ -135,8 +147,8 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
.toList() .toList()
..sort((a, b) => a.displayName.compareTo(b.displayName)); ..sort((a, b) => a.displayName.compareTo(b.displayName));
void _syncMapSelectionFromController() { List<Contact> _mapSelectionForText(String text) {
final tokens = _controller.text final tokens = text
.trim() .trim()
.split(',') .split(',')
.map((token) => token.trim().toUpperCase()) .map((token) => token.trim().toUpperCase())
@@ -154,7 +166,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
selected.add(match); selected.add(match);
} }
} }
_selectedMapHops = selected; return selected;
} }
Future<void> _loadHashSizePreference() async { Future<void> _loadHashSizePreference() async {
@@ -164,7 +176,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_selectedHashSize = hashSize; _selectedHashSize = hashSize;
}); });
_reparse(); _reparse();
_syncMapSelectionFromController(); }
Future<void> _loadPathHistory() async {
await _pathHistoryService.initialize();
if (!mounted) return;
setState(() {
_pathHistory = _pathHistoryService.historyFor(
widget.contact.publicKeyHex,
);
});
} }
String _tokenFor(Contact contact, int hashSize) { String _tokenFor(Contact contact, int hashSize) {
@@ -210,6 +231,23 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
TextPosition(offset: _controller.text.length), TextPosition(offset: _controller.text.length),
); );
_errorText = null; _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(); _reparse();
} }
@@ -297,6 +335,295 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_applyResolvedPlan(plan); _applyResolvedPlan(plan);
} }
String _canonicalRouteFromBytes(
List<int> pathBytes, {
required int hashSize,
}) {
final hops = <String>[];
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<Contact> routeCandidates,
required List<LatLng> mapPoints,
required List<LatLng> 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<PathRecord>.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appProvider = context.watch<AppProvider>(); final appProvider = context.watch<AppProvider>();
@@ -339,238 +666,107 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
), ),
]; ];
return FractionallySizedBox( return DefaultTabController(
heightFactor: 0.85, length: 2,
child: Padding( child: FractionallySizedBox(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), heightFactor: 0.85,
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [ child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
'Set Route for ${widget.contact.displayName}', children: [
style: Theme.of(context).textTheme.headlineSmall, Text(
), 'Set Route for ${widget.contact.displayName}',
const SizedBox(height: 16), style: Theme.of(context).textTheme.headlineSmall,
Expanded( ),
child: SingleChildScrollView( const SizedBox(height: 8),
child: Column( Text(
crossAxisAlignment: CrossAxisAlignment.start, '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: [ children: [
TextField( SingleChildScrollView(
controller: _controller, child: Column(
textCapitalization: TextCapitalization.characters, crossAxisAlignment: CrossAxisAlignment.start,
decoration: InputDecoration( children: [
labelText: 'Route', _buildBuilderTab(
hintText: _selectedHashSize == 1 context,
? 'AA,BB,CC' routeCandidates: routeCandidates,
: _selectedHashSize == 2 mapPoints: mapPoints,
? 'AABB,CCDD' routePoints: routePoints,
: '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,
), ),
), const SizedBox(height: 16),
], _AutomationRoutingInfo(
), isExpanded: _showRoutingInfo,
const SizedBox(height: 16), onToggle: () {
SizedBox( setState(() {
height: 260, _showRoutingInfo = !_showRoutingInfo;
child: ClipRRect( });
borderRadius: BorderRadius.circular(12), },
child: DecoratedBox( autoRouteRotationEnabled:
decoration: BoxDecoration( appProvider.autoRouteRotationEnabled,
border: Border.all( nearestRelayFallbackEnabled:
color: Theme.of(context).dividerColor, 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), SingleChildScrollView(child: _buildHistoryTab()),
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),
], ],
), ),
), ),
), OverflowBar(
OverflowBar( alignment: MainAxisAlignment.spaceBetween,
alignment: MainAxisAlignment.spaceBetween, spacing: 8,
spacing: 8, overflowSpacing: 8,
overflowSpacing: 8, children: [
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
if (widget.contact.routeHasPath)
TextButton( TextButton(
onPressed: () => Navigator.of( onPressed: () => Navigator.of(context).pop(),
context, child: const Text('Cancel'),
).pop(const ContactRouteDialogResult.clear()),
child: const Text('Clear Route'),
), ),
FilledButton( if (widget.contact.routeHasPath)
onPressed: _parsedRoute == null TextButton(
? null onPressed: () => Navigator.of(
: () => Navigator.of(context).pop( context,
ContactRouteDialogResult.setWithFallback( ).pop(const ContactRouteDialogResult.clear()),
_parsedRoute!, child: const Text('Clear Route'),
inferredFallbackLocation: ),
_buildSyntheticFallbackLocation(), 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 { class _RouteMarkerDot extends StatelessWidget {
final String label; final String label;
final Color color; final Color color;

View File

@@ -21,6 +21,7 @@ import '../../l10n/app_localizations.dart';
class ContactTile extends StatelessWidget { class ContactTile extends StatelessWidget {
final Contact contact; final Contact contact;
final String? groupLabel;
final Position? currentPosition; final Position? currentPosition;
final double Function(double, double, double, double)? calculateDistance; final double Function(double, double, double, double)? calculateDistance;
final String Function(double)? formatDistance; final String Function(double)? formatDistance;
@@ -30,6 +31,7 @@ class ContactTile extends StatelessWidget {
const ContactTile({ const ContactTile({
super.key, super.key,
required this.contact, required this.contact,
this.groupLabel,
this.currentPosition, this.currentPosition,
this.calculateDistance, this.calculateDistance,
this.formatDistance, this.formatDistance,
@@ -133,11 +135,12 @@ class ContactTile extends StatelessWidget {
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
children: [ children: [
_buildMetaPill( if (groupLabel case final label?)
context, _buildMetaPill(
icon: _contactTypeIcon(contact), context,
label: contact.type.displayName, icon: Icons.folder_copy_outlined,
), label: label,
),
_buildMetaPill( _buildMetaPill(
context, context,
icon: Icons.key_outlined, 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( Widget _buildLocationLine(
BuildContext context, { BuildContext context, {
required double latitude, required double latitude,

View File

@@ -104,6 +104,7 @@ class _MessageBubbleState extends State<MessageBubble> {
final textColor = final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface; baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of( final backgroundColor = Theme.of(
context, context,
).colorScheme.primary.withValues(alpha: 0.12); ).colorScheme.primary.withValues(alpha: 0.12);
@@ -130,8 +131,8 @@ class _MessageBubbleState extends State<MessageBubble> {
WidgetSpan( WidgetSpan(
alignment: PlaceholderAlignment.middle, alignment: PlaceholderAlignment.middle,
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1, vertical: 1), margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@@ -141,8 +142,9 @@ class _MessageBubbleState extends State<MessageBubble> {
'@$mentionName', '@$mentionName',
style: baseBodyStyle?.copyWith( style: baseBodyStyle?.copyWith(
color: textColor, color: textColor,
fontWeight: FontWeight.w700, fontSize: mentionFontSize,
height: 1.1, fontWeight: FontWeight.w600,
height: 1.0,
), ),
), ),
), ),
@@ -2072,9 +2074,11 @@ class _MessageBubbleState extends State<MessageBubble> {
Expanded( Expanded(
child: Text( child: Text(
displayName, displayName,
style: Theme.of(context).textTheme.labelMedium style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
height: 1.1,
color: isOwnMessage color: isOwnMessage
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: null, : null,

View File

@@ -134,13 +134,20 @@ Widget buildChannelHeaderPill(
BuildContext context, { BuildContext context, {
required String label, required String label,
IconData icon = Icons.campaign_outlined, 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( final labelColor = Theme.of(
context, context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82); ).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: padding,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: Theme.of(
context, context,
@@ -152,19 +159,21 @@ Widget buildChannelHeaderPill(
children: [ children: [
Icon( Icon(
icon, icon,
size: 11, size: iconSize,
color: Theme.of( color: Theme.of(
context, context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7), ).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
), ),
const SizedBox(width: 5), SizedBox(width: iconSpacing),
Flexible( Flexible(
child: Text( child: Text(
label, label,
style: Theme.of(context).textTheme.labelSmall?.copyWith( style:
color: labelColor, textStyle ??
fontWeight: FontWeight.w600, Theme.of(context).textTheme.labelSmall?.copyWith(
), color: labelColor,
fontWeight: FontWeight.w600,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -182,5 +191,16 @@ Widget buildDirectHeaderCounterpart(
context, context,
label: label, label: label,
icon: Icons.alternate_email, 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,
),
); );
} }