fix: retain voice codec settings now

ref:
This commit is contained in:
Janez T
2026-03-01 11:00:30 +01:00
parent daeb8aeb9c
commit 474fd02635
34 changed files with 3796 additions and 639 deletions

View File

@@ -19,6 +19,7 @@ import '../widgets/messages/sar_update_sheet.dart';
import '../widgets/messages/recipient_selector_sheet.dart';
import '../widgets/messages/message_bubble.dart';
import '../services/message_destination_preferences.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/voice_recorder_service.dart';
import '../services/voice_codec_service.dart';
import '../utils/toast_logger.dart';
@@ -54,11 +55,15 @@ class _MessagesTabState extends State<MessagesTab> {
bool _isRecording = false;
bool _isSendingVoice = false;
static const int _maxVoicePackets = 10;
static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 1;
bool get _voiceSupported => Platform.isIOS;
StreamSubscription<Int16List>? _voiceStreamSub;
String? _currentVoiceSessionId;
final List<Int16List> _recordedChunks = [];
VoicePacketMode? _activeVoiceMode;
int _selectedVoiceBitrate = VoiceBitratePreferences.defaultBitrate;
@override
void initState() {
@@ -66,6 +71,7 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.addListener(_updateCharacterCount);
// Load saved message destination
_loadSavedDestination();
_loadVoiceBitrate();
// Mark all messages as read when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
@@ -73,6 +79,14 @@ class _MessagesTabState extends State<MessagesTab> {
});
}
Future<void> _loadVoiceBitrate() async {
final bitrate = await VoiceBitratePreferences.getBitrate();
if (!mounted) return;
setState(() {
_selectedVoiceBitrate = bitrate;
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -409,6 +423,15 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called');
// Read fresh bitrate preference so settings changes apply immediately.
final selectedBitrate = await VoiceBitratePreferences.getBitrate();
if (mounted) {
setState(() {
_selectedVoiceBitrate = selectedBitrate;
});
} else {
_selectedVoiceBitrate = selectedBitrate;
}
final hasPermission = await _voiceRecorder.requestPermission();
debugPrint('🎙️ [Voice] hasPermission=$hasPermission');
if (!hasPermission) {
@@ -418,6 +441,7 @@ class _MessagesTabState extends State<MessagesTab> {
}
if (!mounted) return;
final appProvider = context.read<AppProvider>();
final connectionProvider = context.read<ConnectionProvider>();
debugPrint(
'🎙️ [Voice] isConnected=${connectionProvider.deviceInfo.isConnected}',
@@ -434,8 +458,9 @@ class _MessagesTabState extends State<MessagesTab> {
(_) => rng.nextInt(256),
).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
final radioBwKhz = connectionProvider.deviceInfo.radioBw ?? 125;
_activeVoiceMode = voiceModeForBandwidth(radioBwKhz * 1000);
_activeVoiceMode = VoiceBitratePreferences.toVoiceMode(
_selectedVoiceBitrate,
);
final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs,
);
@@ -448,7 +473,10 @@ class _MessagesTabState extends State<MessagesTab> {
setState(() => _isRecording = true);
try {
final stream = _voiceRecorder.startCapture(chunkDuration: packetDuration);
final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
);
debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen(
(pcmChunk) {
@@ -477,6 +505,9 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _stopAndSendVoice() async {
if (!_isRecording) return;
final trimSilenceEnabled = context
.read<AppProvider>()
.isVoiceSilenceTrimmingEnabled;
debugPrint(
'🎙️ [Voice] _stopAndSendVoice: ${_recordedChunks.length} chunks buffered',
);
@@ -485,11 +516,16 @@ class _MessagesTabState extends State<MessagesTab> {
_voiceStreamSub = null;
await _voiceRecorder.stopCapture();
final chunks = List<Int16List>.from(_recordedChunks);
final rawChunks = List<Int16List>.from(_recordedChunks);
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks;
final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode;
_recordedChunks.clear();
debugPrint(
'🎙️ [Voice] silence trim enabled=$trimSilenceEnabled: raw=${rawChunks.length} chunks -> kept=${chunks.length} chunks',
);
if (mounted) {
setState(() {
_isRecording = false;
@@ -498,7 +534,12 @@ class _MessagesTabState extends State<MessagesTab> {
}
if (chunks.isEmpty || sessionId == null || mode == null || !mounted) {
if (mounted) setState(() { _isSendingVoice = false; _currentVoiceSessionId = null; });
if (mounted) {
setState(() {
_isSendingVoice = false;
_currentVoiceSessionId = null;
});
}
return;
}
@@ -511,7 +552,9 @@ class _MessagesTabState extends State<MessagesTab> {
} catch (e, st) {
debugPrint('❌ [Voice] _encodeAndSendAllPackets threw: $e\n$st');
} finally {
debugPrint('🎙️ [Voice] _stopAndSendVoice finally: resetting _isSendingVoice');
debugPrint(
'🎙️ [Voice] _stopAndSendVoice finally: resetting _isSendingVoice',
);
if (mounted) {
setState(() {
_isSendingVoice = false;
@@ -532,10 +575,13 @@ class _MessagesTabState extends State<MessagesTab> {
final messagesProvider = context.read<MessagesProvider>();
final voiceProvider = context.read<VoiceProvider>();
// Insert the chat placeholder before sending (so it appears immediately)
// Insert the chat placeholder before sending (so it appears immediately).
final msgId = 'voice_${sessionId}_sent';
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
final senderPublicKeyPrefix =
devicePublicKey != null && devicePublicKey.length >= 6
? devicePublicKey.sublist(0, 6)
: null;
final isChannel =
_destinationType ==
MessageDestinationPreferences.destinationTypeChannel;
@@ -558,8 +604,9 @@ class _MessagesTabState extends State<MessagesTab> {
);
messagesProvider.addSentMessage(sentMsg);
final encodedPackets = <VoicePacket>[];
debugPrint(
'🎙️ [Voice] encoding+sending $total packets, mode=${mode.label}, session=$sessionId',
'🎙️ [Voice] encoding $total packets for deferred voice fetch, mode=${mode.label}, session=$sessionId',
);
for (var i = 0; i < total; i++) {
if (!mounted) return;
@@ -576,44 +623,127 @@ class _MessagesTabState extends State<MessagesTab> {
codec2Data: codec2Data,
);
encodedPackets.add(packet);
voiceProvider.addPacket(packet);
if (!isChannel &&
_selectedRecipient != null &&
_selectedRecipient!.outPathLen >= 0) {
debugPrint(
'🎙️ [Voice] packet $i → binary (raw data), pathLen=${_selectedRecipient!.outPathLen}',
);
await connectionProvider.sendRawVoicePacket(
contactPath: _selectedRecipient!.outPath,
contactPathLen: _selectedRecipient!.outPathLen,
payload: packet.encodeBinary(),
);
} else {
final channelIdx = isChannel
? (_selectedRecipient?.publicKey[1] ?? 0)
: 0;
final text = packet.encodeText();
debugPrint(
'🎙️ [Voice] packet $i → text ch=$channelIdx len=${text.length}: $text',
);
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: text,
);
}
debugPrint('🎙️ [Voice] packet $i sent ok');
} catch (e, st) {
debugPrint('❌ [Voice] packet $i send error: $e\n$st');
debugPrint('❌ [Voice] packet $i encode error: $e\n$st');
}
}
debugPrint('🎙️ [Voice] all packets sent for session $sessionId');
if (encodedPackets.isEmpty) {
debugPrint('❌ [Voice] No packets encoded for session $sessionId');
messagesProvider.markMessageFailed(msgId);
return;
}
voiceProvider.cacheOutgoingSession(sessionId, encodedPackets);
if (senderPublicKeyPrefix == null || senderPublicKeyPrefix.length < 6) {
debugPrint('❌ [Voice] Missing device public key prefix for envelope');
messagesProvider.markMessageFailed(msgId);
return;
}
final senderKey6 = senderPublicKeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final durationMs = encodedPackets.fold<int>(
0,
(sum, p) => sum + p.durationMs,
);
final envelope = VoiceEnvelope(
sessionId: sessionId,
mode: mode,
total: encodedPackets.length,
durationMs: durationMs,
senderKey6: senderKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 1,
);
final envelopeText = envelope.encodeText();
try {
if (isChannel) {
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
await connectionProvider.sendChannelMessage(
channelIdx: channelIdx,
text: envelopeText,
messageId: msgId,
);
} else if (_selectedRecipient != null) {
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: _selectedRecipient!.publicKey,
text: envelopeText,
messageId: msgId,
contact: _selectedRecipient,
);
if (!sentSuccessfully) {
messagesProvider.markMessageFailed(msgId);
return;
}
} else {
// Fallback to public channel if destination cannot be resolved.
await connectionProvider.sendChannelMessage(
channelIdx: 0,
text: envelopeText,
messageId: msgId,
);
}
} catch (e, st) {
debugPrint('❌ [Voice] envelope send error: $e\n$st');
messagesProvider.markMessageFailed(msgId);
return;
}
debugPrint('🎙️ [Voice] envelope sent for session $sessionId');
// Mark the placeholder message as "sent" (ackTag=0, timeout=0 = no ACK tracking).
// addSentMessage() forces deliveryStatus.sending; we upgrade it here so the
// bubble shows "Sent" instead of "Sending" once all packets are on the wire.
// For channels this is also set by the onMessageSent callback, but this is harmless.
messagesProvider.markMessageSent(msgId, 0, 0);
}
List<Int16List> _trimSilence(List<Int16List> chunks) {
if (chunks.isEmpty) return chunks;
final isSilent = chunks.map(_isSilentChunk).toList();
final firstVoice = isSilent.indexWhere((silent) => !silent);
if (firstVoice == -1) return const [];
final lastVoice = isSilent.lastIndexWhere((silent) => !silent);
if (lastVoice < firstVoice) return const [];
final trimmed = <Int16List>[];
var interiorSilentRun = 0;
for (var i = firstVoice; i <= lastVoice; i++) {
if (isSilent[i]) {
interiorSilentRun++;
if (interiorSilentRun <= _maxInteriorSilentChunks) {
trimmed.add(chunks[i]);
}
} else {
interiorSilentRun = 0;
trimmed.add(chunks[i]);
}
}
return trimmed;
}
bool _isSilentChunk(Int16List chunk) {
if (chunk.isEmpty) return true;
var sumSquares = 0.0;
var peak = 0;
for (final sample in chunk) {
final absSample = sample.abs();
if (absSample > peak) peak = absSample;
sumSquares += sample * sample;
}
final rms = math.sqrt(sumSquares / chunk.length);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
}
// ── SAR dialog ─────────────────────────────────────────────────────────────
void _showSarDialog() {
@@ -1181,7 +1311,8 @@ class _MessagesTabState extends State<MessagesTab> {
: Theme.of(context).textTheme.bodySmall?.color,
),
suffixIcon: GestureDetector(
onLongPressStart: (_voiceSupported && !_isSendingVoice)
onLongPressStart:
(_voiceSupported && !_isSendingVoice)
? (_) => _startVoiceRecording()
: null,
onLongPressEnd: (_voiceSupported && _isRecording)
@@ -1223,8 +1354,8 @@ class _MessagesTabState extends State<MessagesTab> {
: (_isSendingVoice
? 'Sending voice...'
: _voiceSupported
? 'Send (long press to record voice)'
: 'Send'),
? 'Send (long press to record voice)'
: 'Send'),
),
),
),

View File

@@ -9,10 +9,7 @@ import '../l10n/app_localizations.dart';
class PacketLogScreen extends StatefulWidget {
final MeshCoreBleService bleService;
const PacketLogScreen({
super.key,
required this.bleService,
});
const PacketLogScreen({super.key, required this.bleService});
@override
State<PacketLogScreen> createState() => _PacketLogScreenState();
@@ -56,16 +53,18 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
final logs = _filteredLogs;
if (logs.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No logs to export')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No logs to export')));
}
return;
}
// Create CSV content
final buffer = StringBuffer();
buffer.writeln('Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description');
buffer.writeln(
'Timestamp,Direction,Size (bytes),Opcode Name,Code,Hex Data,Description',
);
for (final log in logs) {
buffer.writeln(log.toCsvRow());
}
@@ -73,7 +72,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
// Save to temporary file
final tempDir = await getTemporaryDirectory();
if (!context.mounted) return;
final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv');
final file = File(
'${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.csv',
);
await file.writeAsString(buffer.toString());
// Share the file
@@ -87,9 +88,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
}
@@ -99,9 +100,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
final logs = _filteredLogs;
if (logs.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No logs to export')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No logs to export')));
}
return;
}
@@ -122,7 +123,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
// Save to temporary file
final tempDir = await getTemporaryDirectory();
if (!context.mounted) return;
final file = File('${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt');
final file = File(
'${tempDir.path}/ble_packets_${DateTime.now().millisecondsSinceEpoch}.txt',
);
await file.writeAsString(buffer.toString());
// Share the file
@@ -136,9 +139,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Export failed: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
}
}
@@ -159,7 +162,9 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(AppLocalizations.of(dialogContext)!.clearAllData),
content: const Text('Are you sure you want to clear all packet logs? This cannot be undone.'),
content: const Text(
'Are you sure you want to clear all packet logs? This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
@@ -204,11 +209,13 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
actions: [
// Direction filter
PopupMenuButton<PacketDirection?>(
icon: Icon(_filterDirection == null
? Icons.filter_list
: _filterDirection == PacketDirection.rx
? Icons.arrow_downward
: Icons.arrow_upward),
icon: Icon(
_filterDirection == null
? Icons.filter_list
: _filterDirection == PacketDirection.rx
? Icons.arrow_downward
: Icons.arrow_upward,
),
tooltip: 'Filter by direction',
onSelected: (direction) {
setState(() {
@@ -220,12 +227,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
value: null,
child: Row(
children: [
Icon(Icons.filter_list,
color: _filterDirection == null ? Theme.of(context).colorScheme.primary : null),
Icon(
Icons.filter_list,
color: _filterDirection == null
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text('All',
style: TextStyle(
fontWeight: _filterDirection == null ? FontWeight.bold : FontWeight.normal)),
Text(
'All',
style: TextStyle(
fontWeight: _filterDirection == null
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
@@ -233,15 +249,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
value: PacketDirection.rx,
child: Row(
children: [
Icon(Icons.arrow_downward,
color: _filterDirection == PacketDirection.rx
? Theme.of(context).colorScheme.primary
: null),
Icon(
Icons.arrow_downward,
color: _filterDirection == PacketDirection.rx
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text('RX (Received)',
style: TextStyle(
fontWeight:
_filterDirection == PacketDirection.rx ? FontWeight.bold : FontWeight.normal)),
Text(
'RX (Received)',
style: TextStyle(
fontWeight: _filterDirection == PacketDirection.rx
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
@@ -249,15 +271,21 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
value: PacketDirection.tx,
child: Row(
children: [
Icon(Icons.arrow_upward,
color: _filterDirection == PacketDirection.tx
? Theme.of(context).colorScheme.primary
: null),
Icon(
Icons.arrow_upward,
color: _filterDirection == PacketDirection.tx
? Theme.of(context).colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text('TX (Sent)',
style: TextStyle(
fontWeight:
_filterDirection == PacketDirection.tx ? FontWeight.bold : FontWeight.normal)),
Text(
'TX (Sent)',
style: TextStyle(
fontWeight: _filterDirection == PacketDirection.tx
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
@@ -265,7 +293,11 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
),
// Auto-scroll toggle
IconButton(
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
icon: Icon(
_autoScroll
? Icons.vertical_align_bottom
: Icons.vertical_align_center,
),
tooltip: _autoScroll ? 'Disable auto-scroll' : 'Enable auto-scroll',
onPressed: () {
setState(() {
@@ -352,11 +384,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.list_alt,
size: 64,
color: Colors.grey[400],
),
Icon(Icons.list_alt, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
_searchQuery.isNotEmpty || _filterDirection != null
@@ -367,7 +395,8 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
color: Colors.grey[600],
),
),
if (_searchQuery.isNotEmpty || _filterDirection != null) ...[
if (_searchQuery.isNotEmpty ||
_filterDirection != null) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: () {
@@ -420,15 +449,13 @@ class _PacketLogCard extends StatelessWidget {
final BlePacketLog log;
final VoidCallback onCopy;
const _PacketLogCard({
required this.log,
required this.onCopy,
});
const _PacketLogCard({required this.log, required this.onCopy});
@override
Widget build(BuildContext context) {
final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -480,67 +507,168 @@ class _PacketLogCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Hex data
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Hex: ',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
Expanded(
child: SelectableText(
log.hexData,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
IconButton(
icon: const Icon(Icons.copy, size: 18),
tooltip: 'Copy hex data',
onPressed: onCopy,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: 8),
// Metadata
Wrap(
spacing: 16,
runSpacing: 8,
spacing: 10,
runSpacing: 10,
children: [
_InfoChip(
icon: Icons.schedule,
label: log.timestamp.toIso8601String(),
_FactCard(
icon: isRx ? Icons.call_received : Icons.call_made,
label: 'Direction',
value: isRx ? 'RX' : 'TX',
accent: directionColor,
),
_InfoChip(
icon: Icons.data_usage,
label: '${log.rawData.length} bytes',
_FactCard(
icon: Icons.data_object,
label: 'Size',
value: '${log.rawData.length} bytes',
),
_FactCard(
icon: Icons.schedule,
label: 'Captured',
value: _formatTimestamp(log.timestamp),
),
if (log.responseCode != null)
_InfoChip(
icon: Icons.tag,
label: log.opcodeDescription,
),
// Show RSSI and SNR for LOG_RX_DATA packets
if (log.logRxDataInfo?.rssiDbm != null)
_InfoChip(
icon: Icons.signal_cellular_alt,
label: 'RSSI: ${log.logRxDataInfo!.rssiDbm} dBm',
),
if (log.logRxDataInfo?.snrDb != null)
_InfoChip(
icon: Icons.waves,
label: 'SNR: ${log.logRxDataInfo!.snrDb!.toStringAsFixed(1)} dB',
_FactCard(
icon: Icons.sell,
label: 'Opcode',
value: log.opcodeName,
),
],
),
if (rxInfo?.rssiDbm != null || rxInfo?.snrDb != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Link Quality',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
const SizedBox(height: 10),
if (rxInfo?.rssiDbm != null)
_SignalMeter(
label: 'RSSI',
valueLabel: '${rxInfo!.rssiDbm} dBm',
normalized: _normalizeRssi(
rxInfo.rssiDbm!.toDouble(),
),
color: _rssiColor(rxInfo.rssiDbm!.toDouble()),
),
if (rxInfo?.snrDb != null) ...[
const SizedBox(height: 8),
_SignalMeter(
label: 'SNR',
valueLabel:
'${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
normalized: _normalizeSnr(rxInfo.snrDb!),
color: _snrColor(rxInfo.snrDb!),
),
],
],
),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.grid_view_rounded, size: 16),
const SizedBox(width: 6),
const Text(
'Hex Explorer',
style: TextStyle(fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
onPressed: onCopy,
tooltip: 'Copy full hex',
icon: const Icon(Icons.copy_all_rounded, size: 18),
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (var i = 0; i < log.rawData.length; i++)
_HexByteChip(
index: i,
value: log.rawData[i],
onTap: () {
_copyText(
context,
log.rawData[i]
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase(),
'Byte ${i.toString().padLeft(2, '0')} copied',
);
},
),
],
),
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
title: const Text(
'Raw stream',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: SelectableText(
log.hexData,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
],
),
],
),
),
],
),
),
@@ -563,27 +691,176 @@ class _PacketLogCard extends StatelessWidget {
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
}
}
static void _copyText(BuildContext context, String text, String message) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(milliseconds: 900),
),
);
}
static double _normalizeRssi(double rssi) {
return ((rssi + 120.0) / 70.0).clamp(0.0, 1.0);
}
static double _normalizeSnr(double snr) {
return ((snr + 20.0) / 40.0).clamp(0.0, 1.0);
}
static Color _rssiColor(double rssi) {
if (rssi >= -80) return Colors.green;
if (rssi >= -95) return Colors.amber;
return Colors.redAccent;
}
static Color _snrColor(double snr) {
if (snr >= 10) return Colors.green;
if (snr >= 0) return Colors.amber;
return Colors.redAccent;
}
}
class _InfoChip extends StatelessWidget {
class _FactCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final Color? accent;
const _InfoChip({
const _FactCard({
required this.icon,
required this.label,
required this.value,
this.accent,
});
@override
Widget build(BuildContext context) {
return Chip(
avatar: Icon(icon, size: 16),
label: Text(
label,
style: const TextStyle(fontSize: 11),
final tileColor = accent ?? Theme.of(context).colorScheme.primary;
return Container(
constraints: const BoxConstraints(minWidth: 108),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: tileColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: tileColor),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(fontSize: 10, color: Colors.grey[700]),
),
Text(
value,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
],
),
],
),
);
}
}
class _SignalMeter extends StatelessWidget {
final String label;
final String valueLabel;
final double normalized;
final Color color;
const _SignalMeter({
required this.label,
required this.valueLabel,
required this.normalized,
required this.color,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 42,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12),
),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: LinearProgressIndicator(
minHeight: 8,
value: normalized,
backgroundColor: color.withValues(alpha: 0.15),
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
),
const SizedBox(width: 10),
SizedBox(
width: 74,
child: Text(
valueLabel,
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
);
}
}
class _HexByteChip extends StatelessWidget {
final int index;
final int value;
final VoidCallback onTap;
const _HexByteChip({
required this.index,
required this.value,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final text = value.toRadixString(16).padLeft(2, '0').toUpperCase();
return Tooltip(
message: 'Byte $index',
waitDuration: const Duration(milliseconds: 250),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(7),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 5),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(7),
),
child: Text(
text,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
),
padding: const EdgeInsets.all(4),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
}

View File

@@ -12,6 +12,7 @@ import '../providers/app_provider.dart';
import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/update_checker_service.dart';
import '../services/voice_bitrate_preferences.dart';
import '../utils/sample_data_generator.dart';
import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart';
@@ -45,6 +46,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _isLoadingSampleData = false;
bool _showRxTxIndicators = true;
bool _isCheckingForUpdates = false;
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
final LocationTrackingService _locationService = LocationTrackingService();
@override
@@ -55,6 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPackageInfo();
_initializeLocationService();
_loadRxTxPreference();
_loadVoiceBitratePreference();
}
@override
@@ -89,6 +92,26 @@ class _SettingsScreenState extends State<SettingsScreen> {
await prefs.setBool('show_rx_tx_indicators', value);
}
Future<void> _loadVoiceBitratePreference() async {
final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return;
setState(() {
_voiceBitrate = value;
});
}
Future<void> _saveVoiceBitratePreference(int value) async {
await VoiceBitratePreferences.setBitrate(value);
if (!mounted) return;
setState(() {
_voiceBitrate = value;
});
}
String _voiceBitrateSubtitle(int bitrate) {
return '$bitrate bps';
}
Future<void> _initializeLocationService() async {
// Initialize location service with BLE service
WidgetsBinding.instance.addPostFrameCallback((_) async {
@@ -550,6 +573,54 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(),
),
const Divider(),
// Voice Settings Section
_buildSectionHeader('Voice'),
Consumer<AppProvider>(
builder: (context, appProvider, child) => _buildVoiceStatsCard(
bitrate: _voiceBitrate,
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
),
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Voice bitrate'),
subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)),
trailing: const Icon(Icons.chevron_right),
onTap: _showVoiceBitrateDialog,
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.tune),
title: const Text('Band-pass filter voice'),
subtitle: const Text(
'Keeps speech frequencies and cuts low/high noise',
),
value: appProvider.isVoiceBandPassFilterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceBandPassFilterEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
title: const Text('Trim silence in voice messages'),
subtitle: const Text(
'Removes long silent parts before sending voice',
),
value: appProvider.isVoiceSilenceTrimmingEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceSilenceTrimmingEnabled(value);
},
),
),
const Divider(),
// Templates Section
_buildSectionHeader('Templates'),
ListTile(
leading: const Icon(Icons.location_searching),
title: Text(AppLocalizations.of(context)!.sarTemplates),
@@ -708,7 +779,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
child: Text(
AppLocalizations.of(context)!.sampleDataDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
),
@@ -765,6 +838,102 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildVoiceStatsCard({
required int bitrate,
required bool bandPassEnabled,
required bool silenceTrimEnabled,
}) {
final supported = VoiceBitratePreferences.supportedBitrates;
final minBitrate = supported.reduce((a, b) => a < b ? a : b).toDouble();
final maxBitrate = supported.reduce((a, b) => a > b ? a : b).toDouble();
final normalized = maxBitrate > minBitrate
? ((bitrate - minBitrate) / (maxBitrate - minBitrate)).clamp(0.0, 1.0)
: 1.0;
final enabledCount = (bandPassEnabled ? 1 : 0) + (silenceTrimEnabled ? 1 : 0);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Voice Processing Stats',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
'Bitrate: $bitrate bps',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: normalized,
minHeight: 8,
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _voiceStatChip(
label: 'Band-pass',
enabled: bandPassEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Silence trim',
enabled: silenceTrimEnabled,
),
),
],
),
const SizedBox(height: 8),
Text(
'Processing enabled: $enabledCount/2',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
Widget _voiceStatChip({required String label, required bool enabled}) {
final color = enabled ? Colors.green : Colors.grey;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
enabled ? Icons.check_circle : Icons.radio_button_unchecked,
size: 16,
color: color,
),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
),
],
),
);
}
void _showThemeDialog() {
showDialog(
context: context,
@@ -828,7 +997,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
subtitle: Text(AppLocalizations.of(context)!.safeAllClearMode),
subtitle: Text(
AppLocalizations.of(context)!.safeAllClearMode,
),
value: AppThemeMode.sarGreen,
),
RadioListTile<AppThemeMode>(
@@ -855,7 +1026,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
const Divider(),
RadioListTile<AppThemeMode>(
title: Text(AppLocalizations.of(context)!.autoSystem),
subtitle: Text(AppLocalizations.of(context)!.followSystemTheme),
subtitle: Text(
AppLocalizations.of(context)!.followSystemTheme,
),
value: AppThemeMode.system,
),
],
@@ -913,6 +1086,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
void _showVoiceBitrateDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Voice bitrate'),
content: SingleChildScrollView(
child: RadioGroup<int>(
groupValue: _voiceBitrate,
onChanged: (value) {
if (value != null) {
_saveVoiceBitratePreference(value);
}
Navigator.pop(context);
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: VoiceBitratePreferences.supportedBitrates
.map(
(bitrate) => RadioListTile<int>(
value: bitrate,
title: Text('$bitrate bps'),
subtitle: bitrate == VoiceBitratePreferences.defaultBitrate
? const Text('Default')
: null,
),
)
.toList(),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
],
),
);
}
void _showAboutDialog() {
showDialog(
context: context,