import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; import '../models/device_info.dart'; import '../models/channel.dart'; import '../models/contact.dart'; import '../providers/channels_provider.dart'; import '../providers/connection_provider.dart'; import '../providers/contacts_provider.dart'; import '../services/validation_service.dart'; import '../l10n/app_localizations.dart'; class DeviceConfigScreen extends StatefulWidget { const DeviceConfigScreen({super.key}); @override State createState() => _DeviceConfigScreenState(); } class _DeviceConfigScreenState extends State { static const int _bulkDeleteBatchSize = 8; static const Duration _bulkDeleteInterItemDelay = Duration(milliseconds: 120); static const Duration _bulkDeleteBatchDelay = Duration(milliseconds: 700); static const Duration _bulkDeleteFinalSyncDelay = Duration(milliseconds: 900); static const int _autoAddFilterModeFlag = 1; static const int _fixedHardwareGpsIntervalSeconds = 5; 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; late TextEditingController _freqController; late TextEditingController _txPowerController; late TextEditingController _autoAddMaxHopsController; late final ConnectionProvider _connectionProvider; int _baseTelemetryMode = 0; int _locationTelemetryMode = 0; int _environmentTelemetryMode = 0; int _advertLocationPolicy = 0; bool _multiAcksEnabled = false; bool _repeatEnabled = false; bool? _gpsEnabled; // null = not supported by hardware bool _gpsLoading = false; bool? _buzzerEnabled; bool _buzzerLoading = false; bool? _gpsHasFix; int? _gpsSatelliteCount; int? _gpsLatE6; int? _gpsLonE6; int? _gpsLastFixAgeSeconds; DateTime? _gpsStatsLoadedAt; Timer? _gpsStatsTicker; Timer? _gpsRefreshTimer; bool _isSyncingDeviceTime = false; int? _selectedPathHashMode; bool _autoAddDiscoveredContactsEnabled = true; bool _autoAddUsersEnabled = true; bool _autoAddRepeatersEnabled = true; bool _autoAddRoomServersEnabled = true; bool _autoAddSensorsEnabled = true; bool _overwriteOldestAutoAddEnabled = false; bool _showCustomRadioSettings = false; bool _isSavingPublicInfo = false; bool _isSavingRadioSettings = false; bool _isSavingAutoDiscoverySettings = false; bool _isClearingContacts = false; bool _isClearingChannels = false; bool _publicInfoSaved = false; bool _radioSettingsSaved = false; bool _autoDiscoverySettingsSaved = false; bool _autoDiscoverySettingsDirty = false; String? _lastAutoDiscoverySignature; String? _lastRadioSettingsSignature; String? _publicInfoError; String? _radioSettingsError; String? _autoDiscoverySettingsError; String _selectedBandwidth = '62.5 kHz'; int _selectedSpreadingFactor = 8; int _selectedCodingRate = 8; _RadioPreset? _selectedRadioPreset; bool _radioSettingsDirty = false; final List _bandwidthOptions = [ '7.8 kHz', '10.4 kHz', '15.6 kHz', '20.8 kHz', '31.25 kHz', '41.7 kHz', '62.5 kHz', '125 kHz', '250 kHz', '500 kHz', ]; @override void initState() { super.initState(); _connectionProvider = context.read(); _connectionProvider.addListener(_handleConnectionProviderChanged); _gpsStatsTicker = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted || _gpsLastFixAgeSeconds == null) return; setState(() {}); }); _gpsRefreshTimer = Timer.periodic(const Duration(seconds: 5), (_) { if (!mounted) return; _loadGpsMode(); }); final deviceInfo = _connectionProvider.deviceInfo; _nameController = TextEditingController( text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '', ); _latController = TextEditingController( text: deviceInfo.advLat != null ? (deviceInfo.advLat! / 1000000).toStringAsFixed(6) : '0.0', ); _lonController = TextEditingController( text: deviceInfo.advLon != null ? (deviceInfo.advLon! / 1000000).toStringAsFixed(6) : '0.0', ); _freqController = TextEditingController( text: deviceInfo.radioFreq != null ? (deviceInfo.radioFreq! / 1000).toStringAsFixed(3) : '869.618', ); _txPowerController = TextEditingController( text: deviceInfo.txPower?.toString() ?? '20', ); _autoAddMaxHopsController = TextEditingController( text: (deviceInfo.autoAddMaxHops ?? 0).toString(), ); _syncRadioSettingsState(deviceInfo); final telemetryModes = deviceInfo.telemetryModes; _baseTelemetryMode = telemetryModes != null ? telemetryModes & 0x03 : 0; _locationTelemetryMode = telemetryModes != null ? (telemetryModes >> 2) & 0x03 : 0; _environmentTelemetryMode = telemetryModes != null ? (telemetryModes >> 4) & 0x03 : 0; _advertLocationPolicy = deviceInfo.advertLocPolicy ?? 0; _multiAcksEnabled = (deviceInfo.multiAcks ?? 0) != 0; _selectedPathHashMode = deviceInfo.pathHashMode; // Initialize repeat mode from device info (firmware v9+) _repeatEnabled = deviceInfo.clientRepeat ?? false; _syncAutoDiscoveryState(deviceInfo); _lastAutoDiscoverySignature = _autoDiscoverySignature(deviceInfo); // Fetch allowed repeat frequencies on open if device supports repeat mode if (deviceInfo.clientRepeat != null && deviceInfo.allowedRepeatFreqRanges == null) { WidgetsBinding.instance.addPostFrameCallback((_) { context.read().getAllowedRepeatFreq(); }); } WidgetsBinding.instance.addPostFrameCallback((_) { _connectionProvider.getBatteryAndStorage(); unawaited(_connectionProvider.getAutoaddConfig()); unawaited(_loadGpsMode()); }); } @override void dispose() { _connectionProvider.removeListener(_handleConnectionProviderChanged); _nameController.dispose(); _latController.dispose(); _lonController.dispose(); _freqController.dispose(); _txPowerController.dispose(); _autoAddMaxHopsController.dispose(); _gpsStatsTicker?.cancel(); _gpsRefreshTimer?.cancel(); super.dispose(); } String _bandwidthFromValue(int bw) { switch (bw) { case 0: return '7.8 kHz'; case 1: return '10.4 kHz'; case 2: return '15.6 kHz'; case 3: return '20.8 kHz'; case 4: return '31.25 kHz'; case 5: return '41.7 kHz'; case 6: return '62.5 kHz'; case 7: return '125 kHz'; case 8: return '250 kHz'; case 9: return '500 kHz'; default: return '62.5 kHz'; } } int _bandwidthToValue(String bw) { return _bandwidthOptions.indexOf(bw); } int? _normalizeBandwidthValue(int? bw) { if (bw == null) { return null; } if (bw >= 0 && bw <= 9) { return bw; } switch (bw) { case 7800: return 0; case 10400: return 1; case 15600: return 2; case 20800: return 3; case 31250: return 4; case 41700: return 5; case 62500: return 6; case 125000: return 7; case 250000: return 8; case 500000: return 9; default: return null; } } int? _normalizeSpreadingFactor(int? spreadingFactor) { if (spreadingFactor == null || spreadingFactor < 7 || spreadingFactor > 12) { return null; } return spreadingFactor; } int? _normalizeCodingRate(int? codingRate) { if (codingRate == null) { return null; } if (codingRate >= 5 && codingRate <= 8) { return codingRate; } if (codingRate >= 1 && codingRate <= 4) { return codingRate + 4; } return null; } _RadioPreset? _matchRadioPreset({ int? frequencyKhz, int? bandwidth, int? spreadingFactor, int? codingRate, }) { final normalizedBandwidth = _normalizeBandwidthValue(bandwidth); final normalizedSpreadingFactor = _normalizeSpreadingFactor( spreadingFactor, ); final normalizedCodingRate = _normalizeCodingRate(codingRate); if (frequencyKhz == null || normalizedBandwidth == null || normalizedSpreadingFactor == null || normalizedCodingRate == null) { return null; } for (final preset in _radioPresets) { if (preset.frequencyKhz == frequencyKhz && preset.bandwidth == normalizedBandwidth && preset.spreadingFactor == normalizedSpreadingFactor && preset.codingRate == normalizedCodingRate) { return preset; } } return null; } String _radioSettingsSignature(DeviceInfo deviceInfo) { return [ deviceInfo.radioFreq, _normalizeBandwidthValue(deviceInfo.radioBw), _normalizeSpreadingFactor(deviceInfo.radioSf), _normalizeCodingRate(deviceInfo.radioCr), deviceInfo.txPower, deviceInfo.clientRepeat, deviceInfo.pathHashMode, ].join('|'); } void _syncRadioSettingsState(DeviceInfo deviceInfo) { final normalizedBandwidth = _normalizeBandwidthValue(deviceInfo.radioBw); final normalizedSpreadingFactor = _normalizeSpreadingFactor( deviceInfo.radioSf, ); final normalizedCodingRate = _normalizeCodingRate(deviceInfo.radioCr); if (deviceInfo.radioFreq != null) { _freqController.text = (deviceInfo.radioFreq! / 1000).toStringAsFixed(3); } if (normalizedBandwidth != null) { _selectedBandwidth = _bandwidthFromValue(normalizedBandwidth); } if (normalizedSpreadingFactor != null) { _selectedSpreadingFactor = normalizedSpreadingFactor; } if (normalizedCodingRate != null) { _selectedCodingRate = normalizedCodingRate; } if (deviceInfo.txPower != null) { _txPowerController.text = deviceInfo.txPower!.toString(); } if (deviceInfo.clientRepeat != null) { _repeatEnabled = deviceInfo.clientRepeat!; } _selectedPathHashMode = deviceInfo.pathHashMode; _selectedRadioPreset = _matchRadioPreset( frequencyKhz: deviceInfo.radioFreq, bandwidth: deviceInfo.radioBw, spreadingFactor: deviceInfo.radioSf, codingRate: deviceInfo.radioCr, ); _showCustomRadioSettings = _selectedRadioPreset == null; _radioSettingsDirty = false; _lastRadioSettingsSignature = _radioSettingsSignature(deviceInfo); } 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; }); } void _syncRadioPresetSelection() { final freqResult = ValidationService().parseFrequency(_freqController.text); final matchedPreset = freqResult.isSuccess ? _matchRadioPreset( frequencyKhz: (freqResult.value! * 1000).round(), bandwidth: _bandwidthToValue(_selectedBandwidth), spreadingFactor: _selectedSpreadingFactor, codingRate: _selectedCodingRate, ) : null; _selectedRadioPreset = matchedPreset; _showCustomRadioSettings = matchedPreset == null; } void _markPublicInfoDirty() { if (_publicInfoSaved || _publicInfoError != null) { setState(() { _publicInfoSaved = false; _publicInfoError = null; }); } } void _markRadioSettingsDirty() { _radioSettingsDirty = true; if (_radioSettingsSaved || _radioSettingsError != null) { setState(() { _radioSettingsSaved = false; _radioSettingsError = null; }); } } void _markAutoDiscoverySettingsDirty() { _autoDiscoverySettingsDirty = true; if (_autoDiscoverySettingsSaved || _autoDiscoverySettingsError != null) { setState(() { _autoDiscoverySettingsSaved = false; _autoDiscoverySettingsError = null; }); } } String _autoDiscoverySignature(DeviceInfo deviceInfo) { return [ deviceInfo.manualAddContacts, deviceInfo.autoAddUsers, deviceInfo.autoAddRepeaters, deviceInfo.autoAddRoomServers, deviceInfo.autoAddSensors, deviceInfo.autoAddOverwriteOldest, deviceInfo.autoAddMaxHops, ].join('|'); } void _handleConnectionProviderChanged() { if (!mounted) return; final deviceInfo = _connectionProvider.deviceInfo; final nextRadioSettingsSignature = _radioSettingsSignature(deviceInfo); final nextAutoDiscoverySignature = _autoDiscoverySignature(deviceInfo); final shouldSyncRadioSettings = !_radioSettingsDirty && nextRadioSettingsSignature != _lastRadioSettingsSignature; final shouldSyncAutoDiscovery = !_autoDiscoverySettingsDirty && nextAutoDiscoverySignature != _lastAutoDiscoverySignature; if (!shouldSyncRadioSettings && !shouldSyncAutoDiscovery) { return; } setState(() { if (shouldSyncRadioSettings) { _syncRadioSettingsState(deviceInfo); } if (shouldSyncAutoDiscovery) { _lastAutoDiscoverySignature = nextAutoDiscoverySignature; _syncAutoDiscoveryState(deviceInfo); } }); } bool _hasAutoAddTargetsEnabled(DeviceInfo deviceInfo) { return (deviceInfo.autoAddUsers ?? false) || (deviceInfo.autoAddRepeaters ?? false) || (deviceInfo.autoAddRoomServers ?? false) || (deviceInfo.autoAddSensors ?? false); } void _syncAutoDiscoveryState(DeviceInfo deviceInfo) { final hasFetchedAutoAddConfig = deviceInfo.autoAddUsers != null || deviceInfo.autoAddRepeaters != null || deviceInfo.autoAddRoomServers != null || deviceInfo.autoAddSensors != null || deviceInfo.autoAddOverwriteOldest != null; _autoAddDiscoveredContactsEnabled = hasFetchedAutoAddConfig ? _hasAutoAddTargetsEnabled(deviceInfo) : !(deviceInfo.manualAddContacts ?? false); _autoAddUsersEnabled = deviceInfo.autoAddUsers ?? true; _autoAddRepeatersEnabled = deviceInfo.autoAddRepeaters ?? true; _autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true; _autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true; _overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false; _autoAddMaxHopsController.text = (deviceInfo.autoAddMaxHops ?? 0) .toString(); } int? _currentGpsLastFixAgeSeconds() { final baseAgeSeconds = _gpsLastFixAgeSeconds; final loadedAt = _gpsStatsLoadedAt; if (baseAgeSeconds == null || loadedAt == null) { return null; } final elapsedSeconds = DateTime.now().difference(loadedAt).inSeconds; if (elapsedSeconds <= 0) { return baseAgeSeconds; } return baseAgeSeconds + elapsedSeconds; } String _formatGpsFixValue() { if (_gpsEnabled == false) { return 'Off'; } if (_gpsHasFix == true) { return 'Locked'; } if (_gpsEnabled == true) { return 'Searching'; } return 'Unavailable'; } String _formatGpsLocationValue() { final latE6 = _gpsLatE6; final lonE6 = _gpsLonE6; if (latE6 == null || lonE6 == null) { return 'No fix yet'; } return '${(latE6 / 1000000).toStringAsFixed(6)}, ${(lonE6 / 1000000).toStringAsFixed(6)}'; } String _formatGpsLastFixValue(AppLocalizations l10n) { final ageSeconds = _currentGpsLastFixAgeSeconds(); if (ageSeconds == null) { return 'Never'; } if (ageSeconds <= 0) { return l10n.justNow; } if (ageSeconds < 60) { return l10n.secondsAgo(ageSeconds); } final ageMinutes = ageSeconds ~/ 60; if (ageMinutes < 60) { return l10n.minutesAgo(ageMinutes); } final ageHours = ageMinutes ~/ 60; if (ageHours < 24) { return l10n.hoursAgo(ageHours); } return l10n.daysAgo(ageHours ~/ 24); } int _telemetryModesForSave() { return (_environmentTelemetryMode << 4) | (_locationTelemetryMode << 2) | _baseTelemetryMode; } int _advertLocationPolicyForSave() { return _advertLocationPolicy; } int _multiAcksForSave() { return _multiAcksEnabled ? 1 : 0; } Future _savePublicInfo() async { final connectionProvider = context.read(); final validator = ValidationService(); setState(() { _isSavingPublicInfo = true; _publicInfoSaved = false; _publicInfoError = null; }); try { // Save name if (_nameController.text.isNotEmpty) { await connectionProvider.setAdvertName(_nameController.text); } // Save stored coordinates only when the firmware advert policy uses prefs. if (_advertLocationPolicy == 2) { // Parse and validate coordinates final latResult = validator.parseLatitude(_latController.text); if (!latResult.isSuccess) { if (mounted) { setState(() { _publicInfoError = latResult.errorMessage!; _isSavingPublicInfo = false; }); } return; } final lonResult = validator.parseLongitude(_lonController.text); if (!lonResult.isSuccess) { if (mounted) { setState(() { _publicInfoError = lonResult.errorMessage!; _isSavingPublicInfo = false; }); } return; } await connectionProvider.setAdvertLatLon( latitude: latResult.value!, longitude: lonResult.value!, ); } await connectionProvider.setOtherParams( manualAddContacts: _autoAddFilterModeFlag, telemetryModes: _telemetryModesForSave(), advertLocationPolicy: _advertLocationPolicyForSave(), multiAcks: _multiAcksForSave(), ); // Refetch device info to update UI with new settings await connectionProvider.refreshDeviceInfo(); if (mounted) { setState(() { _isSavingPublicInfo = false; _publicInfoSaved = true; }); } } catch (e) { if (mounted) { setState(() { _isSavingPublicInfo = false; _publicInfoError = AppLocalizations.of( context, )!.failedToSave(e.toString()); }); } } } Future _saveRadioSettings() async { final connectionProvider = context.read(); final validator = ValidationService(); final deviceInfo = connectionProvider.deviceInfo; setState(() { _isSavingRadioSettings = true; _radioSettingsSaved = false; _radioSettingsError = null; }); try { // Parse and validate frequency final freqResult = validator.parseFrequency(_freqController.text); if (!freqResult.isSuccess) { if (mounted) { setState(() { _radioSettingsError = freqResult.errorMessage!; _isSavingRadioSettings = false; }); } return; } // Parse and validate TX power final txPowerResult = validator.parseTxPower( _txPowerController.text, maxPower: deviceInfo.maxTxPower, ); if (!txPowerResult.isSuccess) { if (mounted) { setState(() { _radioSettingsError = txPowerResult.errorMessage!; _isSavingRadioSettings = false; }); } return; } // Convert from MHz to kHz for protocol final freqKhz = (freqResult.value! * 1000).round(); await connectionProvider.setRadioParams( frequency: freqKhz, bandwidth: _bandwidthToValue(_selectedBandwidth), spreadingFactor: _selectedSpreadingFactor, codingRate: _selectedCodingRate, repeat: deviceInfo.clientRepeat != null ? _repeatEnabled : null, ); // Save TX power await connectionProvider.setTxPower(txPowerResult.value!); if (_selectedPathHashMode != null) { await connectionProvider.setPathHashMode(_selectedPathHashMode!); } // Refetch device info to update UI with new settings await connectionProvider.refreshDeviceInfo(); if (mounted) { setState(() { _syncRadioSettingsState(connectionProvider.deviceInfo); _isSavingRadioSettings = false; _radioSettingsSaved = true; }); } } catch (e) { if (mounted) { setState(() { _isSavingRadioSettings = false; _radioSettingsError = AppLocalizations.of( context, )!.failedToSave(e.toString()); }); } } } Future _saveAutoDiscoverySettings() async { final connectionProvider = context.read(); final autoAddUsers = _autoAddDiscoveredContactsEnabled && _autoAddUsersEnabled; final autoAddRepeaters = _autoAddDiscoveredContactsEnabled && _autoAddRepeatersEnabled; final autoAddRoomServers = _autoAddDiscoveredContactsEnabled && _autoAddRoomServersEnabled; final autoAddSensors = _autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled; final overwriteOldest = _autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled; final maxHopsText = _autoAddMaxHopsController.text.trim(); final maxHops = int.tryParse(maxHopsText); setState(() { _isSavingAutoDiscoverySettings = true; _autoDiscoverySettingsSaved = false; _autoDiscoverySettingsError = null; }); try { if (maxHops == null || maxHops < 0 || maxHops > 64) { throw Exception('Auto-add max hops must be between 0 and 64.'); } await connectionProvider.setAutoaddConfig( autoAddUsers: autoAddUsers, autoAddRepeaters: autoAddRepeaters, autoAddRoomServers: autoAddRoomServers, autoAddSensors: autoAddSensors, overwriteOldest: overwriteOldest, maxHops: maxHops, ); await connectionProvider.setOtherParams( manualAddContacts: _autoAddFilterModeFlag, telemetryModes: _telemetryModesForSave(), advertLocationPolicy: _advertLocationPolicyForSave(), multiAcks: _multiAcksForSave(), ); await connectionProvider.getAutoaddConfig(); if (mounted) { setState(() { _syncAutoDiscoveryState(connectionProvider.deviceInfo); _lastAutoDiscoverySignature = _autoDiscoverySignature( connectionProvider.deviceInfo, ); _isSavingAutoDiscoverySettings = false; _autoDiscoverySettingsSaved = true; _autoDiscoverySettingsDirty = false; }); } } catch (e) { if (mounted) { setState(() { _isSavingAutoDiscoverySettings = false; _autoDiscoverySettingsError = AppLocalizations.of( context, )!.failedToSave(e.toString()); }); } } } Future _loadGpsMode() async { try { final vars = await _connectionProvider.getCustomVars(); if (!mounted) return; final gpsValue = vars['gps']; final gpsFixValue = vars['gps_fix']; final gpsSatsValue = vars['gps_sats']; final gpsLatValue = vars['gps_lat_e6']; final gpsLonValue = vars['gps_lon_e6']; final gpsLastFixAgeValue = vars['gps_last_fix_age_s']; final buzzerValue = vars['buzzer']; setState(() { _gpsEnabled = gpsValue != null ? gpsValue == '1' : null; _buzzerEnabled = buzzerValue != null ? buzzerValue == '1' : null; _gpsHasFix = gpsFixValue != null ? gpsFixValue == '1' : null; _gpsSatelliteCount = int.tryParse(gpsSatsValue ?? ''); _gpsLatE6 = int.tryParse(gpsLatValue ?? ''); _gpsLonE6 = int.tryParse(gpsLonValue ?? ''); _gpsLastFixAgeSeconds = int.tryParse(gpsLastFixAgeValue ?? ''); _gpsStatsLoadedAt = DateTime.now(); }); } catch (_) { // Device may not support custom vars (old firmware / no GPS hardware) } } Future _setBuzzerMode(bool enabled) async { setState(() => _buzzerLoading = true); try { await _connectionProvider.setCustomVar('buzzer', enabled ? '1' : '0'); if (!mounted) return; setState(() { _buzzerEnabled = enabled; _buzzerLoading = false; }); unawaited(_loadGpsMode()); } catch (e) { if (!mounted) return; setState(() => _buzzerLoading = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.failedToSetBuzzerMode(e.toString()))), ); } } Future _setGpsMode(bool enabled) async { setState(() => _gpsLoading = true); try { await _connectionProvider.setCustomVar('gps', enabled ? '1' : '0'); if (!mounted) return; setState(() { _gpsEnabled = enabled; _gpsLoading = false; }); unawaited(_loadGpsMode()); } catch (e) { if (!mounted) return; setState(() => _gpsLoading = false); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.failedToSetGpsMode(e.toString())))); } } Future _useCurrentLocation() async { try { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.locationServicesDisabled, ), ), ); } return; } LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.locationPermissionDenied, ), ), ); } return; } } if (permission == LocationPermission.deniedForever) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of( context, )!.locationPermissionPermanentlyDenied, ), ), ); } return; } Position position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.best, ), ); if (!mounted) return; setState(() { _latController.text = position.latitude.toStringAsFixed(6); _lonController.text = position.longitude.toStringAsFixed(6); _advertLocationPolicy = 2; if (_locationTelemetryMode == 0) { _locationTelemetryMode = 2; } }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.locationBroadcast( position.latitude.toStringAsFixed(6), position.longitude.toStringAsFixed(6), ), ), backgroundColor: Colors.green, ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.failedToGetLocation(e.toString()), ), ), ); } } } Future _syncDeviceTime() async { setState(() => _isSyncingDeviceTime = true); try { _connectionProvider.clearError(); await _connectionProvider.syncDeviceTime(); final syncError = _connectionProvider.error; if (syncError != null) { throw Exception(syncError); } if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.deviceTimeSynced), backgroundColor: Colors.green, ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.failedToSyncDeviceTime(e.toString())), backgroundColor: Theme.of(context).colorScheme.error, ), ); } finally { if (mounted) { setState(() => _isSyncingDeviceTime = false); } } } Future _confirmFactoryReset() async { final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(AppLocalizations.of(context)!.wipeDeviceData), content: const Text( 'This will erase all data on the connected device, including contacts, keys, and saved settings. This cannot be undone.', ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(AppLocalizations.of(context)!.cancel), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, ), onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(AppLocalizations.of(context)!.wipeDevice), ), ], ), ); if (confirmed != true || !mounted) return; final messenger = ScaffoldMessenger.of(context); final connectionProvider = context.read(); try { await connectionProvider.factoryResetDevice(); if (!mounted) return; if (connectionProvider.error != null) { messenger.showSnackBar( SnackBar( content: Text(connectionProvider.error!), backgroundColor: Theme.of(context).colorScheme.error, ), ); return; } messenger.showSnackBar( const SnackBar( content: Text( 'Factory reset command sent. The device should reboot and disconnect shortly.', ), ), ); } catch (e) { if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.failedToWipeDeviceData(e.toString())), backgroundColor: Theme.of(context).colorScheme.error, ), ); } } Future _confirmClearAllContacts() async { final contacts = context .read() .contacts .where((contact) => !contact.isChannel) .toList(); if (contacts.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.noDeviceContactsToClear), ), ); return; } final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(AppLocalizations.of(context)!.clearAllContacts), content: Text( 'This will remove ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the connected device. Channels and radio settings will not be changed.', ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(AppLocalizations.of(context)!.cancel), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, ), onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(AppLocalizations.of(context)!.clearContacts), ), ], ), ); if (confirmed != true || !mounted) return; final messenger = ScaffoldMessenger.of(context); final connectionProvider = context.read(); final contactsProvider = context.read(); connectionProvider.clearError(); setState(() { _isClearingContacts = true; }); try { var processed = 0; for (final contact in List.from(contacts)) { await contactsProvider.removeContact( contact.publicKeyHex, onRemoveFromDevice: connectionProvider.removeContact, ); processed++; await Future.delayed(_bulkDeleteInterItemDelay); if (processed % _bulkDeleteBatchSize == 0) { await Future.delayed(_bulkDeleteBatchDelay); } } // Flush the updated local contact set to storage immediately. await contactsProvider.persistNow(); await Future.delayed(_bulkDeleteFinalSyncDelay); await connectionProvider.getContacts(); if (connectionProvider.error != null) { throw Exception(connectionProvider.error!); } if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text( 'Cleared ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the device.', ), ), ); } catch (e) { if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.failedToClearContacts(e.toString())), backgroundColor: Theme.of(context).colorScheme.error, ), ); } finally { if (mounted) { setState(() { _isClearingContacts = false; }); } } } Future _confirmClearAllChannels() async { final channels = context .read() .channels .where((channel) => !channel.isPublicChannel && channel.name.isNotEmpty) .toList(); if (channels.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.noCustomChannelsToClear), ), ); return; } final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(AppLocalizations.of(context)!.clearAllChannels), content: Text( 'This will remove ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the connected device. Contacts and radio settings will not be changed.', ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(AppLocalizations.of(context)!.cancel), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, ), onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(AppLocalizations.of(context)!.clearChannels), ), ], ), ); if (confirmed != true || !mounted) return; final messenger = ScaffoldMessenger.of(context); final connectionProvider = context.read(); final channelsProvider = context.read(); final contactsProvider = context.read(); connectionProvider.clearError(); setState(() { _isClearingChannels = true; }); try { var processed = 0; for (final channel in List.from(channels)) { await connectionProvider.deleteChannel(channel.index); processed++; await Future.delayed(_bulkDeleteInterItemDelay); if (processed % _bulkDeleteBatchSize == 0) { await Future.delayed(_bulkDeleteBatchDelay); } } // Force local channel/contact cache cleanup before the device resync. for (final channel in channels) { channelsProvider.removeChannel(channel.index); final publicKeyBytes = Uint8List(32); publicKeyBytes[0] = 0xFF; publicKeyBytes[1] = channel.index; final publicKeyHex = publicKeyBytes .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(''); await contactsProvider.removeContact(publicKeyHex); } await contactsProvider.persistNow(); await Future.delayed(_bulkDeleteFinalSyncDelay); await connectionProvider.syncChannels(); if (connectionProvider.error != null) { throw Exception(connectionProvider.error!); } if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text( 'Cleared ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the device.', ), ), ); } catch (e) { if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.failedToClearChannels(e.toString())), backgroundColor: Theme.of(context).colorScheme.error, ), ); } finally { if (mounted) { setState(() { _isClearingChannels = false; }); } } } @override Widget build(BuildContext context) { final deviceInfo = context.watch().deviceInfo; final theme = Theme.of(context); final colorScheme = theme.colorScheme; final locationSet = _advertLocationPolicy != 0; return Scaffold( appBar: AppBar(title: Text(AppLocalizations.of(context)!.deviceSettings)), body: ColoredBox( color: colorScheme.surface, child: SafeArea( child: ListView( padding: const EdgeInsets.fromLTRB(12, 8, 12, 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: AppLocalizations.of(context)!.location, value: locationSet ? 'Shared' : 'Hidden', icon: Icons.my_location_rounded, emphasized: locationSet, ), _HeroStatData( label: AppLocalizations.of(context)!.model, value: deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown, icon: Icons.memory_rounded, ), ], ), SizedBox(height: 12), _ConfigSectionCard( title: AppLocalizations.of(context)!.deviceInfo, subtitle: 'Capabilities, storage, and maintenance tools.', icon: Icons.info_outline_rounded, child: LayoutBuilder( builder: (context, constraints) { final cardWidth = constraints.maxWidth > 420 ? (constraints.maxWidth - 24) / 3 : (constraints.maxWidth - 12) / 2; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 10, runSpacing: 10, children: [ SizedBox( width: cardWidth, child: _StorageStat( label: AppLocalizations.of(context)!.blePin, value: _formatBlePin(deviceInfo.blePin), compact: true, ), ), SizedBox( width: cardWidth, child: _StorageStat( label: AppLocalizations.of( context, )!.maxContacts, value: deviceInfo.maxContacts?.toString() ?? AppLocalizations.of(context)!.unknown, compact: true, ), ), SizedBox( width: cardWidth, child: _StorageStat( label: AppLocalizations.of( context, )!.maxChannels, value: deviceInfo.maxChannels?.toString() ?? AppLocalizations.of(context)!.unknown, compact: true, ), ), if (deviceInfo.pathHashMode != null) SizedBox( width: cardWidth, child: _StorageStat( label: AppLocalizations.of(context)!.pathHash, value: _pathHashModeLabel( deviceInfo.pathHashMode!, ), compact: true, ), ), SizedBox( width: cardWidth, child: _StorageStat( label: AppLocalizations.of(context)!.used, value: _formatStorage( deviceInfo.storageUsedKb ?? 0, ), compact: true, ), ), SizedBox( width: cardWidth, child: _StorageStat( label: AppLocalizations.of(context)!.total, value: deviceInfo.storageTotalKb != null ? _formatStorage(deviceInfo.storageTotalKb!) : AppLocalizations.of(context)!.unknown, compact: true, ), ), ], ), const SizedBox(height: 10), _StorageUsageMeter(deviceInfo: deviceInfo), const SizedBox(height: 10), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: _isSyncingDeviceTime ? null : _syncDeviceTime, icon: _isSyncingDeviceTime ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon(Icons.schedule_rounded, size: 20), label: Text( _isSyncingDeviceTime ? 'Syncing...' : 'Sync device time', ), ), ), if (_buzzerEnabled != null) ...[ const SizedBox(height: 10), _SettingHighlightCard( icon: _buzzerEnabled! ? Icons.volume_up_rounded : Icons.volume_off_rounded, title: AppLocalizations.of(context)!.buzzerAlerts, description: 'Onboard buzzer for radio alerts', accentColor: _buzzerEnabled! ? colorScheme.primary : colorScheme.onSurfaceVariant, trailing: _buzzerLoading ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Switch( value: _buzzerEnabled!, onChanged: _setBuzzerMode, ), ), ], const SizedBox(height: 10), _SettingHighlightCard( icon: _multiAcksEnabled ? Icons.mark_email_read_outlined : Icons.mark_email_unread_outlined, title: AppLocalizations.of(context)!.multiAckMode, description: 'Request extra acknowledgements', accentColor: _multiAcksEnabled ? colorScheme.primary : colorScheme.onSurfaceVariant, trailing: Switch( value: _multiAcksEnabled, onChanged: (value) { setState(() { _multiAcksEnabled = value; _markPublicInfoDirty(); }); }, ), ), ], ); }, ), ), SizedBox(height: 12), _ConfigSectionCard( title: AppLocalizations.of(context)!.publicInfo, subtitle: AppLocalizations.of(context)!.nameAndTelemetryShared, icon: Icons.public_rounded, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _nameController, onChanged: (_) => _markPublicInfoDirty(), decoration: InputDecoration( labelText: 'Device name', border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), filled: true, isDense: true, fillColor: colorScheme.surfaceContainerLowest, helperText: 'Visible to other devices on the mesh', ), ), const SizedBox(height: 10), _ConfigDropdownField( label: AppLocalizations.of(context)!.baseTelemetry, value: _baseTelemetryMode, items: [ DropdownMenuItem(value: 0, child: Text(AppLocalizations.of(context)!.deny)), DropdownMenuItem( value: 1, child: Text(AppLocalizations.of(context)!.useContactFlags), ), DropdownMenuItem(value: 2, child: Text(AppLocalizations.of(context)!.allowAll)), ], onChanged: (value) { if (value == null) return; setState(() { _baseTelemetryMode = value; _markPublicInfoDirty(); }); }, ), const SizedBox(height: 10), _ConfigDropdownField( label: AppLocalizations.of(context)!.locationTelemetry, value: _locationTelemetryMode, items: [ DropdownMenuItem(value: 0, child: Text(AppLocalizations.of(context)!.deny)), DropdownMenuItem( value: 1, child: Text(AppLocalizations.of(context)!.useContactFlags), ), DropdownMenuItem(value: 2, child: Text(AppLocalizations.of(context)!.allowAll)), ], onChanged: (value) { if (value == null) return; setState(() { _locationTelemetryMode = value; _markPublicInfoDirty(); }); }, ), const SizedBox(height: 10), _ConfigDropdownField( label: AppLocalizations.of(context)!.environmentalTelemetry, value: _environmentTelemetryMode, items: [ DropdownMenuItem(value: 0, child: Text(AppLocalizations.of(context)!.deny)), DropdownMenuItem( value: 1, child: Text(AppLocalizations.of(context)!.useContactFlags), ), DropdownMenuItem(value: 2, child: Text(AppLocalizations.of(context)!.allowAll)), ], onChanged: (value) { if (value == null) return; setState(() { _environmentTelemetryMode = value; _markPublicInfoDirty(); }); }, ), const SizedBox(height: 12), if (_publicInfoError != null) ...[ Text( _publicInfoError!, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.error, ), ), const SizedBox(height: 10), ], SizedBox( width: double.infinity, child: _SaveActionButton( onPressed: _isSavingPublicInfo ? null : _savePublicInfo, isSaving: _isSavingPublicInfo, isSaved: _publicInfoSaved, label: AppLocalizations.of(context)!.savePublicInfo, ), ), ], ), ), SizedBox(height: 12), _ConfigSectionCard( title: AppLocalizations.of(context)!.gpsSection, subtitle: AppLocalizations.of(context)!.locationSharingHardwareAndUpdateInterval, icon: Icons.gps_fixed, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _ConfigDropdownField( label: AppLocalizations.of(context)!.gpsAdvertPolicy, value: _advertLocationPolicy, items: [ DropdownMenuItem(value: 0, child: Text(AppLocalizations.of(context)!.hidden)), DropdownMenuItem( value: 1, child: Text(AppLocalizations.of(context)!.shareLiveGps), ), DropdownMenuItem( value: 2, child: Text(AppLocalizations.of(context)!.useSavedCoordinates), ), ], onChanged: (value) { if (value == null) return; setState(() { _advertLocationPolicy = value; _markPublicInfoDirty(); }); }, ), if (_gpsEnabled != null) ...[ const SizedBox(height: 8), _SettingHighlightCard( icon: _gpsEnabled! ? Icons.gps_fixed : Icons.gps_off, title: AppLocalizations.of(context)!.gpsModule, description: 'Onboard GPS hardware', accentColor: _gpsEnabled! ? colorScheme.primary : colorScheme.onSurfaceVariant, trailing: _gpsLoading ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, ), ) : Switch( value: _gpsEnabled!, onChanged: _setGpsMode, ), ), const SizedBox(height: 8), _GpsDiagnosticsCard( fixValue: _formatGpsFixValue(), satellitesValue: _gpsSatelliteCount?.toString() ?? '—', locationValue: _formatGpsLocationValue(), lastFixValue: _formatGpsLastFixValue( AppLocalizations.of(context)!, ), ), ], const SizedBox(height: 10), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Row( children: [ Icon( Icons.timer_outlined, size: 18, color: colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Text( 'GPS polling is fixed at $_fixedHardwareGpsIntervalSeconds seconds on companion radios.', style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ), ], ), ), if (_advertLocationPolicy == 2) ...[ const SizedBox(height: 10), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Saved coordinates', style: theme.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), Row( children: [ Expanded( child: _CompactCoordinateField( controller: _latController, label: AppLocalizations.of(context)!.latitude, onChanged: (_) => _markPublicInfoDirty(), ), ), const SizedBox(width: 8), Expanded( child: _CompactCoordinateField( controller: _lonController, label: AppLocalizations.of(context)!.longitude, onChanged: (_) => _markPublicInfoDirty(), ), ), const SizedBox(width: 8), IconButton.filledTonal( onPressed: _useCurrentLocation, icon: const Icon(Icons.my_location, size: 20), tooltip: AppLocalizations.of( context, )!.useCurrentLocation, ), ], ), ], ), ), ] else if (_advertLocationPolicy == 1) ...[ const SizedBox(height: 8), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Text( 'Firmware will advertise the live GPS fix when available.', style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ), ], ], ), ), SizedBox(height: 12), _ConfigSectionCard( title: AppLocalizations.of(context)!.radioSettings, subtitle: AppLocalizations.of( context, )!.chooseAPresetOrFinetuneCustomRadioSettings, icon: Icons.settings_input_antenna_rounded, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField<_RadioPreset?>( key: ValueKey(_selectedRadioPreset?.id ?? 'custom'), initialValue: _selectedRadioPreset, decoration: InputDecoration( labelText: 'Radio preset', border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), filled: true, isDense: true, fillColor: colorScheme.surfaceContainerLowest, ), isExpanded: true, items: [ DropdownMenuItem<_RadioPreset?>( value: null, child: Text(AppLocalizations.of(context)!.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; _radioSettingsDirty = true; _radioSettingsSaved = false; _radioSettingsError = null; }); return; } _applyRadioPreset(preset); _markRadioSettingsDirty(); }, ), const SizedBox(height: 16), if (_selectedRadioPreset != null) _SelectedPresetCard(preset: _selectedRadioPreset!), const SizedBox(height: 8), InkWell( borderRadius: BorderRadius.circular(10), onTap: () { setState(() { _showCustomRadioSettings = !_showCustomRadioSettings; }); }, child: Padding( padding: const EdgeInsets.symmetric( vertical: 8, horizontal: 4, ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Custom settings', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w800, ), ), Text( _showCustomRadioSettings ? 'Frequency, bandwidth, SF, and CR' : 'Tap to fine-tune radio parameters', style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ), ), Icon( _showCustomRadioSettings ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant, ), ], ), ), ), if (_showCustomRadioSettings) ...[ const SizedBox(height: 12), TextField( controller: _freqController, decoration: InputDecoration( labelText: AppLocalizations.of( context, )!.frequencyMHz, border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), filled: true, fillColor: colorScheme.surfaceContainerLowest, isDense: true, helperText: 'e.g. 869.618', ), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), onChanged: (_) { setState(_syncRadioPresetSelection); _markRadioSettingsDirty(); }, ), const SizedBox(height: 12), DropdownButtonFormField( key: ValueKey('bandwidth-$_selectedBandwidth'), initialValue: _selectedBandwidth, decoration: InputDecoration( labelText: AppLocalizations.of( context, )!.bandwidth, border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), filled: true, isDense: 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; _syncRadioPresetSelection(); }); _markRadioSettingsDirty(); } }, ), const SizedBox(height: 12), DropdownButtonFormField( key: ValueKey( 'spreading-factor-$_selectedSpreadingFactor', ), initialValue: _selectedSpreadingFactor, decoration: InputDecoration( labelText: AppLocalizations.of( context, )!.spreadingFactor, border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), filled: true, isDense: 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; _syncRadioPresetSelection(); }); _markRadioSettingsDirty(); } }, ), const SizedBox(height: 12), DropdownButtonFormField( key: ValueKey('coding-rate-$_selectedCodingRate'), initialValue: _selectedCodingRate, decoration: InputDecoration( labelText: AppLocalizations.of( context, )!.codingRate, border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), filled: true, isDense: 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; _syncRadioPresetSelection(); }); _markRadioSettingsDirty(); } }, ), ], const SizedBox(height: 12), TextField( controller: _txPowerController, onChanged: (_) => _markRadioSettingsDirty(), decoration: InputDecoration( labelText: AppLocalizations.of( context, )!.txPowerDbm, border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), filled: true, isDense: true, fillColor: colorScheme.surfaceContainerLowest, helperText: AppLocalizations.of( context, )!.maxPowerDbm(deviceInfo.maxTxPower ?? 22), ), keyboardType: TextInputType.number, ), if (_selectedPathHashMode != null) ...[ const SizedBox(height: 12), DropdownButtonFormField( key: ValueKey('path-hash-$_selectedPathHashMode'), initialValue: _selectedPathHashMode, decoration: InputDecoration( labelText: 'Advert path hash size', border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), filled: true, isDense: true, fillColor: colorScheme.surfaceContainerLowest, helperText: 'Hash size used in adverts and flood paths', ), items: [ DropdownMenuItem( value: 0, child: Text(AppLocalizations.of(context)!.oneByteMode0), ), DropdownMenuItem( value: 1, child: Text(AppLocalizations.of(context)!.twoBytesMode1), ), DropdownMenuItem( value: 2, child: Text(AppLocalizations.of(context)!.threeBytesMode2), ), ], onChanged: (int? newValue) { if (newValue != null) { setState(() { _selectedPathHashMode = newValue; }); _markRadioSettingsDirty(); } }, ), ], if (deviceInfo.clientRepeat != null) ...[ SizedBox(height: 10), _SettingHighlightCard( icon: Icons.repeat_rounded, title: AppLocalizations.of( context, )!.repeatNearbyTraffic, 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; _radioSettingsSaved = false; _radioSettingsError = null; }); }, ), ), ], const SizedBox(height: 18), if (_radioSettingsError != null) ...[ Text( _radioSettingsError!, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.error, ), ), const SizedBox(height: 10), ], SizedBox( width: double.infinity, child: _SaveActionButton( onPressed: _isSavingRadioSettings ? null : _saveRadioSettings, isSaving: _isSavingRadioSettings, isSaved: _radioSettingsSaved, label: AppLocalizations.of(context)!.saveRadioSettings, ), ), ], ), ), SizedBox(height: 12), _ConfigSectionCard( title: AppLocalizations.of(context)!.autoDiscovery, subtitle: AppLocalizations.of(context)!.howTheRadioAutoAddsDiscoveredNodes, icon: Icons.person_search_rounded, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SettingHighlightCard( icon: _autoAddDiscoveredContactsEnabled ? Icons.person_add_alt_1 : Icons.person_add_disabled, title: AppLocalizations.of( context, )!.enableAutomaticAdding, description: 'Off = manual-only discovery', accentColor: _autoAddDiscoveredContactsEnabled ? colorScheme.primary : colorScheme.onSurfaceVariant, trailing: Switch( value: _autoAddDiscoveredContactsEnabled, onChanged: (value) { setState(() { _autoAddDiscoveredContactsEnabled = value; _markAutoDiscoverySettingsDirty(); }); }, ), ), const SizedBox(height: 8), _AutoDiscoveryToggle( icon: Icons.person_outline_rounded, label: AppLocalizations.of(context)!.autoaddUsers, value: _autoAddUsersEnabled, enabled: _autoAddDiscoveredContactsEnabled, onChanged: (v) { setState(() { _autoAddUsersEnabled = v; _markAutoDiscoverySettingsDirty(); }); }, ), _AutoDiscoveryToggle( icon: Icons.router_outlined, label: AppLocalizations.of(context)!.autoaddRepeaters, value: _autoAddRepeatersEnabled, enabled: _autoAddDiscoveredContactsEnabled, onChanged: (v) { setState(() { _autoAddRepeatersEnabled = v; _markAutoDiscoverySettingsDirty(); }); }, ), _AutoDiscoveryToggle( icon: Icons.meeting_room_outlined, label: AppLocalizations.of(context)!.autoaddRoomServers, value: _autoAddRoomServersEnabled, enabled: _autoAddDiscoveredContactsEnabled, onChanged: (v) { setState(() { _autoAddRoomServersEnabled = v; _markAutoDiscoverySettingsDirty(); }); }, ), _AutoDiscoveryToggle( icon: Icons.sensors_outlined, label: AppLocalizations.of(context)!.autoaddSensors, value: _autoAddSensorsEnabled, enabled: _autoAddDiscoveredContactsEnabled, onChanged: (v) { setState(() { _autoAddSensorsEnabled = v; _markAutoDiscoverySettingsDirty(); }); }, ), _AutoDiscoveryToggle( icon: Icons.history_toggle_off_rounded, label: AppLocalizations.of( context, )!.overwriteOldestWhenFull, value: _overwriteOldestAutoAddEnabled, enabled: _autoAddDiscoveredContactsEnabled, onChanged: (v) { setState(() { _overwriteOldestAutoAddEnabled = v; _markAutoDiscoverySettingsDirty(); }); }, ), const SizedBox(height: 10), TextField( controller: _autoAddMaxHopsController, onChanged: (_) => _markAutoDiscoverySettingsDirty(), decoration: InputDecoration( labelText: 'Auto-add max hops', border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), ), filled: true, isDense: true, fillColor: colorScheme.surfaceContainerLowest, helperText: '0 = no limit, 1 = direct neighbors only', ), keyboardType: TextInputType.number, ), const SizedBox(height: 12), if (_autoDiscoverySettingsError != null) ...[ Text( _autoDiscoverySettingsError!, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.error, ), ), const SizedBox(height: 8), ], SizedBox( width: double.infinity, child: _SaveActionButton( onPressed: _isSavingAutoDiscoverySettings ? null : _saveAutoDiscoverySettings, isSaving: _isSavingAutoDiscoverySettings, isSaved: _autoDiscoverySettingsSaved, label: AppLocalizations.of( context, )!.saveDiscoverySettings, ), ), ], ), ), SizedBox(height: 12), _ConfigSectionCard( title: AppLocalizations.of(context)!.dangerZone, subtitle: AppLocalizations.of( context, )!.destructiveDeviceActions, icon: Icons.warning_amber_rounded, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: _isClearingContacts || _isClearingChannels ? null : _confirmClearAllContacts, style: OutlinedButton.styleFrom( foregroundColor: colorScheme.error, side: BorderSide(color: colorScheme.error), minimumSize: const Size.fromHeight(44), ), icon: _isClearingContacts ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon(Icons.people_alt_outlined, size: 20), label: Text( AppLocalizations.of(context)!.clearAllContacts, ), ), ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: _isClearingContacts || _isClearingChannels ? null : _confirmClearAllChannels, style: OutlinedButton.styleFrom( foregroundColor: colorScheme.error, side: BorderSide(color: colorScheme.error), minimumSize: const Size.fromHeight(44), ), icon: _isClearingChannels ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, ), ) : const Icon(Icons.forum_outlined, size: 20), label: Text( AppLocalizations.of(context)!.clearAllChannels, ), ), ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _isClearingContacts || _isClearingChannels ? null : _confirmFactoryReset, style: FilledButton.styleFrom( backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError, minimumSize: const Size.fromHeight(44), ), icon: const Icon(Icons.delete_forever_rounded, size: 20), label: Text( AppLocalizations.of(context)!.wipeDeviceData, ), ), ), ], ), ), ], ), ), ), ); } String _getDeviceTypeString(BuildContext context, int? deviceType) { if (deviceType == null) return AppLocalizations.of(context)!.unknown; switch (deviceType) { case 0: return AppLocalizations.of(context)!.noneUnknown; case 1: return AppLocalizations.of(context)!.chatNode; case 2: return AppLocalizations.of(context)!.repeater; case 3: return AppLocalizations.of(context)!.roomChannel; default: return AppLocalizations.of(context)!.typeNumber(deviceType); } } 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'; } String _formatBlePin(int? blePin) { if (blePin == null) { return AppLocalizations.of(context)!.unknown; } return blePin.toString().padLeft(6, '0'); } String _pathHashModeLabel(int mode) { switch (mode) { case 0: return '1 byte'; case 1: return '2 bytes'; case 2: return '3 bytes'; default: return 'Mode $mode'; } } } class _ConfigHeroCard extends StatelessWidget { final String title; final String subtitle; final List<_HeroStatData> stats; const _ConfigHeroCard({ required this.title, required this.subtitle, required this.stats, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: Icon( Icons.tune_rounded, color: colorScheme.primary, size: 22, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, color: colorScheme.onSurface, ), ), Text( subtitle, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ), ), ], ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, 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 iconColor = data.emphasized ? colorScheme.primary : colorScheme.onSurfaceVariant; final borderColor = data.emphasized ? colorScheme.primary : colorScheme.outlineVariant; return Container( constraints: const BoxConstraints(minWidth: 120), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(14), border: Border.all(color: borderColor), ), child: Row( children: [ Icon(data.icon, size: 16, color: iconColor), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( data.label, style: theme.textTheme.labelSmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), Text( data.value, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelLarge?.copyWith( color: colorScheme.onSurface, fontWeight: FontWeight.w800, ), ), ], ), ), ], ), ); } } class _ConfigSectionCard extends StatelessWidget { final String title; final String subtitle; final IconData icon; final Widget child; const _ConfigSectionCard({ required this.title, required this.subtitle, required this.icon, required this.child, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Card( color: colorScheme.surfaceContainerLow, elevation: 0, surfaceTintColor: Colors.transparent, clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, color: colorScheme.primary, size: 22), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w800, ), ), Text( subtitle, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ), ), ], ), const SizedBox(height: 12), child, ], ), ), ); } } class _ConfigDropdownField extends StatelessWidget { final String label; final T value; final List> items; final ValueChanged onChanged; const _ConfigDropdownField({ required this.label, required this.value, required this.items, required this.onChanged, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return DropdownButtonFormField( initialValue: value, decoration: InputDecoration( labelText: label, border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)), filled: true, fillColor: colorScheme.surfaceContainerLowest, ), items: items, onChanged: onChanged, ); } } class _StorageStat extends StatelessWidget { final String label; final String value; final bool compact; const _StorageStat({ required this.label, required this.value, this.compact = false, }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Container( padding: EdgeInsets.all(compact ? 8 : 10), decoration: BoxDecoration( color: colorScheme.surfaceContainerLowest, borderRadius: BorderRadius.circular(12), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: TextStyle( fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant, fontSize: compact ? 11 : 12, ), ), Text( value, style: TextStyle( fontWeight: FontWeight.w800, color: colorScheme.onSurface, fontSize: compact ? 14 : 15, ), ), ], ), ); } } class _CompactCoordinateField extends StatelessWidget { final TextEditingController controller; final String label; final ValueChanged? onChanged; const _CompactCoordinateField({ required this.controller, required this.label, this.onChanged, }); @override Widget build(BuildContext context) { return TextField( controller: controller, onChanged: onChanged, decoration: InputDecoration( labelText: label, border: OutlineInputBorder(borderRadius: BorderRadius.circular(18)), filled: true, fillColor: Theme.of(context).colorScheme.surfaceContainerLowest, isDense: true, ), keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true, ), ); } } 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 Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( borderRadius: BorderRadius.circular(999), child: LinearProgressIndicator( value: percent, minHeight: 8, backgroundColor: colorScheme.surface, ), ), const SizedBox(height: 4), Text( '${(deviceInfo.storageUsedPercent ?? 0).toStringAsFixed(0)}% used', style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, ), ), ], ); } } 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.surfaceContainerLow, borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), 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 _SaveActionButton extends StatelessWidget { final VoidCallback? onPressed; final bool isSaving; final bool isSaved; final String label; const _SaveActionButton({ required this.onPressed, required this.isSaving, required this.isSaved, required this.label, }); @override Widget build(BuildContext context) { return FilledButton( onPressed: onPressed, child: AnimatedSwitcher( duration: const Duration(milliseconds: 180), child: isSaving ? Row( key: const ValueKey('saving'), mainAxisSize: MainAxisSize.min, children: [ const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2.2), ), const SizedBox(width: 10), Text(AppLocalizations.of(context)!.saving), ], ) : Row( key: ValueKey(isSaved ? 'saved' : 'idle'), mainAxisSize: MainAxisSize.min, children: [ Icon(isSaved ? Icons.check_rounded : Icons.save_outlined), const SizedBox(width: 8), Text(isSaved ? 'Saved' : label), ], ), ), ); } } 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.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Row( children: [ Icon(icon, color: accentColor, size: 22), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: theme.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), ), Text( description, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ), ), trailing, ], ), ); } } class _GpsDiagnosticsCard extends StatelessWidget { final String fixValue; final String satellitesValue; final String locationValue; final String lastFixValue; const _GpsDiagnosticsCard({ required this.fixValue, required this.satellitesValue, required this.locationValue, required this.lastFixValue, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(14), border: Border.all(color: colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.location_searching_rounded, color: colorScheme.primary, size: 16, ), const SizedBox(width: 6), Text( 'GPS diagnostics', style: theme.textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), ), ], ), const SizedBox(height: 6), _GpsDiagnosticsRow(label: AppLocalizations.of(context)!.fix, value: fixValue), const SizedBox(height: 4), _GpsDiagnosticsRow(label: AppLocalizations.of(context)!.satellites, value: satellitesValue), const SizedBox(height: 4), _GpsDiagnosticsRow(label: AppLocalizations.of(context)!.lastFix, value: lastFixValue), const SizedBox(height: 4), _GpsDiagnosticsRow( label: AppLocalizations.of(context)!.location, value: locationValue, maxLines: 2, ), ], ), ); } } class _GpsDiagnosticsRow extends StatelessWidget { final String label; final String value; final int maxLines; const _GpsDiagnosticsRow({ required this.label, required this.value, this.maxLines = 1, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 84, child: Text( label, style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 12), Expanded( child: Text( value, maxLines: maxLines, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurface, fontWeight: FontWeight.w700, ), ), ), ], ); } } class _AutoDiscoveryToggle extends StatelessWidget { final IconData icon; final String label; final bool value; final bool enabled; final ValueChanged onChanged; const _AutoDiscoveryToggle({ required this.icon, required this.label, required this.value, required this.enabled, required this.onChanged, }); @override Widget build(BuildContext context) { return SwitchListTile( dense: true, visualDensity: VisualDensity.compact, contentPadding: const EdgeInsets.symmetric(horizontal: 4), secondary: Icon(icon, size: 20), title: Text(label), value: value, onChanged: enabled ? onChanged : null, ); } } 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, }); }