diff --git a/definitions/wifi/mac_filter_access_points.yaml b/definitions/wifi/mac_filter_access_points.yaml new file mode 100644 index 000000000..2866df8a4 --- /dev/null +++ b/definitions/wifi/mac_filter_access_points.yaml @@ -0,0 +1,28 @@ +# WiFi access point MAC address filtering (Instant Privacy) +# TR-181: Device.WiFi.AccessPoint.*.MACAddressControlEnabled +# Device.WiFi.AccessPoint.*.AllowedMACAddress + +name: MacFilterAccessPoints +singularName: MacFilterAccessPoint +version: 1.0.0 +multiInstance: Device.WiFi.AccessPoint. +category: wifi +description: WiFi access point MAC address filtering control + +parameters: + - field_name: ssidReference + path: .SSIDReference + type: string + description: Reference to the SSID object — always non-empty, used to ensure the AP row is never filtered out by codegen's all-null check + + - field_name: macAddressControlEnabled + path: .MACAddressControlEnabled + type: boolean + writable: true + description: Whether MAC address filtering (whitelist mode) is enabled for this access point + + - field_name: allowedMACAddress + path: .AllowedMACAddress + type: string + writable: true + description: Comma-separated list of MAC addresses allowed to connect to this access point diff --git a/lib/generated/index.dart b/lib/generated/index.dart index 661a1336f..a2a47ac53 100644 --- a/lib/generated/index.dart +++ b/lib/generated/index.dart @@ -6,6 +6,7 @@ export 'wi_fi_radios.g.dart'; export 'wi_fi_access_points.g.dart'; export 'wi_fi_ssids.g.dart'; export 'data_elements_network.g.dart'; +export 'mac_filter_access_points.g.dart'; export 'system_info.g.dart'; export 'vendor_log_files.g.dart'; export 'firmware_images.g.dart'; diff --git a/lib/generated/mac_filter_access_points.g.dart b/lib/generated/mac_filter_access_points.g.dart new file mode 100644 index 000000000..968a75606 --- /dev/null +++ b/lib/generated/mac_filter_access_points.g.dart @@ -0,0 +1,120 @@ +// AUTO-GENERATED CODE - DO NOT EDIT +// This file was generated by usp-codegen +// Any modifications will be overwritten on next generation + +import 'package:privacy_gui/usp/services/usp_service.dart'; + +/// Single instance from MacFilterAccessPoints +class MacFilterAccessPoint { + final String instancePath; + final String ssidReference; + final bool macAddressControlEnabled; + final String allowedMACAddress; + + const MacFilterAccessPoint({ + required this.instancePath, + required this.ssidReference, + required this.macAddressControlEnabled, + required this.allowedMACAddress, + }); +} + +/// Update descriptor for MacFilterAccessPoint instances +class MacFilterAccessPointUpdate { + final String instancePath; + final bool? macAddressControlEnabled; + final String? allowedMACAddress; + + const MacFilterAccessPointUpdate({ + required this.instancePath, + this.macAddressControlEnabled, + this.allowedMACAddress, + }); +} + +/// WiFi access point MAC address filtering control +class MacFilterAccessPoints { + final List items; + + const MacFilterAccessPoints({required this.items}); + + static const _paths = [ + 'Device.WiFi.AccessPoint.*.SSIDReference', + 'Device.WiFi.AccessPoint.*.MACAddressControlEnabled', + 'Device.WiFi.AccessPoint.*.AllowedMACAddress', + ]; + + /// Fetch all instances via USP Get message + static Future fetch(UspService client) async { + final response = await client.get(_paths); + return MacFilterAccessPoints._fromResponse(response); + } + + factory MacFilterAccessPoints._fromResponse(Map response) { + final items = []; + const basePath = 'Device.WiFi.AccessPoint.'; + final ids = {}; + for (final key in response.keys) { + if (key.startsWith(basePath)) { + final rest = key.substring(basePath.length); + final dot = rest.indexOf('.'); + if (dot > 0) ids.add(rest.substring(0, dot)); + } + } + final sorted = ids.toList() + ..sort((a, b) => (int.tryParse(a) ?? 0).compareTo(int.tryParse(b) ?? 0)); + for (final id in sorted) { + final p = '$basePath$id.'; + if ([ + response['${p}SSIDReference'], + response['${p}MACAddressControlEnabled'], + response['${p}AllowedMACAddress'] + ].every((v) => + v == null || + v == '' || + v == '0' || + v == 0 || + v == false || + v == 'false')) continue; + items.add(MacFilterAccessPoint( + instancePath: p, + ssidReference: (response['${p}SSIDReference'] ?? '') as String, + macAddressControlEnabled: + response['${p}MACAddressControlEnabled'] == true || + response['${p}MACAddressControlEnabled'] == 'true' || + response['${p}MACAddressControlEnabled'] == '1', + allowedMACAddress: (response['${p}AllowedMACAddress'] ?? '') as String, + )); + } + return MacFilterAccessPoints(items: items); + } + + /// Update a single instance via USP Set message + static Future update( + UspService client, MacFilterAccessPointUpdate update) async { + final params = {}; + if (update.macAddressControlEnabled != null) + params['${update.instancePath}MACAddressControlEnabled'] = + update.macAddressControlEnabled; + if (update.allowedMACAddress != null) + params['${update.instancePath}AllowedMACAddress'] = + update.allowedMACAddress; + if (params.isNotEmpty) await client.set(params); + } + + /// Update multiple instances in a single USP Set message + static Future updateMany( + UspService client, List updates, + {bool allowPartial = false}) async { + final params = {}; + for (final update in updates) { + if (update.macAddressControlEnabled != null) + params['${update.instancePath}MACAddressControlEnabled'] = + update.macAddressControlEnabled; + if (update.allowedMACAddress != null) + params['${update.instancePath}AllowedMACAddress'] = + update.allowedMACAddress; + } + if (params.isNotEmpty) await client.set(params, allowPartial: allowPartial); + } +} diff --git a/lib/route/constants.dart b/lib/route/constants.dart index 5787bfe12..b97061aba 100644 --- a/lib/route/constants.dart +++ b/lib/route/constants.dart @@ -31,6 +31,7 @@ class RoutePath { static const uspTopology = '/uspTopology'; static const uspNodeDetail = 'uspNodeDetail'; static const uspInstantSafety = '/uspInstantSafety'; + static const uspInstantPrivacy = '/uspInstantPrivacy'; static const uspAdmin = '/uspAdmin'; static const uspDhcpDetail = '/uspDhcpDetail'; static const uspPortForwardingDetail = '/uspPortForwardingDetail'; @@ -190,6 +191,7 @@ class RouteNamed { static const uspTopology = 'uspTopology'; static const uspNodeDetail = 'uspNodeDetail'; static const uspInstantSafety = 'uspInstantSafety'; + static const uspInstantPrivacy = 'uspInstantPrivacy'; static const uspAdmin = 'uspAdmin'; static const uspDhcpDetail = 'uspDhcpDetail'; static const uspPortForwardingDetail = 'uspPortForwardingDetail'; diff --git a/lib/route/route_usp_dashboard.dart b/lib/route/route_usp_dashboard.dart index 4d733b1e5..7971d3bec 100644 --- a/lib/route/route_usp_dashboard.dart +++ b/lib/route/route_usp_dashboard.dart @@ -57,6 +57,12 @@ final uspDashboardRoute = ShellRoute( path: RoutePath.uspInstantSafety, builder: (context, state) => const UspInstantSafetyView(), ), + LinksysRoute( + name: RouteNamed.uspInstantPrivacy, + path: RoutePath.uspInstantPrivacy, + builder: (context, state) => + const usp_instant_privacy.InstantPrivacyView(), + ), LinksysRoute( name: RouteNamed.uspAdmin, path: RoutePath.uspAdmin, diff --git a/lib/route/router_provider.dart b/lib/route/router_provider.dart index 167e81062..b1748a6a5 100644 --- a/lib/route/router_provider.dart +++ b/lib/route/router_provider.dart @@ -73,6 +73,8 @@ import 'package:privacy_gui/usp_page/devices/views/usp_device_detail_view.dart'; import 'package:privacy_gui/usp_page/topology/views/usp_topology_view.dart'; import 'package:privacy_gui/usp_page/topology/views/usp_node_detail_view.dart'; import 'package:privacy_gui/usp_page/instant_safety/views/instant_safety_view.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/views/instant_privacy_view.dart' + as usp_instant_privacy; import 'package:privacy_gui/usp_page/admin/views/usp_admin_view.dart'; import 'package:privacy_gui/usp_page/dhcp/views/usp_dhcp_detail_view.dart'; import 'package:privacy_gui/usp_page/port_forwarding/views/usp_port_forwarding_detail_view.dart'; diff --git a/lib/usp_page/instant_privacy/models/instant_privacy_device_ui_model.dart b/lib/usp_page/instant_privacy/models/instant_privacy_device_ui_model.dart new file mode 100644 index 000000000..67ad79955 --- /dev/null +++ b/lib/usp_page/instant_privacy/models/instant_privacy_device_ui_model.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; + +/// Presentation layer model for a device in the Instant Privacy device list. +/// +/// Used for both the "connected devices" list (when feature is OFF) +/// and the "allowed devices" list (when feature is ON). +/// Implements [Equatable] per Constitution Article XI. +class InstantPrivacyDeviceUIModel extends Equatable { + /// Normalized uppercase colon-separated MAC address (e.g. AA:BB:CC:DD:EE:FF). + final String mac; + + /// Display name: hostname if available, otherwise falls back to [mac]. + final String displayName; + + const InstantPrivacyDeviceUIModel({ + required this.mac, + required this.displayName, + }); + + @override + List get props => [mac, displayName]; + + Map toMap() => { + 'mac': mac, + 'displayName': displayName, + }; + + Map toJson() => toMap(); + + factory InstantPrivacyDeviceUIModel.fromMap(Map map) { + return InstantPrivacyDeviceUIModel( + mac: map['mac'] as String, + displayName: map['displayName'] as String, + ); + } + + factory InstantPrivacyDeviceUIModel.fromJson(Map json) => + InstantPrivacyDeviceUIModel.fromMap(json); +} diff --git a/lib/usp_page/instant_privacy/providers/instant_privacy_notifier.dart b/lib/usp_page/instant_privacy/providers/instant_privacy_notifier.dart new file mode 100644 index 000000000..22b6ca755 --- /dev/null +++ b/lib/usp_page/instant_privacy/providers/instant_privacy_notifier.dart @@ -0,0 +1,128 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/generated/connected_devices.g.dart'; +import 'package:privacy_gui/generated/mac_filter_access_points.g.dart'; +import 'package:privacy_gui/usp/providers/usp_service_provider.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/models/instant_privacy_device_ui_model.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/providers/instant_privacy_state.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/services/instant_privacy_service.dart'; + +final uspInstantPrivacyProvider = + AsyncNotifierProvider( + UspInstantPrivacyNotifier.new, +); + +class UspInstantPrivacyNotifier extends AsyncNotifier { + UspInstantPrivacyService get _svc => + ref.read(uspInstantPrivacyServiceProvider); + + @override + Future build() async { + final usp = ref.watch(uspServiceProvider); + if (usp == null) throw StateError('USP service not available'); + + final results = await Future.wait([ + ConnectedDevices.fetch(usp), + MacFilterAccessPoints.fetch(usp), + ]); + + final devices = results[0] as ConnectedDevices; + final macAps = results[1] as MacFilterAccessPoints; + final svc = _svc; + + logger.d('[USP] Instant Privacy fetched — ' + 'activeDevices: ${svc.activeDevices(devices).length}, ' + 'isEnabled: ${svc.isEnabled(macAps)}'); + + final active = svc.activeDevices(devices); + + // Build a MAC → hostname lookup from all known hosts (active + inactive) + // so that allowed devices shown in the ON state can display friendly names. + final hostnameByMac = { + for (final d in devices.items) + if (d.macAddress.isNotEmpty) + svc.normalizeMac(d.macAddress): d.hostName.isNotEmpty + ? d.hostName + : svc.normalizeMac(d.macAddress), + }; + + final allowed = svc.allowedDevices(macAps).map((d) { + final name = hostnameByMac[d.mac] ?? 'Unknown Device'; + return name == d.displayName + ? d + : InstantPrivacyDeviceUIModel(mac: d.mac, displayName: name); + }).toList(); + + logger.d('[USP] Instant Privacy fetched — ' + 'activeDevices: ${active.length}, ' + 'isEnabled: ${svc.isEnabled(macAps)}'); + + return UspInstantPrivacyState( + isEnabled: svc.isEnabled(macAps), + connectedDevices: active, + allowedDevices: allowed, + rawMacFilterAps: macAps, + ); + } + + /// Enables Instant Privacy by snapshotting currently connected devices + /// as the MAC whitelist across all APs (atomic, allowPartial: false). + Future enable() async { + final s = state.valueOrNull; + if (s == null || s.isEnabled) return; + + state = AsyncData(s.copyWith(isToggleLocked: true)); + try { + final usp = ref.read(uspServiceProvider)!; + final macs = s.connectedDevices.map((d) => d.mac).toList(); + final updates = _svc.buildEnableUpdates(macs, s.rawMacFilterAps); + await MacFilterAccessPoints.updateMany(usp, updates); + logger.d('[USP] Instant Privacy enabled — ${macs.length} MACs'); + ref.invalidateSelf(); + } catch (e) { + state = AsyncData(s.copyWith(isToggleLocked: false)); + rethrow; + } + } + + /// Disables Instant Privacy by clearing MAC filtering on all APs (atomic). + Future disable() async { + final s = state.valueOrNull; + if (s == null || !s.isEnabled) return; + + state = AsyncData(s.copyWith(isToggleLocked: true)); + try { + final usp = ref.read(uspServiceProvider)!; + final updates = _svc.buildDisableUpdates(s.rawMacFilterAps); + await MacFilterAccessPoints.updateMany(usp, updates); + logger.d('[USP] Instant Privacy disabled'); + ref.invalidateSelf(); + } catch (e) { + state = AsyncData(s.copyWith(isToggleLocked: false)); + rethrow; + } + } + + /// Adds [mac] to the allowed list across all APs. + /// Precondition: [mac] is validated and normalized by the caller. + Future addMac(String mac) async { + final s = state.valueOrNull; + if (s == null || !s.isEnabled) return; + + state = AsyncData(s.copyWith(isToggleLocked: true)); + try { + final usp = ref.read(uspServiceProvider)!; + final updates = _svc.buildAddMacUpdates(mac, s.rawMacFilterAps); + if (updates.isEmpty) { + state = AsyncData(s.copyWith(isToggleLocked: false)); + return; + } + await MacFilterAccessPoints.updateMany(usp, updates); + logger.d('[USP] Instant Privacy addMac — $mac'); + ref.invalidateSelf(); + } catch (e) { + state = AsyncData(s.copyWith(isToggleLocked: false)); + rethrow; + } + } +} diff --git a/lib/usp_page/instant_privacy/providers/instant_privacy_state.dart b/lib/usp_page/instant_privacy/providers/instant_privacy_state.dart new file mode 100644 index 000000000..9192be31f --- /dev/null +++ b/lib/usp_page/instant_privacy/providers/instant_privacy_state.dart @@ -0,0 +1,69 @@ +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/generated/mac_filter_access_points.g.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/models/instant_privacy_device_ui_model.dart'; + +/// State for the Instant Privacy feature page. +/// +/// Implements [Equatable] per Constitution Article XI. +/// NOTE: [rawMacFilterAps] is excluded from [props] because +/// [MacFilterAccessPoints] is a generated codegen class that does not +/// implement [Equatable]. Equality is determined by the derived UI fields only. +class UspInstantPrivacyState extends Equatable { + /// Whether MAC address filtering is currently active on the router. + /// Derived: true if any AP has macAddressControlEnabled = true. + final bool isEnabled; + + /// Devices currently connected to the router (isActive = true). + /// Displayed when the feature is OFF. + final List connectedDevices; + + /// Devices currently on the MAC whitelist. + /// Displayed when the feature is ON. + final List allowedDevices; + + /// Whether the toggle is locked during a save operation (FR-016). + final bool isToggleLocked; + + /// Raw codegen data retained for updateMany() operations. + /// Contains AP instance paths needed to build update descriptors. + /// Excluded from [props] — not Equatable. + final MacFilterAccessPoints rawMacFilterAps; + + const UspInstantPrivacyState({ + required this.isEnabled, + required this.connectedDevices, + required this.allowedDevices, + required this.rawMacFilterAps, + this.isToggleLocked = false, + }); + + /// Whether the toggle should be disabled in the UI. + /// True during save operations OR when no devices are connected and feature is OFF (FR-013). + bool get isToggleDisabled => + isToggleLocked || (!isEnabled && connectedDevices.isEmpty); + + UspInstantPrivacyState copyWith({ + bool? isEnabled, + List? connectedDevices, + List? allowedDevices, + bool? isToggleLocked, + MacFilterAccessPoints? rawMacFilterAps, + }) { + return UspInstantPrivacyState( + isEnabled: isEnabled ?? this.isEnabled, + connectedDevices: connectedDevices ?? this.connectedDevices, + allowedDevices: allowedDevices ?? this.allowedDevices, + isToggleLocked: isToggleLocked ?? this.isToggleLocked, + rawMacFilterAps: rawMacFilterAps ?? this.rawMacFilterAps, + ); + } + + @override + List get props => [ + isEnabled, + connectedDevices, + allowedDevices, + isToggleLocked, + // rawMacFilterAps intentionally excluded — not Equatable + ]; +} diff --git a/lib/usp_page/instant_privacy/services/instant_privacy_service.dart b/lib/usp_page/instant_privacy/services/instant_privacy_service.dart new file mode 100644 index 000000000..cc281e053 --- /dev/null +++ b/lib/usp_page/instant_privacy/services/instant_privacy_service.dart @@ -0,0 +1,139 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/generated/connected_devices.g.dart'; +import 'package:privacy_gui/generated/mac_filter_access_points.g.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/models/instant_privacy_device_ui_model.dart'; + +/// Service provider — stateless, per Constitution Article VI. +final uspInstantPrivacyServiceProvider = Provider( + (ref) => UspInstantPrivacyService(), +); + +/// Stateless transformation service for Instant Privacy. +/// +/// Converts codegen data models to UI models and builds update descriptors +/// for MAC filter operations. Contains zero network calls — all USP I/O +/// is performed by [UspInstantPrivacyNotifier]. +class UspInstantPrivacyService { + static final _macRegExp = RegExp( + r'^([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}$', + ); + + // --------------------------------------------------------------------------- + // Read helpers + // --------------------------------------------------------------------------- + + /// Filters [data] to only currently active devices and maps to UI models. + List activeDevices(ConnectedDevices data) { + return data.items + .where((d) => d.isActive && d.interface_.isNotEmpty) + .map((d) { + final mac = normalizeMac(d.macAddress); + return InstantPrivacyDeviceUIModel( + mac: mac, + displayName: d.hostName.isNotEmpty ? d.hostName : mac, + ); + }).toList(); + } + + /// Returns true if any AP in [data] has MAC filtering enabled. + bool isEnabled(MacFilterAccessPoints data) { + return data.items.any((ap) => ap.macAddressControlEnabled); + } + + /// Parses the current allowed MAC list from [data] and converts to UI models. + /// + /// Uses the first AP's [allowedMACAddress] as the source (all APs share the + /// same list). Deduplicates by MAC address. + List allowedDevices(MacFilterAccessPoints data) { + if (data.items.isEmpty) return []; + final raw = data.items.first.allowedMACAddress; + final seen = {}; + return raw + .split(',') + .map((m) => m.trim()) + .where((m) => m.isNotEmpty) + .map(normalizeMac) + .where(seen.add) + .map((mac) => InstantPrivacyDeviceUIModel(mac: mac, displayName: mac)) + .toList(); + } + + // --------------------------------------------------------------------------- + // Write helpers — build update descriptors for MacFilterAccessPoints.updateMany() + // --------------------------------------------------------------------------- + + /// Builds update descriptors to ENABLE MAC filtering on all APs. + /// + /// Sets [macAddressControlEnabled] = true and [allowedMACAddress] to the + /// comma-joined [macs] list on every AP instance in [data]. + List buildEnableUpdates( + List macs, + MacFilterAccessPoints data, + ) { + final macList = macs.join(','); + return data.items + .map((ap) => MacFilterAccessPointUpdate( + instancePath: ap.instancePath, + macAddressControlEnabled: true, + allowedMACAddress: macList, + )) + .toList(); + } + + /// Builds update descriptors to DISABLE MAC filtering on all APs. + /// + /// Sets [macAddressControlEnabled] = false and clears [allowedMACAddress] + /// on every AP instance in [data]. + List buildDisableUpdates( + MacFilterAccessPoints data) { + return data.items + .map((ap) => MacFilterAccessPointUpdate( + instancePath: ap.instancePath, + macAddressControlEnabled: false, + allowedMACAddress: '', + )) + .toList(); + } + + /// Builds update descriptors to ADD [newMac] to the existing allowed list. + /// + /// Reads the current list from the first AP (all APs share the same list), + /// appends [newMac] if not already present, and updates every AP. + /// Precondition: [newMac] is already validated and normalized. + List buildAddMacUpdates( + String newMac, + MacFilterAccessPoints data, + ) { + if (data.items.isEmpty) return []; + + final existing = data.items.first.allowedMACAddress + .split(',') + .map((m) => m.trim()) + .where((m) => m.isNotEmpty) + .map(normalizeMac) + .toList(); + + if (existing.contains(newMac)) return []; + + final updated = [...existing, newMac].join(','); + return data.items + .map((ap) => MacFilterAccessPointUpdate( + instancePath: ap.instancePath, + macAddressControlEnabled: true, + allowedMACAddress: updated, + )) + .toList(); + } + + // --------------------------------------------------------------------------- + // MAC address utilities + // --------------------------------------------------------------------------- + + /// Returns true if [mac] matches colon-separated or hyphen-separated hex format. + bool validateMac(String mac) => _macRegExp.hasMatch(mac.trim()); + + /// Converts [mac] to uppercase colon-separated canonical form. + /// Precondition: [mac] passes [validateMac]. + String normalizeMac(String mac) => + mac.trim().toUpperCase().replaceAll('-', ':'); +} diff --git a/lib/usp_page/instant_privacy/views/components/instant_privacy_device_tile.dart b/lib/usp_page/instant_privacy/views/components/instant_privacy_device_tile.dart new file mode 100644 index 000000000..a29beb082 --- /dev/null +++ b/lib/usp_page/instant_privacy/views/components/instant_privacy_device_tile.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/models/instant_privacy_device_ui_model.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// A simple tile displaying a device's display name and MAC address. +/// Used for both the connected-devices list (feature OFF) and +/// the allowed-devices list (feature ON). +class InstantPrivacyDeviceTile extends StatelessWidget { + final InstantPrivacyDeviceUIModel device; + + const InstantPrivacyDeviceTile({super.key, required this.device}); + + @override + Widget build(BuildContext context) { + return AppCard( + child: Row( + children: [ + AppIcon.font( + Icons.devices, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + AppGap.sm(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.bodyMedium(device.displayName), + AppText.bodySmall( + device.mac, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/usp_page/instant_privacy/views/instant_privacy_view.dart b/lib/usp_page/instant_privacy/views/instant_privacy_view.dart new file mode 100644 index 000000000..234368dd1 --- /dev/null +++ b/lib/usp_page/instant_privacy/views/instant_privacy_view.dart @@ -0,0 +1,400 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/page/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/models/instant_privacy_device_ui_model.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/providers/instant_privacy_notifier.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/providers/instant_privacy_state.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/services/instant_privacy_service.dart'; +import 'package:privacy_gui/usp_page/instant_privacy/views/components/instant_privacy_device_tile.dart'; +import 'package:privacy_gui/usp_page/shell/usp_top_bar.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Instant Privacy page — one-tap MAC whitelist to lock the network to +/// currently connected devices only. +class InstantPrivacyView extends ConsumerWidget { + const InstantPrivacyView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncState = ref.watch(uspInstantPrivacyProvider); + + return UiKitPageView.withSliver( + scrollable: true, + title: 'Instant Privacy', + topbar: const PreferredSize( + preferredSize: Size.fromHeight(64), + child: UspTopBar(), + ), + onBackTap: () => context.canPop() + ? context.pop() + : context.goNamed(RouteNamed.uspMenu), + onRefresh: () => ref.refresh(uspInstantPrivacyProvider.future), + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: (childContext, constraints) { + return asyncState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => _buildError(context, ref), + data: (state) => _buildContent(context, ref, state), + ); + }, + ); + } + + Widget _buildError(BuildContext context, WidgetRef ref) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppText.titleMedium('Unable to load Instant Privacy settings'), + AppGap.md(), + AppButton.text( + label: 'Retry', + onTap: () => ref.invalidate(uspInstantPrivacyProvider), + ), + ], + ), + ); + } + + Widget _buildContent( + BuildContext context, + WidgetRef ref, + UspInstantPrivacyState state, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.bodyMedium( + 'Lock your network to only currently connected devices. ' + 'Any new device will be blocked until you disable Instant Privacy.', + ), + AppGap.xl(), + _buildToggleCard(context, ref, state), + AppGap.lg(), + if (state.isEnabled) + _buildAllowedDevicesList(context, ref, state) + else + _buildConnectedDevicesList(context, state), + ], + ); + } + + Widget _buildToggleCard( + BuildContext context, + WidgetRef ref, + UspInstantPrivacyState state, + ) { + return AppCard( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelLarge('Instant Privacy'), + AppGap.xs(), + AppText.bodySmall( + state.isEnabled + ? 'Only allowed devices can connect' + : 'All devices can connect freely', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + AppSwitch( + value: state.isEnabled, + onChanged: state.isToggleDisabled + ? null + : (value) => + value ? _onEnable(context, ref) : _onDisable(context, ref), + ), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // OFF state — show connected devices (snapshot preview) + // --------------------------------------------------------------------------- + + Widget _buildConnectedDevicesList( + BuildContext context, + UspInstantPrivacyState state, + ) { + if (state.connectedDevices.isEmpty) { + return _buildEmptyDevicesMessage(context); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelLarge( + 'Devices that will be allowed (${state.connectedDevices.length})'), + AppGap.sm(), + AppText.bodySmall( + 'These devices are currently connected and will form the whitelist when you enable Instant Privacy.', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + AppGap.md(), + for (final device in state.connectedDevices) ...[ + InstantPrivacyDeviceTile(device: device), + AppGap.sm(), + ], + ], + ); + } + + Widget _buildEmptyDevicesMessage(BuildContext context) { + return AppCard( + child: Column( + children: [ + AppIcon.font( + Icons.devices_other, + size: 40, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + AppGap.md(), + AppText.bodyMedium( + 'No devices are currently connected.', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + AppGap.xs(), + AppText.bodySmall( + 'Instant Privacy cannot be enabled until at least one device is connected.', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ); + } + + // --------------------------------------------------------------------------- + // ON state — show allowed devices + add MAC button + // --------------------------------------------------------------------------- + + Widget _buildAllowedDevicesList( + BuildContext context, + WidgetRef ref, + UspInstantPrivacyState state, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AppText.labelLarge( + 'Allowed devices (${state.allowedDevices.length})'), + AppButton.text( + label: 'Add device', + onTap: state.isToggleLocked + ? null + : () => _showAddMacDialog(context, ref, state), + ), + ], + ), + AppGap.sm(), + if (state.allowedDevices.isEmpty) + AppText.bodySmall( + 'No devices in the allowed list.', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ) + else + for (final device in state.allowedDevices) ...[ + InstantPrivacyDeviceTile(device: device), + AppGap.sm(), + ], + ], + ); + } + + // --------------------------------------------------------------------------- + // Confirmation dialogs + // --------------------------------------------------------------------------- + + Future _onEnable(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AppDialog( + titleText: 'Enable Instant Privacy?', + content: AppText.bodyMedium( + 'Only the ${ref.read(uspInstantPrivacyProvider).valueOrNull?.connectedDevices.length ?? 0} currently connected device(s) will be allowed to connect. All other devices will be blocked.', + ), + actions: [ + AppButton.text( + label: 'Cancel', + onTap: () => Navigator.of(ctx).pop(false), + ), + AppButton.primary( + label: 'Enable', + onTap: () => Navigator.of(ctx).pop(true), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await ref.read(uspInstantPrivacyProvider.notifier).enable(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to enable Instant Privacy: $e')), + ); + } + } + } + + Future _onDisable(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AppDialog( + titleText: 'Disable Instant Privacy?', + content: AppText.bodyMedium( + 'All devices will be able to connect freely to your network.', + ), + actions: [ + AppButton.text( + label: 'Cancel', + onTap: () => Navigator.of(ctx).pop(false), + ), + AppButton.primary( + label: 'Disable', + onTap: () => Navigator.of(ctx).pop(true), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await ref.read(uspInstantPrivacyProvider.notifier).disable(); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to disable Instant Privacy: $e')), + ); + } + } + } + + // --------------------------------------------------------------------------- + // Add MAC dialog + // --------------------------------------------------------------------------- + + Future _showAddMacDialog( + BuildContext context, + WidgetRef ref, + UspInstantPrivacyState state, + ) async { + await showDialog( + context: context, + builder: (ctx) => _AddMacDialog( + existingDevices: state.allowedDevices, + onConfirm: (mac) async { + Navigator.of(ctx).pop(); + try { + await ref.read(uspInstantPrivacyProvider.notifier).addMac(mac); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to add device: $e')), + ); + } + } + }, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// _AddMacDialog — stateful dialog for MAC address input with validation +// --------------------------------------------------------------------------- + +class _AddMacDialog extends StatefulWidget { + final List existingDevices; + final Future Function(String mac) onConfirm; + + const _AddMacDialog({ + required this.existingDevices, + required this.onConfirm, + }); + + @override + State<_AddMacDialog> createState() => _AddMacDialogState(); +} + +class _AddMacDialogState extends State<_AddMacDialog> { + final _controller = TextEditingController(); + final _svc = UspInstantPrivacyService(); + String? _errorText; + bool _isConfirming = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onChanged(String value) { + setState(() { + if (value.isEmpty) { + _errorText = null; + return; + } + if (!_svc.validateMac(value)) { + _errorText = 'Invalid MAC address format (e.g. AA:BB:CC:DD:EE:FF)'; + return; + } + final normalized = _svc.normalizeMac(value); + final isDuplicate = + widget.existingDevices.any((d) => d.mac == normalized); + _errorText = + isDuplicate ? 'This device is already in the allowed list' : null; + }); + } + + bool get _canConfirm => + _controller.text.isNotEmpty && + _errorText == null && + _svc.validateMac(_controller.text); + + Future _confirm() async { + if (!_canConfirm) return; + setState(() => _isConfirming = true); + await widget.onConfirm(_svc.normalizeMac(_controller.text)); + } + + @override + Widget build(BuildContext context) { + return AppDialog( + titleText: 'Add device manually', + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.bodyMedium('Enter the MAC address of the device to allow.'), + AppGap.md(), + AppTextFormField( + controller: _controller, + hintText: 'AA:BB:CC:DD:EE:FF', + onChanged: _onChanged, + externalErrorText: _errorText, + ), + ], + ), + actions: [ + AppButton.text( + label: 'Cancel', + onTap: () => Navigator.of(context).pop(), + ), + AppButton.primary( + label: _isConfirming ? 'Adding…' : 'Add', + onTap: (_canConfirm && !_isConfirming) ? _confirm : null, + ), + ], + ); + } +} diff --git a/lib/usp_page/menu/views/usp_menu_view.dart b/lib/usp_page/menu/views/usp_menu_view.dart index c84469e10..1e2abb8ae 100644 --- a/lib/usp_page/menu/views/usp_menu_view.dart +++ b/lib/usp_page/menu/views/usp_menu_view.dart @@ -69,6 +69,12 @@ class UspMenuView extends StatelessWidget { iconData: Icons.shield_outlined, onTap: () => context.goNamed(RouteNamed.uspInstantSafety), ), + AppSectionItemData( + title: 'Instant Privacy', + description: 'Lock network to currently connected devices', + iconData: Icons.lock_outlined, + onTap: () => context.goNamed(RouteNamed.uspInstantPrivacy), + ), AppSectionItemData( title: 'Administration', description: 'Password, timezone, reboot',