meshcore-open/lib/screens/settings_screen.dart
zjs81 b2ce82fe7e Add localization support and translation script
- 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.
2026-01-11 17:13:50 -07:00

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,
);
}
}