mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
711 lines
23 KiB
Dart
711 lines
23 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../models/radio_settings.dart';
|
|
import '../services/app_settings_service.dart';
|
|
import 'app_settings_screen.dart';
|
|
import 'ble_debug_log_screen.dart';
|
|
|
|
class SettingsScreen extends StatelessWidget {
|
|
const SettingsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Settings'),
|
|
centerTitle: true,
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, child) {
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_buildDeviceInfoCard(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),
|
|
_buildAboutCard(context),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDeviceInfoCard(MeshCoreConnector connector) {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Device Info',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildInfoRow('Name', connector.deviceDisplayName),
|
|
_buildInfoRow('ID', connector.deviceIdLabel),
|
|
_buildInfoRow('Status', connector.isConnected ? 'Connected' : 'Disconnected'),
|
|
if (connector.selfName != null)
|
|
_buildInfoRow('Node Name', connector.selfName!),
|
|
if (connector.selfPublicKey != null)
|
|
_buildInfoRow('Public Key', '${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...'),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAppSettingsCard(BuildContext context) {
|
|
return Card(
|
|
child: ListTile(
|
|
leading: const Icon(Icons.settings_outlined),
|
|
title: const Text('App Settings'),
|
|
subtitle: const Text('Notifications, messaging, and map preferences'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const AppSettingsScreen()),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNodeSettingsCard(BuildContext context, MeshCoreConnector connector) {
|
|
return Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text(
|
|
'Node Settings',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.person_outline),
|
|
title: const Text('Node Name'),
|
|
subtitle: Text(connector.selfName ?? 'Not set'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => _editNodeName(context, connector),
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.radio),
|
|
title: const Text('Radio Settings'),
|
|
subtitle: const Text('Frequency, power, spreading factor'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => _showRadioSettings(context, connector),
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.location_on_outlined),
|
|
title: const Text('Location'),
|
|
subtitle: const Text('GPS coordinates'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => _editLocation(context, connector),
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.visibility_off_outlined),
|
|
title: const Text('Privacy Mode'),
|
|
subtitle: const Text('Hide name/location in advertisements'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => _togglePrivacy(context, connector),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActionsCard(BuildContext context, MeshCoreConnector connector) {
|
|
return Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Text(
|
|
'Actions',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.cell_tower),
|
|
title: const Text('Send Advertisement'),
|
|
subtitle: const Text('Broadcast presence now'),
|
|
onTap: () => _sendAdvert(context, connector),
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.sync),
|
|
title: const Text('Sync Time'),
|
|
subtitle: const Text('Set device clock to phone time'),
|
|
onTap: () => _syncTime(context, connector),
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.refresh),
|
|
title: const Text('Refresh Contacts'),
|
|
subtitle: const Text('Reload contact list from device'),
|
|
onTap: () => connector.getContacts(),
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.restart_alt, color: Colors.orange),
|
|
title: const Text('Reboot Device'),
|
|
subtitle: const Text('Restart the MeshCore device'),
|
|
onTap: () => _confirmReboot(context, connector),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAboutCard(BuildContext context) {
|
|
return Card(
|
|
child: ListTile(
|
|
leading: const Icon(Icons.info_outline),
|
|
title: const Text('About'),
|
|
subtitle: const Text('MeshCore Open v0.1.0'),
|
|
onTap: () => _showAbout(context),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDebugCard(BuildContext context) {
|
|
return Card(
|
|
child: ListTile(
|
|
leading: const Icon(Icons.bug_report_outlined),
|
|
title: const Text('BLE Debug Log'),
|
|
subtitle: const Text('Commands, responses, and status'),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const BleDebugLogScreen()),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(label, style: TextStyle(color: Colors.grey[600])),
|
|
Flexible(
|
|
child: Text(
|
|
value,
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
|
|
final controller = TextEditingController(text: connector.selfName ?? '');
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Node Name'),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: const InputDecoration(
|
|
hintText: 'Enter node name',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLength: 31,
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(context);
|
|
await connector.setNodeName(controller.text);
|
|
await connector.refreshDeviceInfo();
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Name updated')),
|
|
);
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showRadioSettings(BuildContext context, MeshCoreConnector connector) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => _RadioSettingsDialog(connector: connector),
|
|
);
|
|
}
|
|
|
|
void _editLocation(BuildContext context, MeshCoreConnector connector) {
|
|
final latController = TextEditingController();
|
|
final lonController = TextEditingController();
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Location'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: latController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Latitude',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: lonController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Longitude',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(context);
|
|
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(
|
|
const SnackBar(content: Text('Enter both latitude and longitude.')),
|
|
);
|
|
return;
|
|
}
|
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Invalid latitude or longitude.')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
await connector.setNodeLocation(lat: lat, lon: lon);
|
|
await connector.refreshDeviceInfo();
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Location updated')),
|
|
);
|
|
},
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _togglePrivacy(BuildContext context, MeshCoreConnector connector) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Privacy Mode'),
|
|
content: const Text('Toggle privacy mode to hide your name and location in advertisements.'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(context);
|
|
await connector.setPrivacyMode(true);
|
|
await connector.refreshDeviceInfo();
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Privacy mode enabled')),
|
|
);
|
|
},
|
|
child: const Text('Enable'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(context);
|
|
await connector.setPrivacyMode(false);
|
|
await connector.refreshDeviceInfo();
|
|
if (!context.mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Privacy mode disabled')),
|
|
);
|
|
},
|
|
child: const Text('Disable'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _sendAdvert(BuildContext context, MeshCoreConnector connector) {
|
|
connector.sendSelfAdvert(flood: true);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Advertisement sent')),
|
|
);
|
|
}
|
|
|
|
void _syncTime(BuildContext context, MeshCoreConnector connector) {
|
|
connector.syncTime();
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Time synchronized')),
|
|
);
|
|
}
|
|
|
|
void _confirmReboot(BuildContext context, MeshCoreConnector connector) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Reboot Device'),
|
|
content: const Text('Are you sure you want to reboot the device? You will be disconnected.'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
connector.rebootDevice();
|
|
},
|
|
child: const Text('Reboot', style: TextStyle(color: Colors.orange)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showAbout(BuildContext context) {
|
|
showAboutDialog(
|
|
context: context,
|
|
applicationName: 'MeshCore Open',
|
|
applicationVersion: '0.1.0',
|
|
applicationLegalese: '2024 MeshCore Open Source Project',
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'An open-source Flutter client for MeshCore LoRa mesh networking devices.',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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<void> _saveSettings() async {
|
|
final freqMHz = double.tryParse(_frequencyController.text);
|
|
final txPower = int.tryParse(_txPowerController.text);
|
|
|
|
if (freqMHz == null || freqMHz < 300 || freqMHz > 2500) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Invalid frequency (300-2500 MHz)')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (txPower == null || txPower < 0 || txPower > 22) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Invalid TX power (0-22 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(
|
|
const SnackBar(content: Text('Radio settings updated')),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Error: $e')),
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
return AlertDialog(
|
|
title: const Text('Radio Settings'),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Presets', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: [
|
|
_PresetChip(
|
|
label: '915 MHz',
|
|
onTap: () => _applyPreset(RadioSettings.preset915MHz),
|
|
),
|
|
_PresetChip(
|
|
label: '868 MHz',
|
|
onTap: () => _applyPreset(RadioSettings.preset868MHz),
|
|
),
|
|
_PresetChip(
|
|
label: '433 MHz',
|
|
onTap: () => _applyPreset(RadioSettings.preset433MHz),
|
|
),
|
|
_PresetChip(
|
|
label: 'Long Range',
|
|
onTap: () => _applyPreset(RadioSettings.presetLongRange),
|
|
),
|
|
_PresetChip(
|
|
label: 'Fast Speed',
|
|
onTap: () => _applyPreset(RadioSettings.presetFastSpeed),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
TextField(
|
|
controller: _frequencyController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Frequency (MHz)',
|
|
border: OutlineInputBorder(),
|
|
helperText: '300.0 - 2500.0',
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
),
|
|
const SizedBox(height: 16),
|
|
DropdownButtonFormField<LoRaBandwidth>(
|
|
value: _bandwidth,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Bandwidth',
|
|
border: 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<LoRaSpreadingFactor>(
|
|
value: _spreadingFactor,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Spreading Factor',
|
|
border: 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<LoRaCodingRate>(
|
|
value: _codingRate,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Coding Rate',
|
|
border: 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: const InputDecoration(
|
|
labelText: 'TX Power (dBm)',
|
|
border: OutlineInputBorder(),
|
|
helperText: '0 - 22',
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: _saveSettings,
|
|
child: const Text('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,
|
|
);
|
|
}
|
|
}
|