mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-19 00:45:33 +10:00
8ba4bbfbc5
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.
1504 lines
48 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|