mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Core Features Unread Message Tracking: Added persistent unread counts for contacts and channels with visual badges Message Deletion: Users can now long-press to delete individual messages in chats and channels SMAZ Compression: Added per-contact compression settings (previously only channels) UTF-8 Length Limiting: Text inputs now enforce protocol byte limits correctly Channel Message Paths: New screen to visualize packet routing through repeater network with map view Protocol Updates Added maxContactMessageBytes() and maxChannelMessageBytes() helpers for message length validation Changed channel PSK format from Base64 to Hexadecimal (breaking change) Added app version field to connection handshake frame UI Improvements Unread badges on all contact and channel list items Enhanced message bubbles with path visualization for channel messages Character count displays in message input fields Improved repeater CLI screen functionality New Files lib/storage/unread_store.dart - Unread tracking persistence lib/storage/contact_settings_store.dart - Per-contact SMAZ settings lib/widgets/unread_badge.dart - Unread count indicator lib/helpers/utf8_length_limiter.dart - Byte-aware text input formatter lib/screens/channel_message_path_screen.dart - Packet path visualization
693 lines
22 KiB
Dart
693 lines
22 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: 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.sendCliCommand('set name ${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);
|
|
var updated = false;
|
|
if (latController.text.isNotEmpty) {
|
|
await connector.sendCliCommand('set lat ${latController.text}');
|
|
updated = true;
|
|
}
|
|
if (lonController.text.isNotEmpty) {
|
|
await connector.sendCliCommand('set lon ${lonController.text}');
|
|
updated = true;
|
|
}
|
|
if (updated) {
|
|
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.sendCliCommand('set privacy on');
|
|
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.sendCliCommand('set privacy off');
|
|
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.sendCliCommand('advert');
|
|
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.sendCliCommand('reboot');
|
|
},
|
|
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,
|
|
);
|
|
}
|
|
}
|