import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; import '../providers/connection_provider.dart'; import '../services/validation_service.dart'; import '../l10n/app_localizations.dart'; class DeviceConfigScreen extends StatefulWidget { const DeviceConfigScreen({super.key}); @override State createState() => _DeviceConfigScreenState(); } class _DeviceConfigScreenState extends State { late TextEditingController _nameController; late TextEditingController _latController; late TextEditingController _lonController; late TextEditingController _freqController; late TextEditingController _txPowerController; bool _telemetryEnabled = false; bool _repeatEnabled = false; String _selectedBandwidth = '62.5 kHz'; int _selectedSpreadingFactor = 8; int _selectedCodingRate = 8; final List _bandwidthOptions = [ '7.8 kHz', '10.4 kHz', '15.6 kHz', '20.8 kHz', '31.25 kHz', '41.7 kHz', '62.5 kHz', '125 kHz', '250 kHz', '500 kHz', ]; @override void initState() { super.initState(); final deviceInfo = context.read().deviceInfo; _nameController = TextEditingController( text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '', ); _latController = TextEditingController( text: deviceInfo.advLat != null ? (deviceInfo.advLat! / 1000000).toStringAsFixed(6) : '0.0', ); _lonController = TextEditingController( text: deviceInfo.advLon != null ? (deviceInfo.advLon! / 1000000).toStringAsFixed(6) : '0.0', ); _freqController = TextEditingController( text: deviceInfo.radioFreq != null ? (deviceInfo.radioFreq! / 1000).toStringAsFixed(3) : '869.618', ); _txPowerController = TextEditingController( text: deviceInfo.txPower?.toString() ?? '20', ); if (deviceInfo.radioBw != null && deviceInfo.radioBw! >= 0 && deviceInfo.radioBw! <= 9) { _selectedBandwidth = _bandwidthFromValue(deviceInfo.radioBw!); } if (deviceInfo.radioSf != null && deviceInfo.radioSf! >= 7 && deviceInfo.radioSf! <= 12) { _selectedSpreadingFactor = deviceInfo.radioSf!; } if (deviceInfo.radioCr != null && deviceInfo.radioCr! >= 5 && deviceInfo.radioCr! <= 8) { _selectedCodingRate = deviceInfo.radioCr!; } // Check if telemetry is enabled (check if lat/lon are set and not zero) _telemetryEnabled = (deviceInfo.advLat != null && deviceInfo.advLat! != 0) || (deviceInfo.advLon != null && deviceInfo.advLon! != 0); // Initialize repeat mode from device info (firmware v9+) _repeatEnabled = deviceInfo.clientRepeat ?? false; // Fetch allowed repeat frequencies on open if device supports repeat mode if (deviceInfo.clientRepeat != null && deviceInfo.allowedRepeatFreqRanges == null) { WidgetsBinding.instance.addPostFrameCallback((_) { context.read().getAllowedRepeatFreq(); }); } } @override void dispose() { _nameController.dispose(); _latController.dispose(); _lonController.dispose(); _freqController.dispose(); _txPowerController.dispose(); super.dispose(); } String _bandwidthFromValue(int bw) { switch (bw) { case 0: return '7.8 kHz'; case 1: return '10.4 kHz'; case 2: return '15.6 kHz'; case 3: return '20.8 kHz'; case 4: return '31.25 kHz'; case 5: return '41.7 kHz'; case 6: return '62.5 kHz'; case 7: return '125 kHz'; case 8: return '250 kHz'; case 9: return '500 kHz'; default: return '62.5 kHz'; } } int _bandwidthToValue(String bw) { return _bandwidthOptions.indexOf(bw); } Future _savePublicInfo() async { final connectionProvider = context.read(); final deviceInfo = connectionProvider.deviceInfo; final validator = ValidationService(); try { // Save name if (_nameController.text.isNotEmpty) { await connectionProvider.setAdvertName(_nameController.text); } // Save position and telemetry settings if (_telemetryEnabled) { // Parse and validate coordinates final latResult = validator.parseLatitude(_latController.text); if (!latResult.isSuccess) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(latResult.errorMessage!), backgroundColor: Colors.red, ), ); } return; } final lonResult = validator.parseLongitude(_lonController.text); if (!lonResult.isSuccess) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(lonResult.errorMessage!), backgroundColor: Colors.red, ), ); } return; } await connectionProvider.setAdvertLatLon( latitude: latResult.value!, longitude: lonResult.value!, ); // Set telemetry modes to "Allow All" (mode 2 for both base and location) final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2) await connectionProvider.setOtherParams( manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0, telemetryModes: telemetryModes, advertLocationPolicy: 1, ); } else { // Clear position await connectionProvider.setAdvertLatLon(latitude: 0.0, longitude: 0.0); // Set telemetry modes to "Deny" (mode 0) final telemetryModes = 0x00; await connectionProvider.setOtherParams( manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0, telemetryModes: telemetryModes, advertLocationPolicy: 0, ); } // Refetch device info to update UI with new settings await connectionProvider.refreshDeviceInfo(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.save), backgroundColor: Colors.green, ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.failedToSave(e.toString()), ), backgroundColor: Colors.red, ), ); } } } Future _saveRadioSettings() async { final connectionProvider = context.read(); final validator = ValidationService(); final deviceInfo = connectionProvider.deviceInfo; try { // Parse and validate frequency final freqResult = validator.parseFrequency(_freqController.text); if (!freqResult.isSuccess) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(freqResult.errorMessage!), backgroundColor: Colors.red, ), ); } return; } // Parse and validate TX power final txPowerResult = validator.parseTxPower( _txPowerController.text, maxPower: deviceInfo.maxTxPower, ); if (!txPowerResult.isSuccess) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(txPowerResult.errorMessage!), backgroundColor: Colors.red, ), ); } return; } // Convert from MHz to kHz for protocol final freqKhz = (freqResult.value! * 1000).round(); await connectionProvider.setRadioParams( frequency: freqKhz, bandwidth: _bandwidthToValue(_selectedBandwidth), spreadingFactor: _selectedSpreadingFactor, codingRate: _selectedCodingRate, repeat: deviceInfo.clientRepeat != null ? _repeatEnabled : null, ); // Save TX power await connectionProvider.setTxPower(txPowerResult.value!); // Refetch device info to update UI with new settings await connectionProvider.refreshDeviceInfo(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.save), backgroundColor: Colors.green, ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.failedToSave(e.toString()), ), backgroundColor: Colors.red, ), ); } } } Future _useCurrentLocation() async { try { bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); if (!serviceEnabled) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.locationServicesDisabled, ), ), ); } return; } LocationPermission permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); if (permission == LocationPermission.denied) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.locationPermissionDenied, ), ), ); } return; } } if (permission == LocationPermission.deniedForever) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of( context, )!.locationPermissionPermanentlyDenied, ), ), ); } return; } Position position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.best, ), ); if (!mounted) return; setState(() { _latController.text = position.latitude.toStringAsFixed(6); _lonController.text = position.longitude.toStringAsFixed(6); _telemetryEnabled = true; }); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.locationBroadcast( position.latitude.toStringAsFixed(6), position.longitude.toStringAsFixed(6), ), ), backgroundColor: Colors.green, ), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context)!.failedToGetLocation(e.toString()), ), ), ); } } } @override Widget build(BuildContext context) { final deviceInfo = context.watch().deviceInfo; final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), body: ListView( padding: const EdgeInsets.all(16), children: [ // Device Info Card Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( AppLocalizations.of(context)!.deviceInformation, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), const SizedBox(height: 16), _InfoRow( AppLocalizations.of(context)!.bleName, deviceInfo.deviceName ?? AppLocalizations.of(context)!.unknown, ), _InfoRow( AppLocalizations.of(context)!.meshName, deviceInfo.selfName ?? AppLocalizations.of(context)!.notSet, ), _InfoRow( AppLocalizations.of(context)!.type, _getDeviceTypeString(context, deviceInfo.deviceType), ), _InfoRow( AppLocalizations.of(context)!.model, deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown, ), _InfoRow( AppLocalizations.of(context)!.version, deviceInfo.semanticVersion ?? AppLocalizations.of(context)!.unknown, ), _InfoRow( AppLocalizations.of(context)!.buildDate, deviceInfo.firmwareBuildDate ?? AppLocalizations.of(context)!.unknown, ), _InfoRow( AppLocalizations.of(context)!.firmware, 'v${deviceInfo.firmwareVersion?.toString() ?? "?"}', ), _InfoRow( AppLocalizations.of(context)!.maxContacts, deviceInfo.maxContacts?.toString() ?? AppLocalizations.of(context)!.unknown, ), _InfoRow( AppLocalizations.of(context)!.maxChannels, deviceInfo.maxChannels?.toString() ?? AppLocalizations.of(context)!.unknown, ), _CopyableInfoRow( AppLocalizations.of(context)!.publicKey, _getPublicKeyHex(deviceInfo.publicKey), ), ], ), ), ), const SizedBox(height: 24), // Public Info Section Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( AppLocalizations.of(context)!.publicInfo, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), Row( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: 8), IconButton.filled( onPressed: _savePublicInfo, icon: const Icon(Icons.save), tooltip: AppLocalizations.of(context)!.save, ), ], ), ], ), const SizedBox(height: 16), // Mesh Network Name TextField( controller: _nameController, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.meshNetworkName, border: const OutlineInputBorder(), helperText: AppLocalizations.of( context, )!.nameBroadcastInMesh, ), ), const SizedBox(height: 8), // Telemetry Toggle - Compact version Row( children: [ Expanded( child: Text( AppLocalizations.of( context, )!.telemetryAndLocationSharing, style: theme.textTheme.bodyMedium, ), ), Switch( value: _telemetryEnabled, onChanged: (value) { setState(() { _telemetryEnabled = value; }); }, ), ], ), // GPS Coordinates (only show if telemetry enabled) if (_telemetryEnabled) ...[ const SizedBox(height: 12), Row( children: [ Expanded( child: TextField( controller: _latController, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.lat, border: const OutlineInputBorder(), isDense: true, ), keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true, ), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: _lonController, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.lon, border: const OutlineInputBorder(), isDense: true, ), keyboardType: const TextInputType.numberWithOptions( decimal: true, signed: true, ), ), ), const SizedBox(width: 8), IconButton.filled( onPressed: _useCurrentLocation, icon: const Icon(Icons.my_location, size: 20), tooltip: AppLocalizations.of( context, )!.useCurrentLocation, ), ], ), ], ], ), ), ), const SizedBox(height: 24), // Radio Settings Section Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( AppLocalizations.of(context)!.radioSettings, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), ), IconButton.filled( onPressed: _saveRadioSettings, icon: const Icon(Icons.save), tooltip: AppLocalizations.of(context)!.save, ), ], ), const SizedBox(height: 16), // LoRa Frequency TextField( controller: _freqController, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.frequencyMHz, border: const OutlineInputBorder(), helperText: AppLocalizations.of( context, )!.frequencyExample, ), keyboardType: const TextInputType.numberWithOptions( decimal: true, ), ), const SizedBox(height: 16), // Bandwidth DropdownButtonFormField( initialValue: _selectedBandwidth, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.bandwidth, border: const OutlineInputBorder(), ), items: _bandwidthOptions.map((String value) { return DropdownMenuItem( value: value, child: Text(value), ); }).toList(), onChanged: (String? newValue) { if (newValue != null) { setState(() { _selectedBandwidth = newValue; }); } }, ), const SizedBox(height: 16), // Spreading Factor DropdownButtonFormField( initialValue: _selectedSpreadingFactor, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.spreadingFactor, border: const OutlineInputBorder(), ), items: List.generate(6, (index) => index + 7).map(( int value, ) { return DropdownMenuItem( value: value, child: Text(value.toString()), ); }).toList(), onChanged: (int? newValue) { if (newValue != null) { setState(() { _selectedSpreadingFactor = newValue; }); } }, ), const SizedBox(height: 16), // Coding Rate DropdownButtonFormField( initialValue: _selectedCodingRate, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.codingRate, border: const OutlineInputBorder(), ), items: List.generate(4, (index) => index + 5).map(( int value, ) { return DropdownMenuItem( value: value, child: Text(value.toString()), ); }).toList(), onChanged: (int? newValue) { if (newValue != null) { setState(() { _selectedCodingRate = newValue; }); } }, ), const SizedBox(height: 16), // TX Power TextField( controller: _txPowerController, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.txPowerDbm, border: const OutlineInputBorder(), helperText: AppLocalizations.of( context, )!.maxPowerDbm(deviceInfo.maxTxPower ?? 22), ), keyboardType: TextInputType.number, ), // Repeat Mode (firmware v9+) if (deviceInfo.clientRepeat != null) ...[ const SizedBox(height: 8), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Client Repeat Mode'), subtitle: deviceInfo.allowedRepeatFreqRanges != null && deviceInfo.allowedRepeatFreqRanges!.isNotEmpty ? Text( 'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}–${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}', ) : const Text( 'Repeat packets on behalf of nearby nodes', ), value: _repeatEnabled, onChanged: (value) { setState(() { _repeatEnabled = value; }); }, ), ], ], ), ), ), const SizedBox(height: 24), ], ), ); } String _getDeviceTypeString(BuildContext context, int? deviceType) { if (deviceType == null) return AppLocalizations.of(context)!.unknown; switch (deviceType) { case 0: return AppLocalizations.of(context)!.noneUnknown; case 1: return AppLocalizations.of(context)!.chatNode; case 2: return AppLocalizations.of(context)!.repeater; case 3: return AppLocalizations.of(context)!.roomChannel; default: return AppLocalizations.of(context)!.typeNumber(deviceType); } } String _getPublicKeyHex(List? publicKey) { if (publicKey == null || publicKey.isEmpty) return 'N/A'; return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); } } class _InfoRow extends StatelessWidget { final String label; final String value; const _InfoRow(this.label, this.value); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 120, child: Text( label, style: const TextStyle( fontWeight: FontWeight.w500, color: Colors.grey, ), ), ), Expanded( child: Text( value, style: const TextStyle(fontWeight: FontWeight.w600), ), ), ], ), ); } } class _CopyableInfoRow extends StatelessWidget { final String label; final String value; const _CopyableInfoRow(this.label, this.value); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 120, child: Text( label, style: const TextStyle( fontWeight: FontWeight.w500, color: Colors.grey, ), ), ), Expanded( child: GestureDetector( onTap: () { Clipboard.setData(ClipboardData(text: value)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of( context, )!.copiedToClipboardShort(label), ), duration: const Duration(seconds: 2), backgroundColor: Colors.green, ), ); }, child: Row( children: [ Expanded( child: Text( value, style: const TextStyle( fontWeight: FontWeight.w600, fontFamily: 'monospace', ), overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), const Icon(Icons.copy, size: 16, color: Colors.grey), ], ), ), ), ], ), ); } }