mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Fix voice quality regression
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user