mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
- Introduced a new extension for localization in Flutter with `LocalizationExtension` in `l10n.dart`. - Added a Python script `translate.py` for translating ARB/JSON localization files using a local Ollama model, preserving keys and placeholders, and handling ICU format rules.
858 lines
28 KiB
Dart
858 lines
28 KiB
Dart
import 'package:flutter/material.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<SettingsScreen> createState() => _SettingsScreenState();
|
|
}
|
|
|
|
class _SettingsScreenState extends State<SettingsScreen> {
|
|
bool _showBatteryVoltage = false;
|
|
String _appVersion = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadVersionInfo();
|
|
}
|
|
|
|
Future<void> _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<MeshCoreConnector>(
|
|
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),
|
|
_buildAboutCard(context),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDeviceInfoCard(BuildContext context, MeshCoreConnector connector) {
|
|
final l10n = context.l10n;
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.settings_deviceInfo,
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_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}'),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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 row = Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
if (leading != null) ...[
|
|
leading,
|
|
const SizedBox(width: 8),
|
|
],
|
|
Text(label, style: TextStyle(color: Colors.grey[600])),
|
|
],
|
|
),
|
|
Flexible(
|
|
child: Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w500,
|
|
color: valueColor,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (onTap != null) {
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(4),
|
|
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();
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => 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),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(l10n.common_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(
|
|
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),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
if (txPower == null || txPower < 0 || txPower > 22) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.settings_txPowerInvalid)),
|
|
);
|
|
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<LoRaBandwidth>(
|
|
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<LoRaSpreadingFactor>(
|
|
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<LoRaCodingRate>(
|
|
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: 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,
|
|
);
|
|
}
|
|
}
|