import 'package:flutter/material.dart'; import 'package:meshcore_open/utils/gpx_export.dart'; import 'package:meshcore_open/widgets/elements_ui.dart'; import 'package:provider/provider.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/radio_settings.dart'; import 'app_settings_screen.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { bool _showBatteryVoltage = false; bool _deviceInfoExpanded = false; String _appVersion = ''; @override void initState() { super.initState(); _loadVersionInfo(); } Future _loadVersionInfo() async { final packageInfo = await PackageInfo.fromPlatform(); setState(() { _appVersion = packageInfo.version; }); } @override Widget build(BuildContext context) { final l10n = context.l10n; return Scaffold( appBar: AppBar(title: Text(l10n.settings_title), centerTitle: true), body: SafeArea( top: false, child: Consumer( builder: (context, connector, child) { return ListView( padding: const EdgeInsets.all(16), children: [ _buildDeviceInfoCard(context, connector), const SizedBox(height: 16), _buildAppSettingsCard(context), const SizedBox(height: 16), _buildNodeSettingsCard(context, connector), const SizedBox(height: 16), _buildActionsCard(context, connector), const SizedBox(height: 16), _buildDebugCard(context), const SizedBox(height: 16), _buildExportCard(connector), const SizedBox(height: 16), _buildAboutCard(context), ], ); }, ), ), ); } Widget _buildDeviceInfoCard( BuildContext context, MeshCoreConnector connector, ) { final l10n = context.l10n; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ InkWell( borderRadius: BorderRadius.circular(12), onTap: () { setState(() { _deviceInfoExpanded = !_deviceInfoExpanded; }); }, child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), child: Row( children: [ Expanded( child: Text( l10n.settings_deviceInfo, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, ), ), ), AnimatedRotation( turns: _deviceInfoExpanded ? 0.5 : 0, duration: const Duration(milliseconds: 200), child: const Icon(Icons.expand_more), ), ], ), ), ), AnimatedCrossFade( firstChild: const SizedBox.shrink(), secondChild: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildInfoRow( l10n.settings_infoName, connector.deviceDisplayName, ), _buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel), _buildInfoRow( l10n.settings_infoStatus, connector.isConnected ? l10n.common_connected : l10n.common_disconnected, ), _buildBatteryInfoRow(context, connector), if (connector.selfName != null) _buildInfoRow(l10n.settings_nodeName, connector.selfName!), if (connector.selfPublicKey != null) _buildInfoRow( l10n.settings_infoPublicKey, '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...', ), _buildInfoRow( l10n.settings_infoContactsCount, '${connector.contacts.length}', ), _buildInfoRow( l10n.settings_infoChannelCount, '${connector.channels.length}', ), ], ), ), crossFadeState: _deviceInfoExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, duration: const Duration(milliseconds: 200), ), ], ), ); } Widget _buildBatteryInfoRow( BuildContext context, MeshCoreConnector connector, ) { final l10n = context.l10n; final percent = connector.batteryPercent; final millivolts = connector.batteryMillivolts; // figure out display value final String displayValue; if (millivolts == null) { displayValue = l10n.common_notAvailable; } else if (_showBatteryVoltage) { displayValue = l10n.common_voltageValue( (millivolts / 1000.0).toStringAsFixed(2), ); } else { displayValue = percent != null ? l10n.common_percentValue(percent) : l10n.common_notAvailable; } final IconData icon; final Color? iconColor; final Color? valueColor; if (percent == null) { icon = Icons.battery_unknown; iconColor = Colors.grey; valueColor = null; } else if (percent <= 15) { icon = Icons.battery_alert; iconColor = Colors.orange; valueColor = Colors.orange; } else { icon = Icons.battery_full; iconColor = null; valueColor = null; } return _buildInfoRow( l10n.settings_infoBattery, displayValue, leading: Icon(icon, size: 18, color: iconColor), valueColor: valueColor, onTap: millivolts != null ? () { setState(() { _showBatteryVoltage = !_showBatteryVoltage; }); } : null, ); } Widget _buildAppSettingsCard(BuildContext context) { final l10n = context.l10n; return Card( child: ListTile( leading: const Icon(Icons.settings_outlined), title: Text(l10n.settings_appSettings), subtitle: Text(l10n.settings_appSettingsSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( context, MaterialPageRoute(builder: (context) => const AppSettingsScreen()), ); }, ), ); } Widget _buildNodeSettingsCard( BuildContext context, MeshCoreConnector connector, ) { final l10n = context.l10n; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( l10n.settings_nodeSettings, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), ListTile( leading: const Icon(Icons.person_outline), title: Text(l10n.settings_nodeName), subtitle: Text(connector.selfName ?? l10n.settings_nodeNameNotSet), trailing: const Icon(Icons.chevron_right), onTap: () => _editNodeName(context, connector), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.radio), title: Text(l10n.settings_radioSettings), subtitle: Text(l10n.settings_radioSettingsSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () => _showRadioSettings(context, connector), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.location_on_outlined), title: Text(l10n.settings_location), subtitle: Text(l10n.settings_locationSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () => _editLocation(context, connector), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.visibility_off_outlined), title: Text(l10n.settings_privacyMode), subtitle: Text(l10n.settings_privacyModeSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () => _togglePrivacy(context, connector), ), ], ), ); } Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( l10n.settings_actions, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), ListTile( leading: const Icon(Icons.cell_tower), title: Text(l10n.settings_sendAdvertisement), subtitle: Text(l10n.settings_sendAdvertisementSubtitle), onTap: () => _sendAdvert(context, connector), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.sync), title: Text(l10n.settings_syncTime), subtitle: Text(l10n.settings_syncTimeSubtitle), onTap: () => _syncTime(context, connector), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.refresh), title: Text(l10n.settings_refreshContacts), subtitle: Text(l10n.settings_refreshContactsSubtitle), onTap: () => connector.getContacts(), ), const Divider(height: 1), ListTile( leading: const Icon(Icons.restart_alt, color: Colors.orange), title: Text(l10n.settings_rebootDevice), subtitle: Text(l10n.settings_rebootDeviceSubtitle), onTap: () => _confirmReboot(context, connector), ), ], ), ); } Widget _buildAboutCard(BuildContext context) { final l10n = context.l10n; return Card( child: ListTile( leading: const Icon(Icons.info_outline), title: Text(l10n.settings_about), subtitle: Text( l10n.settings_aboutVersion( _appVersion.isEmpty ? l10n.common_loading : _appVersion, ), ), onTap: () => _showAbout(context), ), ); } Widget _buildDebugCard(BuildContext context) { final l10n = context.l10n; return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( l10n.settings_debug, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ), ListTile( leading: const Icon(Icons.bluetooth_outlined), title: Text(l10n.settings_bleDebugLog), subtitle: Text(l10n.settings_bleDebugLogSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => const BleDebugLogScreen(), ), ); }, ), const Divider(height: 1), ListTile( leading: const Icon(Icons.code_outlined), title: Text(l10n.settings_appDebugLog), subtitle: Text(l10n.settings_appDebugLogSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => const AppDebugLogScreen(), ), ); }, ), ], ), ); } Widget _buildInfoRow( String label, String value, { Widget? leading, Color? valueColor, VoidCallback? onTap, }) { final theme = Theme.of(context); final row = Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (leading != null) ...[leading, const SizedBox(width: 8)], Expanded( child: Text( label, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontWeight: FontWeight.w500, ), ), ), ], ), const SizedBox(height: 4), Text( value, style: theme.textTheme.bodyLarge?.copyWith( color: valueColor, fontWeight: FontWeight.w500, ), ), ], ), ); if (onTap != null) { return InkWell( borderRadius: BorderRadius.circular(6), onTap: onTap, child: row, ); } return row; } void _editNodeName(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; final controller = TextEditingController(text: connector.selfName ?? ''); showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.settings_nodeName), content: TextField( controller: controller, decoration: InputDecoration( hintText: l10n.settings_nodeNameHint, border: const OutlineInputBorder(), ), maxLength: 31, ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l10n.common_cancel), ), TextButton( onPressed: () async { Navigator.pop(context); await connector.setNodeName(controller.text); await connector.refreshDeviceInfo(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_nodeNameUpdated)), ); }, child: Text(l10n.common_save), ), ], ), ); } void _showRadioSettings(BuildContext context, MeshCoreConnector connector) { showDialog( context: context, builder: (context) => _RadioSettingsDialog(connector: connector), ); } void _editLocation(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; final latController = TextEditingController(); final lonController = TextEditingController(); final intervalController = TextEditingController(); bool isLogging = connector.sparseLocationLogger?.isLogging() ?? false; latController.text = connector.selfLatitude?.toStringAsFixed(6) ?? ''; lonController.text = connector.selfLongitude?.toStringAsFixed(6) ?? ''; // Safe access to custom vars - may be null before device responds final customVars = connector.currentCustomVars ?? {}; final bool hasGPS = customVars.containsKey("gps"); bool isGPSEnabled = customVars["gps"] == "1"; // Read current interval or default to 900 (15 minutes) final currentInterval = int.tryParse(customVars["gps_interval"] ?? "") ?? 900; intervalController.text = currentInterval.toString(); showDialog( context: context, builder: (dialogContext) => StatefulBuilder( builder: (context, setDialogState) => AlertDialog( title: Text(l10n.settings_location), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: latController, decoration: InputDecoration( labelText: l10n.settings_latitude, border: const OutlineInputBorder(), ), keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true, ), ), const SizedBox(height: 16), TextField( controller: lonController, decoration: InputDecoration( labelText: l10n.settings_longitude, border: const OutlineInputBorder(), ), keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true, ), ), const SizedBox(height: 16), FeatureToggleRow( title: "GPS Logging", subtitle: "Enable GPS logging on the device", value: isLogging, onChanged: (value) async { setDialogState(() => isLogging = value); if (value) { await connector.sparseLocationLogger?.startLogging(); } else { await connector.sparseLocationLogger?.stopLogging(); } }, ), if (hasGPS) ...[ const SizedBox(height: 16), TextField( controller: intervalController, decoration: InputDecoration( labelText: l10n.settings_locationIntervalSec, border: const OutlineInputBorder(), ), keyboardType: const TextInputType.numberWithOptions( decimal: false, signed: false, ), ), const SizedBox(height: 16), FeatureToggleRow( title: l10n.settings_locationGPSEnable, subtitle: l10n.settings_locationGPSEnableSubtitle, value: isGPSEnabled, onChanged: (value) async { setDialogState(() => isGPSEnabled = value); if (value) { await connector.setCustomVar("gps:1"); } else { await connector.setCustomVar("gps:0"); } }, ), ], ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l10n.common_cancel), ), TextButton( onPressed: () async { Navigator.pop(context); if (hasGPS) { final intervalText = intervalController.text.trim(); if (intervalText.isEmpty) { return; } final interval = int.tryParse(intervalText); if (interval == null || interval < 60 || interval >= 86400) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(l10n.settings_locationIntervalInvalid), ), ); return; } await connector.setCustomVar("gps_interval:$interval"); await connector.refreshDeviceInfo(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_locationUpdated)), ); } final latText = latController.text.trim(); final lonText = lonController.text.trim(); if (latText.isEmpty && lonText.isEmpty) { return; } final currentLat = connector.selfLatitude; final currentLon = connector.selfLongitude; final lat = latText.isNotEmpty ? double.tryParse(latText) : currentLat; final lon = lonText.isNotEmpty ? double.tryParse(lonText) : currentLon; if (lat == null || lon == null) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_locationBothRequired)), ); return; } if (lat < -90 || lat > 90 || lon < -180 || lon > 180) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_locationInvalid)), ); return; } await connector.setNodeLocation(lat: lat, lon: lon); await connector.refreshDeviceInfo(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_locationUpdated)), ); }, child: Text(l10n.common_save), ), ], ), ), ); } void _togglePrivacy(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.settings_privacyMode), content: Text(l10n.settings_privacyModeToggle), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l10n.common_cancel), ), TextButton( onPressed: () async { Navigator.pop(context); await connector.setPrivacyMode(true); await connector.refreshDeviceInfo(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_privacyModeEnabled)), ); }, child: Text(l10n.common_enable), ), TextButton( onPressed: () async { Navigator.pop(context); await connector.setPrivacyMode(false); await connector.refreshDeviceInfo(); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_privacyModeDisabled)), ); }, child: Text(l10n.common_disable), ), ], ), ); } void _sendAdvert(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; connector.sendSelfAdvert(flood: true); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.settings_advertisementSent))); } void _syncTime(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; connector.syncTime(); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.settings_timeSynchronized))); } void _confirmReboot(BuildContext context, MeshCoreConnector connector) { final l10n = context.l10n; showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.settings_rebootDevice), content: Text(l10n.settings_rebootDeviceConfirm), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l10n.common_cancel), ), TextButton( onPressed: () { Navigator.pop(context); connector.rebootDevice(); }, child: Text( l10n.common_reboot, style: const TextStyle(color: Colors.orange), ), ), ], ), ); } void _showAbout(BuildContext context) { final l10n = context.l10n; showAboutDialog( context: context, applicationName: l10n.appTitle, applicationVersion: _appVersion.isEmpty ? l10n.common_loading : _appVersion, applicationLegalese: l10n.settings_aboutLegalese, children: [ const SizedBox(height: 16), Text(l10n.settings_aboutDescription), ], ); } _gpxExport( GpxExport exporter, String name, String description, String filename, String shareText, String subject, ) async { final l10n = context.l10n; final result = await exporter.exportGPX( name, description, filename, shareText, subject, ); if (!mounted) return; switch (result) { case gpxExportSuccess: ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportSuccess))); case gpxExportNoContacts: ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_gpxExportNoContacts)), ); break; case gpxExportNotAvailable: ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_gpxExportNotAvailable)), ); break; case gpxExportFailed: ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.settings_gpxExportError))); break; } } _buildExportCard(MeshCoreConnector connector) { final l10n = context.l10n; return Card( child: Column( children: [ ListTile( leading: const Icon(Icons.download_outlined), title: Text(l10n.settings_gpxExportRepeaters), subtitle: Text(l10n.settings_gpxExportRepeatersSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () async { final exporter = GpxExport(connector); exporter.addRepeaters(); _gpxExport( exporter, l10n.map_repeater, l10n.settings_gpxExportRepeatersRoom, "meshcore_repeaters_", l10n.settings_gpxExportShareText, l10n.settings_gpxExportShareSubject, ); }, ), ListTile( leading: const Icon(Icons.download_outlined), title: Text(l10n.settings_gpxExportContacts), subtitle: Text(l10n.settings_gpxExportContactsSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () async { final exporter = GpxExport(connector); exporter.addContacts(); _gpxExport( exporter, l10n.map_repeater, l10n.settings_gpxExportChat, "meshcore_contacts_", l10n.settings_gpxExportShareText, l10n.settings_gpxExportShareSubject, ); }, ), ListTile( leading: const Icon(Icons.download_outlined), title: Text(l10n.settings_gpxExportAll), subtitle: Text(l10n.settings_gpxExportAllSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () async { final exporter = GpxExport(connector); exporter.addAll(); _gpxExport( exporter, l10n.map_repeater, l10n.settings_gpxExportAllContacts, "meshcore_all_", l10n.settings_gpxExportShareText, l10n.settings_gpxExportShareSubject, ); }, ), ], ), ); } } class _RadioSettingsDialog extends StatefulWidget { final MeshCoreConnector connector; const _RadioSettingsDialog({required this.connector}); @override State<_RadioSettingsDialog> createState() => _RadioSettingsDialogState(); } class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { final _frequencyController = TextEditingController(); LoRaBandwidth _bandwidth = LoRaBandwidth.bw125; LoRaSpreadingFactor _spreadingFactor = LoRaSpreadingFactor.sf7; LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5; final _txPowerController = TextEditingController(text: '20'); @override void initState() { super.initState(); // Populate with current settings if available if (widget.connector.currentFreqHz != null) { _frequencyController.text = (widget.connector.currentFreqHz! / 1000.0) .toStringAsFixed(3); } else { _frequencyController.text = '915.0'; } if (widget.connector.currentBwHz != null) { // Find matching bandwidth enum final bwValue = widget.connector.currentBwHz!; for (var bw in LoRaBandwidth.values) { if (bw.hz == bwValue) { _bandwidth = bw; break; } } } if (widget.connector.currentSf != null) { // Find matching spreading factor enum final sfValue = widget.connector.currentSf!; for (var sf in LoRaSpreadingFactor.values) { if (sf.value == sfValue) { _spreadingFactor = sf; break; } } } if (widget.connector.currentCr != null) { // Find matching coding rate enum final crValue = _toUiCodingRate(widget.connector.currentCr!); for (var cr in LoRaCodingRate.values) { if (cr.value == crValue) { _codingRate = cr; break; } } } if (widget.connector.currentTxPower != null) { _txPowerController.text = widget.connector.currentTxPower.toString(); } } @override void dispose() { _frequencyController.dispose(); _txPowerController.dispose(); super.dispose(); } void _applyPreset(RadioSettings preset) { setState(() { _frequencyController.text = preset.frequencyMHz.toString(); _bandwidth = preset.bandwidth; _spreadingFactor = preset.spreadingFactor; _codingRate = preset.codingRate; _txPowerController.text = preset.txPowerDbm.toString(); }); } Future _saveSettings() async { final l10n = context.l10n; final freqMHz = double.tryParse(_frequencyController.text); final txPower = int.tryParse(_txPowerController.text); if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.settings_frequencyInvalid))); return; } final maxTxPower = widget.connector.maxTxPower ?? 22; if (txPower == null || txPower < 0 || txPower > maxTxPower) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'), ), ); return; } final freqHz = (freqMHz * 1000).round(); final bwHz = _bandwidth.hz; final sf = _spreadingFactor.value; final cr = _toDeviceCodingRate( _codingRate.value, widget.connector.currentCr, ); try { await widget.connector.sendFrame( buildSetRadioParamsFrame(freqHz, bwHz, sf, cr), ); await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower)); await widget.connector.refreshDeviceInfo(); if (!mounted) return; Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_radioSettingsUpdated)), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.settings_error(e.toString()))), ); } } int _toUiCodingRate(int deviceCr) { return deviceCr <= 4 ? deviceCr + 4 : deviceCr; } int _toDeviceCodingRate(int uiCr, int? deviceCr) { if (deviceCr != null && deviceCr <= 4) { return uiCr - 4; } return uiCr; } @override Widget build(BuildContext context) { final l10n = context.l10n; return AlertDialog( title: Text(l10n.settings_radioSettings), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.settings_presets, style: const TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Wrap( spacing: 8, children: [ _PresetChip( label: l10n.settings_preset915Mhz, onTap: () => _applyPreset(RadioSettings.preset915MHz), ), _PresetChip( label: l10n.settings_preset868Mhz, onTap: () => _applyPreset(RadioSettings.preset868MHz), ), _PresetChip( label: l10n.settings_preset433Mhz, onTap: () => _applyPreset(RadioSettings.preset433MHz), ), _PresetChip( label: l10n.settings_longRange, onTap: () => _applyPreset(RadioSettings.presetLongRange), ), _PresetChip( label: l10n.settings_fastSpeed, onTap: () => _applyPreset(RadioSettings.presetFastSpeed), ), ], ), const SizedBox(height: 24), TextField( controller: _frequencyController, decoration: InputDecoration( labelText: l10n.settings_frequency, border: const OutlineInputBorder(), helperText: l10n.settings_frequencyHelper, ), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), ), const SizedBox(height: 16), DropdownButtonFormField( initialValue: _bandwidth, decoration: InputDecoration( labelText: l10n.settings_bandwidth, border: const OutlineInputBorder(), ), items: LoRaBandwidth.values .map( (bw) => DropdownMenuItem(value: bw, child: Text(bw.label)), ) .toList(), onChanged: (value) { if (value != null) setState(() => _bandwidth = value); }, ), const SizedBox(height: 16), DropdownButtonFormField( initialValue: _spreadingFactor, decoration: InputDecoration( labelText: l10n.settings_spreadingFactor, border: const OutlineInputBorder(), ), items: LoRaSpreadingFactor.values .map( (sf) => DropdownMenuItem(value: sf, child: Text(sf.label)), ) .toList(), onChanged: (value) { if (value != null) setState(() => _spreadingFactor = value); }, ), const SizedBox(height: 16), DropdownButtonFormField( initialValue: _codingRate, decoration: InputDecoration( labelText: l10n.settings_codingRate, border: const OutlineInputBorder(), ), items: LoRaCodingRate.values .map( (cr) => DropdownMenuItem(value: cr, child: Text(cr.label)), ) .toList(), onChanged: (value) { if (value != null) setState(() => _codingRate = value); }, ), const SizedBox(height: 16), TextField( controller: _txPowerController, decoration: InputDecoration( labelText: l10n.settings_txPower, border: const OutlineInputBorder(), helperText: widget.connector.maxTxPower != null ? '${l10n.settings_txPowerHelper} (max: ${widget.connector.maxTxPower} dBm)' : l10n.settings_txPowerHelper, ), keyboardType: TextInputType.number, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l10n.common_cancel), ), FilledButton(onPressed: _saveSettings, child: Text(l10n.common_save)), ], ); } } class _PresetChip extends StatelessWidget { final String label; final VoidCallback onTap; const _PresetChip({required this.label, required this.onTap}); @override Widget build(BuildContext context) { return ActionChip(label: Text(label), onPressed: onTap); } }