diff --git a/lib/main.dart b/lib/main.dart index afcda654e..926e00df0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,7 +39,8 @@ void main() async { // TODO Revisit again until Flutter SDK 3.27.x // https://github.com/flutter/engine/commit/35af5fe80e0212caff4b34b583232d833b5a2596 // - if (defaultTargetPlatform != TargetPlatform.iOS && + if (!kDebugMode && + defaultTargetPlatform != TargetPlatform.iOS && defaultTargetPlatform != TargetPlatform.android) { SemanticsBinding.instance.ensureSemantics(); } diff --git a/lib/page/advanced_settings/apps_and_gaming/ports/views/widgets/protocol_utils.dart b/lib/page/advanced_settings/apps_and_gaming/ports/views/widgets/protocol_utils.dart index cbf400aa1..8947830a7 100644 --- a/lib/page/advanced_settings/apps_and_gaming/ports/views/widgets/protocol_utils.dart +++ b/lib/page/advanced_settings/apps_and_gaming/ports/views/widgets/protocol_utils.dart @@ -23,28 +23,27 @@ Future showSelectProtocolModal( String selected = value; return showSimpleAppDialog(context, title: loc(context).channel, - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppRadioList( - selected: value, - itemHeight: 56, - items: ['TCP', 'UDP', 'Both'] - .map((e) => AppRadioListItem( - title: getProtocolTitle(context, e), - value: e, - )) - .toList(), - onChanged: (index, selectedType) { - if (selectedType != null) { - selected = selectedType; - } - }, - ), - ], - ), + scrollable: true, + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppRadioList( + selected: value, + itemHeight: 56, + items: ['TCP', 'UDP', 'Both'] + .map((e) => AppRadioListItem( + title: getProtocolTitle(context, e), + value: e, + )) + .toList(), + onChanged: (index, selectedType) { + if (selectedType != null) { + selected = selectedType; + } + }, + ), + ], ), actions: [ AppButton.text( diff --git a/lib/page/advanced_settings/dmz/views/dmz_settings_view.dart b/lib/page/advanced_settings/dmz/views/dmz_settings_view.dart index fa0f8b95b..047335cf7 100644 --- a/lib/page/advanced_settings/dmz/views/dmz_settings_view.dart +++ b/lib/page/advanced_settings/dmz/views/dmz_settings_view.dart @@ -261,29 +261,22 @@ class _DMZSettingsViewState extends ConsumerState { DMZDestinationType.ip ? Container( constraints: const BoxConstraints(maxWidth: 429), - child: Focus( - onFocusChange: (value) { - if (!value) { - _checkDestinationIPAdress(); - } - }, - child: AppIpv4TextField( - key: const Key('destinationIP'), - controller: _destinationIPController, - readOnly: SegmentReadOnly( - segment1: subnetMask[0] == '255', - segment2: subnetMask[1] == '255', - segment3: subnetMask[2] == '255', - ), - onChanged: (value) { - ref - .read(dmzSettingsProvider.notifier) - .setSettings(state.settings.current - .copyWith( - destinationIPAddress: () => value)); - }, - errorText: _destinationError, + child: AppIpv4TextField( + key: const Key('destinationIP'), + controller: _destinationIPController, + readOnly: SegmentReadOnly( + segment0: subnetMask[0] == '255', + segment1: subnetMask[1] == '255', + segment2: subnetMask[2] == '255', ), + onChanged: (value) { + ref + .read(dmzSettingsProvider.notifier) + .setSettings(state.settings.current.copyWith( + destinationIPAddress: () => value)); + _checkDestinationIPAdress(); + }, + errorText: _destinationError, ), ) : null, diff --git a/lib/page/advanced_settings/firewall/views/ipv6_port_service_list_view.dart b/lib/page/advanced_settings/firewall/views/ipv6_port_service_list_view.dart index 93e3a5400..1ae764a74 100644 --- a/lib/page/advanced_settings/firewall/views/ipv6_port_service_list_view.dart +++ b/lib/page/advanced_settings/firewall/views/ipv6_port_service_list_view.dart @@ -438,6 +438,7 @@ class _Ipv6PortServiceListViewState // Clear editing state _editingRule = null; + _sheetStateSetter = null; // Fix: Add button state error _clearControllers(); return true; } diff --git a/lib/page/advanced_settings/internet_settings/widgets/optional_settings_form.dart b/lib/page/advanced_settings/internet_settings/widgets/optional_settings_form.dart index f039eb95c..c7e7025ab 100644 --- a/lib/page/advanced_settings/internet_settings/widgets/optional_settings_form.dart +++ b/lib/page/advanced_settings/internet_settings/widgets/optional_settings_form.dart @@ -209,20 +209,39 @@ class _OptionalSettingsFormState extends ConsumerState { child: Focus( onFocusChange: (hasFocus) { if (!hasFocus) { + final max = _getMaxMtu(ipv4Setting.ipv4ConnectionType); + final min = _getMinMtu(ipv4Setting.ipv4ConnectionType); + if (_mtuSizeController.text.isEmpty || + (int.parse(_mtuSizeController.text) < min)) { + _mtuSizeController.text = min.toString(); + _mtuSizeController.selection = TextSelection.fromPosition( + TextPosition(offset: _mtuSizeController.text.length), + ); + notifier.updateMtu(min); + return; + } else if (_mtuSizeController.text.isNotEmpty && + (int.parse(_mtuSizeController.text) > max)) { + _mtuSizeController.text = max.toString(); + _mtuSizeController.selection = TextSelection.fromPosition( + TextPosition(offset: _mtuSizeController.text.length), + ); + notifier.updateMtu(max); + } setState(() => _mtuSizeTouched = true); } }, child: AppMinMaxInput( key: ValueKey('mtuManualSizeText_$isMtuAuto'), - value: int.tryParse(_mtuSizeController.text), + controller: _mtuSizeController, enabled: !isMtuAuto, label: loc(context).size, - min: 576, + min: _getMinMtu(ipv4Setting.ipv4ConnectionType), max: _getMaxMtu(ipv4Setting.ipv4ConnectionType), errorText: _mtuSizeTouched && !isMtuAuto && _isMtuInvalid( _mtuSizeController.text, + _getMinMtu(ipv4Setting.ipv4ConnectionType), _getMaxMtu(ipv4Setting.ipv4ConnectionType), ) ? loc(context).invalidInput @@ -233,7 +252,6 @@ class _OptionalSettingsFormState extends ConsumerState { _mtuSizeTouched = true; }); } - _mtuSizeController.text = value?.toString() ?? ''; notifier.updateMtu(value ?? 0); }, ), @@ -399,9 +417,13 @@ class _OptionalSettingsFormState extends ConsumerState { return NetworkUtils.getMaxMtu(wanType); } - bool _isMtuInvalid(String text, int max) { + int _getMinMtu(String wanType) { + return NetworkUtils.getMinMtu(wanType); + } + + bool _isMtuInvalid(String text, int min, int max) { final value = int.tryParse(text); if (value == null) return true; - return value < 576 || value > max; + return value < min || value > max; } } diff --git a/lib/page/advanced_settings/internet_settings/widgets/wan_forms/bridge_form.dart b/lib/page/advanced_settings/internet_settings/widgets/wan_forms/bridge_form.dart index c9074a4ff..f106fd2c2 100644 --- a/lib/page/advanced_settings/internet_settings/widgets/wan_forms/bridge_form.dart +++ b/lib/page/advanced_settings/internet_settings/widgets/wan_forms/bridge_form.dart @@ -49,8 +49,9 @@ class _BridgeFormState extends BaseWanFormState { children: [ buildDisplayFields(context), // Display same info as in display mode AppGap.md(), + AppGap.md(), AppStyledText( - text: '${loc(context).toLogInLocallyWhileInBridgeMode}', + text: loc(context).toLogInLocallyWhileInBridgeMode, key: const ValueKey('toLogInLocallyWhileInBridgeMode'), ), AppGap.sm(), diff --git a/lib/page/advanced_settings/internet_settings/widgets/wan_forms/ipv6/automatic_ipv6_form.dart b/lib/page/advanced_settings/internet_settings/widgets/wan_forms/ipv6/automatic_ipv6_form.dart index 4145ec478..9aed830b4 100644 --- a/lib/page/advanced_settings/internet_settings/widgets/wan_forms/ipv6/automatic_ipv6_form.dart +++ b/lib/page/advanced_settings/internet_settings/widgets/wan_forms/ipv6/automatic_ipv6_form.dart @@ -162,25 +162,33 @@ class _AutomaticIPv6FormState extends BaseIPv6WanFormState { children: [ Padding( padding: inputPadding, - child: AppDropdown( - key: const ValueKey('ipv6TunnelDropdown'), - label: loc(context).sixrdTunnel, - value: ipv6Setting.ipv6rdTunnelMode ?? IPv6rdTunnelMode.disabled, - items: const [ - IPv6rdTunnelMode.disabled, - IPv6rdTunnelMode.automatic, - IPv6rdTunnelMode.manual, - ], - itemAsString: (item) { - return getIpv6rdTunnelModeLoc(context, item); - }, - onChanged: widget.isEditing && !ipv6Setting.isIPv6AutomaticEnabled - ? (value) { - if (value == null) return; - notifier.updateIpv6Settings( - ipv6Setting.copyWith(ipv6rdTunnelMode: () => value)); - } - : null, + child: Opacity( + opacity: widget.isEditing && !ipv6Setting.isIPv6AutomaticEnabled + ? 1.0 + : 0.5, + child: IgnorePointer( + ignoring: + !(widget.isEditing && !ipv6Setting.isIPv6AutomaticEnabled), + child: AppDropdown( + key: const ValueKey('ipv6TunnelDropdown'), + label: loc(context).sixrdTunnel, + value: + ipv6Setting.ipv6rdTunnelMode ?? IPv6rdTunnelMode.disabled, + items: const [ + IPv6rdTunnelMode.disabled, + IPv6rdTunnelMode.automatic, + IPv6rdTunnelMode.manual, + ], + itemAsString: (item) { + return getIpv6rdTunnelModeLoc(context, item); + }, + onChanged: (value) { + if (value == null) return; + notifier.updateIpv6Settings( + ipv6Setting.copyWith(ipv6rdTunnelMode: () => value)); + }, + ), + ), ), ), _manualSixrdTunnel(ipv6Setting, context), diff --git a/lib/page/advanced_settings/internet_settings/widgets/wan_forms/pppoe_form.dart b/lib/page/advanced_settings/internet_settings/widgets/wan_forms/pppoe_form.dart index 48bf5bd37..51029fe27 100644 --- a/lib/page/advanced_settings/internet_settings/widgets/wan_forms/pppoe_form.dart +++ b/lib/page/advanced_settings/internet_settings/widgets/wan_forms/pppoe_form.dart @@ -96,6 +96,8 @@ class _PppoeFormState extends BaseWanFormState { // Fix: Compare against current controller text to avoid cursor reset // Only update controller if the new value is actually different from what's currently in the input if ((newIpv4Setting.username ?? '') != _usernameController.text) { + debugPrint( + 'PppoeForm: Syncing Username. State=${newIpv4Setting.username}, Ctrl=${_usernameController.text}'); _usernameController.text = newIpv4Setting.username ?? ''; } if ((newIpv4Setting.password ?? '') != _passwordController.text) { @@ -105,6 +107,8 @@ class _PppoeFormState extends BaseWanFormState { final newVlanStr = newIpv4Setting.vlanId != null ? '${newIpv4Setting.vlanId}' : ''; if (newVlanStr != _vlanIdController.text) { + debugPrint( + 'PppoeForm: Syncing VlanId. State=$newVlanStr, Ctrl=${_vlanIdController.text}'); _vlanIdController.text = newVlanStr; } @@ -187,12 +191,12 @@ class _PppoeFormState extends BaseWanFormState { padding: inputPadding, child: AppMinMaxInput( key: const ValueKey('pppoeVlanId'), + controller: _vlanIdController, min: 5, max: 4094, label: loc(context).vlanIdOptional, - value: int.tryParse(_vlanIdController.text), onChanged: (value) { - _vlanIdController.text = value?.toString() ?? ''; + // controller is updated by AppMinMaxInput internally notifier.updateIpv4Settings(ipv4Setting.copyWith( vlanId: () => value, )); diff --git a/lib/page/advanced_settings/local_network_settings/views/dhcp_reservations_view.dart b/lib/page/advanced_settings/local_network_settings/views/dhcp_reservations_view.dart index 99a5c59c1..84e30fa7e 100644 --- a/lib/page/advanced_settings/local_network_settings/views/dhcp_reservations_view.dart +++ b/lib/page/advanced_settings/local_network_settings/views/dhcp_reservations_view.dart @@ -141,10 +141,9 @@ class _DHCPReservationsContentViewState onTap: () { showSimpleAppOkDialog( context, - content: SingleChildScrollView( - child: DevicesFilterWidget( - onlineOnly: true, - ), + scrollable: true, + content: DevicesFilterWidget( + onlineOnly: true, ), ); }, diff --git a/lib/page/advanced_settings/local_network_settings/views/dhcp_server_view.dart b/lib/page/advanced_settings/local_network_settings/views/dhcp_server_view.dart index ef59ca3ec..a17017651 100644 --- a/lib/page/advanced_settings/local_network_settings/views/dhcp_server_view.dart +++ b/lib/page/advanced_settings/local_network_settings/views/dhcp_server_view.dart @@ -55,14 +55,32 @@ class _DHCPServerViewState extends ConsumerState { } void _updateControllers(LocalNetworkSettingsState state) { - _startIpAddressController.text = state.settings.current.firstIPAddress; - _maxUserAllowedController.text = '${state.settings.current.maxUserAllowed}'; - _clientLeaseTimeController.text = - '${state.settings.current.clientLeaseTime}'; - _dns1Controller.text = state.settings.current.dns1 ?? ''; - _dns2Controller.text = state.settings.current.dns2 ?? ''; - _dns3Controller.text = state.settings.current.dns3 ?? ''; - _winsController.text = state.settings.current.wins ?? ''; + if (_startIpAddressController.text != + state.settings.current.firstIPAddress) { + _startIpAddressController.text = state.settings.current.firstIPAddress; + } + if (_maxUserAllowedController.text != + '${state.settings.current.maxUserAllowed}') { + _maxUserAllowedController.text = + '${state.settings.current.maxUserAllowed}'; + } + if (_clientLeaseTimeController.text != + '${state.settings.current.clientLeaseTime}') { + _clientLeaseTimeController.text = + '${state.settings.current.clientLeaseTime}'; + } + if (_dns1Controller.text != (state.settings.current.dns1 ?? '')) { + _dns1Controller.text = state.settings.current.dns1 ?? ''; + } + if (_dns2Controller.text != (state.settings.current.dns2 ?? '')) { + _dns2Controller.text = state.settings.current.dns2 ?? ''; + } + if (_dns3Controller.text != (state.settings.current.dns3 ?? '')) { + _dns3Controller.text = state.settings.current.dns3 ?? ''; + } + if (_winsController.text != (state.settings.current.wins ?? '')) { + _winsController.text = state.settings.current.wins ?? ''; + } } @override @@ -144,6 +162,9 @@ class _DHCPServerViewState extends ConsumerState { ), ), AppGap.sm(), + Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: AppText.labelLarge(loc(context).maximumNumberOfUsers)), Padding( padding: inputPadding, child: AppTextField( @@ -163,6 +184,9 @@ class _DHCPServerViewState extends ConsumerState { AppGap.xs(), _ipAddressRange(state), AppGap.xl(), + Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: AppText.labelLarge(loc(context).clientLeaseTime)), Padding( padding: inputPadding, child: AppTextField( diff --git a/lib/page/advanced_settings/static_routing/static_routing_view.dart b/lib/page/advanced_settings/static_routing/static_routing_view.dart index ca20f71dc..0e085d42b 100644 --- a/lib/page/advanced_settings/static_routing/static_routing_view.dart +++ b/lib/page/advanced_settings/static_routing/static_routing_view.dart @@ -271,6 +271,7 @@ class _StaticRoutingViewState extends ConsumerState if (shouldInitialize) { _editingRule = rule; Future.microtask(() { + _selectedInterface = RoutingSettingInterface.resolve(rule.interface); _isInitializing = true; try { final state = ref.read(staticRoutingProvider); @@ -307,13 +308,13 @@ class _StaticRoutingViewState extends ConsumerState _destinationIPController.text = rule.destinationIP; _subnetMaskController.text = rule.subnetMask; _gatewayController.text = rule.gateway; - _selectedInterface = RoutingSettingInterface.resolve(rule.interface); // Clear errors _nameError = null; _destIpError = null; _subnetError = null; _gatewayError = null; + _sheetStateSetter?.call(() {}); } finally { _isInitializing = false; } diff --git a/lib/page/components/shortcuts/dialogs.dart b/lib/page/components/shortcuts/dialogs.dart index 44d8c0fd5..09840e818 100644 --- a/lib/page/components/shortcuts/dialogs.dart +++ b/lib/page/components/shortcuts/dialogs.dart @@ -161,7 +161,9 @@ Future showSubmitAppDialog( content: SizedBox( width: width ?? kDefaultDialogWidth, child: switch (isLoading) { - true => loadingWidget ?? const AppLoader(), + true => loadingWidget ?? + const Center( + child: SizedBox(width: 40, child: AppLoader())), false => contentBuilder(context, setState, onSubmit), }), actions: isLoading diff --git a/lib/page/components/styled/remote_assistance/remote_assistance_dialog.dart b/lib/page/components/styled/remote_assistance/remote_assistance_dialog.dart index 42c35ed9e..3cf5281dd 100644 --- a/lib/page/components/styled/remote_assistance/remote_assistance_dialog.dart +++ b/lib/page/components/styled/remote_assistance/remote_assistance_dialog.dart @@ -5,7 +5,7 @@ import 'package:privacy_gui/core/cloud/providers/remote_assistance/remote_client import 'package:privacy_gui/core/data/providers/polling_provider.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/components/customs/timer_countdown_widget.dart'; -import 'package:privacy_gui/page/dashboard/views/components/remote_assistance_animation.dart'; +import 'package:privacy_gui/page/dashboard/views/components/_components.dart'; import 'package:ui_kit_library/ui_kit.dart'; import 'package:privacy_gui/core/cloud/model/guardians_remote_assistance.dart'; import 'package:url_launcher/url_launcher.dart'; diff --git a/lib/page/dashboard/_dashboard.dart b/lib/page/dashboard/_dashboard.dart index 4a3bc01ca..37747358c 100644 --- a/lib/page/dashboard/_dashboard.dart +++ b/lib/page/dashboard/_dashboard.dart @@ -1,2 +1,4 @@ export 'views/_views.dart'; export 'providers/_providers.dart'; +export 'models/_models.dart'; +export 'strategies/_strategies.dart'; diff --git a/lib/page/dashboard/models/_models.dart b/lib/page/dashboard/models/_models.dart new file mode 100644 index 000000000..f0e8661c3 --- /dev/null +++ b/lib/page/dashboard/models/_models.dart @@ -0,0 +1,7 @@ +export 'dashboard_layout.dart'; +export 'dashboard_layout_preferences.dart'; +export 'dashboard_widget_specs.dart'; +export 'display_mode.dart'; +export 'height_strategy.dart'; +export 'widget_grid_constraints.dart'; +export 'widget_spec.dart'; diff --git a/lib/page/dashboard/models/dashboard_layout.dart b/lib/page/dashboard/models/dashboard_layout.dart new file mode 100644 index 000000000..0cc431e3d --- /dev/null +++ b/lib/page/dashboard/models/dashboard_layout.dart @@ -0,0 +1,55 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Defines the different layout variants for the dashboard. +/// Used to determine how components should arrange themselves. +enum DashboardLayoutVariant { + /// Mobile layout - single column, all components stacked vertically + mobile, + + /// Desktop layout with horizontal emphasis - left column expanded + desktopHorizontal, + + /// Desktop layout with vertical emphasis - shows port info in left column + desktopVertical, + + /// Desktop layout for devices without LAN ports + desktopNoLanPorts, + + /// Tablet layout - optimized for mid-size screens (flexible 2-column) + tablet, + + /// Tablet layout with horizontal ports (stacked vertically) + tabletHorizontal, + + /// Tablet layout with vertical ports (side-by-side) + tabletVertical; + + /// Resolves the layout variant based on context and state. + static DashboardLayoutVariant fromContext( + BuildContext context, { + required bool hasLanPort, + required bool isHorizontalLayout, + }) { + if (context.isMobileLayout) { + return DashboardLayoutVariant.mobile; + } + + if (context.isTabletLayout) { + if (!hasLanPort) { + return DashboardLayoutVariant.tablet; + } + return isHorizontalLayout + ? DashboardLayoutVariant.tabletHorizontal + : DashboardLayoutVariant.tabletVertical; + } + + if (!hasLanPort) { + return DashboardLayoutVariant.desktopNoLanPorts; + } + + return isHorizontalLayout + ? DashboardLayoutVariant.desktopHorizontal + : DashboardLayoutVariant.desktopVertical; + } +} diff --git a/lib/page/dashboard/models/dashboard_layout_preferences.dart b/lib/page/dashboard/models/dashboard_layout_preferences.dart new file mode 100644 index 000000000..4ad4e97ea --- /dev/null +++ b/lib/page/dashboard/models/dashboard_layout_preferences.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; +import 'package:equatable/equatable.dart'; +import 'dashboard_widget_specs.dart'; +import 'display_mode.dart'; +import 'grid_widget_config.dart'; + +/// User's Dashboard layout preferences. +/// +/// Stores widget display modes and grid configurations for custom layouts. +class DashboardLayoutPreferences extends Equatable { + /// Whether to use custom layout (unified Wrap) or legacy hardcoded layouts + final bool useCustomLayout; + + /// Widget grid configurations (keyed by widget ID) + final Map widgetConfigs; + + const DashboardLayoutPreferences({ + this.useCustomLayout = false, + this.widgetConfigs = const {}, + }); + + // --------------------------------------------------------------------------- + // Configuration Getters + // --------------------------------------------------------------------------- + + /// Get config for a widget (creates default if not exists) + GridWidgetConfig getConfig(String widgetId) { + return widgetConfigs[widgetId] ?? _defaultConfig(widgetId); + } + + /// Get display mode for a widget + DisplayMode getMode(String widgetId) => getConfig(widgetId).displayMode; + + /// Get visibility for a widget + bool isVisible(String widgetId) => getConfig(widgetId).visible; + + /// Get ordered list of visible widget configs + List get orderedVisibleWidgets { + final allConfigs = + DashboardWidgetSpecs.all.map((spec) => getConfig(spec.id)).toList(); + final visible = allConfigs.where((c) => c.visible).toList(); + visible.sort((a, b) => a.order.compareTo(b.order)); + return visible; + } + + /// Get all widget configs in order (including hidden) + List get allWidgetsOrdered { + final allConfigs = + DashboardWidgetSpecs.all.map((spec) => getConfig(spec.id)).toList(); + allConfigs.sort((a, b) => a.order.compareTo(b.order)); + return allConfigs; + } + + // --------------------------------------------------------------------------- + // Configuration Setters + // --------------------------------------------------------------------------- + + /// Update a widget's configuration + DashboardLayoutPreferences updateConfig(GridWidgetConfig config) { + return DashboardLayoutPreferences( + useCustomLayout: useCustomLayout, + widgetConfigs: {...widgetConfigs, config.widgetId: config}, + ); + } + + /// Toggle custom layout usage + DashboardLayoutPreferences toggleCustomLayout(bool enabled) { + return DashboardLayoutPreferences( + useCustomLayout: enabled, + widgetConfigs: widgetConfigs, + ); + } + + /// Update display mode for a widget + DashboardLayoutPreferences setMode(String widgetId, DisplayMode mode) { + final config = getConfig(widgetId); + return updateConfig(config.copyWith(displayMode: mode)); + } + + /// Update visibility for a widget + DashboardLayoutPreferences setVisibility(String widgetId, bool visible) { + final config = getConfig(widgetId); + return updateConfig(config.copyWith(visible: visible)); + } + + /// Update column span for a widget + DashboardLayoutPreferences setColumnSpan(String widgetId, int? columnSpan) { + final config = getConfig(widgetId); + return updateConfig(config.copyWith( + columnSpan: columnSpan, + clearColumnSpan: columnSpan == null, + )); + } + + /// Reorder widgets + DashboardLayoutPreferences reorder(int oldIndex, int newIndex) { + final ordered = allWidgetsOrdered.toList(); + if (oldIndex < 0 || oldIndex >= ordered.length) return this; + if (newIndex < 0 || newIndex >= ordered.length) return this; + + final item = ordered.removeAt(oldIndex); + ordered.insert(newIndex, item); + + final newConfigs = {}; + for (var i = 0; i < ordered.length; i++) { + final config = ordered[i]; + newConfigs[config.widgetId] = config.copyWith(order: i); + } + + return DashboardLayoutPreferences( + useCustomLayout: useCustomLayout, + widgetConfigs: newConfigs, + ); + } + + /// Reset all preferences to defaults + DashboardLayoutPreferences reset() { + return const DashboardLayoutPreferences(); + } + + // --------------------------------------------------------------------------- + // Default Configuration + // --------------------------------------------------------------------------- + + /// Generate default config for a widget + static GridWidgetConfig _defaultConfig(String widgetId) { + final spec = DashboardWidgetSpecs.getById(widgetId); + final defaultOrder = + spec != null ? DashboardWidgetSpecs.all.indexOf(spec) : 0; + return GridWidgetConfig( + widgetId: widgetId, + order: defaultOrder, + ); + } + + // --------------------------------------------------------------------------- + // JSON Serialization + // --------------------------------------------------------------------------- + + /// JSON serialization + Map toJson() => { + 'useCustomLayout': useCustomLayout, + 'widgetConfigs': widgetConfigs.map( + (k, v) => MapEntry(k, v.toJson()), + ), + }; + + /// JSON deserialization + factory DashboardLayoutPreferences.fromJson(Map json) { + final useCustomLayout = json['useCustomLayout'] as bool? ?? false; + final configsJson = json['widgetConfigs'] as Map?; + + // Legacy support: migrate from old widgetModes format + final legacyModes = json['widgetModes'] as Map?; + + if (configsJson != null) { + final configs = {}; + for (final entry in configsJson.entries) { + try { + configs[entry.key] = GridWidgetConfig.fromJson( + entry.value as Map, + ); + } catch (_) { + // Ignore invalid entries + } + } + return DashboardLayoutPreferences( + useCustomLayout: useCustomLayout, + widgetConfigs: configs, + ); + } + + // Migrate from legacy format + if (legacyModes != null) { + final configs = {}; + var order = 0; + for (final entry in legacyModes.entries) { + try { + final mode = DisplayMode.values.byName(entry.value as String); + configs[entry.key] = GridWidgetConfig( + widgetId: entry.key, + order: order++, + displayMode: mode, + ); + } catch (_) { + // Ignore invalid values + } + } + return DashboardLayoutPreferences(widgetConfigs: configs); + } + + return const DashboardLayoutPreferences(); + } + + /// Parse from JSON string + factory DashboardLayoutPreferences.fromJsonString(String jsonString) { + try { + return DashboardLayoutPreferences.fromJson( + jsonDecode(jsonString) as Map, + ); + } catch (_) { + return const DashboardLayoutPreferences(); + } + } + + /// Convert to JSON string + String toJsonString() => jsonEncode(toJson()); + + @override + List get props => [useCustomLayout, widgetConfigs]; +} diff --git a/lib/page/dashboard/models/dashboard_widget_specs.dart b/lib/page/dashboard/models/dashboard_widget_specs.dart new file mode 100644 index 000000000..edad46301 --- /dev/null +++ b/lib/page/dashboard/models/dashboard_widget_specs.dart @@ -0,0 +1,200 @@ +import 'display_mode.dart'; +import 'height_strategy.dart'; +import 'widget_grid_constraints.dart'; +import 'widget_spec.dart'; + +/// Dashboard 所有元件的規格定義 +/// +/// 定義各元件在不同 [DisplayMode] 下的 grid 約束。 +/// 所有 column 數值基於 12-column 設計。 +abstract class DashboardWidgetSpecs { + DashboardWidgetSpecs._(); + + // --------------------------------------------------------------------------- + // Internet Status + // --------------------------------------------------------------------------- + static const internetStatus = WidgetSpec( + id: 'internet_status', + displayName: 'Internet Status', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 6, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 6, + maxColumns: 8, + preferredColumns: 8, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 8, + maxColumns: 12, + preferredColumns: 12, + heightStrategy: HeightStrategy.intrinsic(), + ), + }, + ); + + // --------------------------------------------------------------------------- + // Networks (節點狀態) + // --------------------------------------------------------------------------- + static const networks = WidgetSpec( + id: 'networks', + displayName: 'Networks', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 3, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 6, + heightStrategy: HeightStrategy.intrinsic(), + ), + }, + ); + + // --------------------------------------------------------------------------- + // WiFi Grid + // --------------------------------------------------------------------------- + static const wifiGrid = WidgetSpec( + id: 'wifi_grid', + displayName: 'Wi-Fi Networks', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 6, + maxColumns: 8, + preferredColumns: 8, + heightStrategy: HeightStrategy.aspectRatio(4.0), // 橫向卡片 + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 8, + maxColumns: 12, + preferredColumns: 8, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 12, + maxColumns: 12, + preferredColumns: 12, + heightStrategy: HeightStrategy.intrinsic(), + ), + }, + ); + + // --------------------------------------------------------------------------- + // Quick Panel + // --------------------------------------------------------------------------- + static const quickPanel = WidgetSpec( + id: 'quick_panel', + displayName: 'Quick Panel', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 3, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 6, + heightStrategy: HeightStrategy.intrinsic(), + ), + }, + ); + + // --------------------------------------------------------------------------- + // Port and Speed + // --------------------------------------------------------------------------- + static const portAndSpeed = WidgetSpec( + id: 'port_and_speed', + displayName: 'Ports & Speed', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 6, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 8, + preferredColumns: 8, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 8, + maxColumns: 12, + preferredColumns: 8, + heightStrategy: HeightStrategy.intrinsic(), + ), + }, + ); + + // --------------------------------------------------------------------------- + // 所有規格列表(用於設定 UI 迭代) + // --------------------------------------------------------------------------- + static const List all = [ + internetStatus, + networks, + wifiGrid, + quickPanel, + portAndSpeed, + vpn, + ]; + + // --------------------------------------------------------------------------- + // VPN (if supported) + // --------------------------------------------------------------------------- + static const vpn = WidgetSpec( + id: 'vpn', + displayName: 'VPN', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 3, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.intrinsic(), + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 6, + heightStrategy: HeightStrategy.intrinsic(), + ), + }, + ); + + /// 根據 ID 查詢規格 + static WidgetSpec? getById(String id) { + for (final spec in all) { + if (spec.id == id) return spec; + } + return null; + } +} diff --git a/lib/page/dashboard/models/display_mode.dart b/lib/page/dashboard/models/display_mode.dart new file mode 100644 index 000000000..7ee3fff2f --- /dev/null +++ b/lib/page/dashboard/models/display_mode.dart @@ -0,0 +1,13 @@ +/// 元件顯示模式 +/// +/// 定義 Dashboard 元件的三種顯示密度級別。 +enum DisplayMode { + /// 最小化顯示,只顯示關鍵資訊 + compact, + + /// 預設標準顯示 + normal, + + /// 放大顯示,完整資訊 + expanded, +} diff --git a/lib/page/dashboard/models/grid_widget_config.dart b/lib/page/dashboard/models/grid_widget_config.dart new file mode 100644 index 000000000..752b354ef --- /dev/null +++ b/lib/page/dashboard/models/grid_widget_config.dart @@ -0,0 +1,77 @@ +import 'package:equatable/equatable.dart'; +import 'display_mode.dart'; + +/// Configuration for a single dashboard widget in the custom grid layout. +/// +/// This class stores all user-configurable properties for a widget: +/// - Order: Position in the widget list +/// - Visibility: Whether the widget is shown +/// - DisplayMode: Compact, normal, or expanded +/// - Column span: Width in columns (1-12 based on UI Kit column system) +class GridWidgetConfig extends Equatable { + /// Unique widget identifier + final String widgetId; + + /// Sort order (0-based index) + final int order; + + /// Whether this widget is visible + final bool visible; + + /// Display mode for this widget + final DisplayMode displayMode; + + /// Column span (1-12, null = use default from WidgetSpec) + final int? columnSpan; + + const GridWidgetConfig({ + required this.widgetId, + required this.order, + this.visible = true, + this.displayMode = DisplayMode.normal, + this.columnSpan, + }); + + GridWidgetConfig copyWith({ + String? widgetId, + int? order, + bool? visible, + DisplayMode? displayMode, + int? columnSpan, + bool clearColumnSpan = false, + }) { + return GridWidgetConfig( + widgetId: widgetId ?? this.widgetId, + order: order ?? this.order, + visible: visible ?? this.visible, + displayMode: displayMode ?? this.displayMode, + columnSpan: clearColumnSpan ? null : (columnSpan ?? this.columnSpan), + ); + } + + /// JSON serialization + Map toJson() => { + 'widgetId': widgetId, + 'order': order, + 'visible': visible, + 'displayMode': displayMode.name, + if (columnSpan != null) 'columnSpan': columnSpan, + }; + + /// JSON deserialization + factory GridWidgetConfig.fromJson(Map json) { + return GridWidgetConfig( + widgetId: json['widgetId'] as String, + order: json['order'] as int? ?? 0, + visible: json['visible'] as bool? ?? true, + displayMode: DisplayMode.values.byName( + json['displayMode'] as String? ?? 'normal', + ), + columnSpan: json['columnSpan'] as int?, + ); + } + + @override + List get props => + [widgetId, order, visible, displayMode, columnSpan]; +} diff --git a/lib/page/dashboard/models/height_strategy.dart b/lib/page/dashboard/models/height_strategy.dart new file mode 100644 index 000000000..ac33b6671 --- /dev/null +++ b/lib/page/dashboard/models/height_strategy.dart @@ -0,0 +1,63 @@ +/// 高度計算策略 +/// +/// 定義 Dashboard 元件的高度計算方式。 +/// 使用 sealed class 確保類型安全的模式匹配。 +sealed class HeightStrategy { + const HeightStrategy(); + + /// 讓元件自己決定高度(intrinsic sizing) + const factory HeightStrategy.intrinsic() = IntrinsicHeightStrategy; + + /// 高度 = 單個 column 寬度 × 倍數 + /// + /// 例:multiplier=2.0 表示高度為 2 個 column 寬度 + const factory HeightStrategy.columnBased(double multiplier) = + ColumnBasedHeightStrategy; + + /// 固定寬高比 + /// + /// [ratio] = width / height,例:16/9 = 1.78 + const factory HeightStrategy.aspectRatio(double ratio) = + AspectRatioHeightStrategy; +} + +/// 讓元件自行決定高度 +class IntrinsicHeightStrategy extends HeightStrategy { + const IntrinsicHeightStrategy(); + + @override + bool operator ==(Object other) => other is IntrinsicHeightStrategy; + + @override + int get hashCode => runtimeType.hashCode; +} + +/// 基於欄寬倍數的高度 +class ColumnBasedHeightStrategy extends HeightStrategy { + /// 欄寬倍數,高度 = singleColumnWidth * multiplier + final double multiplier; + + const ColumnBasedHeightStrategy(this.multiplier); + + @override + bool operator ==(Object other) => + other is ColumnBasedHeightStrategy && other.multiplier == multiplier; + + @override + int get hashCode => multiplier.hashCode; +} + +/// 固定寬高比 +class AspectRatioHeightStrategy extends HeightStrategy { + /// 寬高比 (width / height) + final double ratio; + + const AspectRatioHeightStrategy(this.ratio); + + @override + bool operator ==(Object other) => + other is AspectRatioHeightStrategy && other.ratio == ratio; + + @override + int get hashCode => ratio.hashCode; +} diff --git a/lib/page/dashboard/models/widget_grid_constraints.dart b/lib/page/dashboard/models/widget_grid_constraints.dart new file mode 100644 index 000000000..492ae1d76 --- /dev/null +++ b/lib/page/dashboard/models/widget_grid_constraints.dart @@ -0,0 +1,68 @@ +import 'dart:math'; +import 'height_strategy.dart'; + +/// 基於 12-column grid 的元件約束 +/// +/// 所有 column 數值都是基於 12-column 設計, +/// 會自動按比例縮放到目前的 currentMaxColumns(4/8/12)。 +class WidgetGridConstraints { + /// 最小佔用欄數(基於 12-column) + final int minColumns; + + /// 最大佔用欄數(基於 12-column) + final int maxColumns; + + /// 理想/預設佔用欄數(基於 12-column) + final int preferredColumns; + + /// 高度計算策略 + final HeightStrategy heightStrategy; + + const WidgetGridConstraints({ + required this.minColumns, + required this.maxColumns, + required this.preferredColumns, + this.heightStrategy = const HeightStrategy.intrinsic(), + }) : assert(minColumns >= 1 && minColumns <= 12), + assert(maxColumns >= minColumns && maxColumns <= 12), + assert( + preferredColumns >= minColumns && preferredColumns <= maxColumns); + + /// 按比例縮放到目標 column 數 + /// + /// 例:preferredColumns=6 在 desktop(12) = 6 + /// 在 tablet(8) = 6 * 8 / 12 = 4 + int scaleToMaxColumns(int targetMaxColumns) { + return (preferredColumns * targetMaxColumns / 12) + .round() + .clamp(1, targetMaxColumns); + } + + /// 縮放 minColumns 到目標 column 數 + int scaleMinToMaxColumns(int targetMaxColumns) { + return max(1, (minColumns * targetMaxColumns / 12).round()); + } + + /// 縮放 maxColumns 到目標 column 數 + int scaleMaxToMaxColumns(int targetMaxColumns) { + return (maxColumns * targetMaxColumns / 12) + .round() + .clamp(1, targetMaxColumns); + } + + @override + bool operator ==(Object other) => + other is WidgetGridConstraints && + other.minColumns == minColumns && + other.maxColumns == maxColumns && + other.preferredColumns == preferredColumns && + other.heightStrategy == heightStrategy; + + @override + int get hashCode => Object.hash( + minColumns, + maxColumns, + preferredColumns, + heightStrategy, + ); +} diff --git a/lib/page/dashboard/models/widget_spec.dart b/lib/page/dashboard/models/widget_spec.dart new file mode 100644 index 000000000..09be4440d --- /dev/null +++ b/lib/page/dashboard/models/widget_spec.dart @@ -0,0 +1,33 @@ +import 'display_mode.dart'; +import 'widget_grid_constraints.dart'; + +/// 元件規格定義 +/// +/// 每種 DisplayMode 對應不同的 grid 約束。 +class WidgetSpec { + /// 元件唯一識別碼 + final String id; + + /// 顯示名稱(用於設定 UI) + final String displayName; + + /// 各 DisplayMode 的約束定義 + final Map constraints; + + const WidgetSpec({ + required this.id, + required this.displayName, + required this.constraints, + }); + + /// 取得指定模式的約束,若無則回傳 normal 模式 + WidgetGridConstraints getConstraints(DisplayMode mode) => + constraints[mode] ?? constraints[DisplayMode.normal]!; + + @override + bool operator ==(Object other) => + other is WidgetSpec && other.id == id && other.displayName == displayName; + + @override + int get hashCode => Object.hash(id, displayName); +} diff --git a/lib/page/dashboard/providers/_providers.dart b/lib/page/dashboard/providers/_providers.dart index 3f7051279..b7eb3ce5a 100644 --- a/lib/page/dashboard/providers/_providers.dart +++ b/lib/page/dashboard/providers/_providers.dart @@ -1,2 +1,3 @@ export 'dashboard_home_provider.dart'; export 'dashboard_home_state.dart'; +export 'dashboard_preferences_provider.dart'; diff --git a/lib/page/dashboard/providers/dashboard_preferences_provider.dart b/lib/page/dashboard/providers/dashboard_preferences_provider.dart new file mode 100644 index 000000000..c9293bd14 --- /dev/null +++ b/lib/page/dashboard/providers/dashboard_preferences_provider.dart @@ -0,0 +1,78 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../models/dashboard_layout_preferences.dart'; +import '../models/display_mode.dart'; + +const _prefsKey = 'dashboard_layout_preferences'; + +/// Provider for Dashboard layout preferences +final dashboardPreferencesProvider = + NotifierProvider( + () => DashboardPreferencesNotifier(), +); + +/// Notifier for managing Dashboard layout preferences +/// +/// Handles loading, saving, and updating user preferences for widget +/// display modes, visibility, column spans, and ordering. +/// Preferences are persisted to SharedPreferences. +class DashboardPreferencesNotifier + extends Notifier { + @override + DashboardLayoutPreferences build() { + _loadFromPrefs(); + return const DashboardLayoutPreferences(); + } + + /// Load preferences from SharedPreferences + Future _loadFromPrefs() async { + final prefs = await SharedPreferences.getInstance(); + final json = prefs.getString(_prefsKey); + if (json != null) { + state = DashboardLayoutPreferences.fromJsonString(json); + } + } + + /// Set the display mode for a specific widget + Future setWidgetMode(String widgetId, DisplayMode mode) async { + state = state.setMode(widgetId, mode); + await _saveToPrefs(); + } + + /// Set the visibility for a specific widget + Future setVisibility(String widgetId, bool visible) async { + state = state.setVisibility(widgetId, visible); + await _saveToPrefs(); + } + + /// Set the column span for a specific widget + Future setColumnSpan(String widgetId, int? columnSpan) async { + state = state.setColumnSpan(widgetId, columnSpan); + await _saveToPrefs(); + } + + /// Reorder widgets + Future reorder(int oldIndex, int newIndex) async { + state = state.reorder(oldIndex, newIndex); + await _saveToPrefs(); + } + + /// Toggle custom layout usage + Future toggleCustomLayout(bool enabled) async { + state = state.toggleCustomLayout(enabled); + await _saveToPrefs(); + } + + /// Save preferences to SharedPreferences + Future _saveToPrefs() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, state.toJsonString()); + } + + /// Reset all preferences to defaults + Future resetToDefaults() async { + state = const DashboardLayoutPreferences(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsKey); + } +} diff --git a/lib/page/dashboard/strategies/_strategies.dart b/lib/page/dashboard/strategies/_strategies.dart new file mode 100644 index 000000000..d1f62f129 --- /dev/null +++ b/lib/page/dashboard/strategies/_strategies.dart @@ -0,0 +1,11 @@ +export 'dashboard_layout_context.dart'; +export 'dashboard_layout_strategy.dart'; +export 'dashboard_layout_factory.dart'; +export 'grid_layout_resolver.dart'; +export 'mobile_layout_strategy.dart'; +export 'desktop_horizontal_layout_strategy.dart'; +export 'desktop_vertical_layout_strategy.dart'; +export 'desktop_no_lan_ports_layout_strategy.dart'; +export 'tablet_layout_strategy.dart'; +export 'tablet_horizontal_layout_strategy.dart'; +export 'tablet_vertical_layout_strategy.dart'; diff --git a/lib/page/dashboard/strategies/custom_dashboard_layout_strategy.dart b/lib/page/dashboard/strategies/custom_dashboard_layout_strategy.dart new file mode 100644 index 000000000..c4f176c19 --- /dev/null +++ b/lib/page/dashboard/strategies/custom_dashboard_layout_strategy.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import 'dashboard_layout_context.dart'; +import 'dashboard_layout_strategy.dart'; + +/// Universal Custom Layout Strategy +/// +/// Handles the Unified Dynamic Grid Layout (Wrap-based) for all device types. +/// Used when "Custom Layout" is enabled in preferences. +class CustomDashboardLayoutStrategy extends DashboardLayoutStrategy { + const CustomDashboardLayoutStrategy(); + + @override + Widget build(DashboardLayoutContext ctx) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ctx.title, + AppGap.xl(), + // Flexible Grid Layout using Wrap + SizedBox( + width: double.infinity, + child: Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + spacing: AppSpacing.lg, + runSpacing: AppSpacing.lg, + children: ctx.orderedVisibleWidgets, + ), + ), + ], + ); + } +} diff --git a/lib/page/dashboard/strategies/dashboard_layout_context.dart b/lib/page/dashboard/strategies/dashboard_layout_context.dart new file mode 100644 index 000000000..6f4b99f30 --- /dev/null +++ b/lib/page/dashboard/strategies/dashboard_layout_context.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import '../models/dashboard_widget_specs.dart'; +import '../models/display_mode.dart'; +import '../models/grid_widget_config.dart'; +import '../models/widget_spec.dart'; +import 'grid_layout_resolver.dart'; + +/// Configuration for port and speed widget building. +class PortAndSpeedConfig { + /// Direction of port layout (horizontal or vertical). + /// If null, the widget will determine direction based on available width. + final Axis? direction; + + /// Whether to show the speed test section. + final bool showSpeedTest; + + /// Height for the ports section (null = flexible). + final double? portsHeight; + + /// Height for the speed test section (null = flexible). + final double? speedTestHeight; + + /// Padding for the ports section. + final EdgeInsets portsPadding; + + const PortAndSpeedConfig({ + this.direction, + this.showSpeedTest = true, + this.portsHeight, + this.speedTestHeight, + this.portsPadding = const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xxl, + ), + }); +} + +/// Holds all pre-built widgets and context data needed by layout strategies. +/// +/// This class implements IoC (Inversion of Control) - widgets are built by +/// the View and passed down to Strategies, rather than Strategies building +/// their own widgets. +class DashboardLayoutContext { + /// The build context for layout calculations. + final BuildContext context; + + /// Riverpod ref for accessing providers. + final WidgetRef ref; + + /// Current dashboard home state. + final DashboardHomeState state; + + /// Whether the device has LAN ports. + final bool hasLanPort; + + /// Whether the port layout is horizontal. + final bool isHorizontalLayout; + + // Pre-built atomic widgets (IoC - built by View, used by Strategy) + + /// Dashboard title widget. + final Widget title; + + /// Internet connection status widget. + final Widget internetWidget; + + /// Network nodes overview widget. + final Widget networksWidget; + + /// WiFi cards grid widget. + final Widget wifiGrid; + + /// Quick actions panel widget. + final Widget quickPanel; + + /// VPN status tile (null if VPN not supported). + final Widget? vpnTile; + + /// Factory function to build port and speed widget with configuration. + /// + /// Strategies call this with their specific configuration to get + /// a properly configured port and speed widget. + final Widget Function(PortAndSpeedConfig config) buildPortAndSpeed; + + // --------------------------------------------------------------------------- + // Grid Constraint System + // --------------------------------------------------------------------------- + + /// Widget configurations (keyed by widget ID). + /// + /// Used by the grid constraint system to determine widget sizing and display mode. + final Map widgetConfigs; + + const DashboardLayoutContext({ + required this.context, + required this.ref, + required this.state, + required this.hasLanPort, + required this.isHorizontalLayout, + required this.title, + required this.internetWidget, + required this.networksWidget, + required this.wifiGrid, + required this.quickPanel, + this.vpnTile, + required this.buildPortAndSpeed, + this.widgetConfigs = const {}, + }); + + /// Convenience getter for column width calculation. + double colWidth(int columns) => context.colWidth(columns); + + // --------------------------------------------------------------------------- + // Grid Constraint Helpers + // --------------------------------------------------------------------------- + + /// Creates a [GridLayoutResolver] for this context. + GridLayoutResolver get resolver => GridLayoutResolver(context); + + /// Gets the full configuration for a widget spec. + GridWidgetConfig getConfigFor(WidgetSpec spec) { + return widgetConfigs[spec.id] ?? + GridWidgetConfig(widgetId: spec.id, order: 0); + } + + /// Gets the display mode for a widget spec. + DisplayMode getModeFor(WidgetSpec spec) => getConfigFor(spec).displayMode; + + /// Gets the resolved column count for a widget. + int getColumnsFor(WidgetSpec spec, {int? availableColumns}) { + final config = getConfigFor(spec); + return resolver.resolveColumns( + spec, + config.displayMode, + availableColumns: availableColumns, + overrideColumns: config.columnSpan, + ); + } + + /// Gets the resolved width for a widget. + double getWidthFor(WidgetSpec spec, {int? availableColumns}) { + final config = getConfigFor(spec); + return resolver.resolveWidth( + spec, + config.displayMode, + availableColumns: availableColumns, + overrideColumns: config.columnSpan, + ); + } + + /// Gets the resolved height for a widget (null = intrinsic). + double? getHeightFor(WidgetSpec spec, {int? availableColumns}) { + final config = getConfigFor(spec); + return resolver.resolveHeight( + spec, + config.displayMode, + availableColumns: availableColumns, + overrideColumns: config.columnSpan, + ); + } + + /// Wraps a widget with size constraints based on its spec and user preferences. + Widget wrapWidget( + Widget child, { + required WidgetSpec spec, + int? availableColumns, + }) { + final config = getConfigFor(spec); + + // Force full width (stack) on mobile, ignoring user width settings + final isMobile = context.isMobileLayout; + // Force half width (2 columns) on tablet, ignoring user width settings + final isTablet = context.isTabletLayout; + + final effectiveOverride = isMobile + ? 12 + : isTablet + ? 6 + : config.columnSpan; + + return resolver.wrapWithConstraints( + child, + spec: spec, + mode: config.displayMode, + availableColumns: availableColumns, + overrideColumns: effectiveOverride, + ); + } + + // --------------------------------------------------------------------------- + // Dynamic Layout Helpers + // --------------------------------------------------------------------------- + + /// Gets a map of all dashboard widgets keyed by their ID. + /// + /// For PortAndSpeed, a default configuration is used. + Map get _allWidgets => { + DashboardWidgetSpecs.internetStatus.id: internetWidget, + DashboardWidgetSpecs.networks.id: networksWidget, + DashboardWidgetSpecs.wifiGrid.id: wifiGrid, + DashboardWidgetSpecs.quickPanel.id: quickPanel, + // Use a default config for PortAndSpeed in flexible layouts + DashboardWidgetSpecs.portAndSpeed.id: buildPortAndSpeed( + const PortAndSpeedConfig( + direction: null, // Auto-detect + showSpeedTest: true, + ), + ), + if (vpnTile != null) DashboardWidgetSpecs.vpn.id: vpnTile!, + }; + + /// Gets the list of visible widgets, ordered and wrapped with constraints. + /// + /// This is the primary method for flexible layout strategies. + List get orderedVisibleWidgets { + final widgets = _allWidgets; + + // 1. Get ordered specs from configs + final orderedSpecs = DashboardWidgetSpecs.all.toList() + ..sort((a, b) { + final configA = getConfigFor(a); + final configB = getConfigFor(b); + return configA.order.compareTo(configB.order); + }); + + // 2. Filter visible and map to widgets + return orderedSpecs + .where((spec) { + final config = getConfigFor(spec); + // Only show if visible AND widget exists (e.g. VPN might be null) + return config.visible && widgets.containsKey(spec.id); + }) + .map((spec) => wrapWidget( + widgets[spec.id]!, + spec: spec, + )) + .toList(); + } +} diff --git a/lib/page/dashboard/strategies/dashboard_layout_factory.dart b/lib/page/dashboard/strategies/dashboard_layout_factory.dart new file mode 100644 index 000000000..d9c083a20 --- /dev/null +++ b/lib/page/dashboard/strategies/dashboard_layout_factory.dart @@ -0,0 +1,39 @@ +import 'package:privacy_gui/page/dashboard/models/dashboard_layout.dart'; + +import 'dashboard_layout_strategy.dart'; +import 'mobile_layout_strategy.dart'; +import 'desktop_horizontal_layout_strategy.dart'; +import 'desktop_vertical_layout_strategy.dart'; +import 'desktop_no_lan_ports_layout_strategy.dart'; +import 'tablet_layout_strategy.dart'; +import 'tablet_horizontal_layout_strategy.dart'; +import 'tablet_vertical_layout_strategy.dart'; + +/// Factory for creating layout strategies based on variant. +/// +/// Strategies are cached as const singletons since they are stateless. +/// This provides O(1) lookup with zero allocation overhead. +class DashboardLayoutFactory { + DashboardLayoutFactory._(); + + /// Map of variant to strategy singleton instances. + static const _strategies = { + DashboardLayoutVariant.mobile: MobileLayoutStrategy(), + DashboardLayoutVariant.desktopHorizontal: DesktopHorizontalLayoutStrategy(), + DashboardLayoutVariant.desktopVertical: DesktopVerticalLayoutStrategy(), + DashboardLayoutVariant.desktopNoLanPorts: DesktopNoLanPortsLayoutStrategy(), + DashboardLayoutVariant.tablet: TabletLayoutStrategy(), + DashboardLayoutVariant.tabletHorizontal: TabletHorizontalLayoutStrategy(), + DashboardLayoutVariant.tabletVertical: TabletVerticalLayoutStrategy(), + }; + + /// Creates (or retrieves) the strategy for the given variant. + /// + /// Since strategies are stateless, this always returns the same instance + /// for a given variant. + static DashboardLayoutStrategy create(DashboardLayoutVariant variant) { + final strategy = _strategies[variant]; + assert(strategy != null, 'No strategy registered for variant: $variant'); + return strategy!; + } +} diff --git a/lib/page/dashboard/strategies/dashboard_layout_strategy.dart b/lib/page/dashboard/strategies/dashboard_layout_strategy.dart new file mode 100644 index 000000000..e57ac7e56 --- /dev/null +++ b/lib/page/dashboard/strategies/dashboard_layout_strategy.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; +import 'dashboard_layout_context.dart'; + +/// Abstract strategy interface for dashboard layouts. +/// +/// Each concrete implementation handles a specific [DashboardLayoutVariant] +/// and is responsible for arranging widgets according to that layout's rules. +/// +/// Strategies are stateless singletons - all required data is passed via +/// [DashboardLayoutContext]. +abstract class DashboardLayoutStrategy { + const DashboardLayoutStrategy(); + + /// Builds the layout widget tree using the provided context. + /// + /// The context contains: + /// - Pre-built atomic widgets (IoC pattern) + /// - Layout configuration (hasLanPort, isHorizontalLayout) + /// - BuildContext for responsive calculations (colWidth) + Widget build(DashboardLayoutContext context); +} diff --git a/lib/page/dashboard/strategies/desktop_horizontal_layout_strategy.dart b/lib/page/dashboard/strategies/desktop_horizontal_layout_strategy.dart new file mode 100644 index 000000000..2994ae60b --- /dev/null +++ b/lib/page/dashboard/strategies/desktop_horizontal_layout_strategy.dart @@ -0,0 +1,65 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import 'dashboard_layout_context.dart'; +import 'dashboard_layout_strategy.dart'; + +/// Desktop horizontal layout strategy. +/// +/// Left column (expanded): Internet → Port → WiFi (stacked vertically) +/// Right column (4 col): Networks → VPN → QuickPanel +class DesktopHorizontalLayoutStrategy extends DashboardLayoutStrategy { + const DesktopHorizontalLayoutStrategy(); + + @override + Widget build(DashboardLayoutContext ctx) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ctx.title, + AppGap.xl(), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + ctx.internetWidget, + AppGap.lg(), + ctx.buildPortAndSpeed(const PortAndSpeedConfig( + direction: Axis.horizontal, + showSpeedTest: true, + portsHeight: 224, + speedTestHeight: 112, + portsPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xxxl, + ), + )), + AppGap.lg(), + ctx.wifiGrid, + ], + ), + ), + AppGap.gutter(), + SizedBox( + width: ctx.colWidth(4), + child: Column( + children: [ + ctx.networksWidget, + AppGap.lg(), + if (ctx.vpnTile != null) ...[ + ctx.vpnTile!, + AppGap.lg(), + ], + ctx.quickPanel, + ], + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/page/dashboard/strategies/desktop_no_lan_ports_layout_strategy.dart b/lib/page/dashboard/strategies/desktop_no_lan_ports_layout_strategy.dart new file mode 100644 index 000000000..5695588d4 --- /dev/null +++ b/lib/page/dashboard/strategies/desktop_no_lan_ports_layout_strategy.dart @@ -0,0 +1,77 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import 'dashboard_layout_context.dart'; +import 'dashboard_layout_strategy.dart'; + +/// Desktop layout for devices without LAN ports. +/// +/// Top row: Internet (8 col) | Port (4 col) +/// Bottom row: Networks + QuickPanel (4 col) | VPN + WiFi (8 col) +class DesktopNoLanPortsLayoutStrategy extends DashboardLayoutStrategy { + const DesktopNoLanPortsLayoutStrategy(); + + @override + Widget build(DashboardLayoutContext ctx) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ctx.title, + AppGap.xl(), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: ctx.colWidth(8), + child: ctx.internetWidget, + ), + AppGap.gutter(), + SizedBox( + width: ctx.colWidth(4), + child: ctx.buildPortAndSpeed(const PortAndSpeedConfig( + direction: Axis.horizontal, + showSpeedTest: true, + portsHeight: 120, + speedTestHeight: 132, + portsPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.md, + ), + )), + ), + ], + ), + AppGap.lg(), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: ctx.colWidth(4), + child: Column( + children: [ + ctx.networksWidget, + AppGap.lg(), + ctx.quickPanel, + ], + ), + ), + AppGap.gutter(), + SizedBox( + width: ctx.colWidth(8), + child: Column( + children: [ + if (ctx.vpnTile != null) ...[ + ctx.vpnTile!, + AppGap.lg(), + ], + ctx.wifiGrid, + ], + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/page/dashboard/strategies/desktop_vertical_layout_strategy.dart b/lib/page/dashboard/strategies/desktop_vertical_layout_strategy.dart new file mode 100644 index 000000000..d83e68d10 --- /dev/null +++ b/lib/page/dashboard/strategies/desktop_vertical_layout_strategy.dart @@ -0,0 +1,64 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import 'dashboard_layout_context.dart'; +import 'dashboard_layout_strategy.dart'; + +/// Desktop vertical layout strategy. +/// +/// Left column (3 col): Port → QuickPanel (ports displayed vertically) +/// Right column (expanded): Internet → Networks → VPN → WiFi +class DesktopVerticalLayoutStrategy extends DashboardLayoutStrategy { + const DesktopVerticalLayoutStrategy(); + + @override + Widget build(DashboardLayoutContext ctx) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ctx.title, + AppGap.xl(), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: ctx.colWidth(3), + child: Column( + children: [ + ctx.buildPortAndSpeed(const PortAndSpeedConfig( + direction: Axis.vertical, + showSpeedTest: false, + portsHeight: 752, + portsPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xxl, + ), + )), + AppGap.lg(), + ctx.quickPanel, + ], + ), + ), + AppGap.gutter(), + Expanded( + child: Column( + children: [ + ctx.internetWidget, + AppGap.lg(), + ctx.networksWidget, + AppGap.lg(), + if (ctx.vpnTile != null) ...[ + ctx.vpnTile!, + AppGap.lg(), + ], + ctx.wifiGrid, + ], + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/page/dashboard/strategies/grid_layout_resolver.dart b/lib/page/dashboard/strategies/grid_layout_resolver.dart new file mode 100644 index 000000000..eace5f4ae --- /dev/null +++ b/lib/page/dashboard/strategies/grid_layout_resolver.dart @@ -0,0 +1,123 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import '../models/display_mode.dart'; +import '../models/height_strategy.dart'; +import '../models/widget_spec.dart'; + +/// Layout Resolver +/// +/// Calculates actual column counts and sizes based on component constraints +/// and current screen state. Only reads UI Kit public API, no modifications. +class GridLayoutResolver { + final BuildContext context; + + const GridLayoutResolver(this.context); + + /// Current maximum columns (4/8/12) + int get currentMaxColumns => context.currentMaxColumns; + + /// Calculate columns for a widget + /// + /// [spec] Widget specification + /// [mode] Display mode + /// [availableColumns] Available columns (for nested layouts, defaults to currentMaxColumns) + /// [overrideColumns] User-specified column override from preferences (null = use spec default) + int resolveColumns( + WidgetSpec spec, + DisplayMode mode, { + int? availableColumns, + int? overrideColumns, + }) { + final constraints = spec.getConstraints(mode); + final maxCols = availableColumns ?? currentMaxColumns; + + // If user has overridden columns, scale that value instead of spec default + if (overrideColumns != null) { + // Scale the override from 12-column system to current max + final scaledOverride = (overrideColumns * maxCols / 12).round(); + // Clamp to valid range + return scaledOverride.clamp(1, maxCols); + } + + // Use spec default - scale proportionally + final scaled = constraints.scaleToMaxColumns(maxCols); + + // Ensure within constraints + final scaledMin = constraints.scaleMinToMaxColumns(maxCols); + final scaledMax = constraints.scaleMaxToMaxColumns(maxCols); + + return scaled.clamp(scaledMin, scaledMax); + } + + /// Calculate width for a widget + double resolveWidth( + WidgetSpec spec, + DisplayMode mode, { + int? availableColumns, + int? overrideColumns, + }) { + final columns = resolveColumns( + spec, + mode, + availableColumns: availableColumns, + overrideColumns: overrideColumns, + ); + return context.colWidth(columns); + } + + /// Calculate height for a widget + /// + /// Returns null for intrinsic sizing + double? resolveHeight( + WidgetSpec spec, + DisplayMode mode, { + int? availableColumns, + int? overrideColumns, + }) { + final constraints = spec.getConstraints(mode); + final singleColWidth = context.colWidth(1); + final width = resolveWidth( + spec, + mode, + availableColumns: availableColumns, + overrideColumns: overrideColumns, + ); + + return switch (constraints.heightStrategy) { + IntrinsicHeightStrategy() => null, + ColumnBasedHeightStrategy(multiplier: final m) => singleColWidth * m, + AspectRatioHeightStrategy(ratio: final r) => width / r, + }; + } + + /// Build constrained SizedBox wrapper + /// + /// Height is null when using intrinsic sizing + Widget wrapWithConstraints( + Widget child, { + required WidgetSpec spec, + required DisplayMode mode, + int? availableColumns, + int? overrideColumns, + }) { + final width = resolveWidth( + spec, + mode, + availableColumns: availableColumns, + overrideColumns: overrideColumns, + ); + final height = resolveHeight( + spec, + mode, + availableColumns: availableColumns, + overrideColumns: overrideColumns, + ); + + return SizedBox( + width: width, + height: height, + child: child, + ); + } +} diff --git a/lib/page/dashboard/strategies/mobile_layout_strategy.dart b/lib/page/dashboard/strategies/mobile_layout_strategy.dart new file mode 100644 index 000000000..251bda86b --- /dev/null +++ b/lib/page/dashboard/strategies/mobile_layout_strategy.dart @@ -0,0 +1,44 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import 'dashboard_layout_context.dart'; +import 'dashboard_layout_strategy.dart'; + +/// Mobile layout strategy - single column, all components stacked vertically. +class MobileLayoutStrategy extends DashboardLayoutStrategy { + const MobileLayoutStrategy(); + + @override + Widget build(DashboardLayoutContext ctx) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ctx.title, + AppGap.xl(), + ctx.internetWidget, + AppGap.lg(), + ctx.buildPortAndSpeed(const PortAndSpeedConfig( + direction: Axis.horizontal, + showSpeedTest: true, + portsPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xxl, + ), + )), + AppGap.lg(), + ctx.networksWidget, + if (ctx.vpnTile != null) ...[ + AppGap.lg(), + ctx.vpnTile!, + ], + AppGap.lg(), + ctx.quickPanel, + AppGap.lg(), + ctx.wifiGrid, + AppGap.lg(), + ], + ); + } +} diff --git a/lib/page/dashboard/strategies/tablet_horizontal_layout_strategy.dart b/lib/page/dashboard/strategies/tablet_horizontal_layout_strategy.dart new file mode 100644 index 000000000..b595ba1fd --- /dev/null +++ b/lib/page/dashboard/strategies/tablet_horizontal_layout_strategy.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import 'dashboard_layout_context.dart'; +import 'dashboard_layout_strategy.dart'; + +/// Tablet layout with horizontal ports. +/// +/// Vertical stack for port section (Internet on top, Port below). +/// Solves overflow for wide port widgets on narrower tablet screens. +class TabletHorizontalLayoutStrategy extends DashboardLayoutStrategy { + const TabletHorizontalLayoutStrategy(); + + @override + Widget build(DashboardLayoutContext ctx) { + return Column( + children: [ + ctx.title, + AppGap.xl(), + // Vertical stack: Internet Top, Port Bottom + ctx.internetWidget, + AppGap.lg(), + ctx.buildPortAndSpeed(const PortAndSpeedConfig( + direction: Axis.horizontal, + showSpeedTest: true, + portsHeight: 224, + speedTestHeight: 112, + portsPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xxl, + ), + )), + AppGap.lg(), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 1, + child: Column( + children: [ + ctx.networksWidget, + AppGap.lg(), + ctx.quickPanel, + ], + ), + ), + AppGap.gutter(), + Expanded( + flex: 1, + child: Column( + children: [ + if (ctx.vpnTile != null) ...[ + ctx.vpnTile!, + AppGap.lg(), + ], + ctx.wifiGrid, + ], + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/page/dashboard/strategies/tablet_layout_strategy.dart b/lib/page/dashboard/strategies/tablet_layout_strategy.dart new file mode 100644 index 000000000..33af3b428 --- /dev/null +++ b/lib/page/dashboard/strategies/tablet_layout_strategy.dart @@ -0,0 +1,81 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import 'dashboard_layout_context.dart'; +import 'dashboard_layout_strategy.dart'; + +/// Tablet layout strategy for devices without LAN ports. +/// +/// Two column layout with equal split: +/// Left: Networks → QuickPanel +/// Right: VPN → WiFi +/// +/// Port section shows side-by-side with Internet at top. +class TabletLayoutStrategy extends DashboardLayoutStrategy { + const TabletLayoutStrategy(); + + @override + Widget build(DashboardLayoutContext ctx) { + return Column( + children: [ + ctx.title, + AppGap.xl(), + // Port section: Side-by-Side (Internet Left, Port Right) + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 1, + child: ctx.internetWidget, + ), + AppGap.gutter(), + Expanded( + flex: 1, + child: ctx.buildPortAndSpeed(const PortAndSpeedConfig( + direction: Axis.horizontal, + showSpeedTest: true, + portsPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + )), + ), + ], + ), + ), + AppGap.lg(), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 1, + child: Column( + children: [ + ctx.networksWidget, + AppGap.lg(), + ctx.quickPanel, + ], + ), + ), + AppGap.gutter(), + Expanded( + flex: 1, + child: Column( + children: [ + if (ctx.vpnTile != null) ...[ + ctx.vpnTile!, + AppGap.lg(), + ], + ctx.wifiGrid, + ], + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/page/dashboard/strategies/tablet_vertical_layout_strategy.dart b/lib/page/dashboard/strategies/tablet_vertical_layout_strategy.dart new file mode 100644 index 000000000..cb7033f91 --- /dev/null +++ b/lib/page/dashboard/strategies/tablet_vertical_layout_strategy.dart @@ -0,0 +1,63 @@ +import 'package:flutter/widgets.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import 'dashboard_layout_context.dart'; +import 'dashboard_layout_strategy.dart'; + +/// Tablet layout with vertical ports. +/// +/// Custom layout optimized for vertical port display: +/// - Top: Internet +/// - Middle: Networks +/// - Bottom: Split row (Port left | WiFi + QuickPanel right) +class TabletVerticalLayoutStrategy extends DashboardLayoutStrategy { + const TabletVerticalLayoutStrategy(); + + @override + Widget build(DashboardLayoutContext ctx) { + return Column( + children: [ + ctx.title, + AppGap.xl(), + ctx.internetWidget, + AppGap.lg(), + ctx.networksWidget, + AppGap.lg(), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 1, + child: ctx.buildPortAndSpeed(const PortAndSpeedConfig( + direction: Axis.vertical, + showSpeedTest: false, + portsHeight: 752, + portsPadding: EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xxl, + ), + )), + ), + AppGap.gutter(), + Expanded( + flex: 2, + child: Column( + children: [ + if (ctx.vpnTile != null) ...[ + ctx.vpnTile!, + AppGap.lg(), + ], + ctx.wifiGrid, + AppGap.lg(), + ctx.quickPanel, + ], + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/page/dashboard/views/components/_components.dart b/lib/page/dashboard/views/components/_components.dart new file mode 100644 index 000000000..362497745 --- /dev/null +++ b/lib/page/dashboard/views/components/_components.dart @@ -0,0 +1,19 @@ +/// Dashboard home components barrel file. +library; + +export 'settings/dashboard_layout_settings_panel.dart'; +export 'core/dashboard_loading_wrapper.dart'; +export 'core/dashboard_tile.dart'; +export 'widgets/parts/external_speed_test_links.dart'; +export 'dialogs/firmware_update_countdown_dialog.dart'; +export 'widgets/home_title.dart'; +export 'widgets/parts/internal_speed_test_result.dart'; +export 'widgets/internet_status.dart'; +export 'core/loading_tile.dart'; +export 'widgets/networks.dart'; +export 'widgets/port_and_speed.dart'; +export 'widgets/parts/port_status_widget.dart'; +export 'widgets/quick_panel.dart'; +export 'widgets/parts/remote_assistance_animation.dart'; +export 'widgets/parts/wifi_card.dart'; +export 'widgets/wifi_grid.dart'; diff --git a/lib/page/dashboard/views/components/core/dashboard_loading_wrapper.dart b/lib/page/dashboard/views/components/core/dashboard_loading_wrapper.dart new file mode 100644 index 000000000..c1b3f1295 --- /dev/null +++ b/lib/page/dashboard/views/components/core/dashboard_loading_wrapper.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/data/providers/polling_provider.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/loading_tile.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// A wrapper widget that shows a loading state while dashboard data is being fetched. +/// +/// This widget checks the [pollingProvider] state and displays a [LoadingTile] +/// inside an [AppCard] when data is not ready. Once ready, it renders the +/// child content via the [builder] callback. +/// +/// Example usage: +/// ```dart +/// DashboardLoadingWrapper( +/// loadingHeight: 250, +/// builder: (context, ref) { +/// final state = ref.watch(dashboardHomeProvider); +/// return MyContentWidget(state: state); +/// }, +/// ) +/// ``` +class DashboardLoadingWrapper extends ConsumerWidget { + /// Creates a dashboard loading wrapper. + const DashboardLoadingWrapper({ + super.key, + required this.builder, + this.loadingHeight = 250, + this.loadingWidth, + }); + + /// Builder callback that creates the content widget when loading is complete. + final Widget Function(BuildContext context, WidgetRef ref) builder; + + /// Height of the loading placeholder. Defaults to 250. + final double loadingHeight; + + /// Width of the loading placeholder. Defaults to double.infinity. + final double? loadingWidth; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isLoading = + (ref.watch(pollingProvider).value?.isReady ?? false) == false; + + if (isLoading) { + return AppCard( + padding: EdgeInsets.zero, + child: SizedBox( + width: loadingWidth ?? double.infinity, + height: loadingHeight, + child: const LoadingTile(), + ), + ); + } + + return builder(context, ref); + } +} diff --git a/lib/page/dashboard/views/components/dashboard_tile.dart b/lib/page/dashboard/views/components/core/dashboard_tile.dart similarity index 90% rename from lib/page/dashboard/views/components/dashboard_tile.dart rename to lib/page/dashboard/views/components/core/dashboard_tile.dart index 16f28da97..773c90d9d 100644 --- a/lib/page/dashboard/views/components/dashboard_tile.dart +++ b/lib/page/dashboard/views/components/core/dashboard_tile.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/loading_tile.dart'; import 'package:ui_kit_library/ui_kit.dart'; class DashboardTile extends StatelessWidget { diff --git a/lib/page/dashboard/views/components/loading_tile.dart b/lib/page/dashboard/views/components/core/loading_tile.dart similarity index 100% rename from lib/page/dashboard/views/components/loading_tile.dart rename to lib/page/dashboard/views/components/core/loading_tile.dart diff --git a/lib/page/dashboard/views/components/dialogs/firmware_update_countdown_dialog.dart b/lib/page/dashboard/views/components/dialogs/firmware_update_countdown_dialog.dart new file mode 100644 index 000000000..cc49e8ea9 --- /dev/null +++ b/lib/page/dashboard/views/components/dialogs/firmware_update_countdown_dialog.dart @@ -0,0 +1,61 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// A countdown dialog shown after firmware update completion. +/// Displays a 5-second countdown before reloading the application. +class FirmwareUpdateCountdownDialog extends StatefulWidget { + final VoidCallback onFinish; + const FirmwareUpdateCountdownDialog({super.key, required this.onFinish}); + + @override + State createState() => + _FirmwareUpdateCountdownDialogState(); +} + +class _FirmwareUpdateCountdownDialogState + extends State { + int _seconds = 5; + late final Timer _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_seconds == 1) { + _timer.cancel(); + Navigator.of(context).pop(); + widget.onFinish(); + } else { + setState(() { + _seconds--; + }); + } + }); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: AppText.titleLarge(loc(context).firmwareUpdated), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + AppGap.lg(), + AppText.labelLarge( + loc(context).firmwareUpdateCountdownMessage(_seconds), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/internet_status.dart b/lib/page/dashboard/views/components/internet_status.dart deleted file mode 100644 index aea55060d..000000000 --- a/lib/page/dashboard/views/components/internet_status.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:privacy_gui/core/cloud/providers/geolocation/geolocation_provider.dart'; -import 'package:privacy_gui/core/cloud/providers/geolocation/geolocation_state.dart'; -import 'package:privacy_gui/core/data/providers/node_internet_status_provider.dart'; -import 'package:privacy_gui/core/data/providers/polling_provider.dart'; -import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/components/customs/animated_refresh_container.dart'; -import 'package:privacy_gui/page/components/shared_widgets.dart'; -import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; -import 'package:privacy_gui/page/instant_topology/providers/_providers.dart'; -import 'package:privacy_gui/route/constants.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; -import 'package:privacy_gui/utils.dart'; - -import 'package:ui_kit_library/ui_kit.dart'; - -class InternetConnectionWidget extends ConsumerStatefulWidget { - const InternetConnectionWidget({super.key}); - - @override - ConsumerState createState() => - _InternetConnectionWidgetState(); -} - -class _InternetConnectionWidgetState - extends ConsumerState { - @override - Widget build(BuildContext context) { - final wanStatus = ref.watch(internetStatusProvider); - final isOnline = wanStatus == InternetStatus.online; - - final isLoading = - (ref.watch(pollingProvider).value?.isReady ?? false) == false; - final geolocationState = ref.watch(geolocationProvider); - final master = isLoading - ? null - : ref.watch(instantTopologyProvider).root.children.first; - final masterIcon = ref.watch(dashboardHomeProvider).masterIcon; - final wanPortConnection = - ref.watch(dashboardHomeProvider).wanPortConnection; - final isMasterOffline = - master?.data.isOnline == false || wanPortConnection == 'None'; - return isLoading - ? AppCard( - padding: EdgeInsets.zero, - child: SizedBox( - width: double.infinity, - height: 150, - child: const LoadingTile())) - : AppCard( - padding: EdgeInsets.zero, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.circle, - color: isOnline - ? Theme.of(context) - .extension()! - .semanticSuccess - : Theme.of(context) - .colorScheme - .surfaceContainerHighest, - size: 16.0, - ), - AppGap.sm(), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.titleSmall( - isOnline - ? loc(context).internetOnline - : loc(context).internetOffline, - ), - if (geolocationState.value?.name.isNotEmpty == - true) ...[ - AppGap.sm(), - SharedWidgets.geolocationWidget( - context, - geolocationState.value?.name ?? '', - geolocationState.value?.displayLocationText ?? - ''), - ], - ], - ), - ), - if (!Utils.isMobilePlatform()) - AnimatedRefreshContainer( - builder: (controller) { - return Padding( - padding: const EdgeInsets.all(0.0), - child: AppIconButton( - icon: AppIcon.font( - AppFontIcons.refresh, - ), - onTap: () { - controller.repeat(); - ref - .read(pollingProvider.notifier) - .forcePolling() - .then((value) { - controller.stop(); - }); - }, - ), - ); - }, - ), - ], - ), - ), - Container( - key: const ValueKey('master'), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, - border: Border.all(color: Colors.transparent), - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(8), - bottomRight: Radius.circular(8), - ), - ), - child: Row( - children: [ - SizedBox( - width: context.isMobileLayout ? 120 : 90, - child: SharedWidgets.resolveRouterImage( - context, masterIcon, - size: 112), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - top: AppSpacing.lg, - bottom: AppSpacing.lg, - left: AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - AppText.titleMedium( - master?.data.location ?? '-----'), - AppGap.lg(), - Table( - border: const TableBorder(), - columnWidths: const { - 0: FlexColumnWidth(1), - 1: FlexColumnWidth(2), - }, - children: [ - TableRow(children: [ - AppText.labelLarge( - '${loc(context).connection}:'), - AppText.bodyMedium(isMasterOffline - ? '--' - : (master?.data.isWiredConnection == - true) - ? loc(context).wired - : loc(context).wireless), - ]), - TableRow(children: [ - AppText.labelLarge( - '${loc(context).model}:'), - AppText.bodyMedium( - master?.data.model ?? '--'), - ]), - TableRow(children: [ - AppText.labelLarge( - '${loc(context).serialNo}:'), - AppText.bodyMedium( - master?.data.serialNumber ?? '--'), - ]), - TableRow(children: [ - AppText.labelLarge( - '${loc(context).fwVersion}:'), - Wrap( - crossAxisAlignment: - WrapCrossAlignment.center, - children: [ - AppText.bodyMedium( - master?.data.fwVersion ?? '--'), - if (!isMasterOffline) ...[ - AppGap.lg(), - SharedWidgets - .nodeFirmwareStatusWidget( - context, - master?.data.fwUpToDate == false, - () { - context.pushNamed(RouteNamed - .firmwareUpdateDetail); - }, - ), - ] - ], - ), - ]), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/page/dashboard/views/components/networks.dart b/lib/page/dashboard/views/components/networks.dart deleted file mode 100644 index 5c94e814b..000000000 --- a/lib/page/dashboard/views/components/networks.dart +++ /dev/null @@ -1,340 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; -import 'package:privacy_gui/core/data/providers/firmware_update_provider.dart'; -import 'package:privacy_gui/core/data/providers/node_internet_status_provider.dart'; -import 'package:privacy_gui/core/data/providers/polling_provider.dart'; -import 'package:privacy_gui/core/utils/devices.dart'; -import 'package:privacy_gui/core/utils/topology_adapter.dart'; -import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/dashboard/_dashboard.dart'; -import 'package:privacy_gui/page/nodes/providers/node_detail_id_provider.dart'; -import 'package:privacy_gui/page/instant_topology/providers/_providers.dart'; -import 'package:privacy_gui/page/instant_topology/views/model/topology_model.dart'; -import 'package:privacy_gui/route/constants.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; - -import 'package:ui_kit_library/ui_kit.dart'; - -class DashboardNetworks extends ConsumerStatefulWidget { - const DashboardNetworks({super.key}); - - @override - ConsumerState createState() => _DashboardNetworksState(); -} - -class _DashboardNetworksState extends ConsumerState { - @override - Widget build(BuildContext context) { - final state = ref.watch(dashboardHomeProvider); - final topologyState = ref.watch(instantTopologyProvider); - - // Convert topology data to ui_kit format - final meshTopology = TopologyAdapter.convert(topologyState.root.children); - - const topologyItemHeight = 96.0; - const treeViewBaseHeight = 68.0; - final routerLength = - topologyState.root.children.firstOrNull?.toFlatList().length ?? 1; - final double nodeTopologyHeight = context.isMobileLayout - ? routerLength * topologyItemHeight - : min(routerLength * topologyItemHeight, 3 * topologyItemHeight); - final hasLanPort = - ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; - final showAllTopology = context.isMobileLayout || routerLength <= 3; - final isLoading = - (ref.watch(pollingProvider).value?.isReady ?? false) == false; - return isLoading - ? AppCard( - padding: EdgeInsets.zero, - child: SizedBox(width: double.infinity, child: const LoadingTile())) - : AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppGap.lg(), - AppResponsiveLayout( - desktop: (ctx) => !hasLanPort - ? _mobile(context, ref) - : state.isHorizontalLayout - ? _desktopHorizontal(context, ref) - : _desktopVertical(context, ref), - mobile: (ctx) => _mobile(context, ref), - ), - SizedBox( - height: - isLoading ? 188 : nodeTopologyHeight + treeViewBaseHeight, - child: AppTopology( - topology: meshTopology, - viewMode: - TopologyViewMode.tree, // Force tree view for dashboard - enableAnimation: - !showAllTopology, // Disable animation for mobile/small screens - onNodeTap: TopologyAdapter.wrapNodeTapCallback( - topologyState.root.children, - (RouterTreeNode node) { - if (node.data.isOnline) { - ref.read(nodeDetailIdProvider.notifier).state = - node.data.deviceId; - context.pushNamed(RouteNamed.nodeDetails); - } - }, - ), - indent: 16.0, // Reduced indentation - treeConfig: TopologyTreeConfiguration( - preferAnimationNode: false, - showType: false, - showStatusText: false, - showStatusIndicator: true, - titleBuilder: (meshNode) => meshNode.name, - subtitleBuilder: (meshNode) { - // Use metadata directly instead of searching the tree again - final model = meshNode.extra; - final deviceCount = meshNode - .metadata?['connectedDeviceCount'] as int? ?? - 0; - final deviceLabel = deviceCount <= 1 - ? loc(context).device - : loc(context).devices; - - if (model == null || model.isEmpty) { - return '$deviceCount $deviceLabel'; - } - return '$model • $deviceCount $deviceLabel'; - }, - ), - ), - ), - ], - ), - ); - } - - Widget _desktopHorizontal(BuildContext context, WidgetRef ref) { - final topologyState = ref.watch(instantTopologyProvider); - final wanStatus = ref.watch(internetStatusProvider); - - final newFirmware = hasNewFirmware(ref); - final isOnline = wanStatus == InternetStatus.online; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.titleSmall(loc(context).myNetwork), - if (isOnline) ...[ - AppGap.lg(), - _firmwareStatusWidget(context, newFirmware), - ], - AppGap.xxl(), - Row( - children: [ - Expanded( - child: _nodesInfoTile( - context, - ref, - topologyState, - ), - ), - AppGap.gutter(), - Expanded( - child: _devicesInfoTile( - context, - ref, - topologyState, - ), - ) - ], - ), - ], - ); - } - - Widget _desktopVertical(BuildContext context, WidgetRef ref) { - final wanStatus = ref.watch(internetStatusProvider); - final topologyState = ref.watch(instantTopologyProvider); - final newFirmware = hasNewFirmware(ref); - final isOnline = wanStatus == InternetStatus.online; - - return Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.titleSmall(loc(context).myNetwork), - if (isOnline) _firmwareStatusWidget(context, newFirmware), - ], - ), - const Spacer(), - Expanded( - flex: 3, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - _nodesInfoTile(context, ref, topologyState), - AppGap.gutter(), - _devicesInfoTile( - context, - ref, - topologyState, - ) - ], - ), - ), - ], - ); - } - - Widget _mobile(BuildContext context, WidgetRef ref) { - final newFirmware = hasNewFirmware(ref); - final wanStatus = ref.watch(internetStatusProvider); - final isOnline = wanStatus == InternetStatus.online; - final topologyState = ref.watch(instantTopologyProvider); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.titleSmall(loc(context).myNetwork), - if (isOnline) _firmwareStatusWidget(context, newFirmware), - ], - ), - AppGap.xxl(), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _nodesInfoTile( - context, - ref, - topologyState, - )), - AppGap.gutter(), - Expanded( - child: _devicesInfoTile( - context, - ref, - topologyState, - ), - ) - ], - ), - ], - ); - } - - bool hasNewFirmware(WidgetRef ref) { - final nodesStatus = - ref.watch(firmwareUpdateProvider.select((value) => value.nodesStatus)); - return nodesStatus?.any((element) => element.availableUpdate != null) ?? - false; - } - - Widget _firmwareStatusWidget(BuildContext context, bool newFirmware) { - return InkWell( - onTap: newFirmware - ? () => context.pushNamed(RouteNamed.firmwareUpdateDetail) - : null, - child: Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - newFirmware - ? AppText.labelMedium( - loc(context).updateFirmware, - color: Theme.of(context).colorScheme.primary, - ) - : _firmwareUpdateToDateWidget(context), - newFirmware - ? AppIcon.font( - AppFontIcons.cloudDownload, - color: Theme.of(context).colorScheme.primary, - ) - : AppIcon.font( - AppFontIcons.check, - color: Theme.of(context) - .extension() - ?.semanticSuccess ?? - Colors.green, - ) - ], - ), - ); - } - - Widget _firmwareUpdateToDateWidget(BuildContext context) { - return AppStyledText( - text: loc(context).dashboardFirmwareUpdateToDate, - ); - } - - Widget _nodesInfoTile( - BuildContext context, WidgetRef ref, InstantTopologyState state) { - final nodes = state.root.children.firstOrNull?.toFlatList() ?? []; - final hasOffline = nodes.any((element) => !element.data.isOnline); - return _infoTile( - icon: hasOffline - ? AppIcon.font(AppFontIcons.infoCircle, - color: Theme.of(context).colorScheme.error) - : AppIcon.font(AppFontIcons.networkNode), - // iconColor is now handled inside the AppIcon or ignored if passed - text: nodes.length == 1 ? loc(context).node : loc(context).nodes, - count: nodes.length, - onTap: () { - ref.read(topologySelectedIdProvider.notifier).state = ''; - context.pushNamed(RouteNamed.menuInstantTopology); - }, - ); - } - - Widget _devicesInfoTile( - BuildContext context, WidgetRef ref, InstantTopologyState state) { - final externalDeviceCount = ref - .watch(deviceManagerProvider) - .externalDevices - .where((e) => e.isOnline()) - .length; - - return _infoTile( - text: - externalDeviceCount == 1 ? loc(context).device : loc(context).devices, - count: externalDeviceCount, - icon: AppIcon.font(AppFontIcons.devices), - onTap: () { - context.pushNamed(RouteNamed.menuInstantDevices); - }, - ); - } - - Widget _infoTile({ - required String text, - required int count, - required Widget icon, - required VoidCallback onTap, - }) { - return InkWell( - onTap: onTap, - child: AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.titleSmall('$count'), - icon, - ], - ), - AppGap.lg(), - AppText.bodySmall(text), - ], - ), - ), - ); - } -} diff --git a/lib/page/dashboard/views/components/port_and_speed.dart b/lib/page/dashboard/views/components/port_and_speed.dart deleted file mode 100644 index ad3be0c41..000000000 --- a/lib/page/dashboard/views/components/port_and_speed.dart +++ /dev/null @@ -1,839 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'package:go_router/go_router.dart'; -import 'package:privacy_gui/constants/build_config.dart'; -import 'package:privacy_gui/core/data/providers/node_internet_status_provider.dart'; -import 'package:privacy_gui/core/data/providers/polling_provider.dart'; -import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; -import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; -import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; -import 'package:privacy_gui/page/health_check/widgets/speed_test_widget.dart'; -import 'package:privacy_gui/route/constants.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; - -import 'package:ui_kit_library/ui_kit.dart'; -import 'package:privacy_gui/util/url_helper/url_helper.dart' - if (dart.library.io) 'package:privacy_gui/util/url_helper/url_helper_mobile.dart' - if (dart.library.html) 'package:privacy_gui/util/url_helper/url_helper_web.dart'; - -class DashboardHomePortAndSpeed extends ConsumerWidget { - const DashboardHomePortAndSpeed({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(dashboardHomeProvider); - final isLoading = - (ref.watch(pollingProvider).value?.isReady ?? false) == false; - final horizontalLayout = state.isHorizontalLayout; - final wanStatus = ref.watch(internetStatusProvider); - final isOnline = wanStatus == InternetStatus.online; - final hasLanPort = state.lanPortConnections.isNotEmpty; - - return isLoading - ? AppCard( - padding: EdgeInsets.zero, - child: SizedBox( - width: double.infinity, - height: 250, - child: const LoadingTile())) - : AppResponsiveLayout( - desktop: (ctx) => !hasLanPort - ? _desktopNoLanPorts(context, ref, state, isOnline, isLoading) - : horizontalLayout - ? _desktopHorizontal( - context, ref, state, isOnline, isLoading) - : _desktopVertical( - context, ref, state, isOnline, isLoading), - mobile: (ctx) => _mobile(context, ref, state, isOnline, isLoading)); - } - - Widget _mobile(BuildContext context, WidgetRef ref, DashboardHomeState state, - bool isOnline, bool isLoading) { - final hasLanPort = state.lanPortConnections.isNotEmpty; - return SizedBox( - width: double.infinity, - // constraints: - // BoxConstraints(minHeight: !state.isHealthCheckSupported ? 240 : 420), - child: AppCard( - padding: EdgeInsets.zero, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.xxl, - ), - child: Row( - // mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...state.lanPortConnections - .mapIndexed((index, e) => Expanded( - child: _portWidget( - context, - e == 'None' ? null : e, - loc(context).indexedPort(index + 1), - false, - hasLanPort), - )) - .toList(), - Expanded( - child: _portWidget( - context, - state.wanPortConnection == 'None' - ? null - : state.wanPortConnection, - loc(context).wan, - true, - hasLanPort), - ) - ], - ), - ), - // AppGap.xxl(), - _createSpeedTestTile(context, ref, state, hasLanPort, true), - ], - )), - ); - } - - Widget _desktopVertical(BuildContext context, WidgetRef ref, - DashboardHomeState state, bool isOnline, bool isLoading) { - final hasLanPort = state.lanPortConnections.isNotEmpty; - final isHealthCheckSupported = - ref.watch(healthCheckProvider).isSpeedTestModuleSupported; - return Container( - constraints: BoxConstraints( - minWidth: 150, minHeight: !isHealthCheckSupported ? 360 : 520), - child: AppCard( - padding: EdgeInsets.zero, - child: Column( - mainAxisAlignment: !isHealthCheckSupported - ? MainAxisAlignment.center - : MainAxisAlignment.spaceBetween, - children: [ - SizedBox( - height: 752, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xxl, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ...state.lanPortConnections - .mapIndexed((index, e) => Padding( - padding: const EdgeInsets.only(bottom: 36.0), - child: _portWidget( - context, - e == 'None' ? null : e, - loc(context).indexedPort(index + 1), - false, - hasLanPort), - )) - .toList(), - _portWidget( - context, - state.wanPortConnection == 'None' - ? null - : state.wanPortConnection, - loc(context).wan, - true, - hasLanPort), - ], - ), - ), - ), - SizedBox( - width: double.infinity, - // height: state.isHealthCheckSupported ? 304 : 154, - child: _createSpeedTestTile(context, ref, state, hasLanPort)), - ], - )), - ); - } - - Widget _desktopHorizontal(BuildContext context, WidgetRef ref, - DashboardHomeState state, bool isOnline, bool isLoading) { - final hasLanPort = state.lanPortConnections.isNotEmpty; - - return Container( - width: double.infinity, - constraints: const BoxConstraints(minHeight: 110), - child: AppCard( - padding: EdgeInsets.zero, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 224, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xxxl, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...state.lanPortConnections - .mapIndexed((index, e) => Expanded( - child: _portWidget( - context, - e == 'None' ? null : e, - loc(context).indexedPort(index + 1), - false, - hasLanPort), - )) - .toList(), - Expanded( - child: _portWidget( - context, - state.wanPortConnection == 'None' - ? null - : state.wanPortConnection, - loc(context).wan, - true, - hasLanPort), - ) - ], - ), - ), - ), - SizedBox( - height: 112, - child: _createSpeedTestTile(context, ref, state, hasLanPort)), - ], - )), - ); - } - - Widget _desktopNoLanPorts(BuildContext context, WidgetRef ref, - DashboardHomeState state, bool isOnline, bool isLoading) { - final hasLanPort = state.lanPortConnections.isNotEmpty; - - return Container( - width: double.infinity, - constraints: const BoxConstraints(minHeight: 256), - child: AppCard( - padding: EdgeInsets.zero, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 120, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.md, // Reduced from xl to fix overflow - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...state.lanPortConnections - .mapIndexed((index, e) => Expanded( - child: _portWidget( - context, - e == 'None' ? null : e, - loc(context).indexedPort(index + 1), - false, - hasLanPort), - )) - .toList(), - Expanded( - child: _portWidget( - context, - state.wanPortConnection == 'None' - ? null - : state.wanPortConnection, - loc(context).wan, - true, - hasLanPort), - ) - ], - ), - ), - ), - SizedBox( - height: 132, - child: _createSpeedTestTile(context, ref, state, false)), - ], - )), - ); - } - - Widget _createSpeedTestTile(BuildContext context, WidgetRef ref, - DashboardHomeState state, bool hasLanPort, - [bool mobile = false]) { - final isRemote = BuildConfig.isRemote(); - final isHealthCheckSupported = - ref.watch(healthCheckProvider).isSpeedTestModuleSupported; - return isHealthCheckSupported - ? hasLanPort - ? Column( - children: [ - const Divider(), - const SpeedTestWidget( - showDetails: false, - showInfoPanel: true, - showStepDescriptions: false, - showLatestOnIdle: true, - layout: SpeedTestLayout.vertical), - AppGap.xxl(), - ], - ) - : _speedCheckWidget(context, ref, state) - : Tooltip( - message: loc(context).featureUnavailableInRemoteMode, - child: Opacity( - opacity: isRemote ? 0.5 : 1, - child: AbsorbPointer( - absorbing: isRemote, - child: _externalSpeedTest(context, state), - ), - ), - ); - } - - Widget _speedCheckWidget( - BuildContext context, WidgetRef ref, DashboardHomeState state) { - final speedTest = ref.watch(healthCheckProvider); - final horizontalLayout = state.isHorizontalLayout; - final hasLanPort = - ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; - final dateTime = speedTest.latestSpeedTest?.timestampEpoch == null - ? null - : DateTime.fromMillisecondsSinceEpoch( - speedTest.latestSpeedTest!.timestampEpoch!); - final isLegacy = dateTime == null - ? true - : DateTime.now().difference(dateTime).inDays > 1; - final dateTimeStr = - dateTime == null ? '' : loc(context).formalDateTime(dateTime, dateTime); - return Container( - key: const ValueKey('speedCheck'), - color: Theme.of(context).colorScheme.surfaceContainerLow, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxxl), - child: Column( - crossAxisAlignment: horizontalLayout - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, - children: [ - AppResponsiveLayout( - desktop: (ctx) => !hasLanPort - ? Padding( - padding: - const EdgeInsets.symmetric(vertical: AppSpacing.md), - child: Column( - children: [ - if (dateTimeStr.isNotEmpty) ...[ - AppText.bodySmall(dateTimeStr), - AppGap.sm(), - ], - Row( - spacing: AppSpacing.lg, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Opacity( - opacity: isLegacy ? 0.6 : 1, - child: _downloadSpeedResult( - context, - speedTest.latestSpeedTest?.downloadSpeed ?? - '--', - speedTest.latestSpeedTest?.downloadUnit, - isLegacy), - ), - Opacity( - opacity: isLegacy ? 0.6 : 1, - child: _uploadSpeedResult( - context, - speedTest.latestSpeedTest?.uploadSpeed ?? - '--', - speedTest.latestSpeedTest?.uploadUnit, - isLegacy), - ) - ], - ), - AppGap.lg(), - _speedTestButton(context, state) - ], - ), - ) - : horizontalLayout - ? Padding( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.xxl), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (dateTimeStr.isNotEmpty) ...[ - AppText.bodySmall(dateTimeStr), - AppGap.sm(), - ], - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Opacity( - opacity: isLegacy ? 0.6 : 1, - child: _downloadSpeedResult( - context, - speedTest.latestSpeedTest - ?.downloadSpeed ?? - '--', - speedTest.latestSpeedTest - ?.downloadUnit, - isLegacy), - ), - ), - Expanded( - child: Opacity( - opacity: isLegacy ? 0.6 : 1, - child: _uploadSpeedResult( - context, - speedTest.latestSpeedTest - ?.uploadSpeed ?? - '--', - speedTest - .latestSpeedTest?.uploadUnit, - isLegacy), - ), - ), - ], - ) - ], - ), - ), - _speedTestButton(context, state) - ], - ), - ) - : Padding( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.xxl), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (dateTimeStr.isNotEmpty) ...[ - AppText.bodySmall(dateTimeStr), - AppGap.sm(), - ], - _downloadSpeedResult( - context, - speedTest.latestSpeedTest?.downloadSpeed ?? - '--', - speedTest.latestSpeedTest?.downloadUnit, - isLegacy), - AppGap.xxl(), - _uploadSpeedResult( - context, - speedTest.latestSpeedTest?.uploadSpeed ?? - '--', - speedTest.latestSpeedTest?.uploadUnit, - isLegacy), - AppGap.lg(), - _speedTestButton(context, state), - ]), - ), - mobile: (ctx) => Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.xxl), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.bodySmall(dateTimeStr), - AppGap.sm(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Opacity( - opacity: isLegacy ? 0.6 : 1, - child: _downloadSpeedResult( - context, - speedTest.latestSpeedTest?.downloadSpeed ?? - '--', - speedTest.latestSpeedTest?.downloadUnit, - isLegacy), - ), - ), - Expanded( - child: Opacity( - opacity: isLegacy ? 0.6 : 1, - child: _uploadSpeedResult( - context, - speedTest.latestSpeedTest?.uploadSpeed ?? - '--', - speedTest.latestSpeedTest?.uploadUnit, - isLegacy), - ), - ), - ], - ) - ], - ), - ), - _speedTestButton(context, state) - ], - ), - ), - ), - ], - ), - ); - } - - Widget _externalSpeedTest(BuildContext context, DashboardHomeState state) { - final horizontalLayout = state.isHorizontalLayout; - final hasLanPort = state.lanPortConnections.isNotEmpty; - - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, - border: Border.all(color: Colors.transparent), - borderRadius: BorderRadius.circular(12).copyWith( - topLeft: Radius.circular(0), - topRight: Radius.circular(0), - ), - ), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xl, vertical: AppSpacing.sm), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _speedTestHeader(context, state), - AppGap.sm(), - Flexible( - child: hasLanPort && !horizontalLayout && !context.isMobileLayout - ? SizedBox( - width: 144, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - spacing: AppSpacing.sm, - children: [ - AppButton( - label: loc(context).speedTestExternalTileCloudFlare, - onTap: () { - openUrl('https://speed.cloudflare.com/'); - }, - ), - AppButton( - label: loc(context).speedTestExternalTileFast, - onTap: () { - openUrl('https://www.fast.com'); - }, - ), - ]), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - spacing: AppSpacing.lg, - children: [ - Expanded( - child: AppButton( - label: loc(context).speedTestExternalTileCloudFlare, - onTap: () { - openUrl('https://speed.cloudflare.com/'); - }, - ), - ), - Expanded( - child: AppButton( - label: loc(context).speedTestExternalTileFast, - onTap: () { - openUrl('https://www.fast.com'); - }, - ), - ), - ]), - ), - AppGap.sm(), - AppText.bodyExtraSmall(loc(context).speedTestExternalOthers), - ], - ), - ); - } - - Widget _speedTestHeader(BuildContext context, DashboardHomeState state) { - final horizontalLayout = state.isHorizontalLayout; - final hasLanPort = state.lanPortConnections.isNotEmpty; - final speedTitle = AppText.titleMedium(loc(context).speedTextTileStart); - final infoIcon = InkWell( - child: AppIcon.font( - AppFontIcons.infoCircle, - color: Theme.of(context).colorScheme.primary, - ), - onTap: () { - openUrl('https://support.linksys.com/kb/article/79-en/'); - }, - ); - final speedDesc = - AppText.labelSmall(loc(context).speedTestExternalTileLabel); - final rowHeader = Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded(child: speedTitle), - infoIcon, - speedDesc, - ], - ); - final columnHeader = Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align(alignment: AlignmentDirectional.centerStart, child: speedTitle), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - infoIcon, - AppGap.sm(), - Expanded( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: AlignmentDirectional.centerStart, - child: speedDesc, - ), - ) - ], - ) - ], - ); - return AppResponsiveLayout( - desktop: (ctx) => - hasLanPort && horizontalLayout ? rowHeader : columnHeader, - mobile: (ctx) => rowHeader); - } - - Widget _speedTestButton(BuildContext context, DashboardHomeState state) { - return SizedBox( - height: 40, - child: AppButton( - label: loc(context).speedTextTileStart, - onTap: () { - context.pushNamed(RouteNamed.dashboardSpeedTest); - }, - ), - ); - } - - Widget _downloadSpeedResult( - BuildContext context, String value, String? unit, bool isLegacy, - [WrapAlignment alignment = WrapAlignment.start]) { - return Wrap( - alignment: alignment, - crossAxisAlignment: WrapCrossAlignment.end, - children: [ - AppIcon.font( - AppFontIcons.arrowDownward, - color: isLegacy - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.primary, - ), - AppText.titleLarge( - value, - color: isLegacy - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.onSurface, - ), - if (unit != null && unit.isNotEmpty) ...[ - AppGap.xs(), - AppText.bodySmall( - '${unit}ps', - color: isLegacy - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.onSurface, - ) - ] - ], - ); - } - - Widget _uploadSpeedResult( - BuildContext context, String value, String? unit, bool isLegacy, - [WrapAlignment alignment = WrapAlignment.start]) { - return Wrap( - alignment: alignment, - crossAxisAlignment: WrapCrossAlignment.end, - children: [ - AppIcon.font( - AppFontIcons.arrowUpward, - color: isLegacy - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.primary, - ), - AppText.titleLarge( - value, - color: isLegacy - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.onSurface, - ), - if (unit != null && unit.isNotEmpty) ...[ - AppGap.xs(), - AppText.bodySmall( - '${unit}ps', - color: isLegacy - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.onSurface, - ) - ] - ], - ); - } - - Widget _portWidget(BuildContext context, String? connection, String label, - bool isWan, bool hasLanPorts) { - final isMobile = context.isMobileLayout; - final portLabel = [ - AppIcon.font( - connection == null - ? AppFontIcons.circle - : AppFontIcons.checkCircleFilled, - color: connection == null - ? Theme.of(context).colorScheme.surfaceContainerHighest - : Colors.green, - ), - if (hasLanPorts) ...[ - AppGap.sm(), - AppText.labelMedium(label), - ], - ]; - - return hasLanPorts - ? Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Wrap( - // mainAxisSize: MainAxisSize.min, - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - isMobile - ? Column( - children: portLabel, - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: portLabel, - ) - ], - ), - Padding( - padding: const EdgeInsets.all(AppSpacing.sm), - child: connection == null - ? Assets.images.imgPortOff.svg( - width: 40, - height: 40, - semanticsLabel: 'port status image', - ) - : Assets.images.imgPortOn.svg( - width: 40, - height: 40, - semanticsLabel: 'port status image', - ), - ), - if (connection != null) - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font( - AppFontIcons.bidirectional, - color: Theme.of(context).colorScheme.primary, - ), - AppText.bodySmall(connection), - ], - ), - AppText.bodySmall( - loc(context).connectedSpeed, - textAlign: TextAlign.center, - ), - ], - ), - if (isWan) AppText.labelMedium(loc(context).internet), - ], - ) - : Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Wrap( - // mainAxisSize: MainAxisSize.min, - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - isMobile - ? Column( - children: portLabel, - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: portLabel, - ) - ], - ), - Padding( - padding: const EdgeInsets.all(AppSpacing.sm), - child: connection == null - ? Assets.images.imgPortOff.svg( - width: 40, - height: 40, - semanticsLabel: 'port status image', - ) - : Assets.images.imgPortOn.svg( - width: 40, - height: 40, - semanticsLabel: 'port status image', - ), - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (connection != null) - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font( - AppFontIcons.bidirectional, - color: Theme.of(context).colorScheme.primary, - ), - AppText.bodyMedium(connection), - ], - ), - AppText.bodySmall( - loc(context).connectedSpeed, - textAlign: TextAlign.center, - ) - ], - ), - if (isWan) AppText.labelMedium(loc(context).internet), - ], - ), - ], - ); - } -} diff --git a/lib/page/dashboard/views/components/quick_panel.dart b/lib/page/dashboard/views/components/quick_panel.dart deleted file mode 100644 index 86777f13c..000000000 --- a/lib/page/dashboard/views/components/quick_panel.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; -import 'package:privacy_gui/core/jnap/models/node_light_settings.dart'; -import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.dart'; -import 'package:privacy_gui/core/data/providers/polling_provider.dart'; -import 'package:privacy_gui/core/utils/nodes.dart'; -import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; -import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_device_list_provider.dart'; -import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_provider.dart'; -import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; -import 'package:privacy_gui/page/instant_topology/providers/instant_topology_provider.dart'; -import 'package:privacy_gui/page/nodes/providers/node_detail_state.dart'; -import 'package:privacy_gui/route/constants.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; -import 'package:ui_kit_library/ui_kit.dart'; - -class DashboardQuickPanel extends ConsumerStatefulWidget { - const DashboardQuickPanel({super.key}); - - @override - ConsumerState createState() => - _DashboardQuickPanelState(); -} - -class _DashboardQuickPanelState extends ConsumerState { - @override - Widget build(BuildContext context) { - final privacyState = ref.watch(instantPrivacyProvider); - final nodeLightState = ref.watch(nodeLightSettingsProvider); - - final isLoading = - (ref.watch(pollingProvider).value?.isReady ?? false) == false; - final master = isLoading - ? null - : ref.watch(instantTopologyProvider).root.children.first; - bool isSupportNodeLight = serviceHelper.isSupportLedMode(); - bool isCognitive = isCognitiveMeshRouter( - modelNumber: master?.data.model ?? '', - hardwareVersion: master?.data.hardwareVersion ?? '1'); - - return isLoading - ? AppCard( - padding: EdgeInsets.zero, - child: SizedBox( - width: double.infinity, - height: 150, - child: const LoadingTile())) - : AppCard( - padding: EdgeInsets.all(AppSpacing.xxl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - toggleTileWidget( - title: loc(context).instantPrivacy, - value: privacyState.status.mode == MacFilterMode.allow, - leading: AppBadge( - label: 'BETA', - color: Theme.of(context) - .extension()! - .semanticWarning, - ), - onTap: () { - context.pushNamed(RouteNamed.menuInstantPrivacy); - }, - onChanged: (value) { - showInstantPrivacyConfirmDialog(context, value) - .then((isOk) { - if (isOk != true) { - return; - } - final notifier = - ref.read(instantPrivacyProvider.notifier); - if (value) { - final macAddressList = ref - .read(instantPrivacyDeviceListProvider) - .map((e) => e.macAddress.toUpperCase()) - .toList(); - notifier.setMacAddressList(macAddressList); - } - notifier.setEnable(value); - if (context.mounted) { - doSomethingWithSpinner(context, notifier.save()); - } - }); - }, - tips: loc(context).instantPrivacyInfo, - semantics: 'quick instant privacy switch'), - if (isCognitive && isSupportNodeLight) ...[ - const Divider( - height: 48, - thickness: 1.0, - ), - toggleTileWidget( - title: loc(context).nightMode, - value: nodeLightState.isNightModeEnable, - subTitle: ref - .read(nodeLightSettingsProvider.notifier) - .currentStatus == - NodeLightStatus.night - ? loc(context).nightModeTime - : ref - .read(nodeLightSettingsProvider.notifier) - .currentStatus == - NodeLightStatus.off - ? loc(context).allDayOff - : null, - onChanged: (value) { - final notifier = - ref.read(nodeLightSettingsProvider.notifier); - if (value) { - notifier.setSettings(NodeLightSettings.night()); - } else { - notifier.setSettings(NodeLightSettings.on()); - } - doSomethingWithSpinner(context, notifier.save()); - }, - tips: loc(context).nightModeTips, - semantics: 'quick night mode switch'), - ] - ], - ), - ); - } - - Widget toggleTileWidget({ - required String title, - Widget? leading, - String? subTitle, - VoidCallback? onTap, - required bool value, - required void Function(bool value)? onChanged, - String? tips, - String? semantics, - }) { - return SizedBox( - height: 60, - child: InkWell( - focusColor: Colors.transparent, - splashColor: Theme.of(context).colorScheme.primary, - onTap: onTap, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Wrap( - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - AppText.labelLarge(title), - if (subTitle != null) AppText.bodySmall(subTitle), - ], - ), - if (leading != null) ...[ - SizedBox( - width: AppSpacing.xs, - ), - leading, - ], - SizedBox( - width: AppSpacing.sm, - ), - if (tips != null) - Tooltip( - message: tips, - child: Icon( - Icons.info_outline, - semanticLabel: '{$semantics} icon', - color: Theme.of(context).colorScheme.primary, - ), - ) - ], - ), - ), - AppSwitch( - key: ValueKey(semantics), - value: value, - onChanged: onChanged, - ), - ], - ), - ), - ); - } -} diff --git a/lib/page/dashboard/views/components/settings/dashboard_layout_settings_panel.dart b/lib/page/dashboard/views/components/settings/dashboard_layout_settings_panel.dart new file mode 100644 index 000000000..c9c1bb1e6 --- /dev/null +++ b/lib/page/dashboard/views/components/settings/dashboard_layout_settings_panel.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/dashboard/models/dashboard_widget_specs.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/models/grid_widget_config.dart'; +import 'package:privacy_gui/page/dashboard/models/widget_spec.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_preferences_provider.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Settings panel for customizing dashboard layout. +/// +/// Allows users to: +/// - Reorder widgets via drag-and-drop +/// - Toggle widget visibility +/// - Adjust column span (1-12) +/// - Select display mode (compact/normal/expanded) +class DashboardLayoutSettingsPanel extends ConsumerWidget { + const DashboardLayoutSettingsPanel({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final preferences = ref.watch(dashboardPreferencesProvider); + final orderedWidgets = preferences.allWidgetsOrdered; + + return SingleChildScrollView( + child: AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + AppText.titleMedium('Dashboard Layout'), + AppGap.md(), + AppText.bodySmall( + 'Customize your dashboard layout. Enable custom layout to unlock advanced controls.', + ), + AppGap.xl(), + + // Custom Layout Toggle + AppCard( + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + title: AppText.labelLarge('Enable Custom Layout'), + subtitle: AppText.bodySmall( + 'Unlock full control over widget order, width, and display modes. Defaults to unified flexible grid.', + ), + value: preferences.useCustomLayout, + onChanged: (value) { + ref + .read(dashboardPreferencesProvider.notifier) + .toggleCustomLayout(value); + }, + ), + ), + AppGap.lg(), + + // Legacy Mode Warning + if (!preferences.useCustomLayout) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xl), + child: AppCard( + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.blue), + AppGap.md(), + Expanded( + child: AppText.bodySmall( + 'You are currently using the optimized standard layout. Enable Custom Layout above to customize.', + ), + ), + ], + ), + ), + ), + + // Controls - Disabled opacity if not custom + Opacity( + opacity: preferences.useCustomLayout ? 1.0 : 0.5, + child: IgnorePointer( + ignoring: !preferences.useCustomLayout, + child: Column( + children: [ + ...orderedWidgets.asMap().entries.map((entry) { + final index = entry.key; + final config = entry.value; + final spec = + DashboardWidgetSpecs.getById(config.widgetId); + if (spec == null) { + return const SizedBox.shrink(); + } + return _WidgetConfigTile( + key: ValueKey(config.widgetId), + index: index, + totalCount: orderedWidgets.length, + spec: spec, + config: config, + ); + }), + ], + ), + ), + ), + + AppGap.xl(), + + // Allow Reset even in legacy mode? Or only custom? + // Reset clears custom prefs, so it's fine. + if (preferences.useCustomLayout) + Align( + alignment: Alignment.centerRight, + child: AppButton.text( + label: 'Reset to Defaults', + onTap: () { + ref + .read(dashboardPreferencesProvider.notifier) + .resetToDefaults(); + }, + ), + ), + ], + ), + ), + ); + } +} + +/// Individual widget configuration tile with all controls. +class _WidgetConfigTile extends ConsumerWidget { + const _WidgetConfigTile({ + super.key, + required this.index, + required this.totalCount, + required this.spec, + required this.config, + }); + + final int index; + final int totalCount; + final WidgetSpec spec; + final GridWidgetConfig config; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // 1. Calculate constraints + final constraints = spec.getConstraints(config.displayMode); + final minColumns = constraints.minColumns; + final defaultColumns = constraints.preferredColumns; + + // 2. Ensure current value respects min constraint (clamp) + // If user saved a value lower than min (e.g. from a different mode), we clamp it for display/logic + final effectiveColumnSpan = config.columnSpan ?? defaultColumns; + final currentColumns = effectiveColumnSpan.clamp(minColumns, 12); + + // 3. Calculate divisions for slider (steps between min and max) + final divisions = 12 - minColumns; + + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: Move buttons + Name + Visibility + Row( + children: [ + // Up button + IconButton( + icon: const Icon(Icons.arrow_upward, size: 18), + onPressed: index > 0 + ? () { + ref + .read(dashboardPreferencesProvider.notifier) + .reorder(index, index - 1); + } + : null, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + ), + // Down button + IconButton( + icon: const Icon(Icons.arrow_downward, size: 18), + onPressed: index < totalCount - 1 + ? () { + ref + .read(dashboardPreferencesProvider.notifier) + .reorder(index, index + 1); + } + : null, + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + ), + AppGap.sm(), + Expanded( + child: AppText.labelLarge(spec.displayName), + ), + AppSwitch( + value: config.visible, + onChanged: (visible) { + ref + .read(dashboardPreferencesProvider.notifier) + .setVisibility(spec.id, visible); + }, + ), + ], + ), + // Controls - only show if visible + if (config.visible) ...[ + AppGap.lg(), + // Display Mode + Row( + children: [ + SizedBox( + width: 60, + child: AppText.bodySmall('Mode:'), + ), + Expanded( + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: DisplayMode.compact, + label: Text('Compact'), + ), + ButtonSegment( + value: DisplayMode.normal, + label: Text('Normal'), + ), + ButtonSegment( + value: DisplayMode.expanded, + label: Text('Expanded'), + ), + ], + selected: {config.displayMode}, + onSelectionChanged: (selection) { + ref + .read(dashboardPreferencesProvider.notifier) + .setWidgetMode(spec.id, selection.first); + }, + showSelectedIcon: false, + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + ), + ), + ), + ], + ), + AppGap.md(), + // Column Width Slider + Row( + children: [ + SizedBox( + width: 60, + child: AppText.bodySmall('Width:'), + ), + Expanded( + child: Slider( + value: currentColumns.toDouble(), + min: minColumns.toDouble(), + max: 12, + divisions: divisions > 0 ? divisions : 1, + label: '$currentColumns', + onChanged: (value) { + final newValue = value.round(); + // Only set if different from default + // Note: If newValue == default, we set to null to track "auto". + final columnSpan = + newValue == defaultColumns ? null : newValue; + ref + .read(dashboardPreferencesProvider.notifier) + .setColumnSpan(spec.id, columnSpan); + }, + ), + ), + SizedBox( + width: 40, + child: AppText.labelMedium( + '$currentColumns/12', + textAlign: TextAlign.end, + ), + ), + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/shimmer.dart b/lib/page/dashboard/views/components/shimmer.dart deleted file mode 100644 index 9260ffcf6..000000000 --- a/lib/page/dashboard/views/components/shimmer.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class ShimmerContainer extends ConsumerWidget { - final Widget child; - final bool isLoading; - final Widget? loadingWidget; - - const ShimmerContainer({ - super.key, - required this.child, - this.isLoading = false, - this.loadingWidget, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ShimmerLoading( - isLoading: isLoading, - child: isLoading ? loadingWidget ?? child : child); - } -} - -class ShimmerLoading extends StatefulWidget { - const ShimmerLoading({ - super.key, - required this.isLoading, - required this.child, - }); - - final bool isLoading; - final Widget child; - - @override - State createState() => _ShimmerLoadingState(); -} - -class _ShimmerLoadingState extends State { - Listenable? _shimmerChanges; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - if (_shimmerChanges != null) { - _shimmerChanges!.removeListener(_onShimmerChange); - } - _shimmerChanges = Shimmer.of(context)?.shimmerChanges; - if (_shimmerChanges != null) { - _shimmerChanges!.addListener(_onShimmerChange); - } - } - - @override - void dispose() { - _shimmerChanges?.removeListener(_onShimmerChange); - super.dispose(); - } - - void _onShimmerChange() { - if (widget.isLoading) { - setState(() { - // Update the shimmer painting. - }); - } else { - // Stop the shimmer animation. - (_shimmerChanges as AnimationController?)?.stop(); - } - } - - @override - Widget build(BuildContext context) { - if (!widget.isLoading) { - return widget.child; - } - - // Collect ancestor shimmer information. - final shimmer = Shimmer.of(context)!; - if (!shimmer.isSized) { - // The ancestor Shimmer widget isn't laid - // out yet. Return an empty box. - return const SizedBox(); - } - final shimmerSize = shimmer.size; - final gradient = shimmer.gradient; - final offsetWithinShimmer = shimmer.getDescendantOffset( - descendant: context.findRenderObject() as RenderBox, - ); - - return ShaderMask( - blendMode: BlendMode.srcATop, - shaderCallback: (bounds) { - return gradient.createShader( - Rect.fromLTWH( - -offsetWithinShimmer.dx, - -offsetWithinShimmer.dy, - shimmerSize.width, - shimmerSize.height, - ), - ); - }, - child: widget.child, - ); - } -} - -get shimmerGradient => LinearGradient( - colors: [ - Colors.grey, - Colors.grey[300]!, - Colors.grey, - ], - stops: const [ - 0.1, - 0.3, - 0.4, - ], - begin: const Alignment(-1.0, -0.3), - end: const Alignment(1.0, 0.3), - tileMode: TileMode.clamp, - ); - -class Shimmer extends StatefulWidget { - static ShimmerState? of(BuildContext context) { - return context.findAncestorStateOfType(); - } - - const Shimmer({ - super.key, - required this.gradient, - this.child, - }); - - final LinearGradient gradient; - final Widget? child; - - @override - ShimmerState createState() => ShimmerState(); -} - -class ShimmerState extends State with SingleTickerProviderStateMixin { - late AnimationController _shimmerController; - - @override - void initState() { - super.initState(); - - _shimmerController = AnimationController.unbounded(vsync: this) - ..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1000)); - } - - @override - void dispose() { - _shimmerController.dispose(); - super.dispose(); - } - - LinearGradient get gradient => LinearGradient( - colors: widget.gradient.colors, - stops: widget.gradient.stops, - begin: widget.gradient.begin, - end: widget.gradient.end, - transform: - _SlidingGradientTransform(slidePercent: _shimmerController.value), - ); - Listenable get shimmerChanges => _shimmerController; - - bool get isSized => - (context.findRenderObject() as RenderBox?)?.hasSize ?? false; - - Size get size => (context.findRenderObject() as RenderBox).size; - - Offset getDescendantOffset({ - required RenderBox descendant, - Offset offset = Offset.zero, - }) { - final shimmerBox = context.findRenderObject() as RenderBox; - return descendant.localToGlobal(offset, ancestor: shimmerBox); - } - - @override - Widget build(BuildContext context) { - return widget.child ?? const SizedBox(); - } -} - -class _SlidingGradientTransform extends GradientTransform { - const _SlidingGradientTransform({ - required this.slidePercent, - }); - - final double slidePercent; - - @override - Matrix4? transform(Rect bounds, {TextDirection? textDirection}) { - return Matrix4.translationValues(bounds.width * slidePercent, 0.0, 0.0); - } -} diff --git a/lib/page/dashboard/views/components/home_title.dart b/lib/page/dashboard/views/components/widgets/home_title.dart similarity index 50% rename from lib/page/dashboard/views/components/home_title.dart rename to lib/page/dashboard/views/components/widgets/home_title.dart index 1eb85edcd..409978584 100644 --- a/lib/page/dashboard/views/components/home_title.dart +++ b/lib/page/dashboard/views/components/widgets/home_title.dart @@ -3,10 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:privacy_gui/core/data/providers/dashboard_manager_provider.dart'; import 'package:privacy_gui/core/data/providers/node_internet_status_provider.dart'; -import 'package:privacy_gui/core/data/providers/polling_provider.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/dashboard_loading_wrapper.dart'; import 'package:privacy_gui/page/instant_admin/_instant_admin.dart'; import 'package:privacy_gui/page/instant_setup/troubleshooter/providers/pnp_troubleshooter_provider.dart'; import 'package:privacy_gui/route/constants.dart'; @@ -19,70 +18,65 @@ class DashboardHomeTitle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + return DashboardLoadingWrapper( + loadingHeight: 150, + builder: (context, ref) => _buildContent(context, ref), + ); + } + + Widget _buildContent(BuildContext context, WidgetRef ref) { final wanStatus = ref.watch(internetStatusProvider); final state = ref.watch(dashboardManagerProvider); final isOnline = wanStatus == InternetStatus.online; - final isLoading = - (ref.watch(pollingProvider).value?.isReady ?? false) == false; final localTime = DateTime.fromMillisecondsSinceEpoch(state.localTime); - return isLoading - ? AppCard( - padding: EdgeInsets.zero, - child: SizedBox( - width: double.infinity, - height: 150, - child: const LoadingTile())) - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + helloString(context, localTime), + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + InkWell( + onTap: () { + doSomethingWithSpinner( + context, ref.read(timezoneProvider.notifier).fetch()) + .then((_) { + if (!context.mounted) return; + context.pushNamed(RouteNamed.settingsTimeZone); + }); + }, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Expanded( + AppIcon.font(AppFontIcons.calendar, + color: Theme.of(context).colorScheme.onSurface), + Padding( + padding: EdgeInsets.only(left: AppSpacing.sm), child: Text( - helloString(context, localTime), - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - InkWell( - onTap: () { - doSomethingWithSpinner(context, - ref.read(timezoneProvider.notifier).fetch()) - .then((_) { - if (!context.mounted) return; - context.pushNamed(RouteNamed.settingsTimeZone); - }); - }, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - AppIcon.font(AppFontIcons.calendar, - color: Theme.of(context).colorScheme.onSurface), - Padding( - padding: EdgeInsets.only(left: AppSpacing.sm), - child: Text( - loc(context).formalDateTime(localTime, localTime), - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: - Theme.of(context).colorScheme.onSurface, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + loc(context).formalDateTime(localTime, localTime), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface, ), - ), - ], + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ], ), - if (!isLoading && !isOnline) _troubleshooting(context, ref), - ], - ); + ), + ], + ), + if (!isOnline) _troubleshooting(context, ref), + ], + ); } Widget _troubleshooting(BuildContext context, WidgetRef ref) { diff --git a/lib/page/dashboard/views/components/widgets/internet_status.dart b/lib/page/dashboard/views/components/widgets/internet_status.dart new file mode 100644 index 000000000..a947053eb --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/internet_status.dart @@ -0,0 +1,476 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/core/cloud/providers/geolocation/geolocation_provider.dart'; +import 'package:privacy_gui/core/cloud/providers/geolocation/geolocation_state.dart'; +import 'package:privacy_gui/core/data/providers/node_internet_status_provider.dart'; +import 'package:privacy_gui/core/data/providers/polling_provider.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/components/customs/animated_refresh_container.dart'; +import 'package:privacy_gui/page/components/shared_widgets.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/dashboard_loading_wrapper.dart'; +import 'package:privacy_gui/page/instant_topology/providers/_providers.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:privacy_gui/utils.dart'; + +import 'package:ui_kit_library/ui_kit.dart'; + +/// Widget displaying internet connection status. +/// +/// Supports three display modes: +/// - [DisplayMode.compact]: Only status indicator and IP +/// - [DisplayMode.normal]: Full display with router info +/// - [DisplayMode.expanded]: Extra details like uptime +class InternetConnectionWidget extends ConsumerStatefulWidget { + const InternetConnectionWidget({ + super.key, + this.displayMode = DisplayMode.normal, + }); + + /// The display mode for this widget + final DisplayMode displayMode; + + @override + ConsumerState createState() => + _InternetConnectionWidgetState(); +} + +class _InternetConnectionWidgetState + extends ConsumerState { + @override + Widget build(BuildContext context) { + return DashboardLoadingWrapper( + loadingHeight: _getLoadingHeight(), + builder: (context, ref) => _buildContent(context, ref), + ); + } + + double _getLoadingHeight() { + return switch (widget.displayMode) { + DisplayMode.compact => 80, + DisplayMode.normal => 150, + DisplayMode.expanded => 200, + }; + } + + Widget _buildContent(BuildContext context, WidgetRef ref) { + return switch (widget.displayMode) { + DisplayMode.compact => _buildCompactView(context, ref), + DisplayMode.normal => _buildNormalView(context, ref), + DisplayMode.expanded => _buildExpandedView(context, ref), + }; + } + + /// Compact view: Single row with status indicator + Online/Offline + location + Widget _buildCompactView(BuildContext context, WidgetRef ref) { + final wanStatus = ref.watch(internetStatusProvider); + final isOnline = wanStatus == InternetStatus.online; + final geolocationState = ref.watch(geolocationProvider); + + return AppCard( + child: Row( + children: [ + // Status indicator + Icon( + Icons.circle, + color: isOnline + ? Theme.of(context).extension()!.semanticSuccess + : Theme.of(context).colorScheme.surfaceContainerHighest, + size: 12.0, + ), + AppGap.sm(), + // Status text + AppText.labelLarge( + isOnline + ? loc(context).internetOnline + : loc(context).internetOffline, + ), + const Spacer(), + // Location info + if (geolocationState.value?.name.isNotEmpty == true) + AppText.bodySmall( + geolocationState.value?.displayLocationText ?? '', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + // Refresh button (non-mobile only) + if (!Utils.isMobilePlatform()) ...[ + AppGap.md(), + AnimatedRefreshContainer( + builder: (controller) => AppIconButton( + icon: AppIcon.font(AppFontIcons.refresh, size: 16), + onTap: () { + controller.repeat(); + ref.read(pollingProvider.notifier).forcePolling().then((_) { + controller.stop(); + }); + }, + ), + ), + ], + ], + ), + ); + } + + /// Normal view: Full display with router info (current implementation) + Widget _buildNormalView(BuildContext context, WidgetRef ref) { + final wanStatus = ref.watch(internetStatusProvider); + final isOnline = wanStatus == InternetStatus.online; + final geolocationState = ref.watch(geolocationProvider); + final master = ref.watch(instantTopologyProvider).root.children.first; + final masterIcon = ref.watch(dashboardHomeProvider).masterIcon; + final wanPortConnection = + ref.watch(dashboardHomeProvider).wanPortConnection; + final isMasterOffline = + master.data.isOnline == false || wanPortConnection == 'None'; + + return AppCard( + padding: EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.circle, + color: isOnline + ? Theme.of(context) + .extension()! + .semanticSuccess + : Theme.of(context).colorScheme.surfaceContainerHighest, + size: 16.0, + ), + AppGap.sm(), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.titleSmall( + isOnline + ? loc(context).internetOnline + : loc(context).internetOffline, + ), + if (geolocationState.value?.name.isNotEmpty == true) ...[ + AppGap.sm(), + SharedWidgets.geolocationWidget( + context, + geolocationState.value?.name ?? '', + geolocationState.value?.displayLocationText ?? ''), + ], + ], + ), + ), + if (!Utils.isMobilePlatform()) + AnimatedRefreshContainer( + builder: (controller) { + return Padding( + padding: const EdgeInsets.all(0.0), + child: AppIconButton( + icon: AppIcon.font( + AppFontIcons.refresh, + ), + onTap: () { + controller.repeat(); + ref + .read(pollingProvider.notifier) + .forcePolling() + .then((value) { + controller.stop(); + }); + }, + ), + ); + }, + ), + ], + ), + ), + Container( + key: const ValueKey('master'), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + border: Border.all(color: Colors.transparent), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: Row( + children: [ + SizedBox( + width: context.isMobileLayout ? 120 : 90, + child: SharedWidgets.resolveRouterImage(context, masterIcon, + size: 112), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + top: AppSpacing.lg, + bottom: AppSpacing.lg, + left: AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + AppText.titleMedium(master.data.location), + AppGap.lg(), + Table( + border: const TableBorder(), + columnWidths: const { + 0: FlexColumnWidth(1), + 1: FlexColumnWidth(2), + }, + children: [ + TableRow(children: [ + AppText.labelLarge('${loc(context).connection}:'), + AppText.bodyMedium(isMasterOffline + ? '--' + : (master.data.isWiredConnection == true) + ? loc(context).wired + : loc(context).wireless), + ]), + TableRow(children: [ + AppText.labelLarge('${loc(context).model}:'), + AppText.bodyMedium(master.data.model), + ]), + TableRow(children: [ + AppText.labelLarge('${loc(context).serialNo}:'), + AppText.bodyMedium(master.data.serialNumber), + ]), + TableRow(children: [ + AppText.labelLarge('${loc(context).fwVersion}:'), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + AppText.bodyMedium(master.data.fwVersion), + if (!isMasterOffline) ...[ + AppGap.lg(), + SharedWidgets.nodeFirmwareStatusWidget( + context, + master.data.fwUpToDate == false, + () { + context.pushNamed( + RouteNamed.firmwareUpdateDetail); + }, + ), + ] + ], + ), + ]), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// Expanded view: Normal view + uptime info + Widget _buildExpandedView(BuildContext context, WidgetRef ref) { + final wanStatus = ref.watch(internetStatusProvider); + final isOnline = wanStatus == InternetStatus.online; + final geolocationState = ref.watch(geolocationProvider); + final master = ref.watch(instantTopologyProvider).root.children.first; + final masterIcon = ref.watch(dashboardHomeProvider).masterIcon; + final wanPortConnection = + ref.watch(dashboardHomeProvider).wanPortConnection; + final uptime = ref.watch(dashboardHomeProvider).uptime; + final isMasterOffline = + master.data.isOnline == false || wanPortConnection == 'None'; + + return AppCard( + padding: EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.circle, + color: isOnline + ? Theme.of(context) + .extension()! + .semanticSuccess + : Theme.of(context).colorScheme.surfaceContainerHighest, + size: 16.0, + ), + AppGap.sm(), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.titleSmall( + isOnline + ? loc(context).internetOnline + : loc(context).internetOffline, + ), + if (geolocationState.value?.name.isNotEmpty == true) ...[ + AppGap.sm(), + SharedWidgets.geolocationWidget( + context, + geolocationState.value?.name ?? '', + geolocationState.value?.displayLocationText ?? ''), + ], + // Uptime info (expanded only) + if (uptime != null && isOnline) ...[ + AppGap.md(), + Row( + children: [ + AppIcon.font(Icons.access_time, size: 14), + AppGap.xs(), + AppText.bodySmall( + _formatUptime(uptime), + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ], + ), + ], + ], + ), + ), + if (!Utils.isMobilePlatform()) + AnimatedRefreshContainer( + builder: (controller) { + return Padding( + padding: const EdgeInsets.all(0.0), + child: AppIconButton( + icon: AppIcon.font(AppFontIcons.refresh), + onTap: () { + controller.repeat(); + ref + .read(pollingProvider.notifier) + .forcePolling() + .then((_) { + controller.stop(); + }); + }, + ), + ); + }, + ), + ], + ), + ), + Container( + key: const ValueKey('master_expanded'), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + border: Border.all(color: Colors.transparent), + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: Row( + children: [ + SizedBox( + width: context.isMobileLayout ? 120 : 90, + child: SharedWidgets.resolveRouterImage(context, masterIcon, + size: 112), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + top: AppSpacing.lg, + bottom: AppSpacing.lg, + left: AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + AppText.titleMedium(master.data.location), + AppGap.lg(), + Table( + border: const TableBorder(), + columnWidths: const { + 0: FlexColumnWidth(1), + 1: FlexColumnWidth(2), + }, + children: [ + TableRow(children: [ + AppText.labelLarge('${loc(context).connection}:'), + AppText.bodyMedium(isMasterOffline + ? '--' + : (master.data.isWiredConnection == true) + ? loc(context).wired + : loc(context).wireless), + ]), + TableRow(children: [ + AppText.labelLarge('${loc(context).model}:'), + AppText.bodyMedium(master.data.model), + ]), + TableRow(children: [ + AppText.labelLarge('${loc(context).serialNo}:'), + AppText.bodyMedium(master.data.serialNumber), + ]), + TableRow(children: [ + AppText.labelLarge('${loc(context).fwVersion}:'), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + AppText.bodyMedium(master.data.fwVersion), + if (!isMasterOffline) ...[ + AppGap.lg(), + SharedWidgets.nodeFirmwareStatusWidget( + context, + master.data.fwUpToDate == false, + () { + context.pushNamed( + RouteNamed.firmwareUpdateDetail); + }, + ), + ] + ], + ), + ]), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// Format uptime in human-readable format + String _formatUptime(int seconds) { + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + + if (days > 0) { + return '${loc(context).uptime}: ${days}d ${hours}h'; + } else if (hours > 0) { + return '${loc(context).uptime}: ${hours}h ${minutes}m'; + } else { + return '${loc(context).uptime}: ${minutes}m'; + } + } +} diff --git a/lib/page/dashboard/views/components/widgets/networks.dart b/lib/page/dashboard/views/components/widgets/networks.dart new file mode 100644 index 000000000..624267697 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/networks.dart @@ -0,0 +1,400 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; +import 'package:privacy_gui/core/data/providers/firmware_update_provider.dart'; +import 'package:privacy_gui/core/data/providers/node_internet_status_provider.dart'; +import 'package:privacy_gui/core/utils/devices.dart'; +import 'package:privacy_gui/core/utils/topology_adapter.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/dashboard/_dashboard.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/dashboard_loading_wrapper.dart'; +import 'package:privacy_gui/page/instant_topology/providers/_providers.dart'; +import 'package:privacy_gui/page/instant_topology/views/model/topology_model.dart'; +import 'package:privacy_gui/page/nodes/providers/node_detail_id_provider.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Widget displaying network topology and nodes. +/// +/// Supports three display modes: +/// - [DisplayMode.compact]: Minimal view with node/device counts only +/// - [DisplayMode.normal]: Standard view with topology tree +/// - [DisplayMode.expanded]: Full view with detailed topology +class DashboardNetworks extends ConsumerWidget { + const DashboardNetworks({ + super.key, + this.displayMode = DisplayMode.normal, + }); + + /// The display mode for this widget + final DisplayMode displayMode; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DashboardLoadingWrapper( + loadingHeight: _getLoadingHeight(), + builder: (context, ref) => _buildContent(context, ref), + ); + } + + double _getLoadingHeight() { + return switch (displayMode) { + DisplayMode.compact => 120, + DisplayMode.normal => 250, + DisplayMode.expanded => 350, + }; + } + + Widget _buildContent(BuildContext context, WidgetRef ref) { + return switch (displayMode) { + DisplayMode.compact => _buildCompactView(context, ref), + DisplayMode.normal => _buildNormalView(context, ref), + DisplayMode.expanded => _buildExpandedView(context, ref), + }; + } + + /// Compact view: Nodes and devices count displayed side by side + Widget _buildCompactView(BuildContext context, WidgetRef ref) { + final topologyState = ref.watch(instantTopologyProvider); + final nodes = topologyState.root.children.firstOrNull?.toFlatList() ?? []; + final hasOffline = nodes.any((element) => !element.data.isOnline); + final externalDeviceCount = ref + .watch(deviceManagerProvider) + .externalDevices + .where((e) => e.isOnline()) + .length; + + return AppCard( + child: InkWell( + onTap: () => context.pushNamed(RouteNamed.menuInstantTopology), + child: Row( + children: [ + // Nodes section + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + hasOffline + ? AppIcon.font(AppFontIcons.infoCircle, + color: Theme.of(context).colorScheme.error, size: 18) + : AppIcon.font(AppFontIcons.networkNode, size: 18), + AppGap.sm(), + AppText.titleMedium('${nodes.length}'), + AppGap.xs(), + AppText.bodySmall( + nodes.length == 1 ? loc(context).node : loc(context).nodes, + ), + ], + ), + ), + SizedBox(height: 36, child: VerticalDivider()), + // Devices section + Expanded( + child: InkWell( + onTap: () => context.pushNamed(RouteNamed.menuInstantDevices), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppIcon.font(AppFontIcons.devices, size: 18), + AppGap.sm(), + AppText.titleMedium('$externalDeviceCount'), + AppGap.xs(), + AppText.bodySmall( + externalDeviceCount == 1 + ? loc(context).device + : loc(context).devices, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + /// Normal view: Standard view with topology tree (existing implementation) + Widget _buildNormalView(BuildContext context, WidgetRef ref) { + final state = ref.watch(dashboardHomeProvider); + final topologyState = ref.watch(instantTopologyProvider); + + // Convert topology data to ui_kit format + final meshTopology = TopologyAdapter.convert(topologyState.root.children); + + // Calculate topology height + const topologyItemHeight = 72.0; + const treeViewBaseHeight = 72.0; + final routerLength = + topologyState.root.children.firstOrNull?.toFlatList().length ?? 1; + final double nodeTopologyHeight = context.isMobileLayout + ? routerLength * topologyItemHeight + : min(routerLength * topologyItemHeight, 3 * topologyItemHeight); + final showAllTopology = context.isMobileLayout || routerLength <= 3; + + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppGap.lg(), + _buildNetworkHeader(context, ref, state), + SizedBox( + height: nodeTopologyHeight + treeViewBaseHeight, + child: _buildTopologyView( + context, + ref, + meshTopology, + topologyState, + showAllTopology, + ), + ), + ], + ), + ); + } + + /// Expanded view: Full topology with larger tree and more details + Widget _buildExpandedView(BuildContext context, WidgetRef ref) { + final state = ref.watch(dashboardHomeProvider); + final topologyState = ref.watch(instantTopologyProvider); + + // Convert topology data to ui_kit format + final meshTopology = TopologyAdapter.convert(topologyState.root.children); + + // Calculate expanded topology height (show more nodes) + const topologyItemHeight = 80.0; + const treeViewBaseHeight = 80.0; + final routerLength = + topologyState.root.children.firstOrNull?.toFlatList().length ?? 1; + final double nodeTopologyHeight = routerLength * topologyItemHeight; + + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppGap.lg(), + _buildNetworkHeader(context, ref, state), + SizedBox( + height: nodeTopologyHeight + treeViewBaseHeight, + child: _buildTopologyView( + context, + ref, + meshTopology, + topologyState, + true, // Always show all in expanded mode + ), + ), + ], + ), + ); + } + + /// Unified network header with title, firmware status, and info tiles. + Widget _buildNetworkHeader( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + ) { + final topologyState = ref.watch(instantTopologyProvider); + final wanStatus = ref.watch(internetStatusProvider); + final newFirmware = _hasNewFirmware(ref); + final isOnline = wanStatus == InternetStatus.online; + final hasLanPort = + ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; + + // Determine layout variant + final layoutVariant = DashboardLayoutVariant.fromContext( + context, + hasLanPort: hasLanPort, + isHorizontalLayout: state.isHorizontalLayout, + ); + final useVerticalLayout = + layoutVariant == DashboardLayoutVariant.desktopVertical || + layoutVariant == DashboardLayoutVariant.tabletVertical; + + final titleSection = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.titleSmall(loc(context).myNetwork), + if (isOnline) _firmwareStatusWidget(context, newFirmware), + ], + ); + + final infoTilesSection = Row( + children: [ + Expanded(child: _nodesInfoTile(context, ref, topologyState)), + AppGap.gutter(), + Expanded(child: _devicesInfoTile(context, ref, topologyState)), + ], + ); + + // Desktop vertical layout: title on left, info tiles on right + if (useVerticalLayout) { + return Row( + children: [ + titleSection, + const Spacer(), + Expanded(flex: 3, child: infoTilesSection), + ], + ); + } + + // Mobile and desktop horizontal layout: title on top, info tiles below + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + titleSection, + AppGap.xxl(), + infoTilesSection, + ], + ); + } + + Widget _buildTopologyView( + BuildContext context, + WidgetRef ref, + MeshTopology meshTopology, + InstantTopologyState topologyState, + bool showAllTopology, + ) { + return AppTopology( + topology: meshTopology, + viewMode: TopologyViewMode.tree, + enableAnimation: !showAllTopology, + onNodeTap: TopologyAdapter.wrapNodeTapCallback( + topologyState.root.children, + (RouterTreeNode node) { + if (node.data.isOnline) { + ref.read(nodeDetailIdProvider.notifier).state = node.data.deviceId; + context.pushNamed(RouteNamed.nodeDetails); + } + }, + ), + indent: 16.0, + treeConfig: TopologyTreeConfiguration( + preferAnimationNode: false, + showType: false, + showStatusText: false, + showStatusIndicator: true, + titleBuilder: (meshNode) => meshNode.name, + subtitleBuilder: (meshNode) { + final model = meshNode.extra; + final deviceCount = + meshNode.metadata?['connectedDeviceCount'] as int? ?? 0; + final deviceLabel = + deviceCount <= 1 ? loc(context).device : loc(context).devices; + + if (model == null || model.isEmpty) { + return '$deviceCount $deviceLabel'; + } + return '$model • $deviceCount $deviceLabel'; + }, + ), + ); + } + + bool _hasNewFirmware(WidgetRef ref) { + final nodesStatus = + ref.watch(firmwareUpdateProvider.select((value) => value.nodesStatus)); + return nodesStatus?.any((element) => element.availableUpdate != null) ?? + false; + } + + Widget _firmwareStatusWidget(BuildContext context, bool newFirmware) { + return InkWell( + onTap: newFirmware + ? () => context.pushNamed(RouteNamed.firmwareUpdateDetail) + : null, + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + newFirmware + ? AppText.labelMedium( + loc(context).updateFirmware, + color: Theme.of(context).colorScheme.primary, + ) + : AppStyledText(text: loc(context).dashboardFirmwareUpdateToDate), + newFirmware + ? AppIcon.font( + AppFontIcons.cloudDownload, + color: Theme.of(context).colorScheme.primary, + ) + : AppIcon.font( + AppFontIcons.check, + color: Theme.of(context) + .extension() + ?.semanticSuccess ?? + Colors.green, + ), + ], + ), + ); + } + + Widget _nodesInfoTile( + BuildContext context, WidgetRef ref, InstantTopologyState state) { + final nodes = state.root.children.firstOrNull?.toFlatList() ?? []; + final hasOffline = nodes.any((element) => !element.data.isOnline); + return _infoTile( + icon: hasOffline + ? AppIcon.font(AppFontIcons.infoCircle, + color: Theme.of(context).colorScheme.error) + : AppIcon.font(AppFontIcons.networkNode), + text: nodes.length == 1 ? loc(context).node : loc(context).nodes, + count: nodes.length, + onTap: () { + ref.read(topologySelectedIdProvider.notifier).state = ''; + context.pushNamed(RouteNamed.menuInstantTopology); + }, + ); + } + + Widget _devicesInfoTile( + BuildContext context, WidgetRef ref, InstantTopologyState state) { + final externalDeviceCount = ref + .watch(deviceManagerProvider) + .externalDevices + .where((e) => e.isOnline()) + .length; + + return _infoTile( + text: + externalDeviceCount == 1 ? loc(context).device : loc(context).devices, + count: externalDeviceCount, + icon: AppIcon.font(AppFontIcons.devices), + onTap: () => context.pushNamed(RouteNamed.menuInstantDevices), + ); + } + + Widget _infoTile({ + required String text, + required int count, + required Widget icon, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible(child: AppText.titleSmall('$count')), + icon, + ], + ), + AppGap.lg(), + AppText.bodySmall(text), + ], + ), + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/widgets/parts/external_speed_test_links.dart b/lib/page/dashboard/views/components/widgets/parts/external_speed_test_links.dart new file mode 100644 index 000000000..6eccdcfbb --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/parts/external_speed_test_links.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +import 'package:privacy_gui/util/url_helper/url_helper.dart' + if (dart.library.io) 'package:privacy_gui/util/url_helper/url_helper_mobile.dart' + if (dart.library.html) 'package:privacy_gui/util/url_helper/url_helper_web.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Widget for external speed test links (Fast.com, Cloudflare). +class ExternalSpeedTestLinks extends StatelessWidget { + final DashboardHomeState state; + + const ExternalSpeedTestLinks({super.key, required this.state}); + + @override + Widget build(BuildContext context) { + final horizontalLayout = state.isHorizontalLayout; + final hasLanPort = state.lanPortConnections.isNotEmpty; + final isVerticalDesktop = + hasLanPort && !horizontalLayout && !context.isMobileLayout; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + border: Border.all(color: Colors.transparent), + borderRadius: BorderRadius.circular(12).copyWith( + topLeft: Radius.circular(0), + topRight: Radius.circular(0), + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xl, + vertical: AppSpacing.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(context, horizontalLayout, hasLanPort), + AppGap.xs(), + isVerticalDesktop + ? _buildVerticalButtons(context) + : _buildHorizontalButtons(context), + AppGap.xs(), + AppText.bodyExtraSmall(loc(context).speedTestExternalOthers), + ], + ), + ); + } + + Widget _buildHeader( + BuildContext context, bool horizontalLayout, bool hasLanPort) { + final speedTitle = AppText.titleMedium(loc(context).speedTextTileStart); + final infoIcon = InkWell( + child: AppIcon.font( + AppFontIcons.infoCircle, + color: Theme.of(context).colorScheme.primary, + ), + onTap: () => openUrl('https://support.linksys.com/kb/article/79-en/'), + ); + final speedDesc = + AppText.labelSmall(loc(context).speedTestExternalTileLabel); + + final showRowHeader = + context.isMobileLayout || (hasLanPort && horizontalLayout); + + if (showRowHeader) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: speedTitle), + infoIcon, + speedDesc, + ], + ); + } + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align(alignment: AlignmentDirectional.centerStart, child: speedTitle), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + infoIcon, + AppGap.sm(), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: speedDesc, + ), + ), + ], + ), + ], + ); + } + + Widget _buildVerticalButtons(BuildContext context) { + return SizedBox( + width: 144, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + spacing: AppSpacing.sm, + children: [ + AppButton( + label: loc(context).speedTestExternalTileCloudFlare, + onTap: () => openUrl('https://speed.cloudflare.com/'), + ), + AppButton( + label: loc(context).speedTestExternalTileFast, + onTap: () => openUrl('https://www.fast.com'), + ), + ], + ), + ); + } + + Widget _buildHorizontalButtons(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: AppSpacing.lg, + children: [ + Expanded( + child: AppButton( + label: loc(context).speedTestExternalTileCloudFlare, + onTap: () => openUrl('https://speed.cloudflare.com/'), + ), + ), + Expanded( + child: AppButton( + label: loc(context).speedTestExternalTileFast, + onTap: () => openUrl('https://www.fast.com'), + ), + ), + ], + ); + } +} diff --git a/lib/page/dashboard/views/components/widgets/parts/internal_speed_test_result.dart b/lib/page/dashboard/views/components/widgets/parts/internal_speed_test_result.dart new file mode 100644 index 000000000..4b3f2ebc5 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/parts/internal_speed_test_result.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Widget showing speed check results (internal speed test). +class InternalSpeedTestResult extends ConsumerWidget { + final DashboardHomeState state; + + const InternalSpeedTestResult({super.key, required this.state}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final speedTest = ref.watch(healthCheckProvider); + final horizontalLayout = state.isHorizontalLayout; + final hasLanPort = + ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; + + final dateTime = speedTest.latestSpeedTest?.timestampEpoch == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + speedTest.latestSpeedTest!.timestampEpoch!); + final isLegacy = + dateTime == null || DateTime.now().difference(dateTime).inDays > 1; + final dateTimeStr = + dateTime == null ? '' : loc(context).formalDateTime(dateTime, dateTime); + + final downloadResult = _SpeedResultWidget( + value: speedTest.latestSpeedTest?.downloadSpeed ?? '--', + unit: speedTest.latestSpeedTest?.downloadUnit, + isLegacy: isLegacy, + isDownload: true, + ); + + final uploadResult = _SpeedResultWidget( + value: speedTest.latestSpeedTest?.uploadSpeed ?? '--', + unit: speedTest.latestSpeedTest?.uploadUnit, + isLegacy: isLegacy, + isDownload: false, + ); + + final speedTestButton = SizedBox( + height: 40, + child: AppButton( + label: loc(context).speedTextTileStart, + onTap: () => context.pushNamed(RouteNamed.dashboardSpeedTest), + ), + ); + + return Container( + key: const ValueKey('speedCheck'), + color: Theme.of(context).colorScheme.surfaceContainerLow, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxxl), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + child: _buildSpeedLayout( + context, + dateTimeStr, + downloadResult, + uploadResult, + speedTestButton, + horizontalLayout, + hasLanPort, + ), + ), + ); + } + + Widget _buildSpeedLayout( + BuildContext context, + String dateTimeStr, + Widget downloadResult, + Widget uploadResult, + Widget speedTestButton, + bool horizontalLayout, + bool hasLanPort, + ) { + // No LAN ports layout + if (!hasLanPort) { + return Column( + children: [ + if (dateTimeStr.isNotEmpty) ...[ + AppText.bodySmall(dateTimeStr), + AppGap.sm(), + ], + Row( + spacing: AppSpacing.lg, + mainAxisAlignment: MainAxisAlignment.center, + children: [downloadResult, uploadResult], + ), + AppGap.lg(), + speedTestButton, + ], + ); + } + + // Mobile or horizontal layout + if (context.isMobileLayout || horizontalLayout) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (dateTimeStr.isNotEmpty) ...[ + AppText.bodySmall(dateTimeStr), + AppGap.sm(), + ], + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded(child: downloadResult), + Expanded(child: uploadResult), + ], + ), + ], + ), + ), + speedTestButton, + ], + ); + } + + // Vertical layout + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (dateTimeStr.isNotEmpty) ...[ + AppText.bodySmall(dateTimeStr), + AppGap.sm(), + ], + downloadResult, + AppGap.xxl(), + uploadResult, + AppGap.lg(), + speedTestButton, + ], + ); + } +} + +/// Widget showing a single speed result (download or upload). +class _SpeedResultWidget extends StatelessWidget { + final String value; + final String? unit; + final bool isLegacy; + final bool isDownload; + + const _SpeedResultWidget({ + required this.value, + required this.unit, + required this.isLegacy, + required this.isDownload, + }); + + @override + Widget build(BuildContext context) { + final color = isLegacy + ? Theme.of(context).colorScheme.outline + : Theme.of(context).colorScheme.primary; + final textColor = isLegacy + ? Theme.of(context).colorScheme.outline + : Theme.of(context).colorScheme.onSurface; + + return Opacity( + opacity: isLegacy ? 0.6 : 1, + child: Wrap( + alignment: WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.end, + children: [ + AppIcon.font( + isDownload ? AppFontIcons.arrowDownward : AppFontIcons.arrowUpward, + color: color, + ), + AppText.titleLarge(value, color: textColor), + if (unit != null && unit!.isNotEmpty) ...[ + AppGap.xs(), + AppText.bodySmall('${unit}ps', color: textColor), + ], + ], + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/widgets/parts/port_status_widget.dart b/lib/page/dashboard/views/components/widgets/parts/port_status_widget.dart new file mode 100644 index 000000000..6f4f5afb0 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/parts/port_status_widget.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// A widget that displays a network port status with connection speed. +class PortStatusWidget extends StatelessWidget { + const PortStatusWidget({ + super.key, + required this.connection, + required this.label, + required this.isWan, + required this.hasLanPorts, + }); + + /// The connection speed string (e.g., "1Gbps"), or null if disconnected. + final String? connection; + + /// The label for this port (e.g., "LAN 1", "WAN"). + final String label; + + /// Whether this is a WAN port. + final bool isWan; + + /// Whether the device has LAN ports (affects layout). + final bool hasLanPorts; + + @override + Widget build(BuildContext context) { + final isMobile = context.isMobileLayout; + + // Build port label with status icon + final portLabelWidgets = [ + AppIcon.font( + connection == null + ? AppFontIcons.circle + : AppFontIcons.checkCircleFilled, + color: connection == null + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Colors.green, + ), + if (hasLanPorts) ...[ + AppGap.sm(), + AppText.labelMedium(label), + ], + ]; + + // Port image + final portImage = Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: connection == null + ? Assets.images.imgPortOff.svg( + width: 40, + height: 40, + semanticsLabel: 'port status image', + ) + : Assets.images.imgPortOn.svg( + width: 40, + height: 40, + semanticsLabel: 'port status image', + ), + ); + + // Connection speed info + Widget? connectionInfo; + if (connection != null) { + connectionInfo = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + hasLanPorts ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon.font( + AppFontIcons.bidirectional, + color: Theme.of(context).colorScheme.primary, + ), + hasLanPorts + ? AppText.bodySmall(connection!) + : AppText.bodyMedium(connection!), + ], + ), + AppText.bodySmall( + loc(context).connectedSpeed, + textAlign: TextAlign.center, + ), + ], + ); + } + + // WAN label + final wanLabel = isWan ? AppText.labelMedium(loc(context).internet) : null; + + // Build appropriate layout based on hasLanPorts + if (hasLanPorts) { + return _buildVerticalLayout( + context, + isMobile, + portLabelWidgets, + portImage, + connectionInfo, + wanLabel, + ); + } else { + return _buildHorizontalLayout( + context, + isMobile, + portLabelWidgets, + portImage, + connectionInfo, + wanLabel, + ); + } + } + + Widget _buildVerticalLayout( + BuildContext context, + bool isMobile, + List portLabelWidgets, + Widget portImage, + Widget? connectionInfo, + Widget? wanLabel, + ) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + isMobile + ? Column(children: portLabelWidgets) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: portLabelWidgets, + ), + ], + ), + portImage, + if (connectionInfo != null) connectionInfo, + if (wanLabel != null) wanLabel, + ], + ); + } + + Widget _buildHorizontalLayout( + BuildContext context, + bool isMobile, + List portLabelWidgets, + Widget portImage, + Widget? connectionInfo, + Widget? wanLabel, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + isMobile + ? Column(children: portLabelWidgets) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: portLabelWidgets, + ), + ], + ), + portImage, + ], + ), + AppGap.md(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (connectionInfo != null) connectionInfo, + if (wanLabel != null) wanLabel, + ], + ), + ), + ], + ); + } +} diff --git a/lib/page/dashboard/views/components/remote_assistance_animation.dart b/lib/page/dashboard/views/components/widgets/parts/remote_assistance_animation.dart similarity index 100% rename from lib/page/dashboard/views/components/remote_assistance_animation.dart rename to lib/page/dashboard/views/components/widgets/parts/remote_assistance_animation.dart diff --git a/lib/page/dashboard/views/components/widgets/parts/wifi_card.dart b/lib/page/dashboard/views/components/widgets/parts/wifi_card.dart new file mode 100644 index 000000000..dbfdad306 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/parts/wifi_card.dart @@ -0,0 +1,280 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +import 'package:privacy_gui/page/wifi_settings/_wifi_settings.dart'; +import 'package:privacy_gui/page/wifi_settings/providers/wifi_bundle_provider.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:privacy_gui/util/qr_code.dart'; +import 'package:privacy_gui/util/wifi_credential.dart'; +import 'package:privacy_gui/util/export_selector/export_base.dart' + if (dart.library.io) 'package:privacy_gui/util/export_selector/export_mobile.dart' + if (dart.library.html) 'package:privacy_gui/util/export_selector/export_web.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// A card widget displaying WiFi network information with toggle and share. +class WiFiCard extends ConsumerStatefulWidget { + final DashboardWiFiUIModel item; + final int index; + final bool canBeDisabled; + final bool tooltipVisible; + final ValueChanged? onTooltipVisibilityChanged; + + const WiFiCard({ + super.key, + required this.item, + required this.index, + required this.canBeDisabled, + this.tooltipVisible = false, + this.onTooltipVisibilityChanged, + }); + + @override + ConsumerState createState() => _WiFiCardState(); +} + +class _WiFiCardState extends ConsumerState { + final qrBtnKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraint) { + return AppCard( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.xxl, horizontal: AppSpacing.xxl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context), + AppGap.sm(), + _buildSSID(), + AppGap.sm(), + _buildFooter(context), + ], + ), + onTap: () { + context.pushNamed(RouteNamed.menuIncredibleWiFi, + extra: {'wifiIndex': widget.index, 'guest': widget.item.isGuest}); + }, + ); + }); + } + + Widget _buildHeader(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: AppText.bodyMedium( + widget.item.isGuest + ? loc(context).guestWifi + : loc(context).wifiBand(widget.item.radios + .map((e) => e.replaceAll('RADIO_', '')) + .join('/')), + ), + ), + AppSwitch( + value: widget.item.isEnabled, + onChanged: widget.item.isGuest || + !widget.item.isEnabled || + widget.canBeDisabled + ? (value) => _handleWifiToggled(value) + : null, + ), + ], + ); + } + + Widget _buildSSID() { + return FittedBox( + child: AppText.titleMedium(widget.item.ssid), + ); + } + + Widget _buildFooter(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Row( + children: [ + AppIcon.font(AppFontIcons.devices), + AppGap.sm(), + Flexible( + child: AppText.labelLarge( + loc(context).nDevices(widget.item.numOfConnectedDevices), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + Align( + alignment: AlignmentDirectional.centerEnd, + child: _buildTooltip(context), + ), + ], + ); + } + + Widget _buildTooltip(BuildContext context) { + return AppTooltip( + position: AxisDirection.left, + visible: widget.item.isEnabled ? widget.tooltipVisible : false, + maxWidth: 200, + maxHeight: 200, + padding: EdgeInsets.zero, + content: Container( + color: Colors.white, + height: 200, + width: 200, + child: QrImageView( + data: WiFiCredential( + ssid: widget.item.ssid, + password: widget.item.password, + type: SecurityType.wpa, + ).generate(), + ), + ), + child: MouseRegion( + onEnter: widget.item.isEnabled + ? (_) => widget.onTooltipVisibilityChanged?.call(true) + : null, + onExit: (_) => widget.onTooltipVisibilityChanged?.call(false), + child: SizedBox( + width: 80, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + child: AppIconButton.small( + key: qrBtnKey, + styleVariant: ButtonStyleVariant.text, + icon: AppIcon.font(AppFontIcons.qrCode), + onTap: () => _showWiFiShareModal(context), + ), + ) + ], + ), + ), + ), + ); + } + + Future _handleWifiToggled(bool value) async { + final result = await showSwitchWifiDialog(); + if (result) { + if (!mounted) return; + showSpinnerDialog(context); + final wifiProvider = ref.read(wifiBundleProvider.notifier); + await wifiProvider.fetch(); + + if (widget.item.isGuest) { + await _saveGuestWifi(wifiProvider, value); + } else { + await _saveMainWifi(wifiProvider, value); + } + } + } + + Future _saveGuestWifi( + WifiBundleNotifier wifiProvider, bool value) async { + wifiProvider.setWiFiEnabled(value); + await wifiProvider.save().then((_) { + if (mounted) context.pop(); + }).catchError((error, stackTrace) { + if (!mounted) return; + showRouterNotFoundAlert(context, ref, onComplete: () => context.pop()); + }, test: (error) => error is ServiceSideEffectError).onError((error, _) { + if (mounted) context.pop(); + }); + } + + Future _saveMainWifi( + WifiBundleNotifier wifiProvider, bool value) async { + await wifiProvider + .saveToggleEnabled(radios: widget.item.radios, enabled: value) + .then((_) { + if (mounted) context.pop(); + }).catchError((error, stackTrace) { + if (!mounted) return; + showRouterNotFoundAlert(context, ref, onComplete: () => context.pop()); + }, test: (error) => error is ServiceSideEffectError).onError((error, _) { + if (mounted) context.pop(); + }); + } + + Future showSwitchWifiDialog() async { + return await showSimpleAppDialog( + context, + title: loc(context).wifiListSaveModalTitle, + scrollable: true, + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.bodyMedium(loc(context).wifiListSaveModalDesc), + if (!widget.item.isGuest && widget.item.isEnabled) + ..._disableGuestBandWarning(), + AppGap.lg(), + AppText.bodyMedium(loc(context).doYouWantToContinue), + ], + ), + actions: [ + AppButton.text(label: loc(context).cancel, onTap: () => context.pop()), + AppButton.text(label: loc(context).ok, onTap: () => context.pop(true)), + ], + ); + } + + List _disableGuestBandWarning() { + final guestWifiItem = + ref.read(dashboardHomeProvider).wifis.firstWhere((e) => e.isGuest); + final currentRadio = widget.item.radios.first; + return guestWifiItem.isEnabled + ? [ + AppGap.sm(), + AppText.labelMedium( + loc(context).disableBandWarning( + WifiRadioBand.getByValue(currentRadio).bandName), + ) + ] + : []; + } + + void _showWiFiShareModal(BuildContext context) { + showSimpleAppDialog( + context, + title: loc(context).shareWiFi, + scrollable: true, + content: WiFiShareDetailView( + ssid: widget.item.ssid, + password: widget.item.password, + ), + actions: [ + AppButton.text(label: loc(context).close, onTap: () => context.pop()), + AppButton.text( + label: loc(context).downloadQR, + onTap: () async { + createWiFiQRCode(WiFiCredential( + ssid: widget.item.ssid, + password: widget.item.password, + type: SecurityType.wpa, + )).then((imageBytes) { + exportFileFromBytes( + fileName: 'share_wifi_${widget.item.ssid}.png', + utf8Bytes: imageBytes, + ); + }); + }, + ), + ], + ); + } +} diff --git a/lib/page/dashboard/views/components/widgets/port_and_speed.dart b/lib/page/dashboard/views/components/widgets/port_and_speed.dart new file mode 100644 index 000000000..30c3f2fa5 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/port_and_speed.dart @@ -0,0 +1,344 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/constants/build_config.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +import 'package:privacy_gui/page/dashboard/strategies/dashboard_layout_context.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/dashboard_loading_wrapper.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/parts/external_speed_test_links.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/parts/internal_speed_test_result.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/parts/port_status_widget.dart'; +import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; +import 'package:privacy_gui/page/health_check/widgets/speed_test_widget.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Dashboard widget showing port connections and speed test results. +/// +/// This component follows IoC (Inversion of Control) - configuration is +/// provided by the parent [DashboardLayoutStrategy] via [PortAndSpeedConfig], +/// rather than self-determining layout based on variant. +/// +/// Supports three display modes: +/// - [DisplayMode.compact]: Minimal port status only +/// - [DisplayMode.normal]: Ports with speed test +/// - [DisplayMode.expanded]: Detailed port and speed info +class DashboardHomePortAndSpeed extends ConsumerWidget { + const DashboardHomePortAndSpeed({ + super.key, + required this.config, + this.displayMode = DisplayMode.normal, + }); + + /// Configuration provided by the parent Strategy. + final PortAndSpeedConfig config; + + /// The display mode for this widget + final DisplayMode displayMode; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DashboardLoadingWrapper( + loadingHeight: _getLoadingHeight(), + builder: (context, ref) { + final state = ref.watch(dashboardHomeProvider); + return _buildLayout(context, ref, state); + }, + ); + } + + double _getLoadingHeight() { + return switch (displayMode) { + DisplayMode.compact => 150, + DisplayMode.normal => 250, + DisplayMode.expanded => 350, + }; + } + + Widget _buildLayout( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + ) { + if (displayMode == DisplayMode.compact) { + return _buildCompactView(context, ref, state); + } + + // Bypass LayoutBuilder if direction is explicit (fixes IntrinsicHeight errors in legacy layouts) + if (config.direction != null) { + return displayMode == DisplayMode.normal + ? _buildNormalView( + context, ref, state, config.direction!, const BoxConstraints()) + : _buildExpandedView(context, ref, state, config.direction!); + } + + // For Normal and Expanded, we might need auto-direction logic + return LayoutBuilder( + builder: (context, constraints) { + // Determine direction: + // 1. Explicit config + // 2. Auto-detect based on width (breakpoint at 6 columns) + // Min columns updated to 4. + // 4-5 cols -> Vertical. + // 6+ cols -> Horizontal. + final Axis direction = constraints.maxWidth < context.colWidth(6) + ? Axis.vertical + : Axis.horizontal; + + return displayMode == DisplayMode.normal + ? _buildNormalView(context, ref, state, direction, constraints) + : _buildExpandedView(context, ref, state, direction); + }, + ); + } + + /// Compact view: Port status icons only, no speed test + Widget _buildCompactView( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + ) { + return AppCard( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // LAN ports in compact mode + ...state.lanPortConnections.mapIndexed((index, e) { + final isConnected = e != 'None'; + return _compactPortIcon( + context, + label: 'LAN${index + 1}', + isConnected: isConnected, + isWan: false, + ); + }), + // WAN port + _compactPortIcon( + context, + label: loc(context).wan, + isConnected: state.wanPortConnection != 'None', + isWan: true, + ), + ], + ), + ); + } + + Widget _compactPortIcon( + BuildContext context, { + required String label, + required bool isConnected, + required bool isWan, + }) { + return Tooltip( + message: '$label: ${isConnected ? "Connected" : "Disconnected"}', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isWan ? Icons.public : Icons.lan, + size: 20, + color: isConnected + ? Theme.of(context) + .extension() + ?.colorScheme + .semanticSuccess ?? + Colors.green + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + AppGap.xs(), + AppText.labelSmall( + label, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ); + } + + /// Normal view: Ports with speed test (existing implementation) + Widget _buildNormalView( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + Axis direction, + BoxConstraints constraints, + ) { + final hasLanPort = state.lanPortConnections.isNotEmpty; + final isVertical = direction == Axis.vertical; + + // Calculate minimum height based on config + // If auto-layout, we rely on intrinsic sizing primarily, but can keep minHeight for consistency if needed. + // final minHeight = _calculateMinHeight(isVertical, hasLanPort); + + return Container( + width: double.infinity, + // constraints: BoxConstraints(minHeight: minHeight), + child: AppCard( + padding: EdgeInsets.zero, + child: Column( + mainAxisSize: + config.portsHeight == null ? MainAxisSize.min : MainAxisSize.max, + mainAxisAlignment: isVertical + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.start, + children: [ + SizedBox( + height: config.portsHeight, + child: Padding( + padding: config.portsPadding, + child: _buildPortsSection(context, state, direction), + ), + ), + if (config.showSpeedTest) + SizedBox( + width: double.infinity, + height: config.speedTestHeight, + child: _buildSpeedTestSection(context, ref, state, hasLanPort), + ), + ], + ), + ), + ); + } + + /// Expanded view: Detailed port and speed info + Widget _buildExpandedView( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + Axis direction, + ) { + final hasLanPort = state.lanPortConnections.isNotEmpty; + + return Container( + width: double.infinity, + constraints: BoxConstraints(minHeight: 400), + child: AppCard( + padding: EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Expanded port section with more details + Padding( + padding: EdgeInsets.all(AppSpacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.titleSmall(loc(context).ports), + AppGap.lg(), + _buildPortsSection(context, state, direction), + ], + ), + ), + if (config.showSpeedTest) ...[ + const Divider(), + Padding( + padding: EdgeInsets.all(AppSpacing.xl), + child: _buildSpeedTestSection(context, ref, state, hasLanPort), + ), + ], + ], + ), + ), + ); + } + + Widget _buildPortsSection( + BuildContext context, + DashboardHomeState state, + Axis direction, + ) { + final hasLanPort = state.lanPortConnections.isNotEmpty; + final isVertical = direction == Axis.vertical; + + // Build LAN port widgets + final lanPorts = state.lanPortConnections.mapIndexed((index, e) { + final port = PortStatusWidget( + connection: e == 'None' ? null : e, + label: loc(context).indexedPort(index + 1), + isWan: false, + hasLanPorts: hasLanPort, + ); + return isVertical + ? Padding(padding: const EdgeInsets.only(bottom: 36.0), child: port) + : Expanded(child: port); + }).toList(); + + // Build WAN port widget + final wanPort = PortStatusWidget( + connection: + state.wanPortConnection == 'None' ? null : state.wanPortConnection, + label: loc(context).wan, + isWan: true, + hasLanPorts: hasLanPort, + ); + + // Arrange based on config direction + if (isVertical) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ...lanPorts, + wanPort, + ], + ); + } else { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...lanPorts, + Expanded(child: wanPort), + ], + ); + } + } + + Widget _buildSpeedTestSection( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + bool hasLanPort, + ) { + final isRemote = BuildConfig.isRemote(); + final isHealthCheckSupported = + ref.watch(healthCheckProvider).isSpeedTestModuleSupported; + + if (isHealthCheckSupported) { + return hasLanPort + ? Column( + children: [ + const Divider(), + const SpeedTestWidget( + showDetails: false, + showInfoPanel: true, + showStepDescriptions: false, + showLatestOnIdle: true, + layout: SpeedTestLayout.vertical, + ), + AppGap.xxl(), + ], + ) + : InternalSpeedTestResult(state: state); + } + + return Tooltip( + message: loc(context).featureUnavailableInRemoteMode, + child: Opacity( + opacity: isRemote ? 0.5 : 1, + child: AbsorbPointer( + absorbing: isRemote, + child: ExternalSpeedTestLinks(state: state), + ), + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/widgets/quick_panel.dart b/lib/page/dashboard/views/components/widgets/quick_panel.dart new file mode 100644 index 000000000..2a8c19ba5 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/quick_panel.dart @@ -0,0 +1,413 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; +import 'package:privacy_gui/core/jnap/models/node_light_settings.dart'; +import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.dart'; +import 'package:privacy_gui/core/utils/nodes.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/dashboard_loading_wrapper.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_device_list_provider.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_provider.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; +import 'package:privacy_gui/page/instant_topology/providers/instant_topology_provider.dart'; +import 'package:privacy_gui/page/nodes/providers/node_detail_state.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Quick actions panel for the dashboard. +/// +/// Supports three display modes: +/// - [DisplayMode.compact]: Minimal toggles only +/// - [DisplayMode.normal]: Standard toggle list +/// - [DisplayMode.expanded]: Expanded toggles with descriptions +class DashboardQuickPanel extends ConsumerStatefulWidget { + const DashboardQuickPanel({ + super.key, + this.displayMode = DisplayMode.normal, + }); + + /// The display mode for this widget + final DisplayMode displayMode; + + @override + ConsumerState createState() => + _DashboardQuickPanelState(); +} + +class _DashboardQuickPanelState extends ConsumerState { + @override + Widget build(BuildContext context) { + return DashboardLoadingWrapper( + loadingHeight: _getLoadingHeight(), + builder: (context, ref) => _buildContent(context, ref), + ); + } + + double _getLoadingHeight() { + return switch (widget.displayMode) { + DisplayMode.compact => 100, + DisplayMode.normal => 150, + DisplayMode.expanded => 200, + }; + } + + Widget _buildContent(BuildContext context, WidgetRef ref) { + return switch (widget.displayMode) { + DisplayMode.compact => _buildCompactView(context, ref), + DisplayMode.normal => _buildNormalView(context, ref), + DisplayMode.expanded => _buildExpandedView(context, ref), + }; + } + + /// Compact view: Icon-only toggles in a row + Widget _buildCompactView(BuildContext context, WidgetRef ref) { + final privacyState = ref.watch(instantPrivacyProvider); + final nodeLightState = ref.watch(nodeLightSettingsProvider); + final master = ref.watch(instantTopologyProvider).root.children.first; + bool isSupportNodeLight = serviceHelper.isSupportLedMode(); + bool isCognitive = isCognitiveMeshRouter( + modelNumber: master.data.model, + hardwareVersion: master.data.hardwareVersion); + + return AppCard( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Privacy toggle + _compactToggle( + context, + icon: Icons.shield, + isActive: privacyState.status.mode == MacFilterMode.allow, + label: loc(context).instantPrivacy, + onTap: () => context.pushNamed(RouteNamed.menuInstantPrivacy), + onToggle: (value) { + showInstantPrivacyConfirmDialog(context, value).then((isOk) { + if (isOk != true) return; + final notifier = ref.read(instantPrivacyProvider.notifier); + if (value) { + final macAddressList = ref + .read(instantPrivacyDeviceListProvider) + .map((e) => e.macAddress.toUpperCase()) + .toList(); + notifier.setMacAddressList(macAddressList); + } + notifier.setEnable(value); + if (context.mounted) { + doSomethingWithSpinner(context, notifier.save()); + } + }); + }, + ), + // Night mode toggle + if (isCognitive && isSupportNodeLight) + _compactToggle( + context, + icon: AppFontIcons.darkMode, + isActive: nodeLightState.isNightModeEnable, + label: loc(context).nightMode, + onToggle: (value) { + final notifier = ref.read(nodeLightSettingsProvider.notifier); + if (value) { + notifier.setSettings(NodeLightSettings.night()); + } else { + notifier.setSettings(NodeLightSettings.on()); + } + doSomethingWithSpinner(context, notifier.save()); + }, + ), + ], + ), + ); + } + + Widget _compactToggle( + BuildContext context, { + required IconData icon, + required bool isActive, + required String label, + VoidCallback? onTap, + required void Function(bool) onToggle, + }) { + return Tooltip( + message: label, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + child: AppIcon.font( + icon, + size: 24, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + AppGap.xs(), + SizedBox( + width: 48, + height: 28, + child: FittedBox( + child: AppSwitch( + value: isActive, + onChanged: onToggle, + ), + ), + ), + ], + ), + ); + } + + /// Normal view: Standard toggle list (existing implementation) + Widget _buildNormalView(BuildContext context, WidgetRef ref) { + final privacyState = ref.watch(instantPrivacyProvider); + final nodeLightState = ref.watch(nodeLightSettingsProvider); + final master = ref.watch(instantTopologyProvider).root.children.first; + bool isSupportNodeLight = serviceHelper.isSupportLedMode(); + bool isCognitive = isCognitiveMeshRouter( + modelNumber: master.data.model, + hardwareVersion: master.data.hardwareVersion); + + return AppCard( + padding: EdgeInsets.all(AppSpacing.xxl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + toggleTileWidget( + title: loc(context).instantPrivacy, + value: privacyState.status.mode == MacFilterMode.allow, + leading: AppBadge( + label: 'BETA', + color: Theme.of(context) + .extension()! + .semanticWarning, + ), + onTap: () { + context.pushNamed(RouteNamed.menuInstantPrivacy); + }, + onChanged: (value) { + showInstantPrivacyConfirmDialog(context, value).then((isOk) { + if (isOk != true) { + return; + } + final notifier = ref.read(instantPrivacyProvider.notifier); + if (value) { + final macAddressList = ref + .read(instantPrivacyDeviceListProvider) + .map((e) => e.macAddress.toUpperCase()) + .toList(); + notifier.setMacAddressList(macAddressList); + } + notifier.setEnable(value); + if (context.mounted) { + doSomethingWithSpinner(context, notifier.save()); + } + }); + }, + tips: loc(context).instantPrivacyInfo, + semantics: 'quick instant privacy switch'), + if (isCognitive && isSupportNodeLight) ...[ + const Divider( + height: 48, + thickness: 1.0, + ), + toggleTileWidget( + title: loc(context).nightMode, + value: nodeLightState.isNightModeEnable, + subTitle: ref + .read(nodeLightSettingsProvider.notifier) + .currentStatus == + NodeLightStatus.night + ? loc(context).nightModeTime + : ref + .read(nodeLightSettingsProvider.notifier) + .currentStatus == + NodeLightStatus.off + ? loc(context).allDayOff + : null, + onChanged: (value) { + final notifier = ref.read(nodeLightSettingsProvider.notifier); + if (value) { + notifier.setSettings(NodeLightSettings.night()); + } else { + notifier.setSettings(NodeLightSettings.on()); + } + doSomethingWithSpinner(context, notifier.save()); + }, + tips: loc(context).nightModeTips, + semantics: 'quick night mode switch'), + ] + ], + ), + ); + } + + /// Expanded view: Toggles with full descriptions + Widget _buildExpandedView(BuildContext context, WidgetRef ref) { + final privacyState = ref.watch(instantPrivacyProvider); + final nodeLightState = ref.watch(nodeLightSettingsProvider); + final master = ref.watch(instantTopologyProvider).root.children.first; + bool isSupportNodeLight = serviceHelper.isSupportLedMode(); + bool isCognitive = isCognitiveMeshRouter( + modelNumber: master.data.model, + hardwareVersion: master.data.hardwareVersion); + + return AppCard( + padding: EdgeInsets.all(AppSpacing.xxl), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _expandedToggleTile( + context, + title: loc(context).instantPrivacy, + description: loc(context).instantPrivacyInfo, + value: privacyState.status.mode == MacFilterMode.allow, + onTap: () => context.pushNamed(RouteNamed.menuInstantPrivacy), + onChanged: (value) { + showInstantPrivacyConfirmDialog(context, value).then((isOk) { + if (isOk != true) return; + final notifier = ref.read(instantPrivacyProvider.notifier); + if (value) { + final macAddressList = ref + .read(instantPrivacyDeviceListProvider) + .map((e) => e.macAddress.toUpperCase()) + .toList(); + notifier.setMacAddressList(macAddressList); + } + notifier.setEnable(value); + if (context.mounted) { + doSomethingWithSpinner(context, notifier.save()); + } + }); + }, + ), + if (isCognitive && isSupportNodeLight) ...[ + const Divider(height: 32), + _expandedToggleTile( + context, + title: loc(context).nightMode, + description: loc(context).nightModeTips, + value: nodeLightState.isNightModeEnable, + onChanged: (value) { + final notifier = ref.read(nodeLightSettingsProvider.notifier); + if (value) { + notifier.setSettings(NodeLightSettings.night()); + } else { + notifier.setSettings(NodeLightSettings.on()); + } + doSomethingWithSpinner(context, notifier.save()); + }, + ), + ] + ], + ), + ); + } + + Widget _expandedToggleTile( + BuildContext context, { + required String title, + required String description, + required bool value, + VoidCallback? onTap, + required void Function(bool) onChanged, + }) { + return InkWell( + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelLarge(title), + AppGap.sm(), + AppText.bodySmall( + description, + color: Theme.of(context).colorScheme.onSurfaceVariant, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + AppGap.lg(), + AppSwitch(value: value, onChanged: onChanged), + ], + ), + ); + } + + Widget toggleTileWidget({ + required String title, + Widget? leading, + String? subTitle, + VoidCallback? onTap, + required bool value, + required void Function(bool value)? onChanged, + String? tips, + String? semantics, + }) { + return SizedBox( + height: 60, + child: InkWell( + focusColor: Colors.transparent, + splashColor: Theme.of(context).colorScheme.primary, + onTap: onTap, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Wrap( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AppText.labelLarge(title), + if (subTitle != null) AppText.bodySmall(subTitle), + ], + ), + if (leading != null) ...[ + SizedBox( + width: AppSpacing.xs, + ), + leading, + ], + SizedBox( + width: AppSpacing.sm, + ), + if (tips != null) + Tooltip( + message: tips, + child: Icon( + Icons.info_outline, + semanticLabel: '{$semantics} icon', + color: Theme.of(context).colorScheme.primary, + ), + ) + ], + ), + ), + AppSwitch( + key: ValueKey(semantics), + value: value, + onChanged: onChanged, + ), + ], + ), + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/widgets/wifi_grid.dart b/lib/page/dashboard/views/components/widgets/wifi_grid.dart new file mode 100644 index 000000000..9425c60cb --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/wifi_grid.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/dashboard_loading_wrapper.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/parts/wifi_card.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Grid displaying WiFi networks for the dashboard. +/// +/// Supports three display modes: +/// - [DisplayMode.compact]: Horizontal scrollable cards +/// - [DisplayMode.normal]: 2-column grid +/// - [DisplayMode.expanded]: Larger cards with more details +class DashboardWiFiGrid extends ConsumerStatefulWidget { + const DashboardWiFiGrid({ + super.key, + this.displayMode = DisplayMode.normal, + }); + + /// The display mode for this widget + final DisplayMode displayMode; + + @override + ConsumerState createState() => _DashboardWiFiGridState(); +} + +class _DashboardWiFiGridState extends ConsumerState { + Map toolTipVisible = {}; + + @override + Widget build(BuildContext context) { + return DashboardLoadingWrapper( + loadingHeight: _calculateLoadingHeight(context), + builder: (context, ref) => _buildContent(context, ref), + ); + } + + double _calculateLoadingHeight(BuildContext context) { + const itemHeight = 176.0; + final mainSpacing = + AppLayoutConfig.gutter(MediaQuery.of(context).size.width); + return itemHeight * 2 + mainSpacing * 1; + } + + Widget _buildContent(BuildContext context, WidgetRef ref) { + return switch (widget.displayMode) { + DisplayMode.compact => _buildCompactView(context, ref), + DisplayMode.normal => _buildNormalView(context, ref), + DisplayMode.expanded => _buildExpandedView(context, ref), + }; + } + + /// Compact view: Horizontal scrollable small cards + Widget _buildCompactView(BuildContext context, WidgetRef ref) { + final items = + ref.watch(dashboardHomeProvider.select((value) => value.wifis)); + final enabledWiFiCount = + items.where((e) => !e.isGuest && e.isEnabled).length; + final hasLanPort = + ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; + final canBeDisabled = enabledWiFiCount > 1 || hasLanPort; + + const compactHeight = 140.0; + + return SizedBox( + height: compactHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: items.length, + separatorBuilder: (_, __) => AppGap.md(), + itemBuilder: (context, index) { + return SizedBox( + width: 200, + height: compactHeight, + child: _buildWiFiCard(items, index, canBeDisabled), + ); + }, + ), + ); + } + + /// Normal view: 2-column grid (existing implementation) + Widget _buildNormalView(BuildContext context, WidgetRef ref) { + final items = + ref.watch(dashboardHomeProvider.select((value) => value.wifis)); + final crossAxisCount = + (context.isMobileLayout || context.isTabletLayout) ? 1 : 2; + // Use layout gutter for horizontal spacing to match Dashboard Layout + final mainSpacing = + AppLayoutConfig.gutter(MediaQuery.of(context).size.width); + const itemHeight = 176.0; + final mainAxisCount = (items.length / crossAxisCount).ceil(); + + final enabledWiFiCount = + items.where((e) => !e.isGuest && e.isEnabled).length; + final hasLanPort = + ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; + final canBeDisabled = enabledWiFiCount > 1 || hasLanPort; + + final gridHeight = mainAxisCount * itemHeight + + ((mainAxisCount == 0 ? 1 : mainAxisCount) - 1) * AppSpacing.lg; + + return SizedBox( + height: gridHeight, + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: AppSpacing.lg, + crossAxisSpacing: mainSpacing, + mainAxisExtent: itemHeight, + ), + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: items.length, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: _buildWiFiCard(items, index, canBeDisabled), + ); + }, + ), + ); + } + + /// Expanded view: Single column with larger cards + Widget _buildExpandedView(BuildContext context, WidgetRef ref) { + final items = + ref.watch(dashboardHomeProvider.select((value) => value.wifis)); + const itemHeight = 200.0; + + final enabledWiFiCount = + items.where((e) => !e.isGuest && e.isEnabled).length; + final hasLanPort = + ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; + final canBeDisabled = enabledWiFiCount > 1 || hasLanPort; + + final gridHeight = items.length * itemHeight + + (items.isEmpty ? 0 : (items.length - 1)) * AppSpacing.lg; + + return SizedBox( + height: gridHeight, + child: ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: items.length, + separatorBuilder: (_, __) => AppGap.lg(), + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: _buildWiFiCard(items, index, canBeDisabled), + ); + }, + ), + ); + } + + Widget _buildWiFiCard( + List items, + int index, + bool canBeDisabled, + ) { + final item = items[index]; + final visibilityKey = '${item.ssid}${item.radios.join()}${item.isGuest}'; + final isVisible = toolTipVisible[visibilityKey] ?? false; + + return WiFiCard( + tooltipVisible: isVisible, + item: item, + index: index, + canBeDisabled: canBeDisabled, + onTooltipVisibilityChanged: (visible) { + setState(() { + // Hide all other tooltips when showing one + if (visible) { + for (var key in toolTipVisible.keys) { + toolTipVisible[key] = false; + } + } + toolTipVisible[visibilityKey] = visible; + }); + }, + ); + } +} diff --git a/lib/page/dashboard/views/components/wifi_grid.dart b/lib/page/dashboard/views/components/wifi_grid.dart deleted file mode 100644 index 27f9d5a43..000000000 --- a/lib/page/dashboard/views/components/wifi_grid.dart +++ /dev/null @@ -1,370 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:privacy_gui/core/data/providers/polling_provider.dart'; -import 'package:privacy_gui/core/errors/service_error.dart'; -import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; -import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; -import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; -import 'package:privacy_gui/page/wifi_settings/_wifi_settings.dart'; -import 'package:privacy_gui/page/wifi_settings/providers/wifi_bundle_provider.dart'; -import 'package:privacy_gui/route/constants.dart'; -import 'package:privacy_gui/util/qr_code.dart'; -import 'package:privacy_gui/util/wifi_credential.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; - -import 'package:ui_kit_library/ui_kit.dart'; -import 'package:privacy_gui/util/export_selector/export_base.dart' - if (dart.library.io) 'package:privacy_gui/util/export_selector/export_mobile.dart' - if (dart.library.html) 'package:privacy_gui/util/export_selector/export_web.dart'; -import 'package:qr_flutter/qr_flutter.dart'; - -class DashboardWiFiGrid extends ConsumerStatefulWidget { - const DashboardWiFiGrid({super.key}); - - @override - ConsumerState createState() => _DashboardWiFiGridState(); -} - -class _DashboardWiFiGridState extends ConsumerState { - Map toolTipVisible = {}; - - @override - Widget build(BuildContext context) { - final items = - ref.watch(dashboardHomeProvider.select((value) => value.wifis)); - final isLoading = - (ref.watch(pollingProvider).value?.isReady ?? false) == false; - final crossAxisCount = context.isMobileLayout ? 1 : 2; - const mainSpacing = AppSpacing.lg; - const itemHeight = 176.0; - final mainAxisCount = (items.length / crossAxisCount); - - final enabledWiFiCount = - items.where((e) => !e.isGuest && e.isEnabled).length; - final hasLanPort = - ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; - final canBeDisabled = enabledWiFiCount > 1 || hasLanPort; - - return SizedBox( - height: isLoading - ? itemHeight * 2 + mainSpacing * 1 - : mainAxisCount * itemHeight + - ((mainAxisCount == 0 ? 1 : mainAxisCount) - 1) * mainSpacing + - 100, - child: isLoading - ? AppCard(padding: EdgeInsets.zero, child: LoadingTile()) - : GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: AppSpacing.lg, - crossAxisSpacing: mainSpacing, - // childAspectRatio: (3 / 2), - mainAxisExtent: itemHeight, - ), - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: isLoading ? 4 : items.length, - itemBuilder: (context, index) { - createWiFiCard() { - final item = items[index]; - final visibilityKey = - '${item.ssid}${item.radios.join()}${item.isGuest}'; - - final isVisible = toolTipVisible[visibilityKey] ?? false; - return WiFiCard( - tooltipVisible: isVisible, - item: item, - index: index, - canBeDisabled: canBeDisabled, - onTooltipVisibilityChanged: (visible) { - setState(() { - // Hide all other tooltips when showing one - if (visible) { - for (var key in toolTipVisible.keys) { - toolTipVisible[key] = false; - } - } - toolTipVisible[visibilityKey] = visible; - }); - }, - ); - } - - return SizedBox( - height: itemHeight, - child: isLoading - ? AppCard(child: LoadingTile()) - : createWiFiCard()); - }, - ), - ); - } -} - -class WiFiCard extends ConsumerStatefulWidget { - final DashboardWiFiUIModel item; - final int index; - final bool canBeDisabled; - final bool tooltipVisible; - final ValueChanged? onTooltipVisibilityChanged; - - const WiFiCard({ - Key? key, - required this.item, - required this.index, - required this.canBeDisabled, - this.tooltipVisible = false, - this.onTooltipVisibilityChanged, - }) : super(key: key); - - @override - ConsumerState createState() => _WiFiCardState(); -} - -class _WiFiCardState extends ConsumerState { - final qrBtnKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - return LayoutBuilder(builder: (context, constraint) { - return AppCard( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.xxl, horizontal: AppSpacing.xxl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AppText.bodyMedium( - widget.item.isGuest - ? loc(context).guestWifi - : loc(context).wifiBand(widget.item.radios - .map((e) => e.replaceAll('RADIO_', '')) - .join('/')), - ), - AppSwitch( - value: widget.item.isEnabled, - onChanged: widget.item.isGuest || - !widget.item.isEnabled || - widget.canBeDisabled - ? (value) => _handleWifiToggled(value) - : null, - ), - ], - ), - AppGap.sm(), - FittedBox( - child: AppText.titleMedium( - widget.item.ssid, - ), - ), - AppGap.sm(), - Stack( - alignment: Alignment.center, - // mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Align( - alignment: AlignmentDirectional.centerStart, - child: Row( - children: [ - AppIcon.font( - AppFontIcons.devices, - ), - AppGap.sm(), - AppText.labelLarge( - loc(context) - .nDevices(widget.item.numOfConnectedDevices), - ), - ], - ), - ), - Align( - alignment: AlignmentDirectional.centerEnd, - child: _buildTooltip(context)), - ], - ) - ], - ), - onTap: () { - context.pushNamed(RouteNamed.menuIncredibleWiFi, - extra: {'wifiIndex': widget.index, 'guest': widget.item.isGuest}); - }, - ); - }); - } - - Widget _buildTooltip(BuildContext context) { - return AppTooltip( - position: AxisDirection.left, - visible: widget.item.isEnabled ? widget.tooltipVisible : false, - maxWidth: 200, - maxHeight: 200, - padding: EdgeInsets.zero, - content: Container( - color: Colors.white, - height: 200, - width: 200, - child: QrImageView( - data: WiFiCredential( - ssid: widget.item.ssid, - password: widget.item.password, - type: SecurityType.wpa, - ).generate(), - ), - ), - child: MouseRegion( - onEnter: widget.item.isEnabled - ? (_) => widget.onTooltipVisibilityChanged?.call(true) - : null, - onExit: (_) => widget.onTooltipVisibilityChanged?.call(false), - child: SizedBox( - width: 80, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - GestureDetector( - child: AppIconButton.small( - key: qrBtnKey, - styleVariant: ButtonStyleVariant.text, - icon: AppIcon.font(AppFontIcons.qrCode), - onTap: () { - _showWiFiShareModal(context); - }, - ), - ) - ], - ), - ), - ), - ); - } - - Future _handleWifiToggled(bool value) async { - final result = await showSwitchWifiDialog(); - if (result) { - // If the widget is already disposed, do nothing even if the user has confirmed the change - if (!mounted) return; - showSpinnerDialog(context); - final wifiProvider = ref.read(wifiBundleProvider.notifier); - await wifiProvider.fetch(); - if (widget.item.isGuest) { - wifiProvider.setWiFiEnabled(value); - await wifiProvider.save().then((value) { - if (mounted) { - context.pop(); - } - }).catchError((error, stackTrace) { - if (!mounted) return; - // Show RouterNotFound alert for the JNAP side effect error - showRouterNotFoundAlert( - context, - ref, - onComplete: () => context.pop(), - ); - }, test: (error) => error is ServiceSideEffectError).onError( - (error, statckTrace) { - if (mounted) { - // Just dismiss the spinner for other unexpected errors - context.pop(); - } - }); - } else { - await wifiProvider - .saveToggleEnabled(radios: widget.item.radios, enabled: value) - .then((value) { - if (mounted) { - context.pop(); - } - }).catchError((error, stackTrace) { - if (!mounted) return; - // Show RouterNotFound alert for the JNAP side effect error - showRouterNotFoundAlert( - context, - ref, - onComplete: () => context.pop(), - ); - }, test: (error) => error is ServiceSideEffectError).onError( - (error, statckTrace) { - if (mounted) { - // Just dismiss the spinner for other unexpected errors - context.pop(); - } - }); - } - } - } - - Future showSwitchWifiDialog() async { - return await showSimpleAppDialog( - context, - title: loc(context).wifiListSaveModalTitle, - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.bodyMedium(loc(context).wifiListSaveModalDesc), - if (!widget.item.isGuest && widget.item.isEnabled) - ..._disableGuestBandWarning(), - AppGap.lg(), - AppText.bodyMedium(loc(context).doYouWantToContinue), - ], - ), - ), - actions: [ - AppButton.text(label: loc(context).cancel, onTap: () => context.pop()), - AppButton.text(label: loc(context).ok, onTap: () => context.pop(true)), - ], - ); - } - - List _disableGuestBandWarning() { - final guestWifiItem = - ref.read(dashboardHomeProvider).wifis.firstWhere((e) => e.isGuest); - // There will be only one radio item for each wifi card - final currentRadio = widget.item.radios.first; - return guestWifiItem.isEnabled - ? [ - AppGap.sm(), - AppText.labelMedium( - loc(context).disableBandWarning( - WifiRadioBand.getByValue(currentRadio).bandName), - ) - ] - : []; - } - - void _showWiFiShareModal(BuildContext context) { - showSimpleAppDialog(context, - title: loc(context).shareWiFi, - content: SingleChildScrollView( - child: WiFiShareDetailView( - ssid: widget.item.ssid, - password: widget.item.password, - ), - ), - actions: [ - AppButton.text( - label: loc(context).close, - onTap: () { - context.pop(); - }), - AppButton.text( - label: loc(context).downloadQR, - onTap: () async { - createWiFiQRCode(WiFiCredential( - ssid: widget.item.ssid, - password: widget.item.password, - type: SecurityType.wpa)) - .then((imageBytes) { - exportFileFromBytes( - fileName: 'share_wifi_${widget.item.ssid}.png', - utf8Bytes: imageBytes); - }); - }), - ]); - } -} diff --git a/lib/page/dashboard/views/dashboard_home_view.dart b/lib/page/dashboard/views/dashboard_home_view.dart index 46a531d1c..dcf7a6a63 100644 --- a/lib/page/dashboard/views/dashboard_home_view.dart +++ b/lib/page/dashboard/views/dashboard_home_view.dart @@ -6,19 +6,13 @@ import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; import 'package:privacy_gui/core/data/providers/firmware_update_provider.dart'; import 'package:privacy_gui/core/data/providers/polling_provider.dart'; import 'package:privacy_gui/di.dart'; -import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/components/styled/menus/menu_consts.dart'; import 'package:privacy_gui/page/components/styled/menus/widgets/menu_holder.dart'; import 'package:privacy_gui/page/components/ui_kit_page_view.dart'; import 'package:privacy_gui/page/dashboard/_dashboard.dart'; -import 'package:privacy_gui/page/dashboard/views/components/home_title.dart'; -import 'package:privacy_gui/page/dashboard/views/components/internet_status.dart'; -import 'package:privacy_gui/page/dashboard/views/components/networks.dart'; -import 'package:privacy_gui/page/dashboard/views/components/port_and_speed.dart'; -import 'package:privacy_gui/page/dashboard/views/components/quick_panel.dart'; -import 'package:privacy_gui/page/dashboard/views/components/wifi_grid.dart'; +import 'package:privacy_gui/page/dashboard/views/components/_components.dart'; +import 'package:privacy_gui/page/dashboard/strategies/custom_dashboard_layout_strategy.dart'; import 'package:privacy_gui/page/vpn/views/vpn_status_tile.dart'; -import 'package:ui_kit_library/ui_kit.dart'; import 'package:privacy_gui/core/utils/assign_ip/base_assign_ip.dart' if (dart.library.html) 'package:privacy_gui/core/utils/assign_ip/web_assign_ip.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -37,7 +31,6 @@ class _DashboardHomeViewState extends ConsumerState { void initState() { super.initState(); firmware = ref.read(firmwareUpdateProvider.notifier); - // _pushNotificationCheck(); _firmwareUpdateCheck(); ref.read(menuController).setTo(NaviType.home); } @@ -45,10 +38,32 @@ class _DashboardHomeViewState extends ConsumerState { @override Widget build(BuildContext context) { MediaQuery.of(context); - final horizontalLayout = - ref.watch(dashboardHomeProvider).isHorizontalLayout; - final hasLanPort = - ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; + final state = ref.watch(dashboardHomeProvider); + final preferences = ref.watch(dashboardPreferencesProvider); + final hasLanPort = state.lanPortConnections.isNotEmpty; + final isHorizontalLayout = state.isHorizontalLayout; + final isSupportVPN = getIt.get().isSupportVPN(); + + // Forced Display Mode Logic + // If Custom Layout is OFF, force all components to use Normal (Standard) mode. + // This ensures Legacy Layouts render correctly. + final useCustom = preferences.useCustomLayout; + + final internetMode = useCustom + ? preferences.getMode(DashboardWidgetSpecs.internetStatus.id) + : DisplayMode.normal; + final networksMode = useCustom + ? preferences.getMode(DashboardWidgetSpecs.networks.id) + : DisplayMode.normal; + final wifiMode = useCustom + ? preferences.getMode(DashboardWidgetSpecs.wifiGrid.id) + : DisplayMode.normal; + final quickPanelMode = useCustom + ? preferences.getMode(DashboardWidgetSpecs.quickPanel.id) + : DisplayMode.normal; + final portAndSpeedMode = useCustom + ? preferences.getMode(DashboardWidgetSpecs.portAndSpeed.id) + : DisplayMode.normal; return UiKitPageView.withSliver( scrollable: true, @@ -58,175 +73,57 @@ class _DashboardHomeViewState extends ConsumerState { appBarStyle: UiKitAppBarStyle.none, backState: UiKitBackState.none, padding: EdgeInsets.only( - top: 32.0, // was AppSpacing.large3 - bottom: 16.0, // was AppSpacing.medium + top: 32.0, + bottom: 16.0, ), - child: (childContext, constraints) => AppResponsiveLayout( - // New WidgetBuilder API: context has correct PageLayoutScope for colWidth - desktop: (layoutContext) => Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - DashboardHomeTitle(), - AppGap.xl(), - !hasLanPort - ? _desktopNoLanPortsLayout(layoutContext) - : horizontalLayout - ? _desktopHorizontalLayout(layoutContext) - : _desktopVerticalLayout(layoutContext), - ], - ), - mobile: (context) => _mobileLayout(), - ), - ); - } - - Widget _desktopNoLanPortsLayout(BuildContext layoutContext) { - return Column( - children: [ - SizedBox( - height: 256, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: layoutContext.colWidth(8), - child: InternetConnectionWidget()), - AppGap.gutter(), - SizedBox( - width: layoutContext.colWidth(4), - child: DashboardHomePortAndSpeed()), - ], + child: (childContext, constraints) { + // 1. Determine layout variant (single source of truth) + final variant = DashboardLayoutVariant.fromContext( + childContext, + hasLanPort: hasLanPort, + isHorizontalLayout: isHorizontalLayout, + ); + + // 2. Build layout context (IoC - widgets built here, passed to strategy) + // Note: We use keys combining mode and useCustom to force rebuilds when switching strategies. + final layoutContext = DashboardLayoutContext( + context: childContext, + ref: ref, + state: state, + hasLanPort: hasLanPort, + isHorizontalLayout: isHorizontalLayout, + widgetConfigs: preferences.widgetConfigs, + title: const DashboardHomeTitle(), + internetWidget: InternetConnectionWidget( + key: ValueKey('internet-$internetMode-$useCustom'), + displayMode: internetMode, ), - ), - AppGap.lg(), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: layoutContext.colWidth(4), - child: Column( - children: [ - DashboardNetworks(), - AppGap.lg(), - DashboardQuickPanel(), - // _networkInfoTiles(state, isLoading), - ], - ), - ), - AppGap.gutter(), - SizedBox( - width: layoutContext.colWidth(8), - child: Column( - children: [ - if (getIt.get().isSupportVPN()) ...[ - VPNStatusTile(), - AppGap.lg(), - ], - DashboardWiFiGrid(), - ], - )), - ], - ), - ], - ); - } - - Widget _desktopHorizontalLayout(BuildContext layoutContext) { - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Column( - children: [ - InternetConnectionWidget(), - AppGap.lg(), - DashboardHomePortAndSpeed(), - AppGap.lg(), - DashboardWiFiGrid(), - ], - ), + networksWidget: DashboardNetworks( + key: ValueKey('networks-$networksMode-$useCustom'), + displayMode: networksMode, ), - AppGap.gutter(), - SizedBox( - width: layoutContext.colWidth(4), - child: Column( - children: [ - DashboardNetworks(), - AppGap.lg(), - if (getIt.get().isSupportVPN()) ...[ - VPNStatusTile(), - AppGap.lg(), - ], - DashboardQuickPanel(), - // _networkInfoTiles(state, isLoading), - ], - )), - ], - ), - ); - } - - Widget _desktopVerticalLayout(BuildContext layoutContext) { - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: layoutContext.colWidth(3), - child: Column( - children: [ - DashboardHomePortAndSpeed(), - AppGap.lg(), - DashboardQuickPanel(), - ], - ), + wifiGrid: DashboardWiFiGrid( + key: ValueKey('wifi-$wifiMode-$useCustom'), + displayMode: wifiMode, ), - AppGap.gutter(), - Expanded( - child: Column( - children: [ - InternetConnectionWidget(), - AppGap.lg(), - DashboardNetworks(), - AppGap.lg(), - if (getIt.get().isSupportVPN()) ...[ - VPNStatusTile(), - AppGap.lg(), - ], - DashboardWiFiGrid(), - ], - ), + quickPanel: DashboardQuickPanel( + key: ValueKey('quick-$quickPanelMode-$useCustom'), + displayMode: quickPanelMode, ), - ], - ), - ); - } + vpnTile: isSupportVPN ? const VPNStatusTile() : null, + buildPortAndSpeed: (config) => DashboardHomePortAndSpeed( + key: ValueKey('port-$portAndSpeedMode-$useCustom'), + config: config, + displayMode: portAndSpeedMode, + ), + ); - Widget _mobileLayout() { - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DashboardHomeTitle(), - AppGap.xl(), - InternetConnectionWidget(), - AppGap.lg(), - DashboardHomePortAndSpeed(), - AppGap.lg(), - DashboardNetworks(), - if (getIt.get().isSupportVPN()) ...[ - AppGap.lg(), - VPNStatusTile(), - ], - AppGap.lg(), - DashboardQuickPanel(), - AppGap.lg(), - DashboardWiFiGrid(), - AppGap.lg(), - ], + // 3. Delegate to strategy + final strategy = useCustom + ? const CustomDashboardLayoutStrategy() + : DashboardLayoutFactory.create(variant); + return strategy.build(layoutContext); + }, ); } @@ -235,7 +132,7 @@ class _DashboardHomeViewState extends ConsumerState { context: context, barrierDismissible: false, builder: (context) { - return _FirmwareUpdateCountdownDialog(onFinish: reload); + return FirmwareUpdateCountdownDialog(onFinish: reload); }, ); } @@ -253,107 +150,4 @@ class _DashboardHomeViewState extends ConsumerState { }); }); } - - // void _pushNotificationCheck() { - // if (kIsWeb) { - // return; - // } - // if (!mounted) { - // return; - // } - // if (GoRouter.of(context).routerDelegate.currentConfiguration.fullPath != - // RoutePath.dashboardHome) { - // return; - // } - // SharedPreferences.getInstance().then((prefs) { - // final isPushPromptShown = prefs.getBool( - // SmartDevicesPrefsHelper.getNidKey(prefs, key: pShowPushPrompt)) ?? - // false; - // if (!isPushPromptShown) { - // prefs.setBool( - // SmartDevicesPrefsHelper.getNidKey(prefs, key: pShowPushPrompt), - // true); - // showAdaptiveDialog( - // context: context, - // builder: (context) => AlertDialog( - // title: AppText.bodyLarge('Push Notification'), - // content: AppText.bodyLarge( - // 'Do you want to receive Linksys push notifications?'), - // actions: [ - // AppTextButton( - // 'Yes', - // onTap: () { - // final deviceToken = prefs.getString(pDeviceToken); - // if (deviceToken != null) { - // ref - // .read(smartDeviceProvider.notifier) - // .registerSmartDevice(deviceToken); - // } else {} - // context.pop(); - // }, - // ), - // AppTextButton('No', onTap: () { - // context.pop(); - // }) - // ], - // ), - // ); - // } - // }); - // } -} - -class _FirmwareUpdateCountdownDialog extends StatefulWidget { - final VoidCallback onFinish; - const _FirmwareUpdateCountdownDialog({required this.onFinish}); - - @override - State<_FirmwareUpdateCountdownDialog> createState() => - _FirmwareUpdateCountdownDialogState(); -} - -class _FirmwareUpdateCountdownDialogState - extends State<_FirmwareUpdateCountdownDialog> { - int _seconds = 5; - late final Timer _timer; - - @override - void initState() { - super.initState(); - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (_seconds == 1) { - _timer.cancel(); - Navigator.of(context).pop(); - widget.onFinish(); - } else { - setState(() { - _seconds--; - }); - } - }); - } - - @override - void dispose() { - _timer.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: AppText.titleLarge(loc(context).firmwareUpdated), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(), - AppGap.lg(), - AppText.labelLarge( - loc(context).firmwareUpdateCountdownMessage(_seconds), - textAlign: TextAlign.center, - ), - ], - ), - ); - } } diff --git a/lib/page/dashboard/views/dashboard_menu_view.dart b/lib/page/dashboard/views/dashboard_menu_view.dart index dbb3f1cc6..6285fb863 100644 --- a/lib/page/dashboard/views/dashboard_menu_view.dart +++ b/lib/page/dashboard/views/dashboard_menu_view.dart @@ -14,6 +14,7 @@ import 'package:privacy_gui/page/components/styled/menus/menu_consts.dart'; import 'package:privacy_gui/page/components/styled/menus/widgets/menu_holder.dart'; import 'package:privacy_gui/page/components/ui_kit_page_view.dart'; import 'package:privacy_gui/page/dashboard/_dashboard.dart'; +import 'package:privacy_gui/page/dashboard/views/components/_components.dart'; import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_provider.dart'; import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; @@ -78,6 +79,7 @@ class _DashboardMenuViewState extends ConsumerState { childAspectRatio: (205 / 152), mainAxisExtent: isDesktop ? 152 : 112, ), + clipBehavior: Clip.none, physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, itemCount: items.length, @@ -133,12 +135,32 @@ class _DashboardMenuViewState extends ConsumerState { context.pushNamed(RouteNamed.addNodes); }, ), + // AppListTile( + // title: AppText.bodyMedium('Dashboard Layout'), + // leading: const AppIcon.font(AppFontIcons.widgets), + // onTap: () { + // Navigator.of(context).maybePop(); + // _showLayoutSettingsDialog(); + // }, + // ), ], ), ), ); } + void _showLayoutSettingsDialog() { + showDialog( + context: context, + builder: (context) => Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: const DashboardLayoutSettingsPanel(), + ), + ), + ); + } + List createMenuItems() { // final isCloudLogin = // ref.watch(authProvider).value?.loginType == LoginType.remote; diff --git a/lib/page/instant_admin/views/instant_admin_view.dart b/lib/page/instant_admin/views/instant_admin_view.dart index 2127815bc..91b7d9c99 100644 --- a/lib/page/instant_admin/views/instant_admin_view.dart +++ b/lib/page/instant_admin/views/instant_admin_view.dart @@ -145,11 +145,14 @@ class _InstantAdminViewState extends ConsumerState { title: loc(context).autoFirmwareUpdate, value: isFwAutoUpdate, onChanged: (value) async { - await ref - .read(firmwareUpdateProvider.notifier) - .setFirmwareUpdatePolicy(value - ? FirmwareUpdateSettings.firmwareUpdatePolicyAuto - : FirmwareUpdateSettings.firmwareUpdatePolicyManual); + await doSomethingWithSpinner( + context, + ref + .read(firmwareUpdateProvider.notifier) + .setFirmwareUpdatePolicy(value + ? FirmwareUpdateSettings.firmwareUpdatePolicyAuto + : FirmwareUpdateSettings.firmwareUpdatePolicyManual), + ); }, ), ], diff --git a/lib/page/instant_device/views/device_detail_view.dart b/lib/page/instant_device/views/device_detail_view.dart index 2c711ebb8..d74e8c2fc 100644 --- a/lib/page/instant_device/views/device_detail_view.dart +++ b/lib/page/instant_device/views/device_detail_view.dart @@ -209,7 +209,14 @@ class _DeviceDetailViewState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AppText.labelLarge(title), + Tooltip( + message: title, + child: AppText.bodyLarge( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), if (description != null) ...[ AppGap.xs(), AppText.bodyMedium(description), diff --git a/lib/page/instant_setup/troubleshooter/views/pnp_modem_lights_off_view.dart b/lib/page/instant_setup/troubleshooter/views/pnp_modem_lights_off_view.dart index d2c7c3ff0..09a2702ff 100644 --- a/lib/page/instant_setup/troubleshooter/views/pnp_modem_lights_off_view.dart +++ b/lib/page/instant_setup/troubleshooter/views/pnp_modem_lights_off_view.dart @@ -49,8 +49,8 @@ class _PnpLightOffViewState extends ConsumerState { onTap: () { showSimpleAppOkDialog( context, - content: SingleChildScrollView( - child: _bottomSheetContent()), + scrollable: true, + content: _bottomSheetContent(), ); }, ), diff --git a/lib/page/instant_topology/views/instant_topology_view.dart b/lib/page/instant_topology/views/instant_topology_view.dart index 0ea985bd8..dde64c083 100644 --- a/lib/page/instant_topology/views/instant_topology_view.dart +++ b/lib/page/instant_topology/views/instant_topology_view.dart @@ -179,14 +179,12 @@ class _InstantTopologyViewState extends ConsumerState { // Wrap content in padding to respect node borders/glow final paddedContent = Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(12.0), child: content, ); if (deviceCount > 0) { return Stack( - clipBehavior: Clip.none, - fit: StackFit.expand, children: [ paddedContent, Positioned( diff --git a/lib/page/instant_verify/views/components/ping_network_modal.dart b/lib/page/instant_verify/views/components/ping_network_modal.dart index 9a0d5c86b..100b85c53 100644 --- a/lib/page/instant_verify/views/components/ping_network_modal.dart +++ b/lib/page/instant_verify/views/components/ping_network_modal.dart @@ -63,7 +63,8 @@ class _PingNetworkModalState extends ConsumerState { child: SizedBox( width: 36, height: 36, child: CircularProgressIndicator())), if (_pingLog.isNotEmpty) - Expanded( + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), child: SingleChildScrollView( child: AppText.bodySmall(_pingLog), ), diff --git a/lib/page/instant_verify/views/components/traceroute_modal.dart b/lib/page/instant_verify/views/components/traceroute_modal.dart index ee61200f7..c5c33d30c 100644 --- a/lib/page/instant_verify/views/components/traceroute_modal.dart +++ b/lib/page/instant_verify/views/components/traceroute_modal.dart @@ -63,7 +63,8 @@ class _TracerouteModalState extends ConsumerState { child: SizedBox( width: 36, height: 36, child: CircularProgressIndicator())), if (_tracerouteLog.isNotEmpty) - Expanded( + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), child: SingleChildScrollView( child: AppText.bodySmall(_tracerouteLog), ), diff --git a/lib/page/instant_verify/views/instant_verify_view.dart b/lib/page/instant_verify/views/instant_verify_view.dart index 36a07af5d..6ee959d97 100644 --- a/lib/page/instant_verify/views/instant_verify_view.dart +++ b/lib/page/instant_verify/views/instant_verify_view.dart @@ -22,6 +22,7 @@ import 'package:privacy_gui/page/dashboard/_dashboard.dart'; import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; import 'package:privacy_gui/page/health_check/_health_check.dart'; import 'package:privacy_gui/page/instant_verify/providers/instant_verify_provider.dart'; +import 'package:privacy_gui/page/dashboard/views/components/_components.dart'; import 'package:privacy_gui/page/instant_verify/views/components/ping_network_modal.dart'; import 'package:privacy_gui/page/health_check/widgets/speed_test_external_widget.dart'; import 'package:privacy_gui/page/health_check/widgets/speed_test_widget.dart'; @@ -722,124 +723,47 @@ class _InstantVerifyViewState extends ConsumerState Widget _portsCard(BuildContext context, WidgetRef ref) { final state = ref.watch(dashboardHomeProvider); - return SizedBox( - height: context.isMobileLayout ? 224 : 208, - width: double.infinity, - child: AppCard( - key: const ValueKey('portCard'), - padding: EdgeInsets.zero, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xxl, - ), - child: Row( - // mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...state.lanPortConnections - .mapIndexed((index, e) => Expanded( - child: _portWidget( - context, - e == 'None' ? null : e, - loc(context).indexedPort(index + 1), - false), - )) - .toList(), - Expanded( - child: _portWidget( - context, - state.wanPortConnection == 'None' - ? null - : state.wanPortConnection, - loc(context).wan, - true), - ) - ], - ), - ), - ], - )), - ); - } - - Widget _portWidget( - BuildContext context, String? connection, String label, bool isWan) { - final isMobile = context.isMobileLayout; - final portLabel = [ - AppIcon.font( - connection == null - ? AppFontIcons.circle - : AppFontIcons.checkCircleFilled, - color: connection == null - ? Theme.of(context).colorScheme.surfaceContainerHighest - : Theme.of(context).extension()?.semanticSuccess, - ), - AppGap.sm(), - AppText.labelMedium(label), - ]; - return Column( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Wrap( - // mainAxisSize: MainAxisSize.min, - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, + return AppCard( + key: const ValueKey('portCard'), + padding: EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - isMobile - ? Column( - children: portLabel, - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: portLabel, - ) - ], - ), - Padding( - padding: const EdgeInsets.all(AppSpacing.sm), - child: SizedBox( - width: 40, - height: 40, - child: connection == null - ? Assets.images.imgPortOff.svg() - : Assets.images.imgPortOn.svg(), - ), - ), - if (connection != null) - Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xxl, + ), + child: Row( + // mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - AppIcon.font( - AppFontIcons.bidirectional, - color: Theme.of(context).colorScheme.primary, + ...state.lanPortConnections + .mapIndexed((index, e) => Expanded( + child: PortStatusWidget( + connection: e == 'None' ? null : e, + label: loc(context).indexedPort(index + 1), + isWan: false, + hasLanPorts: true, // Force vertical layout + ), + )) + .toList(), + Expanded( + child: PortStatusWidget( + connection: state.wanPortConnection == 'None' + ? null + : state.wanPortConnection, + label: loc(context).wan, + isWan: true, + hasLanPorts: true, // Force vertical layout + ), ), - AppText.bodySmall(connection), ], ), - SizedBox( - width: 70, - child: FittedBox( - fit: BoxFit.scaleDown, - child: AppText.bodySmall( - loc(context).connectedSpeed, - textAlign: TextAlign.center, - maxLines: 2, - ), - ), - ) - ], - ), - if (isWan) AppText.labelMedium(loc(context).internet), - ], - ); + ), + ], + )); } Widget _headerWidget(String title, [Widget? action]) { @@ -1039,8 +963,7 @@ class _InstantVerifyViewState extends ConsumerState title: loc(context).ping, icon: Icons.radio_button_checked, onTap: () { - doSomethingWithSpinner( - context, _showPingNetworkModal(context, ref)); + _showPingNetworkModal(context, ref); }, ), AppGap.lg(), @@ -1050,7 +973,7 @@ class _InstantVerifyViewState extends ConsumerState title: loc(context).traceroute, icon: Icons.route, onTap: () { - doSomethingWithSpinner(context, _showTracerouteModal(context, ref)); + _showTracerouteModal(context, ref); }, ), ], diff --git a/lib/page/nodes/views/node_detail_view.dart b/lib/page/nodes/views/node_detail_view.dart index 2cfab7482..77915e085 100644 --- a/lib/page/nodes/views/node_detail_view.dart +++ b/lib/page/nodes/views/node_detail_view.dart @@ -169,6 +169,8 @@ class _NodeDetailViewState extends ConsumerState child: infoTab(state), ), SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, vertical: AppSpacing.sm), child: deviceTab( state.deviceId, filteredDeviceList, @@ -228,6 +230,7 @@ class _NodeDetailViewState extends ConsumerState backgroundColor: Theme.of(context).colorScheme.surface, builder: (controller) { return Container( + clipBehavior: Clip.none, constraints: BoxConstraints( minWidth: context.colWidth(3), maxWidth: context.colWidth(7)), diff --git a/lib/page/vpn/views/vpn_status_tile.dart b/lib/page/vpn/views/vpn_status_tile.dart index 0aeff05ef..8f98153ec 100644 --- a/lib/page/vpn/views/vpn_status_tile.dart +++ b/lib/page/vpn/views/vpn_status_tile.dart @@ -10,7 +10,7 @@ import 'package:privacy_gui/page/vpn/providers/vpn_notifier.dart'; import 'package:privacy_gui/page/vpn/views/shared_widgets.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/utils.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; +import 'package:privacy_gui/page/dashboard/views/components/_components.dart'; import 'package:ui_kit_library/ui_kit.dart'; class VPNStatusTile extends ConsumerStatefulWidget { diff --git a/lib/page/wifi_settings/views/advanced/wifi_advanced_settings_view.dart b/lib/page/wifi_settings/views/advanced/wifi_advanced_settings_view.dart index f8defade8..867dd7579 100644 --- a/lib/page/wifi_settings/views/advanced/wifi_advanced_settings_view.dart +++ b/lib/page/wifi_settings/views/advanced/wifi_advanced_settings_view.dart @@ -42,7 +42,7 @@ class WifiAdvancedSettingsView extends ConsumerWidget { child: MasonryGridView.count( crossAxisCount: context.isMobileLayout ? 1 : 2, mainAxisSpacing: AppSpacing.sm, - crossAxisSpacing: context.colWidth(1), + crossAxisSpacing: context.layoutGutter, itemCount: advancedSettingWidgets.length, itemBuilder: (context, index) => advancedSettingWidgets[index], shrinkWrap: true, diff --git a/lib/page/wifi_settings/views/mac_filter/mac_filtered_devices_view.dart b/lib/page/wifi_settings/views/mac_filter/mac_filtered_devices_view.dart index d5a29d050..a11f663e8 100644 --- a/lib/page/wifi_settings/views/mac_filter/mac_filtered_devices_view.dart +++ b/lib/page/wifi_settings/views/mac_filter/mac_filtered_devices_view.dart @@ -41,17 +41,21 @@ class _FilteredDevicesViewState extends ConsumerState { return UiKitPageView.withSliver( title: loc(context).filteredDevices, - actions: [ - AppButton.text( - label: loc(context).edit, - icon: AppIcon.font(AppFontIcons.edit), - onTap: state.current.privacy.denyMacAddresses.isNotEmpty - ? () { - _toggleEdit(); - } - : null, - ) - ], + menu: UiKitMenuConfig( + title: '', + items: [ + UiKitMenuItem( + label: loc(context).edit, + icon: AppFontIcons.edit, + onTap: state.current.privacy.denyMacAddresses.isNotEmpty + ? () { + _toggleEdit(); + } + : null, + ), + ], + ), + menuPosition: MenuPosition.top, bottomBar: _isEdit ? UiKitBottomBarConfig( positiveLabel: loc(context).remove, @@ -147,77 +151,64 @@ class _FilteredDevicesViewState extends ConsumerState { ), ), ) - : SizedBox( - height: 76.0 * state.length + AppSpacing.sm * state.length, - child: ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: state.length, - itemBuilder: (context, index) { - final device = state[index]; - return SizedBox( - height: 76, - child: Container( - decoration: _selectedMACs.contains(device.macAddress) - ? BoxDecoration( - color: - Theme.of(context).colorScheme.primaryContainer, - border: Border.all( - color: Theme.of(context).colorScheme.primary, - ), - borderRadius: BorderRadius.circular(8), - ) - : null, - child: AppCard( - onTap: _isEdit - ? () { - setState(() { - if (_selectedMACs.contains(device.macAddress)) { - _selectedMACs.remove(device.macAddress); - } else { - _selectedMACs.add(device.macAddress); - } - }); - } - : null, - padding: const EdgeInsets.all(AppSpacing.lg), - child: Row( + : ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: state.length, + itemBuilder: (context, index) { + final device = state[index]; + final isSelected = _selectedMACs.contains(device.macAddress); + return AppCard( + isSelected: isSelected, + onTap: _isEdit + ? () { + setState(() { + if (_selectedMACs.contains(device.macAddress)) { + _selectedMACs.remove(device.macAddress); + } else { + _selectedMACs.add(device.macAddress); + } + }); + } + : null, + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + if (_isEdit) ...[ + IgnorePointer( + child: AppCheckbox( + value: _selectedMACs.contains(device.macAddress), + onChanged: (value) {}, + ), + ), + AppGap.lg(), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - if (_isEdit) ...[ - IgnorePointer( - child: AppCheckbox( - value: - _selectedMACs.contains(device.macAddress), - onChanged: (value) {}, - ), - ), - AppGap.lg(), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppText.labelLarge(device.name), - AppGap.xs(), - AppText.bodyMedium(device.macAddress), - ], - ), + AppText.labelLarge( + device.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + AppGap.xs(), + AppText.bodyMedium(device.macAddress), ], ), ), - ), - ); - }, - separatorBuilder: (BuildContext context, int index) { - if (index != state.length - 1) { - return AppGap.sm(); - } else { - return const Center(); - } - }, - ), + ], + ), + ); + }, + separatorBuilder: (BuildContext context, int index) { + if (index != state.length - 1) { + return AppGap.sm(); + } else { + return const Center(); + } + }, ); } @@ -230,33 +221,31 @@ class _FilteredDevicesViewState extends ConsumerState { title: loc(context).macAddress, contentBuilder: (context, setState, onSubmit) => Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( - children: [ - AppTextFormField( - key: const Key('macAddressTextField'), - controller: controller, - label: loc(context).macAddress, - onChanged: (text) { - setState(() { - isValid = InputValidator([MACAddressRule()]) - .validate(controller.text); - isDuplicate = ref - .read(macFilteringDeviceListProvider) - .any((device) => device.macAddress == controller.text); - }); - }, + AppMacAddressTextField( + key: const Key('macAddressTextField'), + controller: controller, + label: loc(context).macAddress, + onChanged: (text) { + setState(() { + isValid = InputValidator([MACAddressRule()]) + .validate(controller.text); + isDuplicate = ref + .read(macFilteringDeviceListProvider) + .any((device) => device.macAddress == controller.text); + }); + }, + invalidFormatMessage: loc(context).invalidMACAddress, + ), + if (!isValid || isDuplicate) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: AppText.bodySmall( + loc(context).invalidMACAddress, + color: Theme.of(context).colorScheme.error, ), - if (!isValid || isDuplicate) - Padding( - padding: const EdgeInsets.only(top: AppSpacing.xs), - child: AppText.bodySmall( - loc(context).invalidMACAddress, - color: Theme.of(context).colorScheme.error, - ), - ), - ], - ) + ) ], ), event: () async { diff --git a/lib/page/wifi_settings/views/mac_filter/mac_filtering_view.dart b/lib/page/wifi_settings/views/mac_filter/mac_filtering_view.dart index 1817dc156..e6af0b587 100644 --- a/lib/page/wifi_settings/views/mac_filter/mac_filtering_view.dart +++ b/lib/page/wifi_settings/views/mac_filter/mac_filtering_view.dart @@ -138,6 +138,7 @@ class MacFilteringView extends ConsumerWidget { Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ AppText.labelLarge( loc(context).nDevices(length).capitalizeWords()), diff --git a/lib/page/wifi_settings/views/main/wifi_list_advanced_mode_view.dart b/lib/page/wifi_settings/views/main/wifi_list_advanced_mode_view.dart index a2a214971..e78b6c8c2 100644 --- a/lib/page/wifi_settings/views/main/wifi_list_advanced_mode_view.dart +++ b/lib/page/wifi_settings/views/main/wifi_list_advanced_mode_view.dart @@ -76,6 +76,7 @@ class AdvancedModeView extends ConsumerWidget { } return Table( + defaultVerticalAlignment: TableCellVerticalAlignment.intrinsicHeight, columnWidths: Map.fromEntries( List.generate(columnCount, (index) => index).map((e) => e == columnCount - 1 diff --git a/lib/page/wifi_settings/views/main/wifi_main_view.dart b/lib/page/wifi_settings/views/main/wifi_main_view.dart index d6372c4f5..f5360a1fd 100644 --- a/lib/page/wifi_settings/views/main/wifi_main_view.dart +++ b/lib/page/wifi_settings/views/main/wifi_main_view.dart @@ -168,20 +168,19 @@ class _WiFiMainViewState extends ConsumerState newState.current.wifiList.copyWith(mainWiFi: wifiListSettings); final result = await showSimpleAppDialog(context, title: loc(context).wifiListSaveModalTitle, - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.bodyMedium(loc(context).wifiListSaveModalDesc), - ..._mloWarning(previewState), - ..._disableBandWarning(previewState), - AppGap.lg(), - ..._buildNewSettings(previewState), - AppGap.lg(), - AppText.bodyMedium(loc(context).doYouWantToContinue), - ], - ), + scrollable: true, + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.bodyMedium(loc(context).wifiListSaveModalDesc), + ..._mloWarning(previewState), + ..._disableBandWarning(previewState), + AppGap.lg(), + ..._buildNewSettings(previewState), + AppGap.lg(), + AppText.bodyMedium(loc(context).doYouWantToContinue), + ], ), actions: [ AppButton.text( diff --git a/lib/page/wifi_settings/views/widgets/main_wifi_card.dart b/lib/page/wifi_settings/views/widgets/main_wifi_card.dart index 6599b5b78..d565c578f 100644 --- a/lib/page/wifi_settings/views/widgets/main_wifi_card.dart +++ b/lib/page/wifi_settings/views/widgets/main_wifi_card.dart @@ -174,7 +174,11 @@ class _MainWiFiCardState extends ConsumerState key: Key('wifiWirelessModeCard-${radio.radioID.value}'), title: AppText.bodyMedium(loc(context).wifiMode), description: AppText.labelLarge( - getWifiWirelessModeTitle(context, radio.wirelessMode, null)), + getWifiWirelessModeTitle(context, radio.wirelessMode, null), + maxLines: 2, + minLines: 2, + overflow: TextOverflow.ellipsis, + ), trailing: const AppIcon.font(AppFontIcons.edit), onTap: () { final availableModes = radio.availableWirelessModes diff --git a/pubspec.yaml b/pubspec.yaml index 2905c0f47..8f607e774 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,11 +68,13 @@ dependencies: ui_kit_library: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.10.1 + ref: v2.10.3 + # ui_kit_library: + # path: ../../ui_kit generative_ui: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.10.1 + ref: v2.10.3 path: generative_ui flutter_blue_plus: ^1.4.0 crypto: ^3.0.2 diff --git a/test/page/dashboard/localizations/dashboard_home_view_test.dart b/test/page/dashboard/localizations/dashboard_home_view_test.dart index 258f855b0..50929a3d2 100644 --- a/test/page/dashboard/localizations/dashboard_home_view_test.dart +++ b/test/page/dashboard/localizations/dashboard_home_view_test.dart @@ -8,11 +8,11 @@ import 'package:privacy_gui/core/data/providers/dashboard_manager_state.dart'; import 'package:privacy_gui/core/data/providers/firmware_update_state.dart'; import 'package:privacy_gui/core/data/providers/node_internet_status_provider.dart'; import 'package:privacy_gui/page/dashboard/_dashboard.dart'; -import 'package:privacy_gui/page/dashboard/views/components/home_title.dart'; -import 'package:privacy_gui/page/dashboard/views/components/networks.dart'; -import 'package:privacy_gui/page/dashboard/views/components/port_and_speed.dart'; -import 'package:privacy_gui/page/dashboard/views/components/quick_panel.dart'; -import 'package:privacy_gui/page/dashboard/views/components/wifi_grid.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/home_title.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/networks.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/port_and_speed.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/quick_panel.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/wifi_grid.dart'; import 'package:privacy_gui/page/health_check/providers/health_check_state.dart'; import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; import 'package:privacy_gui/route/route_model.dart'; diff --git a/test/page/dashboard/views/components/loading_tile_test.dart b/test/page/dashboard/views/components/loading_tile_test.dart index 4b9df57b0..85846b127 100644 --- a/test/page/dashboard/views/components/loading_tile_test.dart +++ b/test/page/dashboard/views/components/loading_tile_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/loading_tile.dart'; import 'package:ui_kit_library/ui_kit.dart'; void main() {