You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2200 lines
69 KiB
2200 lines
69 KiB
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:meshcore_open/utils/gpx_export.dart';
|
|
import 'package:meshcore_open/widgets/elements_ui.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../models/offband_gps_status.dart';
|
|
import '../models/radio_settings.dart';
|
|
import '../services/app_debug_log_service.dart';
|
|
import '../connector/observer_config_client.dart';
|
|
import '../helpers/snack_bar_builder.dart';
|
|
import 'settings/settings_shell.dart';
|
|
import 'settings/app_settings_view.dart';
|
|
import 'settings/message_settings_view.dart';
|
|
import 'settings/observer_settings_view.dart';
|
|
import 'app_debug_log_screen.dart';
|
|
import 'ble_debug_log_screen.dart';
|
|
import 'companion_radio_stats_screen.dart';
|
|
import '../widgets/sync_progress_overlay.dart';
|
|
|
|
/// Convert device coding-rate value (1-4 on some firmware, 5-8 on others)
|
|
/// to the UI enum range (always 5-8).
|
|
int _toUiCodingRate(int deviceCr) {
|
|
return deviceCr <= 4 ? deviceCr + 4 : deviceCr;
|
|
}
|
|
|
|
/// Convert UI coding-rate value (5-8) back to firmware encoding.
|
|
/// Uses the current device CR to detect which encoding the firmware expects.
|
|
int _toDeviceCodingRate(int uiCr, int? deviceCr) {
|
|
if (deviceCr != null && deviceCr <= 4) {
|
|
return uiCr - 4;
|
|
}
|
|
return uiCr;
|
|
}
|
|
|
|
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();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_appVersion = '${packageInfo.version}+${packageInfo.buildNumber}';
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
return SettingsShell(
|
|
title: l10n.settings_title,
|
|
appBarBottom: const SyncProgressAppBarBottom(),
|
|
categories: _categories(context),
|
|
);
|
|
}
|
|
|
|
List<SettingsCategory> _categories(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
// Rebuild ONLY when the observer gate flips — NOT on every connector update.
|
|
// A blanket context.watch here rebuilt the whole settings screen on every
|
|
// sync frame, thrashing the UI thread and slowing the channel sync (#81).
|
|
final showObserver = context.select<MeshCoreConnector, bool>(
|
|
(c) => ObserverConfigClient.supportsConfig(
|
|
firmwareVerCode: c.firmwareVerCode ?? 0,
|
|
offbandCaps: c.offbandCaps ?? 0,
|
|
),
|
|
);
|
|
return [
|
|
SettingsCategory(
|
|
icon: Icons.settings_input_antenna,
|
|
title: l10n.settings_radioSettings,
|
|
subtitle: l10n.settings_radioSettingsSubtitle,
|
|
builder: _radioRangePane,
|
|
),
|
|
SettingsCategory(
|
|
icon: Icons.sensors_outlined,
|
|
title: l10n.radioStats_settingsTile,
|
|
subtitle: l10n.radioStats_settingsSubtitle,
|
|
builder: _radioStatsPane,
|
|
),
|
|
SettingsCategory(
|
|
icon: Icons.badge_outlined,
|
|
title: l10n.settings_nodeSettings,
|
|
builder: _identityPane,
|
|
),
|
|
SettingsCategory(
|
|
icon: Icons.shield_outlined,
|
|
title: l10n.settings_privacy,
|
|
subtitle: l10n.settings_privacySubtitle,
|
|
builder: _privacyPane,
|
|
),
|
|
SettingsCategory(
|
|
icon: Icons.contacts_outlined,
|
|
title: l10n.contacts_title,
|
|
subtitle: l10n.settings_contactSettingsSubtitle,
|
|
builder: _contactsPane,
|
|
),
|
|
SettingsCategory(
|
|
icon: Icons.sms_outlined,
|
|
title: l10n.settings_messageSettings,
|
|
subtitle: l10n.settings_messageSettingsSubtitle,
|
|
builder: _messageSettingsPane,
|
|
),
|
|
SettingsCategory(
|
|
icon: Icons.info_outline,
|
|
title: l10n.settings_deviceInfo,
|
|
builder: _devicePane,
|
|
),
|
|
if (showObserver)
|
|
SettingsCategory(
|
|
icon: Icons.cloud_outlined,
|
|
title: 'Observer',
|
|
subtitle: 'WiFi · MQTT brokers · display',
|
|
builder: _observerPane,
|
|
),
|
|
SettingsCategory(
|
|
icon: Icons.tune,
|
|
title: l10n.settings_appSettings,
|
|
subtitle: l10n.settings_appSettingsSubtitle,
|
|
builder: _appPane,
|
|
),
|
|
SettingsCategory(
|
|
icon: Icons.bolt_outlined,
|
|
title: l10n.settings_actions,
|
|
builder: _actionsPane,
|
|
),
|
|
SettingsCategory(
|
|
icon: Icons.build_outlined,
|
|
title: l10n.settings_debug,
|
|
builder: _diagnosticsPane,
|
|
),
|
|
];
|
|
}
|
|
|
|
Widget _observerPane(BuildContext context) => const ObserverSettingsView();
|
|
|
|
Widget _radioRangePane(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.settings_radioSettings,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
_RadioSettingsForm(
|
|
key: ValueKey(
|
|
'${connector.currentFreqHz}-${connector.currentBwHz}-'
|
|
'${connector.currentSf}-${connector.currentCr}-'
|
|
'${connector.currentTxPower}-${connector.clientRepeat}',
|
|
),
|
|
connector: connector,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Card(child: _PathHashSizeTile(connector: connector)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _radioStatsPane(BuildContext context) =>
|
|
const CompanionRadioStatsBody();
|
|
|
|
Widget _identityPane(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
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.location_on_outlined),
|
|
title: Text(l10n.settings_location),
|
|
subtitle: Text(l10n.settings_locationSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () => _editLocation(context, connector),
|
|
),
|
|
if (connector.currentCustomVars?.containsKey('gps') ?? false) ...[
|
|
const Divider(height: 1),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.gps_fixed),
|
|
title: Text(l10n.settings_locationGPSEnable),
|
|
subtitle: Text(l10n.settings_locationGPSEnableSubtitle),
|
|
value: connector.currentCustomVars?['gps'] == '1',
|
|
onChanged: (value) async {
|
|
try {
|
|
await connector.setCustomVar(value ? 'gps:1' : 'gps:0');
|
|
} catch (_) {
|
|
if (context.mounted) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: const Text(
|
|
'Could not update GPS setting. Please try again.',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
],
|
|
if (connector.selfPublicKey != null) ...[
|
|
const Divider(height: 1),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
|
child: _buildInfoRow(
|
|
l10n.settings_infoPublicKey,
|
|
'${pubKeyToHex(connector.selfPublicKey!).substring(0, 16)}...',
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _privacyPane(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
|
child: Text(
|
|
l10n.settings_privacy,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
_PrivacySection(
|
|
key: ValueKey(
|
|
'${connector.telemetryModeBase}-${connector.telemetryModeLoc}-'
|
|
'${connector.telemetryModeEnv}-${connector.advertLocationPolicy}-'
|
|
'${connector.multiAcks}',
|
|
),
|
|
connector: connector,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _contactsPane(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
Card(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
|
child: Text(
|
|
l10n.contactsSettings_autoAddTitle,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
_AutoAddSection(
|
|
key: ValueKey(
|
|
'${connector.autoAddUsers}-${connector.autoAddRepeaters}-'
|
|
'${connector.autoAddRoomServers}-${connector.autoAddSensors}-'
|
|
'${connector.autoAddOverwriteOldest}',
|
|
),
|
|
connector: connector,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _devicePane(BuildContext context) {
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [_buildDeviceInfoCard(context, connector)],
|
|
);
|
|
}
|
|
|
|
Widget _appPane(BuildContext context) => const AppSettingsView();
|
|
|
|
Widget _messageSettingsPane(BuildContext context) =>
|
|
const MessageSettingsView();
|
|
|
|
Widget _actionsPane(BuildContext context) {
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [_buildActionsCard(context, connector)],
|
|
);
|
|
}
|
|
|
|
Widget _diagnosticsPane(BuildContext context) {
|
|
final connector = context.watch<MeshCoreConnector>();
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_buildDebugCard(context),
|
|
const SizedBox(height: 16),
|
|
_buildExportCard(connector),
|
|
const SizedBox(height: 16),
|
|
_buildAboutCard(context),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildDeviceInfoCard(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
final l10n = context.l10n;
|
|
final firmwareVersion = connector.firmwareVersion;
|
|
final deviceModel = connector.deviceModel;
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildInfoRow(l10n.settings_infoName, connector.deviceDisplayName),
|
|
_buildInfoRow(l10n.settings_infoId, connector.deviceIdLabel),
|
|
_buildInfoRow(
|
|
l10n.settings_infoStatus,
|
|
connector.isConnected
|
|
? l10n.common_connected
|
|
: l10n.common_disconnected,
|
|
),
|
|
if (firmwareVersion != null)
|
|
_buildInfoRow(l10n.settings_infoFirmware, firmwareVersion),
|
|
if (deviceModel != null)
|
|
_buildInfoRow(l10n.settings_infoModel, deviceModel),
|
|
_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 _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.delete_outline, color: Colors.red),
|
|
title: Text(l10n.settings_deleteAllPaths),
|
|
subtitle: Text(
|
|
l10n.settings_deleteAllPathsSubtitle,
|
|
style: TextStyle(color: Colors.red[700]),
|
|
),
|
|
onTap: () => connector.deleteAllPaths(),
|
|
),
|
|
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: () async {
|
|
try {
|
|
await connector.getContacts();
|
|
} catch (_) {
|
|
if (context.mounted) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: const Text(
|
|
'Could not refresh contacts. Please try again.',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
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_companionDebugLog),
|
|
subtitle: Text(l10n.settings_companionDebugLogSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const BleDebugLogScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
ListTile(
|
|
leading: const Icon(Icons.code_outlined),
|
|
title: Text(l10n.settings_appDebugLog),
|
|
subtitle: Text(l10n.settings_appDebugLogSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const AppDebugLogScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoRow(
|
|
String label,
|
|
String value, {
|
|
Widget? leading,
|
|
Color? valueColor,
|
|
VoidCallback? onTap,
|
|
}) {
|
|
final theme = Theme.of(context);
|
|
|
|
final row = Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
if (leading != null) ...[leading, const SizedBox(width: 8)],
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
value,
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: valueColor,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (onTap != null) {
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(6),
|
|
onTap: onTap,
|
|
child: row,
|
|
);
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
void _editNodeName(BuildContext context, MeshCoreConnector connector) {
|
|
final l10n = context.l10n;
|
|
final controller = TextEditingController(text: connector.selfName ?? '');
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l10n.settings_nodeName),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: InputDecoration(
|
|
hintText: l10n.settings_nodeNameHint,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
maxLength: 31,
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(context);
|
|
await connector.setNodeName(controller.text);
|
|
await connector.refreshDeviceInfo();
|
|
if (!context.mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_nodeNameUpdated),
|
|
);
|
|
},
|
|
child: Text(l10n.common_save),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildGpsQuerySection(
|
|
BuildContext context,
|
|
OffbandGpsStatus? status,
|
|
bool querying,
|
|
bool queriedOnce,
|
|
VoidCallback onRefresh,
|
|
) {
|
|
final l10n = context.l10n;
|
|
final theme = Theme.of(context);
|
|
|
|
Widget row(String label, String value) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(label),
|
|
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
|
|
],
|
|
),
|
|
);
|
|
|
|
final children = <Widget>[
|
|
const SizedBox(height: 8),
|
|
const Divider(),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
l10n.settings_gpsStatusTitle,
|
|
style: theme.textTheme.titleSmall,
|
|
),
|
|
),
|
|
if (querying)
|
|
const Padding(
|
|
padding: EdgeInsets.all(8),
|
|
child: SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
)
|
|
else
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh),
|
|
tooltip: l10n.settings_gpsStatusRefresh,
|
|
onPressed: onRefresh,
|
|
),
|
|
],
|
|
),
|
|
];
|
|
|
|
if (status != null) {
|
|
final live = status.hasLiveFix;
|
|
final color = live ? Colors.green : Colors.orange;
|
|
children.add(
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
live ? Icons.gps_fixed : Icons.gps_off,
|
|
size: 18,
|
|
color: color,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
live
|
|
? l10n.settings_gpsStatusLiveFix
|
|
: l10n.settings_gpsStatusNoFix,
|
|
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
children.add(const SizedBox(height: 8));
|
|
final coords = (status.latitude != null && status.longitude != null)
|
|
? '${status.latitude!.toStringAsFixed(6)}, '
|
|
'${status.longitude!.toStringAsFixed(6)}'
|
|
: '—';
|
|
children.add(row(l10n.settings_gpsStatusCoords, coords));
|
|
children.add(
|
|
row(l10n.settings_gpsStatusSats, status.satellites?.toString() ?? '—'),
|
|
);
|
|
children.add(
|
|
row(
|
|
l10n.settings_gpsStatusAltitude,
|
|
status.altitudeCm != null
|
|
? '${(status.altitudeCm! / 100).toStringAsFixed(1)} m'
|
|
: '—',
|
|
),
|
|
);
|
|
final t = status.timestampUtc?.toLocal();
|
|
final ml = MaterialLocalizations.of(context);
|
|
final timeStr = t == null
|
|
? '—'
|
|
: '${ml.formatCompactDate(t)} '
|
|
'${ml.formatTimeOfDay(TimeOfDay.fromDateTime(t))}';
|
|
children.add(row(l10n.settings_gpsStatusTime, timeStr));
|
|
} else {
|
|
children.add(
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(
|
|
queriedOnce
|
|
? l10n.settings_gpsStatusNoResponse
|
|
: l10n.settings_gpsStatusTapRefresh,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: children,
|
|
);
|
|
}
|
|
|
|
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();
|
|
|
|
OffbandGpsStatus? gpsStatus = connector.offbandGpsStatus;
|
|
bool queryingGps = false;
|
|
bool queriedOnce = false;
|
|
|
|
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);
|
|
try {
|
|
await connector.setCustomVar(value ? "gps:1" : "gps:0");
|
|
} catch (_) {
|
|
if (context.mounted) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: const Text(
|
|
'Could not update GPS setting. Please try again.',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
_buildGpsQuerySection(
|
|
context,
|
|
gpsStatus,
|
|
queryingGps,
|
|
queriedOnce,
|
|
() async {
|
|
setDialogState(() => queryingGps = true);
|
|
final s = await connector.requestOffbandGps();
|
|
if (!dialogContext.mounted) return;
|
|
setDialogState(() {
|
|
queryingGps = false;
|
|
queriedOnce = true;
|
|
gpsStatus = s;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
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;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_locationIntervalInvalid),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await connector.setCustomVar("gps_interval:$interval");
|
|
await connector.refreshDeviceInfo();
|
|
} catch (_) {
|
|
messenger.showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Could not update location. Please try again.',
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (!context.mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
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;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_locationBothRequired),
|
|
);
|
|
return;
|
|
}
|
|
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) {
|
|
if (!context.mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_locationInvalid),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await connector.setNodeLocation(lat: lat, lon: lon);
|
|
await connector.refreshDeviceInfo();
|
|
} catch (_) {
|
|
messenger.showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Could not update location. Please try again.',
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (!context.mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_locationUpdated),
|
|
);
|
|
},
|
|
child: Text(l10n.common_save),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _syncTime(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
) async {
|
|
final l10n = context.l10n;
|
|
try {
|
|
await connector.syncTime();
|
|
} catch (_) {
|
|
if (context.mounted) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: const Text('Could not sync time. Please try again.'),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (!context.mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
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),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _gpxExport(
|
|
GpxExport exporter,
|
|
String name,
|
|
String description,
|
|
String filename,
|
|
String shareText,
|
|
String subject,
|
|
) async {
|
|
final l10n = context.l10n;
|
|
final result = await exporter.exportGPX(
|
|
name,
|
|
description,
|
|
filename,
|
|
shareText,
|
|
subject,
|
|
);
|
|
if (!mounted) return;
|
|
switch (result) {
|
|
case gpxExportSuccess:
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_gpxExportSuccess),
|
|
);
|
|
case gpxExportNoContacts:
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_gpxExportNoContacts),
|
|
);
|
|
break;
|
|
case gpxExportNotAvailable:
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_gpxExportNotAvailable),
|
|
);
|
|
break;
|
|
case gpxExportFailed:
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_gpxExportError),
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
Widget _buildExportCard(MeshCoreConnector connector) {
|
|
final l10n = context.l10n;
|
|
return Card(
|
|
child: Column(
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.download_outlined),
|
|
title: Text(l10n.settings_gpxExportRepeaters),
|
|
subtitle: Text(l10n.settings_gpxExportRepeatersSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () async {
|
|
final exporter = GpxExport(connector);
|
|
exporter.addRepeaters();
|
|
_gpxExport(
|
|
exporter,
|
|
l10n.map_repeater,
|
|
l10n.settings_gpxExportRepeatersRoom,
|
|
"meshcore_repeaters_",
|
|
l10n.settings_gpxExportShareText,
|
|
l10n.settings_gpxExportShareSubject,
|
|
);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.download_outlined),
|
|
title: Text(l10n.settings_gpxExportContacts),
|
|
subtitle: Text(l10n.settings_gpxExportContactsSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () async {
|
|
final exporter = GpxExport(connector);
|
|
exporter.addContacts();
|
|
_gpxExport(
|
|
exporter,
|
|
l10n.map_repeater,
|
|
l10n.settings_gpxExportChat,
|
|
"meshcore_contacts_",
|
|
l10n.settings_gpxExportShareText,
|
|
l10n.settings_gpxExportShareSubject,
|
|
);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.download_outlined),
|
|
title: Text(l10n.settings_gpxExportAll),
|
|
subtitle: Text(l10n.settings_gpxExportAllSubtitle),
|
|
trailing: const Icon(Icons.chevron_right),
|
|
onTap: () async {
|
|
final exporter = GpxExport(connector);
|
|
exporter.addAll();
|
|
_gpxExport(
|
|
exporter,
|
|
l10n.map_repeater,
|
|
l10n.settings_gpxExportAllContacts,
|
|
"meshcore_all_",
|
|
l10n.settings_gpxExportShareText,
|
|
l10n.settings_gpxExportShareSubject,
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Inline privacy controls for the Privacy pane.
|
|
///
|
|
/// Advert-location, multi-ACKs, and the three telemetry-access modes all commit
|
|
/// through a single [MeshCoreConnector.setTelemetryModeBase] call, so each
|
|
/// control sends the full current state on change (coalesced) and reverts to the
|
|
/// device's values on failure. The host re-keys this widget on the connector's
|
|
/// current values, so a device-reported change rebuilds it with fresh values.
|
|
class _PrivacySection extends StatefulWidget {
|
|
final MeshCoreConnector connector;
|
|
|
|
const _PrivacySection({super.key, required this.connector});
|
|
|
|
@override
|
|
State<_PrivacySection> createState() => _PrivacySectionState();
|
|
}
|
|
|
|
class _PrivacySectionState extends State<_PrivacySection> {
|
|
late int _telemetryMode;
|
|
late int _telemetryLocMode;
|
|
late int _telemetryEnvMode;
|
|
late bool _advertLocPolicy;
|
|
late int _multiAcks;
|
|
|
|
bool _applying = false;
|
|
bool _applyAgain = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_seedFromConnector();
|
|
}
|
|
|
|
void _seedFromConnector() {
|
|
final c = widget.connector;
|
|
_telemetryMode = c.telemetryModeBase;
|
|
_telemetryLocMode = c.telemetryModeLoc;
|
|
_telemetryEnvMode = c.telemetryModeEnv;
|
|
_advertLocPolicy = c.advertLocationPolicy != 0;
|
|
_multiAcks = c.multiAcks;
|
|
}
|
|
|
|
Future<void> _apply() async {
|
|
if (_applying) {
|
|
_applyAgain = true;
|
|
return;
|
|
}
|
|
_applying = true;
|
|
try {
|
|
do {
|
|
_applyAgain = false;
|
|
await widget.connector.setTelemetryModeBase(
|
|
_telemetryMode,
|
|
_telemetryLocMode,
|
|
_telemetryEnvMode,
|
|
_advertLocPolicy ? 1 : 0,
|
|
_multiAcks,
|
|
);
|
|
} while (_applyAgain);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(_seedFromConnector);
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.settings_error(e.toString())),
|
|
);
|
|
} finally {
|
|
_applying = false;
|
|
}
|
|
}
|
|
|
|
Widget _telemetryDropdown({
|
|
required String label,
|
|
required int value,
|
|
required ValueChanged<int> onChanged,
|
|
}) {
|
|
final l10n = context.l10n;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
child: DropdownButtonFormField<int>(
|
|
key: ValueKey('$label-$value'),
|
|
initialValue: value,
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: [
|
|
DropdownMenuItem(
|
|
value: teleModeDeny,
|
|
child: Text(l10n.settings_denyAll),
|
|
),
|
|
DropdownMenuItem(
|
|
value: teleModeAllowFlags,
|
|
child: Text(l10n.settings_allowByContact),
|
|
),
|
|
DropdownMenuItem(
|
|
value: teleModeAllowAll,
|
|
child: Text(l10n.settings_allowAll),
|
|
),
|
|
],
|
|
onChanged: (v) {
|
|
if (v == null) return;
|
|
onChanged(v);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
|
child: Text(l10n.settings_privacySettingsDescription),
|
|
),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.share_location_outlined),
|
|
title: Text(l10n.settings_advertLocation),
|
|
subtitle: Text(l10n.settings_advertLocationSubtitle),
|
|
value: _advertLocPolicy,
|
|
onChanged: (value) {
|
|
setState(() => _advertLocPolicy = value);
|
|
_apply();
|
|
},
|
|
),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.done_all),
|
|
title: Text(l10n.settings_multiAck),
|
|
value: _multiAcks == 1,
|
|
onChanged: (value) {
|
|
setState(() => _multiAcks = value ? 1 : 0);
|
|
_apply();
|
|
},
|
|
),
|
|
_telemetryDropdown(
|
|
label: l10n.settings_telemetryBaseMode,
|
|
value: _telemetryMode,
|
|
onChanged: (v) {
|
|
setState(() => _telemetryMode = v);
|
|
_apply();
|
|
},
|
|
),
|
|
_telemetryDropdown(
|
|
label: l10n.settings_telemetryLocationMode,
|
|
value: _telemetryLocMode,
|
|
onChanged: (v) {
|
|
setState(() => _telemetryLocMode = v);
|
|
_apply();
|
|
},
|
|
),
|
|
_telemetryDropdown(
|
|
label: l10n.settings_telemetryEnvironmentMode,
|
|
value: _telemetryEnvMode,
|
|
onChanged: (v) {
|
|
setState(() => _telemetryEnvMode = v);
|
|
_apply();
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Inline path-hash size selector for the connected device.
|
|
///
|
|
/// 1/2/3-byte hop hashes trade repeater-ID space against the maximum hop
|
|
/// count. The width a region standardises on (commonly 2-byte) belongs here
|
|
/// in Radio & range, not buried under "experimental". Reads the current width
|
|
/// from device info and writes via [MeshCoreConnector.setPathHashMode].
|
|
class _PathHashSizeTile extends StatefulWidget {
|
|
final MeshCoreConnector connector;
|
|
|
|
const _PathHashSizeTile({required this.connector});
|
|
|
|
@override
|
|
State<_PathHashSizeTile> createState() => _PathHashSizeTileState();
|
|
}
|
|
|
|
class _PathHashSizeTileState extends State<_PathHashSizeTile> {
|
|
// Hop ceilings per hash width (1/2/3 bytes), from the firmware path-hash spec.
|
|
static const List<int> _maxHopsPerWidth = [64, 32, 21];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final connector = widget.connector;
|
|
final mode = (connector.pathHashByteWidth - 1).clamp(0, 2);
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
|
child: DropdownButtonFormField<int>(
|
|
key: ValueKey<int>(mode),
|
|
initialValue: mode,
|
|
isExpanded: true,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.repeater_pathHashMode,
|
|
helperText: l10n.repeater_pathHashModeHelper,
|
|
helperMaxLines: 5,
|
|
prefixIcon: const Icon(Icons.alt_route),
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: [
|
|
for (var m = 0; m < 3; m++)
|
|
DropdownMenuItem<int>(
|
|
value: m,
|
|
child: Text('${m + 1}-byte · max ${_maxHopsPerWidth[m]} hops'),
|
|
),
|
|
],
|
|
onChanged: connector.isConnected
|
|
? (value) async {
|
|
if (value == null) return;
|
|
try {
|
|
await connector.setPathHashMode(value);
|
|
await connector.refreshDeviceInfo();
|
|
if (!context.mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(
|
|
'${l10n.repeater_pathHashMode}: ${value + 1}-byte',
|
|
),
|
|
);
|
|
} catch (e) {
|
|
if (!context.mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_error(e.toString())),
|
|
);
|
|
}
|
|
}
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Inline auto-add controls for the Contacts pane.
|
|
///
|
|
/// Replaces the former modal dialog: toggles apply immediately (send + refetch
|
|
/// flags). The host re-keys this widget on the connector's current flags, so a
|
|
/// device-reported change rebuilds it with fresh values.
|
|
class _AutoAddSection extends StatefulWidget {
|
|
final MeshCoreConnector connector;
|
|
|
|
const _AutoAddSection({super.key, required this.connector});
|
|
|
|
@override
|
|
State<_AutoAddSection> createState() => _AutoAddSectionState();
|
|
}
|
|
|
|
class _AutoAddSectionState extends State<_AutoAddSection> {
|
|
late bool _chat;
|
|
late bool _repeater;
|
|
late bool _roomServer;
|
|
late bool _sensor;
|
|
late bool _overwriteOldest;
|
|
|
|
bool _applying = false;
|
|
bool _applyAgain = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final c = widget.connector;
|
|
_chat = c.autoAddUsers ?? false;
|
|
_repeater = c.autoAddRepeaters ?? false;
|
|
_roomServer = c.autoAddRoomServers ?? false;
|
|
_sensor = c.autoAddSensors ?? false;
|
|
_overwriteOldest = c.autoAddOverwriteOldest ?? false;
|
|
}
|
|
|
|
Future<void> _apply() async {
|
|
// Coalesce rapid toggles: if a send is already in flight, flag a re-apply
|
|
// with the latest state for when it finishes instead of overlapping sends.
|
|
if (_applying) {
|
|
_applyAgain = true;
|
|
return;
|
|
}
|
|
_applying = true;
|
|
try {
|
|
do {
|
|
_applyAgain = false;
|
|
await widget.connector.sendFrame(
|
|
buildSetAutoAddConfigFrame(
|
|
autoAddChat: _chat,
|
|
autoAddRepeater: _repeater,
|
|
autoAddRoomServer: _roomServer,
|
|
autoAddSensor: _sensor,
|
|
overwriteOldest: _overwriteOldest,
|
|
),
|
|
);
|
|
await widget.connector.sendFrame(buildGetAutoAddFlagsFrame());
|
|
} while (_applyAgain);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
// Revert the optimistic toggles to the device's last-known flags.
|
|
final c = widget.connector;
|
|
setState(() {
|
|
_chat = c.autoAddUsers ?? false;
|
|
_repeater = c.autoAddRepeaters ?? false;
|
|
_roomServer = c.autoAddRoomServers ?? false;
|
|
_sensor = c.autoAddSensors ?? false;
|
|
_overwriteOldest = c.autoAddOverwriteOldest ?? false;
|
|
});
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.settings_error(e.toString())),
|
|
);
|
|
} finally {
|
|
_applying = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
return Column(
|
|
children: [
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.person_add_outlined),
|
|
title: Text(l10n.contactsSettings_autoAddUsersTitle),
|
|
subtitle: Text(l10n.contactsSettings_autoAddUsersSubtitle),
|
|
value: _chat,
|
|
onChanged: (value) {
|
|
setState(() => _chat = value);
|
|
_apply();
|
|
},
|
|
),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.router_outlined),
|
|
title: Text(l10n.contactsSettings_autoAddRepeatersTitle),
|
|
subtitle: Text(l10n.contactsSettings_autoAddRepeatersSubtitle),
|
|
value: _repeater,
|
|
onChanged: (value) {
|
|
setState(() => _repeater = value);
|
|
_apply();
|
|
},
|
|
),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.meeting_room_outlined),
|
|
title: Text(l10n.contactsSettings_autoAddRoomServersTitle),
|
|
subtitle: Text(l10n.contactsSettings_autoAddRoomServersSubtitle),
|
|
value: _roomServer,
|
|
onChanged: (value) {
|
|
setState(() => _roomServer = value);
|
|
_apply();
|
|
},
|
|
),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.sensors_outlined),
|
|
title: Text(l10n.contactsSettings_autoAddSensorsTitle),
|
|
subtitle: Text(l10n.contactsSettings_autoAddSensorsSubtitle),
|
|
value: _sensor,
|
|
onChanged: (value) {
|
|
setState(() => _sensor = value);
|
|
_apply();
|
|
},
|
|
),
|
|
const Divider(height: 1),
|
|
SwitchListTile(
|
|
secondary: const Icon(Icons.autorenew),
|
|
title: Text(l10n.contactsSettings_overwriteOldestTitle),
|
|
subtitle: Text(l10n.contactsSettings_overwriteOldestSubtitle),
|
|
value: _overwriteOldest,
|
|
onChanged: (value) {
|
|
setState(() => _overwriteOldest = value);
|
|
_apply();
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RadioSettingsForm extends StatefulWidget {
|
|
final MeshCoreConnector connector;
|
|
|
|
const _RadioSettingsForm({super.key, required this.connector});
|
|
|
|
@override
|
|
State<_RadioSettingsForm> createState() => _RadioSettingsFormState();
|
|
}
|
|
|
|
class _RadioSettingsFormState extends State<_RadioSettingsForm> {
|
|
final _frequencyController = TextEditingController();
|
|
LoRaBandwidth _bandwidth = LoRaBandwidth.bw125;
|
|
LoRaSpreadingFactor _spreadingFactor = LoRaSpreadingFactor.sf7;
|
|
LoRaCodingRate _codingRate = LoRaCodingRate.cr4_5;
|
|
final _txPowerController = TextEditingController(text: '20');
|
|
bool _clientRepeat = false;
|
|
int? _selectedPresetIndex;
|
|
_RadioSettingsSnapshot? _lastNonRepeatSnapshot;
|
|
|
|
AppDebugLogService get _appLog =>
|
|
Provider.of<AppDebugLogService>(context, listen: false);
|
|
|
|
@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();
|
|
}
|
|
|
|
_clientRepeat = widget.connector.clientRepeat ?? false;
|
|
_selectedPresetIndex = _findMatchingPresetIndex();
|
|
if (_clientRepeat) {
|
|
_lastNonRepeatSnapshot =
|
|
_sessionRememberedNonRepeatSnapshot() ??
|
|
_inferNonRepeatSnapshotForRepeatEnabled();
|
|
_selectedPresetIndex = _findMatchingPresetIndexForSnapshot(
|
|
_lastNonRepeatSnapshot!,
|
|
);
|
|
} else {
|
|
_lastNonRepeatSnapshot = _nonRepeatSnapshotForCurrentSelection();
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_logRadioSettingsState('Dialog initialized');
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_frequencyController.dispose();
|
|
_txPowerController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _applyPreset(int index) {
|
|
setState(() {
|
|
_applyPresetState(index);
|
|
});
|
|
_logRadioSettingsState(
|
|
'Applied preset ${RadioSettings.presets[index].$1} (#$index)',
|
|
);
|
|
}
|
|
|
|
int? _findMatchingPresetIndex() {
|
|
return _findMatchingPresetIndexForSnapshot(_currentSnapshot());
|
|
}
|
|
|
|
int? _findMatchingPresetIndexForSnapshot(_RadioSettingsSnapshot snapshot) {
|
|
for (final i in _visiblePresetIndexes()) {
|
|
final preset = RadioSettings.presets[i].$2;
|
|
if (preset.frequencyHz == snapshot.frequencyHz &&
|
|
preset.bandwidth == snapshot.bandwidth &&
|
|
preset.spreadingFactor == snapshot.spreadingFactor &&
|
|
preset.codingRate == snapshot.codingRate &&
|
|
preset.txPowerDbm == snapshot.txPowerDbm) {
|
|
return i;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
Iterable<int> _visiblePresetIndexes() sync* {
|
|
for (var i = 0; i < RadioSettings.presets.length; i++) {
|
|
if (_isOffGridPresetIndex(i)) {
|
|
continue;
|
|
}
|
|
yield i;
|
|
}
|
|
}
|
|
|
|
_RadioSettingsSnapshot _currentSnapshot() {
|
|
final frequencyMHz = double.tryParse(_frequencyController.text) ?? 915.0;
|
|
final txPowerDbm = int.tryParse(_txPowerController.text) ?? 20;
|
|
return _RadioSettingsSnapshot(
|
|
frequencyMHz: frequencyMHz,
|
|
bandwidth: _bandwidth,
|
|
spreadingFactor: _spreadingFactor,
|
|
codingRate: _codingRate,
|
|
txPowerDbm: txPowerDbm,
|
|
);
|
|
}
|
|
|
|
bool _isOffGridPresetIndex(int? index) {
|
|
if (index == null) return false;
|
|
return RadioSettings.presets[index].$1.startsWith('Off-Grid ');
|
|
}
|
|
|
|
double _offGridFrequencyForBaseFrequency(double baseFrequencyMHz) {
|
|
if (baseFrequencyMHz < 500) return 433.0;
|
|
if (baseFrequencyMHz < 900) return 869.0;
|
|
return 918.0;
|
|
}
|
|
|
|
double _normalFrequencyForBand(double frequencyMHz) {
|
|
if (frequencyMHz < 500) return 433.650;
|
|
if (frequencyMHz < 900) return 869.432;
|
|
return 915.8;
|
|
}
|
|
|
|
_RadioSettingsSnapshot _fallbackNonRepeatSnapshot(
|
|
double currentFrequencyMHz,
|
|
) {
|
|
return _RadioSettingsSnapshot(
|
|
frequencyMHz: _normalFrequencyForBand(currentFrequencyMHz),
|
|
bandwidth: _bandwidth,
|
|
spreadingFactor: _spreadingFactor,
|
|
codingRate: _codingRate,
|
|
txPowerDbm: int.tryParse(_txPowerController.text) ?? 20,
|
|
);
|
|
}
|
|
|
|
_RadioSettingsSnapshot _nonRepeatSnapshotForCurrentSelection() {
|
|
final current = _currentSnapshot();
|
|
if (!_isOffGridPresetIndex(_selectedPresetIndex)) {
|
|
return current;
|
|
}
|
|
return _fallbackNonRepeatSnapshot(current.frequencyMHz);
|
|
}
|
|
|
|
_RadioSettingsSnapshot? _sessionRememberedNonRepeatSnapshot() {
|
|
final snapshot = widget.connector.rememberedNonRepeatRadioState;
|
|
if (snapshot == null) return null;
|
|
return _RadioSettingsSnapshot.fromMeshCoreSnapshot(snapshot);
|
|
}
|
|
|
|
_RadioSettingsSnapshot _inferNonRepeatSnapshotForRepeatEnabled() {
|
|
final current = _currentSnapshot();
|
|
for (final i in _visiblePresetIndexes()) {
|
|
final preset = RadioSettings.presets[i].$2;
|
|
final offGridFreqHz =
|
|
(_offGridFrequencyForBaseFrequency(preset.frequencyMHz) * 1000)
|
|
.round();
|
|
if (offGridFreqHz == current.frequencyHz &&
|
|
preset.bandwidth == current.bandwidth &&
|
|
preset.spreadingFactor == current.spreadingFactor &&
|
|
preset.codingRate == current.codingRate &&
|
|
preset.txPowerDbm == current.txPowerDbm) {
|
|
return _RadioSettingsSnapshot(
|
|
frequencyMHz: preset.frequencyMHz,
|
|
bandwidth: preset.bandwidth,
|
|
spreadingFactor: preset.spreadingFactor,
|
|
codingRate: preset.codingRate,
|
|
txPowerDbm: preset.txPowerDbm,
|
|
);
|
|
}
|
|
}
|
|
return _fallbackNonRepeatSnapshot(current.frequencyMHz);
|
|
}
|
|
|
|
void _applySnapshot(_RadioSettingsSnapshot snapshot) {
|
|
_frequencyController.text = snapshot.frequencyMHz.toStringAsFixed(3);
|
|
_bandwidth = snapshot.bandwidth;
|
|
_spreadingFactor = snapshot.spreadingFactor;
|
|
_codingRate = snapshot.codingRate;
|
|
_txPowerController.text = snapshot.txPowerDbm.toString();
|
|
}
|
|
|
|
void _applyPresetState(int index) {
|
|
final preset = RadioSettings.presets[index].$2;
|
|
final baseSnapshot = _RadioSettingsSnapshot(
|
|
frequencyMHz: preset.frequencyMHz,
|
|
bandwidth: preset.bandwidth,
|
|
spreadingFactor: preset.spreadingFactor,
|
|
codingRate: preset.codingRate,
|
|
txPowerDbm: preset.txPowerDbm,
|
|
);
|
|
final frequencyMHz = _clientRepeat
|
|
? _offGridFrequencyForBaseFrequency(baseSnapshot.frequencyMHz)
|
|
: baseSnapshot.frequencyMHz;
|
|
_frequencyController.text = frequencyMHz.toString();
|
|
_bandwidth = preset.bandwidth;
|
|
_spreadingFactor = preset.spreadingFactor;
|
|
_codingRate = preset.codingRate;
|
|
_txPowerController.text = preset.txPowerDbm.toString();
|
|
_selectedPresetIndex = index;
|
|
_lastNonRepeatSnapshot = baseSnapshot;
|
|
}
|
|
|
|
void _syncPresetSelection() {
|
|
final previousPresetIndex = _selectedPresetIndex;
|
|
final previousLastNonRepeat = _lastNonRepeatSnapshot;
|
|
if (_clientRepeat) {
|
|
final baseSnapshot =
|
|
previousLastNonRepeat ?? _inferNonRepeatSnapshotForRepeatEnabled();
|
|
if (_bandwidth != baseSnapshot.bandwidth ||
|
|
_spreadingFactor != baseSnapshot.spreadingFactor ||
|
|
_codingRate != baseSnapshot.codingRate ||
|
|
(int.tryParse(_txPowerController.text) ?? 20) !=
|
|
baseSnapshot.txPowerDbm) {
|
|
_lastNonRepeatSnapshot = _RadioSettingsSnapshot(
|
|
frequencyMHz: baseSnapshot.frequencyMHz,
|
|
bandwidth: _bandwidth,
|
|
spreadingFactor: _spreadingFactor,
|
|
codingRate: _codingRate,
|
|
txPowerDbm: int.tryParse(_txPowerController.text) ?? 20,
|
|
);
|
|
}
|
|
_selectedPresetIndex = _findMatchingPresetIndexForSnapshot(
|
|
_lastNonRepeatSnapshot ?? baseSnapshot,
|
|
);
|
|
if (previousPresetIndex != _selectedPresetIndex ||
|
|
previousLastNonRepeat != _lastNonRepeatSnapshot) {
|
|
_logRadioSettingsState(
|
|
'Preset match updated while repeat enabled: ${_presetLabel(previousPresetIndex)} -> ${_presetLabel(_selectedPresetIndex)}',
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
_lastNonRepeatSnapshot = _nonRepeatSnapshotForCurrentSelection();
|
|
_selectedPresetIndex = _findMatchingPresetIndexForSnapshot(
|
|
_lastNonRepeatSnapshot!,
|
|
);
|
|
if (previousPresetIndex != _selectedPresetIndex ||
|
|
previousLastNonRepeat != _lastNonRepeatSnapshot) {
|
|
_logRadioSettingsState(
|
|
'Preset sync updated state from ${_presetLabel(previousPresetIndex)} to ${_presetLabel(_selectedPresetIndex)}',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _handleManualSettingsChanged(String source) {
|
|
_logRadioSettingsState('Manual settings edit: $source');
|
|
setState(_syncPresetSelection);
|
|
}
|
|
|
|
void _handleClientRepeatChanged(bool enabled) {
|
|
_logRadioSettingsState(
|
|
'Off-grid repeat toggle requested: $_clientRepeat -> $enabled',
|
|
);
|
|
setState(() {
|
|
final currentSnapshot = _currentSnapshot();
|
|
if (enabled) {
|
|
if (!_clientRepeat) {
|
|
_syncPresetSelection();
|
|
}
|
|
final baseSnapshot = _lastNonRepeatSnapshot ?? currentSnapshot;
|
|
_clientRepeat = true;
|
|
_frequencyController.text = _offGridFrequencyForBaseFrequency(
|
|
baseSnapshot.frequencyMHz,
|
|
).toStringAsFixed(3);
|
|
return;
|
|
}
|
|
|
|
_clientRepeat = false;
|
|
_applySnapshot(
|
|
_lastNonRepeatSnapshot ??
|
|
_fallbackNonRepeatSnapshot(currentSnapshot.frequencyMHz),
|
|
);
|
|
_syncPresetSelection();
|
|
});
|
|
_logRadioSettingsState('Off-grid repeat toggle applied');
|
|
}
|
|
|
|
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) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_frequencyInvalid),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final maxTxPower = widget.connector.maxTxPower ?? 22;
|
|
if (txPower == null || txPower < 0 || txPower > maxTxPower) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
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,
|
|
);
|
|
|
|
// if the client repeat isnt null then we know its supported
|
|
//otherwise we leave it out of the frame to avoid accidentally enabling
|
|
final knownRepeat = widget.connector.clientRepeat != null;
|
|
|
|
if (knownRepeat) {
|
|
const validRepeatFreqsKHz = {433000, 869000, 918000};
|
|
if (_clientRepeat && !validRepeatFreqsKHz.contains(freqHz)) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_clientRepeatFreqWarning),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
_logRadioSettingsState('Saving radio settings');
|
|
await widget.connector.sendFrame(
|
|
buildSetRadioParamsFrame(
|
|
freqHz,
|
|
bwHz,
|
|
sf,
|
|
cr,
|
|
clientRepeat: knownRepeat ? _clientRepeat : null,
|
|
),
|
|
);
|
|
await widget.connector.sendFrame(buildSetRadioTxPowerFrame(txPower));
|
|
await widget.connector.refreshDeviceInfo();
|
|
final rememberedSnapshot = _clientRepeat
|
|
? _lastNonRepeatSnapshot
|
|
: _currentSnapshot();
|
|
if (rememberedSnapshot != null) {
|
|
widget.connector.rememberNonRepeatRadioState(
|
|
rememberedSnapshot.toMeshCoreSnapshot(widget.connector.currentCr),
|
|
);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
_logRadioSettingsState('Radio settings saved successfully');
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_radioSettingsUpdated),
|
|
);
|
|
} catch (e) {
|
|
_appLog.warn('Radio settings save failed: $e', tag: 'RadioSettings');
|
|
if (!mounted) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.settings_error(e.toString())),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _presetLabel(int? index) {
|
|
if (index == null) {
|
|
return 'custom';
|
|
}
|
|
return '${RadioSettings.presets[index].$1} (#$index)';
|
|
}
|
|
|
|
String _formatSnapshot(_RadioSettingsSnapshot? snapshot) {
|
|
if (snapshot == null) {
|
|
return 'null';
|
|
}
|
|
return '${snapshot.frequencyMHz.toStringAsFixed(3)}MHz/'
|
|
'${snapshot.bandwidth.label}/'
|
|
'${snapshot.spreadingFactor.label}/'
|
|
'${snapshot.codingRate.label}/'
|
|
'${snapshot.txPowerDbm}dBm';
|
|
}
|
|
|
|
void _logRadioSettingsState(String message) {
|
|
if (!kDebugMode) return;
|
|
_appLog.info(
|
|
'$message | '
|
|
'freq=${_frequencyController.text}MHz '
|
|
'bw=${_bandwidth.label} '
|
|
'sf=${_spreadingFactor.label} '
|
|
'cr=${_codingRate.label} '
|
|
'tx=${_txPowerController.text}dBm '
|
|
'repeat=$_clientRepeat '
|
|
'preset=${_presetLabel(_selectedPresetIndex)} '
|
|
'lastNonRepeat=${_formatSnapshot(_lastNonRepeatSnapshot)}',
|
|
tag: 'RadioSettings',
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DropdownButtonFormField<int>(
|
|
key: ValueKey<int?>(_selectedPresetIndex),
|
|
initialValue: _selectedPresetIndex,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.settings_presets,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: [
|
|
for (final i in _visiblePresetIndexes())
|
|
DropdownMenuItem(
|
|
value: i,
|
|
child: Text(RadioSettings.presets[i].$1),
|
|
),
|
|
],
|
|
onChanged: (index) {
|
|
if (index != null) {
|
|
_applyPreset(index);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _frequencyController,
|
|
onChanged: (_) => _handleManualSettingsChanged('frequency'),
|
|
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;
|
|
_syncPresetSelection();
|
|
});
|
|
_logRadioSettingsState('Manual settings edit: bandwidth');
|
|
}
|
|
},
|
|
),
|
|
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;
|
|
_syncPresetSelection();
|
|
});
|
|
_logRadioSettingsState('Manual settings edit: spreading factor');
|
|
}
|
|
},
|
|
),
|
|
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;
|
|
_syncPresetSelection();
|
|
});
|
|
_logRadioSettingsState('Manual settings edit: coding rate');
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _txPowerController,
|
|
onChanged: (_) => _handleManualSettingsChanged('tx power'),
|
|
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,
|
|
),
|
|
if (widget.connector.clientRepeat != null) ...[
|
|
const SizedBox(height: 16),
|
|
SwitchListTile(
|
|
title: Text(l10n.settings_clientRepeat),
|
|
subtitle: Text(l10n.settings_clientRepeatSubtitle),
|
|
value: _clientRepeat,
|
|
onChanged: _handleClientRepeatChanged,
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
onPressed: _saveSettings,
|
|
icon: const Icon(Icons.save_outlined),
|
|
label: Text(l10n.common_save),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RadioSettingsSnapshot {
|
|
final double frequencyMHz;
|
|
final LoRaBandwidth bandwidth;
|
|
final LoRaSpreadingFactor spreadingFactor;
|
|
final LoRaCodingRate codingRate;
|
|
final int txPowerDbm;
|
|
|
|
const _RadioSettingsSnapshot({
|
|
required this.frequencyMHz,
|
|
required this.bandwidth,
|
|
required this.spreadingFactor,
|
|
required this.codingRate,
|
|
required this.txPowerDbm,
|
|
});
|
|
|
|
/// Frequency in integer Hz — avoids floating-point comparison issues.
|
|
int get frequencyHz => (frequencyMHz * 1000).round();
|
|
|
|
/// Convert from the connector's raw-int snapshot to UI-enum snapshot.
|
|
static _RadioSettingsSnapshot? fromMeshCoreSnapshot(
|
|
MeshCoreRadioStateSnapshot snapshot,
|
|
) {
|
|
final bw = LoRaBandwidth.values
|
|
.where((b) => b.hz == snapshot.bwHz)
|
|
.firstOrNull;
|
|
final sf = LoRaSpreadingFactor.values
|
|
.where((s) => s.value == snapshot.sf)
|
|
.firstOrNull;
|
|
final cr = LoRaCodingRate.values
|
|
.where((c) => c.value == _toUiCodingRate(snapshot.cr))
|
|
.firstOrNull;
|
|
if (bw == null || sf == null || cr == null) return null;
|
|
return _RadioSettingsSnapshot(
|
|
frequencyMHz: snapshot.freqHz / 1000.0,
|
|
bandwidth: bw,
|
|
spreadingFactor: sf,
|
|
codingRate: cr,
|
|
txPowerDbm: snapshot.txPowerDbm,
|
|
);
|
|
}
|
|
|
|
/// Convert back to the connector's raw-int snapshot.
|
|
MeshCoreRadioStateSnapshot toMeshCoreSnapshot(int? deviceCr) {
|
|
return MeshCoreRadioStateSnapshot(
|
|
freqHz: frequencyHz,
|
|
bwHz: bandwidth.hz,
|
|
sf: spreadingFactor.value,
|
|
cr: _toDeviceCodingRate(codingRate.value, deviceCr),
|
|
txPowerDbm: txPowerDbm,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return other is _RadioSettingsSnapshot &&
|
|
frequencyHz == other.frequencyHz &&
|
|
bandwidth == other.bandwidth &&
|
|
spreadingFactor == other.spreadingFactor &&
|
|
codingRate == other.codingRate &&
|
|
txPowerDbm == other.txPowerDbm;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(
|
|
frequencyHz,
|
|
bandwidth,
|
|
spreadingFactor,
|
|
codingRate,
|
|
txPowerDbm,
|
|
);
|
|
}
|