mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
The second critical bug was in the validation logic that checks whether responses from the device contain valid data. The validator was rejecting any interval values of zero because it checked interval > 0, but zero is now a meaningful and valid value that indicates advertisements are disabled. Without this fix, any time a device reported back that advertisements were disabled, the app would silently discard that information as invalid, leaving the UI out of sync with reality. We changed the validation to use interval >= 0 instead and updated the comment to explicitly document that zero means disabled. The third fix was a minor code style issue where a single-line if statement was missing braces, causing a linter warning. This doesn't affect functionality but ensures the code meets project standards.
1464 lines
47 KiB
Dart
1464 lines
47 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 '../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> {
|
|
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;
|
|
|
|
// 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);
|
|
}
|
|
|
|
Contact _resolveRepeater(MeshCoreConnector connector) {
|
|
return connector.contacts.firstWhere(
|
|
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
|
orElse: () => widget.repeater,
|
|
);
|
|
}
|
|
|
|
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() ?? '';
|
|
}
|
|
});
|
|
}
|
|
|
|
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,
|
|
),
|
|
// 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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|