Files
meshcore-open/lib/screens/repeater_settings_screen.dart
T
just-stuff-tm 8ba4bbfbc5 add auto clock synchronization setting after repeater login
Introduced a new setting for automatic clock synchronization after a successful repeater login.
Added localization support for the new feature in multiple languages (Bulgarian, German, English, Spanish, French, Hungarian, Italian, Japanese, Korean, Dutch, Polish, Portuguese, Russian, Slovak, Slovenian, Swedish, Ukrainian, Chinese).
Implemented storage service methods to manage the new setting.
Updated the repeater settings screen to include a toggle for the new feature.
Enhanced the repeater login dialog to trigger clock synchronization automatically if the setting is enabled.
2026-04-10 14:25:53 -04:00

1504 lines
48 KiB
Dart

import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../services/app_debug_log_service.dart';
import '../services/repeater_command_service.dart';
import '../services/storage_service.dart';
import '../widgets/path_management_dialog.dart';
class RepeaterSettingsScreen extends StatefulWidget {
final Contact repeater;
final String password;
const RepeaterSettingsScreen({
super.key,
required this.repeater,
required this.password,
});
@override
State<RepeaterSettingsScreen> createState() => _RepeaterSettingsScreenState();
}
class _RepeaterSettingsScreenState extends State<RepeaterSettingsScreen> {
final StorageService _storage = StorageService();
bool _isLoading = false;
bool _hasChanges = false;
bool _refreshingBasic = false;
bool _refreshingRadio = false;
bool _refreshingTxPower = false;
bool _refreshingLocation = false;
bool _refreshingRepeat = false;
bool _refreshingAllowReadOnly = false;
bool _refreshingAdvertisement = false;
StreamSubscription<Uint8List>? _frameSubscription;
RepeaterCommandService? _commandService;
final Map<String, String> _fetchedSettings = {};
// Basic settings
final TextEditingController _nameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _guestPasswordController =
TextEditingController();
// Radio settings
final TextEditingController _freqController = TextEditingController();
final TextEditingController _txPowerController = TextEditingController();
int? _bandwidth;
int? _spreadingFactor;
int? _codingRate;
// Location settings
final TextEditingController _latController = TextEditingController();
final TextEditingController _lonController = TextEditingController();
// Feature toggles
bool _repeatEnabled = true;
bool _allowReadOnly = true;
bool _privacyMode = false;
bool _autoClockSyncAfterLogin = false;
// Advertisement settings
bool _advertEnable = true;
int _advertInterval = 120; // minutes/2
bool _floodAdvertEnable = true;
int _floodAdvertInterval = 12; // hours
int _privAdvertInterval = 60; // minutes
final List<int> _bandwidthOptions = [
7800,
10400,
15600,
20800,
31250,
41700,
62500,
125000,
250000,
500000,
];
final List<int> _spreadingFactorOptions = [5, 6, 7, 8, 9, 10, 11, 12];
final List<int> _codingRateOptions = [5, 6, 7, 8];
@override
void initState() {
super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_commandService = RepeaterCommandService(connector);
_setupMessageListener();
_loadSettings();
}
@override
void dispose() {
_frameSubscription?.cancel();
_commandService?.dispose();
_nameController.dispose();
_passwordController.dispose();
_guestPasswordController.dispose();
_freqController.dispose();
_txPowerController.dispose();
_latController.dispose();
_lonController.dispose();
super.dispose();
}
void _setupMessageListener() {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Listen for incoming text messages from the repeater
_frameSubscription = connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
// Check if it's a text message response
if (frame[0] == respCodeContactMsgRecv ||
frame[0] == respCodeContactMsgRecvV3) {
_handleTextMessageResponse(frame);
}
});
}
void _handleTextMessageResponse(Uint8List frame) {
final parsed = parseContactMessageText(frame);
if (parsed == null) return;
if (!_matchesRepeaterPrefix(parsed.senderPrefix)) return;
// Notify command service of response (for retry handling)
_commandService?.handleResponse(widget.repeater, parsed.text);
}
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
bool _matchesRepeaterPrefix(Uint8List prefix) {
final target = widget.repeater.publicKey;
if (target.length < 6 || prefix.length < 6) return false;
for (int i = 0; i < 6; i++) {
if (prefix[i] != target[i]) return false;
}
return true;
}
void _updateUIFromFetchedSettings() {
if (_fetchedSettings.isEmpty) return;
final appLog = Provider.of<AppDebugLogService>(context, listen: false);
appLog.info(
'Updating UI with keys: ${_fetchedSettings.keys.toList()}',
tag: 'RadioSettings',
);
setState(() {
// Update name
if (_fetchedSettings.containsKey('name')) {
_nameController.text = _fetchedSettings['name']!;
}
// Update radio settings - parse "908.205017,62.5,10,7" format
// Format: freq_mhz,bandwidth_khz,spreading_factor,coding_rate
if (_fetchedSettings.containsKey('radio')) {
final appLog = Provider.of<AppDebugLogService>(context, listen: false);
final radioStr = _fetchedSettings['radio']!;
appLog.info('Raw radio string: "$radioStr"', tag: 'RadioSettings');
final parts = radioStr.split(',');
appLog.info(
'Split into ${parts.length} parts: $parts',
tag: 'RadioSettings',
);
if (parts.isNotEmpty) {
final freqText = parts[0].replaceAll(RegExp(r'[^0-9.]'), '').trim();
appLog.info('Frequency text: "$freqText"', tag: 'RadioSettings');
if (freqText.isNotEmpty) {
_freqController.text = freqText;
}
}
if (parts.length > 1) {
final bwText = parts[1].replaceAll(RegExp(r'[^0-9.]'), '').trim();
appLog.info('Bandwidth text: "$bwText"', tag: 'RadioSettings');
final bw = double.tryParse(bwText);
if (bw != null) {
_bandwidth = (bw * 1000).toInt();
appLog.info('Bandwidth Hz: $_bandwidth', tag: 'RadioSettings');
if (_bandwidth != null && !_bandwidthOptions.contains(_bandwidth)) {
_bandwidthOptions.add(_bandwidth!);
_bandwidthOptions.sort();
}
}
}
if (parts.length > 2) {
final sfText = parts[2].replaceAll(RegExp(r'[^0-9]'), '').trim();
appLog.info('SF text: "$sfText"', tag: 'RadioSettings');
_spreadingFactor = int.tryParse(sfText) ?? _spreadingFactor;
}
if (parts.length > 3) {
final crText = parts[3].replaceAll(RegExp(r'[^0-9]'), '').trim();
appLog.info('CR text: "$crText"', tag: 'RadioSettings');
_codingRate = int.tryParse(crText) ?? _codingRate;
}
appLog.info(
'Final values: freq=${_freqController.text}, bw=$_bandwidth, sf=$_spreadingFactor, cr=$_codingRate',
tag: 'RadioSettings',
);
}
if (_fetchedSettings.containsKey('tx')) {
final txValue = _fetchedSettings['tx']!;
// Extract just the power value - format is typically "10" or "10 dBm"
final powerStr = txValue.replaceAll(RegExp(r'[^0-9-]'), '');
final powerInt = int.tryParse(powerStr);
if (powerInt != null && powerInt >= 1 && powerInt <= 30) {
_txPowerController.text = powerInt.toString();
}
}
if (_fetchedSettings.containsKey('lat')) {
appLog.info(
'Setting lat to: "${_fetchedSettings['lat']}"',
tag: 'RadioSettings',
);
_latController.text = _fetchedSettings['lat']!;
}
if (_fetchedSettings.containsKey('lon')) {
appLog.info(
'Setting lon to: "${_fetchedSettings['lon']}"',
tag: 'RadioSettings',
);
_lonController.text = _fetchedSettings['lon']!;
}
if (_fetchedSettings.containsKey('repeat')) {
_repeatEnabled = _normalizeOnOff(_fetchedSettings['repeat']!);
}
if (_fetchedSettings.containsKey('allow.read.only')) {
_allowReadOnly = _normalizeOnOff(_fetchedSettings['allow.read.only']!);
}
if (_fetchedSettings.containsKey('privacy')) {
_privacyMode = _normalizeOnOff(_fetchedSettings['privacy']!);
}
if (_fetchedSettings.containsKey('advert.interval')) {
_advertInterval = _parseIntWithFallback(
_fetchedSettings['advert.interval']!,
_advertInterval,
);
_advertEnable = _advertInterval > 0;
}
if (_fetchedSettings.containsKey('flood.advert.interval')) {
_floodAdvertInterval = _parseIntWithFallback(
_fetchedSettings['flood.advert.interval']!,
_floodAdvertInterval,
);
_floodAdvertEnable = _floodAdvertInterval > 0;
}
if (_fetchedSettings.containsKey('priv.advert.interval')) {
_privAdvertInterval = _parseIntWithFallback(
_fetchedSettings['priv.advert.interval']!,
_privAdvertInterval,
);
}
});
}
bool _normalizeOnOff(String value) {
final normalized = value.trim().toLowerCase();
return normalized == 'on' ||
normalized == 'true' ||
normalized == '1' ||
normalized == 'enabled';
}
int _parseIntWithFallback(String value, int fallback) {
final parsed = int.tryParse(value.replaceAll(RegExp(r'[^0-9-]'), ''));
return parsed ?? fallback;
}
String _formatBandwidthLabel(int bandwidthHz) {
final bandwidthKHz = bandwidthHz / 1000;
var text = bandwidthKHz.toStringAsFixed(2);
text = text.replaceAll(RegExp(r'0+$'), '').replaceAll(RegExp(r'\.$'), '');
return '$text kHz';
}
void _applySettingResponse(String command, String response) {
final appLog = Provider.of<AppDebugLogService>(context, listen: false);
appLog.info(
'Command: "$command", Raw response: "$response"',
tag: 'RadioSettings',
);
final value = _extractCliValue(response);
appLog.info('Extracted value: "$value"', tag: 'RadioSettings');
if (value == null) return;
final normalized = command.trim().toLowerCase();
if (!normalized.startsWith('get ')) return;
final key = normalized.substring(4);
// Validate response content matches expected format for the command
// This prevents mismatched responses over LoRa where order isn't guaranteed
if (!_validateResponseForCommand(key, value)) {
appLog.warn(
'Response "$value" does not match expected format for "$key", ignoring',
tag: 'RadioSettings',
);
return;
}
switch (key) {
case 'name':
case 'radio':
case 'tx':
case 'lat':
case 'lon':
case 'repeat':
case 'allow.read.only':
case 'privacy':
case 'advert.interval':
case 'flood.advert.interval':
case 'priv.advert.interval':
appLog.info('Storing key="$key" value="$value"', tag: 'RadioSettings');
_fetchedSettings[key] = value;
break;
}
}
/// Validates that a response value matches the expected format for a given command.
/// Returns true if the response appears valid for the command type.
bool _validateResponseForCommand(String key, String value) {
switch (key) {
case 'radio':
// Radio format: "freq,bw,sf,cr" e.g., "908.205017,62.5,10,7"
// Must have at least 3 commas and start with a frequency-like number
final parts = value.split(',');
if (parts.length < 4) return false;
final freq = double.tryParse(
parts[0].replaceAll(RegExp(r'[^0-9.]'), ''),
);
// Frequency should be in reasonable LoRa range (300-2500 MHz)
return freq != null && freq >= 300 && freq <= 2500;
case 'tx':
// TX power: single integer 1-30
final power = int.tryParse(value.replaceAll(RegExp(r'[^0-9-]'), ''));
// Must NOT contain commas (distinguishes from radio format)
if (value.contains(',')) return false;
return power != null && power >= 1 && power <= 30;
case 'lat':
// Latitude: decimal number between -90 and 90
if (value.contains(',')) return false; // Not radio format
final lat = double.tryParse(value.replaceAll(RegExp(r'[^0-9.\-]'), ''));
return lat != null && lat >= -90 && lat <= 90;
case 'lon':
// Longitude: decimal number between -180 and 180
if (value.contains(',')) return false; // Not radio format
final lon = double.tryParse(value.replaceAll(RegExp(r'[^0-9.\-]'), ''));
return lon != null && lon >= -180 && lon <= 180;
case 'repeat':
case 'allow.read.only':
case 'privacy':
// Boolean values: on/off/true/false/1/0/enabled/disabled
final lower = value.toLowerCase().trim();
return [
'on',
'off',
'true',
'false',
'1',
'0',
'enabled',
'disabled',
].contains(lower);
case 'advert.interval':
case 'flood.advert.interval':
case 'priv.advert.interval':
// Interval: non-negative integer (0 means disabled)
if (value.contains(',')) return false;
final interval = int.tryParse(value.replaceAll(RegExp(r'[^0-9]'), ''));
return interval != null && interval >= 0;
case 'name':
// Name: any non-empty string, but should NOT look like radio settings
if (value.isEmpty) return false;
// If it has 3+ commas and looks like numbers, probably radio data
final commaCount = ','.allMatches(value).length;
if (commaCount >= 3 && RegExp(r'^[\d.,\s]+$').hasMatch(value)) {
return false;
}
return true;
default:
// Unknown keys - accept any value
return true;
}
}
String? _extractCliValue(String response) {
final lines = response.split('\n');
for (final line in lines) {
final trimmed = line.trim();
if (trimmed.isEmpty) continue;
if (trimmed.startsWith('>')) {
final value = trimmed.substring(1).trim();
if (value.isNotEmpty) return value;
}
final colonIndex = trimmed.indexOf(':');
if (colonIndex > 0) {
final value = trimmed.substring(colonIndex + 1).trim();
if (value.isNotEmpty) return value;
}
}
return null;
}
Future<void> _refreshSection({
required String label,
required List<String> commands,
required ValueSetter<bool> setRefreshing,
}) async {
if (_commandService == null) return;
final l10n = context.l10n;
setState(() {
setRefreshing(true);
_fetchedSettings.clear();
});
var successCount = 0;
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
for (final command in commands) {
try {
final response = await _commandService!.sendCommand(
repeater,
command,
retries: 1,
);
_applySettingResponse(command, response);
successCount += 1;
await Future.delayed(const Duration(milliseconds: 200));
} catch (e) {
debugPrint('Error fetching $command: $e');
}
}
if (mounted) {
if (successCount > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.repeater_refreshed(label)),
backgroundColor: Colors.green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.repeater_errorRefreshing(label)),
backgroundColor: Colors.red,
),
);
}
if (_fetchedSettings.isNotEmpty) {
_updateUIFromFetchedSettings();
}
setState(() {
setRefreshing(false);
});
}
}
Future<void> _refreshBasicSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: l10n.repeater_basicSettings,
commands: const ['get name'],
setRefreshing: (value) => _refreshingBasic = value,
);
}
Future<void> _refreshRadioSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: l10n.repeater_radioSettings,
commands: const ['get radio'],
setRefreshing: (value) => _refreshingRadio = value,
);
}
Future<void> _refreshTxPower() async {
final l10n = context.l10n;
await _refreshSection(
label: l10n.repeater_txPower,
commands: const ['get tx'],
setRefreshing: (value) => _refreshingTxPower = value,
);
}
Future<void> _refreshLocationSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: l10n.repeater_locationSettings,
commands: const ['get lat', 'get lon'],
setRefreshing: (value) => _refreshingLocation = value,
);
}
Future<void> _refreshRepeat() async {
final l10n = context.l10n;
await _refreshSection(
label: l10n.repeater_packetForwarding,
commands: const ['get repeat'],
setRefreshing: (value) => _refreshingRepeat = value,
);
}
Future<void> _refreshAllowReadOnly() async {
final l10n = context.l10n;
await _refreshSection(
label: l10n.repeater_guestAccess,
commands: const ['get allow.read.only'],
setRefreshing: (value) => _refreshingAllowReadOnly = value,
);
}
Future<void> _refreshAdvertisementSettings() async {
final l10n = context.l10n;
await _refreshSection(
label: l10n.repeater_advertisementSettings,
commands: const [
'get advert.interval',
'get flood.advert.interval',
// 'get priv.advert.interval', // Hidden until privacy mode is implemented
],
setRefreshing: (value) => _refreshingAdvertisement = value,
);
}
Future<void> _loadSettings() async {
// Just populate with current repeater data on initial load
// User must click sync button to fetch from device
setState(() {
_nameController.text = widget.repeater.name;
if (widget.repeater.hasLocation) {
_latController.text = widget.repeater.latitude?.toString() ?? '';
_lonController.text = widget.repeater.longitude?.toString() ?? '';
}
});
final autoClockSync = await _storage
.getRepeaterAutoClockSyncAfterLoginEnabled(
widget.repeater.publicKeyHex,
);
if (!mounted) return;
setState(() {
_autoClockSyncAfterLogin = autoClockSync;
});
}
Future<void> _saveSettings() async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
setState(() {
_isLoading = true;
});
try {
final selection = await connector.preparePathForContactSend(repeater);
final commands = <String>[];
// Build set commands for each setting
if (_nameController.text.isNotEmpty) {
commands.add('set name ${_nameController.text}');
}
if (_passwordController.text.isNotEmpty) {
commands.add('password ${_passwordController.text}');
}
if (_guestPasswordController.text.isNotEmpty) {
commands.add('set guest.password ${_guestPasswordController.text}');
}
// Radio parameters
if (_freqController.text.isNotEmpty &&
_bandwidth != null &&
_spreadingFactor != null &&
_codingRate != null) {
final freqMHz = double.tryParse(_freqController.text);
if (freqMHz != null) {
final bwKHz = _bandwidth! / 1000;
commands.add(
'set radio ${freqMHz.toStringAsFixed(1)} $bwKHz $_spreadingFactor $_codingRate',
);
}
}
// Location
if (_latController.text.isNotEmpty) {
commands.add('set lat ${_latController.text}');
}
if (_lonController.text.isNotEmpty) {
commands.add('set lon ${_lonController.text}');
}
// Feature toggles
commands.add('set repeat ${_repeatEnabled ? "on" : "off"}');
commands.add('set allow.read.only ${_allowReadOnly ? "on" : "off"}');
commands.add('set privacy ${_privacyMode ? "on" : "off"}');
// Advertisement intervals
commands.add('set advert.interval $_advertInterval');
commands.add('set flood.advert.interval $_floodAdvertInterval');
if (_privacyMode) {
commands.add('set priv.advert.interval $_privAdvertInterval');
}
// Send all commands
for (final command in commands) {
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
connector.trackRepeaterAck(
contact: repeater,
selection: selection,
text: command,
timestampSeconds: timestampSeconds,
);
final frame = buildSendCliCommandFrame(
repeater.publicKey,
command,
timestampSeconds: timestampSeconds,
);
await connector.sendFrame(frame);
await Future.delayed(
const Duration(milliseconds: 200),
); // Delay between commands
}
setState(() {
_isLoading = false;
_hasChanges = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.repeater_settingsSaved),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
setState(() {
_isLoading = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.repeater_errorSavingSettings(e.toString()),
),
backgroundColor: Colors.red,
),
);
}
}
}
void _markChanged() {
if (!_hasChanges) {
setState(() {
_hasChanges = true;
});
}
}
Widget _buildSectionHeader({
required IconData icon,
required String title,
required String tooltip,
required bool isRefreshing,
required VoidCallback onRefresh,
}) {
return Row(
children: [
Icon(icon, color: Theme.of(context).textTheme.headlineSmall?.color),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const Spacer(),
IconButton(
icon: isRefreshing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: isRefreshing ? null : onRefresh,
tooltip: tooltip,
),
],
);
}
Widget _buildInlineRefreshButton({
required bool isRefreshing,
required VoidCallback onRefresh,
required String tooltip,
}) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: IconButton(
icon: isRefreshing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 20),
onPressed: isRefreshing ? null : onRefresh,
tooltip: tooltip,
visualDensity: VisualDensity.compact,
),
);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final repeater = _resolveRepeater(connector);
final isFloodMode = repeater.pathOverride == -1;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.repeater_settingsTitle),
Text(
repeater.name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
],
),
centerTitle: false,
actions: [
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.repeater_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
if (mounted) {
setState(() {});
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
if (_hasChanges)
TextButton.icon(
onPressed: _isLoading ? null : _saveSettings,
icon: const Icon(Icons.save),
label: Text(l10n.common_save),
),
],
),
body: SafeArea(
top: false,
child: _isLoading && _nameController.text.isEmpty
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildBasicSettingsCard(),
const SizedBox(height: 16),
_buildRadioSettingsCard(),
const SizedBox(height: 16),
_buildLocationSettingsCard(),
const SizedBox(height: 16),
_buildFeatureTogglesCard(),
const SizedBox(height: 16),
_buildAdvertisementSettingsCard(),
const SizedBox(height: 32),
_buildDangerZoneCard(),
],
),
),
);
}
Widget _buildBasicSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(
icon: Icons.settings,
title: l10n.repeater_basicSettings,
tooltip: l10n.repeater_refreshBasicSettings,
isRefreshing: _refreshingBasic,
onRefresh: _refreshBasicSettings,
),
const Divider(),
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.repeater_repeaterName,
helperText: l10n.repeater_repeaterNameHelper,
border: const OutlineInputBorder(),
),
onChanged: (_) => _markChanged(),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: InputDecoration(
labelText: l10n.repeater_adminPassword,
helperText: l10n.repeater_adminPasswordHelper,
border: const OutlineInputBorder(),
),
obscureText: true,
onChanged: (_) => _markChanged(),
),
const SizedBox(height: 16),
TextField(
controller: _guestPasswordController,
decoration: InputDecoration(
labelText: l10n.repeater_guestPassword,
helperText: l10n.repeater_guestPasswordHelper,
border: const OutlineInputBorder(),
),
obscureText: true,
onChanged: (_) => _markChanged(),
),
],
),
),
);
}
Widget _buildRadioSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(
icon: Icons.radio,
title: l10n.repeater_radioSettings,
tooltip: l10n.repeater_refreshRadioSettings,
isRefreshing: _refreshingRadio,
onRefresh: _refreshRadioSettings,
),
const Divider(),
TextField(
controller: _freqController,
decoration: InputDecoration(
labelText: l10n.repeater_frequencyMhz,
helperText: l10n.repeater_frequencyHelper,
border: const OutlineInputBorder(),
suffixText: 'MHz',
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
onChanged: (_) => _markChanged(),
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: _txPowerController,
decoration: InputDecoration(
labelText: l10n.repeater_txPower,
helperText: l10n.repeater_txPowerHelper,
border: const OutlineInputBorder(),
suffixText: 'dBm',
),
keyboardType: TextInputType.number,
onChanged: (_) => _markChanged(),
),
),
const SizedBox(width: 8),
_buildInlineRefreshButton(
isRefreshing: _refreshingTxPower,
onRefresh: _refreshTxPower,
tooltip: l10n.repeater_refreshTxPower,
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: _bandwidth,
decoration: InputDecoration(
labelText: l10n.repeater_bandwidth,
border: const OutlineInputBorder(),
),
items: _bandwidthOptions.map((bw) {
return DropdownMenuItem(
value: bw,
child: Text(_formatBandwidthLabel(bw)),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_bandwidth = value;
});
_markChanged();
}
},
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: _spreadingFactor,
decoration: InputDecoration(
labelText: l10n.repeater_spreadingFactor,
border: const OutlineInputBorder(),
),
items: _spreadingFactorOptions.map((sf) {
return DropdownMenuItem(value: sf, child: Text('SF$sf'));
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_spreadingFactor = value;
});
_markChanged();
}
},
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
initialValue: _codingRate,
decoration: InputDecoration(
labelText: l10n.repeater_codingRate,
border: const OutlineInputBorder(),
),
items: _codingRateOptions.map((cr) {
return DropdownMenuItem(value: cr, child: Text('4/$cr'));
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_codingRate = value;
});
_markChanged();
}
},
),
],
),
),
);
}
Widget _buildLocationSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(
icon: Icons.location_on,
title: l10n.repeater_locationSettings,
tooltip: l10n.repeater_refreshLocationSettings,
isRefreshing: _refreshingLocation,
onRefresh: _refreshLocationSettings,
),
const Divider(),
TextField(
controller: _latController,
decoration: InputDecoration(
labelText: l10n.repeater_latitude,
helperText: l10n.repeater_latitudeHelper,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
onChanged: (_) => _markChanged(),
),
const SizedBox(height: 16),
TextField(
controller: _lonController,
decoration: InputDecoration(
labelText: l10n.repeater_longitude,
helperText: l10n.repeater_longitudeHelper,
border: const OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
onChanged: (_) => _markChanged(),
),
],
),
),
);
}
Widget _buildFeatureTogglesCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.toggle_on,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8),
Text(
l10n.repeater_features,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(),
_buildFeatureToggleRow(
title: l10n.repeater_packetForwarding,
subtitle: l10n.repeater_packetForwardingSubtitle,
value: _repeatEnabled,
isRefreshing: _refreshingRepeat,
onChanged: (value) {
setState(() {
_repeatEnabled = value;
});
_markChanged();
},
onRefresh: _refreshRepeat,
refreshTooltip: l10n.repeater_refreshPacketForwarding,
),
_buildFeatureToggleRow(
title: l10n.repeater_guestAccess,
subtitle: l10n.repeater_guestAccessSubtitle,
value: _allowReadOnly,
isRefreshing: _refreshingAllowReadOnly,
onChanged: (value) {
setState(() {
_allowReadOnly = value;
});
_markChanged();
},
onRefresh: _refreshAllowReadOnly,
refreshTooltip: l10n.repeater_refreshGuestAccess,
),
SwitchListTile(
title: Text(l10n.repeater_clockSyncAfterLogin),
subtitle: Text(l10n.repeater_clockSyncAfterLoginSubtitle),
value: _autoClockSyncAfterLogin,
onChanged: (value) async {
setState(() {
_autoClockSyncAfterLogin = value;
});
await _storage.setRepeaterAutoClockSyncAfterLoginEnabled(
widget.repeater.publicKeyHex,
value,
);
},
contentPadding: EdgeInsets.zero,
),
// Privacy mode - hidden until fully implemented
// _buildFeatureToggleRow(
// title: l10n.repeater_privacyMode,
// subtitle: l10n.repeater_privacyModeSubtitle,
// value: _privacyMode,
// isRefreshing: _refreshingPrivacy,
// onChanged: (value) {
// setState(() {
// _privacyMode = value;
// });
// _markChanged();
// },
// onRefresh: _refreshPrivacy,
// refreshTooltip: l10n.repeater_refreshPrivacyMode,
// ),
],
),
),
);
}
Widget _buildFeatureToggleRow({
required String title,
required String subtitle,
required bool value,
required bool isRefreshing,
required ValueChanged<bool> onChanged,
required VoidCallback onRefresh,
required String refreshTooltip,
}) {
return Row(
children: [
Expanded(
child: SwitchListTile(
title: Text(title),
subtitle: Text(subtitle),
value: value,
onChanged: onChanged,
contentPadding: EdgeInsets.zero,
),
),
IconButton(
icon: isRefreshing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 20),
onPressed: isRefreshing ? null : onRefresh,
tooltip: refreshTooltip,
visualDensity: VisualDensity.compact,
),
],
);
}
Widget _buildAdvertisementSettingsCard() {
final l10n = context.l10n;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader(
icon: Icons.broadcast_on_personal,
title: l10n.repeater_advertisementSettings,
tooltip: l10n.repeater_refreshAdvertisementSettings,
isRefreshing: _refreshingAdvertisement,
onRefresh: _refreshAdvertisementSettings,
),
const Divider(),
ListTile(
title: Text(l10n.repeater_localAdvertInterval),
subtitle: Text(
l10n.repeater_localAdvertIntervalMinutes(_advertInterval),
),
trailing: Switch(
value: _advertEnable,
onChanged: (value) {
setState(() {
_advertInterval = value ? 60 : 0;
_advertEnable = value;
});
_markChanged();
},
),
),
Slider(
value: _advertInterval == 0
? 60.toDouble()
: _advertInterval.toDouble(),
min: 60,
max: 240,
divisions: 18,
label: l10n.repeater_localAdvertIntervalMinutes(_advertInterval),
onChanged: _advertEnable
? (value) {
setState(() {
_advertInterval = value.toInt();
});
_markChanged();
}
: null,
),
const SizedBox(height: 16),
ListTile(
title: Text(l10n.repeater_floodAdvertInterval),
subtitle: Text(
l10n.repeater_floodAdvertIntervalHours(_floodAdvertInterval),
),
trailing: Switch(
value: _floodAdvertEnable,
onChanged: (value) {
setState(() {
_floodAdvertInterval = value ? 3 : 0;
_floodAdvertEnable = value;
});
_markChanged();
},
),
),
Slider(
value: _floodAdvertInterval == 0
? 3.toDouble()
: _floodAdvertInterval.toDouble(),
min: 3,
max: 168,
divisions: 165,
label: l10n.repeater_floodAdvertIntervalHours(
_floodAdvertInterval,
),
onChanged: _floodAdvertEnable
? (value) {
setState(() {
_floodAdvertInterval = value.toInt();
});
_markChanged();
}
: null,
),
// Encrypted advertisement interval - hidden until privacy mode is implemented
// if (_privacyMode) ...[
// const SizedBox(height: 16),
// ListTile(
// title: Text(l10n.repeater_encryptedAdvertInterval),
// subtitle: Text(l10n.repeater_localAdvertIntervalMinutes(_privAdvertInterval)),
// trailing: Text(l10n.repeater_localAdvertIntervalMinutes(_privAdvertInterval)),
// ),
// Slider(
// value: _privAdvertInterval.toDouble(),
// min: 30,
// max: 240,
// divisions: 21,
// label: l10n.repeater_localAdvertIntervalMinutes(_privAdvertInterval),
// onChanged: (value) {
// setState(() {
// _privAdvertInterval = value.toInt();
// });
// _markChanged();
// },
// ),
// ],
],
),
),
);
}
Widget _buildDangerZoneCard() {
final l10n = context.l10n;
final colorScheme = Theme.of(context).colorScheme;
return Card(
color: colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning, color: colorScheme.onErrorContainer),
const SizedBox(width: 8),
Text(
l10n.repeater_dangerZone,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: colorScheme.onErrorContainer,
),
),
],
),
const Divider(),
ListTile(
leading: Icon(Icons.refresh, color: colorScheme.onErrorContainer),
title: Text(
l10n.repeater_rebootRepeater,
style: TextStyle(color: colorScheme.onErrorContainer),
),
subtitle: Text(
l10n.repeater_rebootRepeaterSubtitle,
style: TextStyle(
color: colorScheme.onErrorContainer.withValues(alpha: 0.8),
),
),
onTap: () => _confirmAction(
l10n.repeater_rebootRepeater,
l10n.repeater_rebootRepeaterConfirm,
() => _sendDangerCommand('reboot'),
),
),
// Regenerate identity key - hidden until fully implemented
// ListTile(
// leading: Icon(Icons.vpn_key, color: colorScheme.onErrorContainer),
// title: Text('Regenerate Identity Key', style: TextStyle(color: colorScheme.onErrorContainer)),
// subtitle: Text(
// 'Generate new public/private key pair',
// style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)),
// ),
// onTap: () => _confirmAction(
// 'Regenerate Identity',
// 'This will generate a new identity for the repeater. Continue?',
// () => _sendDangerCommand('regen key'),
// ),
// ),
ListTile(
leading: Icon(
Icons.delete_forever,
color: colorScheme.onErrorContainer,
),
title: Text(
l10n.repeater_eraseFileSystem,
style: TextStyle(color: colorScheme.onErrorContainer),
),
subtitle: Text(
l10n.repeater_eraseFileSystemSubtitle,
style: TextStyle(
color: colorScheme.onErrorContainer.withValues(alpha: 0.8),
),
),
onTap: () => _confirmAction(
l10n.repeater_eraseFileSystem,
l10n.repeater_eraseFileSystemConfirm,
() => _sendDangerCommand('erase'),
isDestructive: true,
),
),
],
),
),
);
}
Future<void> _sendDangerCommand(String command) async {
final l10n = context.l10n;
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final repeater = _resolveRepeater(connector);
if (command == 'erase') {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.repeater_eraseSerialOnly)));
}
return;
}
try {
final selection = await connector.preparePathForContactSend(repeater);
final timestampSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
connector.trackRepeaterAck(
contact: repeater,
selection: selection,
text: command,
timestampSeconds: timestampSeconds,
);
final frame = buildSendCliCommandFrame(
repeater.publicKey,
command,
timestampSeconds: timestampSeconds,
);
await connector.sendFrame(frame);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.repeater_commandSent(command))),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.repeater_errorSendingCommand(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
void _confirmAction(
String title,
String message,
VoidCallback onConfirm, {
bool isDestructive = false,
}) {
final l10n = context.l10n;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.common_cancel),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
onConfirm();
},
style: isDestructive
? FilledButton.styleFrom(backgroundColor: Colors.red)
: null,
child: Text(l10n.repeater_confirm),
),
],
),
);
}
}