mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
formats all dart files using `dart format .` from the root project dir this makes the code style repeatable by new contributors and makes PR review easier
969 lines
31 KiB
Dart
969 lines
31 KiB
Dart
import 'package:flutter/material.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<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();
|
|
final intervalController = TextEditingController();
|
|
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,
|
|
),
|
|
),
|
|
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),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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<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: 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);
|
|
}
|
|
}
|