mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-12 00:40:28 +00:00
fix: retain voice codec settings now
ref:
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user