From 92b6eba2464df05c4dfe09f458956b03bbd034b1 Mon Sep 17 00:00:00 2001 From: User Date: Sat, 28 Feb 2026 16:01:07 +0000 Subject: [PATCH 01/28] Add WiFi scan and manage feature - Add WiFi scanner screen to scan and connect to wireless networks - Add WiFi scan result model with encryption details - Add WiFi management (edit/delete/toggle) to interfaces screen - Add WiFi Scanner to More menu - Add API methods for wireless scanning and UCI operations - Add mock API implementations for testing --- lib/models/wifi_scan_result.dart | 140 +++ lib/screens/interfaces_screen.dart | 918 +++++++++++++- lib/screens/more_screen.dart | 15 + lib/screens/wifi_scan_screen.dart | 1078 +++++++++++++++++ lib/services/api_service.dart | 94 ++ .../interfaces/api_service_interface.dart | 40 + lib/services/mock_api_service.dart | 169 +++ lib/state/app_state.dart | 334 +++++ pubspec.lock | 82 +- 9 files changed, 2818 insertions(+), 52 deletions(-) create mode 100644 lib/models/wifi_scan_result.dart create mode 100644 lib/screens/wifi_scan_screen.dart diff --git a/lib/models/wifi_scan_result.dart b/lib/models/wifi_scan_result.dart new file mode 100644 index 0000000..9afa625 --- /dev/null +++ b/lib/models/wifi_scan_result.dart @@ -0,0 +1,140 @@ +/// Represents a WiFi network found during a wireless scan. +class WifiScanResult { + final String ssid; + final String bssid; + final String mode; + final int channel; + final int signal; + final int quality; + final int qualityMax; + final WifiEncryption encryption; + + WifiScanResult({ + required this.ssid, + required this.bssid, + required this.mode, + required this.channel, + required this.signal, + required this.quality, + required this.qualityMax, + required this.encryption, + }); + + factory WifiScanResult.fromJson(Map json) { + return WifiScanResult( + ssid: json['ssid'] as String? ?? '', + bssid: json['bssid'] as String? ?? '', + mode: json['mode'] as String? ?? 'Unknown', + channel: json['channel'] as int? ?? 0, + signal: json['signal'] as int? ?? -100, + quality: json['quality'] as int? ?? 0, + qualityMax: json['quality_max'] as int? ?? 100, + encryption: WifiEncryption.fromJson( + json['encryption'] as Map? ?? {}, + ), + ); + } + + /// Signal quality as a percentage (0-100). + int get qualityPercent { + if (qualityMax <= 0) return 0; + return ((quality / qualityMax) * 100).round().clamp(0, 100); + } + + /// Human-readable signal strength descriptor. + String get signalStrength { + if (signal >= -50) return 'Excellent'; + if (signal >= -60) return 'Good'; + if (signal >= -70) return 'Fair'; + if (signal >= -80) return 'Weak'; + return 'Very Weak'; + } + + /// Returns the appropriate WiFi signal icon level (0-4 bars). + int get signalBars { + if (signal >= -50) return 4; + if (signal >= -60) return 3; + if (signal >= -70) return 2; + if (signal >= -80) return 1; + return 0; + } + + /// Frequency band string based on channel number. + String get band { + if (channel >= 1 && channel <= 14) return '2.4 GHz'; + if (channel >= 32 && channel <= 177) return '5 GHz'; + if (channel >= 1 && channel <= 233) return '6 GHz'; + return 'Unknown'; + } +} + +/// Represents WiFi encryption details from a scan result. +class WifiEncryption { + final bool enabled; + final String description; + final bool wep; + final int wpa; + final List authSuites; + final List pairCiphers; + final List groupCiphers; + + WifiEncryption({ + required this.enabled, + required this.description, + required this.wep, + required this.wpa, + required this.authSuites, + required this.pairCiphers, + required this.groupCiphers, + }); + + factory WifiEncryption.fromJson(Map json) { + return WifiEncryption( + enabled: json['enabled'] as bool? ?? false, + description: json['description'] as String? ?? 'None', + wep: json['wep'] as bool? ?? false, + wpa: json['wpa'] as int? ?? 0, + authSuites: _toStringList(json['auth_suites']), + pairCiphers: _toStringList(json['pair_ciphers']), + groupCiphers: _toStringList(json['group_ciphers']), + ); + } + + static List _toStringList(dynamic value) { + if (value is List) { + return value.map((e) => e.toString()).toList(); + } + return []; + } + + /// Whether this network is open (no encryption). + bool get isOpen => !enabled; + + /// Short encryption label for display. + String get shortLabel { + if (!enabled) return 'Open'; + if (wep) return 'WEP'; + if (description.contains('WPA3')) return 'WPA3'; + if (description.contains('WPA2')) return 'WPA2'; + if (description.contains('WPA')) return 'WPA'; + return description; + } + + /// Returns the OpenWrt encryption config string for connecting. + String get openwrtEncryption { + if (!enabled) return 'none'; + if (wep) return 'wep-open'; + final hasSAE = authSuites.contains('SAE'); + final hasPSK = authSuites.contains('PSK'); + if (hasSAE && hasPSK) { + return wpa >= 2 ? 'sae-mixed' : 'sae'; + } + if (hasSAE) return 'sae'; + if (wpa >= 2) return 'psk2'; + if (wpa >= 1) return 'psk'; + return 'psk2'; // Default fallback + } + + /// Whether this encryption type requires a password. + bool get requiresPassword => enabled; +} diff --git a/lib/screens/interfaces_screen.dart b/lib/screens/interfaces_screen.dart index 17a052a..cd34f28 100644 --- a/lib/screens/interfaces_screen.dart +++ b/lib/screens/interfaces_screen.dart @@ -8,6 +8,7 @@ import 'package:luci_mobile/widgets/luci_app_bar.dart'; import 'package:luci_mobile/design/luci_design_system.dart'; import 'package:luci_mobile/widgets/luci_loading_states.dart'; import 'package:luci_mobile/widgets/luci_refresh_components.dart'; +import 'package:luci_mobile/screens/wifi_scan_screen.dart'; class InterfacesScreen extends ConsumerStatefulWidget { final String? scrollToInterface; @@ -398,7 +399,42 @@ class _InterfacesScreenState extends ConsumerState { slivers: [ SliverToBoxAdapter(child: LuciSectionHeader('Wired')), _buildWiredInterfacesList(), - SliverToBoxAdapter(child: LuciSectionHeader('Wireless')), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Wireless', + style: LuciTextStyles.sectionHeader(context), + ), + TextButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + const WifiScanScreen(), + ), + ); + }, + icon: Icon(Icons.radar, size: 16), + label: Text('Scan'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ), + ), _buildWirelessInterfacesList(), SliverToBoxAdapter( child: Padding( @@ -489,16 +525,18 @@ class _InterfacesScreenState extends ConsumerState { final interfacesList = >[]; final uciRadios = {}; - final uciInterfaces = {}; + final uciInterfaces = >{}; - final uciValues = uciWirelessConfig?['values'] as Map?; + // Try 'values' key (real API) then 'wireless' key (mock data) + final uciValues = (uciWirelessConfig?['values'] as Map?) ?? + (uciWirelessConfig?['wireless'] as Map?); if (uciValues != null) { uciValues.forEach((key, value) { final typedValue = value as Map?; if (typedValue?['.type'] == 'wifi-device') { uciRadios[key] = typedValue!; } else if (typedValue?['.type'] == 'wifi-iface') { - uciInterfaces[key] = typedValue!; + uciInterfaces[key] = Map.from(typedValue!); } }); } @@ -517,24 +555,44 @@ class _InterfacesScreenState extends ConsumerState { } final isRadioEnabled = uciRadios[radioName]?['disabled'] != '1'; - final isIfaceEnabled = config['disabled'] != '1'; + final isIfaceEnabled = config['disabled'] != '1' && + config['disabled'] != 1 && + config['disabled'] != true; final isEnabled = isRadioEnabled && isIfaceEnabled; final name = iface['name'] ?? ''; final ssid = iwinfo['ssid'] ?? config['ssid'] ?? ''; final deviceName = config['device'] ?? radioName; + final mode = config['mode'] ?? iwinfo['mode'] ?? 'N/A'; + + // Build encryption description + final encIwinfo = iwinfo['encryption'] as Map?; + final encDescription = encIwinfo?['description'] ?? config['encryption'] ?? 'N/A'; + interfacesList.add({ 'name': config['ssid'] ?? iwinfo['ssid'] ?? 'Unnamed', 'subtitle': - '${config['mode']?.toUpperCase() ?? iwinfo['mode']?.toUpperCase() ?? 'N/A'} • Ch. ${iwinfo['channel']?.toString() ?? config['channel']?.toString() ?? 'N/A'}', + '${mode.toString().toUpperCase()} • Ch. ${iwinfo['channel']?.toString() ?? config['channel']?.toString() ?? 'N/A'}', 'isEnabled': isEnabled, + 'isIfaceEnabled': isIfaceEnabled, + 'isRadioEnabled': isRadioEnabled, 'deviceName': deviceName, 'radioName': radioName, 'ssid': ssid, 'interfaceName': name, + 'uciSection': uciName ?? '', + 'mode': mode, + 'encryption': config['encryption'] ?? '', + 'encryptionDescription': encDescription, + 'network': (config['network'] is List) + ? (config['network'] as List).join(', ') + : config['network']?.toString() ?? '', + 'channel': iwinfo['channel']?.toString() ?? + config['channel']?.toString() ?? 'auto', + 'signal': iwinfo['signal']?.toString() ?? '--', 'details': { 'Device': config['device'] ?? radioName, - 'Mode': config['mode'] ?? iwinfo['mode'] ?? 'N/A', + 'Mode': mode, 'Channel': iwinfo['channel']?.toString() ?? config['channel']?.toString() ?? @@ -552,23 +610,35 @@ class _InterfacesScreenState extends ConsumerState { uciInterfaces.forEach((uciName, config) { if (!runtimeInterfaces.contains(uciName)) { - final radioName = config['device']; + final radioName = config['device'] ?? ''; final isRadioEnabled = uciRadios[radioName]?['disabled'] != '1'; final isIfaceEnabled = config['disabled'] != '1'; final isEnabled = isRadioEnabled && isIfaceEnabled; + final mode = config['mode'] ?? 'N/A'; final name = config['ssid'] ?? 'Unnamed'; interfacesList.add({ 'name': config['ssid'] ?? 'Unnamed', - 'subtitle': '${config['mode']?.toUpperCase() ?? 'N/A'} • Disabled', + 'subtitle': '${mode.toString().toUpperCase()} • Disabled', 'isEnabled': isEnabled, + 'isIfaceEnabled': isIfaceEnabled, + 'isRadioEnabled': isRadioEnabled, 'deviceName': radioName, 'radioName': radioName, 'ssid': name, 'interfaceName': name, + 'uciSection': uciName, + 'mode': mode, + 'encryption': config['encryption'] ?? '', + 'encryptionDescription': config['encryption'] ?? 'N/A', + 'network': (config['network'] is List) + ? (config['network'] as List).join(', ') + : config['network']?.toString() ?? '', + 'channel': config['channel']?.toString() ?? 'auto', + 'signal': '--', 'details': { 'Device': radioName, - 'Mode': config['mode'] ?? 'N/A', + 'Mode': mode, 'SSID': config['ssid'] ?? 'N/A', 'Network': (config['network'] is List) ? (config['network'] as List).join(', ') @@ -620,7 +690,7 @@ class _InterfacesScreenState extends ConsumerState { subtitle: iface['subtitle'], isUp: iface['isEnabled'], icon: Icons.wifi, - details: _buildGenericDetails(context, iface['details']), + details: _buildWirelessDetails(context, iface), initiallyExpanded: shouldExpand, ), ); @@ -628,6 +698,159 @@ class _InterfacesScreenState extends ConsumerState { ); } + Widget _buildWirelessDetails( + BuildContext context, + Map iface, + ) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final details = iface['details'] as Map; + final uciSection = iface['uciSection'] as String? ?? ''; + final isIfaceEnabled = iface['isIfaceEnabled'] as bool? ?? true; + final mode = iface['mode']?.toString() ?? ''; + final encDescription = iface['encryptionDescription']?.toString() ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Details rows + ...details.entries.map((entry) { + return _buildDetailRow(context, entry.key, entry.value.toString()); + }), + // Encryption row + if (encDescription.isNotEmpty && encDescription != 'N/A') + _buildDetailRow(context, 'Encryption', encDescription), + + const Divider(height: 1, indent: 16, endIndent: 16), + const SizedBox(height: 8), + + // Management action buttons + if (uciSection.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: Column( + children: [ + // Enable/Disable toggle row + _WifiToggleRow( + uciSection: uciSection, + isEnabled: isIfaceEnabled, + ), + + const SizedBox(height: 8), + + // Action buttons row + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _showEditWifiSheet(context, iface), + icon: Icon(Icons.edit_outlined, size: 18), + label: const Text('Edit'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.primary, + side: BorderSide( + color: colorScheme.primary.withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: () => + _showDeleteWifiDialog(context, iface), + icon: Icon(Icons.delete_outline, size: 18), + label: const Text('Remove'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide( + color: colorScheme.error.withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + ], + ), + + const SizedBox(height: 4), + + // Mode label + if (mode.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + mode.toLowerCase() == 'sta' + ? 'Client (Station) mode' + : mode.toLowerCase() == 'ap' + ? 'Access Point mode' + : '${mode.toUpperCase()} mode', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ) + else + // No UCI section - just show details + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'UCI section unavailable — limited management', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + + const SizedBox(height: 8), + ], + ); + } + + void _showEditWifiSheet( + BuildContext context, + Map iface, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => _WifiEditBottomSheet(iface: iface), + ); + } + + void _showDeleteWifiDialog( + BuildContext context, + Map iface, + ) { + final ssid = iface['ssid']?.toString() ?? 'this interface'; + final uciSection = iface['uciSection'] as String? ?? ''; + if (uciSection.isEmpty) return; + + showDialog( + context: context, + builder: (ctx) => _WifiDeleteDialog( + ssid: ssid, + uciSection: uciSection, + ), + ); + } + Widget _buildWiredDetails(BuildContext context, NetworkInterface interface) { return Column( children: [ @@ -1297,3 +1520,676 @@ class _UnifiedNetworkCardState extends State<_UnifiedNetworkCard> return card; } } + +// ────────────────────────────────────────────────────────────────── +// WiFi Enable/Disable Toggle Row +// ────────────────────────────────────────────────────────────────── + +class _WifiToggleRow extends ConsumerStatefulWidget { + final String uciSection; + final bool isEnabled; + + const _WifiToggleRow({ + required this.uciSection, + required this.isEnabled, + }); + + @override + ConsumerState<_WifiToggleRow> createState() => _WifiToggleRowState(); +} + +class _WifiToggleRowState extends ConsumerState<_WifiToggleRow> { + bool _isToggling = false; + + Future _toggle(bool value) async { + if (_isToggling) return; + setState(() => _isToggling = true); + + final appState = ref.read(appStateProvider); + final success = await appState.setWirelessInterfaceEnabled( + widget.uciSection, + value, + context: context, + ); + + if (mounted) { + setState(() => _isToggling = false); + if (!success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Failed to toggle interface'), + backgroundColor: Theme.of(context).colorScheme.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + widget.isEnabled ? Icons.wifi : Icons.wifi_off, + size: 20, + color: widget.isEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.isEnabled ? 'Interface Enabled' : 'Interface Disabled', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (_isToggling) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Switch( + value: widget.isEnabled, + onChanged: _toggle, + ), + ], + ), + ); + } +} + +// ────────────────────────────────────────────────────────────────── +// WiFi Edit Bottom Sheet +// ────────────────────────────────────────────────────────────────── + +class _WifiEditBottomSheet extends ConsumerStatefulWidget { + final Map iface; + + const _WifiEditBottomSheet({required this.iface}); + + @override + ConsumerState<_WifiEditBottomSheet> createState() => + _WifiEditBottomSheetState(); +} + +class _WifiEditBottomSheetState extends ConsumerState<_WifiEditBottomSheet> { + late TextEditingController _ssidController; + late TextEditingController _passwordController; + late TextEditingController _networkController; + late String _selectedEncryption; + bool _obscurePassword = true; + bool _isSaving = false; + String? _error; + + static const _encryptionOptions = [ + {'value': 'none', 'label': 'None (Open)'}, + {'value': 'psk2', 'label': 'WPA2-PSK'}, + {'value': 'psk', 'label': 'WPA-PSK'}, + {'value': 'psk-mixed', 'label': 'WPA/WPA2 Mixed PSK'}, + {'value': 'sae', 'label': 'WPA3-SAE'}, + {'value': 'sae-mixed', 'label': 'WPA2/WPA3 Mixed'}, + ]; + + @override + void initState() { + super.initState(); + _ssidController = TextEditingController( + text: widget.iface['ssid']?.toString() ?? '', + ); + _passwordController = TextEditingController(); + _networkController = TextEditingController( + text: widget.iface['network']?.toString() ?? 'lan', + ); + + // Map the current encryption to our dropdown values + final currentEnc = widget.iface['encryption']?.toString() ?? 'none'; + _selectedEncryption = _encryptionOptions.any( + (o) => o['value'] == currentEnc, + ) + ? currentEnc + : 'psk2'; + } + + @override + void dispose() { + _ssidController.dispose(); + _passwordController.dispose(); + _networkController.dispose(); + super.dispose(); + } + + bool get _requiresPassword => _selectedEncryption != 'none'; + + Future _save() async { + final ssid = _ssidController.text.trim(); + if (ssid.isEmpty) { + setState(() => _error = 'SSID cannot be empty.'); + return; + } + + if (_requiresPassword && + _passwordController.text.isNotEmpty && + _passwordController.text.length < 8 && + _selectedEncryption != 'none') { + setState( + () => _error = 'Password must be at least 8 characters.', + ); + return; + } + + setState(() { + _isSaving = true; + _error = null; + }); + + final uciSection = widget.iface['uciSection'] as String? ?? ''; + if (uciSection.isEmpty) { + setState(() { + _isSaving = false; + _error = 'Cannot identify UCI section for this interface.'; + }); + return; + } + + // Build values to update + final values = { + 'ssid': ssid, + 'encryption': _selectedEncryption, + }; + + // Only update password if user typed one + if (_passwordController.text.isNotEmpty) { + values['key'] = _passwordController.text; + } + + // Update network binding + final network = _networkController.text.trim(); + if (network.isNotEmpty) { + values['network'] = network; + } + + final appState = ref.read(appStateProvider); + final success = await appState.modifyWirelessInterface( + uciSection, + values, + context: context, + ); + + if (!mounted) return; + + if (success) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white, size: 20), + const SizedBox(width: 8), + Text('Updated "$ssid" — reloading WiFi...'), + ], + ), + backgroundColor: Colors.green.shade700, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: const Duration(seconds: 3), + ), + ); + } else { + setState(() { + _isSaving = false; + _error = 'Failed to save changes. Please try again.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final mode = widget.iface['mode']?.toString() ?? 'ap'; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Icon( + Icons.edit, + color: colorScheme.primary, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Edit Wireless Interface', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + '${widget.iface['radioName']} • ${mode.toUpperCase()} mode', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 24), + + // SSID field + _buildLabel(context, 'SSID (Network Name)'), + const SizedBox(height: 6), + TextField( + controller: _ssidController, + enabled: !_isSaving, + decoration: _inputDecoration( + context, + hintText: 'Enter SSID', + prefixIcon: Icons.wifi, + ), + ), + + const SizedBox(height: 16), + + // Encryption selector + _buildLabel(context, 'Encryption'), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + color: colorScheme.surfaceContainerLow, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedEncryption, + isExpanded: true, + icon: Icon( + Icons.arrow_drop_down, + color: colorScheme.onSurfaceVariant, + ), + items: _encryptionOptions.map((opt) { + return DropdownMenuItem( + value: opt['value'], + child: Text( + opt['label']!, + style: theme.textTheme.bodyMedium, + ), + ); + }).toList(), + onChanged: _isSaving + ? null + : (value) { + if (value != null) { + setState(() => _selectedEncryption = value); + } + }, + ), + ), + ), + + // Password field (only for encrypted networks) + if (_requiresPassword) ...[ + const SizedBox(height: 16), + _buildLabel(context, 'Password (leave empty to keep current)'), + const SizedBox(height: 6), + TextField( + controller: _passwordController, + obscureText: _obscurePassword, + enabled: !_isSaving, + decoration: _inputDecoration( + context, + hintText: 'Enter new password', + prefixIcon: Icons.key, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + ), + ), + ], + + const SizedBox(height: 16), + + // Network binding + _buildLabel(context, 'Network'), + const SizedBox(height: 6), + TextField( + controller: _networkController, + enabled: !_isSaving, + decoration: _inputDecoration( + context, + hintText: 'e.g., lan, wwan', + prefixIcon: Icons.lan_outlined, + ), + ), + + // Error + if (_error != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error, + size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 20), + + // Warning + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: colorScheme.tertiary, + size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Changes will be applied immediately. WiFi will briefly restart.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // Save button + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _isSaving ? null : _save, + icon: _isSaving + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : const Icon(Icons.save), + label: Text( + _isSaving ? 'Applying...' : 'Save Changes', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + ), + + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + + Widget _buildLabel(BuildContext context, String text) { + return Text( + text, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ); + } + + InputDecoration _inputDecoration( + BuildContext context, { + required String hintText, + required IconData prefixIcon, + Widget? suffixIcon, + }) { + final colorScheme = Theme.of(context).colorScheme; + return InputDecoration( + hintText: hintText, + prefixIcon: Icon(prefixIcon), + suffixIcon: suffixIcon, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLow, + ); + } +} + +// ────────────────────────────────────────────────────────────────── +// WiFi Delete Confirmation Dialog +// ────────────────────────────────────────────────────────────────── + +class _WifiDeleteDialog extends ConsumerStatefulWidget { + final String ssid; + final String uciSection; + + const _WifiDeleteDialog({ + required this.ssid, + required this.uciSection, + }); + + @override + ConsumerState<_WifiDeleteDialog> createState() => _WifiDeleteDialogState(); +} + +class _WifiDeleteDialogState extends ConsumerState<_WifiDeleteDialog> { + bool _isDeleting = false; + + Future _delete() async { + setState(() => _isDeleting = true); + + final appState = ref.read(appStateProvider); + final success = await appState.deleteWirelessInterface( + widget.uciSection, + context: context, + ); + + if (!mounted) return; + + Navigator.of(context).pop(); + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white, size: 20), + const SizedBox(width: 8), + Text('"${widget.ssid}" removed'), + ], + ), + backgroundColor: Colors.green.shade700, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: const Duration(seconds: 3), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Failed to remove interface'), + backgroundColor: Theme.of(context).colorScheme.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final displayName = widget.ssid.isNotEmpty ? widget.ssid : widget.uciSection; + + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + icon: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Icon(Icons.delete_forever, color: colorScheme.error, size: 32), + ), + title: const Text('Remove Wireless Interface?'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'This will permanently remove "$displayName" from your wireless configuration.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.warning_amber, color: colorScheme.error, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'WiFi will restart. Clients on this network will be disconnected.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _isDeleting ? null : _delete, + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ), + child: _isDeleting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Remove'), + ), + ], + ); + } +} diff --git a/lib/screens/more_screen.dart b/lib/screens/more_screen.dart index 965aeca..61f1372 100644 --- a/lib/screens/more_screen.dart +++ b/lib/screens/more_screen.dart @@ -11,6 +11,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:luci_mobile/config/app_config.dart'; import 'package:luci_mobile/screens/manage_routers_screen.dart'; +import 'package:luci_mobile/screens/wifi_scan_screen.dart'; import 'package:luci_mobile/utils/http_client_manager.dart'; import 'package:luci_mobile/state/app_state.dart'; @@ -309,6 +310,20 @@ class _MoreScreenState extends ConsumerState { ); return _MoreScreenSection( tiles: [ + _buildMoreTile( + context, + icon: Icons.wifi_find, + iconColor: Theme.of(context).colorScheme.primary, + title: 'WiFi Scanner', + subtitle: 'Scan and connect to wireless networks', + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const WifiScanScreen(), + ), + ); + }, + ), _buildMoreTile( context, icon: Icons.restart_alt, diff --git a/lib/screens/wifi_scan_screen.dart b/lib/screens/wifi_scan_screen.dart new file mode 100644 index 0000000..a3e430f --- /dev/null +++ b/lib/screens/wifi_scan_screen.dart @@ -0,0 +1,1078 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:luci_mobile/main.dart'; +import 'package:luci_mobile/models/wifi_scan_result.dart'; +import 'package:luci_mobile/design/luci_design_system.dart'; + +class WifiScanScreen extends ConsumerStatefulWidget { + const WifiScanScreen({super.key}); + + @override + ConsumerState createState() => _WifiScanScreenState(); +} + +class _WifiScanScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + List _scanResults = []; + bool _isScanning = false; + String? _error; + String? _selectedDevice; + List> _radioDevices = []; + late AnimationController _pulseController; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadRadioDevices(); + }); + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + void _loadRadioDevices() { + final appState = ref.read(appStateProvider); + final devices = appState.getAvailableRadioDevices(); + setState(() { + _radioDevices = devices; + if (devices.isNotEmpty) { + _selectedDevice = devices.first['ifname']; + } + }); + } + + Future _startScan() async { + if (_selectedDevice == null) return; + + setState(() { + _isScanning = true; + _error = null; + _scanResults = []; + }); + _pulseController.repeat(); + + try { + final appState = ref.read(appStateProvider); + final results = await appState.scanWirelessNetworks( + device: _selectedDevice!, + context: context, + ); + + if (!mounted) return; + setState(() { + _scanResults = results; + _isScanning = false; + if (results.isEmpty) { + _error = 'No networks found. Try scanning again.'; + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isScanning = false; + _error = 'Scan failed: $e'; + }); + } + _pulseController.stop(); + _pulseController.reset(); + } + + void _showConnectDialog(WifiScanResult network) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (context) => _ConnectBottomSheet( + network: network, + radioDevices: _radioDevices, + selectedDevice: _selectedDevice, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('WiFi Scanner'), + centerTitle: true, + elevation: 0, + ), + body: Column( + children: [ + // Device selector and scan button + _buildControlBar(theme, colorScheme), + + // Results area + Expanded( + child: _isScanning + ? _buildScanningIndicator(theme, colorScheme) + : _scanResults.isEmpty + ? _buildEmptyState(theme, colorScheme) + : _buildResultsList(theme, colorScheme), + ), + ], + ), + ); + } + + Widget _buildControlBar(ThemeData theme, ColorScheme colorScheme) { + return Container( + padding: const EdgeInsets.fromLTRB( + LuciSpacing.md, + LuciSpacing.sm, + LuciSpacing.md, + LuciSpacing.md, + ), + decoration: BoxDecoration( + color: colorScheme.surface, + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + // Radio device selector + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + color: colorScheme.surfaceContainerLow, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedDevice, + isExpanded: true, + icon: Icon( + Icons.arrow_drop_down, + color: colorScheme.onSurfaceVariant, + ), + hint: Text( + 'Select radio', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + items: _radioDevices.map((device) { + return DropdownMenuItem( + value: device['ifname'], + child: Row( + children: [ + Icon( + Icons.router, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + '${device['ifname']} (${device['ssid']})', + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ); + }).toList(), + onChanged: _isScanning + ? null + : (value) { + setState(() { + _selectedDevice = value; + }); + }, + ), + ), + ), + ), + const SizedBox(width: 12), + // Scan button + FilledButton.icon( + onPressed: _isScanning || _selectedDevice == null + ? null + : _startScan, + icon: _isScanning + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : const Icon(Icons.radar, size: 20), + label: Text(_isScanning ? 'Scanning' : 'Scan'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ], + ), + ); + } + + Widget _buildScanningIndicator(ThemeData theme, ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AnimatedBuilder( + animation: _pulseController, + builder: (context, child) { + return Stack( + alignment: Alignment.center, + children: [ + // Outer pulse ring + Transform.scale( + scale: 1.0 + (_pulseController.value * 0.6), + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: colorScheme.primary.withValues( + alpha: 0.3 * (1.0 - _pulseController.value), + ), + width: 2, + ), + ), + ), + ), + // Middle pulse ring + Transform.scale( + scale: 1.0 + (_pulseController.value * 0.35), + child: Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: colorScheme.primary.withValues( + alpha: 0.5 * (1.0 - _pulseController.value), + ), + width: 2, + ), + ), + ), + ), + // Center icon + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + ), + child: Icon( + Icons.radar, + size: 40, + color: colorScheme.primary, + ), + ), + ], + ); + }, + ), + const SizedBox(height: LuciSpacing.lg), + Text( + 'Scanning for networks...', + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: LuciSpacing.sm), + Text( + 'This may take a few seconds', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState(ThemeData theme, ColorScheme colorScheme) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.wifi_find, + size: 64, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + ), + const SizedBox(height: LuciSpacing.md), + Text( + _error ?? 'Select a radio and tap Scan\nto find nearby WiFi networks', + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge?.copyWith( + color: _error != null + ? colorScheme.error + : colorScheme.onSurfaceVariant, + ), + ), + if (_radioDevices.isEmpty) ...[ + const SizedBox(height: LuciSpacing.md), + Text( + 'No wireless radios detected.\nMake sure your router has wireless interfaces.', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } + + Widget _buildResultsList(ThemeData theme, ColorScheme colorScheme) { + // Separate networks with SSIDs from hidden ones + final visibleNetworks = + _scanResults.where((r) => r.ssid.isNotEmpty).toList(); + final hiddenNetworks = + _scanResults.where((r) => r.ssid.isEmpty).toList(); + + return ListView( + padding: const EdgeInsets.symmetric(vertical: LuciSpacing.sm), + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric( + horizontal: LuciSpacing.md, + vertical: LuciSpacing.sm, + ), + child: Row( + children: [ + Text( + 'AVAILABLE NETWORKS', + style: LuciTextStyles.sectionHeader(context), + ), + const Spacer(), + Text( + '${_scanResults.length} found', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + // Visible networks + ...visibleNetworks.map( + (network) => _WifiNetworkTile( + network: network, + onTap: () => _showConnectDialog(network), + ), + ), + + // Hidden networks section + if (hiddenNetworks.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB( + LuciSpacing.md, + LuciSpacing.md, + LuciSpacing.md, + LuciSpacing.sm, + ), + child: Text( + 'HIDDEN NETWORKS', + style: LuciTextStyles.sectionHeader(context), + ), + ), + ...hiddenNetworks.map( + (network) => _WifiNetworkTile( + network: network, + onTap: () => _showConnectDialog(network), + ), + ), + ], + ], + ); + } +} + +// ────────────────────────────────────────────────────────────────── +// WiFi Network List Tile +// ────────────────────────────────────────────────────────────────── + +class _WifiNetworkTile extends StatelessWidget { + final WifiScanResult network; + final VoidCallback onTap; + + const _WifiNetworkTile({required this.network, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final signalColor = _getSignalColor(colorScheme); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: LuciSpacing.md, + vertical: 4, + ), + child: Card( + elevation: 1, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: LuciCardStyles.standardRadius, + side: BorderSide( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.10), + width: 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + borderRadius: LuciCardStyles.standardRadius, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: LuciSpacing.md, + vertical: 12, + ), + child: Row( + children: [ + // Signal strength icon + _SignalIcon( + bars: network.signalBars, + color: signalColor, + hasLock: network.encryption.enabled, + ), + const SizedBox(width: 14), + + // Network info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + network.ssid.isNotEmpty + ? network.ssid + : '(Hidden Network)', + style: LuciTextStyles.cardTitle(context).copyWith( + fontStyle: network.ssid.isEmpty + ? FontStyle.italic + : FontStyle.normal, + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '${network.band} • Ch. ${network.channel} • ${network.signal} dBm', + style: LuciTextStyles.cardSubtitle(context), + ), + ], + ), + ), + + // Encryption badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: network.encryption.isOpen + ? colorScheme.errorContainer.withValues(alpha: 0.3) + : colorScheme.primaryContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + network.encryption.shortLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: network.encryption.isOpen + ? colorScheme.error + : colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + + const SizedBox(width: 4), + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + size: 20, + ), + ], + ), + ), + ), + ), + ); + } + + Color _getSignalColor(ColorScheme colorScheme) { + if (network.signal >= -50) return Colors.green; + if (network.signal >= -60) return Colors.lightGreen; + if (network.signal >= -70) return Colors.orange; + if (network.signal >= -80) return Colors.deepOrange; + return colorScheme.error; + } +} + +// ────────────────────────────────────────────────────────────────── +// Signal strength icon with bars +// ────────────────────────────────────────────────────────────────── + +class _SignalIcon extends StatelessWidget { + final int bars; + final Color color; + final bool hasLock; + + const _SignalIcon({ + required this.bars, + required this.color, + required this.hasLock, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return SizedBox( + width: 36, + height: 36, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + ), + // Use built-in WiFi icons based on signal level + Icon( + _getWifiIcon(), + size: 22, + color: color, + ), + // Lock indicator + if (hasLock) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: colorScheme.surface, + shape: BoxShape.circle, + ), + child: Icon( + Icons.lock, + size: 10, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } + + IconData _getWifiIcon() { + switch (bars) { + case 4: + return Icons.wifi; + case 3: + return Icons.wifi; + case 2: + return Icons.wifi_2_bar; + case 1: + return Icons.wifi_1_bar; + default: + return Icons.wifi_1_bar; + } + } +} + +// ────────────────────────────────────────────────────────────────── +// Connect Bottom Sheet +// ────────────────────────────────────────────────────────────────── + +class _ConnectBottomSheet extends ConsumerStatefulWidget { + final WifiScanResult network; + final List> radioDevices; + final String? selectedDevice; + + const _ConnectBottomSheet({ + required this.network, + required this.radioDevices, + this.selectedDevice, + }); + + @override + ConsumerState<_ConnectBottomSheet> createState() => + _ConnectBottomSheetState(); +} + +class _ConnectBottomSheetState extends ConsumerState<_ConnectBottomSheet> { + final _passwordController = TextEditingController(); + bool _obscurePassword = true; + bool _isConnecting = false; + String? _selectedRadio; + String? _error; + + @override + void initState() { + super.initState(); + // Default to the radio that was used for scanning + if (widget.selectedDevice != null && widget.radioDevices.isNotEmpty) { + // Find the radioName for the selected device + final matchingDevice = widget.radioDevices.firstWhere( + (d) => d['ifname'] == widget.selectedDevice, + orElse: () => widget.radioDevices.first, + ); + _selectedRadio = matchingDevice['radioName']; + } + } + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + Future _connect() async { + if (_selectedRadio == null) { + setState(() { + _error = 'Please select a radio device.'; + }); + return; + } + + if (widget.network.encryption.requiresPassword && + _passwordController.text.isEmpty) { + setState(() { + _error = 'Password is required for this network.'; + }); + return; + } + + // WPA2 requires minimum 8 characters + if (widget.network.encryption.requiresPassword && + !widget.network.encryption.wep && + _passwordController.text.length < 8) { + setState(() { + _error = 'Password must be at least 8 characters.'; + }); + return; + } + + setState(() { + _isConnecting = true; + _error = null; + }); + + final appState = ref.read(appStateProvider); + final success = await appState.connectToWirelessNetwork( + radioDevice: _selectedRadio!, + ssid: widget.network.ssid, + encryption: widget.network.encryption.openwrtEncryption, + password: _passwordController.text, + context: context, + ); + + if (!mounted) return; + + if (success) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Connecting to "${widget.network.ssid}"...', + ), + ), + ], + ), + backgroundColor: Colors.green.shade700, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: const Duration(seconds: 4), + ), + ); + } else { + setState(() { + _isConnecting = false; + _error = 'Failed to connect. Check your password and try again.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final network = widget.network; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB( + LuciSpacing.lg, + LuciSpacing.md, + LuciSpacing.lg, + LuciSpacing.lg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: LuciSpacing.lg), + + // Network header + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Icon( + Icons.wifi, + color: colorScheme.primary, + size: 28, + ), + ), + const SizedBox(width: LuciSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + network.ssid.isNotEmpty + ? network.ssid + : '(Hidden Network)', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + '${network.encryption.shortLabel} • ${network.band} • Ch. ${network.channel}', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: LuciSpacing.lg), + + // Network details + _buildInfoRow( + context, + 'BSSID', + network.bssid, + Icons.router_outlined, + ), + _buildInfoRow( + context, + 'Signal', + '${network.signal} dBm (${network.signalStrength})', + Icons.signal_cellular_alt, + ), + _buildInfoRow( + context, + 'Encryption', + network.encryption.description, + Icons.security, + ), + + const SizedBox(height: LuciSpacing.lg), + Divider( + color: colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: LuciSpacing.md), + + // Radio selector + Text( + 'Connect using radio:', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: LuciSpacing.sm), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + color: colorScheme.surfaceContainerLow, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedRadio, + isExpanded: true, + icon: Icon( + Icons.arrow_drop_down, + color: colorScheme.onSurfaceVariant, + ), + items: widget.radioDevices.map((device) { + return DropdownMenuItem( + value: device['radioName'], + child: Text( + '${device['radioName']} (${device['ifname']})', + style: theme.textTheme.bodyMedium, + ), + ); + }).toList(), + onChanged: _isConnecting + ? null + : (value) { + setState(() { + _selectedRadio = value; + }); + }, + ), + ), + ), + + // Password field (only for encrypted networks) + if (network.encryption.requiresPassword) ...[ + const SizedBox(height: LuciSpacing.md), + Text( + 'Password:', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: LuciSpacing.sm), + TextField( + controller: _passwordController, + obscureText: _obscurePassword, + enabled: !_isConnecting, + decoration: InputDecoration( + hintText: 'Enter network password', + prefixIcon: const Icon(Icons.key), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLow, + ), + onSubmitted: (_) => _connect(), + ), + ], + + // Error message + if (_error != null) ...[ + const SizedBox(height: LuciSpacing.sm), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: colorScheme.error, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: LuciSpacing.lg), + + // Warning for station mode + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + color: colorScheme.tertiary, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'This will create a new station-mode (client) interface on the selected radio. The radio will connect to this network as a client.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: LuciSpacing.lg), + + // Connect button + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _isConnecting ? null : _connect, + icon: _isConnecting + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : const Icon(Icons.wifi), + label: Text( + _isConnecting ? 'Connecting...' : 'Connect', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + ), + + const SizedBox(height: LuciSpacing.sm), + ], + ), + ), + ), + ); + } + + Widget _buildInfoRow( + BuildContext context, + String label, + String value, + IconData icon, + ) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon(icon, size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 10), + SizedBox( + width: 80, + child: Text( + label, + style: LuciTextStyles.detailLabel(context), + ), + ), + Expanded( + child: Text( + value, + style: LuciTextStyles.detailValue(context), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index b3fc063..81de466 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -667,4 +667,98 @@ class RealApiService implements IApiService { context: context, ); } + + @override + Future>> scanWirelessNetworks({ + required String ipAddress, + required String sysauth, + required bool useHttps, + required String device, + BuildContext? context, + }) async { + try { + final result = await callWithContext( + ipAddress, + sysauth, + useHttps, + object: 'iwinfo', + method: 'scan', + params: {'device': device}, + context: context, + ); + // Handle LuCI RPC format: [status, data] + if (result is List && result.length > 1 && result[0] == 0) { + final data = result[1]; + if (data is Map && data['results'] is List) { + return (data['results'] as List) + .whereType>() + .toList(); + } + } + return []; + } catch (e, stack) { + Logger.exception('Failed to scan wireless networks', e, stack); + return []; + } + } + + @override + Future uciAdd( + String ipAddress, + String sysauth, + bool useHttps, { + required String config, + required String type, + required Map values, + BuildContext? context, + }) async { + return await callWithContext( + ipAddress, + sysauth, + useHttps, + object: 'uci', + method: 'add', + params: {'config': config, 'type': type, 'values': values}, + context: context, + ); + } + + @override + Future uciDelete( + String ipAddress, + String sysauth, + bool useHttps, { + required String config, + required String section, + BuildContext? context, + }) async { + return await callWithContext( + ipAddress, + sysauth, + useHttps, + object: 'uci', + method: 'delete', + params: {'config': config, 'section': section}, + context: context, + ); + } + + @override + Future uciGetAll( + String ipAddress, + String sysauth, + bool useHttps, { + required String config, + BuildContext? context, + }) async { + return await callWithContext( + ipAddress, + sysauth, + useHttps, + object: 'uci', + method: 'get', + params: {'config': config}, + context: context, + ); + } } diff --git a/lib/services/interfaces/api_service_interface.dart b/lib/services/interfaces/api_service_interface.dart index 44c26e9..62e2a60 100644 --- a/lib/services/interfaces/api_service_interface.dart +++ b/lib/services/interfaces/api_service_interface.dart @@ -81,4 +81,44 @@ abstract class IApiService { required String command, BuildContext? context, }); + + /// Scans for nearby wireless networks using a given radio device (e.g., wlan0). + /// Returns the raw scan results from iwinfo.scan. + Future>> scanWirelessNetworks({ + required String ipAddress, + required String sysauth, + required bool useHttps, + required String device, + BuildContext? context, + }); + + /// Adds a new wifi-iface section via UCI to connect to a network as a station. + Future uciAdd( + String ipAddress, + String sysauth, + bool useHttps, { + required String config, + required String type, + required Map values, + BuildContext? context, + }); + + /// Deletes a UCI section (e.g., to remove a wifi-iface). + Future uciDelete( + String ipAddress, + String sysauth, + bool useHttps, { + required String config, + required String section, + BuildContext? context, + }); + + /// Retrieves the full UCI config for a given config name. + Future uciGetAll( + String ipAddress, + String sysauth, + bool useHttps, { + required String config, + BuildContext? context, + }); } diff --git a/lib/services/mock_api_service.dart b/lib/services/mock_api_service.dart index c616e8b..3b7644f 100644 --- a/lib/services/mock_api_service.dart +++ b/lib/services/mock_api_service.dart @@ -904,4 +904,173 @@ class MockApiService implements IApiService { // For mock service, just delegate to fetchAssociatedStations return await fetchAssociatedStations(); } + + @override + Future>> scanWirelessNetworks({ + required String ipAddress, + required String sysauth, + required bool useHttps, + required String device, + BuildContext? context, + }) async { + await Future.delayed(const Duration(seconds: 2)); // Simulate scan time + return [ + { + 'ssid': 'Neighbor-WiFi', + 'bssid': 'AA:BB:CC:11:22:33', + 'mode': 'Master', + 'channel': 1, + 'signal': -45, + 'quality': 65, + 'quality_max': 70, + 'encryption': { + 'enabled': true, + 'description': 'WPA2 PSK (CCMP)', + 'wep': false, + 'wpa': 2, + 'auth_suites': ['PSK'], + 'pair_ciphers': ['CCMP'], + 'group_ciphers': ['CCMP'], + }, + }, + { + 'ssid': 'CoffeeShop-Free', + 'bssid': 'DD:EE:FF:44:55:66', + 'mode': 'Master', + 'channel': 6, + 'signal': -62, + 'quality': 48, + 'quality_max': 70, + 'encryption': { + 'enabled': false, + 'description': 'None', + 'wep': false, + 'wpa': 0, + 'auth_suites': [], + 'pair_ciphers': [], + 'group_ciphers': [], + }, + }, + { + 'ssid': 'Office-5G', + 'bssid': '11:22:33:AA:BB:CC', + 'mode': 'Master', + 'channel': 36, + 'signal': -55, + 'quality': 55, + 'quality_max': 70, + 'encryption': { + 'enabled': true, + 'description': 'WPA2 PSK (CCMP)', + 'wep': false, + 'wpa': 2, + 'auth_suites': ['PSK'], + 'pair_ciphers': ['CCMP'], + 'group_ciphers': ['CCMP'], + }, + }, + { + 'ssid': 'SmartHome-IoT', + 'bssid': '77:88:99:DD:EE:FF', + 'mode': 'Master', + 'channel': 11, + 'signal': -71, + 'quality': 35, + 'quality_max': 70, + 'encryption': { + 'enabled': true, + 'description': 'WPA3 SAE (CCMP)', + 'wep': false, + 'wpa': 3, + 'auth_suites': ['SAE'], + 'pair_ciphers': ['CCMP'], + 'group_ciphers': ['CCMP'], + }, + }, + { + 'ssid': 'NETGEAR-Guest', + 'bssid': 'CC:DD:EE:11:22:33', + 'mode': 'Master', + 'channel': 44, + 'signal': -78, + 'quality': 22, + 'quality_max': 70, + 'encryption': { + 'enabled': true, + 'description': 'WPA2 PSK (CCMP)', + 'wep': false, + 'wpa': 2, + 'auth_suites': ['PSK'], + 'pair_ciphers': ['CCMP'], + 'group_ciphers': ['CCMP'], + }, + }, + { + 'ssid': '', + 'bssid': 'FF:00:11:22:33:44', + 'mode': 'Master', + 'channel': 3, + 'signal': -82, + 'quality': 15, + 'quality_max': 70, + 'encryption': { + 'enabled': true, + 'description': 'WPA2 PSK (CCMP)', + 'wep': false, + 'wpa': 2, + 'auth_suites': ['PSK'], + 'pair_ciphers': ['CCMP'], + 'group_ciphers': ['CCMP'], + }, + }, + ]; + } + + @override + Future uciAdd( + String ipAddress, + String sysauth, + bool useHttps, { + required String config, + required String type, + required Map values, + BuildContext? context, + }) async { + await Future.delayed(const Duration(milliseconds: 300)); + return [0, 'cfg_new_section']; + } + + @override + Future uciDelete( + String ipAddress, + String sysauth, + bool useHttps, { + required String config, + required String section, + BuildContext? context, + }) async { + await Future.delayed(const Duration(milliseconds: 200)); + return [0, 'success']; + } + + @override + Future uciGetAll( + String ipAddress, + String sysauth, + bool useHttps, { + required String config, + BuildContext? context, + }) async { + await Future.delayed(const Duration(milliseconds: 200)); + // Just reuse the call method for uci.get + return await call( + ipAddress, + sysauth, + useHttps, + object: 'uci', + method: 'get', + params: {'config': config}, + context: context, + ); + } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index d8acf60..6c413bc 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -18,6 +18,7 @@ import 'package:luci_mobile/services/service_factory.dart'; import 'package:luci_mobile/config/app_config.dart'; import 'package:luci_mobile/utils/http_client_manager.dart'; import 'package:luci_mobile/utils/logger.dart'; +import 'package:luci_mobile/models/wifi_scan_result.dart'; class AppState extends ChangeNotifier { static AppState? _instance; @@ -1250,6 +1251,339 @@ class AppState extends ChangeNotifier { } } + /// Scans for nearby wireless networks on a given radio interface. + /// [device] is the wireless device name (e.g., 'wlan0', 'wlan1'). + Future> scanWirelessNetworks({ + required String device, + BuildContext? context, + }) async { + if (_reviewerModeEnabled) { + // Use mock scan results in reviewer mode + final mockResults = await _apiService!.scanWirelessNetworks( + ipAddress: 'mock', + sysauth: 'mock', + useHttps: false, + device: device, + context: context, + ); + return mockResults.map((r) => WifiScanResult.fromJson(r)).toList() + ..sort((a, b) => b.signal.compareTo(a.signal)); + } + + if (_authService?.sysauth == null || _authService?.ipAddress == null) { + return []; + } + + try { + final results = await _apiService!.scanWirelessNetworks( + ipAddress: _authService!.ipAddress!, + sysauth: _authService!.sysauth!, + useHttps: _authService!.useHttps, + device: device, + context: context, + ); + final scanResults = + results.map((r) => WifiScanResult.fromJson(r)).toList() + ..sort((a, b) => b.signal.compareTo(a.signal)); + return scanResults; + } catch (e, stack) { + Logger.exception('Failed to scan wireless networks', e, stack); + return []; + } + } + + /// Returns a list of available wireless radio devices (e.g., wlan0, wlan1) + /// from the current dashboard data. + List> getAvailableRadioDevices() { + final wirelessData = + _dashboardData?['wireless'] as Map? ?? {}; + final devices = >[]; + + wirelessData.forEach((radioName, radioData) { + if (radioData is Map) { + final interfaces = radioData['interfaces'] as List?; + if (interfaces != null && interfaces.isNotEmpty) { + for (final iface in interfaces) { + final ifname = iface['ifname'] as String?; + if (ifname != null) { + final config = iface['config'] as Map? ?? {}; + final iwinfo = iface['iwinfo'] as Map? ?? {}; + devices.add({ + 'ifname': ifname, + 'radioName': radioName, + 'ssid': (iwinfo['ssid'] ?? config['ssid'] ?? radioName) + .toString(), + }); + } + } + } else { + // Radio exists but has no interfaces - still usable for scanning + devices.add({ + 'ifname': radioName, + 'radioName': radioName, + 'ssid': radioName, + }); + } + } + }); + return devices; + } + + /// Connects to a wireless network by creating a new wifi-iface in station mode. + /// + /// [radioDevice] is the radio to use (e.g., 'radio0'). + /// [ssid] is the network SSID to connect to. + /// [encryption] is the OpenWrt encryption type (e.g., 'psk2', 'sae', 'none'). + /// [password] is the network password (empty for open networks). + /// [networkName] is the UCI network name to bind to (defaults to 'wwan'). + Future connectToWirelessNetwork({ + required String radioDevice, + required String ssid, + required String encryption, + String password = '', + String networkName = 'wwan', + BuildContext? context, + }) async { + if (_reviewerModeEnabled) { + await Future.delayed(const Duration(seconds: 2)); + await fetchDashboardData(); + return true; + } + + if (_authService?.sysauth == null || _authService?.ipAddress == null) { + return false; + } + + try { + final ip = _authService!.ipAddress!; + final auth = _authService!.sysauth!; + final https = _authService!.useHttps; + + // Build the values for the new wifi-iface + final values = { + 'device': radioDevice, + 'network': networkName, + 'mode': 'sta', + 'ssid': ssid, + 'encryption': encryption, + }; + if (password.isNotEmpty) { + // WPA3/SAE uses 'key', WPA/WPA2 also uses 'key' + values['key'] = password; + } + + // 1. Add a new wifi-iface section + final addResult = await _apiService!.uciAdd( + ip, + auth, + https, + config: 'wireless', + type: 'wifi-iface', + values: values, + context: context, + ); + + Logger.info('UCI add result: $addResult'); + + // 2. Commit wireless configuration + await _apiService!.uciCommit( + ip, + auth, + https, + config: 'wireless', + context: context?.mounted == true ? context : null, + ); + + // 3. Reload wifi to apply changes + await _apiService!.systemExec( + ip, + auth, + https, + command: 'wifi reload', + context: context?.mounted == true ? context : null, + ); + + // Wait a moment for the wifi to apply, then refresh + await Future.delayed(const Duration(seconds: 3)); + await fetchDashboardData(); + + return true; + } catch (e, stack) { + Logger.exception('Failed to connect to wireless network', e, stack); + _dashboardError = 'Failed to connect to $ssid: $e'; + notifyListeners(); + return false; + } + } + + /// Enables or disables a specific wifi-iface UCI section. + /// + /// [uciSection] is the UCI section name (e.g., 'default_radio0', 'wifinet0'). + /// [enabled] true to enable, false to disable. + Future setWirelessInterfaceEnabled( + String uciSection, + bool enabled, { + BuildContext? context, + }) async { + if (_reviewerModeEnabled) { + await Future.delayed(const Duration(milliseconds: 500)); + await fetchDashboardData(); + return true; + } + + if (_authService?.sysauth == null || _authService?.ipAddress == null) { + return false; + } + + try { + await _apiService!.uciSet( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + config: 'wireless', + section: uciSection, + values: {'disabled': enabled ? '0' : '1'}, + context: context, + ); + + await _apiService!.uciCommit( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + config: 'wireless', + context: context?.mounted == true ? context : null, + ); + + await _apiService!.systemExec( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + command: 'wifi reload', + context: context?.mounted == true ? context : null, + ); + + await Future.delayed(const Duration(seconds: 2)); + await fetchDashboardData(); + return true; + } catch (e, stack) { + Logger.exception('Failed to toggle wireless interface', e, stack); + _dashboardError = 'Failed to toggle interface: $e'; + notifyListeners(); + return false; + } + } + + /// Modifies properties of an existing wifi-iface UCI section. + /// + /// [uciSection] is the UCI section name (e.g., 'default_radio0'). + /// [values] is a map of UCI option key-value pairs to set. + Future modifyWirelessInterface( + String uciSection, + Map values, { + BuildContext? context, + }) async { + if (_reviewerModeEnabled) { + await Future.delayed(const Duration(seconds: 1)); + await fetchDashboardData(); + return true; + } + + if (_authService?.sysauth == null || _authService?.ipAddress == null) { + return false; + } + + try { + await _apiService!.uciSet( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + config: 'wireless', + section: uciSection, + values: values, + context: context, + ); + + await _apiService!.uciCommit( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + config: 'wireless', + context: context?.mounted == true ? context : null, + ); + + await _apiService!.systemExec( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + command: 'wifi reload', + context: context?.mounted == true ? context : null, + ); + + await Future.delayed(const Duration(seconds: 2)); + await fetchDashboardData(); + return true; + } catch (e, stack) { + Logger.exception('Failed to modify wireless interface', e, stack); + _dashboardError = 'Failed to modify interface: $e'; + notifyListeners(); + return false; + } + } + + /// Deletes a wifi-iface UCI section and reloads wifi. + /// + /// [uciSection] is the UCI section name to remove. + Future deleteWirelessInterface( + String uciSection, { + BuildContext? context, + }) async { + if (_reviewerModeEnabled) { + await Future.delayed(const Duration(milliseconds: 500)); + await fetchDashboardData(); + return true; + } + + if (_authService?.sysauth == null || _authService?.ipAddress == null) { + return false; + } + + try { + await _apiService!.uciDelete( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + config: 'wireless', + section: uciSection, + context: context, + ); + + await _apiService!.uciCommit( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + config: 'wireless', + context: context?.mounted == true ? context : null, + ); + + await _apiService!.systemExec( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + command: 'wifi reload', + context: context?.mounted == true ? context : null, + ); + + await Future.delayed(const Duration(seconds: 2)); + await fetchDashboardData(); + return true; + } catch (e, stack) { + Logger.exception('Failed to delete wireless interface', e, stack); + _dashboardError = 'Failed to delete interface: $e'; + notifyListeners(); + return false; + } + } + Future tryAutoLogin({BuildContext? context}) async { if (_reviewerModeEnabled) { return await _authService!.tryAutoLogin( diff --git a/pubspec.lock b/pubspec.lock index 1ca97ce..45464ce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.11.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.1" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -85,18 +85,18 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.3" fl_chart: dependency: "direct main" description: name: fl_chart - sha256: "577aeac8ca414c25333334d7c4bb246775234c0e44b38b10a82b559dd4d764e7" + sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -188,10 +188,10 @@ packages: dependency: transitive description: name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" url: "https://pub.dev" source: hosted - version: "4.1.2" + version: "4.0.2" js: dependency: transitive description: @@ -204,10 +204,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: @@ -228,34 +228,34 @@ packages: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -300,10 +300,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" url: "https://pub.dev" source: hosted - version: "2.2.17" + version: "2.2.15" path_provider_foundation: dependency: transitive description: @@ -369,10 +369,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.0" stack_trace: dependency: transitive description: @@ -401,26 +401,26 @@ packages: dependency: transitive description: name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.2.0" term_glyph: dependency: transitive description: name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "1.2.1" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.9" typed_data: dependency: transitive description: @@ -441,10 +441,10 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79" + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" url: "https://pub.dev" source: hosted - version: "6.3.16" + version: "6.3.14" url_launcher_ios: dependency: transitive description: @@ -481,10 +481,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "772638d3b34c779ede05ba3d38af34657a05ac55b06279ea6edd409e323dca8e" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.3.3" url_launcher_windows: dependency: transitive description: @@ -505,10 +505,10 @@ packages: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "14.2.5" web: dependency: transitive description: @@ -521,10 +521,10 @@ packages: dependency: transitive description: name: win32 - sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e url: "https://pub.dev" source: hosted - version: "5.14.0" + version: "5.10.1" xdg_directories: dependency: transitive description: @@ -534,5 +534,5 @@ packages: source: hosted version: "1.1.0" sdks: - dart: ">=3.8.1 <4.0.0" + dart: ">=3.9.0-0 <4.0.0" flutter: ">=3.27.4" From bcd83f6e07ab76d758ce1e632050d6d641e952e5 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 03:54:58 +0200 Subject: [PATCH 02/28] Update api_service.dart --- lib/services/api_service.dart | 54 +++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 81de466..8743587 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -295,6 +295,8 @@ class RealApiService implements IApiService { required String method, Map? params, BuildContext? context, + Duration? receiveTimeout, + CancelToken? cancelToken, }) async { final url = _buildUrl(ipAddress, useHttps, '/cgi-bin/luci/admin/ubus'); final client = _createHttpClient(useHttps, ipAddress, context: context); @@ -310,8 +312,10 @@ class RealApiService implements IApiService { final response = await client.post( url.toString(), data: jsonEncode(rpcPayload), + cancelToken: cancelToken, options: Options( headers: {'Content-Type': 'application/json'}, + receiveTimeout: receiveTimeout, ), ); @@ -657,17 +661,49 @@ class RealApiService implements IApiService { required String command, BuildContext? context, }) async { + // OpenWrt ubus uses 'file' object with 'exec' method, not 'system.exec'. + // file.exec takes {"command": "/path/to/bin", "params": ["arg1", "arg2"]} + final parts = command.trim().split(RegExp(r'\s+')); + String execCommand = parts.first; + final execParams = parts.length > 1 ? parts.sublist(1) : []; + + // Resolve common commands to their full paths + const commandPaths = { + 'wifi': '/sbin/wifi', + 'reboot': '/sbin/reboot', + 'ifup': '/sbin/ifup', + 'ifdown': '/sbin/ifdown', + 'service': '/sbin/service', + 'uci': '/sbin/uci', + }; + if (!execCommand.startsWith('/')) { + execCommand = commandPaths[execCommand] ?? '/usr/bin/$execCommand'; + } + return await callWithContext( ipAddress, sysauth, useHttps, - object: 'system', + object: 'file', method: 'exec', - params: {'command': command}, + params: { + 'command': execCommand, + 'params': execParams, + }, context: context, ); } + // Cancel token for ongoing scan operations + CancelToken? _scanCancelToken; + + /// Cancel any ongoing wireless scan + @override + void cancelScan() { + _scanCancelToken?.cancel('Scan cancelled by user'); + _scanCancelToken = null; + } + @override Future>> scanWirelessNetworks({ required String ipAddress, @@ -676,6 +712,10 @@ class RealApiService implements IApiService { required String device, BuildContext? context, }) async { + // Cancel any previous scan + cancelScan(); + _scanCancelToken = CancelToken(); + try { final result = await callWithContext( ipAddress, @@ -685,7 +725,11 @@ class RealApiService implements IApiService { method: 'scan', params: {'device': device}, context: context, + // iwinfo scan can take 15-30+ seconds on real hardware + receiveTimeout: const Duration(seconds: 120), + cancelToken: _scanCancelToken, ); + _scanCancelToken = null; // Handle LuCI RPC format: [status, data] if (result is List && result.length > 1 && result[0] == 0) { final data = result[1]; @@ -696,6 +740,12 @@ class RealApiService implements IApiService { } } return []; + } on DioException catch (e) { + if (e.type == DioExceptionType.cancel) { + return []; // User cancelled + } + Logger.exception('Failed to scan wireless networks', e, e.stackTrace); + return []; } catch (e, stack) { Logger.exception('Failed to scan wireless networks', e, stack); return []; From da62cc6f0b261bb468e06d947969f0db701f3962 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 03:56:18 +0200 Subject: [PATCH 03/28] Update api_service_interface.dart --- lib/services/interfaces/api_service_interface.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/services/interfaces/api_service_interface.dart b/lib/services/interfaces/api_service_interface.dart index 62e2a60..5110855 100644 --- a/lib/services/interfaces/api_service_interface.dart +++ b/lib/services/interfaces/api_service_interface.dart @@ -92,6 +92,9 @@ abstract class IApiService { BuildContext? context, }); + /// Cancel any ongoing wireless network scan. + void cancelScan() {} + /// Adds a new wifi-iface section via UCI to connect to a network as a station. Future uciAdd( String ipAddress, From f2a9e73b3ad76c1f4aedf9603d4532d400f40858 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 03:57:07 +0200 Subject: [PATCH 04/28] Update app_state.dart --- lib/state/app_state.dart | 171 ++++++++++++++++++++++++++------------- 1 file changed, 114 insertions(+), 57 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 6c413bc..17ddd1a 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1194,6 +1194,37 @@ class AppState extends ChangeNotifier { ); } + /// Helper: runs wifi reload and refreshes dashboard. + /// Never throws — wifi reload failures are logged but don't fail the operation + /// since UCI changes are already committed at this point. + Future _wifiReload({ + BuildContext? context, + String? radioName, + int delaySeconds = 4, + }) async { + try { + final command = + radioName != null ? 'wifi reload $radioName' : 'wifi reload'; + await _apiService!.systemExec( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + command: command, + context: context?.mounted == true ? context : null, + ); + } catch (e) { + Logger.warning('wifi reload failed (changes still committed via UCI): $e'); + } + + await Future.delayed(Duration(seconds: delaySeconds)); + + try { + await fetchDashboardData(); + } catch (_) { + // Dashboard refresh may fail while wifi is restarting — that's OK + } + } + Future setWirelessRadioState( String device, bool enabled, { @@ -1232,16 +1263,7 @@ class AppState extends ChangeNotifier { ); // 3. Reload wifi to apply changes - await _apiService!.systemExec( - _authService!.ipAddress!, - _authService!.sysauth!, - _authService!.useHttps, - command: 'wifi reload', - context: context?.mounted == true ? context : null, - ); - - // Refresh dashboard data to reflect the change - await fetchDashboardData(); + await _wifiReload(context: context); return true; } catch (e) { @@ -1251,6 +1273,11 @@ class AppState extends ChangeNotifier { } } + /// Cancel any ongoing wireless network scan. + void cancelWirelessScan() { + _apiService?.cancelScan(); + } + /// Scans for nearby wireless networks on a given radio interface. /// [device] is the wireless device name (e.g., 'wlan0', 'wlan1'). Future> scanWirelessNetworks({ @@ -1302,26 +1329,52 @@ class AppState extends ChangeNotifier { wirelessData.forEach((radioName, radioData) { if (radioData is Map) { final interfaces = radioData['interfaces'] as List?; + + // Determine band from frequency/channel + final freq = radioData['frequency']; + final channel = radioData['channel']; + String band = ''; + if (freq is int) { + band = freq >= 5000 ? '5 GHz' : freq >= 4000 ? '4 GHz' : '2.4 GHz'; + } else if (channel is int) { + band = channel >= 36 ? '5 GHz' : '2.4 GHz'; + } + if (interfaces != null && interfaces.isNotEmpty) { + // Find the best interface for scanning: + // Prefer an AP interface, fall back to any active interface + String? bestIfname; + String bestSsid = radioName; for (final iface in interfaces) { final ifname = iface['ifname'] as String?; - if (ifname != null) { - final config = iface['config'] as Map? ?? {}; - final iwinfo = iface['iwinfo'] as Map? ?? {}; - devices.add({ - 'ifname': ifname, - 'radioName': radioName, - 'ssid': (iwinfo['ssid'] ?? config['ssid'] ?? radioName) - .toString(), - }); + if (ifname == null) continue; + final config = iface['config'] as Map? ?? {}; + final iwinfo = iface['iwinfo'] as Map? ?? {}; + final mode = config['mode']?.toString() ?? ''; + final ssid = + (iwinfo['ssid'] ?? config['ssid'] ?? '').toString(); + + if (bestIfname == null || mode == 'ap') { + bestIfname = ifname; + if (ssid.isNotEmpty) bestSsid = ssid; } + // If we found an AP interface, stop looking + if (mode == 'ap') break; } + + devices.add({ + 'ifname': bestIfname ?? radioName, + 'radioName': radioName, + 'ssid': bestSsid, + 'band': band, + }); } else { // Radio exists but has no interfaces - still usable for scanning devices.add({ 'ifname': radioName, 'radioName': radioName, 'ssid': radioName, + 'band': band, }); } } @@ -1329,6 +1382,44 @@ class AppState extends ChangeNotifier { return devices; } + /// Restarts a wireless radio by running `wifi reload`. + Future restartWirelessRadio( + String radioName, { + BuildContext? context, + }) async { + if (_reviewerModeEnabled) { + await Future.delayed(const Duration(seconds: 2)); + await fetchDashboardData(); + return true; + } + + if (_authService?.sysauth == null || _authService?.ipAddress == null) { + return false; + } + + try { + await _apiService!.systemExec( + _authService!.ipAddress!, + _authService!.sysauth!, + _authService!.useHttps, + command: 'wifi reload $radioName', + context: context, + ); + + await Future.delayed(const Duration(seconds: 5)); + + try { + await fetchDashboardData(); + } catch (_) {} + return true; + } catch (e, stack) { + Logger.exception('Failed to restart radio $radioName', e, stack); + _dashboardError = 'Failed to restart radio: $e'; + notifyListeners(); + return false; + } + } + /// Connects to a wireless network by creating a new wifi-iface in station mode. /// /// [radioDevice] is the radio to use (e.g., 'radio0'). @@ -1395,17 +1486,7 @@ class AppState extends ChangeNotifier { ); // 3. Reload wifi to apply changes - await _apiService!.systemExec( - ip, - auth, - https, - command: 'wifi reload', - context: context?.mounted == true ? context : null, - ); - - // Wait a moment for the wifi to apply, then refresh - await Future.delayed(const Duration(seconds: 3)); - await fetchDashboardData(); + await _wifiReload(context: context); return true; } catch (e, stack) { @@ -1454,16 +1535,8 @@ class AppState extends ChangeNotifier { context: context?.mounted == true ? context : null, ); - await _apiService!.systemExec( - _authService!.ipAddress!, - _authService!.sysauth!, - _authService!.useHttps, - command: 'wifi reload', - context: context?.mounted == true ? context : null, - ); + await _wifiReload(context: context); - await Future.delayed(const Duration(seconds: 2)); - await fetchDashboardData(); return true; } catch (e, stack) { Logger.exception('Failed to toggle wireless interface', e, stack); @@ -1511,16 +1584,8 @@ class AppState extends ChangeNotifier { context: context?.mounted == true ? context : null, ); - await _apiService!.systemExec( - _authService!.ipAddress!, - _authService!.sysauth!, - _authService!.useHttps, - command: 'wifi reload', - context: context?.mounted == true ? context : null, - ); + await _wifiReload(context: context); - await Future.delayed(const Duration(seconds: 2)); - await fetchDashboardData(); return true; } catch (e, stack) { Logger.exception('Failed to modify wireless interface', e, stack); @@ -1565,16 +1630,8 @@ class AppState extends ChangeNotifier { context: context?.mounted == true ? context : null, ); - await _apiService!.systemExec( - _authService!.ipAddress!, - _authService!.sysauth!, - _authService!.useHttps, - command: 'wifi reload', - context: context?.mounted == true ? context : null, - ); + await _wifiReload(context: context); - await Future.delayed(const Duration(seconds: 2)); - await fetchDashboardData(); return true; } catch (e, stack) { Logger.exception('Failed to delete wireless interface', e, stack); From 56cbc7f409997aac4b76d50d762134074e001bc1 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 03:57:54 +0200 Subject: [PATCH 05/28] Update wifi_scan_screen.dart --- lib/screens/wifi_scan_screen.dart | 315 ++++++++++++++++++++++-------- 1 file changed, 231 insertions(+), 84 deletions(-) diff --git a/lib/screens/wifi_scan_screen.dart b/lib/screens/wifi_scan_screen.dart index a3e430f..e579ff5 100644 --- a/lib/screens/wifi_scan_screen.dart +++ b/lib/screens/wifi_scan_screen.dart @@ -15,8 +15,9 @@ class _WifiScanScreenState extends ConsumerState with SingleTickerProviderStateMixin { List _scanResults = []; bool _isScanning = false; + bool _isRestarting = false; String? _error; - String? _selectedDevice; + String? _selectedRadio; // radioName key (e.g., 'radio0') List> _radioDevices = []; late AnimationController _pulseController; @@ -44,13 +45,24 @@ class _WifiScanScreenState extends ConsumerState setState(() { _radioDevices = devices; if (devices.isNotEmpty) { - _selectedDevice = devices.first['ifname']; + _selectedRadio = devices.first['radioName']; } }); } + /// Get the interface name to use for scanning based on selected radio + String? get _selectedIfname { + if (_selectedRadio == null) return null; + final device = _radioDevices.firstWhere( + (d) => d['radioName'] == _selectedRadio, + orElse: () => {}, + ); + return device['ifname']; + } + Future _startScan() async { - if (_selectedDevice == null) return; + final ifname = _selectedIfname; + if (ifname == null) return; setState(() { _isScanning = true; @@ -62,7 +74,7 @@ class _WifiScanScreenState extends ConsumerState try { final appState = ref.read(appStateProvider); final results = await appState.scanWirelessNetworks( - device: _selectedDevice!, + device: ifname, context: context, ); @@ -81,10 +93,77 @@ class _WifiScanScreenState extends ConsumerState _error = 'Scan failed: $e'; }); } + if (mounted) { + _pulseController.stop(); + _pulseController.reset(); + } + } + + void _stopScan() { + final appState = ref.read(appStateProvider); + appState.cancelWirelessScan(); + setState(() { + _isScanning = false; + }); _pulseController.stop(); _pulseController.reset(); } + Future _restartRadio() async { + if (_selectedRadio == null || _isRestarting) return; + + setState(() { + _isRestarting = true; + _error = null; + }); + + try { + final appState = ref.read(appStateProvider); + final success = await appState.restartWirelessRadio( + _selectedRadio!, + context: context, + ); + + if (!mounted) return; + setState(() => _isRestarting = false); + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white, size: 20), + const SizedBox(width: 8), + Text('$_selectedRadio restarted'), + ], + ), + backgroundColor: Colors.green.shade700, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: const Duration(seconds: 3), + ), + ); + _loadRadioDevices(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Failed to restart radio'), + backgroundColor: Theme.of(context).colorScheme.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } catch (e) { + if (!mounted) return; + setState(() { + _isRestarting = false; + _error = 'Restart failed: $e'; + }); + } + } + void _showConnectDialog(WifiScanResult network) { showModalBottomSheet( context: context, @@ -96,7 +175,7 @@ class _WifiScanScreenState extends ConsumerState builder: (context) => _ConnectBottomSheet( network: network, radioDevices: _radioDevices, - selectedDevice: _selectedDevice, + selectedDevice: _selectedRadio, ), ); } @@ -148,89 +227,154 @@ class _WifiScanScreenState extends ConsumerState ), ], ), - child: Row( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - // Radio device selector - Expanded( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.3), - ), - color: colorScheme.surfaceContainerLow, + // Radio device selector (full width) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedDevice, - isExpanded: true, - icon: Icon( - Icons.arrow_drop_down, + color: colorScheme.surfaceContainerLow, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedRadio, + isExpanded: true, + icon: Icon( + Icons.arrow_drop_down, + color: colorScheme.onSurfaceVariant, + ), + hint: Text( + 'Select radio', + style: theme.textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), - hint: Text( - 'Select radio', - style: theme.textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, + ), + items: _radioDevices.map((device) { + final radioName = device['radioName'] ?? ''; + final ssid = device['ssid'] ?? ''; + final band = device['band'] ?? ''; + final label = band.isNotEmpty + ? '$radioName ($band)' + : radioName; + final subtitle = ssid != radioName && ssid.isNotEmpty + ? ssid + : null; + return DropdownMenuItem( + value: radioName, + child: Row( + children: [ + Icon( + Icons.router, + size: 18, + color: colorScheme.primary, + ), + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + if (subtitle != null) + Text( + subtitle, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], ), - ), - items: _radioDevices.map((device) { - return DropdownMenuItem( - value: device['ifname'], - child: Row( - children: [ - Icon( - Icons.router, - size: 18, - color: colorScheme.primary, + ); + }).toList(), + onChanged: (_isScanning || _isRestarting) + ? null + : (value) { + setState(() { + _selectedRadio = value; + }); + }, + ), + ), + ), + const SizedBox(height: 10), + // Scan + Restart Radio buttons row + Row( + children: [ + // Scan / Stop button + Expanded( + child: _isScanning + ? OutlinedButton.icon( + onPressed: _stopScan, + icon: const Icon(Icons.stop, size: 20), + label: const Text('Stop Scan'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + foregroundColor: colorScheme.error, + side: BorderSide( + color: colorScheme.error.withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - const SizedBox(width: 8), - Flexible( - child: Text( - '${device['ifname']} (${device['ssid']})', - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium, - ), + ), + ) + : FilledButton.icon( + onPressed: _isRestarting || _selectedRadio == null + ? null + : _startScan, + icon: const Icon(Icons.radar, size: 20), + label: const Text('Scan Networks'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - ], + ), ), - ); - }).toList(), - onChanged: _isScanning - ? null - : (value) { - setState(() { - _selectedDevice = value; - }); - }, - ), ), - ), - ), - const SizedBox(width: 12), - // Scan button - FilledButton.icon( - onPressed: _isScanning || _selectedDevice == null - ? null - : _startScan, - icon: _isScanning - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.onPrimary, + const SizedBox(width: 10), + // Restart Radio button + Expanded( + child: OutlinedButton.icon( + onPressed: _isScanning || _isRestarting || _selectedRadio == null + ? null + : _restartRadio, + icon: _isRestarting + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ) + : const Icon(Icons.restart_alt, size: 20), + label: Text(_isRestarting ? 'Restarting...' : 'Restart Radio'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + side: BorderSide( + color: colorScheme.primary.withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - ) - : const Icon(Icons.radar, size: 20), - label: Text(_isScanning ? 'Scanning' : 'Scan'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + ), + ), ), - ), + ], ), ], ), @@ -653,12 +797,10 @@ class _ConnectBottomSheetState extends ConsumerState<_ConnectBottomSheet> { super.initState(); // Default to the radio that was used for scanning if (widget.selectedDevice != null && widget.radioDevices.isNotEmpty) { - // Find the radioName for the selected device - final matchingDevice = widget.radioDevices.firstWhere( - (d) => d['ifname'] == widget.selectedDevice, - orElse: () => widget.radioDevices.first, - ); - _selectedRadio = matchingDevice['radioName']; + // selectedDevice is now a radioName directly + _selectedRadio = widget.selectedDevice; + } else if (widget.radioDevices.isNotEmpty) { + _selectedRadio = widget.radioDevices.first['radioName']; } } @@ -871,10 +1013,15 @@ class _ConnectBottomSheetState extends ConsumerState<_ConnectBottomSheet> { color: colorScheme.onSurfaceVariant, ), items: widget.radioDevices.map((device) { + final radioName = device['radioName'] ?? ''; + final band = device['band'] ?? ''; + final label = band.isNotEmpty + ? '$radioName ($band)' + : radioName; return DropdownMenuItem( value: device['radioName'], child: Text( - '${device['radioName']} (${device['ifname']})', + label, style: theme.textTheme.bodyMedium, ), ); From 86fff5a27c884a2b9d397633e1dd4878bc193c20 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 03:58:11 +0200 Subject: [PATCH 06/28] Update interfaces_screen.dart --- lib/screens/interfaces_screen.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/screens/interfaces_screen.dart b/lib/screens/interfaces_screen.dart index cd34f28..87bb553 100644 --- a/lib/screens/interfaces_screen.dart +++ b/lib/screens/interfaces_screen.dart @@ -421,8 +421,8 @@ class _InterfacesScreenState extends ConsumerState { ), ); }, - icon: Icon(Icons.radar, size: 16), - label: Text('Scan'), + icon: Icon(Icons.cell_tower, size: 16), + label: Text('Radio'), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 12, From 113cdefdd0880dad872ed58159d50241cd55d8ac Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:42:59 +0200 Subject: [PATCH 07/28] Update api_service.dart --- lib/services/api_service.dart | 77 ++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 8743587..39cd9a3 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -716,6 +716,8 @@ class RealApiService implements IApiService { cancelScan(); _scanCancelToken = CancelToken(); + Logger.info('WiFi scan starting on device: $device'); + try { final result = await callWithContext( ipAddress, @@ -730,25 +732,80 @@ class RealApiService implements IApiService { cancelToken: _scanCancelToken, ); _scanCancelToken = null; - // Handle LuCI RPC format: [status, data] - if (result is List && result.length > 1 && result[0] == 0) { + + Logger.info('WiFi scan raw result type: ${result.runtimeType}'); + + // Parse result — handle multiple response formats + if (result is List) { + final statusCode = result.isNotEmpty ? result[0] : null; + + // Check for ubus error codes + if (statusCode != null && statusCode != 0) { + const ubusErrors = { + 1: 'Invalid command', + 2: 'Invalid argument', + 3: 'Method not found', + 4: 'Not found', + 5: 'No data', + 6: 'Permission denied', + 7: 'Request timed out', + }; + final errMsg = ubusErrors[statusCode] ?? 'Unknown error'; + throw Exception( + 'iwinfo scan failed: $errMsg (code $statusCode) on device "$device"'); + } + + if (result.length < 2 || result[1] == null) { + // Success but no data — genuinely no networks found + return []; + } + final data = result[1]; - if (data is Map && data['results'] is List) { - return (data['results'] as List) + + // Format 1: {"results": [{...}, {...}]} — standard iwinfo response + if (data is Map) { + if (data['results'] is List) { + return (data['results'] as List) + .whereType>() + .toList(); + } + // Format 2: Map but no 'results' key — try all list values + for (final value in data.values) { + if (value is List && value.isNotEmpty && value.first is Map) { + return value + .whereType>() + .toList(); + } + } + Logger.warning( + 'WiFi scan: response is Map but no results. Keys: ${data.keys.toList()}'); + return []; + } + + // Format 3: [{...}, {...}] — results directly as array + if (data is List) { + return data .whereType>() .toList(); } + + Logger.warning( + 'WiFi scan: unexpected data type: ${data.runtimeType}'); + return []; } + + Logger.warning('WiFi scan: result is not List: ${result.runtimeType}'); return []; } on DioException catch (e) { if (e.type == DioExceptionType.cancel) { - return []; // User cancelled + return []; // User cancelled — not an error } - Logger.exception('Failed to scan wireless networks', e, e.stackTrace); - return []; - } catch (e, stack) { - Logger.exception('Failed to scan wireless networks', e, stack); - return []; + if (e.type == DioExceptionType.receiveTimeout) { + throw Exception( + 'Scan timed out on "$device". The radio may be busy.'); + } + Logger.exception('WiFi scan DioException', e, e.stackTrace); + rethrow; } } From cb69914e63c1970f49b508564729c66789e15d3f Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:43:39 +0200 Subject: [PATCH 08/28] Update api_service_interface.dart From 5839fe2aad13113917839d23bdb49dde385a691c Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:44:20 +0200 Subject: [PATCH 09/28] Update app_state.dart --- lib/state/app_state.dart | 43 ++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 17ddd1a..9f5a258 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1279,7 +1279,7 @@ class AppState extends ChangeNotifier { } /// Scans for nearby wireless networks on a given radio interface. - /// [device] is the wireless device name (e.g., 'wlan0', 'wlan1'). + /// [device] is the wireless device name (e.g., 'wlan0', 'phy0-ap0'). Future> scanWirelessNetworks({ required String device, BuildContext? context, @@ -1298,7 +1298,7 @@ class AppState extends ChangeNotifier { } if (_authService?.sysauth == null || _authService?.ipAddress == null) { - return []; + throw Exception('Not authenticated'); } try { @@ -1309,13 +1309,34 @@ class AppState extends ChangeNotifier { device: device, context: context, ); + + if (results.isEmpty) { + // Try with phy name (strip -ap0, -sta0 suffix) as fallback + final phyMatch = RegExp(r'^(phy\d+)-').firstMatch(device); + if (phyMatch != null) { + final phyName = phyMatch.group(1)!; + Logger.info('Scan returned empty on $device, retrying with $phyName'); + final retryResults = await _apiService!.scanWirelessNetworks( + ipAddress: _authService!.ipAddress!, + sysauth: _authService!.sysauth!, + useHttps: _authService!.useHttps, + device: phyName, + context: context, + ); + if (retryResults.isNotEmpty) { + return retryResults.map((r) => WifiScanResult.fromJson(r)).toList() + ..sort((a, b) => b.signal.compareTo(a.signal)); + } + } + } + final scanResults = results.map((r) => WifiScanResult.fromJson(r)).toList() ..sort((a, b) => b.signal.compareTo(a.signal)); return scanResults; } catch (e, stack) { Logger.exception('Failed to scan wireless networks', e, stack); - return []; + rethrow; // Let the UI show the actual error } } @@ -1517,6 +1538,8 @@ class AppState extends ChangeNotifier { } try { + Logger.info('Toggle interface $uciSection → ${enabled ? 'enabled' : 'disabled'}'); + await _apiService!.uciSet( _authService!.ipAddress!, _authService!.sysauth!, @@ -1526,6 +1549,7 @@ class AppState extends ChangeNotifier { values: {'disabled': enabled ? '0' : '1'}, context: context, ); + Logger.info('UCI set done'); await _apiService!.uciCommit( _authService!.ipAddress!, @@ -1534,16 +1558,19 @@ class AppState extends ChangeNotifier { config: 'wireless', context: context?.mounted == true ? context : null, ); - - await _wifiReload(context: context); - - return true; + Logger.info('UCI commit done'); } catch (e, stack) { - Logger.exception('Failed to toggle wireless interface', e, stack); + // UCI operations failed — actual error + Logger.exception('Failed to toggle wireless interface (UCI)', e, stack); _dashboardError = 'Failed to toggle interface: $e'; notifyListeners(); return false; } + + // UCI changes are committed — reload wifi in background (best-effort) + await _wifiReload(context: context); + Logger.info('Toggle interface $uciSection complete'); + return true; } /// Modifies properties of an existing wifi-iface UCI section. From 497a256f4d919dd4c6afb897ef330ed9f21a1fe1 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:44:39 +0200 Subject: [PATCH 10/28] Update wifi_scan_screen.dart From 26ffa2665e6aacab7695e07a14fcecab52e89c17 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:44:57 +0200 Subject: [PATCH 11/28] Update interfaces_screen.dart From abc2d52bf416b7c01020ea4dee780884a72024aa Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:32:18 +0200 Subject: [PATCH 12/28] Update wifi_scan_result.dart --- lib/models/wifi_scan_result.dart | 41 ++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/lib/models/wifi_scan_result.dart b/lib/models/wifi_scan_result.dart index 9afa625..ffdf42e 100644 --- a/lib/models/wifi_scan_result.dart +++ b/lib/models/wifi_scan_result.dart @@ -22,19 +22,36 @@ class WifiScanResult { factory WifiScanResult.fromJson(Map json) { return WifiScanResult( - ssid: json['ssid'] as String? ?? '', - bssid: json['bssid'] as String? ?? '', - mode: json['mode'] as String? ?? 'Unknown', - channel: json['channel'] as int? ?? 0, - signal: json['signal'] as int? ?? -100, - quality: json['quality'] as int? ?? 0, - qualityMax: json['quality_max'] as int? ?? 100, + ssid: _safeString(json['ssid'], ''), + bssid: _safeString(json['bssid'], ''), + mode: _safeString(json['mode'], 'Unknown'), + channel: _safeInt(json['channel'], 0), + signal: _safeInt(json['signal'], -100), + quality: _safeInt(json['quality'], 0), + qualityMax: _safeInt(json['quality_max'], 100), encryption: WifiEncryption.fromJson( - json['encryption'] as Map? ?? {}, + json['encryption'] is Map + ? json['encryption'] + : {}, ), ); } + /// Safely extract an int from a dynamic value (could be int, double, String, List, null). + static int _safeInt(dynamic value, int defaultValue) { + if (value is int) return value; + if (value is double) return value.round(); + if (value is String) return int.tryParse(value) ?? defaultValue; + return defaultValue; + } + + /// Safely extract a String from a dynamic value. + static String _safeString(dynamic value, String defaultValue) { + if (value is String) return value; + if (value == null) return defaultValue; + return value.toString(); + } + /// Signal quality as a percentage (0-100). int get qualityPercent { if (qualityMax <= 0) return 0; @@ -90,10 +107,10 @@ class WifiEncryption { factory WifiEncryption.fromJson(Map json) { return WifiEncryption( - enabled: json['enabled'] as bool? ?? false, - description: json['description'] as String? ?? 'None', - wep: json['wep'] as bool? ?? false, - wpa: json['wpa'] as int? ?? 0, + enabled: json['enabled'] == true, + description: WifiScanResult._safeString(json['description'], 'None'), + wep: json['wep'] == true, + wpa: WifiScanResult._safeInt(json['wpa'], 0), authSuites: _toStringList(json['auth_suites']), pairCiphers: _toStringList(json['pair_ciphers']), groupCiphers: _toStringList(json['group_ciphers']), From cd7f545a31d67e4cbfef4d16a9e8b16d54e5fbd6 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 12:32:35 +0200 Subject: [PATCH 13/28] Update app_state.dart --- lib/state/app_state.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 9f5a258..2af64f6 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1199,17 +1199,14 @@ class AppState extends ChangeNotifier { /// since UCI changes are already committed at this point. Future _wifiReload({ BuildContext? context, - String? radioName, int delaySeconds = 4, }) async { try { - final command = - radioName != null ? 'wifi reload $radioName' : 'wifi reload'; await _apiService!.systemExec( _authService!.ipAddress!, _authService!.sysauth!, _authService!.useHttps, - command: command, + command: 'wifi reload', context: context?.mounted == true ? context : null, ); } catch (e) { @@ -1403,7 +1400,7 @@ class AppState extends ChangeNotifier { return devices; } - /// Restarts a wireless radio by running `wifi reload`. + /// Restarts wireless by running `wifi reload`. Future restartWirelessRadio( String radioName, { BuildContext? context, @@ -1419,11 +1416,12 @@ class AppState extends ChangeNotifier { } try { + // Use 'wifi reload' (all radios) — per-radio syntax isn't supported on all versions await _apiService!.systemExec( _authService!.ipAddress!, _authService!.sysauth!, _authService!.useHttps, - command: 'wifi reload $radioName', + command: 'wifi reload', context: context, ); From ceffe29b2d5a27540351e3ac92f3563d4d99e9a2 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:57:05 +0200 Subject: [PATCH 14/28] Update app_state.dart --- lib/state/app_state.dart | 146 ++++++++++++++++++++++++++++----------- 1 file changed, 107 insertions(+), 39 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 2af64f6..c3225f0 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1194,31 +1194,83 @@ class AppState extends ChangeNotifier { ); } - /// Helper: runs wifi reload and refreshes dashboard. - /// Never throws — wifi reload failures are logged but don't fail the operation - /// since UCI changes are already committed at this point. - Future _wifiReload({ + /// Restarts a specific radio via UCI disable/enable cycle. + /// This is more reliable than `wifi reload` which doesn't work on all routers. + /// Never throws — failures are logged but don't fail the calling operation. + Future _restartRadioViaUci( + String radioName, { BuildContext? context, - int delaySeconds = 4, + int delaySeconds = 5, }) async { + final ip = _authService!.ipAddress!; + final auth = _authService!.sysauth!; + final https = _authService!.useHttps; + final safeCtx = context?.mounted == true ? context : null; + try { - await _apiService!.systemExec( - _authService!.ipAddress!, - _authService!.sysauth!, - _authService!.useHttps, - command: 'wifi reload', - context: context?.mounted == true ? context : null, + // Disable the radio + await _apiService!.uciSet( + ip, auth, https, + config: 'wireless', + section: radioName, + values: {'disabled': '1'}, + context: safeCtx, + ); + await _apiService!.uciCommit( + ip, auth, https, + config: 'wireless', + context: safeCtx, ); + + await Future.delayed(Duration(seconds: delaySeconds)); + + // Re-enable the radio + await _apiService!.uciSet( + ip, auth, https, + config: 'wireless', + section: radioName, + values: {'disabled': '0'}, + context: safeCtx, + ); + await _apiService!.uciCommit( + ip, auth, https, + config: 'wireless', + context: safeCtx, + ); + + await Future.delayed(Duration(seconds: delaySeconds)); } catch (e) { - Logger.warning('wifi reload failed (changes still committed via UCI): $e'); + Logger.warning('Radio restart via UCI failed for $radioName: $e'); } - await Future.delayed(Duration(seconds: delaySeconds)); - try { await fetchDashboardData(); - } catch (_) { - // Dashboard refresh may fail while wifi is restarting — that's OK + } catch (_) {} + } + + /// Helper: restarts all known radios via UCI disable/enable cycle. + /// Used after operations that need wifi to reload (toggle, modify, delete). + /// Never throws. + Future _wifiReload({ + BuildContext? context, + }) async { + // Get list of radios from dashboard data + final wirelessData = + _dashboardData?['wireless'] as Map? ?? {}; + final radios = wirelessData.keys.toList(); + + if (radios.isEmpty) { + Logger.warning('_wifiReload: no radios found in dashboard data'); + await Future.delayed(const Duration(seconds: 4)); + try { + await fetchDashboardData(); + } catch (_) {} + return; + } + + // Cycle all radios + for (final radio in radios) { + await _restartRadioViaUci(radio, context: context, delaySeconds: 3); } } @@ -1259,8 +1311,11 @@ class AppState extends ChangeNotifier { context: context?.mounted == true ? context : null, ); - // 3. Reload wifi to apply changes - await _wifiReload(context: context); + // 3. Wait and refresh — don't cycle radios as that would undo the toggle + await Future.delayed(const Duration(seconds: 4)); + try { + await fetchDashboardData(); + } catch (_) {} return true; } catch (e) { @@ -1400,7 +1455,7 @@ class AppState extends ChangeNotifier { return devices; } - /// Restarts wireless by running `wifi reload`. + /// Restarts a wireless radio via UCI disable/enable cycle. Future restartWirelessRadio( String radioName, { BuildContext? context, @@ -1416,20 +1471,8 @@ class AppState extends ChangeNotifier { } try { - // Use 'wifi reload' (all radios) — per-radio syntax isn't supported on all versions - await _apiService!.systemExec( - _authService!.ipAddress!, - _authService!.sysauth!, - _authService!.useHttps, - command: 'wifi reload', - context: context, - ); - - await Future.delayed(const Duration(seconds: 5)); - - try { - await fetchDashboardData(); - } catch (_) {} + Logger.info('Restarting radio $radioName via UCI cycle'); + await _restartRadioViaUci(radioName, context: context); return true; } catch (e, stack) { Logger.exception('Failed to restart radio $radioName', e, stack); @@ -1469,6 +1512,28 @@ class AppState extends ChangeNotifier { final auth = _authService!.sysauth!; final https = _authService!.useHttps; + // Find next available wifinet# name to avoid anonymous sections + String sectionName = 'wifinet0'; + try { + final uciResult = await _apiService!.uciGetAll( + ip, auth, https, + config: 'wireless', + context: context, + ); + if (uciResult is List && uciResult.length > 1 && uciResult[1] is Map) { + final sections = (uciResult[1] as Map).keys.toSet(); + int idx = 0; + while (sections.contains('wifinet$idx')) { + idx++; + } + sectionName = 'wifinet$idx'; + } + } catch (e) { + Logger.warning('Could not query existing sections, using $sectionName: $e'); + } + + Logger.info('Creating named wifi-iface section: $sectionName'); + // Build the values for the new wifi-iface final values = { 'device': radioDevice, @@ -1478,17 +1543,17 @@ class AppState extends ChangeNotifier { 'encryption': encryption, }; if (password.isNotEmpty) { - // WPA3/SAE uses 'key', WPA/WPA2 also uses 'key' values['key'] = password; } - // 1. Add a new wifi-iface section + // 1. Add a named wifi-iface section final addResult = await _apiService!.uciAdd( ip, auth, https, config: 'wireless', type: 'wifi-iface', + name: sectionName, values: values, context: context, ); @@ -1504,8 +1569,8 @@ class AppState extends ChangeNotifier { context: context?.mounted == true ? context : null, ); - // 3. Reload wifi to apply changes - await _wifiReload(context: context); + // 3. Restart radio to apply changes + await _restartRadioViaUci(radioDevice, context: context); return true; } catch (e, stack) { @@ -1565,8 +1630,11 @@ class AppState extends ChangeNotifier { return false; } - // UCI changes are committed — reload wifi in background (best-effort) - await _wifiReload(context: context); + // UCI changes are committed — wait and refresh (don't cycle radios for toggle) + await Future.delayed(const Duration(seconds: 4)); + try { + await fetchDashboardData(); + } catch (_) {} Logger.info('Toggle interface $uciSection complete'); return true; } From 2269b7829637dfdc08b4ea195dae7046cfd71fc8 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:57:29 +0200 Subject: [PATCH 15/28] Update wifi_scan_result.dart --- lib/models/wifi_scan_result.dart | 72 ++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/lib/models/wifi_scan_result.dart b/lib/models/wifi_scan_result.dart index ffdf42e..64f2ab3 100644 --- a/lib/models/wifi_scan_result.dart +++ b/lib/models/wifi_scan_result.dart @@ -106,14 +106,72 @@ class WifiEncryption { }); factory WifiEncryption.fromJson(Map json) { + // 'wpa' can be int (e.g., 2) or List (e.g., [2] or [1,2]) + int wpaVersion = 0; + final rawWpa = json['wpa']; + if (rawWpa is int) { + wpaVersion = rawWpa; + } else if (rawWpa is List && rawWpa.isNotEmpty) { + // Take the highest WPA version from the array + wpaVersion = rawWpa + .whereType() + .fold(0, (max, v) => v > max ? v : max); + if (wpaVersion == 0) { + // Try parsing from dynamic types + for (final v in rawWpa) { + final parsed = WifiScanResult._safeInt(v, 0); + if (parsed > wpaVersion) wpaVersion = parsed; + } + } + } + + final wep = json['wep'] == true; + final enabled = json['enabled'] == true || wpaVersion > 0 || wep; + + // Auth suites: try multiple key names used by different OpenWrt versions + final authSuites = _toStringList(json['auth_suites']) + + _toStringList(json['authentication']); + + // Ciphers: try multiple key names + final pairCiphers = _toStringList(json['pair_ciphers']) + + _toStringList(json['ciphers']); + final groupCiphers = _toStringList(json['group_ciphers']); + + // Build description from available data if not provided + String description; + final rawDesc = json['description']; + if (rawDesc is String && rawDesc.isNotEmpty) { + description = rawDesc; + } else if (!enabled) { + description = 'None'; + } else if (wep) { + description = 'WEP'; + } else { + // Build from wpa version + auth + ciphers + final wpaPart = wpaVersion >= 3 + ? 'WPA3' + : wpaVersion >= 2 + ? 'WPA2' + : wpaVersion >= 1 + ? 'WPA' + : 'WPA'; + final authPart = authSuites.isNotEmpty + ? ' ${authSuites.map((s) => s.toUpperCase()).join("/")}' + : ''; + final cipherPart = pairCiphers.isNotEmpty + ? ' (${pairCiphers.map((s) => s.toUpperCase()).join(", ")})' + : ''; + description = '$wpaPart$authPart$cipherPart'; + } + return WifiEncryption( - enabled: json['enabled'] == true, - description: WifiScanResult._safeString(json['description'], 'None'), - wep: json['wep'] == true, - wpa: WifiScanResult._safeInt(json['wpa'], 0), - authSuites: _toStringList(json['auth_suites']), - pairCiphers: _toStringList(json['pair_ciphers']), - groupCiphers: _toStringList(json['group_ciphers']), + enabled: enabled, + description: description, + wep: wep, + wpa: wpaVersion, + authSuites: authSuites, + pairCiphers: pairCiphers, + groupCiphers: groupCiphers, ); } From 5a40c5132188bbda1af690f2035f5c4d530aa0f8 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:58:09 +0200 Subject: [PATCH 16/28] Update api_service.dart --- lib/services/api_service.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index 39cd9a3..1061ec7 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -817,15 +817,24 @@ class RealApiService implements IApiService { required String config, required String type, required Map values, + String? name, BuildContext? context, }) async { + final params = { + 'config': config, + 'type': type, + 'values': values, + }; + if (name != null) { + params['name'] = name; + } return await callWithContext( ipAddress, sysauth, useHttps, object: 'uci', method: 'add', - params: {'config': config, 'type': type, 'values': values}, + params: params, context: context, ); } From 078fc0222e4b7e205b43a19f0dd7fd29c3838fac Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:58:26 +0200 Subject: [PATCH 17/28] Update mock_api_service.dart --- lib/services/mock_api_service.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/services/mock_api_service.dart b/lib/services/mock_api_service.dart index 3b7644f..514ea60 100644 --- a/lib/services/mock_api_service.dart +++ b/lib/services/mock_api_service.dart @@ -1034,10 +1034,11 @@ class MockApiService implements IApiService { required String config, required String type, required Map values, + String? name, BuildContext? context, }) async { await Future.delayed(const Duration(milliseconds: 300)); - return [0, 'cfg_new_section']; + return [0, name ?? 'cfg_new_section']; } @override @@ -1073,4 +1074,7 @@ class MockApiService implements IApiService { context: context, ); } + + @override + void cancelScan() {} } From 4071f90240d55b14347842fcd6a4e3d79be48703 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:59:10 +0200 Subject: [PATCH 18/28] Update api_service_interface.dart --- lib/services/interfaces/api_service_interface.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/services/interfaces/api_service_interface.dart b/lib/services/interfaces/api_service_interface.dart index 5110855..0679ead 100644 --- a/lib/services/interfaces/api_service_interface.dart +++ b/lib/services/interfaces/api_service_interface.dart @@ -95,7 +95,8 @@ abstract class IApiService { /// Cancel any ongoing wireless network scan. void cancelScan() {} - /// Adds a new wifi-iface section via UCI to connect to a network as a station. + /// Adds a new UCI section. If [name] is provided, creates a named section; + /// otherwise creates an anonymous section. Future uciAdd( String ipAddress, String sysauth, @@ -103,6 +104,7 @@ abstract class IApiService { required String config, required String type, required Map values, + String? name, BuildContext? context, }); From 270eb103ac6e18770c03ffaaf30cfca239667a81 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:59:58 +0200 Subject: [PATCH 19/28] Update wifi_scan_screen.dart From 850cd9af7d19bc7a763e6530ecc082064a5f3099 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:00:17 +0200 Subject: [PATCH 20/28] Update interfaces_screen.dart From 96068abbf221bea96c5eb181b07f33096b31139f Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:52:03 +0200 Subject: [PATCH 21/28] Update wifi_scan_result.dart From 5c22dd788a124b1284c293a8cd828450ad5725f6 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:52:21 +0200 Subject: [PATCH 22/28] Update app_state.dart From c5098caa8c44c32d2d52e5a1a55bde7184749905 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:52:41 +0200 Subject: [PATCH 23/28] Update api_service.dart From 47101ba25fdc337bd859b7129cdc46b738111af1 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:52:56 +0200 Subject: [PATCH 24/28] Update mock_api_service.dart From 83e211eeb97c9d80ffdfff9974289133f90ec2e9 Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:53:44 +0200 Subject: [PATCH 25/28] Update api_service_interface.dart From 4f7cea02d63834e3d624306d7596cd99b5e51d2d Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:54:30 +0200 Subject: [PATCH 26/28] Update wifi_scan_screen.dart From 1ef6e88244ef9e57a918441274cfd9dc218f5d5a Mon Sep 17 00:00:00 2001 From: kjames2001 <62420081+kjames2001@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:54:48 +0200 Subject: [PATCH 27/28] Update interfaces_screen.dart --- lib/screens/interfaces_screen.dart | 2194 ++++++++++++++++++++++++++++ 1 file changed, 2194 insertions(+) diff --git a/lib/screens/interfaces_screen.dart b/lib/screens/interfaces_screen.dart index 87bb553..e817f5f 100644 --- a/lib/screens/interfaces_screen.dart +++ b/lib/screens/interfaces_screen.dart @@ -2119,6 +2119,2200 @@ class _WifiDeleteDialogState extends ConsumerState<_WifiDeleteDialog> { } } + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final displayName = widget.ssid.isNotEmpty ? widget.ssid : widget.uciSection; + + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + icon: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Icon(Icons.delete_forever, color: colorScheme.error, size: 32), + ), + title: const Text('Remove Wireless Interface?'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'This will permanently remove "$displayName" from your wireless configuration.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.warning_amber, color: colorScheme.error, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'WiFi will restart. Clients on this network will be disconnected.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _isDeleting ? null : _delete, + style: FilledButton.styleFrom( + backgroundColor: colorScheme.error, + foregroundColor: colorScheme.onError, + ), + child: _isDeleting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Remove'), + ), + ], + ); + } +}import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:luci_mobile/main.dart'; +import 'package:flutter/services.dart'; +import 'package:luci_mobile/models/interface.dart'; +import 'dart:math'; +import 'package:luci_mobile/widgets/luci_app_bar.dart'; +import 'package:luci_mobile/design/luci_design_system.dart'; +import 'package:luci_mobile/widgets/luci_loading_states.dart'; +import 'package:luci_mobile/widgets/luci_refresh_components.dart'; +import 'package:luci_mobile/screens/wifi_scan_screen.dart'; + +class InterfacesScreen extends ConsumerStatefulWidget { + final String? scrollToInterface; + final VoidCallback? onScrollComplete; + + const InterfacesScreen({ + super.key, + this.scrollToInterface, + this.onScrollComplete, + }); + + @override + ConsumerState createState() => _InterfacesScreenState(); +} + +class _InterfacesScreenState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + String? _targetInterface; + String? _expandedInterface; + final Map _interfaceKeys = {}; + + // Unified key generator for all interfaces + String _interfaceKey({String? name, String? ssid, String? deviceName}) { + if (ssid != null && ssid.trim().isNotEmpty) { + return ssid.trim(); // SSID is case sensitive + } else if (deviceName != null && deviceName.trim().isNotEmpty) { + return deviceName.trim().toLowerCase(); + } else if (name != null && name.trim().isNotEmpty) { + return name.trim().toLowerCase(); + } + return ''; + } + + // Unified key generator and matcher for all interfaces + String _normalizeInterfaceKey(String? value) { + return (value ?? '').trim().toLowerCase(); + } + + String _interfaceKeyForWireless({ + String? ssid, + String? radioName, + String? deviceName, + String? name, + }) { + final radio = (radioName ?? '').trim(); + final ssidTrimmed = (ssid ?? '').trim(); + + // If SSID is empty, we need to ensure uniqueness even with same radio + if (ssidTrimmed.isEmpty) { + // Use device name as fallback for uniqueness + final device = (deviceName ?? '').trim(); + if (device.isNotEmpty && device != radio) { + return '${ssidTrimmed.toLowerCase()}__${device.toLowerCase()}'; + } + // Use interface name as fallback + final interfaceName = (name ?? '').trim(); + if (interfaceName.isNotEmpty && interfaceName != radio) { + return '${ssidTrimmed.toLowerCase()}__${interfaceName.toLowerCase()}'; + } + // If all names are the same, add a unique suffix + return '${ssidTrimmed.toLowerCase()}__${radio.toLowerCase()}_${DateTime.now().millisecondsSinceEpoch}'; + } + + // If SSID is not empty, use SSID + radio + return '${ssidTrimmed.toLowerCase()}__${radio.toLowerCase()}'; + } + + @override + void initState() { + super.initState(); + _targetInterface = widget.scrollToInterface; + if (_targetInterface != null) { + // Delay scrolling to allow the widget to build + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToInterface(_targetInterface!); + }); + } + } + + @override + void didUpdateWidget(InterfacesScreen oldWidget) { + super.didUpdateWidget(oldWidget); + + // Handle parameter changes (important for iOS navigation) + if (widget.scrollToInterface != oldWidget.scrollToInterface) { + _targetInterface = widget.scrollToInterface; + if (_targetInterface != null) { + // Delay scrolling to allow the widget to build + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToInterface(_targetInterface!); + }); + } else { + // Clear target interface if no new target is provided + setState(() { + _targetInterface = null; + }); + } + } + } + + @override + void dispose() { + // Clear target interface when widget is disposed + _targetInterface = null; + super.dispose(); + } + + void _scrollToInterface(String interfaceName) { + if (!_scrollController.hasClients) return; + + // Find the target interface and calculate its position + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + // Get the app state to access interface data + final appState = ref.read(appStateProvider); + final dashboardData = appState.dashboardData; + + if (dashboardData != null) { + // Check wired interfaces first + final wiredInterfaces = + dashboardData['interfaceDump']?['interface'] as List?; + if (wiredInterfaces != null) { + for (int i = 0; i < wiredInterfaces.length; i++) { + final iface = wiredInterfaces[i] as Map; + final name = iface['interface'] as String? ?? ''; + final keyStr = _interfaceKey(name: name); + // Use exact matching only + if (keyStr == interfaceName.toLowerCase()) { + _scrollToExpandedCard(keyStr); + return; + } + } + } + + // If not found in wired, check wireless interfaces + final wirelessData = + dashboardData['wireless'] as Map?; + if (wirelessData != null) { + final normalizedTarget = _normalizeInterfaceKey(interfaceName); + wirelessData.forEach((radioName, radioData) { + final interfaces = radioData['interfaces'] as List?; + if (interfaces != null) { + for (var i = 0; i < interfaces.length; i++) { + final interface = interfaces[i]; + final config = interface['config'] ?? {}; + final iwinfo = interface['iwinfo'] ?? {}; + final deviceName = config['device'] ?? radioName; + final ssid = iwinfo['ssid'] ?? config['ssid'] ?? ''; + final name = interface['name'] ?? ''; + final keyStr = _interfaceKeyForWireless( + ssid: ssid, + radioName: radioName, + deviceName: deviceName, + name: name, + ); + // Generate all possible normalized keys for matching + final ssidKey = _normalizeInterfaceKey(ssid); + final deviceKey = _normalizeInterfaceKey(deviceName); + final nameKey = _normalizeInterfaceKey(name); + // Match against all possible keys + if (normalizedTarget == ssidKey || + normalizedTarget == deviceKey || + normalizedTarget == nameKey) { + _scrollToExpandedCard(keyStr); + return; + } + } + } + }); + } + } + + // If not found, use section-based scrolling + if (interfaceName.toLowerCase().contains('wifi') || + interfaceName.toLowerCase().contains('wireless') || + interfaceName.toLowerCase().contains('radio')) { + _scrollToSection(200); // Wireless section + } else { + _scrollToSection(80); // Wired section + } + } + }); + } + + double _headerOffset(BuildContext context) { + // App bar (56) + section header (60) + return 116.0; + } + + void _scrollToExpandedCard(String keyStr, {int retry = 0}) { + if (!mounted) return; + + // Set the expanded interface + if (_expandedInterface != keyStr) { + setState(() { + _expandedInterface = keyStr; + }); + + // Wait for the expansion animation to complete (400ms) before calculating scroll + Future.delayed(const Duration(milliseconds: 450), () { + if (mounted) _performScrollToCard(keyStr, retry: retry); + }); + } else { + // Already expanded, perform scroll immediately + _performScrollToCard(keyStr, retry: retry); + } + } + + void _performScrollToCard(String keyStr, {int retry = 0}) { + if (!mounted) return; + + final key = _interfaceKeys[keyStr]; + final currentContext = context; // Store context + + final ctx = key?.currentContext; + if (ctx == null) { + if (retry < 5) { + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) _performScrollToCard(keyStr, retry: retry + 1); + }); + } + return; + } + + final headerOffset = _headerOffset(currentContext); + final renderBox = ctx.findRenderObject() as RenderBox?; + if (renderBox == null) { + if (retry < 5) { + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) _performScrollToCard(keyStr, retry: retry + 1); + }); + } + return; + } + + final cardOffset = renderBox.localToGlobal(Offset.zero).dy; + final cardHeight = renderBox.size.height; + final scrollableBox = _scrollController.position.hasContentDimensions + ? _scrollController.position.context.storageContext.findRenderObject() + as RenderBox? + : null; + final scrollableTop = scrollableBox?.localToGlobal(Offset.zero).dy ?? 0.0; + final visibleTop = scrollableTop + headerOffset; + final visibleBottom = MediaQuery.of(currentContext).size.height; + final cardBottom = cardOffset + cardHeight; + + // Calculate how much of the card is visible + final visibleCardTop = max(cardOffset, visibleTop); + final visibleCardBottom = min(cardBottom, visibleBottom); + final visibleCardHeight = max(0.0, visibleCardBottom - visibleCardTop); + final cardVisibilityRatio = cardHeight > 0 + ? visibleCardHeight / cardHeight + : 0.0; + + // Only scroll if less than 90% of the card is visible + final needsScroll = cardVisibilityRatio < 0.9; + + if (needsScroll) { + // Calculate optimal scroll position to center the card + final screenHeight = MediaQuery.of(currentContext).size.height; + final availableHeight = screenHeight - headerOffset; + final targetPosition = + cardOffset - headerOffset - (availableHeight - cardHeight) / 2; + final clampedPosition = targetPosition.clamp( + 0.0, + _scrollController.position.maxScrollExtent, + ); + + _scrollController + .animateTo( + clampedPosition, + duration: const Duration(milliseconds: 500), + curve: Curves.fastOutSlowIn, + ) + .then((_) { + if (mounted) { + setState(() { + _targetInterface = null; + }); + widget.onScrollComplete?.call(); + } + }); + } else { + if (mounted) { + setState(() { + _targetInterface = null; + }); + widget.onScrollComplete?.call(); + } + } + } + + void _scrollToSection(double targetPosition) { + if (!_scrollController.hasClients || + !_scrollController.position.hasContentDimensions) { + return; + } + + final maxScroll = _scrollController.position.maxScrollExtent; + final clampedPosition = targetPosition.clamp(0.0, maxScroll); + + _scrollController + .animateTo( + clampedPosition, + duration: const Duration(milliseconds: 800), + curve: Curves.easeInOut, + ) + .then((_) { + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted) { + setState(() { + _targetInterface = null; + }); + widget.onScrollComplete?.call(); + } + }); + }); + } + + @override + Widget build(BuildContext context) { + final appState = ref.read(appStateProvider); + + return Scaffold( + appBar: const LuciAppBar(title: 'Interfaces'), + body: SafeArea( + top: true, + bottom: false, + child: Stack( + children: [ + LuciPullToRefresh( + onRefresh: () => appState.fetchDashboardData(), + child: Builder( + builder: (context) { + final watchedAppState = ref.watch(appStateProvider); + final isLoading = watchedAppState.isDashboardLoading; + final dashboardError = watchedAppState.dashboardError; + final dashboardData = watchedAppState.dashboardData; + + if (isLoading && dashboardData == null) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: LuciSpacing.md), + child: Column( + children: [ + SizedBox(height: LuciSpacing.md), + // Interface cards skeleton + Expanded( + child: ListView.separated( + itemCount: 4, + separatorBuilder: (context, index) => + SizedBox(height: LuciSpacing.md), + itemBuilder: (context, index) => LuciCardSkeleton( + showTitle: true, + showSubtitle: true, + contentLines: 3, + ), + ), + ), + ], + ), + ); + } + + if (dashboardError != null && dashboardData == null) { + return LuciErrorDisplay( + title: 'Failed to Load Interfaces', + message: + 'Could not connect to the router. Please check your network connection and router settings.', + actionLabel: 'Retry', + onAction: () => appState.fetchDashboardData(), + icon: Icons.wifi_off_rounded, + ); + } + + if (dashboardData == null) { + return LuciEmptyState( + title: 'No Interface Data', + message: + 'Unable to fetch interface information. Pull down to refresh or tap the button below.', + icon: Icons.device_hub_outlined, + actionLabel: 'Fetch Data', + onAction: () => appState.fetchDashboardData(), + ); + } + + return CustomScrollView( + controller: _scrollController, + slivers: [ + SliverToBoxAdapter(child: LuciSectionHeader('Wired')), + _buildWiredInterfacesList(), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Wireless', + style: LuciTextStyles.sectionHeader(context), + ), + TextButton.icon( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + const WifiScanScreen(), + ), + ); + }, + icon: Icon(Icons.cell_tower, size: 16), + label: Text('Radio'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ), + ), + _buildWirelessInterfacesList(), + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only(bottom: 16), + child: SizedBox.shrink(), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildWiredInterfacesList() { + final appState = ref.watch(appStateProvider); + final dynamic detailedData = appState.dashboardData?['interfaceDump']; + final dynamic statsDataSource = appState.dashboardData?['networkDevices']; + var interfacesList = []; + + if (detailedData is Map && + detailedData.containsKey('interface') && + detailedData['interface'] is List && + statsDataSource is Map) { + final List interfaceDataList = detailedData['interface']; + final Map networkStatsMap = Map.from( + statsDataSource, + ); + + interfacesList = interfaceDataList.whereType>().map(( + detailedInterfaceMap, + ) { + final stats = detailedInterfaceMap['stats']; + if (stats == null || (stats is Map && stats.isEmpty)) { + final String? deviceName = + detailedInterfaceMap['l3_device'] ?? + detailedInterfaceMap['device']; + if (deviceName != null) { + final statsContainer = networkStatsMap[deviceName]; + if (statsContainer is Map && statsContainer['stats'] is Map) { + detailedInterfaceMap['stats'] = statsContainer['stats']; + } + } + } + return NetworkInterface.fromJson(detailedInterfaceMap); + }).toList(); + } + + final interfaces = interfacesList; + if (interfaces.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final iface = interfaces[index]; + final isTargetInterface = + _targetInterface != null && + iface.name.toLowerCase() == _targetInterface!.toLowerCase(); + + final keyStr = _interfaceKey(name: iface.name); + final key = _interfaceKeys.putIfAbsent(keyStr, () => GlobalKey()); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: _UnifiedNetworkCard( + key: key, + name: iface.name.toUpperCase(), + subtitle: _buildMinimalInterfaceSubtitle(iface), + isUp: iface.isUp, + icon: _getInterfaceIcon(iface.protocol), + details: _buildWiredDetails(context, iface), + initiallyExpanded: + isTargetInterface || _expandedInterface == keyStr, + ), + ); + }, childCount: interfaces.length), + ); + } + + Widget _buildWirelessInterfacesList() { + final appState = ref.watch(appStateProvider); + final dashboardData = appState.dashboardData; + final wirelessData = dashboardData?['wireless'] as Map?; + final uciWirelessConfig = dashboardData?['uciWirelessConfig']; + final interfacesList = >[]; + + final uciRadios = {}; + final uciInterfaces = >{}; + + // Try 'values' key (real API) then 'wireless' key (mock data) + final uciValues = (uciWirelessConfig?['values'] as Map?) ?? + (uciWirelessConfig?['wireless'] as Map?); + if (uciValues != null) { + uciValues.forEach((key, value) { + final typedValue = value as Map?; + if (typedValue?['.type'] == 'wifi-device') { + uciRadios[key] = typedValue!; + } else if (typedValue?['.type'] == 'wifi-iface') { + uciInterfaces[key] = Map.from(typedValue!); + } + }); + } + + final runtimeInterfaces = {}; + if (wirelessData != null) { + wirelessData.forEach((radioName, radioData) { + final interfaces = radioData['interfaces'] as List?; + if (interfaces != null) { + for (final iface in interfaces) { + final config = iface['config'] ?? {}; + final iwinfo = iface['iwinfo'] ?? {}; + final uciName = iface['section'] as String?; + if (uciName != null) { + runtimeInterfaces.add(uciName); + } + + final isRadioEnabled = uciRadios[radioName]?['disabled'] != '1'; + final isIfaceEnabled = config['disabled'] != '1' && + config['disabled'] != 1 && + config['disabled'] != true; + final isEnabled = isRadioEnabled && isIfaceEnabled; + + final name = iface['name'] ?? ''; + final ssid = iwinfo['ssid'] ?? config['ssid'] ?? ''; + final deviceName = config['device'] ?? radioName; + final mode = config['mode'] ?? iwinfo['mode'] ?? 'N/A'; + + // Build encryption description + final encIwinfo = iwinfo['encryption'] as Map?; + final encDescription = encIwinfo?['description'] ?? config['encryption'] ?? 'N/A'; + + interfacesList.add({ + 'name': config['ssid'] ?? iwinfo['ssid'] ?? 'Unnamed', + 'subtitle': + '${mode.toString().toUpperCase()} • Ch. ${iwinfo['channel']?.toString() ?? config['channel']?.toString() ?? 'N/A'}', + 'isEnabled': isEnabled, + 'isIfaceEnabled': isIfaceEnabled, + 'isRadioEnabled': isRadioEnabled, + 'deviceName': deviceName, + 'radioName': radioName, + 'ssid': ssid, + 'interfaceName': name, + 'uciSection': uciName ?? '', + 'mode': mode, + 'encryption': config['encryption'] ?? '', + 'encryptionDescription': encDescription, + 'network': (config['network'] is List) + ? (config['network'] as List).join(', ') + : config['network']?.toString() ?? '', + 'channel': iwinfo['channel']?.toString() ?? + config['channel']?.toString() ?? 'auto', + 'signal': iwinfo['signal']?.toString() ?? '--', + 'details': { + 'Device': config['device'] ?? radioName, + 'Mode': mode, + 'Channel': + iwinfo['channel']?.toString() ?? + config['channel']?.toString() ?? + 'N/A', + 'Signal': '${iwinfo['signal']?.toString() ?? '--'} dBm', + 'Network': (config['network'] is List) + ? (config['network'] as List).join(', ') + : config['network'] ?? 'N/A', + }, + }); + } + } + }); + } + + uciInterfaces.forEach((uciName, config) { + if (!runtimeInterfaces.contains(uciName)) { + final radioName = config['device'] ?? ''; + final isRadioEnabled = uciRadios[radioName]?['disabled'] != '1'; + final isIfaceEnabled = config['disabled'] != '1'; + final isEnabled = isRadioEnabled && isIfaceEnabled; + final mode = config['mode'] ?? 'N/A'; + + final name = config['ssid'] ?? 'Unnamed'; + interfacesList.add({ + 'name': config['ssid'] ?? 'Unnamed', + 'subtitle': '${mode.toString().toUpperCase()} • Disabled', + 'isEnabled': isEnabled, + 'isIfaceEnabled': isIfaceEnabled, + 'isRadioEnabled': isRadioEnabled, + 'deviceName': radioName, + 'radioName': radioName, + 'ssid': name, + 'interfaceName': name, + 'uciSection': uciName, + 'mode': mode, + 'encryption': config['encryption'] ?? '', + 'encryptionDescription': config['encryption'] ?? 'N/A', + 'network': (config['network'] is List) + ? (config['network'] as List).join(', ') + : config['network']?.toString() ?? '', + 'channel': config['channel']?.toString() ?? 'auto', + 'signal': '--', + 'details': { + 'Device': radioName, + 'Mode': mode, + 'SSID': config['ssid'] ?? 'N/A', + 'Network': (config['network'] is List) + ? (config['network'] as List).join(', ') + : config['network'] ?? 'N/A', + }, + }); + } + }); + + final interfaces = interfacesList; + if (interfaces.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final iface = interfaces[index]; + final deviceName = iface['deviceName'] ?? ''; + final radioName = iface['radioName'] ?? ''; + final ssid = iface['ssid'] ?? ''; + final name = iface['interfaceName'] ?? ''; + // Use the stored values for key generation + final keyStr = _interfaceKeyForWireless( + ssid: ssid, + radioName: radioName, + deviceName: deviceName, + name: name, + ); + final key = _interfaceKeys.putIfAbsent(keyStr, () => GlobalKey()); + final displayName = ssid.toString().isNotEmpty + ? ssid.toString() + : deviceName.toString(); + + // Check if this is the target interface for expansion + final isTargetInterface = + _targetInterface != null && + (_normalizeInterfaceKey(ssid) == + _normalizeInterfaceKey(_targetInterface!) || + _normalizeInterfaceKey(deviceName) == + _normalizeInterfaceKey(_targetInterface!) || + _normalizeInterfaceKey(name) == + _normalizeInterfaceKey(_targetInterface!)); + + final shouldExpand = isTargetInterface || _expandedInterface == keyStr; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: _UnifiedNetworkCard( + key: key, + name: displayName, + subtitle: iface['subtitle'], + isUp: iface['isEnabled'], + icon: Icons.wifi, + details: _buildWirelessDetails(context, iface), + initiallyExpanded: shouldExpand, + ), + ); + }, childCount: interfaces.length), + ); + } + + Widget _buildWirelessDetails( + BuildContext context, + Map iface, + ) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final details = iface['details'] as Map; + final uciSection = iface['uciSection'] as String? ?? ''; + final isIfaceEnabled = iface['isIfaceEnabled'] as bool? ?? true; + final mode = iface['mode']?.toString() ?? ''; + final encDescription = iface['encryptionDescription']?.toString() ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Details rows + ...details.entries.map((entry) { + return _buildDetailRow(context, entry.key, entry.value.toString()); + }), + // Encryption row + if (encDescription.isNotEmpty && encDescription != 'N/A') + _buildDetailRow(context, 'Encryption', encDescription), + + const Divider(height: 1, indent: 16, endIndent: 16), + const SizedBox(height: 8), + + // Management action buttons + if (uciSection.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: Column( + children: [ + // Enable/Disable toggle row + _WifiToggleRow( + uciSection: uciSection, + isEnabled: isIfaceEnabled, + ), + + const SizedBox(height: 8), + + // Action buttons row + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _showEditWifiSheet(context, iface), + icon: Icon(Icons.edit_outlined, size: 18), + label: const Text('Edit'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.primary, + side: BorderSide( + color: colorScheme.primary.withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: () => + _showDeleteWifiDialog(context, iface), + icon: Icon(Icons.delete_outline, size: 18), + label: const Text('Remove'), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide( + color: colorScheme.error.withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + ], + ), + + const SizedBox(height: 4), + + // Mode label + if (mode.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + mode.toLowerCase() == 'sta' + ? 'Client (Station) mode' + : mode.toLowerCase() == 'ap' + ? 'Access Point mode' + : '${mode.toUpperCase()} mode', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ) + else + // No UCI section - just show details + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'UCI section unavailable — limited management', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ), + + const SizedBox(height: 8), + ], + ); + } + + void _showEditWifiSheet( + BuildContext context, + Map iface, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (ctx) => _WifiEditBottomSheet(iface: iface), + ); + } + + void _showDeleteWifiDialog( + BuildContext context, + Map iface, + ) { + final ssid = iface['ssid']?.toString() ?? 'this interface'; + final uciSection = iface['uciSection'] as String? ?? ''; + if (uciSection.isEmpty) return; + + showDialog( + context: context, + builder: (ctx) => _WifiDeleteDialog( + ssid: ssid, + uciSection: uciSection, + ), + ); + } + + Widget _buildWiredDetails(BuildContext context, NetworkInterface interface) { + return Column( + children: [ + _buildDetailRow(context, 'Device', interface.device), + _buildDetailRow(context, 'Uptime', interface.formattedUptime), + if (interface.ipAddress != null) + _buildDetailRow( + context, + 'IP Address', + interface.ipAddress!, + onTap: () => + _copyToClipboard(context, interface.ipAddress!, 'IP Address'), + ), + if (interface.ipv6Addresses != null && + interface.ipv6Addresses!.isNotEmpty) + ...interface.ipv6Addresses!.map( + (ipv6) => _buildDetailRow( + context, + 'IPv6 Address', + ipv6, + onTap: () => _copyToClipboard(context, ipv6, 'IPv6 Address'), + ), + ), + if (interface.gateway != null) + _buildDetailRow( + context, + 'Gateway', + interface.gateway!, + onTap: () => + _copyToClipboard(context, interface.gateway!, 'Gateway IP'), + ), + if (interface.dnsServers.isNotEmpty) + _buildDetailRow( + context, + 'DNS', + interface.dnsServers.join(', '), + onTap: () => _copyToClipboard( + context, + interface.dnsServers.join(', '), + 'DNS Servers', + ), + ), + // Add WireGuard peer information if this is a WireGuard interface + if (interface.protocol.toLowerCase() == 'wireguard') ...[ + Builder( + builder: (context) { + return _buildWireGuardPeersSection(context, interface.name); + }, + ), + ], + const Divider(height: 1, indent: 16, endIndent: 16), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: _buildStatsRow(context, interface.stats), + ), + ], + ); + } + + Widget _buildWireGuardPeersSection( + BuildContext context, + String interfaceName, + ) { + final appState = ref.watch(appStateProvider); + final wireguardData = + appState.dashboardData?['wireguard'] as Map?; + final peerData = wireguardData?[interfaceName]; + if (peerData == null) { + return const SizedBox.shrink(); + } + final peers = peerData['peers'] as Map?; + if (peers == null || peers.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: const Divider(height: 24, thickness: 1, indent: 0, endIndent: 0), + ); + } + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1, thickness: 1, indent: 0, endIndent: 0), + const SizedBox(height: 8), + ...peers.values.map( + (peer) => + _buildCohesivePeerRow(context, peer as Map), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + Widget _buildCohesivePeerRow( + BuildContext context, + Map peer, + ) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final publicKey = peer['public_key'] as String? ?? 'Unknown'; + final endpoint = peer['endpoint'] as String? ?? 'N/A'; + final peerName = peer['name'] as String?; + int lastHandshake = 0; + final rawHandshake = peer['last_handshake'] ?? peer['latest_handshake']; + if (rawHandshake != null) { + if (rawHandshake is int) { + lastHandshake = rawHandshake; + } else if (rawHandshake is String) { + lastHandshake = int.tryParse(rawHandshake) ?? 0; + } + } + final displayKey = publicKey.length > 16 + ? '${publicKey.substring(0, 8)}...${publicKey.substring(publicKey.length - 8)}' + : publicKey; + String formatHandshakeTime(int timestamp) { + if (timestamp == 0) return 'Never'; + final now = DateTime.now(); + final handshakeTime = DateTime.fromMillisecondsSinceEpoch( + timestamp * 1000, + ); + final difference = now.difference(handshakeTime); + if (difference.inSeconds < 0) return 'Never'; + if (difference.inDays > 0) { + return '${difference.inDays}d ago'; + } else if (difference.inHours > 0) { + return '${difference.inHours}h ago'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes}m ago'; + } else { + return '${difference.inSeconds}s ago'; + } + } + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.vpn_key, size: 18, color: colorScheme.primary), + const SizedBox(width: 8), + Flexible( + child: Text( + displayKey, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + fontSize: 14, + ), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (peerName != null && peerName.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + peerName, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.normal, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Last Handshake', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + const SizedBox(height: 2), + Text( + formatHandshakeTime(lastHandshake), + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 14, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(width: 24), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + 'Endpoint', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + const SizedBox(height: 2), + Text( + endpoint, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 14, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildGenericDetails( + BuildContext context, + Map details, + ) { + return Column( + children: details.entries.map((entry) { + return _buildDetailRow(context, entry.key, entry.value.toString()); + }).toList(), + ); + } + + Widget _buildDetailRow( + BuildContext context, + String title, + String value, { + VoidCallback? onTap, + }) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + ), + ), + Row( + children: [ + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, + ), + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + ), + if (onTap != null) + GestureDetector( + onTap: onTap, + child: const Padding( + padding: EdgeInsets.only(left: 8.0), + child: Icon( + Icons.copy_all_outlined, + size: 16, + semanticLabel: 'Copy', + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + void _copyToClipboard(BuildContext context, String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label copied to clipboard'), + duration: const Duration(seconds: 2), + ), + ); + } + + Widget _buildStatsRow(BuildContext context, Map stats) { + String formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + const suffixes = ["B", "KB", "MB", "GB", "TB"]; + var i = (log(bytes) / log(1024)).floor(); + return '${(bytes / pow(1024, i)).toStringAsFixed(2)} ${suffixes[i]}'; + } + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildStatColumn( + context, + 'Received', + formatBytes(stats['rx_bytes'] ?? 0), + Icons.arrow_downward, + Colors.green, + ), + _buildStatColumn( + context, + 'Transmitted', + formatBytes(stats['tx_bytes'] ?? 0), + Icons.arrow_upward, + Colors.blue, + ), + ], + ); + } + + Widget _buildStatColumn( + BuildContext context, + String label, + String value, + IconData icon, + Color color, + ) { + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 4), + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + IconData _getInterfaceIcon(String protocol) { + switch (protocol.toLowerCase()) { + case 'wireguard': + return Icons.shield_outlined; + case 'static': + return Icons.settings_ethernet; + case 'dhcp': + return Icons.dns_outlined; + default: + return Icons.device_hub_outlined; + } + } + + String _buildMinimalInterfaceSubtitle(NetworkInterface iface) { + final v4 = iface.ipAddress; + final v6s = iface.ipv6Addresses ?? []; + final v6 = v6s.isNotEmpty ? v6s.first : null; + String? shown; + int extra = 0; + if (v4 != null) { + shown = v4; + if (v6 != null) extra++; + } else if (v6 != null) { + shown = v6; + } + if (shown == null) return iface.protocol; + if (extra > 0) { + return '${iface.protocol} • $shown +$extra'; + } else { + return '${iface.protocol} • $shown'; + } + } +} + +class LuciSectionHeader extends StatelessWidget { + final String title; + const LuciSectionHeader(this.title, {super.key}); + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + ), + ), + ); + } +} + +class _UnifiedNetworkCard extends StatefulWidget { + final String name; + final String subtitle; + final bool isUp; + final IconData icon; + final Widget details; + final bool initiallyExpanded; + + const _UnifiedNetworkCard({ + required this.name, + required this.subtitle, + required this.isUp, + required this.icon, + required this.details, + this.initiallyExpanded = false, + super.key, + }); + + @override + State<_UnifiedNetworkCard> createState() => _UnifiedNetworkCardState(); +} + +class _UnifiedNetworkCardState extends State<_UnifiedNetworkCard> + with SingleTickerProviderStateMixin { + bool _isExpanded = false; + late AnimationController _controller; + @override + void initState() { + super.initState(); + _isExpanded = widget.initiallyExpanded; + _controller = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + if (widget.initiallyExpanded) { + _controller.forward(); + } + } + + @override + void didUpdateWidget(covariant _UnifiedNetworkCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initiallyExpanded != oldWidget.initiallyExpanded) { + setState(() { + _isExpanded = widget.initiallyExpanded; + if (_isExpanded) { + _controller.forward(); + } else { + _controller.reverse(); + } + }); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _toggleExpand() { + setState(() { + _isExpanded = !_isExpanded; + if (_isExpanded) { + _controller.forward(); + } else { + _controller.reverse(); + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final card = Card( + elevation: _isExpanded ? 6 : 2, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: LuciCardStyles.standardRadius, + side: BorderSide( + color: widget.initiallyExpanded && _isExpanded + ? colorScheme.primary.withValues(alpha: 0.3) + : colorScheme.surfaceContainerHighest.withValues(alpha: 0.10), + width: widget.initiallyExpanded && _isExpanded ? 2 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: AnimatedScale( + scale: widget.initiallyExpanded && _isExpanded ? 1.02 : 1.0, + duration: LuciAnimations.standard, + curve: Curves.easeOutBack, + child: Column( + children: [ + InkWell( + onTap: _toggleExpand, + borderRadius: LuciCardStyles.standardRadius, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: LuciSpacing.lg, + vertical: 10.0, + ), + child: Row( + children: [ + Stack( + alignment: Alignment.topRight, + children: [ + Container( + padding: const EdgeInsets.all(8.0), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues( + alpha: 0.13, + ), + shape: BoxShape.circle, + ), + child: AnimatedScale( + scale: widget.initiallyExpanded && _isExpanded + ? 1.1 + : 1.0, + duration: const Duration(milliseconds: 500), + curve: Curves.elasticOut, + child: Icon( + widget.icon, + color: widget.isUp + ? colorScheme.primary + : colorScheme.onSurface, + size: 22, + semanticLabel: 'Interface icon', + ), + ), + ), + Positioned( + right: 0, + top: 0, + child: Tooltip( + message: widget.isUp + ? 'Interface is up' + : 'Interface is down', + child: LuciStatusIndicators.statusDot( + context, + widget.isUp, + ), + ), + ), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.name, + style: LuciTextStyles.cardTitle(context), + semanticsLabel: 'Interface name: ${widget.name}', + ), + const SizedBox(height: LuciSpacing.xs), + Container( + margin: const EdgeInsets.only(right: 32), + child: Divider( + color: colorScheme.surfaceContainerHighest + .withValues(alpha: 0.10), + thickness: 1, + height: 8, + ), + ), + Text( + widget.subtitle, + style: LuciTextStyles.cardSubtitle(context), + semanticsLabel: + 'Interface details: ${widget.subtitle}', + ), + ], + ), + ), + if (!widget.isUp) + Padding( + padding: const EdgeInsets.only(right: LuciSpacing.xs), + child: LuciStatusIndicators.statusChip( + context, + 'OFF', + false, + ), + ), + const SizedBox(width: LuciSpacing.sm), + Icon( + _isExpanded ? Icons.expand_less : Icons.expand_more, + color: colorScheme.onSurfaceVariant, + size: 26, + semanticLabel: _isExpanded + ? 'Collapse details' + : 'Expand details', + ), + ], + ), + ), + ), + if (_isExpanded) + Column( + children: [ + const Divider(height: 1, indent: 18, endIndent: 18), + widget.details, + ], + ), + ], + ), + ), + ); + + if (!widget.isUp) { + return ColorFiltered( + colorFilter: const ColorFilter.matrix([ + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + child: card, + ); + } + return card; + } +} + +// ────────────────────────────────────────────────────────────────── +// WiFi Enable/Disable Toggle Row +// ────────────────────────────────────────────────────────────────── + +class _WifiToggleRow extends ConsumerStatefulWidget { + final String uciSection; + final bool isEnabled; + + const _WifiToggleRow({ + required this.uciSection, + required this.isEnabled, + }); + + @override + ConsumerState<_WifiToggleRow> createState() => _WifiToggleRowState(); +} + +class _WifiToggleRowState extends ConsumerState<_WifiToggleRow> { + bool _isToggling = false; + + Future _toggle(bool value) async { + if (_isToggling) return; + setState(() => _isToggling = true); + + final appState = ref.read(appStateProvider); + final success = await appState.setWirelessInterfaceEnabled( + widget.uciSection, + value, + context: context, + ); + + if (mounted) { + setState(() => _isToggling = false); + if (!success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Failed to toggle interface'), + backgroundColor: Theme.of(context).colorScheme.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + widget.isEnabled ? Icons.wifi : Icons.wifi_off, + size: 20, + color: widget.isEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.isEnabled ? 'Interface Enabled' : 'Interface Disabled', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + if (_isToggling) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Switch( + value: widget.isEnabled, + onChanged: _toggle, + ), + ], + ), + ); + } +} + +// ────────────────────────────────────────────────────────────────── +// WiFi Edit Bottom Sheet +// ────────────────────────────────────────────────────────────────── + +class _WifiEditBottomSheet extends ConsumerStatefulWidget { + final Map iface; + + const _WifiEditBottomSheet({required this.iface}); + + @override + ConsumerState<_WifiEditBottomSheet> createState() => + _WifiEditBottomSheetState(); +} + +class _WifiEditBottomSheetState extends ConsumerState<_WifiEditBottomSheet> { + late TextEditingController _ssidController; + late TextEditingController _passwordController; + late TextEditingController _networkController; + late String _selectedEncryption; + bool _obscurePassword = true; + bool _isSaving = false; + String? _error; + + static const _encryptionOptions = [ + {'value': 'none', 'label': 'None (Open)'}, + {'value': 'psk2', 'label': 'WPA2-PSK'}, + {'value': 'psk', 'label': 'WPA-PSK'}, + {'value': 'psk-mixed', 'label': 'WPA/WPA2 Mixed PSK'}, + {'value': 'sae', 'label': 'WPA3-SAE'}, + {'value': 'sae-mixed', 'label': 'WPA2/WPA3 Mixed'}, + ]; + + @override + void initState() { + super.initState(); + _ssidController = TextEditingController( + text: widget.iface['ssid']?.toString() ?? '', + ); + _passwordController = TextEditingController(); + _networkController = TextEditingController( + text: widget.iface['network']?.toString() ?? 'lan', + ); + + // Map the current encryption to our dropdown values + final currentEnc = widget.iface['encryption']?.toString() ?? 'none'; + _selectedEncryption = _encryptionOptions.any( + (o) => o['value'] == currentEnc, + ) + ? currentEnc + : 'psk2'; + } + + @override + void dispose() { + _ssidController.dispose(); + _passwordController.dispose(); + _networkController.dispose(); + super.dispose(); + } + + bool get _requiresPassword => _selectedEncryption != 'none'; + + Future _save() async { + final ssid = _ssidController.text.trim(); + if (ssid.isEmpty) { + setState(() => _error = 'SSID cannot be empty.'); + return; + } + + if (_requiresPassword && + _passwordController.text.isNotEmpty && + _passwordController.text.length < 8 && + _selectedEncryption != 'none') { + setState( + () => _error = 'Password must be at least 8 characters.', + ); + return; + } + + setState(() { + _isSaving = true; + _error = null; + }); + + final uciSection = widget.iface['uciSection'] as String? ?? ''; + if (uciSection.isEmpty) { + setState(() { + _isSaving = false; + _error = 'Cannot identify UCI section for this interface.'; + }); + return; + } + + // Build values to update + final values = { + 'ssid': ssid, + 'encryption': _selectedEncryption, + }; + + // Only update password if user typed one + if (_passwordController.text.isNotEmpty) { + values['key'] = _passwordController.text; + } + + // Update network binding + final network = _networkController.text.trim(); + if (network.isNotEmpty) { + values['network'] = network; + } + + final appState = ref.read(appStateProvider); + final success = await appState.modifyWirelessInterface( + uciSection, + values, + context: context, + ); + + if (!mounted) return; + + if (success) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white, size: 20), + const SizedBox(width: 8), + Text('Updated "$ssid" — reloading WiFi...'), + ], + ), + backgroundColor: Colors.green.shade700, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: const Duration(seconds: 3), + ), + ); + } else { + setState(() { + _isSaving = false; + _error = 'Failed to save changes. Please try again.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final mode = widget.iface['mode']?.toString() ?? 'ap'; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 20), + + // Header + Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.3), + shape: BoxShape.circle, + ), + child: Icon( + Icons.edit, + color: colorScheme.primary, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Edit Wireless Interface', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text( + '${widget.iface['radioName']} • ${mode.toUpperCase()} mode', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 24), + + // SSID field + _buildLabel(context, 'SSID (Network Name)'), + const SizedBox(height: 6), + TextField( + controller: _ssidController, + enabled: !_isSaving, + decoration: _inputDecoration( + context, + hintText: 'Enter SSID', + prefixIcon: Icons.wifi, + ), + ), + + const SizedBox(height: 16), + + // Encryption selector + _buildLabel(context, 'Encryption'), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + color: colorScheme.surfaceContainerLow, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedEncryption, + isExpanded: true, + icon: Icon( + Icons.arrow_drop_down, + color: colorScheme.onSurfaceVariant, + ), + items: _encryptionOptions.map((opt) { + return DropdownMenuItem( + value: opt['value'], + child: Text( + opt['label']!, + style: theme.textTheme.bodyMedium, + ), + ); + }).toList(), + onChanged: _isSaving + ? null + : (value) { + if (value != null) { + setState(() => _selectedEncryption = value); + } + }, + ), + ), + ), + + // Password field (only for encrypted networks) + if (_requiresPassword) ...[ + const SizedBox(height: 16), + _buildLabel(context, 'Password (leave empty to keep current)'), + const SizedBox(height: 6), + TextField( + controller: _passwordController, + obscureText: _obscurePassword, + enabled: !_isSaving, + decoration: _inputDecoration( + context, + hintText: 'Enter new password', + prefixIcon: Icons.key, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_off + : Icons.visibility, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + ), + ), + ], + + const SizedBox(height: 16), + + // Network binding + _buildLabel(context, 'Network'), + const SizedBox(height: 6), + TextField( + controller: _networkController, + enabled: !_isSaving, + decoration: _inputDecoration( + context, + hintText: 'e.g., lan, wwan', + prefixIcon: Icons.lan_outlined, + ), + ), + + // Error + if (_error != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.errorContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error, + size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _error!, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 20), + + // Warning + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: colorScheme.tertiary, + size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Changes will be applied immediately. WiFi will briefly restart.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // Save button + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _isSaving ? null : _save, + icon: _isSaving + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onPrimary, + ), + ) + : const Icon(Icons.save), + label: Text( + _isSaving ? 'Applying...' : 'Save Changes', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + ), + + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + + Widget _buildLabel(BuildContext context, String text) { + return Text( + text, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ); + } + + InputDecoration _inputDecoration( + BuildContext context, { + required String hintText, + required IconData prefixIcon, + Widget? suffixIcon, + }) { + final colorScheme = Theme.of(context).colorScheme; + return InputDecoration( + hintText: hintText, + prefixIcon: Icon(prefixIcon), + suffixIcon: suffixIcon, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.3), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLow, + ); + } +} + +// ────────────────────────────────────────────────────────────────── +// WiFi Delete Confirmation Dialog +// ────────────────────────────────────────────────────────────────── + +class _WifiDeleteDialog extends ConsumerStatefulWidget { + final String ssid; + final String uciSection; + + const _WifiDeleteDialog({ + required this.ssid, + required this.uciSection, + }); + + @override + ConsumerState<_WifiDeleteDialog> createState() => _WifiDeleteDialogState(); +} + +class _WifiDeleteDialogState extends ConsumerState<_WifiDeleteDialog> { + bool _isDeleting = false; + + Future _delete() async { + setState(() => _isDeleting = true); + + final appState = ref.read(appStateProvider); + final success = await appState.deleteWirelessInterface( + widget.uciSection, + context: context, + ); + + if (!mounted) return; + + Navigator.of(context).pop(); + + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white, size: 20), + const SizedBox(width: 8), + Text('"${widget.ssid}" removed'), + ], + ), + backgroundColor: Colors.green.shade700, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + duration: const Duration(seconds: 3), + ), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Failed to remove interface'), + backgroundColor: Theme.of(context).colorScheme.error, + behavior: SnackBarBehavior.floating, + ), + ); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); From 9b6b27e1f6b1790e025fa0b72b2ddb14bceeed4a Mon Sep 17 00:00:00 2001 From: root Date: Mon, 2 Mar 2026 18:48:23 +0000 Subject: [PATCH 28/28] Fix STA network handling: proper wifinet naming, firewall zone, and deletion warning - Issue 1: Show correct warning for STA deletion (no WiFi restart) - Issue 3: Use radio-specific network names (wwan for radio0, wwan1 for radio1) - Properly find next available wifinet# to avoid conflicts - Add new STA networks to existing WAN firewall zone - Include BSSID when connecting to target specific AP - Only restart WiFi for AP deletion, not STA --- lib/screens/interfaces_screen.dart | 2214 +--------------------------- lib/screens/wifi_scan_screen.dart | 1 + lib/state/app_state.dart | 240 ++- 3 files changed, 222 insertions(+), 2233 deletions(-) diff --git a/lib/screens/interfaces_screen.dart b/lib/screens/interfaces_screen.dart index e817f5f..c247fa9 100644 --- a/lib/screens/interfaces_screen.dart +++ b/lib/screens/interfaces_screen.dart @@ -840,6 +840,7 @@ class _InterfacesScreenState extends ConsumerState { ) { final ssid = iface['ssid']?.toString() ?? 'this interface'; final uciSection = iface['uciSection'] as String? ?? ''; + final mode = iface['mode']?.toString() ?? 'ap'; if (uciSection.isEmpty) return; showDialog( @@ -847,6 +848,7 @@ class _InterfacesScreenState extends ConsumerState { builder: (ctx) => _WifiDeleteDialog( ssid: ssid, uciSection: uciSection, + mode: mode, ), ); } @@ -2064,10 +2066,12 @@ class _WifiEditBottomSheetState extends ConsumerState<_WifiEditBottomSheet> { class _WifiDeleteDialog extends ConsumerStatefulWidget { final String ssid; final String uciSection; + final String mode; const _WifiDeleteDialog({ required this.ssid, required this.uciSection, + required this.mode, }); @override @@ -2083,6 +2087,7 @@ class _WifiDeleteDialogState extends ConsumerState<_WifiDeleteDialog> { final appState = ref.read(appStateProvider); final success = await appState.deleteWirelessInterface( widget.uciSection, + mode: widget.mode, context: context, ); @@ -2124,6 +2129,15 @@ class _WifiDeleteDialogState extends ConsumerState<_WifiDeleteDialog> { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final displayName = widget.ssid.isNotEmpty ? widget.ssid : widget.uciSection; + final modeLower = widget.mode.toLowerCase(); + final isStaMode = modeLower.contains('sta') || + modeLower.contains('client') || + modeLower == 'station' || + modeLower == 'n/a' || + widget.mode.isEmpty; + final warningMessage = isStaMode + ? 'This will remove the STA connection from this radio.' + : 'WiFi will restart. Clients on this network will be disconnected.'; return AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), @@ -2155,2203 +2169,9 @@ class _WifiDeleteDialogState extends ConsumerState<_WifiDeleteDialog> { Icon(Icons.warning_amber, color: colorScheme.error, size: 18), const SizedBox(width: 8), Expanded( - child: Text( - 'WiFi will restart. Clients on this network will be disconnected.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), - ), - ), - ], - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: _isDeleting ? null : _delete, - style: FilledButton.styleFrom( - backgroundColor: colorScheme.error, - foregroundColor: colorScheme.onError, - ), - child: _isDeleting - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Text('Remove'), - ), - ], - ); - } -}import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:luci_mobile/main.dart'; -import 'package:flutter/services.dart'; -import 'package:luci_mobile/models/interface.dart'; -import 'dart:math'; -import 'package:luci_mobile/widgets/luci_app_bar.dart'; -import 'package:luci_mobile/design/luci_design_system.dart'; -import 'package:luci_mobile/widgets/luci_loading_states.dart'; -import 'package:luci_mobile/widgets/luci_refresh_components.dart'; -import 'package:luci_mobile/screens/wifi_scan_screen.dart'; - -class InterfacesScreen extends ConsumerStatefulWidget { - final String? scrollToInterface; - final VoidCallback? onScrollComplete; - - const InterfacesScreen({ - super.key, - this.scrollToInterface, - this.onScrollComplete, - }); - - @override - ConsumerState createState() => _InterfacesScreenState(); -} - -class _InterfacesScreenState extends ConsumerState { - final ScrollController _scrollController = ScrollController(); - String? _targetInterface; - String? _expandedInterface; - final Map _interfaceKeys = {}; - - // Unified key generator for all interfaces - String _interfaceKey({String? name, String? ssid, String? deviceName}) { - if (ssid != null && ssid.trim().isNotEmpty) { - return ssid.trim(); // SSID is case sensitive - } else if (deviceName != null && deviceName.trim().isNotEmpty) { - return deviceName.trim().toLowerCase(); - } else if (name != null && name.trim().isNotEmpty) { - return name.trim().toLowerCase(); - } - return ''; - } - - // Unified key generator and matcher for all interfaces - String _normalizeInterfaceKey(String? value) { - return (value ?? '').trim().toLowerCase(); - } - - String _interfaceKeyForWireless({ - String? ssid, - String? radioName, - String? deviceName, - String? name, - }) { - final radio = (radioName ?? '').trim(); - final ssidTrimmed = (ssid ?? '').trim(); - - // If SSID is empty, we need to ensure uniqueness even with same radio - if (ssidTrimmed.isEmpty) { - // Use device name as fallback for uniqueness - final device = (deviceName ?? '').trim(); - if (device.isNotEmpty && device != radio) { - return '${ssidTrimmed.toLowerCase()}__${device.toLowerCase()}'; - } - // Use interface name as fallback - final interfaceName = (name ?? '').trim(); - if (interfaceName.isNotEmpty && interfaceName != radio) { - return '${ssidTrimmed.toLowerCase()}__${interfaceName.toLowerCase()}'; - } - // If all names are the same, add a unique suffix - return '${ssidTrimmed.toLowerCase()}__${radio.toLowerCase()}_${DateTime.now().millisecondsSinceEpoch}'; - } - - // If SSID is not empty, use SSID + radio - return '${ssidTrimmed.toLowerCase()}__${radio.toLowerCase()}'; - } - - @override - void initState() { - super.initState(); - _targetInterface = widget.scrollToInterface; - if (_targetInterface != null) { - // Delay scrolling to allow the widget to build - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollToInterface(_targetInterface!); - }); - } - } - - @override - void didUpdateWidget(InterfacesScreen oldWidget) { - super.didUpdateWidget(oldWidget); - - // Handle parameter changes (important for iOS navigation) - if (widget.scrollToInterface != oldWidget.scrollToInterface) { - _targetInterface = widget.scrollToInterface; - if (_targetInterface != null) { - // Delay scrolling to allow the widget to build - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollToInterface(_targetInterface!); - }); - } else { - // Clear target interface if no new target is provided - setState(() { - _targetInterface = null; - }); - } - } - } - - @override - void dispose() { - // Clear target interface when widget is disposed - _targetInterface = null; - super.dispose(); - } - - void _scrollToInterface(String interfaceName) { - if (!_scrollController.hasClients) return; - - // Find the target interface and calculate its position - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - // Get the app state to access interface data - final appState = ref.read(appStateProvider); - final dashboardData = appState.dashboardData; - - if (dashboardData != null) { - // Check wired interfaces first - final wiredInterfaces = - dashboardData['interfaceDump']?['interface'] as List?; - if (wiredInterfaces != null) { - for (int i = 0; i < wiredInterfaces.length; i++) { - final iface = wiredInterfaces[i] as Map; - final name = iface['interface'] as String? ?? ''; - final keyStr = _interfaceKey(name: name); - // Use exact matching only - if (keyStr == interfaceName.toLowerCase()) { - _scrollToExpandedCard(keyStr); - return; - } - } - } - - // If not found in wired, check wireless interfaces - final wirelessData = - dashboardData['wireless'] as Map?; - if (wirelessData != null) { - final normalizedTarget = _normalizeInterfaceKey(interfaceName); - wirelessData.forEach((radioName, radioData) { - final interfaces = radioData['interfaces'] as List?; - if (interfaces != null) { - for (var i = 0; i < interfaces.length; i++) { - final interface = interfaces[i]; - final config = interface['config'] ?? {}; - final iwinfo = interface['iwinfo'] ?? {}; - final deviceName = config['device'] ?? radioName; - final ssid = iwinfo['ssid'] ?? config['ssid'] ?? ''; - final name = interface['name'] ?? ''; - final keyStr = _interfaceKeyForWireless( - ssid: ssid, - radioName: radioName, - deviceName: deviceName, - name: name, - ); - // Generate all possible normalized keys for matching - final ssidKey = _normalizeInterfaceKey(ssid); - final deviceKey = _normalizeInterfaceKey(deviceName); - final nameKey = _normalizeInterfaceKey(name); - // Match against all possible keys - if (normalizedTarget == ssidKey || - normalizedTarget == deviceKey || - normalizedTarget == nameKey) { - _scrollToExpandedCard(keyStr); - return; - } - } - } - }); - } - } - - // If not found, use section-based scrolling - if (interfaceName.toLowerCase().contains('wifi') || - interfaceName.toLowerCase().contains('wireless') || - interfaceName.toLowerCase().contains('radio')) { - _scrollToSection(200); // Wireless section - } else { - _scrollToSection(80); // Wired section - } - } - }); - } - - double _headerOffset(BuildContext context) { - // App bar (56) + section header (60) - return 116.0; - } - - void _scrollToExpandedCard(String keyStr, {int retry = 0}) { - if (!mounted) return; - - // Set the expanded interface - if (_expandedInterface != keyStr) { - setState(() { - _expandedInterface = keyStr; - }); - - // Wait for the expansion animation to complete (400ms) before calculating scroll - Future.delayed(const Duration(milliseconds: 450), () { - if (mounted) _performScrollToCard(keyStr, retry: retry); - }); - } else { - // Already expanded, perform scroll immediately - _performScrollToCard(keyStr, retry: retry); - } - } - - void _performScrollToCard(String keyStr, {int retry = 0}) { - if (!mounted) return; - - final key = _interfaceKeys[keyStr]; - final currentContext = context; // Store context - - final ctx = key?.currentContext; - if (ctx == null) { - if (retry < 5) { - Future.delayed(const Duration(milliseconds: 100), () { - if (mounted) _performScrollToCard(keyStr, retry: retry + 1); - }); - } - return; - } - - final headerOffset = _headerOffset(currentContext); - final renderBox = ctx.findRenderObject() as RenderBox?; - if (renderBox == null) { - if (retry < 5) { - Future.delayed(const Duration(milliseconds: 100), () { - if (mounted) _performScrollToCard(keyStr, retry: retry + 1); - }); - } - return; - } - - final cardOffset = renderBox.localToGlobal(Offset.zero).dy; - final cardHeight = renderBox.size.height; - final scrollableBox = _scrollController.position.hasContentDimensions - ? _scrollController.position.context.storageContext.findRenderObject() - as RenderBox? - : null; - final scrollableTop = scrollableBox?.localToGlobal(Offset.zero).dy ?? 0.0; - final visibleTop = scrollableTop + headerOffset; - final visibleBottom = MediaQuery.of(currentContext).size.height; - final cardBottom = cardOffset + cardHeight; - - // Calculate how much of the card is visible - final visibleCardTop = max(cardOffset, visibleTop); - final visibleCardBottom = min(cardBottom, visibleBottom); - final visibleCardHeight = max(0.0, visibleCardBottom - visibleCardTop); - final cardVisibilityRatio = cardHeight > 0 - ? visibleCardHeight / cardHeight - : 0.0; - - // Only scroll if less than 90% of the card is visible - final needsScroll = cardVisibilityRatio < 0.9; - - if (needsScroll) { - // Calculate optimal scroll position to center the card - final screenHeight = MediaQuery.of(currentContext).size.height; - final availableHeight = screenHeight - headerOffset; - final targetPosition = - cardOffset - headerOffset - (availableHeight - cardHeight) / 2; - final clampedPosition = targetPosition.clamp( - 0.0, - _scrollController.position.maxScrollExtent, - ); - - _scrollController - .animateTo( - clampedPosition, - duration: const Duration(milliseconds: 500), - curve: Curves.fastOutSlowIn, - ) - .then((_) { - if (mounted) { - setState(() { - _targetInterface = null; - }); - widget.onScrollComplete?.call(); - } - }); - } else { - if (mounted) { - setState(() { - _targetInterface = null; - }); - widget.onScrollComplete?.call(); - } - } - } - - void _scrollToSection(double targetPosition) { - if (!_scrollController.hasClients || - !_scrollController.position.hasContentDimensions) { - return; - } - - final maxScroll = _scrollController.position.maxScrollExtent; - final clampedPosition = targetPosition.clamp(0.0, maxScroll); - - _scrollController - .animateTo( - clampedPosition, - duration: const Duration(milliseconds: 800), - curve: Curves.easeInOut, - ) - .then((_) { - Future.delayed(const Duration(milliseconds: 500), () { - if (mounted) { - setState(() { - _targetInterface = null; - }); - widget.onScrollComplete?.call(); - } - }); - }); - } - - @override - Widget build(BuildContext context) { - final appState = ref.read(appStateProvider); - - return Scaffold( - appBar: const LuciAppBar(title: 'Interfaces'), - body: SafeArea( - top: true, - bottom: false, - child: Stack( - children: [ - LuciPullToRefresh( - onRefresh: () => appState.fetchDashboardData(), - child: Builder( - builder: (context) { - final watchedAppState = ref.watch(appStateProvider); - final isLoading = watchedAppState.isDashboardLoading; - final dashboardError = watchedAppState.dashboardError; - final dashboardData = watchedAppState.dashboardData; - - if (isLoading && dashboardData == null) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: LuciSpacing.md), - child: Column( - children: [ - SizedBox(height: LuciSpacing.md), - // Interface cards skeleton - Expanded( - child: ListView.separated( - itemCount: 4, - separatorBuilder: (context, index) => - SizedBox(height: LuciSpacing.md), - itemBuilder: (context, index) => LuciCardSkeleton( - showTitle: true, - showSubtitle: true, - contentLines: 3, - ), - ), - ), - ], - ), - ); - } - - if (dashboardError != null && dashboardData == null) { - return LuciErrorDisplay( - title: 'Failed to Load Interfaces', - message: - 'Could not connect to the router. Please check your network connection and router settings.', - actionLabel: 'Retry', - onAction: () => appState.fetchDashboardData(), - icon: Icons.wifi_off_rounded, - ); - } - - if (dashboardData == null) { - return LuciEmptyState( - title: 'No Interface Data', - message: - 'Unable to fetch interface information. Pull down to refresh or tap the button below.', - icon: Icons.device_hub_outlined, - actionLabel: 'Fetch Data', - onAction: () => appState.fetchDashboardData(), - ); - } - - return CustomScrollView( - controller: _scrollController, - slivers: [ - SliverToBoxAdapter(child: LuciSectionHeader('Wired')), - _buildWiredInterfacesList(), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Wireless', - style: LuciTextStyles.sectionHeader(context), - ), - TextButton.icon( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => - const WifiScanScreen(), - ), - ); - }, - icon: Icon(Icons.cell_tower, size: 16), - label: Text('Radio'), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 4, - ), - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - ), - ), - _buildWirelessInterfacesList(), - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.only(bottom: 16), - child: SizedBox.shrink(), - ), - ), - ], - ); - }, - ), - ), - ], - ), - ), - ); - } - - Widget _buildWiredInterfacesList() { - final appState = ref.watch(appStateProvider); - final dynamic detailedData = appState.dashboardData?['interfaceDump']; - final dynamic statsDataSource = appState.dashboardData?['networkDevices']; - var interfacesList = []; - - if (detailedData is Map && - detailedData.containsKey('interface') && - detailedData['interface'] is List && - statsDataSource is Map) { - final List interfaceDataList = detailedData['interface']; - final Map networkStatsMap = Map.from( - statsDataSource, - ); - - interfacesList = interfaceDataList.whereType>().map(( - detailedInterfaceMap, - ) { - final stats = detailedInterfaceMap['stats']; - if (stats == null || (stats is Map && stats.isEmpty)) { - final String? deviceName = - detailedInterfaceMap['l3_device'] ?? - detailedInterfaceMap['device']; - if (deviceName != null) { - final statsContainer = networkStatsMap[deviceName]; - if (statsContainer is Map && statsContainer['stats'] is Map) { - detailedInterfaceMap['stats'] = statsContainer['stats']; - } - } - } - return NetworkInterface.fromJson(detailedInterfaceMap); - }).toList(); - } - - final interfaces = interfacesList; - if (interfaces.isEmpty) { - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final iface = interfaces[index]; - final isTargetInterface = - _targetInterface != null && - iface.name.toLowerCase() == _targetInterface!.toLowerCase(); - - final keyStr = _interfaceKey(name: iface.name); - final key = _interfaceKeys.putIfAbsent(keyStr, () => GlobalKey()); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: _UnifiedNetworkCard( - key: key, - name: iface.name.toUpperCase(), - subtitle: _buildMinimalInterfaceSubtitle(iface), - isUp: iface.isUp, - icon: _getInterfaceIcon(iface.protocol), - details: _buildWiredDetails(context, iface), - initiallyExpanded: - isTargetInterface || _expandedInterface == keyStr, - ), - ); - }, childCount: interfaces.length), - ); - } - - Widget _buildWirelessInterfacesList() { - final appState = ref.watch(appStateProvider); - final dashboardData = appState.dashboardData; - final wirelessData = dashboardData?['wireless'] as Map?; - final uciWirelessConfig = dashboardData?['uciWirelessConfig']; - final interfacesList = >[]; - - final uciRadios = {}; - final uciInterfaces = >{}; - - // Try 'values' key (real API) then 'wireless' key (mock data) - final uciValues = (uciWirelessConfig?['values'] as Map?) ?? - (uciWirelessConfig?['wireless'] as Map?); - if (uciValues != null) { - uciValues.forEach((key, value) { - final typedValue = value as Map?; - if (typedValue?['.type'] == 'wifi-device') { - uciRadios[key] = typedValue!; - } else if (typedValue?['.type'] == 'wifi-iface') { - uciInterfaces[key] = Map.from(typedValue!); - } - }); - } - - final runtimeInterfaces = {}; - if (wirelessData != null) { - wirelessData.forEach((radioName, radioData) { - final interfaces = radioData['interfaces'] as List?; - if (interfaces != null) { - for (final iface in interfaces) { - final config = iface['config'] ?? {}; - final iwinfo = iface['iwinfo'] ?? {}; - final uciName = iface['section'] as String?; - if (uciName != null) { - runtimeInterfaces.add(uciName); - } - - final isRadioEnabled = uciRadios[radioName]?['disabled'] != '1'; - final isIfaceEnabled = config['disabled'] != '1' && - config['disabled'] != 1 && - config['disabled'] != true; - final isEnabled = isRadioEnabled && isIfaceEnabled; - - final name = iface['name'] ?? ''; - final ssid = iwinfo['ssid'] ?? config['ssid'] ?? ''; - final deviceName = config['device'] ?? radioName; - final mode = config['mode'] ?? iwinfo['mode'] ?? 'N/A'; - - // Build encryption description - final encIwinfo = iwinfo['encryption'] as Map?; - final encDescription = encIwinfo?['description'] ?? config['encryption'] ?? 'N/A'; - - interfacesList.add({ - 'name': config['ssid'] ?? iwinfo['ssid'] ?? 'Unnamed', - 'subtitle': - '${mode.toString().toUpperCase()} • Ch. ${iwinfo['channel']?.toString() ?? config['channel']?.toString() ?? 'N/A'}', - 'isEnabled': isEnabled, - 'isIfaceEnabled': isIfaceEnabled, - 'isRadioEnabled': isRadioEnabled, - 'deviceName': deviceName, - 'radioName': radioName, - 'ssid': ssid, - 'interfaceName': name, - 'uciSection': uciName ?? '', - 'mode': mode, - 'encryption': config['encryption'] ?? '', - 'encryptionDescription': encDescription, - 'network': (config['network'] is List) - ? (config['network'] as List).join(', ') - : config['network']?.toString() ?? '', - 'channel': iwinfo['channel']?.toString() ?? - config['channel']?.toString() ?? 'auto', - 'signal': iwinfo['signal']?.toString() ?? '--', - 'details': { - 'Device': config['device'] ?? radioName, - 'Mode': mode, - 'Channel': - iwinfo['channel']?.toString() ?? - config['channel']?.toString() ?? - 'N/A', - 'Signal': '${iwinfo['signal']?.toString() ?? '--'} dBm', - 'Network': (config['network'] is List) - ? (config['network'] as List).join(', ') - : config['network'] ?? 'N/A', - }, - }); - } - } - }); - } - - uciInterfaces.forEach((uciName, config) { - if (!runtimeInterfaces.contains(uciName)) { - final radioName = config['device'] ?? ''; - final isRadioEnabled = uciRadios[radioName]?['disabled'] != '1'; - final isIfaceEnabled = config['disabled'] != '1'; - final isEnabled = isRadioEnabled && isIfaceEnabled; - final mode = config['mode'] ?? 'N/A'; - - final name = config['ssid'] ?? 'Unnamed'; - interfacesList.add({ - 'name': config['ssid'] ?? 'Unnamed', - 'subtitle': '${mode.toString().toUpperCase()} • Disabled', - 'isEnabled': isEnabled, - 'isIfaceEnabled': isIfaceEnabled, - 'isRadioEnabled': isRadioEnabled, - 'deviceName': radioName, - 'radioName': radioName, - 'ssid': name, - 'interfaceName': name, - 'uciSection': uciName, - 'mode': mode, - 'encryption': config['encryption'] ?? '', - 'encryptionDescription': config['encryption'] ?? 'N/A', - 'network': (config['network'] is List) - ? (config['network'] as List).join(', ') - : config['network']?.toString() ?? '', - 'channel': config['channel']?.toString() ?? 'auto', - 'signal': '--', - 'details': { - 'Device': radioName, - 'Mode': mode, - 'SSID': config['ssid'] ?? 'N/A', - 'Network': (config['network'] is List) - ? (config['network'] as List).join(', ') - : config['network'] ?? 'N/A', - }, - }); - } - }); - - final interfaces = interfacesList; - if (interfaces.isEmpty) { - return const SliverToBoxAdapter(child: SizedBox.shrink()); - } - return SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final iface = interfaces[index]; - final deviceName = iface['deviceName'] ?? ''; - final radioName = iface['radioName'] ?? ''; - final ssid = iface['ssid'] ?? ''; - final name = iface['interfaceName'] ?? ''; - // Use the stored values for key generation - final keyStr = _interfaceKeyForWireless( - ssid: ssid, - radioName: radioName, - deviceName: deviceName, - name: name, - ); - final key = _interfaceKeys.putIfAbsent(keyStr, () => GlobalKey()); - final displayName = ssid.toString().isNotEmpty - ? ssid.toString() - : deviceName.toString(); - - // Check if this is the target interface for expansion - final isTargetInterface = - _targetInterface != null && - (_normalizeInterfaceKey(ssid) == - _normalizeInterfaceKey(_targetInterface!) || - _normalizeInterfaceKey(deviceName) == - _normalizeInterfaceKey(_targetInterface!) || - _normalizeInterfaceKey(name) == - _normalizeInterfaceKey(_targetInterface!)); - - final shouldExpand = isTargetInterface || _expandedInterface == keyStr; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: _UnifiedNetworkCard( - key: key, - name: displayName, - subtitle: iface['subtitle'], - isUp: iface['isEnabled'], - icon: Icons.wifi, - details: _buildWirelessDetails(context, iface), - initiallyExpanded: shouldExpand, - ), - ); - }, childCount: interfaces.length), - ); - } - - Widget _buildWirelessDetails( - BuildContext context, - Map iface, - ) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final details = iface['details'] as Map; - final uciSection = iface['uciSection'] as String? ?? ''; - final isIfaceEnabled = iface['isIfaceEnabled'] as bool? ?? true; - final mode = iface['mode']?.toString() ?? ''; - final encDescription = iface['encryptionDescription']?.toString() ?? ''; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Details rows - ...details.entries.map((entry) { - return _buildDetailRow(context, entry.key, entry.value.toString()); - }), - // Encryption row - if (encDescription.isNotEmpty && encDescription != 'N/A') - _buildDetailRow(context, 'Encryption', encDescription), - - const Divider(height: 1, indent: 16, endIndent: 16), - const SizedBox(height: 8), - - // Management action buttons - if (uciSection.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), - child: Column( - children: [ - // Enable/Disable toggle row - _WifiToggleRow( - uciSection: uciSection, - isEnabled: isIfaceEnabled, - ), - - const SizedBox(height: 8), - - // Action buttons row - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () => _showEditWifiSheet(context, iface), - icon: Icon(Icons.edit_outlined, size: 18), - label: const Text('Edit'), - style: OutlinedButton.styleFrom( - foregroundColor: colorScheme.primary, - side: BorderSide( - color: colorScheme.primary.withValues(alpha: 0.5), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric(vertical: 10), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - onPressed: () => - _showDeleteWifiDialog(context, iface), - icon: Icon(Icons.delete_outline, size: 18), - label: const Text('Remove'), - style: OutlinedButton.styleFrom( - foregroundColor: colorScheme.error, - side: BorderSide( - color: colorScheme.error.withValues(alpha: 0.5), - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric(vertical: 10), - ), - ), - ), - ], - ), - - const SizedBox(height: 4), - - // Mode label - if (mode.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - mode.toLowerCase() == 'sta' - ? 'Client (Station) mode' - : mode.toLowerCase() == 'ap' - ? 'Access Point mode' - : '${mode.toUpperCase()} mode', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - ], - ), - ) - else - // No UCI section - just show details - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text( - 'UCI section unavailable — limited management', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - - const SizedBox(height: 8), - ], - ); - } - - void _showEditWifiSheet( - BuildContext context, - Map iface, - ) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => _WifiEditBottomSheet(iface: iface), - ); - } - - void _showDeleteWifiDialog( - BuildContext context, - Map iface, - ) { - final ssid = iface['ssid']?.toString() ?? 'this interface'; - final uciSection = iface['uciSection'] as String? ?? ''; - if (uciSection.isEmpty) return; - - showDialog( - context: context, - builder: (ctx) => _WifiDeleteDialog( - ssid: ssid, - uciSection: uciSection, - ), - ); - } - - Widget _buildWiredDetails(BuildContext context, NetworkInterface interface) { - return Column( - children: [ - _buildDetailRow(context, 'Device', interface.device), - _buildDetailRow(context, 'Uptime', interface.formattedUptime), - if (interface.ipAddress != null) - _buildDetailRow( - context, - 'IP Address', - interface.ipAddress!, - onTap: () => - _copyToClipboard(context, interface.ipAddress!, 'IP Address'), - ), - if (interface.ipv6Addresses != null && - interface.ipv6Addresses!.isNotEmpty) - ...interface.ipv6Addresses!.map( - (ipv6) => _buildDetailRow( - context, - 'IPv6 Address', - ipv6, - onTap: () => _copyToClipboard(context, ipv6, 'IPv6 Address'), - ), - ), - if (interface.gateway != null) - _buildDetailRow( - context, - 'Gateway', - interface.gateway!, - onTap: () => - _copyToClipboard(context, interface.gateway!, 'Gateway IP'), - ), - if (interface.dnsServers.isNotEmpty) - _buildDetailRow( - context, - 'DNS', - interface.dnsServers.join(', '), - onTap: () => _copyToClipboard( - context, - interface.dnsServers.join(', '), - 'DNS Servers', - ), - ), - // Add WireGuard peer information if this is a WireGuard interface - if (interface.protocol.toLowerCase() == 'wireguard') ...[ - Builder( - builder: (context) { - return _buildWireGuardPeersSection(context, interface.name); - }, - ), - ], - const Divider(height: 1, indent: 16, endIndent: 16), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: _buildStatsRow(context, interface.stats), - ), - ], - ); - } - - Widget _buildWireGuardPeersSection( - BuildContext context, - String interfaceName, - ) { - final appState = ref.watch(appStateProvider); - final wireguardData = - appState.dashboardData?['wireguard'] as Map?; - final peerData = wireguardData?[interfaceName]; - if (peerData == null) { - return const SizedBox.shrink(); - } - final peers = peerData['peers'] as Map?; - if (peers == null || peers.isEmpty) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: const Divider(height: 24, thickness: 1, indent: 0, endIndent: 0), - ); - } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(height: 1, thickness: 1, indent: 0, endIndent: 0), - const SizedBox(height: 8), - ...peers.values.map( - (peer) => - _buildCohesivePeerRow(context, peer as Map), - ), - const SizedBox(height: 8), - ], - ), - ); - } - - Widget _buildCohesivePeerRow( - BuildContext context, - Map peer, - ) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final publicKey = peer['public_key'] as String? ?? 'Unknown'; - final endpoint = peer['endpoint'] as String? ?? 'N/A'; - final peerName = peer['name'] as String?; - int lastHandshake = 0; - final rawHandshake = peer['last_handshake'] ?? peer['latest_handshake']; - if (rawHandshake != null) { - if (rawHandshake is int) { - lastHandshake = rawHandshake; - } else if (rawHandshake is String) { - lastHandshake = int.tryParse(rawHandshake) ?? 0; - } - } - final displayKey = publicKey.length > 16 - ? '${publicKey.substring(0, 8)}...${publicKey.substring(publicKey.length - 8)}' - : publicKey; - String formatHandshakeTime(int timestamp) { - if (timestamp == 0) return 'Never'; - final now = DateTime.now(); - final handshakeTime = DateTime.fromMillisecondsSinceEpoch( - timestamp * 1000, - ); - final difference = now.difference(handshakeTime); - if (difference.inSeconds < 0) return 'Never'; - if (difference.inDays > 0) { - return '${difference.inDays}d ago'; - } else if (difference.inHours > 0) { - return '${difference.inHours}h ago'; - } else if (difference.inMinutes > 0) { - return '${difference.inMinutes}m ago'; - } else { - return '${difference.inSeconds}s ago'; - } - } - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.vpn_key, size: 18, color: colorScheme.primary), - const SizedBox(width: 8), - Flexible( - child: Text( - displayKey, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - color: colorScheme.onSurface, - fontSize: 14, - ), - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - if (peerName != null && peerName.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - peerName, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.normal, - ), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Last Handshake', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 12, - ), - ), - const SizedBox(height: 2), - Text( - formatHandshakeTime(lastHandshake), - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - fontSize: 14, - color: colorScheme.onSurface, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - const SizedBox(width: 24), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - 'Endpoint', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 12, - ), - ), - const SizedBox(height: 2), - Text( - endpoint, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - fontSize: 14, - color: colorScheme.onSurface, - ), - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildGenericDetails( - BuildContext context, - Map details, - ) { - return Column( - children: details.entries.map((entry) { - return _buildDetailRow(context, entry.key, entry.value.toString()); - }).toList(), - ); - } - - Widget _buildDetailRow( - BuildContext context, - String title, - String value, { - VoidCallback? onTap, - }) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - title, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface, - ), - ), - Row( - children: [ - Text( - value, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - color: colorScheme.onSurface, - ), - textAlign: TextAlign.end, - overflow: TextOverflow.ellipsis, - ), - if (onTap != null) - GestureDetector( - onTap: onTap, - child: const Padding( - padding: EdgeInsets.only(left: 8.0), - child: Icon( - Icons.copy_all_outlined, - size: 16, - semanticLabel: 'Copy', - ), - ), - ), - ], - ), - ], - ), - ), - ); - } - - void _copyToClipboard(BuildContext context, String text, String label) { - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('$label copied to clipboard'), - duration: const Duration(seconds: 2), - ), - ); - } - - Widget _buildStatsRow(BuildContext context, Map stats) { - String formatBytes(int bytes) { - if (bytes <= 0) return '0 B'; - const suffixes = ["B", "KB", "MB", "GB", "TB"]; - var i = (log(bytes) / log(1024)).floor(); - return '${(bytes / pow(1024, i)).toStringAsFixed(2)} ${suffixes[i]}'; - } - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _buildStatColumn( - context, - 'Received', - formatBytes(stats['rx_bytes'] ?? 0), - Icons.arrow_downward, - Colors.green, - ), - _buildStatColumn( - context, - 'Transmitted', - formatBytes(stats['tx_bytes'] ?? 0), - Icons.arrow_upward, - Colors.blue, - ), - ], - ); - } - - Widget _buildStatColumn( - BuildContext context, - String label, - String value, - IconData icon, - Color color, - ) { - final theme = Theme.of(context); - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 4), - Text( - label, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurface, - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - value, - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - ], - ); - } - - IconData _getInterfaceIcon(String protocol) { - switch (protocol.toLowerCase()) { - case 'wireguard': - return Icons.shield_outlined; - case 'static': - return Icons.settings_ethernet; - case 'dhcp': - return Icons.dns_outlined; - default: - return Icons.device_hub_outlined; - } - } - - String _buildMinimalInterfaceSubtitle(NetworkInterface iface) { - final v4 = iface.ipAddress; - final v6s = iface.ipv6Addresses ?? []; - final v6 = v6s.isNotEmpty ? v6s.first : null; - String? shown; - int extra = 0; - if (v4 != null) { - shown = v4; - if (v6 != null) extra++; - } else if (v6 != null) { - shown = v6; - } - if (shown == null) return iface.protocol; - if (extra > 0) { - return '${iface.protocol} • $shown +$extra'; - } else { - return '${iface.protocol} • $shown'; - } - } -} - -class LuciSectionHeader extends StatelessWidget { - final String title; - const LuciSectionHeader(this.title, {super.key}); - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: Text( - title, - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - ), - ), - ); - } -} - -class _UnifiedNetworkCard extends StatefulWidget { - final String name; - final String subtitle; - final bool isUp; - final IconData icon; - final Widget details; - final bool initiallyExpanded; - - const _UnifiedNetworkCard({ - required this.name, - required this.subtitle, - required this.isUp, - required this.icon, - required this.details, - this.initiallyExpanded = false, - super.key, - }); - - @override - State<_UnifiedNetworkCard> createState() => _UnifiedNetworkCardState(); -} - -class _UnifiedNetworkCardState extends State<_UnifiedNetworkCard> - with SingleTickerProviderStateMixin { - bool _isExpanded = false; - late AnimationController _controller; - @override - void initState() { - super.initState(); - _isExpanded = widget.initiallyExpanded; - _controller = AnimationController( - duration: const Duration(milliseconds: 400), - vsync: this, - ); - if (widget.initiallyExpanded) { - _controller.forward(); - } - } - - @override - void didUpdateWidget(covariant _UnifiedNetworkCard oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.initiallyExpanded != oldWidget.initiallyExpanded) { - setState(() { - _isExpanded = widget.initiallyExpanded; - if (_isExpanded) { - _controller.forward(); - } else { - _controller.reverse(); - } - }); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _toggleExpand() { - setState(() { - _isExpanded = !_isExpanded; - if (_isExpanded) { - _controller.forward(); - } else { - _controller.reverse(); - } - }); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final card = Card( - elevation: _isExpanded ? 6 : 2, - margin: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: LuciCardStyles.standardRadius, - side: BorderSide( - color: widget.initiallyExpanded && _isExpanded - ? colorScheme.primary.withValues(alpha: 0.3) - : colorScheme.surfaceContainerHighest.withValues(alpha: 0.10), - width: widget.initiallyExpanded && _isExpanded ? 2 : 1, - ), - ), - clipBehavior: Clip.antiAlias, - child: AnimatedScale( - scale: widget.initiallyExpanded && _isExpanded ? 1.02 : 1.0, - duration: LuciAnimations.standard, - curve: Curves.easeOutBack, - child: Column( - children: [ - InkWell( - onTap: _toggleExpand, - borderRadius: LuciCardStyles.standardRadius, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: LuciSpacing.lg, - vertical: 10.0, - ), - child: Row( - children: [ - Stack( - alignment: Alignment.topRight, - children: [ - Container( - padding: const EdgeInsets.all(8.0), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues( - alpha: 0.13, - ), - shape: BoxShape.circle, - ), - child: AnimatedScale( - scale: widget.initiallyExpanded && _isExpanded - ? 1.1 - : 1.0, - duration: const Duration(milliseconds: 500), - curve: Curves.elasticOut, - child: Icon( - widget.icon, - color: widget.isUp - ? colorScheme.primary - : colorScheme.onSurface, - size: 22, - semanticLabel: 'Interface icon', - ), - ), - ), - Positioned( - right: 0, - top: 0, - child: Tooltip( - message: widget.isUp - ? 'Interface is up' - : 'Interface is down', - child: LuciStatusIndicators.statusDot( - context, - widget.isUp, - ), - ), - ), - ], - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.name, - style: LuciTextStyles.cardTitle(context), - semanticsLabel: 'Interface name: ${widget.name}', - ), - const SizedBox(height: LuciSpacing.xs), - Container( - margin: const EdgeInsets.only(right: 32), - child: Divider( - color: colorScheme.surfaceContainerHighest - .withValues(alpha: 0.10), - thickness: 1, - height: 8, - ), - ), - Text( - widget.subtitle, - style: LuciTextStyles.cardSubtitle(context), - semanticsLabel: - 'Interface details: ${widget.subtitle}', - ), - ], - ), - ), - if (!widget.isUp) - Padding( - padding: const EdgeInsets.only(right: LuciSpacing.xs), - child: LuciStatusIndicators.statusChip( - context, - 'OFF', - false, - ), - ), - const SizedBox(width: LuciSpacing.sm), - Icon( - _isExpanded ? Icons.expand_less : Icons.expand_more, - color: colorScheme.onSurfaceVariant, - size: 26, - semanticLabel: _isExpanded - ? 'Collapse details' - : 'Expand details', - ), - ], - ), - ), - ), - if (_isExpanded) - Column( - children: [ - const Divider(height: 1, indent: 18, endIndent: 18), - widget.details, - ], - ), - ], - ), - ), - ); - - if (!widget.isUp) { - return ColorFiltered( - colorFilter: const ColorFilter.matrix([ - 0.2126, - 0.7152, - 0.0722, - 0, - 0, - 0.2126, - 0.7152, - 0.0722, - 0, - 0, - 0.2126, - 0.7152, - 0.0722, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - ]), - child: card, - ); - } - return card; - } -} - -// ────────────────────────────────────────────────────────────────── -// WiFi Enable/Disable Toggle Row -// ────────────────────────────────────────────────────────────────── - -class _WifiToggleRow extends ConsumerStatefulWidget { - final String uciSection; - final bool isEnabled; - - const _WifiToggleRow({ - required this.uciSection, - required this.isEnabled, - }); - - @override - ConsumerState<_WifiToggleRow> createState() => _WifiToggleRowState(); -} - -class _WifiToggleRowState extends ConsumerState<_WifiToggleRow> { - bool _isToggling = false; - - Future _toggle(bool value) async { - if (_isToggling) return; - setState(() => _isToggling = true); - - final appState = ref.read(appStateProvider); - final success = await appState.setWirelessInterfaceEnabled( - widget.uciSection, - value, - context: context, - ); - - if (mounted) { - setState(() => _isToggling = false); - if (!success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Failed to toggle interface'), - backgroundColor: Theme.of(context).colorScheme.error, - behavior: SnackBarBehavior.floating, - ), - ); - } - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - widget.isEnabled ? Icons.wifi : Icons.wifi_off, - size: 20, - color: widget.isEnabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - widget.isEnabled ? 'Interface Enabled' : 'Interface Disabled', - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - ), - if (_isToggling) - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - Switch( - value: widget.isEnabled, - onChanged: _toggle, - ), - ], - ), - ); - } -} - -// ────────────────────────────────────────────────────────────────── -// WiFi Edit Bottom Sheet -// ────────────────────────────────────────────────────────────────── - -class _WifiEditBottomSheet extends ConsumerStatefulWidget { - final Map iface; - - const _WifiEditBottomSheet({required this.iface}); - - @override - ConsumerState<_WifiEditBottomSheet> createState() => - _WifiEditBottomSheetState(); -} - -class _WifiEditBottomSheetState extends ConsumerState<_WifiEditBottomSheet> { - late TextEditingController _ssidController; - late TextEditingController _passwordController; - late TextEditingController _networkController; - late String _selectedEncryption; - bool _obscurePassword = true; - bool _isSaving = false; - String? _error; - - static const _encryptionOptions = [ - {'value': 'none', 'label': 'None (Open)'}, - {'value': 'psk2', 'label': 'WPA2-PSK'}, - {'value': 'psk', 'label': 'WPA-PSK'}, - {'value': 'psk-mixed', 'label': 'WPA/WPA2 Mixed PSK'}, - {'value': 'sae', 'label': 'WPA3-SAE'}, - {'value': 'sae-mixed', 'label': 'WPA2/WPA3 Mixed'}, - ]; - - @override - void initState() { - super.initState(); - _ssidController = TextEditingController( - text: widget.iface['ssid']?.toString() ?? '', - ); - _passwordController = TextEditingController(); - _networkController = TextEditingController( - text: widget.iface['network']?.toString() ?? 'lan', - ); - - // Map the current encryption to our dropdown values - final currentEnc = widget.iface['encryption']?.toString() ?? 'none'; - _selectedEncryption = _encryptionOptions.any( - (o) => o['value'] == currentEnc, - ) - ? currentEnc - : 'psk2'; - } - - @override - void dispose() { - _ssidController.dispose(); - _passwordController.dispose(); - _networkController.dispose(); - super.dispose(); - } - - bool get _requiresPassword => _selectedEncryption != 'none'; - - Future _save() async { - final ssid = _ssidController.text.trim(); - if (ssid.isEmpty) { - setState(() => _error = 'SSID cannot be empty.'); - return; - } - - if (_requiresPassword && - _passwordController.text.isNotEmpty && - _passwordController.text.length < 8 && - _selectedEncryption != 'none') { - setState( - () => _error = 'Password must be at least 8 characters.', - ); - return; - } - - setState(() { - _isSaving = true; - _error = null; - }); - - final uciSection = widget.iface['uciSection'] as String? ?? ''; - if (uciSection.isEmpty) { - setState(() { - _isSaving = false; - _error = 'Cannot identify UCI section for this interface.'; - }); - return; - } - - // Build values to update - final values = { - 'ssid': ssid, - 'encryption': _selectedEncryption, - }; - - // Only update password if user typed one - if (_passwordController.text.isNotEmpty) { - values['key'] = _passwordController.text; - } - - // Update network binding - final network = _networkController.text.trim(); - if (network.isNotEmpty) { - values['network'] = network; - } - - final appState = ref.read(appStateProvider); - final success = await appState.modifyWirelessInterface( - uciSection, - values, - context: context, - ); - - if (!mounted) return; - - if (success) { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.check_circle, color: Colors.white, size: 20), - const SizedBox(width: 8), - Text('Updated "$ssid" — reloading WiFi...'), - ], - ), - backgroundColor: Colors.green.shade700, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - duration: const Duration(seconds: 3), - ), - ); - } else { - setState(() { - _isSaving = false; - _error = 'Failed to save changes. Please try again.'; - }); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final mode = widget.iface['mode']?.toString() ?? 'ap'; - - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 16, 24, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Drag handle - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 20), - - // Header - Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.primaryContainer.withValues(alpha: 0.3), - shape: BoxShape.circle, - ), - child: Icon( - Icons.edit, - color: colorScheme.primary, - size: 24, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Edit Wireless Interface', - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - '${widget.iface['radioName']} • ${mode.toUpperCase()} mode', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), - ], - ), - - const SizedBox(height: 24), - - // SSID field - _buildLabel(context, 'SSID (Network Name)'), - const SizedBox(height: 6), - TextField( - controller: _ssidController, - enabled: !_isSaving, - decoration: _inputDecoration( - context, - hintText: 'Enter SSID', - prefixIcon: Icons.wifi, - ), - ), - - const SizedBox(height: 16), - - // Encryption selector - _buildLabel(context, 'Encryption'), - const SizedBox(height: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: colorScheme.outline.withValues(alpha: 0.3), - ), - color: colorScheme.surfaceContainerLow, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _selectedEncryption, - isExpanded: true, - icon: Icon( - Icons.arrow_drop_down, - color: colorScheme.onSurfaceVariant, - ), - items: _encryptionOptions.map((opt) { - return DropdownMenuItem( - value: opt['value'], - child: Text( - opt['label']!, - style: theme.textTheme.bodyMedium, - ), - ); - }).toList(), - onChanged: _isSaving - ? null - : (value) { - if (value != null) { - setState(() => _selectedEncryption = value); - } - }, - ), - ), - ), - - // Password field (only for encrypted networks) - if (_requiresPassword) ...[ - const SizedBox(height: 16), - _buildLabel(context, 'Password (leave empty to keep current)'), - const SizedBox(height: 6), - TextField( - controller: _passwordController, - obscureText: _obscurePassword, - enabled: !_isSaving, - decoration: _inputDecoration( - context, - hintText: 'Enter new password', - prefixIcon: Icons.key, - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? Icons.visibility_off - : Icons.visibility, - ), - onPressed: () { - setState(() => _obscurePassword = !_obscurePassword); - }, - ), - ), - ), - ], - - const SizedBox(height: 16), - - // Network binding - _buildLabel(context, 'Network'), - const SizedBox(height: 6), - TextField( - controller: _networkController, - enabled: !_isSaving, - decoration: _inputDecoration( - context, - hintText: 'e.g., lan, wwan', - prefixIcon: Icons.lan_outlined, - ), - ), - - // Error - if (_error != null) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.errorContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.error_outline, color: colorScheme.error, - size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - _error!, - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.error, - ), - ), - ), - ], - ), - ), - ], - - const SizedBox(height: 20), - - // Warning - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.info_outline, color: colorScheme.tertiary, - size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Changes will be applied immediately. WiFi will briefly restart.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ), - ], - ), - ), - - const SizedBox(height: 20), - - // Save button - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _isSaving ? null : _save, - icon: _isSaving - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: colorScheme.onPrimary, - ), - ) - : const Icon(Icons.save), - label: Text( - _isSaving ? 'Applying...' : 'Save Changes', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - ), - ), - - const SizedBox(height: 8), - ], - ), - ), - ), - ); - } - - Widget _buildLabel(BuildContext context, String text) { - return Text( - text, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ); - } - - InputDecoration _inputDecoration( - BuildContext context, { - required String hintText, - required IconData prefixIcon, - Widget? suffixIcon, - }) { - final colorScheme = Theme.of(context).colorScheme; - return InputDecoration( - hintText: hintText, - prefixIcon: Icon(prefixIcon), - suffixIcon: suffixIcon, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.3), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.primary, width: 2), - ), - filled: true, - fillColor: colorScheme.surfaceContainerLow, - ); - } -} - -// ────────────────────────────────────────────────────────────────── -// WiFi Delete Confirmation Dialog -// ────────────────────────────────────────────────────────────────── - -class _WifiDeleteDialog extends ConsumerStatefulWidget { - final String ssid; - final String uciSection; - - const _WifiDeleteDialog({ - required this.ssid, - required this.uciSection, - }); - - @override - ConsumerState<_WifiDeleteDialog> createState() => _WifiDeleteDialogState(); -} - -class _WifiDeleteDialogState extends ConsumerState<_WifiDeleteDialog> { - bool _isDeleting = false; - - Future _delete() async { - setState(() => _isDeleting = true); - - final appState = ref.read(appStateProvider); - final success = await appState.deleteWirelessInterface( - widget.uciSection, - context: context, - ); - - if (!mounted) return; - - Navigator.of(context).pop(); - - if (success) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.check_circle, color: Colors.white, size: 20), - const SizedBox(width: 8), - Text('"${widget.ssid}" removed'), - ], - ), - backgroundColor: Colors.green.shade700, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - duration: const Duration(seconds: 3), - ), - ); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('Failed to remove interface'), - backgroundColor: Theme.of(context).colorScheme.error, - behavior: SnackBarBehavior.floating, - ), - ); - } - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final displayName = widget.ssid.isNotEmpty ? widget.ssid : widget.uciSection; - - return AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - icon: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: colorScheme.errorContainer.withValues(alpha: 0.3), - shape: BoxShape.circle, - ), - child: Icon(Icons.delete_forever, color: colorScheme.error, size: 32), - ), - title: const Text('Remove Wireless Interface?'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'This will permanently remove "$displayName" from your wireless configuration.', - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.errorContainer.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.warning_amber, color: colorScheme.error, size: 18), - const SizedBox(width: 8), - Expanded( - child: Text( - 'WiFi will restart. Clients on this network will be disconnected.', - style: theme.textTheme.bodySmall?.copyWith( + child: Text( + warningMessage, + style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.error, ), ), diff --git a/lib/screens/wifi_scan_screen.dart b/lib/screens/wifi_scan_screen.dart index e579ff5..9df4d3d 100644 --- a/lib/screens/wifi_scan_screen.dart +++ b/lib/screens/wifi_scan_screen.dart @@ -847,6 +847,7 @@ class _ConnectBottomSheetState extends ConsumerState<_ConnectBottomSheet> { ssid: widget.network.ssid, encryption: widget.network.encryption.openwrtEncryption, password: _passwordController.text, + bssid: widget.network.bssid, context: context, ); diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index c3225f0..032f089 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1494,7 +1494,7 @@ class AppState extends ChangeNotifier { required String ssid, required String encryption, String password = '', - String networkName = 'wwan', + String? bssid, BuildContext? context, }) async { if (_reviewerModeEnabled) { @@ -1512,8 +1512,15 @@ class AppState extends ChangeNotifier { final auth = _authService!.sysauth!; final https = _authService!.useHttps; - // Find next available wifinet# name to avoid anonymous sections + // Use radio-specific network name to avoid conflicts between radios + // radio0 -> wwan, radio1 -> wwan1, radio2 -> wwan2, etc + final radioIndex = int.tryParse(radioDevice.replaceAll('radio', '')) ?? 0; + final staNetworkName = radioIndex == 0 ? 'wwan' : 'wwan$radioIndex'; + + // Find next available wifinet# name properly String sectionName = 'wifinet0'; + bool existingStaUpdated = false; + try { final uciResult = await _apiService!.uciGetAll( ip, auth, https, @@ -1521,46 +1528,173 @@ class AppState extends ChangeNotifier { context: context, ); if (uciResult is List && uciResult.length > 1 && uciResult[1] is Map) { - final sections = (uciResult[1] as Map).keys.toSet(); - int idx = 0; - while (sections.contains('wifinet$idx')) { - idx++; + final wirelessConfig = uciResult[1] as Map; + final sections = wirelessConfig.keys.toSet(); + + // Find existing STA on this radio - if exists, update it + String? existingStaOnThisRadio; + for (final sectionKey in sections) { + final section = wirelessConfig[sectionKey]; + if (section is Map) { + final device = section['device']?.toString(); + final mode = section['mode']?.toString(); + if (device == radioDevice && mode == 'sta') { + existingStaOnThisRadio = sectionKey.toString(); + break; + } + } + } + + if (existingStaOnThisRadio != null) { + // Found existing STA on this radio - update it + Logger.info('Found existing STA on $radioDevice, updating section $existingStaOnThisRadio'); + await _apiService!.uciSet( + ip, auth, https, + config: 'wireless', + section: existingStaOnThisRadio, + values: { + 'network': staNetworkName, + 'ssid': ssid, + 'encryption': encryption, + if (password.isNotEmpty) 'key': password, + if (bssid != null && bssid.isNotEmpty) 'bssid': bssid, + }, + context: context, + ); + existingStaUpdated = true; + sectionName = existingStaOnThisRadio; + } else { + // No existing STA on this radio - create new with proper wifinet# + Logger.info('No existing STA on $radioDevice, creating new section'); + int maxIdx = -1; + for (final key in sections) { + if (key.toString().startsWith('wifinet')) { + final numStr = key.toString().replaceAll('wifinet', ''); + final num = int.tryParse(numStr); + if (num != null && num > maxIdx) { + maxIdx = num; + } + } + } + sectionName = 'wifinet${maxIdx + 1}'; + Logger.info('Selected new section name: $sectionName (max was $maxIdx)'); } - sectionName = 'wifinet$idx'; } } catch (e) { - Logger.warning('Could not query existing sections, using $sectionName: $e'); + Logger.warning('Could not query existing sections: $e'); } - Logger.info('Creating named wifi-iface section: $sectionName'); + // If we didn't update an existing STA, create new one + if (!existingStaUpdated) { + Logger.info('Creating new wifi-iface section: $sectionName with network: $staNetworkName'); - // Build the values for the new wifi-iface - final values = { - 'device': radioDevice, - 'network': networkName, - 'mode': 'sta', - 'ssid': ssid, - 'encryption': encryption, - }; - if (password.isNotEmpty) { - values['key'] = password; - } + final values = { + 'device': radioDevice, + 'network': staNetworkName, + 'mode': 'sta', + 'ssid': ssid, + 'encryption': encryption, + }; + if (password.isNotEmpty) { + values['key'] = password; + } + if (bssid != null && bssid.isNotEmpty) { + values['bssid'] = bssid; + } - // 1. Add a named wifi-iface section - final addResult = await _apiService!.uciAdd( - ip, - auth, - https, - config: 'wireless', - type: 'wifi-iface', - name: sectionName, - values: values, - context: context, - ); + // 1. Add a named wifi-iface section + final addResult = await _apiService!.uciAdd( + ip, + auth, + https, + config: 'wireless', + type: 'wifi-iface', + name: sectionName, + values: values, + context: context, + ); - Logger.info('UCI add result: $addResult'); + Logger.info('UCI add result: $addResult'); + + // 2. Create network interface if needed (for wwan1, wwan2, etc.) + if (staNetworkName != 'wwan') { + try { + await _apiService!.uciAdd( + ip, auth, https, + config: 'network', + type: 'interface', + name: staNetworkName, + values: {'proto': 'dhcp'}, + context: context, + ); + Logger.info('Created network interface: $staNetworkName'); + } catch (e) { + Logger.warning('Network interface may already exist: $e'); + } + } + + // 3. Add to firewall WAN zone if not already there + try { + final firewallResult = await _apiService!.uciGetAll( + ip, auth, https, + config: 'firewall', + context: context, + ); + bool foundInWan = false; + int wanZoneIndex = -1; + if (firewallResult is List && firewallResult.length > 1 && firewallResult[1] is Map) { + final firewallConfig = firewallResult[1] as Map; + int zoneIndex = 0; + for (final key in firewallConfig.keys) { + final section = firewallConfig[key]; + if (section is Map) { + final typeName = section['.type']?.toString() ?? key.toString().split('@').first; + // Check if this is a zone section + if (typeName == 'zone' || (section.containsKey('name') && section.containsKey('input'))) { + final zoneName = section['name']?.toString(); + if (zoneName == 'wan') { + wanZoneIndex = zoneIndex; + // Check if network is already in this zone + final networks = section['network']; + if (networks is String && networks == staNetworkName) { + foundInWan = true; + break; + } else if (networks is String && networks.contains(staNetworkName)) { + foundInWan = true; + break; + } else if (networks is List && networks.contains(staNetworkName)) { + foundInWan = true; + break; + } + } + zoneIndex++; + } + } + } + } - // 2. Commit wireless configuration + if (!foundInWan && wanZoneIndex >= 0) { + // Use system command to add network to wan zone + // The systemExec splits by whitespace, so we pass each part separately + await _apiService!.systemExec( + ip, auth, https, + command: "uci add_list firewall.@zone[$wanZoneIndex].network=$staNetworkName", + context: context, + ); + // Commit firewall changes + await _apiService!.uciCommit( + ip, auth, https, + config: 'firewall', + context: context, + ); + Logger.info('Added $staNetworkName to WAN firewall zone at index $wanZoneIndex'); + } + } catch (e) { + Logger.warning('Failed to add to firewall zone: $e'); + } + } + + // 4. Commit wireless configuration await _apiService!.uciCommit( ip, auth, @@ -1569,7 +1703,27 @@ class AppState extends ChangeNotifier { context: context?.mounted == true ? context : null, ); - // 3. Restart radio to apply changes + // 5. Commit network and firewall configuration + try { + await _apiService!.uciCommit( + ip, auth, https, + config: 'network', + context: context?.mounted == true ? context : null, + ); + } catch (e) { + Logger.warning('Network commit failed: $e'); + } + try { + await _apiService!.uciCommit( + ip, auth, https, + config: 'firewall', + context: context?.mounted == true ? context : null, + ); + } catch (e) { + Logger.warning('Firewall commit failed: $e'); + } + + // 6. Restart radio to apply changes await _restartRadioViaUci(radioDevice, context: context); return true; @@ -1688,11 +1842,13 @@ class AppState extends ChangeNotifier { } } - /// Deletes a wifi-iface UCI section and reloads wifi. + /// Deletes a wifi-iface UCI section. /// /// [uciSection] is the UCI section name to remove. + /// [mode] is the interface mode ('ap' or 'sta') - if 'ap', WiFi will reload. Future deleteWirelessInterface( String uciSection, { + String mode = 'ap', BuildContext? context, }) async { if (_reviewerModeEnabled) { @@ -1723,7 +1879,19 @@ class AppState extends ChangeNotifier { context: context?.mounted == true ? context : null, ); - await _wifiReload(context: context); + // Only reload WiFi for AP mode - STA deletion doesn't require radio restart + if (mode.toLowerCase().contains('sta') || + mode.toLowerCase().contains('client') || + mode.toLowerCase() == 'station') { + // STA mode - just refresh data, no radio restart + await Future.delayed(const Duration(seconds: 2)); + try { + await fetchDashboardData(); + } catch (_) {} + } else { + // AP mode - restart WiFi + await _wifiReload(context: context); + } return true; } catch (e, stack) {