From 69ec2dfdb58af8839cf52ce43f5cf386a92ec8eb Mon Sep 17 00:00:00 2001 From: Janez T Date: Thu, 12 Mar 2026 20:12:25 +0100 Subject: [PATCH] Add live mesh traffic view --- .gitignore | 3 +- ios/Podfile.lock | 6 - lib/providers/voice_provider.dart | 20 +- lib/screens/device_config_screen.dart | 1466 ++++++++++++-------- lib/screens/messages_tab.dart | 53 +- lib/screens/settings_screen.dart | 82 +- lib/services/voice_codec_service_io.dart | 54 +- lib/services/voice_codec_service_stub.dart | 2 - lib/services/voice_recorder_service.dart | 65 +- lib/utils/voice_message_parser.dart | 24 +- pubspec.lock | 9 - pubspec.yaml | 4 - 12 files changed, 952 insertions(+), 836 deletions(-) diff --git a/.gitignore b/.gitignore index 29fd002..bdd1c48 100644 --- a/.gitignore +++ b/.gitignore @@ -80,10 +80,11 @@ create_feature_graphic.py # Tool caches and local state .cachebro/ .osgrep/ +third_party/lpcnet_flutter/ # Claude Code local settings (permissions, personal config) .claude/settings.local.json # Dart code coverage coverage/ -lcov.info \ No newline at end of file +lcov.info diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 095c0e0..593f051 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -57,8 +57,6 @@ PODS: - FlutterMacOS - image_picker_ios (0.0.1): - Flutter - - lpcnet_flutter (0.1.0): - - Flutter - nsd_ios (0.0.1): - Flutter - package_info_plus (0.4.5): @@ -100,7 +98,6 @@ DEPENDENCIES: - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - - lpcnet_flutter (from `.symlinks/plugins/lpcnet_flutter/ios`) - nsd_ios (from `.symlinks/plugins/nsd_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -144,8 +141,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/geolocator_apple/darwin" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" - lpcnet_flutter: - :path: ".symlinks/plugins/lpcnet_flutter/ios" nsd_ios: :path: ".symlinks/plugins/nsd_ios/ios" package_info_plus: @@ -182,7 +177,6 @@ SPEC CHECKSUMS: flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 - lpcnet_flutter: 67a3c72ea4caa511373ee9b028223e33f0e85b97 nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index abbd20a..2465d14 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -325,25 +325,7 @@ class VoiceProvider with ChangeNotifier { } 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; + return pcm; } Future clearStoredVoiceData() async { diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index 323419e..c949030 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; import '../models/device_info.dart'; @@ -15,6 +14,153 @@ class DeviceConfigScreen extends StatefulWidget { } class _DeviceConfigScreenState extends State { + static const List<_RadioPreset> _radioPresets = [ + _RadioPreset( + id: 'australia', + label: 'Australia', + summary: '915.800 MHz, BW 250 kHz, SF10, CR5', + frequencyKhz: 915800, + bandwidth: 8, + spreadingFactor: 10, + codingRate: 5, + ), + _RadioPreset( + id: 'australia_narrow', + label: 'Australia (Narrow)', + summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8', + frequencyKhz: 916575, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 8, + ), + _RadioPreset( + id: 'australia_sa_wa', + label: 'Australia: SA, WA', + summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 923125, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'australia_qld', + label: 'Australia: QLD', + summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5', + frequencyKhz: 923125, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 5, + ), + _RadioPreset( + id: 'eu_uk_narrow', + label: 'EU/UK (Narrow)', + summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'eu_uk_deprecated', + label: 'EU/UK (Deprecated)', + summary: '869.525 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 869525, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'czech_republic_narrow', + label: 'Czech Republic (Narrow)', + summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 869432, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'eu_433_long_range', + label: 'EU 433MHz (Long Range)', + summary: '433.650 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 433650, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'new_zealand', + label: 'New Zealand', + summary: '917.375 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 917375, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + _RadioPreset( + id: 'new_zealand_narrow', + label: 'New Zealand (Narrow)', + summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 917375, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'portugal_433', + label: 'Portugal 433', + summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6', + frequencyKhz: 433375, + bandwidth: 6, + spreadingFactor: 9, + codingRate: 6, + ), + _RadioPreset( + id: 'portugal_868', + label: 'Portugal 868', + summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 6, + ), + _RadioPreset( + id: 'switzerland', + label: 'Switzerland', + summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8', + frequencyKhz: 869618, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 8, + ), + _RadioPreset( + id: 'usa_canada_recommended', + label: 'USA/Canada (Recommended)', + summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5', + frequencyKhz: 910525, + bandwidth: 6, + spreadingFactor: 7, + codingRate: 5, + ), + _RadioPreset( + id: 'vietnam_narrow', + label: 'Vietnam (Narrow)', + summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5', + frequencyKhz: 920250, + bandwidth: 6, + spreadingFactor: 8, + codingRate: 5, + ), + _RadioPreset( + id: 'vietnam_deprecated', + label: 'Vietnam (Deprecated)', + summary: '920.250 MHz, BW 250 kHz, SF11, CR5', + frequencyKhz: 920250, + bandwidth: 8, + spreadingFactor: 11, + codingRate: 5, + ), + ]; + late TextEditingController _nameController; late TextEditingController _latController; late TextEditingController _lonController; @@ -23,9 +169,11 @@ class _DeviceConfigScreenState extends State { bool _telemetryEnabled = false; bool _repeatEnabled = false; + bool _showCustomRadioSettings = false; String _selectedBandwidth = '62.5 kHz'; int _selectedSpreadingFactor = 8; int _selectedCodingRate = 8; + _RadioPreset? _selectedRadioPreset; final List _bandwidthOptions = [ '7.8 kHz', @@ -83,6 +231,14 @@ class _DeviceConfigScreenState extends State { _selectedCodingRate = deviceInfo.radioCr!; } + _selectedRadioPreset = _matchRadioPreset( + frequencyKhz: deviceInfo.radioFreq, + bandwidth: deviceInfo.radioBw, + spreadingFactor: deviceInfo.radioSf, + codingRate: deviceInfo.radioCr, + ); + _showCustomRadioSettings = _selectedRadioPreset == null; + // Check if telemetry is enabled (check if lat/lon are set and not zero) _telemetryEnabled = (deviceInfo.advLat != null && deviceInfo.advLat! != 0) || @@ -145,6 +301,41 @@ class _DeviceConfigScreenState extends State { return _bandwidthOptions.indexOf(bw); } + _RadioPreset? _matchRadioPreset({ + int? frequencyKhz, + int? bandwidth, + int? spreadingFactor, + int? codingRate, + }) { + if (frequencyKhz == null || + bandwidth == null || + spreadingFactor == null || + codingRate == null) { + return null; + } + + for (final preset in _radioPresets) { + if (preset.frequencyKhz == frequencyKhz && + preset.bandwidth == bandwidth && + preset.spreadingFactor == spreadingFactor && + preset.codingRate == codingRate) { + return preset; + } + } + return null; + } + + void _applyRadioPreset(_RadioPreset preset) { + setState(() { + _selectedRadioPreset = preset; + _freqController.text = (preset.frequencyKhz / 1000).toStringAsFixed(3); + _selectedBandwidth = _bandwidthFromValue(preset.bandwidth); + _selectedSpreadingFactor = preset.spreadingFactor; + _selectedCodingRate = preset.codingRate; + _showCustomRadioSettings = false; + }); + } + Future _savePublicInfo() async { final connectionProvider = context.read(); final deviceInfo = connectionProvider.deviceInfo; @@ -410,389 +601,451 @@ class _DeviceConfigScreenState extends State { return Scaffold( appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - children: [ - _ConfigHeroCard( - title: deviceInfo.selfName ?? deviceInfo.deviceName ?? 'MeshCore', - subtitle: - '${_getDeviceTypeString(context, deviceInfo.deviceType)} • ${deviceInfo.semanticVersion ?? deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown}', - chips: [ - _StatusChipData( - icon: Icons.bluetooth, - label: - '${AppLocalizations.of(context)!.bleName}: ${deviceInfo.deviceName ?? AppLocalizations.of(context)!.unknown}', - ), - _StatusChipData( - icon: Icons.my_location, - label: locationSet ? 'Location ready' : 'Location off', - emphasized: locationSet, - ), - _StatusChipData( - icon: Icons.settings_input_antenna, - label: '${_freqController.text} MHz • $_selectedBandwidth', - ), - _StatusChipData( - icon: Icons.key, - label: 'FW v${deviceInfo.firmwareVersion?.toString() ?? "?"}', - ), - if (deviceInfo.storageUsedKb != null && - deviceInfo.storageTotalKb != null) - _StatusChipData( - icon: Icons.storage_rounded, - label: - '${_formatStorage(deviceInfo.storageUsedKb!)} / ${_formatStorage(deviceInfo.storageTotalKb!)}', + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colorScheme.primaryContainer.withValues(alpha: 0.55), + colorScheme.surface, + colorScheme.surface, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.22, 1.0], + ), + ), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + children: [ + _ConfigHeroCard( + title: + deviceInfo.selfName ?? deviceInfo.deviceName ?? 'MeshCore', + subtitle: + '${_getDeviceTypeString(context, deviceInfo.deviceType)} • ${deviceInfo.semanticVersion ?? deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown}', + stats: [ + _HeroStatData( + label: 'Location', + value: locationSet ? 'Shared' : 'Hidden', + icon: Icons.my_location_rounded, + emphasized: locationSet, ), - ], - ), - const SizedBox(height: 20), - _ConfigSectionCard( - title: AppLocalizations.of(context)!.deviceInformation, - subtitle: - '${deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown} • ${deviceInfo.firmwareBuildDate ?? AppLocalizations.of(context)!.unknown}', - icon: Icons.memory_rounded, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InfoRow( - AppLocalizations.of(context)!.bleName, - deviceInfo.deviceName ?? + _HeroStatData( + label: 'Frequency', + value: '${_freqController.text} MHz', + icon: Icons.settings_input_antenna_rounded, + ), + _HeroStatData( + label: 'Bandwidth', + value: _selectedBandwidth, + icon: Icons.width_normal_rounded, + ), + _HeroStatData( + label: 'Model', + value: + deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown, - ), - _InfoRow( - AppLocalizations.of(context)!.meshName, - deviceInfo.selfName ?? AppLocalizations.of(context)!.notSet, - ), - _InfoRow( - AppLocalizations.of(context)!.type, - _getDeviceTypeString(context, deviceInfo.deviceType), - ), - _InfoRow( - AppLocalizations.of(context)!.model, - deviceInfo.manufacturerModel ?? - AppLocalizations.of(context)!.unknown, - ), - _InfoRow( - AppLocalizations.of(context)!.version, - deviceInfo.semanticVersion ?? - AppLocalizations.of(context)!.unknown, - ), - _InfoRow( - AppLocalizations.of(context)!.buildDate, - deviceInfo.firmwareBuildDate ?? - AppLocalizations.of(context)!.unknown, - ), - _InfoRow( - AppLocalizations.of(context)!.firmware, - 'v${deviceInfo.firmwareVersion?.toString() ?? "?"}', - ), - _InfoRow( - AppLocalizations.of(context)!.maxContacts, - deviceInfo.maxContacts?.toString() ?? - AppLocalizations.of(context)!.unknown, - ), - _InfoRow( - AppLocalizations.of(context)!.maxChannels, - deviceInfo.maxChannels?.toString() ?? - AppLocalizations.of(context)!.unknown, - ), - _InfoRow( - 'Storage used', - _formatStorageValue(deviceInfo.storageUsedKb), - ), - _InfoRow( - 'Storage limit', - _formatStorageValue(deviceInfo.storageTotalKb), - ), - _InfoRow('Storage status', _formatStorageStatus(deviceInfo)), - if (deviceInfo.storageUsedPercent != null) ...[ - const SizedBox(height: 10), - _StorageUsageMeter(deviceInfo: deviceInfo), - ], - _CopyableInfoRow( - AppLocalizations.of(context)!.publicKey, - _getPublicKeyHex(deviceInfo.publicKey), + icon: Icons.memory_rounded, ), ], ), - ), - const SizedBox(height: 20), - _ConfigSectionCard( - title: AppLocalizations.of(context)!.publicInfo, - subtitle: AppLocalizations.of(context)!.nameBroadcastInMesh, - icon: Icons.public_rounded, - trailing: FilledButton.icon( - onPressed: _savePublicInfo, - icon: const Icon(Icons.save_outlined), - label: Text(AppLocalizations.of(context)!.save), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.45, - ), - borderRadius: BorderRadius.circular(18), - ), - child: Row( - children: [ - Icon( - _telemetryEnabled - ? Icons.travel_explore - : Icons.location_disabled, - color: _telemetryEnabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - AppLocalizations.of( - context, - )!.telemetryAndLocationSharing, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - _telemetryEnabled - ? 'This device advertises position data to the mesh.' - : 'Position broadcasting is currently disabled.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Switch( - value: _telemetryEnabled, - onChanged: (value) { - setState(() { - _telemetryEnabled = value; - }); - }, - ), - ], - ), - ), - const SizedBox(height: 18), - TextField( - controller: _nameController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.meshNetworkName, - border: const OutlineInputBorder(), - helperText: AppLocalizations.of( - context, - )!.nameBroadcastInMesh, - ), - ), - if (_telemetryEnabled) ...[ - const SizedBox(height: 16), + const SizedBox(height: 20), + _ConfigSectionCard( + title: 'Storage', + subtitle: 'Available space on this device.', + icon: Icons.storage_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Row( children: [ Expanded( - child: _CompactCoordinateField( - controller: _latController, - label: AppLocalizations.of(context)!.lat, + child: _StorageStat( + label: 'Used', + value: _formatStorage( + deviceInfo.storageUsedKb ?? 0, + ), ), ), - const SizedBox(width: 8), + const SizedBox(width: 12), Expanded( - child: _CompactCoordinateField( - controller: _lonController, - label: AppLocalizations.of(context)!.lon, + child: _StorageStat( + label: 'Total', + value: deviceInfo.storageTotalKb != null + ? _formatStorage(deviceInfo.storageTotalKb!) + : AppLocalizations.of(context)!.unknown, ), ), - const SizedBox(width: 8), - IconButton.filled( - onPressed: _useCurrentLocation, - icon: const Icon(Icons.my_location, size: 20), - tooltip: AppLocalizations.of( - context, - )!.useCurrentLocation, - ), ], ), - ], - ], - ), - ), - const SizedBox(height: 20), - _ConfigSectionCard( - title: AppLocalizations.of(context)!.radioSettings, - subtitle: - '${_freqController.text} MHz • SF$_selectedSpreadingFactor • CR$_selectedCodingRate', - icon: Icons.settings_input_antenna_rounded, - trailing: FilledButton.icon( - onPressed: _saveRadioSettings, - icon: const Icon(Icons.save_outlined), - label: Text(AppLocalizations.of(context)!.save), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: _RadioMetricTile( - label: AppLocalizations.of(context)!.bandwidth, - value: _selectedBandwidth, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _RadioMetricTile( - label: AppLocalizations.of(context)!.spreadingFactor, - value: 'SF$_selectedSpreadingFactor', - ), - ), - const SizedBox(width: 10), - Expanded( - child: _RadioMetricTile( - label: AppLocalizations.of(context)!.codingRate, - value: 'CR$_selectedCodingRate', - ), - ), - ], - ), - const SizedBox(height: 16), - TextField( - controller: _freqController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.frequencyMHz, - border: const OutlineInputBorder(), - helperText: AppLocalizations.of( - context, - )!.frequencyExample, - ), - keyboardType: const TextInputType.numberWithOptions( - decimal: true, - ), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _selectedBandwidth, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.bandwidth, - border: const OutlineInputBorder(), - ), - items: _bandwidthOptions.map((String value) { - return DropdownMenuItem( - value: value, - child: Text(value), - ); - }).toList(), - onChanged: (String? newValue) { - if (newValue != null) { - setState(() { - _selectedBandwidth = newValue; - }); - } - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _selectedSpreadingFactor, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.spreadingFactor, - border: const OutlineInputBorder(), - ), - items: List.generate(6, (index) => index + 7).map(( - int value, - ) { - return DropdownMenuItem( - value: value, - child: Text(value.toString()), - ); - }).toList(), - onChanged: (int? newValue) { - if (newValue != null) { - setState(() { - _selectedSpreadingFactor = newValue; - }); - } - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _selectedCodingRate, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.codingRate, - border: const OutlineInputBorder(), - ), - items: List.generate(4, (index) => index + 5).map(( - int value, - ) { - return DropdownMenuItem( - value: value, - child: Text(value.toString()), - ); - }).toList(), - onChanged: (int? newValue) { - if (newValue != null) { - setState(() { - _selectedCodingRate = newValue; - }); - } - }, - ), - const SizedBox(height: 16), - TextField( - controller: _txPowerController, - decoration: InputDecoration( - labelText: AppLocalizations.of(context)!.txPowerDbm, - border: const OutlineInputBorder(), - helperText: AppLocalizations.of( - context, - )!.maxPowerDbm(deviceInfo.maxTxPower ?? 22), - ), - keyboardType: TextInputType.number, - ), - if (deviceInfo.clientRepeat != null) ...[ const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.45, - ), - borderRadius: BorderRadius.circular(18), - ), - child: SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Client Repeat Mode'), - subtitle: - deviceInfo.allowedRepeatFreqRanges != null && - deviceInfo.allowedRepeatFreqRanges!.isNotEmpty - ? Text( - 'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}', - ) - : const Text( - 'Repeat packets on behalf of nearby nodes', - ), - value: _repeatEnabled, + _StorageUsageMeter(deviceInfo: deviceInfo), + ], + ), + ), + const SizedBox(height: 20), + _ConfigSectionCard( + title: AppLocalizations.of(context)!.publicInfo, + subtitle: 'Choose the name and location this device shares.', + icon: Icons.public_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SettingHighlightCard( + icon: _telemetryEnabled + ? Icons.travel_explore + : Icons.location_disabled, + title: AppLocalizations.of( + context, + )!.telemetryAndLocationSharing, + description: _telemetryEnabled + ? 'Your location is shared with nearby devices.' + : 'Your location is not being shared.', + accentColor: _telemetryEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _telemetryEnabled, onChanged: (value) { setState(() { - _repeatEnabled = value; + _telemetryEnabled = value; }); }, ), ), + const SizedBox(height: 18), + TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: 'Device name', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: + 'This is the name other devices will see on the mesh.', + ), + ), + if (_telemetryEnabled) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surface.withValues(alpha: 0.65), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: 0.7, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Shared location', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + 'Set coordinates manually or use your current location.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _CompactCoordinateField( + controller: _latController, + label: 'Latitude', + ), + ), + const SizedBox(width: 8), + Expanded( + child: _CompactCoordinateField( + controller: _lonController, + label: 'Longitude', + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + onPressed: _useCurrentLocation, + icon: const Icon(Icons.my_location, size: 20), + tooltip: AppLocalizations.of( + context, + )!.useCurrentLocation, + ), + ], + ), + ], + ), + ), + ], + const SizedBox(height: 18), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _savePublicInfo, + icon: const Icon(Icons.save_outlined), + label: const Text('Save public info'), + ), + ), ], - ], + ), ), - ), - ], + const SizedBox(height: 20), + _ConfigSectionCard( + title: AppLocalizations.of(context)!.radioSettings, + subtitle: 'Choose a preset or fine-tune custom radio settings.', + icon: Icons.settings_input_antenna_rounded, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField<_RadioPreset?>( + initialValue: _selectedRadioPreset, + decoration: InputDecoration( + labelText: 'Radio preset', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: + 'Start with an official preset, or switch to custom settings below.', + ), + isExpanded: true, + items: [ + const DropdownMenuItem<_RadioPreset?>( + value: null, + child: Text('Custom'), + ), + ..._radioPresets.map( + (preset) => DropdownMenuItem<_RadioPreset?>( + value: preset, + child: Text( + preset.label, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + onChanged: (preset) { + if (preset == null) { + setState(() { + _selectedRadioPreset = null; + _showCustomRadioSettings = true; + }); + return; + } + _applyRadioPreset(preset); + }, + ), + const SizedBox(height: 16), + if (_selectedRadioPreset != null) + _SelectedPresetCard(preset: _selectedRadioPreset!), + const SizedBox(height: 8), + Theme( + data: theme.copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + initiallyExpanded: _showCustomRadioSettings, + onExpansionChanged: (expanded) { + setState(() { + _showCustomRadioSettings = expanded; + if (expanded) { + _selectedRadioPreset = null; + } + }); + }, + title: Text( + 'Custom settings', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + subtitle: Text( + 'Adjust frequency, bandwidth, spreading factor, coding rate, and power.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + children: [ + const SizedBox(height: 12), + TextField( + controller: _freqController, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.frequencyMHz, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: + 'Enter the channel frequency, for example 869.618.', + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + onChanged: (_) { + if (_selectedRadioPreset != null) { + setState(() { + _selectedRadioPreset = null; + }); + } + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _selectedBandwidth, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.bandwidth, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: _bandwidthOptions.map((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _selectedBandwidth = newValue; + _selectedRadioPreset = null; + }); + } + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _selectedSpreadingFactor, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.spreadingFactor, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: List.generate(6, (index) => index + 7).map(( + int value, + ) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedSpreadingFactor = newValue; + _selectedRadioPreset = null; + }); + } + }, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _selectedCodingRate, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.codingRate, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: List.generate(4, (index) => index + 5).map(( + int value, + ) { + return DropdownMenuItem( + value: value, + child: Text(value.toString()), + ); + }).toList(), + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedCodingRate = newValue; + _selectedRadioPreset = null; + }); + } + }, + ), + const SizedBox(height: 16), + TextField( + controller: _txPowerController, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.txPowerDbm, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: AppLocalizations.of( + context, + )!.maxPowerDbm(deviceInfo.maxTxPower ?? 22), + ), + keyboardType: TextInputType.number, + ), + ], + ), + ), + if (deviceInfo.clientRepeat != null) ...[ + const SizedBox(height: 16), + _SettingHighlightCard( + icon: Icons.repeat_rounded, + title: 'Repeat nearby traffic', + description: + deviceInfo.allowedRepeatFreqRanges != null && + deviceInfo.allowedRepeatFreqRanges!.isNotEmpty + ? 'Available on: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}' + : 'Help extend range by repeating packets for nearby devices.', + accentColor: colorScheme.secondary, + trailing: Switch( + value: _repeatEnabled, + onChanged: (value) { + setState(() { + _repeatEnabled = value; + }); + }, + ), + ), + ], + const SizedBox(height: 18), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _saveRadioSettings, + icon: const Icon(Icons.save_outlined), + label: const Text('Save radio settings'), + ), + ), + ], + ), + ), + ], + ), ), ), ); @@ -814,30 +1067,6 @@ class _DeviceConfigScreenState extends State { } } - String _getPublicKeyHex(List? publicKey) { - if (publicKey == null || publicKey.isEmpty) return 'N/A'; - return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); - } - - String _formatStorageValue(int? storageKb) { - if (storageKb == null) { - return AppLocalizations.of(context)!.unknown; - } - return _formatStorage(storageKb); - } - - String _formatStorageStatus(DeviceInfo deviceInfo) { - final used = deviceInfo.storageUsedKb; - final total = deviceInfo.storageTotalKb; - final percent = deviceInfo.storageUsedPercent; - - if (used == null || total == null || percent == null) { - return AppLocalizations.of(context)!.unknown; - } - - return '${percent.toStringAsFixed(0)}% full (${_formatStorage(total - used)} free)'; - } - String _formatStorage(int storageKb) { if (storageKb >= 1024 * 1024) { return '${(storageKb / (1024 * 1024)).toStringAsFixed(2)} GB'; @@ -852,12 +1081,12 @@ class _DeviceConfigScreenState extends State { class _ConfigHeroCard extends StatelessWidget { final String title; final String subtitle; - final List<_StatusChipData> chips; + final List<_HeroStatData> stats; const _ConfigHeroCard({ required this.title, required this.subtitle, - required this.chips, + required this.stats, }); @override @@ -866,17 +1095,28 @@ class _ConfigHeroCard extends StatelessWidget { final colorScheme = theme.colorScheme; return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(22), decoration: BoxDecoration( gradient: LinearGradient( colors: [ colorScheme.primaryContainer, + colorScheme.primary.withValues(alpha: 0.14), colorScheme.surfaceContainerHighest, ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(28), + border: Border.all( + color: colorScheme.onPrimaryContainer.withValues(alpha: 0.08), + ), + boxShadow: [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.10), + blurRadius: 26, + offset: const Offset(0, 12), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -884,8 +1124,8 @@ class _ConfigHeroCard extends StatelessWidget { Row( children: [ Container( - width: 52, - height: 52, + width: 56, + height: 56, decoration: BoxDecoration( color: colorScheme.onPrimaryContainer.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(18), @@ -900,6 +1140,26 @@ class _ConfigHeroCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: colorScheme.onPrimaryContainer.withValues( + alpha: 0.10, + ), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Device settings', + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 10), Text( title, style: theme.textTheme.headlineSmall?.copyWith( @@ -914,6 +1174,7 @@ class _ConfigHeroCard extends StatelessWidget { color: colorScheme.onPrimaryContainer.withValues( alpha: 0.82, ), + height: 1.35, ), ), ], @@ -921,11 +1182,83 @@ class _ConfigHeroCard extends StatelessWidget { ), ], ), - const SizedBox(height: 18), + const SizedBox(height: 20), Wrap( spacing: 10, runSpacing: 10, - children: chips.map(_StatusChip.new).toList(), + children: stats.map(_HeroStat.new).toList(), + ), + ], + ), + ); + } +} + +class _HeroStatData { + final String label; + final String value; + final IconData icon; + final bool emphasized; + + const _HeroStatData({ + required this.label, + required this.value, + required this.icon, + this.emphasized = false, + }); +} + +class _HeroStat extends StatelessWidget { + final _HeroStatData data; + + const _HeroStat(this.data); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final background = data.emphasized + ? colorScheme.primary.withValues(alpha: 0.18) + : colorScheme.onPrimaryContainer.withValues(alpha: 0.10); + + return Container( + constraints: const BoxConstraints(minWidth: 140), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.onPrimaryContainer.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + Icon(data.icon, size: 18, color: colorScheme.onPrimaryContainer), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onPrimaryContainer.withValues( + alpha: 0.76, + ), + ), + ), + const SizedBox(height: 2), + Text( + data.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w800, + ), + ), + ], + ), ), ], ), @@ -938,14 +1271,12 @@ class _ConfigSectionCard extends StatelessWidget { final String subtitle; final IconData icon; final Widget child; - final Widget? trailing; const _ConfigSectionCard({ required this.title, required this.subtitle, required this.icon, required this.child, - this.trailing, }); @override @@ -954,7 +1285,9 @@ class _ConfigSectionCard extends StatelessWidget { final colorScheme = theme.colorScheme; return Card( + elevation: 0, clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), child: Padding( padding: const EdgeInsets.all(18), child: Column( @@ -988,12 +1321,12 @@ class _ConfigSectionCard extends StatelessWidget { subtitle, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, + height: 1.35, ), ), ], ), ), - if (trailing != null) ...[const SizedBox(width: 12), trailing!], ], ), const SizedBox(height: 18), @@ -1005,47 +1338,38 @@ class _ConfigSectionCard extends StatelessWidget { } } -class _StatusChipData { - final IconData icon; +class _StorageStat extends StatelessWidget { final String label; - final bool emphasized; + final String value; - const _StatusChipData({ - required this.icon, - required this.label, - this.emphasized = false, - }); -} - -class _StatusChip extends StatelessWidget { - final _StatusChipData data; - - const _StatusChip(this.data); + const _StorageStat({required this.label, required this.value}); @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final chipColor = data.emphasized - ? colorScheme.primary.withValues(alpha: 0.14) - : colorScheme.onPrimaryContainer.withValues(alpha: 0.10); - + final colorScheme = Theme.of(context).colorScheme; return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: chipColor, - borderRadius: BorderRadius.circular(999), + color: colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: colorScheme.outlineVariant), ), - child: Row( - mainAxisSize: MainAxisSize.min, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(data.icon, size: 16, color: colorScheme.onPrimaryContainer), - const SizedBox(width: 8), Text( - data.label, - style: theme.textTheme.labelLarge?.copyWith( - color: colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w700, + label, + style: TextStyle( + fontWeight: FontWeight.w600, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 6), + Text( + value, + style: TextStyle( + fontWeight: FontWeight.w800, + color: colorScheme.onSurface, ), ), ], @@ -1069,7 +1393,9 @@ class _CompactCoordinateField extends StatelessWidget { controller: controller, decoration: InputDecoration( labelText: label, - border: const OutlineInputBorder(), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(18)), + filled: true, + fillColor: Theme.of(context).colorScheme.surfaceContainerLowest, isDense: true, ), keyboardType: const TextInputType.numberWithOptions( @@ -1080,143 +1406,6 @@ class _CompactCoordinateField extends StatelessWidget { } } -class _RadioMetricTile extends StatelessWidget { - final String label; - final String value; - - const _RadioMetricTile({required this.label, required this.value}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(18), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 6), - Text( - value, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - ], - ), - ); - } -} - -class _InfoRow extends StatelessWidget { - final String label; - final String value; - - const _InfoRow(this.label, this.value); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - label, - style: const TextStyle( - fontWeight: FontWeight.w500, - color: Colors.grey, - ), - ), - ), - Expanded( - child: Text( - value, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ), - ], - ), - ); - } -} - -class _CopyableInfoRow extends StatelessWidget { - final String label; - final String value; - - const _CopyableInfoRow(this.label, this.value); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - label, - style: const TextStyle( - fontWeight: FontWeight.w500, - color: Colors.grey, - ), - ), - ), - Expanded( - child: GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData(text: value)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of( - context, - )!.copiedToClipboardShort(label), - ), - duration: const Duration(seconds: 2), - backgroundColor: Colors.green, - ), - ); - }, - child: Row( - children: [ - Expanded( - child: Text( - value, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontFamily: 'monospace', - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - const Icon(Icons.copy, size: 16, color: Colors.grey), - ], - ), - ), - ), - ], - ), - ); - } -} - class _StorageUsageMeter extends StatelessWidget { final DeviceInfo deviceInfo; @@ -1234,8 +1423,16 @@ class _StorageUsageMeter extends StatelessWidget { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + gradient: LinearGradient( + colors: [ + colorScheme.surfaceContainerHighest.withValues(alpha: 0.65), + colorScheme.surfaceContainer, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), borderRadius: BorderRadius.circular(18), + border: Border.all(color: colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1245,7 +1442,7 @@ class _StorageUsageMeter extends StatelessWidget { Icon(Icons.storage_rounded, color: colorScheme.primary, size: 18), const SizedBox(width: 8), Text( - 'Device storage', + 'Storage usage', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w800, ), @@ -1263,7 +1460,7 @@ class _StorageUsageMeter extends StatelessWidget { ), const SizedBox(height: 10), Text( - '${(deviceInfo.storageUsedPercent ?? 0).toStringAsFixed(0)}% used', + '${(deviceInfo.storageUsedPercent ?? 0).toStringAsFixed(0)}% of storage used', style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w700, ), @@ -1273,3 +1470,140 @@ class _StorageUsageMeter extends StatelessWidget { ); } } + +class _SelectedPresetCard extends StatelessWidget { + final _RadioPreset preset; + + const _SelectedPresetCard({required this.preset}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: colorScheme.primary.withValues(alpha: 0.18)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + preset.label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + preset.summary, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +class _SettingHighlightCard extends StatelessWidget { + final IconData icon; + final String title; + final String description; + final Color accentColor; + final Widget trailing; + + const _SettingHighlightCard({ + required this.icon, + required this.title, + required this.description, + required this.accentColor, + required this.trailing, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + accentColor.withValues(alpha: 0.14), + colorScheme.surfaceContainerHigh.withValues(alpha: 0.85), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: accentColor.withValues(alpha: 0.18)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: accentColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + description, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.35, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + trailing, + ], + ), + ); + } +} + +class _RadioPreset { + final String id; + final String label; + final String summary; + final int frequencyKhz; + final int bandwidth; + final int spreadingFactor; + final int codingRate; + + const _RadioPreset({ + required this.id, + required this.label, + required this.summary, + required this.frequencyKhz, + required this.bandwidth, + required this.spreadingFactor, + required this.codingRate, + }); +} diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 94874a7..6c95e43 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -24,7 +24,6 @@ import '../widgets/messages/messages_content.dart'; import '../widgets/common/contact_avatar.dart'; import '../services/message_destination_preferences.dart'; import '../services/voice_bitrate_preferences.dart'; -import '../services/voice_codec_preferences.dart'; import '../services/voice_recorder_service.dart'; import '../services/voice_codec_service.dart'; import '../utils/toast_logger.dart'; @@ -72,7 +71,7 @@ class _MessagesTabState extends State { final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); bool _isRecording = false; bool _isSendingVoice = false; - static const Duration _maxVoiceRecordingDuration = Duration(seconds: 4); + static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30); static const double _silenceRmsThreshold = 500.0; static const double _silencePeakThreshold = 1400.0; static const int _maxInteriorSilentChunks = 2; @@ -82,7 +81,6 @@ class _MessagesTabState extends State { final List _recordedChunks = []; VoicePacketMode? _activeVoiceMode; int _selectedVoiceBitrate = VoiceBitratePreferences.defaultBitrate; - VoiceCodecKind _selectedVoiceCodec = VoiceCodecPreferences.defaultCodec; @override void initState() { @@ -98,11 +96,9 @@ class _MessagesTabState extends State { Future _loadVoiceSettings() async { final bitrate = await VoiceBitratePreferences.getBitrate(); - final codec = await VoiceCodecPreferences.getCodec(); if (!mounted) return; setState(() { _selectedVoiceBitrate = bitrate; - _selectedVoiceCodec = codec; }); } @@ -823,15 +819,12 @@ class _MessagesTabState extends State { debugPrint('🎙️ [Voice] _startVoiceRecording called'); // Read fresh voice preferences so settings changes apply immediately. final selectedBitrate = await VoiceBitratePreferences.getBitrate(); - final selectedCodec = await VoiceCodecPreferences.getCodec(); if (mounted) { setState(() { _selectedVoiceBitrate = selectedBitrate; - _selectedVoiceCodec = selectedCodec; }); } else { _selectedVoiceBitrate = selectedBitrate; - _selectedVoiceCodec = selectedCodec; } final hasPermission = await _voiceRecorder.requestPermission(); debugPrint('🎙️ [Voice] hasPermission=$hasPermission'); @@ -859,9 +852,9 @@ class _MessagesTabState extends State { (_) => rng.nextInt(256), ).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); - _activeVoiceMode = _selectedVoiceCodec == VoiceCodecKind.lpcnet - ? VoicePacketMode.lpcnet1600 - : VoiceBitratePreferences.toVoiceMode(_selectedVoiceBitrate); + _activeVoiceMode = VoiceBitratePreferences.toVoiceMode( + _selectedVoiceBitrate, + ); final packetDuration = Duration( milliseconds: _activeVoiceMode!.packetDurationMs, ); @@ -878,7 +871,6 @@ class _MessagesTabState extends State { final stream = _voiceRecorder.startCapture( chunkDuration: packetDuration, sampleRateHz: _activeVoiceMode!.sampleRateHz, - codecKind: _activeVoiceMode!.codec, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled, @@ -1021,15 +1013,10 @@ class _MessagesTabState extends State { debugPrint( '🎙️ [Voice] encoding $total packets for deferred voice fetch, mode=${mode.label}, session=$sessionId', ); - final encodedChunks = mode.codec == VoiceCodecKind.lpcnet - ? await _encodeLpcNetChunks(codec, chunks, mode) - : []; for (var i = 0; i < total; i++) { if (!mounted) return; try { - final codec2Data = mode.codec == VoiceCodecKind.lpcnet - ? encodedChunks[i] - : await codec.encode(chunks[i], mode); + final codec2Data = await codec.encode(chunks[i], mode); debugPrint( '🎙️ [Voice] packet $i/$total encoded: ${codec2Data.length} bytes', ); @@ -1133,36 +1120,6 @@ class _MessagesTabState extends State { messagesProvider.markMessageSent(msgId, 0, 0); } - Future> _encodeLpcNetChunks( - VoiceCodecService codec, - List chunks, - VoicePacketMode mode, - ) async { - final totalSamples = chunks.fold( - 0, - (sum, chunk) => sum + chunk.length, - ); - final merged = Int16List(totalSamples); - final encodedByteLengths = []; - var sampleOffset = 0; - for (final chunk in chunks) { - merged.setRange(sampleOffset, sampleOffset + chunk.length, chunk); - sampleOffset += chunk.length; - encodedByteLengths.add((chunk.length ~/ 640) * 8); - } - - final encoded = await codec.encode(merged, mode); - final encodedChunks = []; - var byteOffset = 0; - for (final length in encodedByteLengths) { - encodedChunks.add( - Uint8List.sublistView(encoded, byteOffset, byteOffset + length), - ); - byteOffset += length; - } - return encodedChunks; - } - List _prepareChunksForSending( List chunks, VoicePacketMode mode, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 63a5d59..aa433b8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -19,7 +19,6 @@ import '../services/locale_preferences.dart'; import '../services/mesh_map_nodes_service.dart'; import '../services/update_checker_service.dart'; import '../services/voice_bitrate_preferences.dart'; -import '../services/voice_codec_preferences.dart'; import '../services/image_preferences.dart'; import '../services/route_hash_preferences.dart'; import '../services/image_codec_service.dart'; @@ -60,7 +59,6 @@ class _SettingsScreenState extends State { bool _showRxTxIndicators = true; bool _isCheckingForUpdates = false; int _voiceBitrate = VoiceBitratePreferences.defaultBitrate; - VoiceCodecKind _voiceCodec = VoiceCodecPreferences.defaultCodec; int _routeHashSize = RouteHashPreferences.defaultHashSize; int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageCompression = ImagePreferences.defaultQuality; @@ -181,11 +179,9 @@ class _SettingsScreenState extends State { Future _loadVoicePreferences() async { final value = await VoiceBitratePreferences.getBitrate(); - final codec = await VoiceCodecPreferences.getCodec(); if (!mounted) return; setState(() { _voiceBitrate = value; - _voiceCodec = codec; }); } @@ -197,27 +193,10 @@ class _SettingsScreenState extends State { }); } - Future _saveVoiceCodecPreference(VoiceCodecKind value) async { - await VoiceCodecPreferences.setCodec(value); - if (!mounted) return; - setState(() { - _voiceCodec = value; - }); - } - String _voiceBitrateSubtitle(int bitrate) { - if (_voiceCodec == VoiceCodecKind.lpcnet) { - return 'Fixed 1600 bps'; - } return '$bitrate bps'; } - String _voiceCodecSubtitle() { - return _voiceCodec == VoiceCodecKind.codec2 - ? 'Selectable low-bitrate modes' - : 'Fixed 16 kHz / 1.6 kbps mode'; - } - Future _loadRouteHashSizePreference() async { final value = await RouteHashPreferences.getHashSize(); if (!mounted) return; @@ -1179,7 +1158,6 @@ class _SettingsScreenState extends State { Consumer2( builder: (context, appProvider, connectionProvider, child) => _buildVoiceStatsCard( - codec: _voiceCodec, bitrate: _voiceBitrate, connectionProvider: connectionProvider, bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, @@ -1194,22 +1172,12 @@ class _SettingsScreenState extends State { ), ), _buildSettingsCard([ - ListTile( - leading: const Icon(Icons.record_voice_over), - title: const Text('Voice codec'), - subtitle: Text(_voiceCodecSubtitle()), - trailing: const Icon(Icons.chevron_right), - onTap: _showVoiceCodecDialog, - ), ListTile( leading: const Icon(Icons.graphic_eq), title: const Text('Voice bitrate'), subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)), trailing: const Icon(Icons.chevron_right), - enabled: _voiceCodec == VoiceCodecKind.codec2, - onTap: _voiceCodec == VoiceCodecKind.codec2 - ? _showVoiceBitrateDialog - : null, + onTap: _showVoiceBitrateDialog, ), Consumer( builder: (context, appProvider, child) => SwitchListTile( @@ -1797,7 +1765,6 @@ class _SettingsScreenState extends State { } Widget _buildVoiceStatsCard({ - required VoiceCodecKind codec, required int bitrate, required ConnectionProvider connectionProvider, required bool bandPassEnabled, @@ -1826,9 +1793,7 @@ class _SettingsScreenState extends State { final radioSf = connectionProvider.deviceInfo.radioSf; final radioCr = connectionProvider.deviceInfo.radioCr; final bwHz = _resolveBandwidthHz(radioBw); - final voiceMode = codec == VoiceCodecKind.lpcnet - ? VoicePacketMode.lpcnet1600 - : VoiceBitratePreferences.toVoiceMode(bitrate); + final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate); const voicePreviewMs = 10000; // 10-second reference clip final packetDurationMs = voiceMode.packetDurationMs; final voicePacketCount = @@ -1865,7 +1830,7 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), Text( - 'Codec: ${voiceMode.label}${codec == VoiceCodecKind.codec2 ? ' · $bitrate bps' : ' · 1600 bps'}', + 'Codec: ${voiceMode.label} · $bitrate bps', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 4), @@ -2219,47 +2184,6 @@ class _SettingsScreenState extends State { ); } - void _showVoiceCodecDialog() { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Voice codec'), - content: SingleChildScrollView( - child: RadioGroup( - groupValue: _voiceCodec, - onChanged: (value) { - if (value != null) { - _saveVoiceCodecPreference(value); - } - Navigator.pop(context); - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - RadioListTile( - value: VoiceCodecKind.codec2, - title: const Text('Codec2'), - subtitle: const Text('Selectable ultra-low bitrate modes'), - ), - RadioListTile( - value: VoiceCodecKind.lpcnet, - title: const Text('LPCNet'), - subtitle: const Text('Fixed 16 kHz / 1.6 kbps neural mode'), - ), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(AppLocalizations.of(context)!.cancel), - ), - ], - ), - ); - } - void _showAboutDialog() { showDialog( context: context, diff --git a/lib/services/voice_codec_service_io.dart b/lib/services/voice_codec_service_io.dart index 87e8d30..2a240af 100644 --- a/lib/services/voice_codec_service_io.dart +++ b/lib/services/voice_codec_service_io.dart @@ -1,6 +1,5 @@ import 'dart:typed_data'; import 'package:codec2_flutter/codec2_flutter.dart'; -import 'package:lpcnet_flutter/lpcnet_flutter.dart'; import 'package:flutter/foundation.dart'; import '../utils/voice_message_parser.dart'; @@ -20,8 +19,6 @@ Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { return Codec2Mode.mode1300; case VoicePacketMode.mode2400: return Codec2Mode.mode2400; - case VoicePacketMode.lpcnet1600: - throw ArgumentError('LPCNet mode does not map to Codec2'); } } @@ -48,22 +45,12 @@ class VoiceCodecService { Future encode(Int16List pcm, VoicePacketMode mode) { _ensureCodec2Supported(); - switch (mode.codec) { - case VoiceCodecKind.codec2: - return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode)); - case VoiceCodecKind.lpcnet: - return LpcNet.encodeInIsolate(pcm); - } + return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode)); } Future decode(Uint8List codec2Bytes, VoicePacketMode mode) { _ensureCodec2Supported(); - switch (mode.codec) { - case VoiceCodecKind.codec2: - return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode)); - case VoiceCodecKind.lpcnet: - return LpcNet.decodeInIsolate(codec2Bytes); - } + return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode)); } /// Decode and concatenate multiple [packets] into a single PCM Int16List. @@ -73,9 +60,6 @@ class VoiceCodecService { VoicePacketMode mode, ) async { _ensureCodec2Supported(); - if (mode.codec == VoiceCodecKind.lpcnet) { - return _decodeLpcNetPackets(packets, mode); - } final all = []; for (final pkt in packets) { if (pkt == null || pkt.codec2Data.isEmpty) { @@ -93,38 +77,4 @@ class VoiceCodecService { } return result; } - - Future _decodeLpcNetPackets( - List packets, - VoicePacketMode mode, - ) async { - final segments = []; - final run = []; - - Future flushRun() async { - if (run.isEmpty) return; - final decoded = await LpcNet.decodeInIsolate(Uint8List.fromList(run)); - segments.add(decoded); - run.clear(); - } - - for (final pkt in packets) { - if (pkt == null || pkt.codec2Data.isEmpty) { - await flushRun(); - segments.add(Int16List(mode.samplesPerPacket)); - continue; - } - run.addAll(pkt.codec2Data); - } - await flushRun(); - - final total = segments.fold(0, (sum, chunk) => sum + chunk.length); - final result = Int16List(total); - var offset = 0; - for (final chunk in segments) { - result.setRange(offset, offset + chunk.length, chunk); - offset += chunk.length; - } - return result; - } } diff --git a/lib/services/voice_codec_service_stub.dart b/lib/services/voice_codec_service_stub.dart index 4dd2072..d2229fe 100644 --- a/lib/services/voice_codec_service_stub.dart +++ b/lib/services/voice_codec_service_stub.dart @@ -60,8 +60,6 @@ Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { return Codec2Mode.mode1300; case VoicePacketMode.mode2400: return Codec2Mode.mode2400; - case VoicePacketMode.lpcnet1600: - throw ArgumentError('LPCNet mode does not map to Codec2'); } } diff --git a/lib/services/voice_recorder_service.dart b/lib/services/voice_recorder_service.dart index 4685707..713c77b 100644 --- a/lib/services/voice_recorder_service.dart +++ b/lib/services/voice_recorder_service.dart @@ -3,7 +3,6 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:record/record.dart'; -import '../utils/voice_message_parser.dart'; /// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono. /// @@ -32,7 +31,6 @@ class VoiceRecorderService { Stream startCapture({ Duration chunkDuration = const Duration(seconds: 1), int sampleRateHz = 8000, - VoiceCodecKind codecKind = VoiceCodecKind.codec2, bool enableBandPassFilter = true, bool enableCompressor = true, bool enableLimiter = true, @@ -50,7 +48,6 @@ class VoiceRecorderService { _startRecording( chunkDuration, sampleRateHz: sampleRateHz, - codecKind: codecKind, enableBandPassFilter: enableBandPassFilter, enableCompressor: enableCompressor, enableLimiter: enableLimiter, @@ -64,7 +61,6 @@ class VoiceRecorderService { Future _startRecording( Duration chunkDuration, { required int sampleRateHz, - required VoiceCodecKind codecKind, required bool enableBandPassFilter, required bool enableCompressor, required bool enableLimiter, @@ -72,10 +68,9 @@ class VoiceRecorderService { required bool enableEchoCancellation, required bool enableNoiseSuppression, }) async { - final bypassProcessing = codecKind == VoiceCodecKind.lpcnet; - final useBandPassFilter = !bypassProcessing && enableBandPassFilter; - final useCompressor = !bypassProcessing && enableCompressor; - final useLimiter = !bypassProcessing && enableLimiter; + final useBandPassFilter = enableBandPassFilter; + final useCompressor = enableCompressor; + final useLimiter = enableLimiter; final config = RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: sampleRateHz, @@ -88,25 +83,21 @@ class VoiceRecorderService { try { final stream = await _recorder.startStream(config); - final voiceFilter = bypassProcessing - ? null - : _VoiceBandPassFilter( - sampleRate: sampleRateHz, - lowCutHz: 250.0, - highCutHz: 3400.0, - ); - final dynamics = bypassProcessing - ? null - : _VoiceDynamicsProcessor( - sampleRate: sampleRateHz, - thresholdDb: -18.0, - ratio: 2.5, - attackMs: 8.0, - releaseMs: 120.0, - makeupGainDb: 4.0, - enableCompressor: useCompressor, - enableLimiter: useLimiter, - ); + final voiceFilter = _VoiceBandPassFilter( + sampleRate: sampleRateHz, + lowCutHz: 250.0, + highCutHz: 3400.0, + ); + final dynamics = _VoiceDynamicsProcessor( + sampleRate: sampleRateHz, + thresholdDb: -18.0, + ratio: 2.5, + attackMs: 8.0, + releaseMs: 120.0, + makeupGainDb: 4.0, + enableCompressor: useCompressor, + enableLimiter: useLimiter, + ); final chunkBytes = sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000; final buffer = []; @@ -118,28 +109,16 @@ class VoiceRecorderService { final chunk = buffer.sublist(0, chunkBytes); buffer.removeRange(0, chunkBytes); final pcm = _bytesToInt16(Uint8List.fromList(chunk)); - if (bypassProcessing) { - _controller?.add(pcm); - } else { - final filtered = useBandPassFilter - ? voiceFilter!.process(pcm) - : pcm; - _controller?.add(dynamics!.process(filtered)); - } + final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm; + _controller?.add(dynamics.process(filtered)); } }, onDone: () { if (buffer.isNotEmpty) { final padded = _padToEven(buffer); final pcm = _bytesToInt16(Uint8List.fromList(padded)); - if (bypassProcessing) { - _controller?.add(pcm); - } else { - final filtered = useBandPassFilter - ? voiceFilter!.process(pcm) - : pcm; - _controller?.add(dynamics!.process(filtered)); - } + final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm; + _controller?.add(dynamics.process(filtered)); } _controller?.close(); }, diff --git a/lib/utils/voice_message_parser.dart b/lib/utils/voice_message_parser.dart index 6e36718..f70321e 100644 --- a/lib/utils/voice_message_parser.dart +++ b/lib/utils/voice_message_parser.dart @@ -12,8 +12,7 @@ const int _defaultLoRaExplicitHeader = 1; const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle enum VoiceCodecKind { - codec2(0, 'Codec2'), - lpcnet(1, 'LPCNet'); + codec2(0, 'Codec2'); const VoiceCodecKind(this.id, this.label); final int id; @@ -29,8 +28,7 @@ enum VoicePacketMode { mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880), mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880), mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800), - mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400), - lpcnet1600(7, 'LPCNet', VoiceCodecKind.lpcnet, 16000, 200, 40); + mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400); const VoicePacketMode( this.id, @@ -176,8 +174,14 @@ class VoicePacket { /// Estimated audio duration of this packet in milliseconds. int get durationMs { - if (mode.bytesPerSecond == 0) return 0; - return (codec2Data.length * 1000 ~/ mode.bytesPerSecond).clamp(0, 1500); + try { + final bytesPerSecond = voiceModeBytesPerSecond(mode); + if (bytesPerSecond <= 0) return 0; + return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500); + } catch (_) { + // Be permissive with stale or malformed persisted voice metadata. + return 0; + } } @override @@ -257,7 +261,13 @@ class VoiceEnvelope { } int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) { - _ => mode.bytesPerSecond, + VoicePacketMode.mode700c => 100, + VoicePacketMode.mode1200 => 150, + VoicePacketMode.mode2400 => 300, + VoicePacketMode.mode1300 => 175, + VoicePacketMode.mode1400 => 175, + VoicePacketMode.mode1600 => 200, + VoicePacketMode.mode3200 => 400, }; /// Approximate end-to-end transmit time for a voice session over MeshCore LoRa. diff --git a/pubspec.lock b/pubspec.lock index 698f993..4d84f84 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -775,15 +775,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" - lpcnet_flutter: - dependency: "direct main" - description: - path: "." - ref: main - resolved-ref: "39fadf011552a785993657506aedff85186b968b" - url: "https://github.com/dz0ny/lpcnet_flutter.git" - source: git - version: "0.1.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 05d4db2..7f755e5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,10 +51,6 @@ dependencies: git: url: https://github.com/dz0ny/codec2_flutter.git ref: 03998a2 - lpcnet_flutter: - git: - url: https://github.com/dz0ny/lpcnet_flutter.git - ref: main ffi: ^2.1.0 # Voice recording (raw PCM stream at 8000 Hz)