mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Core Features Unread Message Tracking: Added persistent unread counts for contacts and channels with visual badges Message Deletion: Users can now long-press to delete individual messages in chats and channels SMAZ Compression: Added per-contact compression settings (previously only channels) UTF-8 Length Limiting: Text inputs now enforce protocol byte limits correctly Channel Message Paths: New screen to visualize packet routing through repeater network with map view Protocol Updates Added maxContactMessageBytes() and maxChannelMessageBytes() helpers for message length validation Changed channel PSK format from Base64 to Hexadecimal (breaking change) Added app version field to connection handshake frame UI Improvements Unread badges on all contact and channel list items Enhanced message bubbles with path visualization for channel messages Character count displays in message input fields Improved repeater CLI screen functionality New Files lib/storage/unread_store.dart - Unread tracking persistence lib/storage/contact_settings_store.dart - Per-contact SMAZ settings lib/widgets/unread_badge.dart - Unread count indicator lib/helpers/utf8_length_limiter.dart - Byte-aware text input formatter lib/screens/channel_message_path_screen.dart - Packet path visualization
1039 lines
32 KiB
Dart
1039 lines
32 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../models/contact.dart';
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../services/repeater_command_service.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 _refreshingLocation = false;
|
|
bool _refreshingFeatures = 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 = 125000;
|
|
int _spreadingFactor = 9;
|
|
int _codingRate = 7;
|
|
|
|
// Location settings
|
|
final TextEditingController _latController = TextEditingController();
|
|
final TextEditingController _lonController = TextEditingController();
|
|
|
|
// Feature toggles
|
|
bool _repeatEnabled = true;
|
|
bool _allowReadOnly = false;
|
|
bool _privacyMode = false;
|
|
|
|
// Advertisement settings
|
|
int _advertInterval = 120; // minutes/2
|
|
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);
|
|
}
|
|
|
|
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;
|
|
|
|
setState(() {
|
|
// Update name
|
|
if (_fetchedSettings.containsKey('name')) {
|
|
_nameController.text = _fetchedSettings['name']!;
|
|
}
|
|
|
|
// Update radio settings - parse "915.00,250.00,9,7" or unit-labeled variants
|
|
if (_fetchedSettings.containsKey('radio')) {
|
|
final radioStr = _fetchedSettings['radio']!;
|
|
final parts = radioStr.split(',');
|
|
final parsed = <String>[];
|
|
for (final part in parts) {
|
|
final trimmed = part.trim();
|
|
if (trimmed.isNotEmpty) {
|
|
parsed.add(trimmed);
|
|
}
|
|
}
|
|
if (parsed.isNotEmpty) {
|
|
final freqText = parsed.first
|
|
.replaceAll('MHz', '')
|
|
.replaceAll('mhz', '')
|
|
.trim();
|
|
if (freqText.isNotEmpty) {
|
|
_freqController.text = freqText;
|
|
}
|
|
}
|
|
if (parsed.length > 1) {
|
|
final bwText = parsed[1]
|
|
.replaceAll('kHz', '')
|
|
.replaceAll('khz', '')
|
|
.trim();
|
|
final bw = double.tryParse(bwText);
|
|
if (bw != null) {
|
|
_bandwidth = (bw * 1000).toInt();
|
|
if (!_bandwidthOptions.contains(_bandwidth)) {
|
|
_bandwidthOptions.add(_bandwidth);
|
|
_bandwidthOptions.sort();
|
|
}
|
|
}
|
|
}
|
|
if (parsed.length > 2) {
|
|
final sfText = parsed[2].replaceAll('SF', '').replaceAll('sf', '').trim();
|
|
_spreadingFactor = int.tryParse(sfText) ?? _spreadingFactor;
|
|
}
|
|
if (parsed.length > 3) {
|
|
final crText = parsed[3].replaceAll('CR', '').replaceAll('cr', '').trim();
|
|
_codingRate = int.tryParse(crText) ?? _codingRate;
|
|
}
|
|
}
|
|
|
|
if (_fetchedSettings.containsKey('tx')) {
|
|
_txPowerController.text = _fetchedSettings['tx']!;
|
|
}
|
|
|
|
if (_fetchedSettings.containsKey('lat')) {
|
|
_latController.text = _fetchedSettings['lat']!;
|
|
}
|
|
if (_fetchedSettings.containsKey('lon')) {
|
|
_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,
|
|
);
|
|
}
|
|
if (_fetchedSettings.containsKey('flood.advert.interval')) {
|
|
_floodAdvertInterval = _parseIntWithFallback(
|
|
_fetchedSettings['flood.advert.interval']!,
|
|
_floodAdvertInterval,
|
|
);
|
|
}
|
|
if (_fetchedSettings.containsKey('priv.advert.interval')) {
|
|
_privAdvertInterval = _parseIntWithFallback(
|
|
_fetchedSettings['priv.advert.interval']!,
|
|
_privAdvertInterval,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
bool _isAnySectionRefreshing() {
|
|
return _refreshingBasic ||
|
|
_refreshingRadio ||
|
|
_refreshingLocation ||
|
|
_refreshingFeatures ||
|
|
_refreshingAdvertisement;
|
|
}
|
|
|
|
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 value = _extractCliValue(response);
|
|
if (value == null) return;
|
|
|
|
final normalized = command.trim().toLowerCase();
|
|
if (!normalized.startsWith('get ')) return;
|
|
final key = normalized.substring(4);
|
|
|
|
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':
|
|
_fetchedSettings[key] = value;
|
|
break;
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
setState(() {
|
|
setRefreshing(true);
|
|
_fetchedSettings.clear();
|
|
});
|
|
|
|
var successCount = 0;
|
|
for (final command in commands) {
|
|
try {
|
|
final response = await _commandService!.sendCommand(widget.repeater, command);
|
|
_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('$label refreshed'),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error refreshing $label'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (_fetchedSettings.isNotEmpty) {
|
|
_updateUIFromFetchedSettings();
|
|
}
|
|
setState(() {
|
|
setRefreshing(false);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _refreshBasicSettings() async {
|
|
await _refreshSection(
|
|
label: 'Basic settings',
|
|
commands: const ['get name'],
|
|
setRefreshing: (value) => _refreshingBasic = value,
|
|
);
|
|
}
|
|
|
|
Future<void> _refreshRadioSettings() async {
|
|
await _refreshSection(
|
|
label: 'Radio settings',
|
|
commands: const ['get radio', 'get tx'],
|
|
setRefreshing: (value) => _refreshingRadio = value,
|
|
);
|
|
}
|
|
|
|
Future<void> _refreshLocationSettings() async {
|
|
await _refreshSection(
|
|
label: 'Location settings',
|
|
commands: const ['get lat', 'get lon'],
|
|
setRefreshing: (value) => _refreshingLocation = value,
|
|
);
|
|
}
|
|
|
|
Future<void> _refreshFeatureSettings() async {
|
|
await _refreshSection(
|
|
label: 'Feature toggles',
|
|
commands: const ['get repeat', 'get allow.read.only', 'get privacy'],
|
|
setRefreshing: (value) => _refreshingFeatures = value,
|
|
);
|
|
}
|
|
|
|
Future<void> _refreshAdvertisementSettings() async {
|
|
await _refreshSection(
|
|
label: 'Advertisement settings',
|
|
commands: const [
|
|
'get advert.interval',
|
|
'get flood.advert.interval',
|
|
'get priv.advert.interval',
|
|
],
|
|
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);
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
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
|
|
final freqMHz = double.tryParse(_freqController.text) ?? 915.0;
|
|
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 frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
|
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(
|
|
const SnackBar(
|
|
content: Text('Settings saved successfully'),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error saving settings: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _markChanged() {
|
|
if (!_hasChanges) {
|
|
setState(() {
|
|
_hasChanges = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
Widget _buildSectionHeader({
|
|
required IconData icon,
|
|
required String title,
|
|
required bool isRefreshing,
|
|
required VoidCallback onRefresh,
|
|
}) {
|
|
return Row(
|
|
children: [
|
|
Icon(icon, color: Theme.of(context).primaryColor),
|
|
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: 'Refresh $title',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('Repeater Settings'),
|
|
Text(
|
|
widget.repeater.name,
|
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
|
),
|
|
],
|
|
),
|
|
centerTitle: false,
|
|
actions: [
|
|
if (_hasChanges)
|
|
TextButton.icon(
|
|
onPressed: _isLoading ? null : _saveSettings,
|
|
icon: const Icon(Icons.save),
|
|
label: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
body: _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() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionHeader(
|
|
icon: Icons.settings,
|
|
title: 'Basic Settings',
|
|
isRefreshing: _refreshingBasic,
|
|
onRefresh: _refreshBasicSettings,
|
|
),
|
|
const Divider(),
|
|
TextField(
|
|
controller: _nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Repeater Name',
|
|
helperText: 'Display name for this repeater',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onChanged: (_) => _markChanged(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _passwordController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Admin Password',
|
|
helperText: 'Full access password',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
obscureText: true,
|
|
onChanged: (_) => _markChanged(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _guestPasswordController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Guest Password',
|
|
helperText: 'Read-only access password',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
obscureText: true,
|
|
onChanged: (_) => _markChanged(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRadioSettingsCard() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionHeader(
|
|
icon: Icons.radio,
|
|
title: 'Radio Settings',
|
|
isRefreshing: _refreshingRadio,
|
|
onRefresh: _refreshRadioSettings,
|
|
),
|
|
const Divider(),
|
|
TextField(
|
|
controller: _freqController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Frequency (MHz)',
|
|
helperText: '300-2500 MHz',
|
|
border: OutlineInputBorder(),
|
|
suffixText: 'MHz',
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
onChanged: (_) => _markChanged(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _txPowerController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'TX Power',
|
|
helperText: '1-30 dBm',
|
|
border: OutlineInputBorder(),
|
|
suffixText: 'dBm',
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
onChanged: (_) => _markChanged(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
DropdownButtonFormField<int>(
|
|
initialValue: _bandwidth,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Bandwidth',
|
|
border: 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: const InputDecoration(
|
|
labelText: 'Spreading Factor',
|
|
border: 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: const InputDecoration(
|
|
labelText: 'Coding Rate',
|
|
border: 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() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionHeader(
|
|
icon: Icons.location_on,
|
|
title: 'Location Settings',
|
|
isRefreshing: _refreshingLocation,
|
|
onRefresh: _refreshLocationSettings,
|
|
),
|
|
const Divider(),
|
|
TextField(
|
|
controller: _latController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Latitude',
|
|
helperText: 'Decimal degrees (e.g., 37.7749)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
|
onChanged: (_) => _markChanged(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _lonController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Longitude',
|
|
helperText: 'Decimal degrees (e.g., -122.4194)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: true),
|
|
onChanged: (_) => _markChanged(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFeatureTogglesCard() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionHeader(
|
|
icon: Icons.toggle_on,
|
|
title: 'Features',
|
|
isRefreshing: _refreshingFeatures,
|
|
onRefresh: _refreshFeatureSettings,
|
|
),
|
|
const Divider(),
|
|
SwitchListTile(
|
|
title: const Text('Packet Forwarding'),
|
|
subtitle: const Text('Enable repeater to forward packets'),
|
|
value: _repeatEnabled,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_repeatEnabled = value;
|
|
});
|
|
_markChanged();
|
|
},
|
|
),
|
|
SwitchListTile(
|
|
title: const Text('Guest Access'),
|
|
subtitle: const Text('Allow read-only guest access'),
|
|
value: _allowReadOnly,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_allowReadOnly = value;
|
|
});
|
|
_markChanged();
|
|
},
|
|
),
|
|
SwitchListTile(
|
|
title: const Text('Privacy Mode'),
|
|
subtitle: const Text('Hide name/location in advertisements'),
|
|
value: _privacyMode,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_privacyMode = value;
|
|
});
|
|
_markChanged();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAdvertisementSettingsCard() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildSectionHeader(
|
|
icon: Icons.broadcast_on_personal,
|
|
title: 'Advertisement Settings',
|
|
isRefreshing: _refreshingAdvertisement,
|
|
onRefresh: _refreshAdvertisementSettings,
|
|
),
|
|
const Divider(),
|
|
ListTile(
|
|
title: const Text('Local Advertisement Interval'),
|
|
subtitle: Text('$_advertInterval minutes'),
|
|
trailing: Text('${_advertInterval}m'),
|
|
),
|
|
Slider(
|
|
value: _advertInterval.toDouble(),
|
|
min: 60,
|
|
max: 240,
|
|
divisions: 18,
|
|
label: '${_advertInterval}m',
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_advertInterval = value.toInt();
|
|
});
|
|
_markChanged();
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
ListTile(
|
|
title: const Text('Flood Advertisement Interval'),
|
|
subtitle: Text('$_floodAdvertInterval hours'),
|
|
trailing: Text('${_floodAdvertInterval}h'),
|
|
),
|
|
Slider(
|
|
value: _floodAdvertInterval.toDouble(),
|
|
min: 3,
|
|
max: 48,
|
|
divisions: 45,
|
|
label: '${_floodAdvertInterval}h',
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_floodAdvertInterval = value.toInt();
|
|
});
|
|
_markChanged();
|
|
},
|
|
),
|
|
if (_privacyMode) ...[
|
|
const SizedBox(height: 16),
|
|
ListTile(
|
|
title: const Text('Encrypted Advertisement Interval'),
|
|
subtitle: Text('$_privAdvertInterval minutes'),
|
|
trailing: Text('${_privAdvertInterval}m'),
|
|
),
|
|
Slider(
|
|
value: _privAdvertInterval.toDouble(),
|
|
min: 30,
|
|
max: 240,
|
|
divisions: 21,
|
|
label: '${_privAdvertInterval}m',
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_privAdvertInterval = value.toInt();
|
|
});
|
|
_markChanged();
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDangerZoneCard() {
|
|
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(
|
|
'Danger Zone',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: colorScheme.onErrorContainer,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
ListTile(
|
|
leading: Icon(Icons.refresh, color: colorScheme.onErrorContainer),
|
|
title: Text('Reboot Repeater', style: TextStyle(color: colorScheme.onErrorContainer)),
|
|
subtitle: Text(
|
|
'Restart the repeater device',
|
|
style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)),
|
|
),
|
|
onTap: () => _confirmAction(
|
|
'Reboot Repeater',
|
|
'Are you sure you want to reboot this repeater?',
|
|
() => _sendDangerCommand('reboot'),
|
|
),
|
|
),
|
|
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('Erase File System', style: TextStyle(color: colorScheme.onErrorContainer)),
|
|
subtitle: Text(
|
|
'Format the repeater file system',
|
|
style: TextStyle(color: colorScheme.onErrorContainer.withValues(alpha: 0.8)),
|
|
),
|
|
onTap: () => _confirmAction(
|
|
'Erase File System',
|
|
'WARNING: This will erase all data on the repeater. This cannot be undone!',
|
|
() => _sendDangerCommand('erase'),
|
|
isDestructive: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _sendDangerCommand(String command) async {
|
|
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
|
|
|
if (command == 'erase') {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Erase is only available over serial console.')),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
|
await connector.sendFrame(frame);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Command sent: $command')),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error sending command: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _confirmAction(
|
|
String title,
|
|
String message,
|
|
VoidCallback onConfirm, {
|
|
bool isDestructive = false,
|
|
}) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(title),
|
|
content: Text(message),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
onConfirm();
|
|
},
|
|
style: isDestructive
|
|
? FilledButton.styleFrom(backgroundColor: Colors.red)
|
|
: null,
|
|
child: const Text('Confirm'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|