From 9316e31baee0acdee1d7371e854de741cce224b8 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Mon, 5 Jan 2026 16:49:11 +0800 Subject: [PATCH 01/26] fix: resolve all flutter analyze warnings and regenerate mocks --- .../architecture_analysis_2026-01-05.md | 727 ++++++++++++++++++ .../http/transaction_http_command.dart | 4 +- lib/core/jnap/models/wirless_connection.dart | 2 +- lib/core/usp/jnap_tr181_mapper.dart | 22 +- .../internet_settings_form_validator.dart | 12 +- .../views/ipv4_connection_view.dart | 1 - .../views/ipv6_connection_view.dart | 1 - .../views/release_and_renew_view.dart | 1 - .../widgets/optional_settings_form.dart | 1 - .../wan_forms/ipv6/automatic_ipv6_form.dart | 1 - .../widgets/wan_forms/pppoe_form.dart | 6 +- .../widgets/wan_forms/pptp_form.dart | 1 - .../views/manual_firmware_update_view.dart | 2 +- .../services/instant_safety_service.dart | 2 + .../providers/pnp_isp_settings_provider.dart | 1 - .../pnp_isp_type_selection_view.dart | 1 - .../views/isp_settings/pnp_pppoe_view.dart | 1 - .../isp_settings/pnp_static_ip_view.dart | 1 - lib/route/route_add_nodes.dart | 2 +- lib/route/route_cloud_login.dart | 2 +- lib/route/route_dashboard.dart | 2 +- lib/route/route_home.dart | 2 +- lib/route/route_pnp.dart | 2 +- lib/util/extensions.dart | 2 +- lib/utils.dart | 2 +- .../src/services/usp_topology_service.dart | 2 +- .../src/converter/usp_protobuf_converter.dart | 7 - .../firmware_update_service_test.dart | 8 +- test/mocks/auth_notifier_mocks.dart | 415 ++++++---- test/mocks/dashboard_home_notifier_mocks.dart | 49 +- .../dashboard_manager_notifier_mocks.dart | 110 +-- test/mocks/device_manager_notifier_mocks.dart | 94 +-- .../internet_settings_notifier_mocks.dart | 126 +-- test/mocks/node_detail_notifier_mocks.dart | 107 +-- test/mocks/polling_notifier_mocks.dart | 29 +- .../test_data/dashboard_home_test_data.dart | 2 +- .../dashboard_manager_test_data.dart | 4 +- test/mocks/test_data/ddns_test_data.dart | 6 +- test/mocks/test_data/polling_test_data.dart | 4 +- .../port_range_forwarding_test_data.dart | 6 +- .../port_range_triggering_test_data.dart | 2 +- .../single_port_forwarding_test_data.dart | 2 +- .../test_data/static_routing_test_data.dart | 1 - .../ddns/providers/ddns_provider_test.dart | 2 +- .../port_range_forwarding_service_test.dart | 10 +- .../port_range_triggering_service_test.dart | 10 +- .../providers/dmz_settings_state_test.dart | 14 +- .../dmz/providers/dmz_status_test.dart | 2 +- .../services/dmz_settings_service_test.dart | 5 +- .../dmz_settings_service_test_data.dart | 2 +- .../providers/firewall_provider_test.dart | 1 - .../providers/firewall_state_test.dart | 14 +- .../ipv6_port_service_list_provider_test.dart | 12 +- .../ipv6_port_service_rule_state_test.dart | 6 +- .../firewall_settings_service_test.dart | 2 - .../ipv6_port_service_list_service_test.dart | 70 +- ...6_port_service_list_service_test_data.dart | 4 +- .../internet_settings_ui_model_test.dart | 8 +- .../internet_settings_service_test.dart | 32 +- .../internet_settings_view_test.dart | 1 - .../dhcp_reservations_provider_test.dart | 2 +- .../dhcp_reservations_state_test.dart | 1 - .../local_network_settings_state_test.dart | 2 - .../static_route_entry_ui_model_test.dart | 2 +- .../static_routing_rule_ui_model_test.dart | 2 +- .../static_routing_rule_provider_test.dart | 2 - .../providers/static_routing_state_test.dart | 13 +- .../services/static_routing_service_test.dart | 1 - .../localizations/snack_bar_test.dart | 3 +- .../views/components/loading_tile_test.dart | 1 - .../providers/health_check_provider_test.dart | 2 +- .../manual_firmware_update_provider_test.dart | 10 +- .../providers/timezone_state_test.dart | 1 - .../router_password_service_test.dart | 1 - .../services/timezone_service_test.dart | 6 +- .../providers/instant_privacy_state_test.dart | 4 +- .../services/instant_safety_service_test.dart | 6 +- .../instant_setup/pnp_step_state_test.dart | 2 +- .../pnp_isp_settings_provider_test.dart | 1 - .../services/pnp_isp_service_test.dart | 10 +- .../models/instant_verify_ui_models_test.dart | 8 +- .../instant_verify_provider_test.dart | 2 +- .../providers/instant_verify_state_test.dart | 4 +- .../providers/wifi_advanced_state_test.dart | 2 +- .../providers/wifi_state_test.dart | 4 +- .../services/channel_finder_service_test.dart | 8 +- .../input_validators_test.dart | 46 +- test_scripts/test_result_parser.dart | 2 + ...generate_screenshot_test_cases_report.dart | 8 +- tools/remove_unused_strings.dart | 2 + tools/run_screenshot_tests.dart | 2 + 91 files changed, 1394 insertions(+), 736 deletions(-) create mode 100644 doc/analysis/architecture_analysis_2026-01-05.md diff --git a/doc/analysis/architecture_analysis_2026-01-05.md b/doc/analysis/architecture_analysis_2026-01-05.md new file mode 100644 index 000000000..972169c8d --- /dev/null +++ b/doc/analysis/architecture_analysis_2026-01-05.md @@ -0,0 +1,727 @@ +# PrivacyGUI 專案架構分析報告 + +## 執行摘要 + +對 PrivacyGUI 專案進行了全面的架構分析,評估模組間的解耦狀況。專案整體架構設計良好,但存在若干需要改進的耦合問題。 + +--- + +## 1. 專案結構概覽 + +``` +lib/ +├── ai/ # AI 助手模組 (15 files) ✅ 解耦良好 +├── core/ # 核心基礎設施 (170 files) +│ ├── bluetooth/ # 藍牙連接 +│ ├── cache/ # 快取機制 +│ ├── cloud/ # 雲端服務 (31 files) +│ ├── http/ # HTTP 客戶端 +│ ├── jnap/ # JNAP 協議層 (99 files) ⚠️ 重度依賴 +│ ├── usp/ # USP 協議層 (11 files) +│ └── utils/ # 工具類 +├── page/ # UI 頁面模組 (391 files) +│ ├── advanced_settings/ # 136 files +│ ├── dashboard/ # 21 files +│ ├── wifi_settings/ # 36 files +│ └── ... (18 more feature modules) +├── providers/ # 全局狀態管理 (25 files) +└── route/ # 路由配置 (14 files) + +packages/ +├── usp_client_core/ # USP 協議核心 (獨立 package) +└── usp_protocol_common/ # USP 協議共享 (獨立 package) +``` + +--- + +## 2. 架構層次分析 + +### 2.1 理想的 4 層架構 (已文件化於 specs/002-dmz-refactor/) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Data Layer (core/jnap/models/) │ +│ - JNAP domain models │ +│ - Protocol serialization (toMap/fromMap) │ +└────────────────────┬────────────────────────────────────────┘ + │ Only Service knows about these + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Service Layer (page/*/services/) │ +│ - Converts Data models ↔ UI models │ +│ - All protocol handling │ +└────────────────────┬────────────────────────────────────────┘ + │ Service returns only UI models + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Application Layer (page/*/providers/) │ +│ - UI-specific models │ +│ - Riverpod state management │ +└────────────────────┬────────────────────────────────────────┘ + │ Only UI models exposed + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ Presentation Layer (page/*/views/) │ +│ - Flutter widgets │ +│ - Only knows about UI models │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. 解耦狀況評估 + +### ✅ 良好的解耦實踐 + +#### 3.1 AI 模組 (`lib/ai/`) +- **評分: 優秀** +- 使用 `IRouterCommandProvider` 抽象介面 +- 遵循 MCP (Model Context Protocol) 模式 +- 支援 JNAP 和 USP 的多實現切換 + +```dart +// lib/ai/abstraction/i_router_command_provider.dart +abstract class IRouterCommandProvider { + Future> listCommands(); // ≈ MCP tools/list + Future execute(...); // ≈ MCP tools/call + List listResources(); // ≈ MCP resources/list + Future readResource(...); // ≈ MCP resources/read +} +``` + +#### 3.2 USP 協議層 (`packages/`) +- **評分: 優秀** +- 獨立的 Flutter package +- 與主專案通過 `core/usp/` 橋接 +- 支援協議切換而不影響上層 + +#### 3.3 DMZ 設定模組 (`page/advanced_settings/dmz/`) +- **評分: 優秀** +- 嚴格遵循 4 層架構 +- 有 `DMZUISettings` 等 UI 專用模型 +- Service 層負責所有模型轉換 + +--- + +### ⚠️ 需要改進的耦合問題 + +#### 3.4 Provider 層直接引用 Data 模型 (詳細分析) + +**問題**: Provider 層直接 import `core/jnap/models/`,違反層次分離原則。 + +--- + +##### 3.4.1 `auto_parent_first_login_provider.dart` + +**檔案位置**: `lib/page/login/auto_parent/providers/auto_parent_first_login_provider.dart` + +**違規導入**: +```dart +import 'package:privacy_gui/core/jnap/models/firmware_update_settings.dart'; +``` + +**問題程式碼** (第 56-87 行): +```dart +Future setFirmwareUpdatePolicy() async { + final repo = ref.read(routerRepositoryProvider); + final firmwareUpdateSettings = await repo + .send(JNAPAction.getFirmwareUpdateSettings, ...) + .then((value) => value.output) + .then( + (output) => FirmwareUpdateSettings.fromMap(output).copyWith( + updatePolicy: FirmwareUpdateSettings.firmwareUpdatePolicyAuto), + ); + // ... + repo.send(JNAPAction.setFirmwareUpdateSettings, + data: firmwareUpdateSettings.toMap(), ...); +} +``` + +**問題分析**: +- Provider 直接調用 `FirmwareUpdateSettings.fromMap()` 反序列化 JNAP 響應 +- Provider 直接調用 `.toMap()` 序列化回 JNAP 格式 +- 這些是 Data 層的協議細節,不應暴露給 Application 層 + +**修復建議**: +1. 創建 UI 模型 `FirmwareUpdatePolicyUI` +2. 在新建的 `AutoParentFirstLoginService` 中處理轉換 +3. Provider 只調用 Service 方法 + +```dart +// 新增: lib/page/login/auto_parent/services/auto_parent_first_login_service.dart +class AutoParentFirstLoginService { + Future setAutoFirmwareUpdatePolicy(Ref ref) async { + final repo = ref.read(routerRepositoryProvider); + final currentSettings = await repo.send(JNAPAction.getFirmwareUpdateSettings, ...); + + // 在 Service 層處理 Data 模型 + final updated = FirmwareUpdateSettings.fromMap(currentSettings.output) + .copyWith(updatePolicy: FirmwareUpdateSettings.firmwareUpdatePolicyAuto); + + await repo.send(JNAPAction.setFirmwareUpdateSettings, data: updated.toMap()); + } +} +``` + +--- + +##### 3.4.2 `add_nodes_provider.dart` + +**檔案位置**: `lib/page/nodes/providers/add_nodes_provider.dart` + +**違規導入**: +```dart +import 'package:privacy_gui/core/jnap/models/back_haul_info.dart'; +``` + +**問題程式碼** (第 115, 226-271 行): +```dart +List backhaulInfoList = []; // 直接使用 Data 模型 + +Stream> pollNodesBackhaulInfo(...) { + return repo.scheduledCommand(...) + .transform( + StreamTransformer>.fromHandlers( + handleData: (result, sink) { + final backhaulList = List.from(result.output['backhaulDevices'] ?? []) + .map((e) => BackHaulInfoData.fromMap(e)).toList(); // 直接反序列化 + sink.add(backhaulList); + }, + ), + ); +} +``` + +**問題分析**: +- `BackHaulInfoData` 是 JNAP 協議的 Data 模型 +- Provider 直接處理 Stream 轉換和反序列化 +- `collectChildNodeData()` 方法直接操作 `BackHaulInfoData` + +**修復建議**: +1. 創建 UI 模型 `BackhaulInfoUI` 在 `lib/page/nodes/providers/add_nodes_state.dart` +2. 創建 `AddNodesService` 處理 JNAP 調用和模型轉換 +3. Provider 只持有 `BackhaulInfoUI` 列表 + +```dart +// 新增: lib/page/nodes/models/backhaul_info_ui.dart +class BackhaulInfoUI { + final String deviceUUID; + final String connectionType; + final WirelessConnectionInfo? wirelessInfo; + // ... 只包含 UI 需要的欄位 +} + +// 新增: lib/page/nodes/services/add_nodes_service.dart +class AddNodesService { + Stream> pollNodesBackhaulInfo(List nodes) { + // 在 Service 層處理 BackHaulInfoData 轉換 + } +} +``` + +--- + +##### 3.4.3 `add_wired_nodes_provider.dart` + +**檔案位置**: `lib/page/nodes/providers/add_wired_nodes_provider.dart` + +**違規導入**: +```dart +import 'package:privacy_gui/core/jnap/models/back_haul_info.dart'; +``` + +**問題程式碼** (第 145-204 行): +```dart +Stream pollBackhaulInfo(BuildContext context, [bool refreshing = false]) { + // ... + condition: (result) { + final backhaulInfoList = List.from(result.output['backhaulDevices'] ?? []) + .map((e) => BackHaulInfoData.fromMap(e)).toList(); // 直接反序列化 + // ... + }, +} +``` + +**問題分析**: +- 與 `add_nodes_provider.dart` 類似的問題 +- 存在重複的 `BackHaulInfoData.fromMap()` 調用 +- 兩個 Provider 有潛在的代碼重複 + +**修復建議**: +1. 重用 `add_nodes_provider.dart` 的解決方案 +2. 考慮合併共享的 backhaul 邏輯到統一的 Service +3. 創建 `lib/page/nodes/services/backhaul_service.dart` 處理所有 backhaul 相關邏輯 + +--- + +##### 3.4.4 `pnp_provider.dart` + +**檔案位置**: `lib/page/instant_setup/providers/pnp_provider.dart` + +**違規導入**: +```dart +import 'package:privacy_gui/core/jnap/models/auto_configuration_settings.dart'; +``` + +**問題程式碼** (第 140-143, 452-457 行): +```dart +// 抽象方法定義 +Future autoConfigurationCheck(); + +// 實現 +Future autoConfigurationCheck() { + final pnpService = ref.read(pnpServiceProvider); + return pnpService.autoConfigurationCheck(); // Service 返回 Data 模型 +} +``` + +**問題分析**: +- `AutoConfigurationSettings` 是 Data 模型,但被用作方法返回類型 +- 雖然 Provider 委託給 Service,但 Service 仍返回 Data 模型穿透了層次邊界 +- 這違反了「Service 只返回 UI 模型」的原則 + +**修復建議**: +1. 創建 `AutoConfigurationUI` 或簡化的 enum/record 類型 +2. 修改 `PnpService.autoConfigurationCheck()` 返回 UI 模型 +3. 將 `ConfigurationResult` 擴展以包含所有必要資訊 + +```dart +// 修改: lib/page/instant_setup/services/pnp_service.dart +Future autoConfigurationCheck() async { + final result = await _fetchAutoConfigSettings(); + return AutoConfigurationUI( + isConfigured: result.isConfigured, + passwordToUse: result.adminPassword, + // ... 只暴露 UI 需要的欄位 + ); +} +``` + +--- + +##### 3.4.5 `mock_pnp_providers.dart` + +**檔案位置**: `lib/page/instant_setup/providers/mock_pnp_providers.dart` + +**違規導入**: +```dart +import 'package:privacy_gui/core/jnap/models/auto_configuration_settings.dart'; +``` + +**問題分析**: +- 這是測試/Demo 用的 Mock 實現 +- 需要返回與主 Provider 相同的類型 +- 當主 Provider 修復後,此檔案需同步更新 + +--- + +#### 3.5 跨頁面 Provider 依賴 (詳細分析) + +**問題**: 頁面模組之間的 Provider 直接依賴形成了複雜的依賴網絡。 + +--- + +##### 3.5.1 依賴圖譜 + +```mermaid +graph LR + subgraph wifi_settings["wifi_settings/"] + WBP[wifi_bundle_provider] + CFP[channelfinder_provider] + DMP2[displayed_mac_filtering_devices_provider] + end + + subgraph dashboard["dashboard/"] + DHP[dashboard_home_provider] + end + + subgraph instant_device["instant_device/"] + DLP[device_list_provider] + DFLP[device_filtered_list_provider] + EDDP[external_device_detail_provider] + end + + subgraph instant_privacy["instant_privacy/"] + IPP[instant_privacy_provider] + IPDL[instant_privacy_device_list_provider] + end + + subgraph health_check["health_check/"] + HCP[health_check_provider] + end + + subgraph instant_topology["instant_topology/"] + ITP[instant_topology_provider] + end + + subgraph nodes["nodes/"] + NDP[node_detail_provider] + ANP[add_nodes_provider] + AWNP[add_wired_nodes_provider] + end + + WBP --> DHP + WBP --> IPP + DHP --> HCP + DFLP --> WBP + DMP2 --> WBP + IPDL --> IPP + NDP --> DLP +``` + +--- + +##### 3.5.2 關鍵耦合熱點分析 + +**熱點 1: `wifi_bundle_provider.dart` (高風險)** + +```dart +// 當前導入 +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; +``` + +**耦合原因分析** (第 35-81 行): +```dart +WifiBundleState build() { + final dashboardManagerState = ref.read(dashboardManagerProvider); // ✓ OK - core provider + final deviceManagerState = ref.read(deviceManagerProvider); // ✓ OK - core provider + final homeState = ref.read(dashboardHomeProvider); // ✗ 跨頁面依賴 + + final initialWifiListStatus = WiFiListStatus( + canDisableMainWiFi: homeState.lanPortConnections.isNotEmpty); // 需要 dashboard 狀態 + // ... + final initialPrivacySettings = InstantPrivacySettings.init(); // ✗ 引用 privacy 的 State +} +``` + +**問題**: +- 需要 `lanPortConnections` 來決定 WiFi 禁用能力 +- 直接引用 `InstantPrivacySettings` 類型 + +**修復建議**: +```dart +// 方案 A: 提取共享狀態到 core +// lib/core/jnap/providers/connectivity_status_provider.dart +final connectivityStatusProvider = Provider((ref) { + final dashboardState = ref.watch(dashboardManagerProvider); + return ConnectivityStatus( + hasLanConnections: dashboardState.lanPortConnections.isNotEmpty, + // ... 其他共享狀態 + ); +}); + +// 方案 B: 使用依賴注入傳遞必要資訊 +// wifi_bundle_provider.dart +WifiBundleState build() { + final hasLanConnections = ref.read(connectivityStatusProvider).hasLanConnections; + // ... +} +``` + +--- + +**熱點 2: `dashboard_home_provider.dart` → `health_check_provider.dart`** + +```dart +// lib/page/dashboard/providers/dashboard_home_provider.dart +import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; + +class DashboardHomeNotifier extends Notifier { + @override + DashboardHomeState build() { + // Watch healthCheckProvider to maintain reactivity + ref.watch(healthCheckProvider); // ✗ 跨頁面依賴 + // ... + } +} +``` + +**問題分析**: +- Dashboard 需要知道 HealthCheck 的狀態來顯示速度測試結果 +- 這是 UI 層級的數據共享需求 + +**修復建議**: +```dart +// 方案: 將 HealthCheck 結果提取到共享層 +// lib/providers/network_health_provider.dart +final networkHealthProvider = Provider((ref) { + // 監聽底層數據,提供給多個頁面使用 + final speedTestResult = ref.watch(_speedTestResultProvider); + return NetworkHealthState(lastSpeedTest: speedTestResult); +}); +``` + +--- + +**熱點 3: `device_filtered_list_provider.dart` → `wifi_bundle_provider.dart`** + +```dart +import 'package:privacy_gui/page/wifi_settings/providers/wifi_bundle_provider.dart'; +``` + +**問題分析**: +- `device_filtered_list` 需要 WiFi 資訊來過濾裝置 +- 這創建了 `instant_device` ↔ `wifi_settings` 的雙向依賴風險 + +**修復建議**: +- 將 WiFi 狀態的「裝置可見」部分提取到 `core/jnap/providers/` +- 或創建專門的 `device_wifi_binding_provider.dart` 在 `lib/providers/` + +--- + +##### 3.5.3 跨頁面依賴完整清單 + +| 來源 Provider | 目標 Provider | 耦合類型 | 風險等級 | +|--------------|--------------|----------|----------| +| `wifi_bundle_provider` | `dashboard_home_provider` | 狀態讀取 | 🔴 高 | +| `wifi_bundle_provider` | `instant_privacy_state` | 類型引用 | 🟡 中 | +| `dashboard_home_provider` | `health_check_provider` | 反應式監聽 | 🔴 高 | +| `device_filtered_list_provider` | `wifi_bundle_provider` | 狀態讀取 | 🟡 中 | +| `displayed_mac_filtering_devices_provider` | `wifi_bundle_provider` | 狀態讀取 | 🟡 中 | +| `instant_privacy_device_list_provider` | `instant_privacy_provider` | 同模組 | 🟢 低 | +| `node_detail_provider` | `device_list_provider` | 數據共享 | 🟡 中 | + +--- + +##### 3.5.4 核心 Provider 的合理引用 + +以下導入被認為是**合理的**,因為它們引用的是 `core/jnap/providers/` 中的共享全局狀態: + +| 被引用的 Core Provider | 引用來源 (page/*) | 用途 | +|-----------------------|------------------|------| +| `dashboardManagerProvider` | 6 個頁面 | 全局 Dashboard 狀態 | +| `deviceManagerProvider` | 11 個頁面 | 裝置列表管理 | +| `pollingProvider` | 9 個頁面 | 輪詢控制 | +| `firmwareUpdateProvider` | 2 個頁面 | 韌體更新狀態 | +| `wanExternalProvider` | 1 個頁面 | WAN 狀態 | + +**這些都是設計良好的共享狀態**,應保持這種模式,但需確保: +- 這些 Provider 不直接返回 Data 模型 +- 它們提供的是 UI 友好的狀態抽象 + +--- + +#### 3.6 巨型檔案 + +| 檔案 | 大小 | 問題 | +|------|------|------| +| `core/usp/jnap_tr181_mapper.dart` | 42.5KB | JNAP↔TR-181 映射邏輯過於集中 | +| `route/router_provider.dart` | 19.8KB | 路由邏輯與認證邏輯混合 | +| `core/jnap/router_repository.dart` | 15.6KB | 多種命令類型處理混合 | +| `core/cloud/linksys_cloud_repository.dart` | 16KB | 雲端功能過於集中 | + +--- + +## 4. 模組間依賴統計 (詳細分析) + +### 4.1 核心模組被引用統計 + +#### 4.1.1 `core/jnap/providers/` 被引用分布 + +``` +dashboardManagerProvider → 6 files (wifi_settings, instant_device, dashboard) +deviceManagerProvider → 11 files (節點、設備、WiFi 相關) +pollingProvider → 9 files (需要控制輪詢的功能) +firmwareUpdateProvider → 2 files (topology, login) +device_manager_state → 7 files (使用 LinksysDevice 類型) +side_effect_provider → 1 file +wan_external_provider → 1 file +``` + +#### 4.1.2 `core/jnap/models/` 被違規引用 + +``` +firmware_update_settings.dart → 1 provider ⚠️ +back_haul_info.dart → 2 providers ⚠️ +auto_configuration_settings.dart → 2 providers ⚠️ +``` + +#### 4.1.3 跨頁面 Provider 引用熱度圖 + +``` + 被引用次數 +wifi_bundle_provider ████████ 3次 +dashboard_home_provider ██████ 2次 +device_list_provider ████ 1次 +health_check_provider ████ 1次 +instant_privacy_provider ████ 1次 +``` + +--- + +### 4.2 依賴方向與違規分析 + +```mermaid +graph TD + subgraph Presentation["Presentation Layer"] + Views[page/*/views/] + end + + subgraph Application["Application Layer"] + Providers[page/*/providers/] + SharedProviders[providers/ - 全局共享] + end + + subgraph Service["Service Layer"] + Services[page/*/services/] + CoreServices[core/*/services/] + end + + subgraph Data["Data Layer"] + JnapModels[core/jnap/models/] + CloudModels[core/cloud/model/] + CoreProviders[core/jnap/providers/] + end + + Views --> Providers + Providers --> Services + Providers --> SharedProviders + Providers --> CoreProviders + Providers -.->|⚠️ 5處違規| JnapModels + Providers -.->|⚠️ 7處跨頁面| Providers + Services --> JnapModels + Services --> CloudModels + CoreProviders --> JnapModels + + style JnapModels fill:#ffcccc + style Providers fill:#ffffcc +``` + +--- + +### 4.3 建議的依賴層次結構 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ page/*/views/ │ +│ - 只 import page/*/providers/ 和 UI Kit │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ page/*/providers/ │ +│ - import page/*/services/ │ +│ - import page/*/models/ (UI 模型) │ +│ - import lib/providers/ (全局共享狀態) │ +│ - import core/jnap/providers/ (✓ 合理的共享狀態) │ +│ - ❌ 禁止 import core/jnap/models/ │ +│ - ❌ 禁止 import 其他 page/*/providers/ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ page/*/services/ │ +│ - import core/jnap/models/ (✓ Data 模型處理) │ +│ - import core/jnap/router_repository.dart │ +│ - 負責 Data ↔ UI 模型轉換 │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ lib/providers/ (全局共享) │ +│ - 跨頁面共享的狀態 │ +│ - 例: connectivityStatusProvider, networkHealthProvider │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ core/jnap/providers/ (核心共享狀態) │ +│ - dashboardManagerProvider │ +│ - deviceManagerProvider │ +│ - pollingProvider │ +│ - 這些應該只暴露 UI 友好的狀態 │ +└─────────────────────────────────────────────────────────────────┘ + +``` + +--- + +### 4.4 修復優先級矩陣 + +| 優先級 | 問題 | 影響範圍 | 修復難度 | 建議時程 | +|--------|------|---------|---------|---------| +| P0 | Provider 直接引用 Data 模型 | 5 個檔案 | 中 | 1-2 週 | +| P1 | `wifi_bundle` ↔ `dashboard` 耦合 | 3 個檔案 | 高 | 2-3 週 | +| P2 | `dashboard` → `health_check` 耦合 | 2 個檔案 | 中 | 1 週 | +| P3 | 其他跨頁面依賴 | 5+ 個檔案 | 中 | 持續進行 | +| P4 | 巨型檔案拆分 | 4 個檔案 | 高 | 按需進行 | + +--- + +### 4.5 驗證命令 + +**檢查 Provider 層是否有 Data 模型引用**: +```bash +grep -r "import 'package:privacy_gui/core/jnap/models/" \ + lib/page/*/providers/*.dart +# 預期: 0 個結果 (修復後) +``` + +**檢查跨頁面 Provider 引用**: +```bash +grep -r "import 'package:privacy_gui/page/" lib/page/*/providers/*.dart \ + | grep -v "import 'package:privacy_gui/page/\($(basename $(dirname $PWD))\)" \ + | grep -v "_state.dart" | grep -v "/models/" +# 應只顯示必要的跨頁面引用 +``` + +--- + +## 5. 符合架構規範的模組 + +以下模組展現了良好的解耦實踐: + +| 模組 | 結構 | 解耦評分 | +|------|------|----------| +| `page/advanced_settings/dmz/` | models/providers/services/views | ⭐⭐⭐⭐⭐ | +| `page/wifi_settings/` | models/providers/services/views | ⭐⭐⭐⭐ | +| `page/instant_admin/` | providers/services/views | ⭐⭐⭐⭐ | +| `page/health_check/` | models/providers/services/views | ⭐⭐⭐⭐ | +| `ai/` | abstraction/orchestrator/providers | ⭐⭐⭐⭐⭐ | + +--- + +## 6. 改進建議 + +### 高優先級 + +1. **Provider 層淨化** + - 移除所有 Provider 對 `core/jnap/models/` 的直接引用 + - 為每個受影響的 Provider 創建對應的 UI 模型 + +2. **共享狀態提取** + - 將 `deviceManagerProvider`、`dashboardManagerProvider` 等全局狀態移至 `lib/providers/` + - 減少 `page/` 模組間的直接依賴 + +### 中優先級 + +3. **拆分巨型檔案** + - `jnap_tr181_mapper.dart` → 按功能域拆分 + - `router_provider.dart` → 分離路由與認證邏輯 + +4. **建立模組邊界** + - 為每個 `page/*` 模組創建 barrel export (`_module.dart`) + - 只暴露公開 API,隱藏內部實現 + +### 低優先級 + +5. **文件化架構規範** + - 擴展 `specs/002-dmz-refactor/ARCHITECTURE_DECISION.md` 為全專案指南 + - 添加 linter 規則強制架構約束 + +--- + +## 7. 總結評分 + +| 維度 | 評分 | 說明 | +|------|------|------| +| 整體架構設計 | ⭐⭐⭐⭐ | 4 層架構清晰,有文件化規範 | +| 核心模組解耦 | ⭐⭐⭐⭐⭐ | AI、USP 模組解耦良好 | +| 頁面模組解耦 | ⭐⭐⭐ | 存在跨模組依賴問題 | +| Provider 層純淨度 | ⭐⭐⭐ | 5 處違規需修復 | +| 模組邊界清晰度 | ⭐⭐⭐ | barrel export 使用不一致 | + +**總體評分: 3.6/5 ⭐** + +專案架構設計良好,主要問題集中在 Provider 層的直接 Data 模型引用和跨頁面依賴。建議優先解決 Provider 層淨化問題,並逐步建立更嚴格的模組邊界。 diff --git a/lib/core/jnap/command/http/transaction_http_command.dart b/lib/core/jnap/command/http/transaction_http_command.dart index 2c2bbddbd..d95bf6bd8 100644 --- a/lib/core/jnap/command/http/transaction_http_command.dart +++ b/lib/core/jnap/command/http/transaction_http_command.dart @@ -44,14 +44,14 @@ class TransactionHttpCommand if (cacheLevel == CacheLevel.localCached) { final prefs = await SharedPreferences.getInstance(); final serialNumber = prefs.getString(pCurrentSN); - jnap.data.forEach((entry) { + for (var entry in jnap.data) { final dataResult = { "target": entry.key.actionValue, "cachedAt": DateTime.now().millisecondsSinceEpoch, }; dataResult["data"] = (entry.value as JNAPSuccess).toJson(); cache.data[entry.key.actionValue] = dataResult; - }); + } if (serialNumber != null) { logger.d( diff --git a/lib/core/jnap/models/wirless_connection.dart b/lib/core/jnap/models/wirless_connection.dart index 351ee54d4..8a3bdf08a 100644 --- a/lib/core/jnap/models/wirless_connection.dart +++ b/lib/core/jnap/models/wirless_connection.dart @@ -38,7 +38,7 @@ class WirelessConnection extends Equatable { isGuest: isGuest ?? this.isGuest, radioID: radioID ?? this.radioID, band: band ?? this.band, - signalDecibels: signalBecibels ?? this.signalDecibels, + signalDecibels: signalBecibels ?? signalDecibels, txRate: txRate ?? this.txRate, rxRate: rxRate ?? this.rxRate, isMLOCapable: isMLOCapable ?? this.isMLOCapable, diff --git a/lib/core/usp/jnap_tr181_mapper.dart b/lib/core/usp/jnap_tr181_mapper.dart index 7ec3cf2a4..216770941 100644 --- a/lib/core/usp/jnap_tr181_mapper.dart +++ b/lib/core/usp/jnap_tr181_mapper.dart @@ -599,7 +599,7 @@ class JnapTr181Mapper { // Mock IPs for demonstration as standard MultiAP APDevice doesn't expose IP easily // In real scenario, we might look this up or USP provides it. final mockIp = '192.168.1.${10 + i}'; - final mockParentIp = '192.168.1.1'; // Assume master is parent + const mockParentIp = '192.168.1.1'; // Assume master is parent backhaulDevices.add({ 'deviceUUID': mac, @@ -740,11 +740,13 @@ class JnapTr181Mapper { String wanConnection = 'Disconnected'; if (wanEnable) { - if (wanBitRate == '1000') + if (wanBitRate == '1000') { wanConnection = '1Gbps'; - else if (wanBitRate == '100') + } else if (wanBitRate == '100') { wanConnection = '100Mbps'; - else if (wanBitRate != '0') wanConnection = '${wanBitRate}Mbps'; + } else if (wanBitRate != '0') { + wanConnection = '${wanBitRate}Mbps'; + } } // LAN Ports (Interfaces 3+ in mock data) @@ -768,13 +770,15 @@ class JnapTr181Mapper { String status = 'Disconnected'; if (enable) { - if (bitRate == '1000') + if (bitRate == '1000') { status = '1Gbps'; - else if (bitRate == '100') + } else if (bitRate == '100') { status = '100Mbps'; - else if (bitRate == '10') + } else if (bitRate == '10') { status = '10Mbps'; - else if (bitRate != '0') status = '${bitRate}Mbps'; + } else if (bitRate != '0') { + status = '${bitRate}Mbps'; + } } lanList.add(status); } @@ -933,7 +937,7 @@ class JnapTr181Mapper { // Usually MAC filtering is global or per-AP. JNAP usually sets it globally or // for the main APs. We'll look at the first AP as the "master" switch. - final prefix = 'Device.WiFi.AccessPoint.1'; + const prefix = 'Device.WiFi.AccessPoint.1'; final isEnabled = values['$prefix.MACAddressControlEnabled']?.toString() == 'true'; diff --git a/lib/page/advanced_settings/internet_settings/utils/internet_settings_form_validator.dart b/lib/page/advanced_settings/internet_settings/utils/internet_settings_form_validator.dart index 0255c2d8e..282c68af3 100644 --- a/lib/page/advanced_settings/internet_settings/utils/internet_settings_form_validator.dart +++ b/lib/page/advanced_settings/internet_settings/utils/internet_settings_form_validator.dart @@ -11,8 +11,9 @@ class InternetSettingsFormValidator { InputValidator([IpAddressNoReservedRule()]); ValidationError? validateMacAddress(String? value) { - if (value == null || value.isEmpty) + if (value == null || value.isEmpty) { return ValidationError.invalidMACAddress; + } if (_macValidator.validate(value)) { return null; } else { @@ -39,8 +40,9 @@ class InternetSettingsFormValidator { } ValidationError? validateSubnetMask(String? value) { - if (value == null || value.isEmpty) + if (value == null || value.isEmpty) { return ValidationError.invalidSubnetMask; + } final subnetMaskValidator = SubnetMaskValidator(); if (subnetMaskValidator.validate(value)) { return null; @@ -50,8 +52,9 @@ class InternetSettingsFormValidator { } ValidationError? validateIpAddress(String? value, [allowEmpty = false]) { - if (value == null || value.isEmpty) + if (value == null || value.isEmpty) { return allowEmpty ? null : ValidationError.invalidIpAddress; + } if (_ipv4Validator.validate(value)) { return null; } else { @@ -60,8 +63,9 @@ class InternetSettingsFormValidator { } ValidationError? validateEmpty(String? value) { - if (value == null || value.isEmpty) + if (value == null || value.isEmpty) { return ValidationError.fieldCannotBeEmpty; + } return null; } } diff --git a/lib/page/advanced_settings/internet_settings/views/ipv4_connection_view.dart b/lib/page/advanced_settings/internet_settings/views/ipv4_connection_view.dart index 57a244464..3cdac6a28 100644 --- a/lib/page/advanced_settings/internet_settings/views/ipv4_connection_view.dart +++ b/lib/page/advanced_settings/internet_settings/views/ipv4_connection_view.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:privacy_gui/constants/build_config.dart'; import 'package:privacy_gui/core/utils/extension.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_provider.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_state.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/widgets/optional_settings_form.dart'; diff --git a/lib/page/advanced_settings/internet_settings/views/ipv6_connection_view.dart b/lib/page/advanced_settings/internet_settings/views/ipv6_connection_view.dart index 30a55847a..98da8a328 100644 --- a/lib/page/advanced_settings/internet_settings/views/ipv6_connection_view.dart +++ b/lib/page/advanced_settings/internet_settings/views/ipv6_connection_view.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:privacy_gui/constants/build_config.dart'; import 'package:privacy_gui/core/utils/extension.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_provider.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_state.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/widgets/optional_settings_form.dart'; diff --git a/lib/page/advanced_settings/internet_settings/views/release_and_renew_view.dart b/lib/page/advanced_settings/internet_settings/views/release_and_renew_view.dart index 6c1e3c79f..73f0aa771 100644 --- a/lib/page/advanced_settings/internet_settings/views/release_and_renew_view.dart +++ b/lib/page/advanced_settings/internet_settings/views/release_and_renew_view.dart @@ -8,7 +8,6 @@ import 'package:privacy_gui/core/jnap/providers/polling_provider.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_provider.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_state.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/views/internet_settings_view.dart'; 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 511b323be..f039eb95c 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 @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/utils/extension.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_provider.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_state.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/utils/internet_settings_form_validator.dart'; 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 103678573..4145ec478 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 @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_provider.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_state.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/utils/internet_settings_form_validator.dart'; 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 e0901e941..48bf5bd37 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 @@ -48,13 +48,15 @@ class _PppoeFormState extends BaseWanFormState { // Initialize focus nodes with listeners _usernameFocusNode = FocusNode() ..addListener(() { - if (!_usernameFocusNode.hasFocus) + if (!_usernameFocusNode.hasFocus) { setState(() => _usernameTouched = true); + } }); _passwordFocusNode = FocusNode() ..addListener(() { - if (!_passwordFocusNode.hasFocus) + if (!_passwordFocusNode.hasFocus) { setState(() => _passwordTouched = true); + } }); _vlanIdFocusNode = FocusNode() ..addListener(() { diff --git a/lib/page/advanced_settings/internet_settings/widgets/wan_forms/pptp_form.dart b/lib/page/advanced_settings/internet_settings/widgets/wan_forms/pptp_form.dart index 2e5e069e4..de51a51cc 100644 --- a/lib/page/advanced_settings/internet_settings/widgets/wan_forms/pptp_form.dart +++ b/lib/page/advanced_settings/internet_settings/widgets/wan_forms/pptp_form.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/utils/extension.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_provider.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_state.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/utils/internet_settings_form_validator.dart'; diff --git a/lib/page/instant_admin/views/manual_firmware_update_view.dart b/lib/page/instant_admin/views/manual_firmware_update_view.dart index c8abfeb34..e89edc6f9 100644 --- a/lib/page/instant_admin/views/manual_firmware_update_view.dart +++ b/lib/page/instant_admin/views/manual_firmware_update_view.dart @@ -183,7 +183,7 @@ class _ManualFirmwareUpdateViewState child: SizedBox( width: MediaQuery.of(context).size.width * 0.5, child: Column( - children: [ + children: const [ AppLoader( variant: LoaderVariant.linear, ), diff --git a/lib/page/instant_safety/services/instant_safety_service.dart b/lib/page/instant_safety/services/instant_safety_service.dart index 88b656f5c..976beb4c1 100644 --- a/lib/page/instant_safety/services/instant_safety_service.dart +++ b/lib/page/instant_safety/services/instant_safety_service.dart @@ -205,6 +205,7 @@ class _CompatibilityItem { const _CompatibilityItem({ required this.modelRegExp, + // ignore: unused_element_parameter this.compatibleFW, }); } @@ -216,6 +217,7 @@ class _CompatibilityFW { const _CompatibilityFW({ required this.min, + // ignore: unused_element_parameter this.max, }); } diff --git a/lib/page/instant_setup/troubleshooter/providers/pnp_isp_settings_provider.dart b/lib/page/instant_setup/troubleshooter/providers/pnp_isp_settings_provider.dart index 66293fd16..85d7f5e43 100644 --- a/lib/page/instant_setup/troubleshooter/providers/pnp_isp_settings_provider.dart +++ b/lib/page/instant_setup/troubleshooter/providers/pnp_isp_settings_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/utils/logger.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/_providers.dart'; import 'package:privacy_gui/page/instant_setup/services/pnp_service.dart'; import 'package:privacy_gui/page/instant_setup/troubleshooter/providers/_providers.dart'; diff --git a/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_isp_type_selection_view.dart b/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_isp_type_selection_view.dart index 610e94101..132497c5f 100644 --- a/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_isp_type_selection_view.dart +++ b/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_isp_type_selection_view.dart @@ -5,7 +5,6 @@ import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/_providers.dart'; import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/page/components/ui_kit_page_view.dart'; diff --git a/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_pppoe_view.dart b/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_pppoe_view.dart index 9e98f6099..80603f631 100644 --- a/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_pppoe_view.dart +++ b/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_pppoe_view.dart @@ -5,7 +5,6 @@ import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/_providers.dart'; import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/page/components/ui_kit_page_view.dart'; diff --git a/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_static_ip_view.dart b/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_static_ip_view.dart index 00ff3a8cd..aa9302d43 100644 --- a/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_static_ip_view.dart +++ b/lib/page/instant_setup/troubleshooter/views/isp_settings/pnp_static_ip_view.dart @@ -5,7 +5,6 @@ import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/_providers.dart'; import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/page/components/ui_kit_page_view.dart'; diff --git a/lib/route/route_add_nodes.dart b/lib/route/route_add_nodes.dart index 35f126c9c..9456980a2 100644 --- a/lib/route/route_add_nodes.dart +++ b/lib/route/route_add_nodes.dart @@ -8,5 +8,5 @@ final addNodesRoute = LinksysRoute( builder: (context, state) => AddNodesView( args: state.extra as Map? ?? {}, ), - routes: [], + routes: const [], ); diff --git a/lib/route/route_cloud_login.dart b/lib/route/route_cloud_login.dart index 65a245cc9..e85add7b1 100644 --- a/lib/route/route_cloud_login.dart +++ b/lib/route/route_cloud_login.dart @@ -8,7 +8,7 @@ final cloudLoginRoute = LinksysRoute( args: state.extra as Map? ?? {} ..addAll(state.extra as Map? ?? {}) ..addAll(state.uri.queryParameters)), - routes: [], + routes: const [], ); final cloudLoginAuthRoute = LinksysRoute( diff --git a/lib/route/route_dashboard.dart b/lib/route/route_dashboard.dart index 774fd89d8..a4664a34a 100644 --- a/lib/route/route_dashboard.dart +++ b/lib/route/route_dashboard.dart @@ -3,7 +3,7 @@ part of 'router_provider.dart'; final shellNavigatorKey = GlobalKey(); final dashboardRoute = ShellRoute( navigatorKey: shellNavigatorKey, - observers: [], + observers: const [], builder: (BuildContext context, GoRouterState state, Widget child) => DashboardShell( child: child, diff --git a/lib/route/route_home.dart b/lib/route/route_home.dart index 27f859bf4..840a2b180 100644 --- a/lib/route/route_home.dart +++ b/lib/route/route_home.dart @@ -7,7 +7,7 @@ final homeRoute = LinksysRoute( column: ColumnGrid(column: 9, centered: true), ), builder: (context, state) => const HomeView(), - routes: [ + routes: const [ // cloudLoginRoute, // cloudRALoginRoute, //setupRoute diff --git a/lib/route/route_pnp.dart b/lib/route/route_pnp.dart index f96fab46b..2f83ab777 100644 --- a/lib/route/route_pnp.dart +++ b/lib/route/route_pnp.dart @@ -30,7 +30,7 @@ final pnpRoute = LinksysRoute( builder: (context, state) => AddNodesView( args: state.extra as Map? ?? {}, ), - routes: [], + routes: const [], ) ], ), diff --git a/lib/util/extensions.dart b/lib/util/extensions.dart index 4aef3f64f..784951354 100644 --- a/lib/util/extensions.dart +++ b/lib/util/extensions.dart @@ -15,7 +15,7 @@ extension Unique on List { /// /// Returns a list containing only the unique elements. List unique([Id Function(E element)? id, bool inplace = true]) { - final ids = Set(); + final ids = {}; var list = inplace ? this : List.from(this); if (list.isEmpty) { return list; diff --git a/lib/utils.dart b/lib/utils.dart index c030c3ae4..4cc8bbf4a 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -106,7 +106,7 @@ class Utils { return '$header$encryptedPassword'; } else { // If encryption fails, return the original match, but mask the password - return '${header}************'; + return '$header************'; } }); } diff --git a/packages/usp_client_core/lib/src/services/usp_topology_service.dart b/packages/usp_client_core/lib/src/services/usp_topology_service.dart index 401bd94bd..ea7867b57 100644 --- a/packages/usp_client_core/lib/src/services/usp_topology_service.dart +++ b/packages/usp_client_core/lib/src/services/usp_topology_service.dart @@ -260,7 +260,7 @@ class UspTopologyService { // Mock IPs for demonstration final mockIp = '192.168.1.${10 + i}'; - final mockParentIp = '192.168.1.1'; // Assume master is parent + const mockParentIp = '192.168.1.1'; // Assume master is parent backhaulDevices.add({ 'deviceUUID': mac, diff --git a/packages/usp_protocol_common/lib/src/converter/usp_protobuf_converter.dart b/packages/usp_protocol_common/lib/src/converter/usp_protobuf_converter.dart index 4aac94e1a..47ce5b697 100644 --- a/packages/usp_protocol_common/lib/src/converter/usp_protobuf_converter.dart +++ b/packages/usp_protocol_common/lib/src/converter/usp_protobuf_converter.dart @@ -1,13 +1,6 @@ import 'package:collection/collection.dart'; import 'package:usp_protocol_common/usp_protocol_common.dart'; import '../generated/usp_msg.pb.dart' as pb; -import '../dtos/base_dto.dart'; -import '../dtos/requests/usp_requests.dart'; -import '../dtos/responses/usp_responses.dart'; -import '../value_objects/usp_path.dart'; -import '../value_objects/usp_value.dart'; -import '../value_objects/usp_value_type.dart'; -import '../exceptions/usp_exception.dart'; /// A utility class for converting between USP DTOs and Protobuf messages. class UspProtobufConverter { diff --git a/test/core/jnap/services/firmware_update_service_test.dart b/test/core/jnap/services/firmware_update_service_test.dart index ab1ad97e8..0e94a6c75 100644 --- a/test/core/jnap/services/firmware_update_service_test.dart +++ b/test/core/jnap/services/firmware_update_service_test.dart @@ -839,7 +839,7 @@ void main() { requestTimeoutOverride: anyNamed('requestTimeoutOverride'), auth: anyNamed('auth'))) .thenAnswer((_) => Stream.value( - JNAPSuccess(result: 'OK', output: {'firmwareUpdateStatus': []}))); + JNAPSuccess(result: 'OK', output: const {'firmwareUpdateStatus': []}))); final resultStream = service.fetchFirmwareUpdateStream( force: true, retry: 2, currentNodesStatus: nodesStatus); @@ -875,7 +875,7 @@ void main() { { 'deviceUUID': 'uuid1', 'lastSuccessfulCheckTime': DateTime.now().toIso8601String(), - 'availableUpdate': { + 'availableUpdate': const { 'firmwareVersion': '1.0.0', 'firmwareDate': '2025-01-01', }, @@ -896,7 +896,7 @@ void main() { '_checkFirmwareUpdateComplete returns false when firmware update is not complete for nodes', () { final service = container.read(firmwareUpdateServiceProvider); - final result = JNAPSuccess(result: 'OK', output: { + final result = JNAPSuccess(result: 'OK', output: const { 'firmwareUpdateStatus': [ { 'deviceUUID': 'uuid1', @@ -926,7 +926,7 @@ void main() { '_checkFirmwareUpdateComplete returns true when firmware update is complete for nodes', () { final service = container.read(firmwareUpdateServiceProvider); - final result = JNAPSuccess(result: 'OK', output: { + final result = JNAPSuccess(result: 'OK', output: const { 'firmwareUpdateStatus': [ { 'deviceUUID': 'uuid1', diff --git a/test/mocks/auth_notifier_mocks.dart b/test/mocks/auth_notifier_mocks.dart index a95c3e535..38c3f1edb 100644 --- a/test/mocks/auth_notifier_mocks.dart +++ b/test/mocks/auth_notifier_mocks.dart @@ -1,15 +1,18 @@ -// Mocks generated by Mockito 5.4.5 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in privacy_gui/test/mocks/mockito_specs/auth_notifier_spec.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; +import 'dart:async' as _i5; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:privacy_gui/core/cloud/model/cloud_session_model.dart' as _i5; -import 'package:privacy_gui/core/cloud/model/region_code.dart' as _i6; -import 'package:privacy_gui/providers/auth/auth_provider.dart' as _i3; +import 'package:privacy_gui/core/cloud/model/cloud_session_model.dart' as _i6; +import 'package:privacy_gui/core/cloud/model/guardians_remote_assistance.dart' + as _i7; +import 'package:privacy_gui/core/cloud/model/region_code.dart' as _i8; +import 'package:privacy_gui/providers/auth/auth_provider.dart' as _i4; +import 'package:privacy_gui/providers/auth/auth_state.dart' as _i3; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -24,29 +27,43 @@ import 'package:privacy_gui/providers/auth/auth_provider.dart' as _i3; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeAsyncNotifierProviderRef_0 extends _i1.SmartFake implements _i2.AsyncNotifierProviderRef { - _FakeAsyncNotifierProviderRef_0(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeAsyncNotifierProviderRef_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeAsyncValue_1 extends _i1.SmartFake implements _i2.AsyncValue { - _FakeAsyncValue_1(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeAsyncValue_1( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } class _FakeAuthState_2 extends _i1.SmartFake implements _i3.AuthState { - _FakeAuthState_2(Object parent, Invocation parentInvocation) - : super(parent, parentInvocation); + _FakeAuthState_2( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); } /// A class which mocks [AuthNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockAuthNotifier extends _i2.AsyncNotifier<_i3.AuthState> - with _i1.Mock - implements _i3.AuthNotifier { +class MockAuthNotifier extends _i1.Mock implements _i4.AuthNotifier { @override _i2.AsyncNotifierProviderRef<_i3.AuthState> get ref => (super.noSuchMethod( Invocation.getter(#ref), @@ -74,119 +91,160 @@ class MockAuthNotifier extends _i2.AsyncNotifier<_i3.AuthState> ), ) as _i2.AsyncValue<_i3.AuthState>); + @override + _i5.Future<_i3.AuthState> get future => (super.noSuchMethod( + Invocation.getter(#future), + returnValue: _i5.Future<_i3.AuthState>.value(_FakeAuthState_2( + this, + Invocation.getter(#future), + )), + returnValueForMissingStub: + _i5.Future<_i3.AuthState>.value(_FakeAuthState_2( + this, + Invocation.getter(#future), + )), + ) as _i5.Future<_i3.AuthState>); + @override set state(_i2.AsyncValue<_i3.AuthState>? newState) => super.noSuchMethod( - Invocation.setter(#state, newState), + Invocation.setter( + #state, + newState, + ), returnValueForMissingStub: null, ); @override - _i4.Future<_i3.AuthState> get future => (super.noSuchMethod( - Invocation.getter(#future), - returnValue: _i4.Future<_i3.AuthState>.value( - _FakeAuthState_2(this, Invocation.getter(#future)), - ), - returnValueForMissingStub: _i4.Future<_i3.AuthState>.value( - _FakeAuthState_2(this, Invocation.getter(#future)), + _i5.Future<_i3.AuthState> build() => (super.noSuchMethod( + Invocation.method( + #build, + [], ), - ) as _i4.Future<_i3.AuthState>); + returnValue: _i5.Future<_i3.AuthState>.value(_FakeAuthState_2( + this, + Invocation.method( + #build, + [], + ), + )), + returnValueForMissingStub: + _i5.Future<_i3.AuthState>.value(_FakeAuthState_2( + this, + Invocation.method( + #build, + [], + ), + )), + ) as _i5.Future<_i3.AuthState>); @override - _i4.Future<_i3.AuthState> build() => (super.noSuchMethod( - Invocation.method(#build, []), - returnValue: _i4.Future<_i3.AuthState>.value( - _FakeAuthState_2(this, Invocation.method(#build, [])), - ), - returnValueForMissingStub: _i4.Future<_i3.AuthState>.value( - _FakeAuthState_2(this, Invocation.method(#build, [])), + _i5.Future<_i3.AuthState?> init() => (super.noSuchMethod( + Invocation.method( + #init, + [], ), - ) as _i4.Future<_i3.AuthState>); + returnValue: _i5.Future<_i3.AuthState?>.value(), + returnValueForMissingStub: _i5.Future<_i3.AuthState?>.value(), + ) as _i5.Future<_i3.AuthState?>); @override - _i4.Future<_i3.AuthState?> init() => (super.noSuchMethod( - Invocation.method(#init, []), - returnValue: _i4.Future<_i3.AuthState?>.value(), - returnValueForMissingStub: _i4.Future<_i3.AuthState?>.value(), - ) as _i4.Future<_i3.AuthState?>); - - @override - _i4.Future<_i5.SessionToken?> checkSessionToken() => (super.noSuchMethod( - Invocation.method(#checkSessionToken, []), - returnValue: _i4.Future<_i5.SessionToken?>.value(), - returnValueForMissingStub: _i4.Future<_i5.SessionToken?>.value(), - ) as _i4.Future<_i5.SessionToken?>); + _i5.Future<_i6.SessionToken?> checkSessionToken() => (super.noSuchMethod( + Invocation.method( + #checkSessionToken, + [], + ), + returnValue: _i5.Future<_i6.SessionToken?>.value(), + returnValueForMissingStub: _i5.Future<_i6.SessionToken?>.value(), + ) as _i5.Future<_i6.SessionToken?>); @override - _i4.Future<_i5.SessionToken?> handleSessionTokenError( + _i5.Future<_i6.SessionToken?> handleSessionTokenError( Object? error, StackTrace? trace, ) => (super.noSuchMethod( - Invocation.method(#handleSessionTokenError, [error, trace]), - returnValue: _i4.Future<_i5.SessionToken?>.value(), - returnValueForMissingStub: _i4.Future<_i5.SessionToken?>.value(), - ) as _i4.Future<_i5.SessionToken?>); + Invocation.method( + #handleSessionTokenError, + [ + error, + trace, + ], + ), + returnValue: _i5.Future<_i6.SessionToken?>.value(), + returnValueForMissingStub: _i5.Future<_i6.SessionToken?>.value(), + ) as _i5.Future<_i6.SessionToken?>); @override - _i4.Future<_i5.SessionToken?> refreshToken(String? refreshToken) => + _i5.Future<_i6.SessionToken?> refreshToken(String? refreshToken) => (super.noSuchMethod( - Invocation.method(#refreshToken, [refreshToken]), - returnValue: _i4.Future<_i5.SessionToken?>.value(), - returnValueForMissingStub: _i4.Future<_i5.SessionToken?>.value(), - ) as _i4.Future<_i5.SessionToken?>); + Invocation.method( + #refreshToken, + [refreshToken], + ), + returnValue: _i5.Future<_i6.SessionToken?>.value(), + returnValueForMissingStub: _i5.Future<_i6.SessionToken?>.value(), + ) as _i5.Future<_i6.SessionToken?>); @override - _i4.Future cloudLogin({ - required String? username, - required String? password, - _i5.SessionToken? sessionToken, + _i5.Future cloudLoginAuth({ + required String? token, + required String? sn, + required _i7.GRASessionInfo? sessionInfo, }) => (super.noSuchMethod( - Invocation.method(#cloudLogin, [], { - #username: username, - #password: password, - #sessionToken: sessionToken, - }), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + Invocation.method( + #cloudLoginAuth, + [], + { + #token: token, + #sn: sn, + #sessionInfo: sessionInfo, + }, + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future<_i3.AuthState> updateCloudCredientials({ - _i5.SessionToken? sessionToken, - String? username, - String? password, + _i5.Future<_i7.GRASessionInfo?> testSessionAuthentication({ + required String? token, + required String? session, }) => (super.noSuchMethod( - Invocation.method(#updateCloudCredientials, [], { - #sessionToken: sessionToken, - #username: username, - #password: password, - }), - returnValue: _i4.Future<_i3.AuthState>.value( - _FakeAuthState_2( - this, - Invocation.method(#updateCloudCredientials, [], { - #sessionToken: sessionToken, - #username: username, - #password: password, - }), - ), + Invocation.method( + #testSessionAuthentication, + [], + { + #token: token, + #session: session, + }, ), - returnValueForMissingStub: _i4.Future<_i3.AuthState>.value( - _FakeAuthState_2( - this, - Invocation.method(#updateCloudCredientials, [], { - #sessionToken: sessionToken, - #username: username, - #password: password, - }), - ), + returnValue: _i5.Future<_i7.GRASessionInfo?>.value(), + returnValueForMissingStub: _i5.Future<_i7.GRASessionInfo?>.value(), + ) as _i5.Future<_i7.GRASessionInfo?>); + + @override + _i5.Future cloudLogin({ + required String? username, + required String? password, + _i6.SessionToken? sessionToken, + }) => + (super.noSuchMethod( + Invocation.method( + #cloudLogin, + [], + { + #username: username, + #password: password, + #sessionToken: sessionToken, + }, ), - ) as _i4.Future<_i3.AuthState>); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future localLogin( + _i5.Future localLogin( String? password, { bool? pnp = false, bool? guardError = true, @@ -195,76 +253,96 @@ class MockAuthNotifier extends _i2.AsyncNotifier<_i3.AuthState> Invocation.method( #localLogin, [password], - {#pnp: pnp, #guardError: guardError}, + { + #pnp: pnp, + #guardError: guardError, + }, ), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future getPasswordHint() => (super.noSuchMethod( - Invocation.method(#getPasswordHint, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + _i5.Future getPasswordHint() => (super.noSuchMethod( + Invocation.method( + #getPasswordHint, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future?> getAdminPasswordAuthStatus( - List? services, - ) => + _i5.Future?> getAdminPasswordAuthStatus( + List? services) => (super.noSuchMethod( - Invocation.method(#getAdminPasswordAuthStatus, [services]), - returnValue: _i4.Future?>.value(), - returnValueForMissingStub: _i4.Future?>.value(), - ) as _i4.Future?>); + Invocation.method( + #getAdminPasswordAuthStatus, + [services], + ), + returnValue: _i5.Future?>.value(), + returnValueForMissingStub: _i5.Future?>.value(), + ) as _i5.Future?>); @override - _i4.Future getDeviceInfo() => (super.noSuchMethod( - Invocation.method(#getDeviceInfo, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + _i5.Future getDeviceInfo() => (super.noSuchMethod( + Invocation.method( + #getDeviceInfo, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i4.Future logout() => (super.noSuchMethod( - Invocation.method(#logout, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + _i5.Future logout() => (super.noSuchMethod( + Invocation.method( + #logout, + [], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override bool isCloudLogin() => (super.noSuchMethod( - Invocation.method(#isCloudLogin, []), + Invocation.method( + #isCloudLogin, + [], + ), returnValue: false, returnValueForMissingStub: false, ) as bool); @override - _i4.Future> fetchRegionCodes() => (super.noSuchMethod( - Invocation.method(#fetchRegionCodes, []), - returnValue: _i4.Future>.value( - <_i6.RegionCode>[], - ), - returnValueForMissingStub: _i4.Future>.value( - <_i6.RegionCode>[], + _i5.Future> fetchRegionCodes() => (super.noSuchMethod( + Invocation.method( + #fetchRegionCodes, + [], ), - ) as _i4.Future>); + returnValue: _i5.Future>.value(<_i8.RegionCode>[]), + returnValueForMissingStub: + _i5.Future>.value(<_i8.RegionCode>[]), + ) as _i5.Future>); @override - _i4.Future raLogin( + _i5.Future raLogin( String? sessionToken, String? networkId, String? serialNumber, ) => (super.noSuchMethod( - Invocation.method(#raLogin, [ - sessionToken, - networkId, - serialNumber, - ]), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), - ) as _i4.Future); + Invocation.method( + #raLogin, + [ + sessionToken, + networkId, + serialNumber, + ], + ), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override void listenSelf( @@ -272,33 +350,52 @@ class MockAuthNotifier extends _i2.AsyncNotifier<_i3.AuthState> _i2.AsyncValue<_i3.AuthState>?, _i2.AsyncValue<_i3.AuthState>, )? listener, { - void Function(Object, StackTrace)? onError, + void Function( + Object, + StackTrace, + )? onError, }) => super.noSuchMethod( - Invocation.method(#listenSelf, [listener], {#onError: onError}), + Invocation.method( + #listenSelf, + [listener], + {#onError: onError}, + ), returnValueForMissingStub: null, ); @override - _i4.Future<_i3.AuthState> update( - _i4.FutureOr<_i3.AuthState> Function(_i3.AuthState)? cb, { - _i4.FutureOr<_i3.AuthState> Function(Object, StackTrace)? onError, + _i5.Future<_i3.AuthState> update( + _i5.FutureOr<_i3.AuthState> Function(_i3.AuthState)? cb, { + _i5.FutureOr<_i3.AuthState> Function( + Object, + StackTrace, + )? onError, }) => (super.noSuchMethod( - Invocation.method(#update, [cb], {#onError: onError}), - returnValue: _i4.Future<_i3.AuthState>.value( - _FakeAuthState_2( - this, - Invocation.method(#update, [cb], {#onError: onError}), - ), + Invocation.method( + #update, + [cb], + {#onError: onError}, ), - returnValueForMissingStub: _i4.Future<_i3.AuthState>.value( - _FakeAuthState_2( - this, - Invocation.method(#update, [cb], {#onError: onError}), + returnValue: _i5.Future<_i3.AuthState>.value(_FakeAuthState_2( + this, + Invocation.method( + #update, + [cb], + {#onError: onError}, ), - ), - ) as _i4.Future<_i3.AuthState>); + )), + returnValueForMissingStub: + _i5.Future<_i3.AuthState>.value(_FakeAuthState_2( + this, + Invocation.method( + #update, + [cb], + {#onError: onError}, + ), + )), + ) as _i5.Future<_i3.AuthState>); @override bool updateShouldNotify( @@ -306,7 +403,13 @@ class MockAuthNotifier extends _i2.AsyncNotifier<_i3.AuthState> _i2.AsyncValue<_i3.AuthState>? next, ) => (super.noSuchMethod( - Invocation.method(#updateShouldNotify, [previous, next]), + Invocation.method( + #updateShouldNotify, + [ + previous, + next, + ], + ), returnValue: false, returnValueForMissingStub: false, ) as bool); diff --git a/test/mocks/dashboard_home_notifier_mocks.dart b/test/mocks/dashboard_home_notifier_mocks.dart index 86092253c..7eebd7f09 100644 --- a/test/mocks/dashboard_home_notifier_mocks.dart +++ b/test/mocks/dashboard_home_notifier_mocks.dart @@ -5,16 +5,10 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart' - as _i5; -import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart' - as _i6; import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart' as _i4; import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart' as _i3; -import 'package:privacy_gui/page/health_check/providers/health_check_state.dart' - as _i7; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -29,6 +23,7 @@ import 'package:privacy_gui/page/health_check/providers/health_check_state.dart' // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeNotifierProviderRef_0 extends _i1.SmartFake implements _i2.NotifierProviderRef { @@ -55,8 +50,7 @@ class _FakeDashboardHomeState_1 extends _i1.SmartFake /// A class which mocks [DashboardHomeNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockDashboardHomeNotifier extends _i2.Notifier<_i3.DashboardHomeState> - with _i1.Mock +class MockDashboardHomeNotifier extends _i1.Mock implements _i4.DashboardHomeNotifier { @override _i2.NotifierProviderRef<_i3.DashboardHomeState> get ref => @@ -117,45 +111,6 @@ class MockDashboardHomeNotifier extends _i2.Notifier<_i3.DashboardHomeState> ), ) as _i3.DashboardHomeState); - @override - _i3.DashboardHomeState createState( - _i5.DashboardManagerState? dashboardManagerState, - _i6.DeviceManagerState? deviceManagerState, - _i7.HealthCheckState? healthCheckState, - ) => - (super.noSuchMethod( - Invocation.method( - #createState, - [ - dashboardManagerState, - deviceManagerState, - healthCheckState, - ], - ), - returnValue: _FakeDashboardHomeState_1( - this, - Invocation.method( - #createState, - [ - dashboardManagerState, - deviceManagerState, - healthCheckState, - ], - ), - ), - returnValueForMissingStub: _FakeDashboardHomeState_1( - this, - Invocation.method( - #createState, - [ - dashboardManagerState, - deviceManagerState, - healthCheckState, - ], - ), - ), - ) as _i3.DashboardHomeState); - @override void listenSelf( void Function( diff --git a/test/mocks/dashboard_manager_notifier_mocks.dart b/test/mocks/dashboard_manager_notifier_mocks.dart index 41181a3f1..bbc025fbe 100644 --- a/test/mocks/dashboard_manager_notifier_mocks.dart +++ b/test/mocks/dashboard_manager_notifier_mocks.dart @@ -1,18 +1,17 @@ -// Mocks generated by Mockito 5.4.4 from annotations -// in privacy_gui/test/page/login/localizations/login_local_view_test.dart. +// Mocks generated by Mockito 5.4.6 from annotations +// in privacy_gui/test/mocks/mockito_specs/dashboard_manager_notifier_spec.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i12; +import 'dart:async' as _i6; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; import 'package:privacy_gui/core/jnap/models/device_info.dart' as _i4; import 'package:privacy_gui/core/jnap/providers/dashboard_manager_provider.dart' - as _i10; + as _i5; import 'package:privacy_gui/core/jnap/providers/dashboard_manager_state.dart' as _i3; -import 'package:privacy_gui/core/jnap/providers/polling_provider.dart' as _i11; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -22,10 +21,12 @@ import 'package:privacy_gui/core/jnap/providers/polling_provider.dart' as _i11; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeNotifierProviderRef_0 extends _i1.SmartFake implements _i2.NotifierProviderRef { @@ -63,10 +64,8 @@ class _FakeNodeDeviceInfo_2 extends _i1.SmartFake /// A class which mocks [DashboardManagerNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockDashboardManagerNotifier - extends _i2.Notifier<_i3.DashboardManagerState> - with _i1.Mock - implements _i10.DashboardManagerNotifier { +class MockDashboardManagerNotifier extends _i1.Mock + implements _i5.DashboardManagerNotifier { @override _i2.NotifierProviderRef<_i3.DashboardManagerState> get ref => (super.noSuchMethod( @@ -127,58 +126,53 @@ class MockDashboardManagerNotifier ) as _i3.DashboardManagerState); @override - _i3.DashboardManagerState createState( - {_i11.CoreTransactionData? pollingResult}) => + _i6.Future saveSelectedNetwork( + String? serialNumber, + String? networkId, + ) => (super.noSuchMethod( Invocation.method( - #createState, + #saveSelectedNetwork, + [ + serialNumber, + networkId, + ], + ), + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + _i6.Future<_i4.NodeDeviceInfo> checkRouterIsBack() => (super.noSuchMethod( + Invocation.method( + #checkRouterIsBack, [], - {#pollingResult: pollingResult}, ), - returnValue: _FakeDashboardManagerState_1( + returnValue: _i6.Future<_i4.NodeDeviceInfo>.value(_FakeNodeDeviceInfo_2( this, Invocation.method( - #createState, + #checkRouterIsBack, [], - {#pollingResult: pollingResult}, ), - ), - returnValueForMissingStub: _FakeDashboardManagerState_1( + )), + returnValueForMissingStub: + _i6.Future<_i4.NodeDeviceInfo>.value(_FakeNodeDeviceInfo_2( this, Invocation.method( - #createState, + #checkRouterIsBack, [], - {#pollingResult: pollingResult}, ), - ), - ) as _i3.DashboardManagerState); - - @override - _i12.Future saveSelectedNetwork( - String? serialNumber, - String? networkId, - ) => - (super.noSuchMethod( - Invocation.method( - #saveSelectedNetwork, - [ - serialNumber, - networkId, - ], - ), - returnValue: _i12.Future.value(), - returnValueForMissingStub: _i12.Future.value(), - ) as _i12.Future); + )), + ) as _i6.Future<_i4.NodeDeviceInfo>); @override - _i12.Future<_i4.NodeDeviceInfo> checkDeviceInfo(String? serialNumber) => + _i6.Future<_i4.NodeDeviceInfo> checkDeviceInfo(String? serialNumber) => (super.noSuchMethod( Invocation.method( #checkDeviceInfo, [serialNumber], ), - returnValue: - _i12.Future<_i4.NodeDeviceInfo>.value(_FakeNodeDeviceInfo_2( + returnValue: _i6.Future<_i4.NodeDeviceInfo>.value(_FakeNodeDeviceInfo_2( this, Invocation.method( #checkDeviceInfo, @@ -186,24 +180,34 @@ class MockDashboardManagerNotifier ), )), returnValueForMissingStub: - _i12.Future<_i4.NodeDeviceInfo>.value(_FakeNodeDeviceInfo_2( + _i6.Future<_i4.NodeDeviceInfo>.value(_FakeNodeDeviceInfo_2( this, Invocation.method( #checkDeviceInfo, [serialNumber], ), )), - ) as _i12.Future<_i4.NodeDeviceInfo>); + ) as _i6.Future<_i4.NodeDeviceInfo>); - // @override - // bool isHealthCheckModuleSupported(String? module) => (super.noSuchMethod( - // Invocation.method( - // #isHealthCheckModuleSupported, - // [module], - // ), - // returnValue: false, - // returnValueForMissingStub: false, - // ) as bool); + @override + void listenSelf( + void Function( + _i3.DashboardManagerState?, + _i3.DashboardManagerState, + )? listener, { + void Function( + Object, + StackTrace, + )? onError, + }) => + super.noSuchMethod( + Invocation.method( + #listenSelf, + [listener], + {#onError: onError}, + ), + returnValueForMissingStub: null, + ); @override bool updateShouldNotify( diff --git a/test/mocks/device_manager_notifier_mocks.dart b/test/mocks/device_manager_notifier_mocks.dart index d183c6deb..b4434d77e 100644 --- a/test/mocks/device_manager_notifier_mocks.dart +++ b/test/mocks/device_manager_notifier_mocks.dart @@ -1,19 +1,18 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in privacy_gui/test/mocks/mockito_specs/device_manager_notifier_spec.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i8; +import 'dart:async' as _i6; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i7; +import 'package:mockito/src/dummies.dart' as _i5; import 'package:privacy_gui/core/jnap/providers/device_manager_provider.dart' - as _i5; + as _i4; import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart' as _i3; -import 'package:privacy_gui/core/jnap/providers/polling_provider.dart' as _i6; -import 'package:privacy_gui/core/utils/icon_device_category.dart' as _i9; +import 'package:privacy_gui/core/utils/icon_device_category.dart' as _i7; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -23,10 +22,12 @@ import 'package:privacy_gui/core/utils/icon_device_category.dart' as _i9; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeNotifierProviderRef_0 extends _i1.SmartFake implements _i2.NotifierProviderRef { @@ -53,9 +54,8 @@ class _FakeDeviceManagerState_1 extends _i1.SmartFake /// A class which mocks [DeviceManagerNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockDeviceManagerNotifier extends _i2.Notifier<_i3.DeviceManagerState> - with _i1.Mock - implements _i5.DeviceManagerNotifier { +class MockDeviceManagerNotifier extends _i1.Mock + implements _i4.DeviceManagerNotifier { @override _i2.NotifierProviderRef<_i3.DeviceManagerState> get ref => (super.noSuchMethod( @@ -116,31 +116,13 @@ class MockDeviceManagerNotifier extends _i2.Notifier<_i3.DeviceManagerState> ) as _i3.DeviceManagerState); @override - _i3.DeviceManagerState createState( - {_i6.CoreTransactionData? pollingResult}) => - (super.noSuchMethod( + void init() => super.noSuchMethod( Invocation.method( - #createState, + #init, [], - {#pollingResult: pollingResult}, - ), - returnValue: _FakeDeviceManagerState_1( - this, - Invocation.method( - #createState, - [], - {#pollingResult: pollingResult}, - ), ), - returnValueForMissingStub: _FakeDeviceManagerState_1( - this, - Invocation.method( - #createState, - [], - {#pollingResult: pollingResult}, - ), - ), - ) as _i3.DeviceManagerState); + returnValueForMissingStub: null, + ); @override bool isEmptyState() => (super.noSuchMethod( @@ -167,14 +149,14 @@ class MockDeviceManagerNotifier extends _i2.Notifier<_i3.DeviceManagerState> #getBandConnectedBy, [device], ), - returnValue: _i7.dummyValue( + returnValue: _i5.dummyValue( this, Invocation.method( #getBandConnectedBy, [device], ), ), - returnValueForMissingStub: _i7.dummyValue( + returnValueForMissingStub: _i5.dummyValue( this, Invocation.method( #getBandConnectedBy, @@ -200,11 +182,11 @@ class MockDeviceManagerNotifier extends _i2.Notifier<_i3.DeviceManagerState> ) as _i3.LinksysDevice?); @override - _i8.Future updateDeviceNameAndIcon({ + _i6.Future updateDeviceNameAndIcon({ required String? targetId, required String? newName, required bool? isLocation, - _i9.IconDeviceCategory? icon, + _i7.IconDeviceCategory? icon, }) => (super.noSuchMethod( Invocation.method( @@ -217,33 +199,53 @@ class MockDeviceManagerNotifier extends _i2.Notifier<_i3.DeviceManagerState> #icon: icon, }, ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i8.Future deleteDevices({required List? deviceIds}) => + _i6.Future deleteDevices({required List? deviceIds}) => (super.noSuchMethod( Invocation.method( #deleteDevices, [], {#deviceIds: deviceIds}, ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); @override - _i8.Future deauthClient({required String? macAddress}) => + _i6.Future deauthClient({required String? macAddress}) => (super.noSuchMethod( Invocation.method( #deauthClient, [], {#macAddress: macAddress}, ), - returnValue: _i8.Future.value(), - returnValueForMissingStub: _i8.Future.value(), - ) as _i8.Future); + returnValue: _i6.Future.value(), + returnValueForMissingStub: _i6.Future.value(), + ) as _i6.Future); + + @override + void listenSelf( + void Function( + _i3.DeviceManagerState?, + _i3.DeviceManagerState, + )? listener, { + void Function( + Object, + StackTrace, + )? onError, + }) => + super.noSuchMethod( + Invocation.method( + #listenSelf, + [listener], + {#onError: onError}, + ), + returnValueForMissingStub: null, + ); @override bool updateShouldNotify( diff --git a/test/mocks/internet_settings_notifier_mocks.dart b/test/mocks/internet_settings_notifier_mocks.dart index 09adfe9db..a7683706c 100644 --- a/test/mocks/internet_settings_notifier_mocks.dart +++ b/test/mocks/internet_settings_notifier_mocks.dart @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.5 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in privacy_gui/test/mocks/mockito_specs/internet_settings_notifier_spec.dart. // Do not manually edit this file. @@ -7,8 +7,6 @@ import 'dart:async' as _i5; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i7; -import 'package:privacy_gui/core/jnap/actions/better_action.dart' as _i6; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_provider.dart' as _i4; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_state.dart' @@ -27,6 +25,7 @@ import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/i // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeNotifierProviderRef_0 extends _i1.SmartFake implements _i2.NotifierProviderRef { @@ -53,9 +52,7 @@ class _FakeInternetSettingsState_1 extends _i1.SmartFake /// A class which mocks [InternetSettingsNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockInternetSettingsNotifier - extends _i2.Notifier<_i3.InternetSettingsState> - with _i1.Mock +class MockInternetSettingsNotifier extends _i1.Mock implements _i4.InternetSettingsNotifier { @override _i2.NotifierProviderRef<_i3.InternetSettingsState> get ref => @@ -117,27 +114,35 @@ class MockInternetSettingsNotifier ) as _i3.InternetSettingsState); @override - _i5.Future< - (_i3.InternetSettings?, _i3.InternetSettingsStatus?)> performFetch({ + _i5.Future<(_i3.InternetSettingsUIModel?, _i3.InternetSettingsStatusUIModel?)> + performFetch({ bool? forceRemote = false, bool? updateStatusOnly = false, }) => - (super.noSuchMethod( - Invocation.method( - #performFetch, - [], - { - #forceRemote: forceRemote, - #updateStatusOnly: updateStatusOnly, - }, - ), - returnValue: _i5 - .Future<(_i3.InternetSettings?, _i3.InternetSettingsStatus?)>.value( - (null, null)), - returnValueForMissingStub: _i5 - .Future<(_i3.InternetSettings?, _i3.InternetSettingsStatus?)>.value( - (null, null)), - ) as _i5.Future<(_i3.InternetSettings?, _i3.InternetSettingsStatus?)>); + (super.noSuchMethod( + Invocation.method( + #performFetch, + [], + { + #forceRemote: forceRemote, + #updateStatusOnly: updateStatusOnly, + }, + ), + returnValue: _i5.Future< + ( + _i3.InternetSettingsUIModel?, + _i3.InternetSettingsStatusUIModel? + )>.value((null, null)), + returnValueForMissingStub: _i5.Future< + ( + _i3.InternetSettingsUIModel?, + _i3.InternetSettingsStatusUIModel? + )>.value((null, null)), + ) as _i5.Future< + ( + _i3.InternetSettingsUIModel?, + _i3.InternetSettingsStatusUIModel? + )>); @override _i5.Future performSave() => (super.noSuchMethod( @@ -150,20 +155,7 @@ class MockInternetSettingsNotifier ) as _i5.Future); @override - List>> getSaveIpv4Transactions( - _i3.InternetSettings? data) => - (super.noSuchMethod( - Invocation.method( - #getSaveIpv4Transactions, - [data], - ), - returnValue: >>[], - returnValueForMissingStub: >>[], - ) as List>>); - - @override - _i5.Future savePnpIpv4(_i3.InternetSettings? data) => + _i5.Future savePnpIpv4(_i3.InternetSettingsUIModel? data) => (super.noSuchMethod( Invocation.method( #savePnpIpv4, @@ -173,56 +165,6 @@ class MockInternetSettingsNotifier returnValueForMissingStub: _i5.Future.value(), ) as _i5.Future); - @override - List>> getSaveIpv6Transactions( - _i3.InternetSettings? data) => - (super.noSuchMethod( - Invocation.method( - #getSaveIpv6Transactions, - [data], - ), - returnValue: >>[], - returnValueForMissingStub: >>[], - ) as List>>); - - @override - MapEntry<_i6.JNAPAction, Map> getMacAddressCloneTransaction( - bool? isMACAddressCloneEnabled, - String? macAddress, - ) => - (super.noSuchMethod( - Invocation.method( - #getMacAddressCloneTransaction, - [ - isMACAddressCloneEnabled, - macAddress, - ], - ), - returnValue: - _i7.dummyValue>>( - this, - Invocation.method( - #getMacAddressCloneTransaction, - [ - isMACAddressCloneEnabled, - macAddress, - ], - ), - ), - returnValueForMissingStub: - _i7.dummyValue>>( - this, - Invocation.method( - #getMacAddressCloneTransaction, - [ - isMACAddressCloneEnabled, - macAddress, - ], - ), - ), - ) as MapEntry<_i6.JNAPAction, Map>); - @override _i5.Future getMyMACAddress() => (super.noSuchMethod( Invocation.method( @@ -254,7 +196,7 @@ class MockInternetSettingsNotifier ) as _i5.Future); @override - void updateStatus(_i3.InternetSettingsStatus? newStatus) => + void updateStatus(_i3.InternetSettingsStatusUIModel? newStatus) => super.noSuchMethod( Invocation.method( #updateStatus, @@ -291,7 +233,8 @@ class MockInternetSettingsNotifier ); @override - void updateIpv4Settings(_i3.Ipv4Setting? ipv4Setting) => super.noSuchMethod( + void updateIpv4Settings(_i3.Ipv4SettingsUIModel? ipv4Setting) => + super.noSuchMethod( Invocation.method( #updateIpv4Settings, [ipv4Setting], @@ -300,7 +243,8 @@ class MockInternetSettingsNotifier ); @override - void updateIpv6Settings(_i3.Ipv6Setting? ipv6Setting) => super.noSuchMethod( + void updateIpv6Settings(_i3.Ipv6SettingsUIModel? ipv6Setting) => + super.noSuchMethod( Invocation.method( #updateIpv6Settings, [ipv6Setting], diff --git a/test/mocks/node_detail_notifier_mocks.dart b/test/mocks/node_detail_notifier_mocks.dart index 8769a6461..33d6d8499 100644 --- a/test/mocks/node_detail_notifier_mocks.dart +++ b/test/mocks/node_detail_notifier_mocks.dart @@ -1,15 +1,14 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in privacy_gui/test/mocks/mockito_specs/node_detail_notifier_spec.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i6; +import 'dart:async' as _i5; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; import 'package:privacy_gui/core/jnap/providers/device_manager_state.dart' - as _i5; -import 'package:privacy_gui/core/jnap/result/jnap_result.dart' as _i4; + as _i4; import 'package:privacy_gui/page/nodes/_nodes.dart' as _i3; // ignore_for_file: type=lint @@ -20,10 +19,12 @@ import 'package:privacy_gui/page/nodes/_nodes.dart' as _i3; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeNotifierProviderRef_0 extends _i1.SmartFake implements _i2.NotifierProviderRef { @@ -47,21 +48,10 @@ class _FakeNodeDetailState_1 extends _i1.SmartFake ); } -class _FakeJNAPResult_2 extends _i1.SmartFake implements _i4.JNAPResult { - _FakeJNAPResult_2( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - /// A class which mocks [NodeDetailNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockNodeDetailNotifier extends _i2.Notifier<_i3.NodeDetailState> - with _i1.Mock +class MockNodeDetailNotifier extends _i1.Mock implements _i3.NodeDetailNotifier { @override _i2.NotifierProviderRef<_i3.NodeDetailState> get ref => (super.noSuchMethod( @@ -123,7 +113,7 @@ class MockNodeDetailNotifier extends _i2.Notifier<_i3.NodeDetailState> @override _i3.NodeDetailState createState( - _i5.DeviceManagerState? deviceManagerState, + _i4.DeviceManagerState? deviceManagerState, String? targetId, ) => (super.noSuchMethod( @@ -157,72 +147,45 @@ class MockNodeDetailNotifier extends _i2.Notifier<_i3.NodeDetailState> ) as _i3.NodeDetailState); @override - _i6.Future<_i4.JNAPResult> startBlinkNodeLED(String? deviceId) => - (super.noSuchMethod( - Invocation.method( - #startBlinkNodeLED, - [deviceId], - ), - returnValue: _i6.Future<_i4.JNAPResult>.value(_FakeJNAPResult_2( - this, - Invocation.method( - #startBlinkNodeLED, - [deviceId], - ), - )), - returnValueForMissingStub: - _i6.Future<_i4.JNAPResult>.value(_FakeJNAPResult_2( - this, - Invocation.method( - #startBlinkNodeLED, - [deviceId], - ), - )), - ) as _i6.Future<_i4.JNAPResult>); - - @override - _i6.Future<_i4.JNAPResult> stopBlinkNodeLED() => (super.noSuchMethod( - Invocation.method( - #stopBlinkNodeLED, - [], - ), - returnValue: _i6.Future<_i4.JNAPResult>.value(_FakeJNAPResult_2( - this, - Invocation.method( - #stopBlinkNodeLED, - [], - ), - )), - returnValueForMissingStub: - _i6.Future<_i4.JNAPResult>.value(_FakeJNAPResult_2( - this, - Invocation.method( - #stopBlinkNodeLED, - [], - ), - )), - ) as _i6.Future<_i4.JNAPResult>); - - @override - _i6.Future toggleBlinkNode([bool? stopOnly = false]) => + _i5.Future toggleBlinkNode([bool? stopOnly = false]) => (super.noSuchMethod( Invocation.method( #toggleBlinkNode, [stopOnly], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); @override - _i6.Future updateDeviceName(String? newName) => (super.noSuchMethod( + _i5.Future updateDeviceName(String? newName) => (super.noSuchMethod( Invocation.method( #updateDeviceName, [newName], ), - returnValue: _i6.Future.value(), - returnValueForMissingStub: _i6.Future.value(), - ) as _i6.Future); + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), + ) as _i5.Future); + + @override + void listenSelf( + void Function( + _i3.NodeDetailState?, + _i3.NodeDetailState, + )? listener, { + void Function( + Object, + StackTrace, + )? onError, + }) => + super.noSuchMethod( + Invocation.method( + #listenSelf, + [listener], + {#onError: onError}, + ), + returnValueForMissingStub: null, + ); @override bool updateShouldNotify( diff --git a/test/mocks/polling_notifier_mocks.dart b/test/mocks/polling_notifier_mocks.dart index 9e3667351..4a9ddae04 100644 --- a/test/mocks/polling_notifier_mocks.dart +++ b/test/mocks/polling_notifier_mocks.dart @@ -7,7 +7,6 @@ import 'dart:async' as _i4; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i5; import 'package:privacy_gui/core/jnap/providers/polling_provider.dart' as _i3; // ignore_for_file: type=lint @@ -23,6 +22,7 @@ import 'package:privacy_gui/core/jnap/providers/polling_provider.dart' as _i3; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeAsyncNotifierProviderRef_0 extends _i1.SmartFake implements _i2.AsyncNotifierProviderRef { @@ -59,9 +59,7 @@ class _FakeCoreTransactionData_2 extends _i1.SmartFake /// A class which mocks [PollingNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockPollingNotifier extends _i2.AsyncNotifier<_i3.CoreTransactionData> - with _i1.Mock - implements _i3.PollingNotifier { +class MockPollingNotifier extends _i1.Mock implements _i3.PollingNotifier { @override bool get paused => (super.noSuchMethod( Invocation.getter(#paused), @@ -174,29 +172,6 @@ class MockPollingNotifier extends _i2.AsyncNotifier<_i3.CoreTransactionData> returnValueForMissingStub: null, ); - @override - _i4.Future checkSmartMode() => (super.noSuchMethod( - Invocation.method( - #checkSmartMode, - [], - ), - returnValue: _i4.Future.value(_i5.dummyValue( - this, - Invocation.method( - #checkSmartMode, - [], - ), - )), - returnValueForMissingStub: - _i4.Future.value(_i5.dummyValue( - this, - Invocation.method( - #checkSmartMode, - [], - ), - )), - ) as _i4.Future); - @override void listenSelf( void Function( diff --git a/test/mocks/test_data/dashboard_home_test_data.dart b/test/mocks/test_data/dashboard_home_test_data.dart index 0931fb2b2..53110b169 100644 --- a/test/mocks/test_data/dashboard_home_test_data.dart +++ b/test/mocks/test_data/dashboard_home_test_data.dart @@ -404,7 +404,7 @@ class DashboardHomeTestData { 'description': 'Test Router', 'firmwareVersion': '1.0.0', 'firmwareDate': '2024-01-01T00:00:00Z', - 'services': ['http://linksys.com/jnap/core/Core'], + 'services': const ['http://linksys.com/jnap/core/Core'], }); } diff --git a/test/mocks/test_data/dashboard_manager_test_data.dart b/test/mocks/test_data/dashboard_manager_test_data.dart index 66e0bf023..359d2cd42 100644 --- a/test/mocks/test_data/dashboard_manager_test_data.dart +++ b/test/mocks/test_data/dashboard_manager_test_data.dart @@ -31,7 +31,7 @@ class DashboardManagerTestData { 'manufacturer': manufacturer, 'description': description, 'firmwareDate': firmwareDate, - 'services': ['http://linksys.com/jnap/core/Core'], + 'services': const ['http://linksys.com/jnap/core/Core'], }, ); @@ -271,7 +271,7 @@ class DashboardManagerTestData { return createSuccessfulPollingData( localTime: JNAPSuccess( result: 'OK', - output: { + output: const { 'currentTime': 'invalid-time-format', }, ), diff --git a/test/mocks/test_data/ddns_test_data.dart b/test/mocks/test_data/ddns_test_data.dart index 676b2d1b7..837c7bbb7 100644 --- a/test/mocks/test_data/ddns_test_data.dart +++ b/test/mocks/test_data/ddns_test_data.dart @@ -75,9 +75,9 @@ class DDNSTestData { 'detectedWANType': 'DHCP', 'wanStatus': wanStatus, 'wanIPv6Status': 'Disconnected', - 'supportedWANTypes': ['DHCP', 'Static', 'PPPoE'], - 'supportedIPv6WANTypes': [], - 'supportedWANCombinations': [], + 'supportedWANTypes': const ['DHCP', 'Static', 'PPPoE'], + 'supportedIPv6WANTypes': const [], + 'supportedWANCombinations': const [], 'wanConnection': { 'wanType': 'DHCP', 'ipAddress': wanIP, diff --git a/test/mocks/test_data/polling_test_data.dart b/test/mocks/test_data/polling_test_data.dart index 3e253007f..0af9f8bbf 100644 --- a/test/mocks/test_data/polling_test_data.dart +++ b/test/mocks/test_data/polling_test_data.dart @@ -47,7 +47,7 @@ class PollingTestData { /// Create getRadioInfo success response static JNAPSuccess createRadioInfoSuccess() => JNAPSuccess( result: 'OK', - output: { + output: const { 'isBandSteeringSupported': true, 'radios': [ { @@ -178,7 +178,7 @@ class PollingTestData { result: 'OK', output: { 'isEnabled': isEnabled, - 'macAddresses': [], + 'macAddresses': const [], }, ); diff --git a/test/mocks/test_data/port_range_forwarding_test_data.dart b/test/mocks/test_data/port_range_forwarding_test_data.dart index 35885e179..30c1fdc5f 100644 --- a/test/mocks/test_data/port_range_forwarding_test_data.dart +++ b/test/mocks/test_data/port_range_forwarding_test_data.dart @@ -80,7 +80,7 @@ class PortRangeForwardingTestData { return JNAPSuccess( result: 'ok', output: { - 'rules': [], + 'rules': const [], 'maxRules': maxRules, 'maxDescriptionLength': maxDescriptionLength, }, @@ -104,11 +104,11 @@ class PortRangeForwardingTestData { 'maxAllowedDHCPLeaseMinutes': 10080, 'maxDHCPReservationDescriptionLength': 32, 'hostName': 'Linksys', - 'dhcpSettings': { + 'dhcpSettings': const { 'firstClientIPAddress': '192.168.1.100', 'lastClientIPAddress': '192.168.1.200', 'leaseMinutes': 1440, - 'reservations': const >[], + 'reservations': >[], }, }, ); diff --git a/test/mocks/test_data/port_range_triggering_test_data.dart b/test/mocks/test_data/port_range_triggering_test_data.dart index 486e7fbea..d5beeb467 100644 --- a/test/mocks/test_data/port_range_triggering_test_data.dart +++ b/test/mocks/test_data/port_range_triggering_test_data.dart @@ -80,7 +80,7 @@ class PortRangeTriggeringTestData { return JNAPSuccess( result: 'ok', output: { - 'rules': [], + 'rules': const [], 'maxRules': maxRules, 'maxDescriptionLength': maxDescriptionLength, }, diff --git a/test/mocks/test_data/single_port_forwarding_test_data.dart b/test/mocks/test_data/single_port_forwarding_test_data.dart index 2a8a3f939..085e260d1 100644 --- a/test/mocks/test_data/single_port_forwarding_test_data.dart +++ b/test/mocks/test_data/single_port_forwarding_test_data.dart @@ -23,7 +23,7 @@ class SinglePortForwardingTestData { 'hostName': 'linksys', 'maxDHCPReservationDescriptionLength': 32, 'isDHCPEnabled': true, - 'dhcpSettings': { + 'dhcpSettings': const { 'lastClientIPAddress': '192.168.1.254', 'leaseMinutes': 1440, 'reservations': [], diff --git a/test/mocks/test_data/static_routing_test_data.dart b/test/mocks/test_data/static_routing_test_data.dart index a8b66285d..f8a00cbb0 100644 --- a/test/mocks/test_data/static_routing_test_data.dart +++ b/test/mocks/test_data/static_routing_test_data.dart @@ -3,7 +3,6 @@ import 'package:privacy_gui/core/jnap/models/lan_settings.dart'; import 'package:privacy_gui/page/advanced_settings/static_routing/models/static_route_entry_ui_model.dart'; import 'package:privacy_gui/page/advanced_settings/static_routing/models/static_routing_rule_ui_model.dart'; import 'package:privacy_gui/page/advanced_settings/static_routing/providers/static_routing_rule_state.dart'; -import 'package:privacy_gui/utils.dart'; /// Test data builder for StaticRoutingService tests /// diff --git a/test/page/advanced_settings/apps_and_gaming/ddns/providers/ddns_provider_test.dart b/test/page/advanced_settings/apps_and_gaming/ddns/providers/ddns_provider_test.dart index 6f324cd1e..e2fd37532 100644 --- a/test/page/advanced_settings/apps_and_gaming/ddns/providers/ddns_provider_test.dart +++ b/test/page/advanced_settings/apps_and_gaming/ddns/providers/ddns_provider_test.dart @@ -40,7 +40,7 @@ void main() { ), ); final status = DDNSStatusUIModel( - supportedProviders: ['', 'DynDNS'], + supportedProviders: const ['', 'DynDNS'], status: 'Connected', ipAddress: '192.168.1.1', ); diff --git a/test/page/advanced_settings/apps_and_gaming/ports/services/port_range_forwarding_service_test.dart b/test/page/advanced_settings/apps_and_gaming/ports/services/port_range_forwarding_service_test.dart index 9dca67f79..25414f9f2 100644 --- a/test/page/advanced_settings/apps_and_gaming/ports/services/port_range_forwarding_service_test.dart +++ b/test/page/advanced_settings/apps_and_gaming/ports/services/port_range_forwarding_service_test.dart @@ -237,8 +237,8 @@ void main() { () async { // Arrange final uiModel = PortRangeForwardingRuleListUIModel( - rules: [ - const PortRangeForwardingRuleUIModel( + rules: const [ + PortRangeForwardingRuleUIModel( isEnabled: true, firstExternalPort: 5000, protocol: 'TCP', @@ -306,8 +306,8 @@ void main() { test('saves multiple rules correctly', () async { // Arrange final uiModel = PortRangeForwardingRuleListUIModel( - rules: [ - const PortRangeForwardingRuleUIModel( + rules: const [ + PortRangeForwardingRuleUIModel( isEnabled: true, firstExternalPort: 3000, protocol: 'TCP', @@ -315,7 +315,7 @@ void main() { lastExternalPort: 3000, description: 'Rule 1', ), - const PortRangeForwardingRuleUIModel( + PortRangeForwardingRuleUIModel( isEnabled: false, firstExternalPort: 4000, protocol: 'UDP', diff --git a/test/page/advanced_settings/apps_and_gaming/ports/services/port_range_triggering_service_test.dart b/test/page/advanced_settings/apps_and_gaming/ports/services/port_range_triggering_service_test.dart index b7779cbb8..e909b1728 100644 --- a/test/page/advanced_settings/apps_and_gaming/ports/services/port_range_triggering_service_test.dart +++ b/test/page/advanced_settings/apps_and_gaming/ports/services/port_range_triggering_service_test.dart @@ -180,8 +180,8 @@ void main() { () async { // Arrange final uiModel = PortRangeTriggeringRuleListUIModel( - rules: [ - const PortRangeTriggeringRuleUIModel( + rules: const [ + PortRangeTriggeringRuleUIModel( isEnabled: true, firstTriggerPort: 5000, lastTriggerPort: 5000, @@ -251,8 +251,8 @@ void main() { test('saves multiple rules correctly', () async { // Arrange final uiModel = PortRangeTriggeringRuleListUIModel( - rules: [ - const PortRangeTriggeringRuleUIModel( + rules: const [ + PortRangeTriggeringRuleUIModel( isEnabled: true, firstTriggerPort: 3000, lastTriggerPort: 3000, @@ -260,7 +260,7 @@ void main() { lastForwardedPort: 3100, description: 'Rule 1', ), - const PortRangeTriggeringRuleUIModel( + PortRangeTriggeringRuleUIModel( isEnabled: false, firstTriggerPort: 4000, lastTriggerPort: 4100, diff --git a/test/page/advanced_settings/dmz/providers/dmz_settings_state_test.dart b/test/page/advanced_settings/dmz/providers/dmz_settings_state_test.dart index dd8411665..824ed9c0b 100644 --- a/test/page/advanced_settings/dmz/providers/dmz_settings_state_test.dart +++ b/test/page/advanced_settings/dmz/providers/dmz_settings_state_test.dart @@ -600,7 +600,7 @@ void main() { ); const state = DMZSettingsState( settings: preservableSettings, - status: const DMZStatus(), + status: DMZStatus(), ); const newSettings = DMZUISettings( @@ -634,7 +634,7 @@ void main() { ); const state = DMZSettingsState( settings: preservableSettings, - status: const DMZStatus(), + status: DMZStatus(), ); const newStatus = DMZStatus( @@ -663,7 +663,7 @@ void main() { ); const state = DMZSettingsState( settings: preservableSettings, - status: const DMZStatus(), + status: DMZStatus(), ); // Act @@ -747,7 +747,7 @@ void main() { ); const state = DMZSettingsState( settings: preservableSettings, - status: const DMZStatus(), + status: DMZStatus(), ); // Act @@ -774,7 +774,7 @@ void main() { ); const originalState = DMZSettingsState( settings: preservableSettings, - status: const DMZStatus(), + status: DMZStatus(), ); final json = originalState.toJson(); @@ -834,11 +834,11 @@ void main() { ); const state1 = DMZSettingsState( settings: preservableSettings, - status: const DMZStatus(), + status: DMZStatus(), ); const state2 = DMZSettingsState( settings: preservableSettings, - status: const DMZStatus(), + status: DMZStatus(), ); // Assert diff --git a/test/page/advanced_settings/dmz/providers/dmz_status_test.dart b/test/page/advanced_settings/dmz/providers/dmz_status_test.dart index 90cd0dc3a..4f96da0ef 100644 --- a/test/page/advanced_settings/dmz/providers/dmz_status_test.dart +++ b/test/page/advanced_settings/dmz/providers/dmz_status_test.dart @@ -40,7 +40,7 @@ void main() { }); test('fromMap uses defaults for missing values', () { - final status = DMZStatus.fromMap({}); + final status = DMZStatus.fromMap(const {}); expect(status.ipAddress, '192.168.1.1'); expect(status.subnetMask, '255.255.0.0'); }); diff --git a/test/page/advanced_settings/dmz/services/dmz_settings_service_test.dart b/test/page/advanced_settings/dmz/services/dmz_settings_service_test.dart index fc21ab1c3..29fe8a362 100644 --- a/test/page/advanced_settings/dmz/services/dmz_settings_service_test.dart +++ b/test/page/advanced_settings/dmz/services/dmz_settings_service_test.dart @@ -4,7 +4,6 @@ import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/jnap/actions/jnap_transaction.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; -import 'package:privacy_gui/core/jnap/models/dmz_settings.dart' as jnap_models; import 'package:privacy_gui/page/advanced_settings/dmz/providers/dmz_settings_state.dart'; import 'package:privacy_gui/page/advanced_settings/dmz/services/dmz_settings_service.dart'; @@ -214,7 +213,7 @@ void main() { cacheLevel: any(named: 'cacheLevel'), auth: any(named: 'auth'), data: any(named: 'data'), - )).thenAnswer((_) async => JNAPSuccess(result: 'ok', output: {})); + )).thenAnswer((_) async => JNAPSuccess(result: 'ok', output: const {})); final mockRef = UnitTestHelper.createMockRef( routerRepository: mockRepository, @@ -281,7 +280,7 @@ void main() { cacheLevel: any(named: 'cacheLevel'), auth: any(named: 'auth'), data: any(named: 'data'), - )).thenAnswer((_) async => JNAPSuccess(result: 'ok', output: {})); + )).thenAnswer((_) async => JNAPSuccess(result: 'ok', output: const {})); final mockRef = UnitTestHelper.createMockRef( routerRepository: mockRepository, diff --git a/test/page/advanced_settings/dmz/services/dmz_settings_service_test_data.dart b/test/page/advanced_settings/dmz/services/dmz_settings_service_test_data.dart index 24c2b9401..97fc3616e 100644 --- a/test/page/advanced_settings/dmz/services/dmz_settings_service_test_data.dart +++ b/test/page/advanced_settings/dmz/services/dmz_settings_service_test_data.dart @@ -21,7 +21,7 @@ class DMZSettingsTestData { 'maxNetworkPrefixLength': 30, 'minAllowedDHCPLeaseMinutes': 1, 'maxAllowedDHCPLeaseMinutes': 525600, - 'dhcpSettings': { + 'dhcpSettings': const { 'firstClientIPAddress': '192.168.1.10', 'lastClientIPAddress': '192.168.1.254', 'leaseMinutes': 1440, diff --git a/test/page/advanced_settings/firewall/providers/firewall_provider_test.dart b/test/page/advanced_settings/firewall/providers/firewall_provider_test.dart index 8eab799aa..b231b464d 100644 --- a/test/page/advanced_settings/firewall/providers/firewall_provider_test.dart +++ b/test/page/advanced_settings/firewall/providers/firewall_provider_test.dart @@ -4,7 +4,6 @@ import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/page/advanced_settings/firewall/providers/firewall_provider.dart'; import 'package:privacy_gui/page/advanced_settings/firewall/providers/firewall_state.dart'; -import 'package:privacy_gui/page/advanced_settings/firewall/services/firewall_settings_service.dart'; // Mock class for Ref class MockRef extends Mock implements Ref {} diff --git a/test/page/advanced_settings/firewall/providers/firewall_state_test.dart b/test/page/advanced_settings/firewall/providers/firewall_state_test.dart index 59d060b9d..b5af8f6a4 100644 --- a/test/page/advanced_settings/firewall/providers/firewall_state_test.dart +++ b/test/page/advanced_settings/firewall/providers/firewall_state_test.dart @@ -700,7 +700,7 @@ void main() { ); const state = FirewallState( settings: preservableSettings, - status: const EmptyStatus(), + status: EmptyStatus(), ); const newSettings = FirewallUISettings( @@ -746,7 +746,7 @@ void main() { ); const state = FirewallState( settings: preservableSettings, - status: const EmptyStatus(), + status: EmptyStatus(), ); const newStatus = EmptyStatus(); @@ -778,7 +778,7 @@ void main() { ); const state = FirewallState( settings: preservableSettings, - status: const EmptyStatus(), + status: EmptyStatus(), ); // Act @@ -809,7 +809,7 @@ void main() { ); const state = FirewallState( settings: preservableSettings, - status: const EmptyStatus(), + status: EmptyStatus(), ); // Act @@ -839,7 +839,7 @@ void main() { ); const originalState = FirewallState( settings: preservableSettings, - status: const EmptyStatus(), + status: EmptyStatus(), ); final map = originalState.toMap(); @@ -870,7 +870,7 @@ void main() { ); const originalState = FirewallState( settings: preservableSettings, - status: const EmptyStatus(), + status: EmptyStatus(), ); // Act @@ -901,7 +901,7 @@ void main() { ); const originalState = FirewallState( settings: preservableSettings, - status: const EmptyStatus(), + status: EmptyStatus(), ); final map = originalState.toMap(); final json = jsonEncode(map); diff --git a/test/page/advanced_settings/firewall/providers/ipv6_port_service_list_provider_test.dart b/test/page/advanced_settings/firewall/providers/ipv6_port_service_list_provider_test.dart index f0bd52e38..377b9b14d 100644 --- a/test/page/advanced_settings/firewall/providers/ipv6_port_service_list_provider_test.dart +++ b/test/page/advanced_settings/firewall/providers/ipv6_port_service_list_provider_test.dart @@ -105,7 +105,6 @@ void main() { // Act notifier.setRules(rules); - final state = container.read(ipv6PortServiceListProvider); // Assert expect(notifier.isExceedMax(), false); @@ -128,7 +127,6 @@ void main() { // Act notifier.setRules(rules); - final state = container.read(ipv6PortServiceListProvider); // Assert expect(notifier.isExceedMax(), true); @@ -873,12 +871,12 @@ void main() { group('performFetch integration', () { test('fetches rules via service provider', () async { // Arrange - final testRules = IPv6PortServiceRuleUIList(rules: [ + final testRules = IPv6PortServiceRuleUIList(rules: const [ IPv6PortServiceRuleUI( enabled: true, description: 'Test Rule', ipv6Address: '2001:db8::1', - portRanges: const [ + portRanges: [ PortRangeUI(protocol: 'TCP', firstPort: 80, lastPort: 80) ], ), @@ -886,7 +884,7 @@ void main() { enabled: false, description: 'Another Rule', ipv6Address: '2001:db8::2', - portRanges: const [ + portRanges: [ PortRangeUI(protocol: 'UDP', firstPort: 53, lastPort: 53) ], ) @@ -913,12 +911,12 @@ void main() { test('fetches with forceRemote parameter', () async { // Arrange - final testRules = IPv6PortServiceRuleUIList(rules: [ + final testRules = IPv6PortServiceRuleUIList(rules: const [ IPv6PortServiceRuleUI( enabled: true, description: 'Remote Rule', ipv6Address: '2001:db8::1', - portRanges: const [ + portRanges: [ PortRangeUI(protocol: 'TCP', firstPort: 22, lastPort: 22) ], ) diff --git a/test/page/advanced_settings/firewall/providers/ipv6_port_service_rule_state_test.dart b/test/page/advanced_settings/firewall/providers/ipv6_port_service_rule_state_test.dart index dbc2016e3..687e71578 100644 --- a/test/page/advanced_settings/firewall/providers/ipv6_port_service_rule_state_test.dart +++ b/test/page/advanced_settings/firewall/providers/ipv6_port_service_rule_state_test.dart @@ -99,7 +99,7 @@ void main() { lastPort: 8080, ); - final portRange = PortRangeUI.fromMap({ + final portRange = PortRangeUI.fromMap(const { 'protocol': 'TCP', 'firstPort': 80, 'lastPort': 8080, @@ -258,7 +258,7 @@ void main() { ], ); - final rule = IPv6PortServiceRuleUI.fromMap({ + final rule = IPv6PortServiceRuleUI.fromMap(const { 'enabled': true, 'description': 'Test', 'ipv6Address': '2001:db8::1', @@ -472,7 +472,7 @@ void main() { ), ]); - final list = IPv6PortServiceRuleUIList.fromMap({ + final list = IPv6PortServiceRuleUIList.fromMap(const { 'rules': [ { 'enabled': true, diff --git a/test/page/advanced_settings/firewall/services/firewall_settings_service_test.dart b/test/page/advanced_settings/firewall/services/firewall_settings_service_test.dart index 5cce84078..fd690ffdb 100644 --- a/test/page/advanced_settings/firewall/services/firewall_settings_service_test.dart +++ b/test/page/advanced_settings/firewall/services/firewall_settings_service_test.dart @@ -318,8 +318,6 @@ void main() { routerRepository: mockRepository, ); - final uiSettings = - FirewallSettingsTestData.createSuccessfulResponse().output; final firewallUISettings = service.fetchFirewallSettings(mockRef).then((value) => value.$1!); diff --git a/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test.dart b/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test.dart index dbb9c41ac..40a441ab6 100644 --- a/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test.dart +++ b/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test.dart @@ -36,8 +36,8 @@ void main() { isEnabled: true, description: 'SSH Access', ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 22, lastPort: 22), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 22, lastPort: 22), ], ); @@ -62,17 +62,17 @@ void main() { isEnabled: true, description: 'Web Server', ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 80, lastPort: 80), - const PortRange(protocol: 'TCP', firstPort: 443, lastPort: 443), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 80, lastPort: 80), + PortRange(protocol: 'TCP', firstPort: 443, lastPort: 443), ], ), IPv6FirewallRule( isEnabled: false, description: 'DNS', ipv6Address: '2001:db8::2', - portRanges: [ - const PortRange(protocol: 'UDP', firstPort: 53, lastPort: 53), + portRanges: const [ + PortRange(protocol: 'UDP', firstPort: 53, lastPort: 53), ], ), ]; @@ -101,24 +101,24 @@ void main() { isEnabled: true, description: 'TCP Rule', ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 1000, lastPort: 2000), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 1000, lastPort: 2000), ], ), IPv6FirewallRule( isEnabled: true, description: 'UDP Rule', ipv6Address: '2001:db8::2', - portRanges: [ - const PortRange(protocol: 'UDP', firstPort: 3000, lastPort: 4000), + portRanges: const [ + PortRange(protocol: 'UDP', firstPort: 3000, lastPort: 4000), ], ), IPv6FirewallRule( isEnabled: true, description: 'Both Rule', ipv6Address: '2001:db8::3', - portRanges: [ - const PortRange( + portRanges: const [ + PortRange( protocol: 'Both', firstPort: 5000, lastPort: 6000), ], ), @@ -136,8 +136,8 @@ void main() { isEnabled: true, description: 'Edge Ports', ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 0, lastPort: 65535), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 0, lastPort: 65535), ], ); @@ -154,8 +154,8 @@ void main() { isEnabled: true, description: 'Test-Rule_#123!@Special', ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 8080, lastPort: 8080), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 8080, lastPort: 8080), ], ); @@ -170,8 +170,8 @@ void main() { isEnabled: true, description: longDescription, ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 80, lastPort: 80), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 80, lastPort: 80), ], ); @@ -186,8 +186,8 @@ void main() { isEnabled: true, description: 'Test', ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 80, lastPort: 80), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 80, lastPort: 80), ], ); @@ -220,9 +220,9 @@ void main() { isEnabled: true, description: 'Preserve Test', ipv6Address: '2001:db8::10', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 1000, lastPort: 2000), - const PortRange(protocol: 'UDP', firstPort: 3000, lastPort: 4000), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 1000, lastPort: 2000), + PortRange(protocol: 'UDP', firstPort: 3000, lastPort: 4000), ], ); @@ -277,8 +277,8 @@ void main() { isEnabled: true, description: 'Single Port', ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 80, lastPort: 80), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 80, lastPort: 80), ], ); @@ -388,9 +388,9 @@ void main() { isEnabled: true, description: 'Round-trip Test', ipv6Address: '2001:db8::10', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 1000, lastPort: 2000), - const PortRange(protocol: 'UDP', firstPort: 3000, lastPort: 4000), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 1000, lastPort: 2000), + PortRange(protocol: 'UDP', firstPort: 3000, lastPort: 4000), ], ); @@ -462,8 +462,8 @@ void main() { isEnabled: true, description: 'Bad Protocol', ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'INVALID', firstPort: 80, lastPort: 80), + portRanges: const [ + PortRange(protocol: 'INVALID', firstPort: 80, lastPort: 80), ], ); @@ -481,8 +481,8 @@ void main() { isEnabled: true, description: 'Bad Port', ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 65536, lastPort: 65537), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 65536, lastPort: 65537), ], ); @@ -500,8 +500,8 @@ void main() { isEnabled: true, description: 'Reversed Range', ipv6Address: '2001:db8::1', - portRanges: [ - const PortRange(protocol: 'TCP', firstPort: 443, lastPort: 80), + portRanges: const [ + PortRange(protocol: 'TCP', firstPort: 443, lastPort: 80), ], ); diff --git a/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test_data.dart b/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test_data.dart index 363792868..57243eb7b 100644 --- a/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test_data.dart +++ b/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test_data.dart @@ -23,7 +23,7 @@ class IPv6PortServiceTestData { JNAPSuccess( result: 'OK', output: { - 'rules': [], + 'rules': const [], 'maxRules': maxRules, 'maxDescriptionLength': maxDescriptionLength, }, @@ -86,7 +86,7 @@ class IPv6PortServiceTestData { JNAPSuccess( result: 'OK', output: { - 'rules': [ + 'rules': const [ { 'description': 'Web Server', 'ipv6Address': '2001:db8::1', diff --git a/test/page/advanced_settings/internet_settings/models/internet_settings_ui_model_test.dart b/test/page/advanced_settings/internet_settings/models/internet_settings_ui_model_test.dart index 00ffeb897..cb2932e1a 100644 --- a/test/page/advanced_settings/internet_settings/models/internet_settings_ui_model_test.dart +++ b/test/page/advanced_settings/internet_settings/models/internet_settings_ui_model_test.dart @@ -214,7 +214,7 @@ void main() { test('toMap and fromMap work correctly', () { // Arrange - final original = const InternetSettingsStatusUIModel( + const original = InternetSettingsStatusUIModel( supportedIPv4ConnectionType: ['DHCP', 'PPPoE'], supportedWANCombinations: [], supportedIPv6ConnectionType: ['Automatic'], @@ -235,7 +235,7 @@ void main() { test('toJson and fromJson work correctly', () { // Arrange - final original = const InternetSettingsStatusUIModel( + const original = InternetSettingsStatusUIModel( supportedIPv4ConnectionType: ['Static'], supportedWANCombinations: [], supportedIPv6ConnectionType: ['PPPoE'], @@ -254,7 +254,7 @@ void main() { test('equality works correctly', () { // Arrange - final model1 = const InternetSettingsStatusUIModel( + const model1 = InternetSettingsStatusUIModel( supportedIPv4ConnectionType: ['DHCP'], supportedWANCombinations: [], supportedIPv6ConnectionType: ['Automatic'], @@ -263,7 +263,7 @@ void main() { redirection: null, ); - final model2 = const InternetSettingsStatusUIModel( + const model2 = InternetSettingsStatusUIModel( supportedIPv4ConnectionType: ['DHCP'], supportedWANCombinations: [], supportedIPv6ConnectionType: ['Automatic'], diff --git a/test/page/advanced_settings/internet_settings/services/internet_settings_service_test.dart b/test/page/advanced_settings/internet_settings/services/internet_settings_service_test.dart index b70675f6c..56aed1c76 100644 --- a/test/page/advanced_settings/internet_settings/services/internet_settings_service_test.dart +++ b/test/page/advanced_settings/internet_settings/services/internet_settings_service_test.dart @@ -351,18 +351,18 @@ void main() { final mockTransaction = JNAPTransactionSuccessWrap( result: 'OK', - data: [ + data: const [ MapEntry( JNAPAction.setMACAddressCloneSettings, - const JNAPSuccess(result: 'OK', output: {}), + JNAPSuccess(result: 'OK', output: {}), ), MapEntry( JNAPAction.setWANSettings, - const JNAPSuccess(result: 'OK', output: {}), + JNAPSuccess(result: 'OK', output: {}), ), MapEntry( JNAPAction.setIPv6Settings, - const JNAPSuccess(result: 'OK', output: {}), + JNAPSuccess(result: 'OK', output: {}), ), ], ); @@ -394,18 +394,18 @@ void main() { final mockTransaction = JNAPTransactionSuccessWrap( result: 'OK', - data: [ + data: const [ MapEntry( JNAPAction.setMACAddressCloneSettings, - const JNAPSuccess(result: 'OK', output: {}), + JNAPSuccess(result: 'OK', output: {}), ), MapEntry( JNAPAction.setWANSettings, - const JNAPSuccess(result: 'OK', output: {}), + JNAPSuccess(result: 'OK', output: {}), ), MapEntry( JNAPAction.setIPv6Settings, - const JNAPSuccess(result: 'OK', output: {}), + JNAPSuccess(result: 'OK', output: {}), ), ], ); @@ -439,14 +439,14 @@ void main() { final mockTransaction = JNAPTransactionSuccessWrap( result: 'OK', - data: [ + data: const [ MapEntry( JNAPAction.setMACAddressCloneSettings, - const JNAPSuccess(result: 'OK', output: {}), + JNAPSuccess(result: 'OK', output: {}), ), MapEntry( JNAPAction.setWANSettings, - const JNAPSuccess( + JNAPSuccess( result: 'OK', output: { 'redirection': {'url': 'http://192.168.1.1'} @@ -455,7 +455,7 @@ void main() { ), MapEntry( JNAPAction.setIPv6Settings, - const JNAPSuccess(result: 'OK', output: {}), + JNAPSuccess(result: 'OK', output: {}), ), ], ); @@ -523,14 +523,14 @@ void main() { cacheLevel: any(named: 'cacheLevel'), )).thenAnswer((_) async => JNAPTransactionSuccessWrap( result: 'OK', - data: [ + data: const [ MapEntry( JNAPAction.setMACAddressCloneSettings, - const JNAPSuccess(result: 'OK', output: {}), + JNAPSuccess(result: 'OK', output: {}), ), MapEntry( JNAPAction.setWANSettings, - const JNAPSuccess(result: 'OK', output: {}), + JNAPSuccess(result: 'OK', output: {}), ), ], )); @@ -604,7 +604,7 @@ void main() { ); // Create ServiceSideEffectError without originalResult - final sideEffectError = const ServiceSideEffectError(null); + const sideEffectError = ServiceSideEffectError(null); when(() => mockRepo.transaction( any(), diff --git a/test/page/advanced_settings/internet_settings/views/localizations/internet_settings_view_test.dart b/test/page/advanced_settings/internet_settings/views/localizations/internet_settings_view_test.dart index d2ce2b313..69d16a5fb 100644 --- a/test/page/advanced_settings/internet_settings/views/localizations/internet_settings_view_test.dart +++ b/test/page/advanced_settings/internet_settings/views/localizations/internet_settings_view_test.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:privacy_gui/core/utils/extension.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/internet_settings_state.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/views/internet_settings_view.dart'; import 'package:ui_kit_library/ui_kit.dart'; diff --git a/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_provider_test.dart b/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_provider_test.dart index dc1390e47..050170077 100644 --- a/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_provider_test.dart +++ b/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_provider_test.dart @@ -311,7 +311,7 @@ void main() { ), ); notifier - .updateSettings(DHCPReservationsSettings(reservations: [newItem])); + .updateSettings(DHCPReservationsSettings(reservations: const [newItem])); final state = container.read(dhcpReservationProvider); expect(state.settings.current.reservations, hasLength(1)); diff --git a/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_state_test.dart b/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_state_test.dart index fa5aae543..d5886659c 100644 --- a/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_state_test.dart +++ b/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_state_test.dart @@ -1,7 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/page/advanced_settings/local_network_settings/models/dhcp_reservation_ui_model.dart'; import 'package:privacy_gui/page/advanced_settings/local_network_settings/providers/dhcp_reservations_state.dart'; -import 'package:privacy_gui/providers/preservable.dart'; void main() { group('DHCPReservationsSettings', () { diff --git a/test/page/advanced_settings/local_network_settings/providers/local_network_settings_state_test.dart b/test/page/advanced_settings/local_network_settings/providers/local_network_settings_state_test.dart index 0ed0d1359..567e64209 100644 --- a/test/page/advanced_settings/local_network_settings/providers/local_network_settings_state_test.dart +++ b/test/page/advanced_settings/local_network_settings/providers/local_network_settings_state_test.dart @@ -1,7 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/advanced_settings/local_network_settings/models/dhcp_reservation_ui_model.dart'; import 'package:privacy_gui/page/advanced_settings/local_network_settings/providers/local_network_settings_state.dart'; -import 'package:privacy_gui/providers/preservable.dart'; void main() { group('LocalNetworkSettings', () { diff --git a/test/page/advanced_settings/static_routing/models/static_route_entry_ui_model_test.dart b/test/page/advanced_settings/static_routing/models/static_route_entry_ui_model_test.dart index f48b8a584..b0d1496c1 100644 --- a/test/page/advanced_settings/static_routing/models/static_route_entry_ui_model_test.dart +++ b/test/page/advanced_settings/static_routing/models/static_route_entry_ui_model_test.dart @@ -60,7 +60,7 @@ void main() { }); test('fromMap uses defaults for missing values', () { - final model = StaticRouteEntryUIModel.fromMap({}); + final model = StaticRouteEntryUIModel.fromMap(const {}); expect(model.name, ''); expect(model.interface, 'LAN'); }); diff --git a/test/page/advanced_settings/static_routing/models/static_routing_rule_ui_model_test.dart b/test/page/advanced_settings/static_routing/models/static_routing_rule_ui_model_test.dart index 42b8443af..6e6c3443b 100644 --- a/test/page/advanced_settings/static_routing/models/static_routing_rule_ui_model_test.dart +++ b/test/page/advanced_settings/static_routing/models/static_routing_rule_ui_model_test.dart @@ -75,7 +75,7 @@ void main() { }); test('fromMap uses defaults for missing values', () { - final model = StaticRoutingRuleUIModel.fromMap({}); + final model = StaticRoutingRuleUIModel.fromMap(const {}); expect(model.name, ''); expect(model.networkPrefixLength, 24); expect(model.interface, 'LAN'); diff --git a/test/page/advanced_settings/static_routing/providers/static_routing_rule_provider_test.dart b/test/page/advanced_settings/static_routing/providers/static_routing_rule_provider_test.dart index 444ac62ae..806f9af21 100644 --- a/test/page/advanced_settings/static_routing/providers/static_routing_rule_provider_test.dart +++ b/test/page/advanced_settings/static_routing/providers/static_routing_rule_provider_test.dart @@ -2,7 +2,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/page/advanced_settings/static_routing/models/static_routing_rule_ui_model.dart'; import 'package:privacy_gui/page/advanced_settings/static_routing/providers/static_routing_rule_provider.dart'; -import 'package:privacy_gui/page/advanced_settings/static_routing/providers/static_routing_rule_state.dart'; void main() { group('StaticRoutingRuleNotifier', () { @@ -17,7 +16,6 @@ void main() { }); test('builds with default initial state', () { - final notifier = container.read(staticRoutingRuleProvider.notifier); final state = container.read(staticRoutingRuleProvider); expect(state.routerIp, '192.168.1.1'); diff --git a/test/page/advanced_settings/static_routing/providers/static_routing_state_test.dart b/test/page/advanced_settings/static_routing/providers/static_routing_state_test.dart index decd5cbbc..7b37cf86a 100644 --- a/test/page/advanced_settings/static_routing/providers/static_routing_state_test.dart +++ b/test/page/advanced_settings/static_routing/providers/static_routing_state_test.dart @@ -1,7 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/page/advanced_settings/static_routing/models/static_route_entry_ui_model.dart'; import 'package:privacy_gui/page/advanced_settings/static_routing/providers/static_routing_state.dart'; -import 'dart:convert'; void main() { group('StaticRouteEntryUI - Model Tests', () { @@ -150,7 +149,7 @@ void main() { final settings = StaticRoutingSettings( isNATEnabled: true, isDynamicRoutingEnabled: false, - entries: [], + entries: const [], ); // Assert @@ -164,7 +163,7 @@ void main() { final original = StaticRoutingSettings( isNATEnabled: true, isDynamicRoutingEnabled: false, - entries: [], + entries: const [], ); // Act @@ -181,17 +180,17 @@ void main() { final settings1 = StaticRoutingSettings( isNATEnabled: true, isDynamicRoutingEnabled: false, - entries: [], + entries: const [], ); final settings2 = StaticRoutingSettings( isNATEnabled: true, isDynamicRoutingEnabled: false, - entries: [], + entries: const [], ); final settings3 = StaticRoutingSettings( isNATEnabled: false, isDynamicRoutingEnabled: false, - entries: [], + entries: const [], ); // Assert @@ -246,7 +245,7 @@ void main() { final original = StaticRoutingSettings( isNATEnabled: true, isDynamicRoutingEnabled: false, - entries: [], + entries: const [], ); // Act diff --git a/test/page/advanced_settings/static_routing/services/static_routing_service_test.dart b/test/page/advanced_settings/static_routing/services/static_routing_service_test.dart index baddc6422..5d484d787 100644 --- a/test/page/advanced_settings/static_routing/services/static_routing_service_test.dart +++ b/test/page/advanced_settings/static_routing/services/static_routing_service_test.dart @@ -5,7 +5,6 @@ import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/page/advanced_settings/static_routing/models/static_route_entry_ui_model.dart'; import 'package:privacy_gui/page/advanced_settings/static_routing/services/static_routing_service.dart'; -import 'package:privacy_gui/page/advanced_settings/static_routing/providers/static_routing_state.dart'; import '../../../../mocks/test_data/static_routing_test_data.dart'; class MockRouterRepository extends Mock implements RouterRepository {} diff --git a/test/page/components/localizations/snack_bar_test.dart b/test/page/components/localizations/snack_bar_test.dart index dc3d59892..a311eb785 100644 --- a/test/page/components/localizations/snack_bar_test.dart +++ b/test/page/components/localizations/snack_bar_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/route/route_model.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -108,7 +107,7 @@ void main() { 'Failed: Unknown error: _ErrorUnexpected', ]; - final startIndex = 6; // Success buttons (0-5) take up first 6 slots + const startIndex = 6; // Success buttons (0-5) take up first 6 slots for (var i = 0; i < failedButtons.length; i++) { final label = failedButtons[i]; diff --git a/test/page/dashboard/views/components/loading_tile_test.dart b/test/page/dashboard/views/components/loading_tile_test.dart index ca9a47078..4b9df57b0 100644 --- a/test/page/dashboard/views/components/loading_tile_test.dart +++ b/test/page/dashboard/views/components/loading_tile_test.dart @@ -2,7 +2,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:ui_kit_library/ui_kit.dart'; -import 'package:mockito/mockito.dart'; void main() { // Use a simple test wrapper that provides the AppDesignTheme diff --git a/test/page/health_check/providers/health_check_provider_test.dart b/test/page/health_check/providers/health_check_provider_test.dart index d8ac8529e..ef2b23098 100644 --- a/test/page/health_check/providers/health_check_provider_test.dart +++ b/test/page/health_check/providers/health_check_provider_test.dart @@ -252,7 +252,7 @@ void main() { expect(state.result, partialResult); expect(state.step, HealthCheckStep.uploadBandwidth); // Meter value should have changed from 0.0 (initial) to a predictable non-zero value - final expectedRandomValue = (0.5 * (15 - (-3)) + (-3)) * 1024; + const expectedRandomValue = (0.5 * (15 - (-3)) + (-3)) * 1024; expect(state.meterValue, expectedRandomValue); }); diff --git a/test/page/instant_admin/providers/manual_firmware_update_provider_test.dart b/test/page/instant_admin/providers/manual_firmware_update_provider_test.dart index 8582b209b..a008a2b98 100644 --- a/test/page/instant_admin/providers/manual_firmware_update_provider_test.dart +++ b/test/page/instant_admin/providers/manual_firmware_update_provider_test.dart @@ -49,7 +49,7 @@ void main() { test('setFile updates the state with file info', () { final notifier = container.read(manualFirmwareUpdateProvider.notifier); - final fileName = 'test.img'; + const fileName = 'test.img'; final fileBytes = Uint8List.fromList([1, 2, 3]); notifier.setFile(fileName, fileBytes); @@ -93,7 +93,7 @@ void main() { test('manualFirmwareUpdate handles null localPassword', () async { final notifier = container.read(manualFirmwareUpdateProvider.notifier); - final fileName = 'firmware.img'; + const fileName = 'firmware.img'; final bytes = Uint8List.fromList([1, 2, 3]); notifier.setFile(fileName, bytes); @@ -115,7 +115,7 @@ void main() { test('manualFirmwareUpdate calls pollingProvider.notifier.stopPolling', () async { final notifier = container.read(manualFirmwareUpdateProvider.notifier); - final fileName = 'firmware.img'; + const fileName = 'firmware.img'; final bytes = Uint8List.fromList([1, 2, 3]); notifier.setFile(fileName, bytes); @@ -131,7 +131,7 @@ void main() { test('manualFirmwareUpdate sets status to installing on service success', () async { final notifier = container.read(manualFirmwareUpdateProvider.notifier); - final fileName = 'firmware.img'; + const fileName = 'firmware.img'; final bytes = Uint8List.fromList([1, 2, 3]); notifier.setFile(fileName, bytes); @@ -149,7 +149,7 @@ void main() { test('manualFirmwareUpdate sets status to null on service failure', () async { final notifier = container.read(manualFirmwareUpdateProvider.notifier); - final fileName = 'firmware.img'; + const fileName = 'firmware.img'; final bytes = Uint8List.fromList([1, 2, 3]); notifier.setFile(fileName, bytes); diff --git a/test/page/instant_admin/providers/timezone_state_test.dart b/test/page/instant_admin/providers/timezone_state_test.dart index 3dd86e5bb..d449cdcb0 100644 --- a/test/page/instant_admin/providers/timezone_state_test.dart +++ b/test/page/instant_admin/providers/timezone_state_test.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/core/jnap/models/timezone.dart'; diff --git a/test/page/instant_admin/services/router_password_service_test.dart b/test/page/instant_admin/services/router_password_service_test.dart index 9e7df356d..f1cc13935 100644 --- a/test/page/instant_admin/services/router_password_service_test.dart +++ b/test/page/instant_admin/services/router_password_service_test.dart @@ -4,7 +4,6 @@ import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/constants/_constants.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/jnap/actions/jnap_transaction.dart'; -import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/page/instant_admin/services/router_password_service.dart'; diff --git a/test/page/instant_admin/services/timezone_service_test.dart b/test/page/instant_admin/services/timezone_service_test.dart index e429b6010..c31437e6c 100644 --- a/test/page/instant_admin/services/timezone_service_test.dart +++ b/test/page/instant_admin/services/timezone_service_test.dart @@ -121,7 +121,7 @@ void main() { .thenAnswer( (_) async => InstantAdminTestData.createSetTimeSettingsSuccess()); - final settings = const TimezoneSettings( + const settings = TimezoneSettings( timezoneId: 'PST8', isDaylightSaving: true, ); @@ -162,7 +162,7 @@ void main() { (_) async => InstantAdminTestData.createSetTimeSettingsSuccess()); // Timezone that does NOT observe DST - final settings = const TimezoneSettings( + const settings = TimezoneSettings( timezoneId: 'JST-9', isDaylightSaving: true, // User selected DST, but timezone doesn't support it @@ -201,7 +201,7 @@ void main() { auth: true, )).thenThrow(InstantAdminTestData.createGenericError()); - final settings = const TimezoneSettings( + const settings = TimezoneSettings( timezoneId: 'PST8', isDaylightSaving: true, ); diff --git a/test/page/instant_privacy/providers/instant_privacy_state_test.dart b/test/page/instant_privacy/providers/instant_privacy_state_test.dart index 97ac81dd4..2d0ce47b9 100644 --- a/test/page/instant_privacy/providers/instant_privacy_state_test.dart +++ b/test/page/instant_privacy/providers/instant_privacy_state_test.dart @@ -62,7 +62,7 @@ void main() { }); test('copyWith creates new instance with updated mode', () { - final original = const InstantPrivacyStatus(mode: MacFilterMode.disabled); + const original = InstantPrivacyStatus(mode: MacFilterMode.disabled); final copied = original.copyWith(mode: MacFilterMode.allow); expect(copied.mode, MacFilterMode.allow); @@ -70,7 +70,7 @@ void main() { }); test('copyWith retains original value when not specified', () { - final original = const InstantPrivacyStatus(mode: MacFilterMode.allow); + const original = InstantPrivacyStatus(mode: MacFilterMode.allow); final copied = original.copyWith(); expect(copied.mode, MacFilterMode.allow); diff --git a/test/page/instant_safety/services/instant_safety_service_test.dart b/test/page/instant_safety/services/instant_safety_service_test.dart index 3dad4d43f..6883802a6 100644 --- a/test/page/instant_safety/services/instant_safety_service_test.dart +++ b/test/page/instant_safety/services/instant_safety_service_test.dart @@ -169,7 +169,7 @@ void main() { auth: any(named: 'auth'), cacheLevel: any(named: 'cacheLevel'), data: any(named: 'data'), - )).thenAnswer((_) async => JNAPSuccess(result: 'OK', output: {})); + )).thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const {})); // Fetch first to cache settings await service.fetchSettings(deviceInfo: null, forceRemote: false); @@ -205,7 +205,7 @@ void main() { auth: any(named: 'auth'), cacheLevel: any(named: 'cacheLevel'), data: any(named: 'data'), - )).thenAnswer((_) async => JNAPSuccess(result: 'OK', output: {})); + )).thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const {})); await service.fetchSettings(deviceInfo: null, forceRemote: false); @@ -241,7 +241,7 @@ void main() { auth: any(named: 'auth'), cacheLevel: any(named: 'cacheLevel'), data: any(named: 'data'), - )).thenAnswer((_) async => JNAPSuccess(result: 'OK', output: {})); + )).thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const {})); await service.fetchSettings(deviceInfo: null, forceRemote: false); diff --git a/test/page/instant_setup/pnp_step_state_test.dart b/test/page/instant_setup/pnp_step_state_test.dart index ebd53214f..845cbab41 100644 --- a/test/page/instant_setup/pnp_step_state_test.dart +++ b/test/page/instant_setup/pnp_step_state_test.dart @@ -92,7 +92,7 @@ void main() { final newStepState = PnpStepState( status: StepViewStatus.error, - data: {'ssid': 'NewWiFi', 'password': 'newpassword'}, + data: const {'ssid': 'NewWiFi', 'password': 'newpassword'}, error: Exception('New error'), ); pnpNotifier.setStepState(stepId, newStepState); diff --git a/test/page/instant_setup/troubleshooter/providers/pnp_isp_settings_provider_test.dart b/test/page/instant_setup/troubleshooter/providers/pnp_isp_settings_provider_test.dart index d712727d3..66181d371 100644 --- a/test/page/instant_setup/troubleshooter/providers/pnp_isp_settings_provider_test.dart +++ b/test/page/instant_setup/troubleshooter/providers/pnp_isp_settings_provider_test.dart @@ -1,7 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; -import 'package:privacy_gui/page/advanced_settings/internet_settings/models/internet_settings_enums.dart'; import 'package:privacy_gui/page/advanced_settings/internet_settings/providers/_providers.dart'; import 'package:privacy_gui/page/instant_setup/services/pnp_service.dart'; import 'package:privacy_gui/page/instant_setup/troubleshooter/providers/_providers.dart'; diff --git a/test/page/instant_setup/troubleshooter/services/pnp_isp_service_test.dart b/test/page/instant_setup/troubleshooter/services/pnp_isp_service_test.dart index 92380add2..ec4263b58 100644 --- a/test/page/instant_setup/troubleshooter/services/pnp_isp_service_test.dart +++ b/test/page/instant_setup/troubleshooter/services/pnp_isp_service_test.dart @@ -15,7 +15,7 @@ void main() { late PnpIspService pnpIspService; // Helper for common mock output - Map _baseWanStatusOutput({ + Map baseWanStatusOutput({ String wanStatus = 'Connecting', String ipAddress = '0.0.0.0', bool includeWanConnection = false, @@ -55,7 +55,7 @@ void main() { // ARRANGE final successResult = JNAPSuccess( result: jnapResultOk, - output: _baseWanStatusOutput(wanStatus: 'Connected'), + output: baseWanStatusOutput(wanStatus: 'Connected'), ); when(mockRouterRepository.scheduledCommand( @@ -80,7 +80,7 @@ void main() { // ARRANGE final successResult = JNAPSuccess( result: jnapResultOk, - output: _baseWanStatusOutput( + output: baseWanStatusOutput( wanStatus: 'Connected', // PPPoE also needs to be 'Connected' ipAddress: '123.45.67.89', includeWanConnection: true, @@ -110,7 +110,7 @@ void main() { final nonMatchingResult = JNAPSuccess( result: jnapResultOk, output: - _baseWanStatusOutput(wanStatus: 'Connecting'), // Not 'Connected' + baseWanStatusOutput(wanStatus: 'Connecting'), // Not 'Connected' ); when(mockRouterRepository.scheduledCommand( @@ -145,7 +145,7 @@ void main() { // ARRANGE final nonMatchingResult = JNAPSuccess( result: jnapResultOk, - output: _baseWanStatusOutput( + output: baseWanStatusOutput( wanStatus: 'Connected', // Status is connected, but IP is invalid ipAddress: '0.0.0.0', includeWanConnection: true, diff --git a/test/page/instant_verify/models/instant_verify_ui_models_test.dart b/test/page/instant_verify/models/instant_verify_ui_models_test.dart index 6ab2a0785..daf75e9cf 100644 --- a/test/page/instant_verify/models/instant_verify_ui_models_test.dart +++ b/test/page/instant_verify/models/instant_verify_ui_models_test.dart @@ -204,7 +204,7 @@ void main() { test('fromJnap creates correct model from JNAP GetRadioInfo', () { final jnapModel = GetRadioInfo( isBandSteeringSupported: true, - radios: [ + radios: const [ RouterRadio( radioID: 'RADIO_2.4GHz', physicalRadioID: 'phy0', @@ -266,9 +266,9 @@ void main() { physicalRadioID: 'phy0', bssid: '00:11:22:33:44:55', band: '2.4GHz', - supportedModes: ['802.11bgn'], - supportedChannelsForChannelWidths: [], - supportedSecurityTypes: ['WPA2-Personal'], + supportedModes: const ['802.11bgn'], + supportedChannelsForChannelWidths: const [], + supportedSecurityTypes: const ['WPA2-Personal'], maxRadiusSharedKeyLength: 64, settings: RouterRadioSettings( isEnabled: false, diff --git a/test/page/instant_verify/providers/instant_verify_provider_test.dart b/test/page/instant_verify/providers/instant_verify_provider_test.dart index 816e14c3a..3cfcf5c77 100644 --- a/test/page/instant_verify/providers/instant_verify_provider_test.dart +++ b/test/page/instant_verify/providers/instant_verify_provider_test.dart @@ -213,7 +213,7 @@ void main() { .thenReturn(RadioInfoUIModel.initial()); when(() => mockService.parseGuestRadioSettings(any())) .thenReturn(GuestRadioSettingsUIModel.initial()); - when(() => mockService.stopTraceroute()).thenAnswer((_) async => null); + when(() => mockService.stopTraceroute()).thenAnswer((_) async {}); container = createContainer(); final notifier = container.read(instantVerifyProvider.notifier); diff --git a/test/page/instant_verify/providers/instant_verify_state_test.dart b/test/page/instant_verify/providers/instant_verify_state_test.dart index 0b989ec19..306eefb7a 100644 --- a/test/page/instant_verify/providers/instant_verify_state_test.dart +++ b/test/page/instant_verify/providers/instant_verify_state_test.dart @@ -58,7 +58,7 @@ void main() { ), radioInfo: RadioInfoUIModel( isBandSteeringSupported: true, - radios: [ + radios: const [ RouterRadioUIModel( radioID: 'RADIO_2.4GHz', band: '2.4GHz', @@ -73,7 +73,7 @@ void main() { guestRadioSettings: GuestRadioSettingsUIModel( isGuestNetworkACaptivePortal: false, isGuestNetworkEnabled: true, - radios: [ + radios: const [ GuestRadioUIModel( radioID: 'RADIO_2.4GHz', isEnabled: true, diff --git a/test/page/wifi_settings/providers/wifi_advanced_state_test.dart b/test/page/wifi_settings/providers/wifi_advanced_state_test.dart index 05c8786f2..79a9f99eb 100644 --- a/test/page/wifi_settings/providers/wifi_advanced_state_test.dart +++ b/test/page/wifi_settings/providers/wifi_advanced_state_test.dart @@ -68,7 +68,7 @@ void main() { }); test('fromMap handles null values', () { - final state = WifiAdvancedSettingsState.fromMap({}); + final state = WifiAdvancedSettingsState.fromMap(const {}); expect(state.isIptvEnabled, isNull); expect(state.isMLOEnabled, isNull); }); diff --git a/test/page/wifi_settings/providers/wifi_state_test.dart b/test/page/wifi_settings/providers/wifi_state_test.dart index fcbad73cd..9d5ec80e4 100644 --- a/test/page/wifi_settings/providers/wifi_state_test.dart +++ b/test/page/wifi_settings/providers/wifi_state_test.dart @@ -67,12 +67,12 @@ void main() { test('equality comparison works', () { final s1 = WiFiListSettings( - mainWiFi: [], + mainWiFi: const [], guestWiFi: createGuestItem(), simpleModeWifi: createWifiItem(), ); final s2 = WiFiListSettings( - mainWiFi: [], + mainWiFi: const [], guestWiFi: createGuestItem(), simpleModeWifi: createWifiItem(), ); diff --git a/test/page/wifi_settings/services/channel_finder_service_test.dart b/test/page/wifi_settings/services/channel_finder_service_test.dart index ff7d41c90..4c549a00e 100644 --- a/test/page/wifi_settings/services/channel_finder_service_test.dart +++ b/test/page/wifi_settings/services/channel_finder_service_test.dart @@ -1,10 +1,8 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; -import 'package:privacy_gui/page/wifi_settings/providers/channelfinder_info.dart'; import 'package:privacy_gui/page/wifi_settings/services/channel_finder_service.dart'; class MockRouterRepository extends Mock implements RouterRepository {} @@ -26,7 +24,7 @@ void main() { group('getSelectedChannels', () { test('returns empty list when no channels', () async { when(() => mockRepo.send(any(), cacheLevel: any(named: 'cacheLevel'))) - .thenAnswer((_) async => JNAPSuccess(result: 'OK', output: { + .thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const { 'isRunning': false, 'selectedChannels': [], })); @@ -38,7 +36,7 @@ void main() { test('returns selected channels when available', () async { when(() => mockRepo.send(any(), cacheLevel: any(named: 'cacheLevel'))) - .thenAnswer((_) async => JNAPSuccess(result: 'OK', output: { + .thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const { 'isRunning': false, 'selectedChannels': [ { @@ -59,7 +57,7 @@ void main() { test('throws error when already running', () async { when(() => mockRepo.send(any(), cacheLevel: any(named: 'cacheLevel'))) - .thenAnswer((_) async => JNAPSuccess(result: 'OK', output: { + .thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const { 'isRunning': true, })); diff --git a/test/validator_rules/input_validators_test.dart b/test/validator_rules/input_validators_test.dart index 67e7548dc..7241bbbb6 100644 --- a/test/validator_rules/input_validators_test.dart +++ b/test/validator_rules/input_validators_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'package:privacy_gui/validator_rules/input_validators.dart'; import 'package:privacy_gui/validator_rules/rules.dart'; import 'package:test/test.dart'; @@ -993,14 +995,14 @@ void main() { }); // Test result tracking - final Map> _testResults = {}; + final Map> testResults = {}; // Helper to track test results - void _trackTestResult(String group, String testName, bool passed, + void trackTestResult(String group, String testName, bool passed, String address, String? expectedType, {String? description}) { - if (!_testResults.containsKey(group)) { - _testResults[group] = { + if (!testResults.containsKey(group)) { + testResults[group] = { 'total': 0, 'passed': 0, 'failed': 0, @@ -1016,24 +1018,24 @@ void main() { if (description != null) 'description': description, }; - _testResults[group]!['total']++; + testResults[group]!['total']++; if (passed) { - _testResults[group]!['passed']++; + testResults[group]!['passed']++; } else { - _testResults[group]!['failed']++; + testResults[group]!['failed']++; } - _testResults[group]!['details'].add(result); + testResults[group]!['details'].add(result); } // Print test summary - void _printTestSummary() { + void printTestSummary() { print('\n\n=== IPv6 Validation Test Summary ===\n'); int totalTests = 0; int totalPassed = 0; int totalFailed = 0; - _testResults.forEach((group, data) { + testResults.forEach((group, data) { print('\n=== $group ==='); print( 'Total: ${data['total']} | Passed: ${data['passed']} | Failed: ${data['failed']}'); @@ -1083,18 +1085,18 @@ void main() { group('IPv6WithReservedRule', () { // Add teardown to print summary after all tests tearDownAll(() { - _printTestSummary(); + printTestSummary(); }); // Helper function to run multiple invalid test cases - void _runInvalidTestCases(List addresses, String description) { + void runInvalidTestCases(List addresses, String description) { for (var address in addresses) { test('should reject $description: $address', () { final rule = IPv6WithReservedRule(); final isValid = rule.validate(address); final testName = 'Reject $description: $address'; final passed = isValid == false; - _trackTestResult('Invalid Address: $description', testName, passed, + trackTestResult('Invalid Address: $description', testName, passed, address, 'Should be rejected as $description', description: description); expect(isValid, isFalse, @@ -1134,7 +1136,7 @@ void main() { final isValid = rule.validate(address); final testName = 'Valid Global Unicast: $address'; final passed = isValid == true; - _trackTestResult('Valid Global Unicast', testName, passed, address, + trackTestResult('Valid Global Unicast', testName, passed, address, 'Valid Global Unicast', description: 'Should be accepted as a valid global unicast IPv6 address'); @@ -1153,7 +1155,7 @@ void main() { '::1', '0:0:0:0:0:0:0:1', ]; - _runInvalidTestCases(testCases, 'loopback'); + runInvalidTestCases(testCases, 'loopback'); }); // Link-local addresses (fe80::/10) @@ -1164,7 +1166,7 @@ void main() { 'fe80:0000:0000:0000:0000:0000:0000:0001', 'febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff', // End of fe80::/10 ]; - _runInvalidTestCases(testCases, 'link-local'); + runInvalidTestCases(testCases, 'link-local'); }); // Unique Local Addresses (fc00::/7) @@ -1175,7 +1177,7 @@ void main() { 'fd12:3456:789a:1::1', 'fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', // End of fd00::/8 ]; - _runInvalidTestCases(testCases, 'ULA'); + runInvalidTestCases(testCases, 'ULA'); }); // Multicast addresses (ff00::/8) @@ -1185,7 +1187,7 @@ void main() { 'ff02::1', 'ff0f:ffff:ffff:ffff:ffff:ffff:ffff:ffff', // End of ff00::/8 ]; - _runInvalidTestCases(testCases, 'multicast'); + runInvalidTestCases(testCases, 'multicast'); }); // Unspecified/undefined addresses (::/128 and 0::/96) @@ -1198,7 +1200,7 @@ void main() { '0::0', '0:0:0:0:0:0:0:0:0', ]; - _runInvalidTestCases(testCases, 'unspecified/undefined'); + runInvalidTestCases(testCases, 'unspecified/undefined'); }); // Unallocated address space (e.g., ffff::/16) @@ -1217,7 +1219,7 @@ void main() { '5f00::', '5fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', ]; - _runInvalidTestCases(testCases, 'unallocated/reserved'); + runInvalidTestCases(testCases, 'unallocated/reserved'); }); // IPv4-mapped and IPv4-compatible addresses @@ -1228,7 +1230,7 @@ void main() { '::ffff:0:192.168.1.1', '::ffff:c0a8:0101', // Same as ::ffff:192.168.1.1 ]; - _runInvalidTestCases(testCases, 'IPv4-mapped/compatible'); + runInvalidTestCases(testCases, 'IPv4-mapped/compatible'); }); // Invalid formats and non-IPv6 addresses @@ -1243,7 +1245,7 @@ void main() { '2001:db8:1:2:3:4:5:6:7', // Too many segments '2001:db8:1:2:3', // Too few segments ]; - _runInvalidTestCases(testCases, 'invalid format'); + runInvalidTestCases(testCases, 'invalid format'); }); }); diff --git a/test_scripts/test_result_parser.dart b/test_scripts/test_result_parser.dart index f2018615c..697687f94 100644 --- a/test_scripts/test_result_parser.dart +++ b/test_scripts/test_result_parser.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:convert'; import 'dart:io'; diff --git a/tools/generate_screenshot_test_cases_report.dart b/tools/generate_screenshot_test_cases_report.dart index 93407c79a..e4549c663 100644 --- a/tools/generate_screenshot_test_cases_report.dart +++ b/tools/generate_screenshot_test_cases_report.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:io'; import 'package:path/path.dart' as p; @@ -210,9 +212,10 @@ Future> _parseTestFile(File file, bool debugMode) async { } if (mainDescription == null) { - if (debugMode) + if (debugMode) { print( ' No main description found for Test ID: $baseId in file ${file.path}, skipping test case.'); + } continue; // Skip if no description found } @@ -238,9 +241,10 @@ Future> _parseTestFile(File file, bool debugMode) async { } if (goldenFiles.isEmpty) { - if (debugMode) + if (debugMode) { print( ' No golden files found for Test ID: $baseId in file ${file.path}, skipping test case.'); + } continue; } diff --git a/tools/remove_unused_strings.dart b/tools/remove_unused_strings.dart index b5e5d4839..249f84bfb 100644 --- a/tools/remove_unused_strings.dart +++ b/tools/remove_unused_strings.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:convert'; import 'dart:io'; import 'package:args/args.dart'; diff --git a/tools/run_screenshot_tests.dart b/tools/run_screenshot_tests.dart index 68aa4272a..92bd55bbe 100644 --- a/tools/run_screenshot_tests.dart +++ b/tools/run_screenshot_tests.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:io'; import 'package:path/path.dart' as p; import 'package:args/args.dart'; // Import the args package From 5fb98fe38a047c18e32486078fc7a19da8668f6f Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 6 Jan 2026 13:08:12 +0800 Subject: [PATCH 02/26] refactor: Move agent env config and fix lints - Move env.template to assets/agents/env.template - Update dotenv loading paths in main.dart and main_demo.dart - Fix type definition in IdleChecker - Fix lints in tests --- .github/workflows/ci.yml | 2 +- env.template => assets/agents/env.template | 0 lib/main.dart | 2 +- lib/main_demo.dart | 2 +- lib/page/components/layouts/idle_checker.dart | 2 +- pubspec.yaml | 3 +-- test/common/utils.dart | 1 - test/utils_test.dart | 2 ++ test_scripts/combine_results.dart | 2 ++ 9 files changed, 9 insertions(+), 7 deletions(-) rename env.template => assets/agents/env.template (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e688d9d6..bac5006d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: run: git config --global url."git@github.com:".insteadOf "https://github.com/" - name: 📋 Setup Environment File - run: cp env.template assets/.env + run: cp assets/agents/env.template assets/agents/.env - name: Install Dependencies run: flutter pub get diff --git a/env.template b/assets/agents/env.template similarity index 100% rename from env.template rename to assets/agents/env.template diff --git a/lib/main.dart b/lib/main.dart index a0da31581..afcda654e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -49,7 +49,7 @@ void main() async { // Load environment variables for FAQ Agent (AWS Bedrock) // Create assets/.env with AWS credentials (copy from gen_ui_client) try { - await dotenv.load(fileName: 'env.template'); + await dotenv.load(fileName: 'assets/agents/.env'); debugPrint('FAQ Agent: .env loaded successfully'); } catch (e) { debugPrint('Warning: Could not load .env file for FAQ Agent: $e'); diff --git a/lib/main_demo.dart b/lib/main_demo.dart index 2883781c5..f5efb49f4 100644 --- a/lib/main_demo.dart +++ b/lib/main_demo.dart @@ -38,7 +38,7 @@ void main() async { // Load environment variables (for AWS credentials) try { - await dotenv.load(fileName: 'env.template'); + await dotenv.load(fileName: 'assets/agents/.env'); } catch (e) { debugPrint('No .env file found, using defaults'); } diff --git a/lib/page/components/layouts/idle_checker.dart b/lib/page/components/layouts/idle_checker.dart index c98da3032..bd27b235d 100644 --- a/lib/page/components/layouts/idle_checker.dart +++ b/lib/page/components/layouts/idle_checker.dart @@ -17,7 +17,7 @@ class IdleChecker extends StatefulWidget { }); @override - _IdleCheckerState createState() => _IdleCheckerState(); + State createState() => _IdleCheckerState(); } class _IdleCheckerState extends State { diff --git a/pubspec.yaml b/pubspec.yaml index 330a04a80..2905c0f47 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -129,8 +129,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - - assets/.env - - env.template + - assets/agents/ - assets/icons/ - assets/resources/ # An image asset can refer to one or more resolution-specific "variants", see diff --git a/test/common/utils.dart b/test/common/utils.dart index 86a808406..77db005e9 100644 --- a/test/common/utils.dart +++ b/test/common/utils.dart @@ -26,7 +26,6 @@ void fireOnTap(Finder finder, String text) { final RenderParagraph paragraph = element.renderObject as RenderParagraph; // The children are the individual TextSpans which have GestureRecognizers paragraph.text.visitChildren((dynamic span) { - print(span); if (span.text != text) return true; // continue iterating. (span.recognizer as TapGestureRecognizer).onTap?.call(); diff --git a/test/utils_test.dart b/test/utils_test.dart index 98a6b6917..86a2a2241 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'package:privacy_gui/utils.dart'; import 'package:test/test.dart'; import 'package:privacy_gui/core/utils/fernet_manager.dart'; diff --git a/test_scripts/combine_results.dart b/test_scripts/combine_results.dart index 7f8d9a246..614a7fe7a 100644 --- a/test_scripts/combine_results.dart +++ b/test_scripts/combine_results.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:convert'; import 'dart:io'; From a956be5672e5688fff004d851993d26c6ff260e0 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 6 Jan 2026 13:36:03 +0800 Subject: [PATCH 03/26] fix: Resolve mock signature warnings and fix node detail test - Update Notifier mock signatures in test/mocks/ to correctly extend AsyncNotifier/Notifier and mixin Mock - Fix node_detail_view_test.dart by stubbing missing node light status - Update CI workflow env setup --- .github/workflows/ci.yml | 2 +- test/mocks/auth_notifier_mocks.dart | 4 ++- test/mocks/dashboard_home_notifier_mocks.dart | 3 +- .../dashboard_manager_notifier_mocks.dart | 4 ++- test/mocks/device_manager_notifier_mocks.dart | 3 +- .../internet_settings_notifier_mocks.dart | 4 ++- test/mocks/node_detail_notifier_mocks.dart | 3 +- .../node_light_settings_notifier_mocks.dart | 30 ++++++++++++------- test/mocks/polling_notifier_mocks.dart | 4 ++- .../localizations/node_detail_view_test.dart | 2 ++ 10 files changed, 41 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bac5006d1..255544eae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: run: git config --global url."git@github.com:".insteadOf "https://github.com/" - name: 📋 Setup Environment File - run: cp env.template assets/.env + run: cp assets/agents/env.template assets/agents/.env - name: Install Dependencies run: flutter pub get diff --git a/test/mocks/auth_notifier_mocks.dart b/test/mocks/auth_notifier_mocks.dart index 38c3f1edb..c440bf089 100644 --- a/test/mocks/auth_notifier_mocks.dart +++ b/test/mocks/auth_notifier_mocks.dart @@ -63,7 +63,9 @@ class _FakeAuthState_2 extends _i1.SmartFake implements _i3.AuthState { /// A class which mocks [AuthNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockAuthNotifier extends _i1.Mock implements _i4.AuthNotifier { +class MockAuthNotifier extends _i2.AsyncNotifier<_i3.AuthState> + with _i1.Mock + implements _i4.AuthNotifier { @override _i2.AsyncNotifierProviderRef<_i3.AuthState> get ref => (super.noSuchMethod( Invocation.getter(#ref), diff --git a/test/mocks/dashboard_home_notifier_mocks.dart b/test/mocks/dashboard_home_notifier_mocks.dart index 7eebd7f09..2cb4aded4 100644 --- a/test/mocks/dashboard_home_notifier_mocks.dart +++ b/test/mocks/dashboard_home_notifier_mocks.dart @@ -50,7 +50,8 @@ class _FakeDashboardHomeState_1 extends _i1.SmartFake /// A class which mocks [DashboardHomeNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockDashboardHomeNotifier extends _i1.Mock +class MockDashboardHomeNotifier extends _i2.Notifier<_i3.DashboardHomeState> + with _i1.Mock implements _i4.DashboardHomeNotifier { @override _i2.NotifierProviderRef<_i3.DashboardHomeState> get ref => diff --git a/test/mocks/dashboard_manager_notifier_mocks.dart b/test/mocks/dashboard_manager_notifier_mocks.dart index bbc025fbe..579a191d6 100644 --- a/test/mocks/dashboard_manager_notifier_mocks.dart +++ b/test/mocks/dashboard_manager_notifier_mocks.dart @@ -64,7 +64,9 @@ class _FakeNodeDeviceInfo_2 extends _i1.SmartFake /// A class which mocks [DashboardManagerNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockDashboardManagerNotifier extends _i1.Mock +class MockDashboardManagerNotifier + extends _i2.Notifier<_i3.DashboardManagerState> + with _i1.Mock implements _i5.DashboardManagerNotifier { @override _i2.NotifierProviderRef<_i3.DashboardManagerState> get ref => diff --git a/test/mocks/device_manager_notifier_mocks.dart b/test/mocks/device_manager_notifier_mocks.dart index b4434d77e..8d4c034e5 100644 --- a/test/mocks/device_manager_notifier_mocks.dart +++ b/test/mocks/device_manager_notifier_mocks.dart @@ -54,7 +54,8 @@ class _FakeDeviceManagerState_1 extends _i1.SmartFake /// A class which mocks [DeviceManagerNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockDeviceManagerNotifier extends _i1.Mock +class MockDeviceManagerNotifier extends _i2.Notifier<_i3.DeviceManagerState> + with _i1.Mock implements _i4.DeviceManagerNotifier { @override _i2.NotifierProviderRef<_i3.DeviceManagerState> get ref => diff --git a/test/mocks/internet_settings_notifier_mocks.dart b/test/mocks/internet_settings_notifier_mocks.dart index a7683706c..ddf458a3d 100644 --- a/test/mocks/internet_settings_notifier_mocks.dart +++ b/test/mocks/internet_settings_notifier_mocks.dart @@ -52,7 +52,9 @@ class _FakeInternetSettingsState_1 extends _i1.SmartFake /// A class which mocks [InternetSettingsNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockInternetSettingsNotifier extends _i1.Mock +class MockInternetSettingsNotifier + extends _i2.Notifier<_i3.InternetSettingsState> + with _i1.Mock implements _i4.InternetSettingsNotifier { @override _i2.NotifierProviderRef<_i3.InternetSettingsState> get ref => diff --git a/test/mocks/node_detail_notifier_mocks.dart b/test/mocks/node_detail_notifier_mocks.dart index 33d6d8499..b22ee486e 100644 --- a/test/mocks/node_detail_notifier_mocks.dart +++ b/test/mocks/node_detail_notifier_mocks.dart @@ -51,7 +51,8 @@ class _FakeNodeDetailState_1 extends _i1.SmartFake /// A class which mocks [NodeDetailNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockNodeDetailNotifier extends _i1.Mock +class MockNodeDetailNotifier extends _i2.Notifier<_i3.NodeDetailState> + with _i1.Mock implements _i3.NodeDetailNotifier { @override _i2.NotifierProviderRef<_i3.NodeDetailState> get ref => (super.noSuchMethod( diff --git a/test/mocks/node_light_settings_notifier_mocks.dart b/test/mocks/node_light_settings_notifier_mocks.dart index 3ac226fad..921c74d3c 100644 --- a/test/mocks/node_light_settings_notifier_mocks.dart +++ b/test/mocks/node_light_settings_notifier_mocks.dart @@ -1,15 +1,16 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.6 from annotations // in privacy_gui/test/mocks/mockito_specs/node_light_settings_notifier_spec.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; +import 'dart:async' as _i6; import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; import 'package:privacy_gui/core/jnap/models/node_light_settings.dart' as _i3; import 'package:privacy_gui/core/jnap/providers/node_light_settings_provider.dart' as _i4; +import 'package:privacy_gui/page/nodes/providers/node_detail_state.dart' as _i5; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -19,10 +20,12 @@ import 'package:privacy_gui/core/jnap/providers/node_light_settings_provider.dar // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeNotifierProviderRef_0 extends _i1.SmartFake implements _i2.NotifierProviderRef { @@ -53,6 +56,13 @@ class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightSettings> with _i1.Mock implements _i4.NodeLightSettingsNotifier { @override + _i5.NodeLightStatus get currentStatus => (super.noSuchMethod( + Invocation.getter(#currentStatus), + returnValue: _i5.NodeLightStatus.on, + returnValueForMissingStub: _i5.NodeLightStatus.on, + ) as _i5.NodeLightStatus); + + @override _i2.NotifierProviderRef<_i3.NodeLightSettings> get ref => (super.noSuchMethod( Invocation.getter(#ref), returnValue: _FakeNotifierProviderRef_0<_i3.NodeLightSettings>( @@ -111,14 +121,14 @@ class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightSettings> ) as _i3.NodeLightSettings); @override - _i5.Future<_i3.NodeLightSettings> fetch([bool? forceRemote = false]) => + _i6.Future<_i3.NodeLightSettings> fetch([bool? forceRemote = false]) => (super.noSuchMethod( Invocation.method( #fetch, [forceRemote], ), returnValue: - _i5.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( + _i6.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( this, Invocation.method( #fetch, @@ -126,23 +136,23 @@ class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightSettings> ), )), returnValueForMissingStub: - _i5.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( + _i6.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( this, Invocation.method( #fetch, [forceRemote], ), )), - ) as _i5.Future<_i3.NodeLightSettings>); + ) as _i6.Future<_i3.NodeLightSettings>); @override - _i5.Future<_i3.NodeLightSettings> save() => (super.noSuchMethod( + _i6.Future<_i3.NodeLightSettings> save() => (super.noSuchMethod( Invocation.method( #save, [], ), returnValue: - _i5.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( + _i6.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( this, Invocation.method( #save, @@ -150,14 +160,14 @@ class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightSettings> ), )), returnValueForMissingStub: - _i5.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( + _i6.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( this, Invocation.method( #save, [], ), )), - ) as _i5.Future<_i3.NodeLightSettings>); + ) as _i6.Future<_i3.NodeLightSettings>); @override void setSettings(_i3.NodeLightSettings? settings) => super.noSuchMethod( diff --git a/test/mocks/polling_notifier_mocks.dart b/test/mocks/polling_notifier_mocks.dart index 4a9ddae04..e1fcb84cf 100644 --- a/test/mocks/polling_notifier_mocks.dart +++ b/test/mocks/polling_notifier_mocks.dart @@ -59,7 +59,9 @@ class _FakeCoreTransactionData_2 extends _i1.SmartFake /// A class which mocks [PollingNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockPollingNotifier extends _i1.Mock implements _i3.PollingNotifier { +class MockPollingNotifier extends _i2.AsyncNotifier<_i3.CoreTransactionData> + with _i1.Mock + implements _i3.PollingNotifier { @override bool get paused => (super.noSuchMethod( Invocation.getter(#paused), diff --git a/test/page/nodes/localizations/node_detail_view_test.dart b/test/page/nodes/localizations/node_detail_view_test.dart index 7a154a582..6df7e2c50 100644 --- a/test/page/nodes/localizations/node_detail_view_test.dart +++ b/test/page/nodes/localizations/node_detail_view_test.dart @@ -212,6 +212,8 @@ void main() { allDayOff: false, ), ); + when(testHelper.mockNodeLightSettingsNotifier.currentStatus) + .thenReturn(NodeLightStatus.night); final context = await pumpNodeDetailView(tester, screen); final loc = testHelper.loc(context); From 818b963b18cf447e2b35fbe96f8892be89a33cc5 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 6 Jan 2026 13:39:42 +0800 Subject: [PATCH 04/26] style: Format code with dart format --- .../firmware_update_service_test.dart | 4 +-- .../services/dmz_settings_service_test.dart | 28 +++++++++------- .../ipv6_port_service_list_service_test.dart | 3 +- .../dhcp_reservations_provider_test.dart | 4 +-- .../providers/timezone_state_test.dart | 1 - .../services/instant_safety_service_test.dart | 33 ++++++++++--------- .../services/pnp_isp_service_test.dart | 3 +- .../providers/wifi_advanced_state_test.dart | 3 +- 8 files changed, 42 insertions(+), 37 deletions(-) diff --git a/test/core/jnap/services/firmware_update_service_test.dart b/test/core/jnap/services/firmware_update_service_test.dart index 0e94a6c75..4f3983529 100644 --- a/test/core/jnap/services/firmware_update_service_test.dart +++ b/test/core/jnap/services/firmware_update_service_test.dart @@ -838,8 +838,8 @@ void main() { onCompleted: anyNamed('onCompleted'), requestTimeoutOverride: anyNamed('requestTimeoutOverride'), auth: anyNamed('auth'))) - .thenAnswer((_) => Stream.value( - JNAPSuccess(result: 'OK', output: const {'firmwareUpdateStatus': []}))); + .thenAnswer((_) => Stream.value(JNAPSuccess( + result: 'OK', output: const {'firmwareUpdateStatus': []}))); final resultStream = service.fetchFirmwareUpdateStream( force: true, retry: 2, currentNodesStatus: nodesStatus); diff --git a/test/page/advanced_settings/dmz/services/dmz_settings_service_test.dart b/test/page/advanced_settings/dmz/services/dmz_settings_service_test.dart index 29fe8a362..65bc80f7d 100644 --- a/test/page/advanced_settings/dmz/services/dmz_settings_service_test.dart +++ b/test/page/advanced_settings/dmz/services/dmz_settings_service_test.dart @@ -208,12 +208,14 @@ void main() { ); when(() => mockRepository.send( - any(), - fetchRemote: any(named: 'fetchRemote'), - cacheLevel: any(named: 'cacheLevel'), - auth: any(named: 'auth'), - data: any(named: 'data'), - )).thenAnswer((_) async => JNAPSuccess(result: 'ok', output: const {})); + any(), + fetchRemote: any(named: 'fetchRemote'), + cacheLevel: any(named: 'cacheLevel'), + auth: any(named: 'auth'), + data: any(named: 'data'), + )) + .thenAnswer( + (_) async => JNAPSuccess(result: 'ok', output: const {})); final mockRef = UnitTestHelper.createMockRef( routerRepository: mockRepository, @@ -275,12 +277,14 @@ void main() { ); when(() => mockRepository.send( - any(), - fetchRemote: any(named: 'fetchRemote'), - cacheLevel: any(named: 'cacheLevel'), - auth: any(named: 'auth'), - data: any(named: 'data'), - )).thenAnswer((_) async => JNAPSuccess(result: 'ok', output: const {})); + any(), + fetchRemote: any(named: 'fetchRemote'), + cacheLevel: any(named: 'cacheLevel'), + auth: any(named: 'auth'), + data: any(named: 'data'), + )) + .thenAnswer( + (_) async => JNAPSuccess(result: 'ok', output: const {})); final mockRef = UnitTestHelper.createMockRef( routerRepository: mockRepository, diff --git a/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test.dart b/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test.dart index 40a441ab6..6465fcc68 100644 --- a/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test.dart +++ b/test/page/advanced_settings/firewall/services/ipv6_port_service_list_service_test.dart @@ -118,8 +118,7 @@ void main() { description: 'Both Rule', ipv6Address: '2001:db8::3', portRanges: const [ - PortRange( - protocol: 'Both', firstPort: 5000, lastPort: 6000), + PortRange(protocol: 'Both', firstPort: 5000, lastPort: 6000), ], ), ]; diff --git a/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_provider_test.dart b/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_provider_test.dart index 050170077..3ca4bf5ba 100644 --- a/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_provider_test.dart +++ b/test/page/advanced_settings/local_network_settings/providers/dhcp_reservations_provider_test.dart @@ -310,8 +310,8 @@ void main() { description: 'Device2', ), ); - notifier - .updateSettings(DHCPReservationsSettings(reservations: const [newItem])); + notifier.updateSettings( + DHCPReservationsSettings(reservations: const [newItem])); final state = container.read(dhcpReservationProvider); expect(state.settings.current.reservations, hasLength(1)); diff --git a/test/page/instant_admin/providers/timezone_state_test.dart b/test/page/instant_admin/providers/timezone_state_test.dart index d449cdcb0..44ae26564 100644 --- a/test/page/instant_admin/providers/timezone_state_test.dart +++ b/test/page/instant_admin/providers/timezone_state_test.dart @@ -1,4 +1,3 @@ - import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/core/jnap/models/timezone.dart'; import 'package:privacy_gui/page/instant_admin/providers/timezone_state.dart'; diff --git a/test/page/instant_safety/services/instant_safety_service_test.dart b/test/page/instant_safety/services/instant_safety_service_test.dart index 6883802a6..0a7fc28cf 100644 --- a/test/page/instant_safety/services/instant_safety_service_test.dart +++ b/test/page/instant_safety/services/instant_safety_service_test.dart @@ -165,11 +165,12 @@ void main() { ); when(() => mockRouterRepository.send( - JNAPAction.setLANSettings, - auth: any(named: 'auth'), - cacheLevel: any(named: 'cacheLevel'), - data: any(named: 'data'), - )).thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const {})); + JNAPAction.setLANSettings, + auth: any(named: 'auth'), + cacheLevel: any(named: 'cacheLevel'), + data: any(named: 'data'), + )) + .thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const {})); // Fetch first to cache settings await service.fetchSettings(deviceInfo: null, forceRemote: false); @@ -201,11 +202,12 @@ void main() { ); when(() => mockRouterRepository.send( - JNAPAction.setLANSettings, - auth: any(named: 'auth'), - cacheLevel: any(named: 'cacheLevel'), - data: any(named: 'data'), - )).thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const {})); + JNAPAction.setLANSettings, + auth: any(named: 'auth'), + cacheLevel: any(named: 'cacheLevel'), + data: any(named: 'data'), + )) + .thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const {})); await service.fetchSettings(deviceInfo: null, forceRemote: false); @@ -237,11 +239,12 @@ void main() { ); when(() => mockRouterRepository.send( - JNAPAction.setLANSettings, - auth: any(named: 'auth'), - cacheLevel: any(named: 'cacheLevel'), - data: any(named: 'data'), - )).thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const {})); + JNAPAction.setLANSettings, + auth: any(named: 'auth'), + cacheLevel: any(named: 'cacheLevel'), + data: any(named: 'data'), + )) + .thenAnswer((_) async => JNAPSuccess(result: 'OK', output: const {})); await service.fetchSettings(deviceInfo: null, forceRemote: false); diff --git a/test/page/instant_setup/troubleshooter/services/pnp_isp_service_test.dart b/test/page/instant_setup/troubleshooter/services/pnp_isp_service_test.dart index ec4263b58..6da968e41 100644 --- a/test/page/instant_setup/troubleshooter/services/pnp_isp_service_test.dart +++ b/test/page/instant_setup/troubleshooter/services/pnp_isp_service_test.dart @@ -109,8 +109,7 @@ void main() { // ARRANGE final nonMatchingResult = JNAPSuccess( result: jnapResultOk, - output: - baseWanStatusOutput(wanStatus: 'Connecting'), // Not 'Connected' + output: baseWanStatusOutput(wanStatus: 'Connecting'), // Not 'Connected' ); when(mockRouterRepository.scheduledCommand( diff --git a/test/page/wifi_settings/providers/wifi_advanced_state_test.dart b/test/page/wifi_settings/providers/wifi_advanced_state_test.dart index 79a9f99eb..48e83d73a 100644 --- a/test/page/wifi_settings/providers/wifi_advanced_state_test.dart +++ b/test/page/wifi_settings/providers/wifi_advanced_state_test.dart @@ -68,7 +68,8 @@ void main() { }); test('fromMap handles null values', () { - final state = WifiAdvancedSettingsState.fromMap(const {}); + final state = + WifiAdvancedSettingsState.fromMap(const {}); expect(state.isIptvEnabled, isNull); expect(state.isMLOEnabled, isNull); }); From 4b2f5886f729e4732d971c4d7d0614154fa69b25 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 6 Jan 2026 14:16:44 +0800 Subject: [PATCH 05/26] chore: Restore polling_provider and cleanup obsolete mocks --- lib/core/jnap/providers/polling_provider.dart | 211 ++++++++++++++++++ test/mocks/dashboard_home_notifier_mocks.dart | 4 - 2 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 lib/core/jnap/providers/polling_provider.dart diff --git a/lib/core/jnap/providers/polling_provider.dart b/lib/core/jnap/providers/polling_provider.dart new file mode 100644 index 000000000..37adc4f08 --- /dev/null +++ b/lib/core/jnap/providers/polling_provider.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/constants/build_config.dart'; +import 'package:privacy_gui/core/cache/linksys_cache_manager.dart'; +import 'package:privacy_gui/core/data/services/polling_service.dart'; +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; +import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; +import 'package:privacy_gui/core/utils/bench_mark.dart'; +import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; +import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.dart'; +import 'package:privacy_gui/page/vpn/providers/vpn_notifier.dart'; +import 'package:privacy_gui/providers/auth/_auth.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_provider.dart'; + +const int pollFirstDelayInSec = 1; + +final pollingProvider = + AsyncNotifierProvider( + () => PollingNotifier()); + +class CoreTransactionData extends Equatable { + final int lastUpdate; + final bool isReady; + final Map data; + + const CoreTransactionData({ + required this.lastUpdate, + required this.isReady, + required this.data, + }); + + @override + List get props => [lastUpdate, isReady, data]; + + CoreTransactionData copyWith({ + int? lastUpdate, + bool? isReady, + Map? data, + }) { + return CoreTransactionData( + lastUpdate: lastUpdate ?? this.lastUpdate, + isReady: isReady ?? this.isReady, + data: data ?? this.data, + ); + } +} + +class PollingNotifier extends AsyncNotifier { + static Timer? _timer; + bool _paused = false; + + /// Service for polling operations + PollingService get _service => ref.read(pollingServiceProvider); + + set paused(bool value) { + _paused = value; + if (_paused) { + _timer?.cancel(); + } else { + checkAndStartPolling(); + } + } + + bool get paused => _paused; + + List>> _coreTransactions = []; + + @override + FutureOr build() { + return const CoreTransactionData(lastUpdate: 0, isReady: false, data: {}); + } + + init() { + state = AsyncValue.data( + const CoreTransactionData(lastUpdate: 0, isReady: false, data: {})); + } + + fetchFirstLaunchedCacheData() { + final cache = ref.read(linksysCacheManagerProvider).data; + + // Delegate cache parsing to Service + final cacheDataMap = _service.parseCacheData( + cache: cache, + commands: _coreTransactions, + ); + + // Cache incomplete - skip + if (cacheDataMap == null) return; + + // Delegate Fernet key update to Service + _service.updateFernetKeyFromResult(cacheDataMap); + + final previousSnapshot = state.value; + state = AsyncValue.data(CoreTransactionData( + lastUpdate: 0, + isReady: previousSnapshot?.isReady ?? false, + data: cacheDataMap)); + } + + Future _polling({bool force = false}) async { + final benchMark = BenchMarkLogger(name: 'Polling provider'); + benchMark.start(); + final previousSnapshot = state.value; + state = const AsyncValue.loading(); + final fetchFuture = _service + .executeTransaction(_coreTransactions, force: force) + .then((successWrap) => successWrap.data) + .then((data) => CoreTransactionData( + lastUpdate: DateTime.now().millisecondsSinceEpoch, + isReady: previousSnapshot?.isReady ?? false, + data: Map.fromEntries(data), + )) + .onError((error, stackTrace) { + logger.e('Polling error: $error, $stackTrace'); + logger.f('[Auth]: Force to log out because of failed polling'); + ref.read(authProvider.notifier).logout(); + + throw error ?? ''; + }); + + state = await AsyncValue.guard( + () => fetchFuture.then( + (result) async { + // Delegate Fernet key update to Service + _service.updateFernetKeyFromResult(result.data); + + await _additionalPolling(); + return result.copyWith(isReady: true); + }, + ).onError((e, stackTrace) { + logger.e('Polling error: $e, $stackTrace'); + throw e ?? ''; + }), + ); + + benchMark.end(); + } + + Future _additionalPolling() async { + if (serviceHelper.isSupportLedMode()) { + await ref.read(nodeLightSettingsProvider.notifier).fetch(); + } + if (serviceHelper.isSupportVPN()) { + await ref.read(vpnProvider.notifier).fetch(false, true); + } + + if (serviceHelper.isSupportHealthCheck()) { + await ref.read(healthCheckProvider.notifier).loadData(); + } + + await ref + .read(instantPrivacyProvider.notifier) + .fetch(updateStatusOnly: true); + } + + Future forcePolling() { + return _polling(force: true).then((_) => _setTimePeriod()); + } + + void checkAndStartPolling([bool force = false]) { + final loginType = ref.read(authProvider).value?.loginType; + if (loginType == LoginType.none) { + return; + } + if (!force && (_timer?.isActive ?? false)) { + return; + } else { + _paused = false; + stopPolling(); + startPolling(); + } + } + + startPolling() { + if (_paused) { + return; + } + logger.d('prepare start polling data'); + _service.checkDeviceMode().then((mode) { + _coreTransactions = _service.buildCoreTransactions(mode: mode); + fetchFirstLaunchedCacheData(); + }).then( + (value) => + Future.delayed(const Duration(seconds: pollFirstDelayInSec), () { + _polling(); + }).then( + (_) { + _setTimePeriod(); + }, + ), + ); + } + + stopPolling() { + logger.d('stop polling data'); + if ((_timer?.isActive ?? false)) { + _timer?.cancel(); + } + } + + _setTimePeriod() { + _timer?.cancel(); + _timer = Timer.periodic( + const Duration(seconds: BuildConfig.refreshTimeInterval), (timer) { + _polling(); + }); + } +} diff --git a/test/mocks/dashboard_home_notifier_mocks.dart b/test/mocks/dashboard_home_notifier_mocks.dart index af2862d25..2cb4aded4 100644 --- a/test/mocks/dashboard_home_notifier_mocks.dart +++ b/test/mocks/dashboard_home_notifier_mocks.dart @@ -5,10 +5,6 @@ // ignore_for_file: no_leading_underscores_for_library_prefixes import 'package:flutter_riverpod/flutter_riverpod.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; -import 'package:privacy_gui/core/data/providers/dashboard_manager_state.dart' - as _i5; -import 'package:privacy_gui/core/data/providers/device_manager_state.dart' - as _i6; import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart' as _i4; import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart' From 15ad9afc5d2167775a8b4674c79f585943f59446 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 12:00:17 +0800 Subject: [PATCH 06/26] feat(ui): UI improvements - dialog scroll, input fixes, WiFi card alignment - AppDialog: unified scroll behavior with scrollable parameter - AppText: add maxLines/minLines to all factories - AppTextField: fix disabled background color - WiFi Card: add intrinsicHeight alignment for equal card heights - WiFi Mode: add minLines:2 for consistent height - Remove redundant SingleChildScrollView from dialogs - Fix crossAxisSpacing in advanced settings grid --- lib/main.dart | 3 +- .../ports/views/widgets/protocol_utils.dart | 43 ++-- .../widgets/optional_settings_form.dart | 32 ++- .../widgets/wan_forms/pppoe_form.dart | 8 +- .../views/dhcp_reservations_view.dart | 7 +- .../dashboard/views/components/wifi_grid.dart | 32 ++- .../views/pnp_modem_lights_off_view.dart | 4 +- .../views/components/ping_network_modal.dart | 3 +- .../views/components/traceroute_modal.dart | 3 +- .../views/instant_verify_view.dart | 5 +- .../advanced/wifi_advanced_settings_view.dart | 2 +- .../mac_filter/mac_filtered_devices_view.dart | 191 ++++++++---------- .../main/wifi_list_advanced_mode_view.dart | 1 + .../views/main/wifi_main_view.dart | 27 ++- .../views/widgets/main_wifi_card.dart | 6 +- pubspec.yaml | 8 +- 16 files changed, 195 insertions(+), 180 deletions(-) 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/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/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/dashboard/views/components/wifi_grid.dart b/lib/page/dashboard/views/components/wifi_grid.dart index 27f9d5a43..106ce2cff 100644 --- a/lib/page/dashboard/views/components/wifi_grid.dart +++ b/lib/page/dashboard/views/components/wifi_grid.dart @@ -301,18 +301,17 @@ class _WiFiCardState extends ConsumerState { 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), - ], - ), + 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()), @@ -340,11 +339,10 @@ class _WiFiCardState extends ConsumerState { void _showWiFiShareModal(BuildContext context) { showSimpleAppDialog(context, title: loc(context).shareWiFi, - content: SingleChildScrollView( - child: WiFiShareDetailView( - ssid: widget.item.ssid, - password: widget.item.password, - ), + scrollable: true, + content: WiFiShareDetailView( + ssid: widget.item.ssid, + password: widget.item.password, ), actions: [ AppButton.text( 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_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..d7b316fdd 100644 --- a/lib/page/instant_verify/views/instant_verify_view.dart +++ b/lib/page/instant_verify/views/instant_verify_view.dart @@ -1039,8 +1039,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 +1049,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/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..4fecb145f 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,60 @@ 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), + 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 +217,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/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..0e7994287 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,10 +65,12 @@ dependencies: usp_protocol_common: path: packages/usp_protocol_common + # ui_kit_library: + # git: + # url: https://github.com/linksys/privacyGUI-UI-kit.git + # ref: v2.10.1 ui_kit_library: - git: - url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.10.1 + path: ../../ui_kit generative_ui: git: url: https://github.com/linksys/privacyGUI-UI-kit.git From 6d2a0c27d20da80cee604fb722736bacf18f509a Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 12:30:44 +0800 Subject: [PATCH 07/26] refactor(dashboard): unify layout logic and extract components - Refactor : remove unused code, extract - Refactor : unify layout logic, extract - Refactor : merge layout methods - Refactor : extract component - Introduce for consistent layout handling --- .../dashboard/models/dashboard_layout.dart | 24 + .../firmware_update_countdown_dialog.dart | 61 + .../dashboard/views/components/networks.dart | 287 ++--- .../views/components/port_and_speed.dart | 1124 +++++++---------- .../views/components/port_status_widget.dart | 186 +++ .../dashboard/views/components/wifi_card.dart | 275 ++++ .../dashboard/views/components/wifi_grid.dart | 373 +----- .../dashboard/views/dashboard_home_view.dart | 107 +- 8 files changed, 1139 insertions(+), 1298 deletions(-) create mode 100644 lib/page/dashboard/models/dashboard_layout.dart create mode 100644 lib/page/dashboard/views/components/firmware_update_countdown_dialog.dart create mode 100644 lib/page/dashboard/views/components/port_status_widget.dart create mode 100644 lib/page/dashboard/views/components/wifi_card.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..b35ef5393 --- /dev/null +++ b/lib/page/dashboard/models/dashboard_layout.dart @@ -0,0 +1,24 @@ +/// 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, +} + +/// Extension to provide utility methods for DashboardLayoutVariant +extension DashboardLayoutVariantX on DashboardLayoutVariant { + /// Returns true if this is a desktop layout variant + bool get isDesktop => this != DashboardLayoutVariant.mobile; + + /// Returns true if this is the mobile layout + bool get isMobile => this == DashboardLayoutVariant.mobile; +} diff --git a/lib/page/dashboard/views/components/firmware_update_countdown_dialog.dart b/lib/page/dashboard/views/components/firmware_update_countdown_dialog.dart new file mode 100644 index 000000000..cc49e8ea9 --- /dev/null +++ b/lib/page/dashboard/views/components/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/networks.dart b/lib/page/dashboard/views/components/networks.dart index 5c94e814b..b48a167f5 100644 --- a/lib/page/dashboard/views/components/networks.dart +++ b/lib/page/dashboard/views/components/networks.dart @@ -11,12 +11,11 @@ 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/dashboard/views/components/loading_tile.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:privacy_gui/page/dashboard/views/components/loading_tile.dart'; - import 'package:ui_kit_library/ui_kit.dart'; class DashboardNetworks extends ConsumerStatefulWidget { @@ -31,10 +30,20 @@ class _DashboardNetworksState extends ConsumerState { Widget build(BuildContext context) { final state = ref.watch(dashboardHomeProvider); final topologyState = ref.watch(instantTopologyProvider); + final isLoading = + (ref.watch(pollingProvider).value?.isReady ?? false) == false; + + if (isLoading) { + return AppCard( + padding: EdgeInsets.zero, + child: SizedBox(width: double.infinity, child: const LoadingTile()), + ); + } // Convert topology data to ui_kit format final meshTopology = TopologyAdapter.convert(topologyState.root.children); + // Calculate topology height const topologyItemHeight = 96.0; const treeViewBaseHeight = 68.0; final routerLength = @@ -42,192 +51,133 @@ class _DashboardNetworksState extends ConsumerState { 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'; - }, - ), - ), - ), - ], + 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, ), - ); + ), + ], + ), + ); } - Widget _desktopHorizontal(BuildContext context, WidgetRef ref) { + /// 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 newFirmware = _hasNewFirmware(ref); final isOnline = wanStatus == InternetStatus.online; + final hasLanPort = + ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; - return Column( + // Determine layout variant + final useVerticalLayout = + !context.isMobileLayout && hasLanPort && !state.isHorizontalLayout; + + final titleSection = 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, - ), - ) - ], - ), + if (isOnline) _firmwareStatusWidget(context, newFirmware), ], ); - } - 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( + final infoTilesSection = Row( + mainAxisSize: useVerticalLayout ? MainAxisSize.min : MainAxisSize.max, 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, - ) - ], - ), - ), + useVerticalLayout + ? _nodesInfoTile(context, ref, topologyState) + : Expanded(child: _nodesInfoTile(context, ref, topologyState)), + AppGap.gutter(), + useVerticalLayout + ? _devicesInfoTile(context, ref, topologyState) + : Expanded(child: _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); + // 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: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.titleSmall(loc(context).myNetwork), - if (isOnline) _firmwareStatusWidget(context, newFirmware), - ], - ), + titleSection, AppGap.xxl(), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _nodesInfoTile( - context, - ref, - topologyState, - )), - AppGap.gutter(), - Expanded( - child: _devicesInfoTile( - context, - ref, - topologyState, - ), - ) - ], - ), + infoTilesSection, ], ); } - bool hasNewFirmware(WidgetRef ref) { + 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) ?? @@ -248,7 +198,7 @@ class _DashboardNetworksState extends ConsumerState { loc(context).updateFirmware, color: Theme.of(context).colorScheme.primary, ) - : _firmwareUpdateToDateWidget(context), + : AppStyledText(text: loc(context).dashboardFirmwareUpdateToDate), newFirmware ? AppIcon.font( AppFontIcons.cloudDownload, @@ -260,18 +210,12 @@ class _DashboardNetworksState extends ConsumerState { .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() ?? []; @@ -281,7 +225,6 @@ class _DashboardNetworksState extends ConsumerState { ? 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: () { @@ -304,9 +247,7 @@ class _DashboardNetworksState extends ConsumerState { externalDeviceCount == 1 ? loc(context).device : loc(context).devices, count: externalDeviceCount, icon: AppIcon.font(AppFontIcons.devices), - onTap: () { - context.pushNamed(RouteNamed.menuInstantDevices); - }, + onTap: () => context.pushNamed(RouteNamed.menuInstantDevices), ); } diff --git a/lib/page/dashboard/views/components/port_and_speed.dart b/lib/page/dashboard/views/components/port_and_speed.dart index ad3be0c41..0c3889a48 100644 --- a/lib/page/dashboard/views/components/port_and_speed.dart +++ b/lib/page/dashboard/views/components/port_and_speed.dart @@ -1,24 +1,24 @@ 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/models/dashboard_layout.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/views/components/loading_tile.dart'; +import 'package:privacy_gui/page/dashboard/views/components/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: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'; +import 'package:ui_kit_library/ui_kit.dart'; +/// Dashboard widget showing port connections and speed test results. class DashboardHomePortAndSpeed extends ConsumerWidget { const DashboardHomePortAndSpeed({super.key}); @@ -28,477 +28,418 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { 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)); - } + if (isLoading) { + return AppCard( + padding: EdgeInsets.zero, + child: SizedBox( + width: double.infinity, + height: 250, + child: const LoadingTile(), + ), + ); + } - 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), - ], - )), - ); - } + // Determine layout variant + final layoutVariant = context.isMobileLayout + ? DashboardLayoutVariant.mobile + : !hasLanPort + ? DashboardLayoutVariant.desktopNoLanPorts + : horizontalLayout + ? DashboardLayoutVariant.desktopHorizontal + : DashboardLayoutVariant.desktopVertical; - 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)), - ], - )), - ); + return _buildLayout(context, ref, state, layoutVariant); } - Widget _desktopHorizontal(BuildContext context, WidgetRef ref, - DashboardHomeState state, bool isOnline, bool isLoading) { + Widget _buildLayout( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + DashboardLayoutVariant variant, + ) { final hasLanPort = state.lanPortConnections.isNotEmpty; + // Configure layout parameters based on variant + final config = _LayoutConfig.fromVariant(variant, hasLanPort); + return Container( width: double.infinity, - constraints: const BoxConstraints(minHeight: 110), + constraints: BoxConstraints(minHeight: config.minHeight), 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), - ) - ], - ), - ), + padding: EdgeInsets.zero, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: config.mainAxisAlignment, + children: [ + SizedBox( + height: config.portsHeight, + child: Padding( + padding: config.portsPadding, + child: _buildPortsSection(context, state, variant), ), - SizedBox( - height: 112, - child: _createSpeedTestTile(context, ref, state, hasLanPort)), - ], - )), + ), + SizedBox( + width: double.infinity, + height: config.speedTestHeight, + child: _buildSpeedTestSection(context, ref, state, hasLanPort), + ), + ], + ), + ), ); } - Widget _desktopNoLanPorts(BuildContext context, WidgetRef ref, - DashboardHomeState state, bool isOnline, bool isLoading) { + Widget _buildPortsSection( + BuildContext context, + DashboardHomeState state, + DashboardLayoutVariant variant, + ) { final hasLanPort = state.lanPortConnections.isNotEmpty; + final isVertical = variant == DashboardLayoutVariant.desktopVertical; - 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)), - ], - )), + // 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 layout variant + 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 _createSpeedTestTile(BuildContext context, WidgetRef ref, - DashboardHomeState state, bool hasLanPort, - [bool mobile = false]) { + Widget _buildSpeedTestSection( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + bool hasLanPort, + ) { 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), - ), - ), - ); + + if (isHealthCheckSupported) { + return hasLanPort + ? Column( + children: [ + const Divider(), + const SpeedTestWidget( + showDetails: false, + showInfoPanel: true, + showStepDescriptions: false, + showLatestOnIdle: true, + layout: SpeedTestLayout.vertical, + ), + AppGap.xxl(), + ], + ) + : _SpeedCheckResultWidget(state: state); + } + + return Tooltip( + message: loc(context).featureUnavailableInRemoteMode, + child: Opacity( + opacity: isRemote ? 0.5 : 1, + child: AbsorbPointer( + absorbing: isRemote, + child: _ExternalSpeedTestWidget(state: state), + ), + ), + ); + } +} + +/// Configuration for different layout variants. +class _LayoutConfig { + final double minHeight; + final double? portsHeight; + final double? speedTestHeight; + final EdgeInsets portsPadding; + final MainAxisAlignment mainAxisAlignment; + + const _LayoutConfig({ + required this.minHeight, + this.portsHeight, + this.speedTestHeight, + required this.portsPadding, + this.mainAxisAlignment = MainAxisAlignment.start, + }); + + factory _LayoutConfig.fromVariant( + DashboardLayoutVariant variant, bool hasLanPort) { + switch (variant) { + case DashboardLayoutVariant.mobile: + return _LayoutConfig( + minHeight: 0, + portsHeight: null, + speedTestHeight: null, + portsPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xxl, + ), + ); + case DashboardLayoutVariant.desktopHorizontal: + return _LayoutConfig( + minHeight: 110, + portsHeight: 224, + speedTestHeight: 112, + portsPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xxxl, + ), + ); + case DashboardLayoutVariant.desktopVertical: + return _LayoutConfig( + minHeight: 360, + portsHeight: 752, + speedTestHeight: null, + portsPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xxl, + ), + mainAxisAlignment: MainAxisAlignment.spaceBetween, + ); + case DashboardLayoutVariant.desktopNoLanPorts: + return _LayoutConfig( + minHeight: 256, + portsHeight: 120, + speedTestHeight: 132, + portsPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.md, + ), + ); + } } +} + +/// Widget showing speed check results (internal speed test). +class _SpeedCheckResultWidget extends ConsumerWidget { + final DashboardHomeState state; + + const _SpeedCheckResultWidget({required this.state}); - Widget _speedCheckWidget( - BuildContext context, WidgetRef ref, DashboardHomeState 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 - ? true - : DateTime.now().difference(dateTime).inDays > 1; + 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: Column( - crossAxisAlignment: horizontalLayout - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, + 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: [ - 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) + 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), + ], ], ), ); } +} + +/// Widget for external speed test links. +class _ExternalSpeedTestWidget extends StatelessWidget { + final DashboardHomeState state; - Widget _externalSpeedTest(BuildContext context, DashboardHomeState state) { + const _ExternalSpeedTestWidget({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( @@ -510,55 +451,18 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { ), ), padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xl, vertical: AppSpacing.sm), + horizontal: AppSpacing.xl, + vertical: AppSpacing.sm, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ - _speedTestHeader(context, state), + _buildHeader(context, horizontalLayout, hasLanPort), 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'); - }, - ), - ), - ]), + child: isVerticalDesktop + ? _buildVerticalButtons(context) + : _buildHorizontalButtons(context), ), AppGap.sm(), AppText.bodyExtraSmall(loc(context).speedTestExternalOthers), @@ -567,31 +471,35 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { ); } - Widget _speedTestHeader(BuildContext context, DashboardHomeState state) { - final horizontalLayout = state.isHorizontalLayout; - final hasLanPort = state.lanPortConnections.isNotEmpty; + 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/'); - }, + 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( + + 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: [ @@ -607,233 +515,51 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { alignment: AlignmentDirectional.centerStart, child: speedDesc, ), - ) + ), ], - ) + ), ], ); - return AppResponsiveLayout( - desktop: (ctx) => - hasLanPort && horizontalLayout ? rowHeader : columnHeader, - mobile: (ctx) => rowHeader); } - Widget _speedTestButton(BuildContext context, DashboardHomeState state) { + Widget _buildVerticalButtons(BuildContext context) { return SizedBox( - height: 40, - child: AppButton( - label: loc(context).speedTextTileStart, - onTap: () { - context.pushNamed(RouteNamed.dashboardSpeedTest); - }, + 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 _downloadSpeedResult( - BuildContext context, String value, String? unit, bool isLegacy, - [WrapAlignment alignment = WrapAlignment.start]) { - return Wrap( - alignment: alignment, - crossAxisAlignment: WrapCrossAlignment.end, + Widget _buildHorizontalButtons(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: AppSpacing.lg, 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, + Expanded( + child: AppButton( + label: loc(context).speedTestExternalTileCloudFlare, + onTap: () => openUrl('https://speed.cloudflare.com/'), + ), ), - AppText.titleLarge( - value, - color: isLegacy - ? Theme.of(context).colorScheme.outline - : Theme.of(context).colorScheme.onSurface, + Expanded( + child: AppButton( + label: loc(context).speedTestExternalTileFast, + onTap: () => openUrl('https://www.fast.com'), + ), ), - 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/port_status_widget.dart b/lib/page/dashboard/views/components/port_status_widget.dart new file mode 100644 index 000000000..a1d630409 --- /dev/null +++ b/lib/page/dashboard/views/components/port_status_widget.dart @@ -0,0 +1,186 @@ +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, + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (connectionInfo != null) connectionInfo, + if (wanLabel != null) wanLabel, + ], + ), + ], + ); + } +} diff --git a/lib/page/dashboard/views/components/wifi_card.dart b/lib/page/dashboard/views/components/wifi_card.dart new file mode 100644 index 000000000..a37ad0029 --- /dev/null +++ b/lib/page/dashboard/views/components/wifi_card.dart @@ -0,0 +1,275 @@ +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: [ + 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(), + AppText.labelLarge( + loc(context).nDevices(widget.item.numOfConnectedDevices), + ), + ], + ), + ), + 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/wifi_grid.dart b/lib/page/dashboard/views/components/wifi_grid.dart index 106ce2cff..ecc25dd44 100644 --- a/lib/page/dashboard/views/components/wifi_grid.dart +++ b/lib/page/dashboard/views/components/wifi_grid.dart @@ -1,25 +1,12 @@ 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:privacy_gui/page/dashboard/views/components/wifi_card.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'; +/// Grid displaying WiFi networks for the dashboard. class DashboardWiFiGrid extends ConsumerStatefulWidget { const DashboardWiFiGrid({super.key}); @@ -47,322 +34,66 @@ class _DashboardWiFiGridState extends ConsumerState { 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(); + final gridHeight = isLoading + ? itemHeight * 2 + mainSpacing * 1 + : mainAxisCount * itemHeight + + ((mainAxisCount == 0 ? 1 : mainAxisCount) - 1) * mainSpacing + + 100; - @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}); - }, + if (isLoading) { + return SizedBox( + height: gridHeight, + child: AppCard(padding: EdgeInsets.zero, child: LoadingTile()), ); - }); - } + } - 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); - }, - ), - ) - ], - ), + 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), + ); + }, ), ); } - 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(); + 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; }); - } - } - } - - 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); - // 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, - 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/dashboard_home_view.dart b/lib/page/dashboard/views/dashboard_home_view.dart index 46a531d1c..1a5afc7b4 100644 --- a/lib/page/dashboard/views/dashboard_home_view.dart +++ b/lib/page/dashboard/views/dashboard_home_view.dart @@ -6,7 +6,6 @@ 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'; @@ -17,6 +16,7 @@ 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/firmware_update_countdown_dialog.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' @@ -235,7 +235,7 @@ class _DashboardHomeViewState extends ConsumerState { context: context, barrierDismissible: false, builder: (context) { - return _FirmwareUpdateCountdownDialog(onFinish: reload); + return FirmwareUpdateCountdownDialog(onFinish: reload); }, ); } @@ -253,107 +253,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, - ), - ], - ), - ); - } } From 34cf4b73a25fe9cc060ceb33e7aa2b4e55d61391 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 12:54:42 +0800 Subject: [PATCH 08/26] refactor(instant_verify): use shared PortStatusWidget - Replace inner with shared in - Eliminate code duplication for port status rendering --- .../views/instant_verify_view.dart | 104 +++--------------- 1 file changed, 16 insertions(+), 88 deletions(-) diff --git a/lib/page/instant_verify/views/instant_verify_view.dart b/lib/page/instant_verify/views/instant_verify_view.dart index d7b316fdd..34698f689 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/port_status_widget.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'; @@ -743,22 +744,24 @@ class _InstantVerifyViewState extends ConsumerState children: [ ...state.lanPortConnections .mapIndexed((index, e) => Expanded( - child: _portWidget( - context, - e == 'None' ? null : e, - loc(context).indexedPort(index + 1), - false), + child: PortStatusWidget( + connection: e == 'None' ? null : e, + label: loc(context).indexedPort(index + 1), + isWan: false, + hasLanPorts: true, // Force vertical layout + ), )) .toList(), Expanded( - child: _portWidget( - context, - state.wanPortConnection == 'None' - ? null - : state.wanPortConnection, - loc(context).wan, - true), - ) + child: PortStatusWidget( + connection: state.wanPortConnection == 'None' + ? null + : state.wanPortConnection, + label: loc(context).wan, + isWan: true, + hasLanPorts: true, // Force vertical layout + ), + ), ], ), ), @@ -767,81 +770,6 @@ class _InstantVerifyViewState extends ConsumerState ); } - 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, - 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, - children: [ - AppIcon.font( - AppFontIcons.bidirectional, - color: Theme.of(context).colorScheme.primary, - ), - 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]) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, From cfe28ea55d1e603391d0d7f66a1f073bb3a9d195 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 12:58:40 +0800 Subject: [PATCH 09/26] refactor(port_and_speed): extract speed test components - Extract and to separate files - Reduce complexity (840 -> 246 lines) - Improve separation of concerns in dashboard components --- .../components/external_speed_test_links.dart | 143 ++++++++ .../internal_speed_test_result.dart | 189 ++++++++++ .../views/components/port_and_speed.dart | 327 +----------------- 3 files changed, 336 insertions(+), 323 deletions(-) create mode 100644 lib/page/dashboard/views/components/external_speed_test_links.dart create mode 100644 lib/page/dashboard/views/components/internal_speed_test_result.dart diff --git a/lib/page/dashboard/views/components/external_speed_test_links.dart b/lib/page/dashboard/views/components/external_speed_test_links.dart new file mode 100644 index 000000000..3901b838a --- /dev/null +++ b/lib/page/dashboard/views/components/external_speed_test_links.dart @@ -0,0 +1,143 @@ +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.sm, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(context, horizontalLayout, hasLanPort), + AppGap.sm(), + Flexible( + child: isVerticalDesktop + ? _buildVerticalButtons(context) + : _buildHorizontalButtons(context), + ), + AppGap.sm(), + 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/internal_speed_test_result.dart b/lib/page/dashboard/views/components/internal_speed_test_result.dart new file mode 100644 index 000000000..4b3f2ebc5 --- /dev/null +++ b/lib/page/dashboard/views/components/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/port_and_speed.dart b/lib/page/dashboard/views/components/port_and_speed.dart index 0c3889a48..eccb0e42d 100644 --- a/lib/page/dashboard/views/components/port_and_speed.dart +++ b/lib/page/dashboard/views/components/port_and_speed.dart @@ -1,21 +1,18 @@ 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/polling_provider.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/dashboard/models/dashboard_layout.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/views/components/external_speed_test_links.dart'; +import 'package:privacy_gui/page/dashboard/views/components/internal_speed_test_result.dart'; import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; import 'package:privacy_gui/page/dashboard/views/components/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:privacy_gui/route/constants.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'; /// Dashboard widget showing port connections and speed test results. @@ -168,7 +165,7 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { AppGap.xxl(), ], ) - : _SpeedCheckResultWidget(state: state); + : InternalSpeedTestResult(state: state); } return Tooltip( @@ -177,7 +174,7 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { opacity: isRemote ? 0.5 : 1, child: AbsorbPointer( absorbing: isRemote, - child: _ExternalSpeedTestWidget(state: state), + child: ExternalSpeedTestLinks(state: state), ), ), ); @@ -247,319 +244,3 @@ class _LayoutConfig { } } } - -/// Widget showing speed check results (internal speed test). -class _SpeedCheckResultWidget extends ConsumerWidget { - final DashboardHomeState state; - - const _SpeedCheckResultWidget({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), - ], - ], - ), - ); - } -} - -/// Widget for external speed test links. -class _ExternalSpeedTestWidget extends StatelessWidget { - final DashboardHomeState state; - - const _ExternalSpeedTestWidget({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.sm, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildHeader(context, horizontalLayout, hasLanPort), - AppGap.sm(), - Flexible( - child: isVerticalDesktop - ? _buildVerticalButtons(context) - : _buildHorizontalButtons(context), - ), - AppGap.sm(), - 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'), - ), - ), - ], - ); - } -} From 119b8cc86ff38b24884be9418d5a589f9497c663 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 13:01:25 +0800 Subject: [PATCH 10/26] fix(dashboard): align WiFi grid horizontal spacing with layout gutter - Use instead of fixed in - Ensure visual consistency between grid items and parent layout --- lib/page/dashboard/views/components/wifi_grid.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/page/dashboard/views/components/wifi_grid.dart b/lib/page/dashboard/views/components/wifi_grid.dart index ecc25dd44..3d8970bd5 100644 --- a/lib/page/dashboard/views/components/wifi_grid.dart +++ b/lib/page/dashboard/views/components/wifi_grid.dart @@ -24,7 +24,9 @@ class _DashboardWiFiGridState extends ConsumerState { final isLoading = (ref.watch(pollingProvider).value?.isReady ?? false) == false; final crossAxisCount = context.isMobileLayout ? 1 : 2; - const mainSpacing = AppSpacing.lg; + // 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); @@ -37,7 +39,7 @@ class _DashboardWiFiGridState extends ConsumerState { final gridHeight = isLoading ? itemHeight * 2 + mainSpacing * 1 : mainAxisCount * itemHeight + - ((mainAxisCount == 0 ? 1 : mainAxisCount) - 1) * mainSpacing + + ((mainAxisCount == 0 ? 1 : mainAxisCount) - 1) * AppSpacing.lg + 100; if (isLoading) { From 7053cbdcdf0616be64813e2623b14a707c056ce5 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 13:08:02 +0800 Subject: [PATCH 11/26] fix(dashboard): resolve multiple render overflow issues in mobile/narrow layouts - Fix horizontal overflow in WiFiCard header and footer using Expanded/Flexible - Fix horizontal overflow in PortStatusWidget - Optimise vertical spacing in ExternalSpeedTestLinks - Increase fixed container height for InternetConnectionWidget in desktop layout - Protect Networks info tiles from overflow --- .../components/external_speed_test_links.dart | 8 +++---- .../dashboard/views/components/networks.dart | 2 +- .../views/components/port_status_widget.dart | 16 ++++++++------ .../dashboard/views/components/wifi_card.dart | 21 ++++++++++++------- .../dashboard/views/dashboard_home_view.dart | 2 +- 5 files changed, 29 insertions(+), 20 deletions(-) diff --git a/lib/page/dashboard/views/components/external_speed_test_links.dart b/lib/page/dashboard/views/components/external_speed_test_links.dart index 3901b838a..35af186af 100644 --- a/lib/page/dashboard/views/components/external_speed_test_links.dart +++ b/lib/page/dashboard/views/components/external_speed_test_links.dart @@ -30,19 +30,19 @@ class ExternalSpeedTestLinks extends StatelessWidget { ), padding: const EdgeInsets.symmetric( horizontal: AppSpacing.xl, - vertical: AppSpacing.sm, + vertical: AppSpacing.xs, ), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: MainAxisSize.max, children: [ _buildHeader(context, horizontalLayout, hasLanPort), - AppGap.sm(), + const Spacer(), Flexible( child: isVerticalDesktop ? _buildVerticalButtons(context) : _buildHorizontalButtons(context), ), - AppGap.sm(), + const Spacer(), AppText.bodyExtraSmall(loc(context).speedTestExternalOthers), ], ), diff --git a/lib/page/dashboard/views/components/networks.dart b/lib/page/dashboard/views/components/networks.dart index b48a167f5..bd5d0c18e 100644 --- a/lib/page/dashboard/views/components/networks.dart +++ b/lib/page/dashboard/views/components/networks.dart @@ -267,7 +267,7 @@ class _DashboardNetworksState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - AppText.titleSmall('$count'), + Flexible(child: AppText.titleSmall('$count')), icon, ], ), diff --git a/lib/page/dashboard/views/components/port_status_widget.dart b/lib/page/dashboard/views/components/port_status_widget.dart index a1d630409..6f4f5afb0 100644 --- a/lib/page/dashboard/views/components/port_status_widget.dart +++ b/lib/page/dashboard/views/components/port_status_widget.dart @@ -173,12 +173,16 @@ class PortStatusWidget extends StatelessWidget { portImage, ], ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (connectionInfo != null) connectionInfo, - if (wanLabel != null) wanLabel, - ], + 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/wifi_card.dart b/lib/page/dashboard/views/components/wifi_card.dart index a37ad0029..dbfdad306 100644 --- a/lib/page/dashboard/views/components/wifi_card.dart +++ b/lib/page/dashboard/views/components/wifi_card.dart @@ -69,12 +69,14 @@ class _WiFiCardState extends ConsumerState { return 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('/')), + 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, @@ -104,8 +106,11 @@ class _WiFiCardState extends ConsumerState { children: [ AppIcon.font(AppFontIcons.devices), AppGap.sm(), - AppText.labelLarge( - loc(context).nDevices(widget.item.numOfConnectedDevices), + Flexible( + child: AppText.labelLarge( + loc(context).nDevices(widget.item.numOfConnectedDevices), + overflow: TextOverflow.ellipsis, + ), ), ], ), diff --git a/lib/page/dashboard/views/dashboard_home_view.dart b/lib/page/dashboard/views/dashboard_home_view.dart index 1a5afc7b4..5b87fe46a 100644 --- a/lib/page/dashboard/views/dashboard_home_view.dart +++ b/lib/page/dashboard/views/dashboard_home_view.dart @@ -85,7 +85,7 @@ class _DashboardHomeViewState extends ConsumerState { return Column( children: [ SizedBox( - height: 256, + height: 300, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ From 32fc9a9a9fb2d7e55411f1cb0bf2fc750e39dc2b Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 13:10:23 +0800 Subject: [PATCH 12/26] fix(dashboard): resolve mobile layout crash and optimize spacing - Fix 'Cannot hit test a render box with no size' error by replacing Spacer() with AppGap in ExternalSpeedTestLinks - Optimize vertical padding/gaps in ExternalSpeedTestLinks to fit constrained desktop layouts - Enhance stability of DashboardLayoutVariant mobile handling --- .../views/components/external_speed_test_links.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/page/dashboard/views/components/external_speed_test_links.dart b/lib/page/dashboard/views/components/external_speed_test_links.dart index 35af186af..acca8990e 100644 --- a/lib/page/dashboard/views/components/external_speed_test_links.dart +++ b/lib/page/dashboard/views/components/external_speed_test_links.dart @@ -33,16 +33,16 @@ class ExternalSpeedTestLinks extends StatelessWidget { vertical: AppSpacing.xs, ), child: Column( - mainAxisSize: MainAxisSize.max, + mainAxisSize: MainAxisSize.min, children: [ _buildHeader(context, horizontalLayout, hasLanPort), - const Spacer(), + AppGap.xs(), Flexible( child: isVerticalDesktop ? _buildVerticalButtons(context) : _buildHorizontalButtons(context), ), - const Spacer(), + AppGap.xs(), AppText.bodyExtraSmall(loc(context).speedTestExternalOthers), ], ), From 1de2bc45bdbabb74995937bd399b8c685d32b702 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 13:12:31 +0800 Subject: [PATCH 13/26] fix(dashboard): resolve layout crash by removing Flexible wrapper in ExternalSpeedTestLinks - Remove Flexible wrapper around buttons row to prevent layout exception in unconstrained height context (Mobile) - Ensure compatibility with both fixed-height Desktop and infinite-height Mobile layouts --- .../views/components/external_speed_test_links.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/page/dashboard/views/components/external_speed_test_links.dart b/lib/page/dashboard/views/components/external_speed_test_links.dart index acca8990e..6eccdcfbb 100644 --- a/lib/page/dashboard/views/components/external_speed_test_links.dart +++ b/lib/page/dashboard/views/components/external_speed_test_links.dart @@ -37,11 +37,9 @@ class ExternalSpeedTestLinks extends StatelessWidget { children: [ _buildHeader(context, horizontalLayout, hasLanPort), AppGap.xs(), - Flexible( - child: isVerticalDesktop - ? _buildVerticalButtons(context) - : _buildHorizontalButtons(context), - ), + isVerticalDesktop + ? _buildVerticalButtons(context) + : _buildHorizontalButtons(context), AppGap.xs(), AppText.bodyExtraSmall(loc(context).speedTestExternalOthers), ], From 928e8a43aeed47c5b3474ef88b824d9937da0cc1 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 13:22:47 +0800 Subject: [PATCH 14/26] feat(dashboard): implement optimized tablet layout - Add dedicated Tablet layout (2-column flexible grid) to DashboardHomeView - Fix DashboardLayoutVariant to support 'tablet' mode - Update DashboardHomePortAndSpeed to support flexible height in tablet mode - Fix desktop layout overflow by relaxing fixed height constraint to minHeight --- .../dashboard/models/dashboard_layout.dart | 3 + .../views/components/port_and_speed.dart | 28 +++++++-- .../dashboard/views/dashboard_home_view.dart | 61 ++++++++++++++++++- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/lib/page/dashboard/models/dashboard_layout.dart b/lib/page/dashboard/models/dashboard_layout.dart index b35ef5393..265419949 100644 --- a/lib/page/dashboard/models/dashboard_layout.dart +++ b/lib/page/dashboard/models/dashboard_layout.dart @@ -12,6 +12,9 @@ enum DashboardLayoutVariant { /// Desktop layout for devices without LAN ports desktopNoLanPorts, + + /// Tablet layout - optimized for mid-size screens (flexible 2-column) + tablet, } /// Extension to provide utility methods for DashboardLayoutVariant diff --git a/lib/page/dashboard/views/components/port_and_speed.dart b/lib/page/dashboard/views/components/port_and_speed.dart index eccb0e42d..e4efc69c3 100644 --- a/lib/page/dashboard/views/components/port_and_speed.dart +++ b/lib/page/dashboard/views/components/port_and_speed.dart @@ -41,11 +41,13 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { // Determine layout variant final layoutVariant = context.isMobileLayout ? DashboardLayoutVariant.mobile - : !hasLanPort - ? DashboardLayoutVariant.desktopNoLanPorts - : horizontalLayout - ? DashboardLayoutVariant.desktopHorizontal - : DashboardLayoutVariant.desktopVertical; + : context.isTabletLayout + ? DashboardLayoutVariant.tablet + : !hasLanPort + ? DashboardLayoutVariant.desktopNoLanPorts + : horizontalLayout + ? DashboardLayoutVariant.desktopHorizontal + : DashboardLayoutVariant.desktopVertical; return _buildLayout(context, ref, state, layoutVariant); } @@ -67,7 +69,7 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { child: AppCard( padding: EdgeInsets.zero, child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: config.mainAxisSize, mainAxisAlignment: config.mainAxisAlignment, children: [ SizedBox( @@ -188,6 +190,7 @@ class _LayoutConfig { final double? speedTestHeight; final EdgeInsets portsPadding; final MainAxisAlignment mainAxisAlignment; + final MainAxisSize mainAxisSize; const _LayoutConfig({ required this.minHeight, @@ -195,6 +198,7 @@ class _LayoutConfig { this.speedTestHeight, required this.portsPadding, this.mainAxisAlignment = MainAxisAlignment.start, + this.mainAxisSize = MainAxisSize.max, }); factory _LayoutConfig.fromVariant( @@ -209,6 +213,18 @@ class _LayoutConfig { horizontal: AppSpacing.lg, vertical: AppSpacing.xxl, ), + mainAxisSize: MainAxisSize.min, + ); + case DashboardLayoutVariant.tablet: + return _LayoutConfig( + minHeight: 0, + portsHeight: null, // Flexible + speedTestHeight: null, // Flexible + portsPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, // Tighter than mobile? + ), + mainAxisSize: MainAxisSize.min, ); case DashboardLayoutVariant.desktopHorizontal: return _LayoutConfig( diff --git a/lib/page/dashboard/views/dashboard_home_view.dart b/lib/page/dashboard/views/dashboard_home_view.dart index 5b87fe46a..0f5cabda5 100644 --- a/lib/page/dashboard/views/dashboard_home_view.dart +++ b/lib/page/dashboard/views/dashboard_home_view.dart @@ -77,6 +77,7 @@ class _DashboardHomeViewState extends ConsumerState { ], ), mobile: (context) => _mobileLayout(), + tablet: (context) => _tabletLayout(context), ), ); } @@ -84,8 +85,8 @@ class _DashboardHomeViewState extends ConsumerState { Widget _desktopNoLanPortsLayout(BuildContext layoutContext) { return Column( children: [ - SizedBox( - height: 300, + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 300), child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -230,6 +231,62 @@ class _DashboardHomeViewState extends ConsumerState { ); } + Widget _tabletLayout(BuildContext layoutContext) { + return Column( + children: [ + DashboardHomeTitle(), + AppGap.xl(), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 1, + child: InternetConnectionWidget(), + ), + AppGap.gutter(), + Expanded( + flex: 1, + child: DashboardHomePortAndSpeed(), + ), + ], + ), + ), + AppGap.lg(), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 1, + child: Column( + children: [ + DashboardNetworks(), + AppGap.lg(), + DashboardQuickPanel(), + ], + ), + ), + AppGap.gutter(), + Expanded( + flex: 1, + child: Column( + children: [ + if (getIt.get().isSupportVPN()) ...[ + VPNStatusTile(), + AppGap.lg(), + ], + DashboardWiFiGrid(), + ], + ), + ), + ], + ), + ), + ], + ); + } + void _showFirmwareUpdateCountdownDialog() { showDialog( context: context, From 3157550892fa4e73d777673d4568aac127e88ab9 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 13:28:54 +0800 Subject: [PATCH 15/26] fix(dashboard): resolve persistent desktop crash using IntrinsicHeight - Replace ConstrainedBox(minHeight) with IntrinsicHeight in _desktopNoLanPortsLayout - Ensures layout stability by resolving content size before constraints - Fixes 'Unexpected null value' and 'LayoutBuilder assertion failed' caused by recursive layout failures --- lib/page/dashboard/views/components/port_and_speed.dart | 1 + lib/page/dashboard/views/dashboard_home_view.dart | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/page/dashboard/views/components/port_and_speed.dart b/lib/page/dashboard/views/components/port_and_speed.dart index e4efc69c3..aec02f91a 100644 --- a/lib/page/dashboard/views/components/port_and_speed.dart +++ b/lib/page/dashboard/views/components/port_and_speed.dart @@ -256,6 +256,7 @@ class _LayoutConfig { horizontal: AppSpacing.sm, vertical: AppSpacing.md, ), + mainAxisSize: MainAxisSize.min, ); } } diff --git a/lib/page/dashboard/views/dashboard_home_view.dart b/lib/page/dashboard/views/dashboard_home_view.dart index 0f5cabda5..1f8bcd3d0 100644 --- a/lib/page/dashboard/views/dashboard_home_view.dart +++ b/lib/page/dashboard/views/dashboard_home_view.dart @@ -85,8 +85,7 @@ class _DashboardHomeViewState extends ConsumerState { Widget _desktopNoLanPortsLayout(BuildContext layoutContext) { return Column( children: [ - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 300), + IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ From ae6a642d8e1b25946fc7f539dd76aa9e3111cc05 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 13:32:36 +0800 Subject: [PATCH 16/26] fix(dashboard): optimize Tablet wifi grid and fix height calc overflow - Force single-column layout for WiFiGrid in Tablet mode (requested by user) - Fix floating point height calculation in WiFiGrid using .ceil() to prevent bottom overflow - Closes user request about squeezed card content in Tablet view --- lib/page/dashboard/views/components/wifi_grid.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/page/dashboard/views/components/wifi_grid.dart b/lib/page/dashboard/views/components/wifi_grid.dart index 3d8970bd5..e36883dd8 100644 --- a/lib/page/dashboard/views/components/wifi_grid.dart +++ b/lib/page/dashboard/views/components/wifi_grid.dart @@ -23,12 +23,13 @@ class _DashboardWiFiGridState extends ConsumerState { ref.watch(dashboardHomeProvider.select((value) => value.wifis)); final isLoading = (ref.watch(pollingProvider).value?.isReady ?? false) == false; - final crossAxisCount = context.isMobileLayout ? 1 : 2; + 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); + final mainAxisCount = (items.length / crossAxisCount).ceil(); final enabledWiFiCount = items.where((e) => !e.isGuest && e.isEnabled).length; From 29dbb47c9c3820f6202f0d21689781cd0113e04c Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 13:36:11 +0800 Subject: [PATCH 17/26] fix(dashboard): adjust networks list height limit - Update topologyItemHeight constant to 72.0 to match actual UI Kit tile height - Update treeViewBaseHeight to 72.0 - Ensures DashboardNetworks card height is limited to exactly 3 routers on Desktop/Tablet, enabling scroll for overflow - Closes user request for scrollable network list --- lib/page/dashboard/views/components/networks.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/page/dashboard/views/components/networks.dart b/lib/page/dashboard/views/components/networks.dart index bd5d0c18e..4a50b1983 100644 --- a/lib/page/dashboard/views/components/networks.dart +++ b/lib/page/dashboard/views/components/networks.dart @@ -44,8 +44,8 @@ class _DashboardNetworksState extends ConsumerState { final meshTopology = TopologyAdapter.convert(topologyState.root.children); // Calculate topology height - const topologyItemHeight = 96.0; - const treeViewBaseHeight = 68.0; + const topologyItemHeight = 72.0; + const treeViewBaseHeight = 72.0; final routerLength = topologyState.root.children.firstOrNull?.toFlatList().length ?? 1; final double nodeTopologyHeight = context.isMobileLayout From cc6b7233d77bcbae2ac3b82dace00545c8abe613 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 13:40:48 +0800 Subject: [PATCH 18/26] refactor(dashboard): centralize layout variant logic - Add static factory to centralize complex layout determination logic - Refactor to use new helper, removing verbose ternary operators - Refactor to use , implicitly improving Tablet layout handling for vertical/horizontal decision - Add missing export to to fix library visibility --- lib/page/dashboard/_dashboard.dart | 1 + .../dashboard/models/dashboard_layout.dart | 28 ++++++++++++++++++- .../dashboard/views/components/networks.dart | 8 +++++- .../views/components/port_and_speed.dart | 14 ++++------ 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/lib/page/dashboard/_dashboard.dart b/lib/page/dashboard/_dashboard.dart index 4a3bc01ca..9278a6aaf 100644 --- a/lib/page/dashboard/_dashboard.dart +++ b/lib/page/dashboard/_dashboard.dart @@ -1,2 +1,3 @@ export 'views/_views.dart'; export 'providers/_providers.dart'; +export 'models/dashboard_layout.dart'; diff --git a/lib/page/dashboard/models/dashboard_layout.dart b/lib/page/dashboard/models/dashboard_layout.dart index 265419949..d09244a5b 100644 --- a/lib/page/dashboard/models/dashboard_layout.dart +++ b/lib/page/dashboard/models/dashboard_layout.dart @@ -1,3 +1,6 @@ +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 { @@ -14,7 +17,30 @@ enum DashboardLayoutVariant { desktopNoLanPorts, /// Tablet layout - optimized for mid-size screens (flexible 2-column) - tablet, + tablet; + + /// 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) { + return DashboardLayoutVariant.tablet; + } + + if (!hasLanPort) { + return DashboardLayoutVariant.desktopNoLanPorts; + } + + return isHorizontalLayout + ? DashboardLayoutVariant.desktopHorizontal + : DashboardLayoutVariant.desktopVertical; + } } /// Extension to provide utility methods for DashboardLayoutVariant diff --git a/lib/page/dashboard/views/components/networks.dart b/lib/page/dashboard/views/components/networks.dart index 4a50b1983..aedd9de52 100644 --- a/lib/page/dashboard/views/components/networks.dart +++ b/lib/page/dashboard/views/components/networks.dart @@ -88,8 +88,14 @@ class _DashboardNetworksState extends ConsumerState { ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; // Determine layout variant + // Determine layout variant + final layoutVariant = DashboardLayoutVariant.fromContext( + context, + hasLanPort: hasLanPort, + isHorizontalLayout: state.isHorizontalLayout, + ); final useVerticalLayout = - !context.isMobileLayout && hasLanPort && !state.isHorizontalLayout; + layoutVariant == DashboardLayoutVariant.desktopVertical; final titleSection = Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/page/dashboard/views/components/port_and_speed.dart b/lib/page/dashboard/views/components/port_and_speed.dart index aec02f91a..fb311fa81 100644 --- a/lib/page/dashboard/views/components/port_and_speed.dart +++ b/lib/page/dashboard/views/components/port_and_speed.dart @@ -39,15 +39,11 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { } // Determine layout variant - final layoutVariant = context.isMobileLayout - ? DashboardLayoutVariant.mobile - : context.isTabletLayout - ? DashboardLayoutVariant.tablet - : !hasLanPort - ? DashboardLayoutVariant.desktopNoLanPorts - : horizontalLayout - ? DashboardLayoutVariant.desktopHorizontal - : DashboardLayoutVariant.desktopVertical; + final layoutVariant = DashboardLayoutVariant.fromContext( + context, + hasLanPort: hasLanPort, + isHorizontalLayout: horizontalLayout, + ); return _buildLayout(context, ref, state, layoutVariant); } From 77895dae801852892a41967c6dd5eba68a1dfdc8 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 13:42:08 +0800 Subject: [PATCH 19/26] chore(dashboard): remove unused DashboardLayoutVariantX extension - Confirmed and extension methods on are unused - Removed to keep codebase clean and avoid ambiguity with --- lib/page/dashboard/models/dashboard_layout.dart | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lib/page/dashboard/models/dashboard_layout.dart b/lib/page/dashboard/models/dashboard_layout.dart index d09244a5b..0e16609ab 100644 --- a/lib/page/dashboard/models/dashboard_layout.dart +++ b/lib/page/dashboard/models/dashboard_layout.dart @@ -42,12 +42,3 @@ enum DashboardLayoutVariant { : DashboardLayoutVariant.desktopVertical; } } - -/// Extension to provide utility methods for DashboardLayoutVariant -extension DashboardLayoutVariantX on DashboardLayoutVariant { - /// Returns true if this is a desktop layout variant - bool get isDesktop => this != DashboardLayoutVariant.mobile; - - /// Returns true if this is the mobile layout - bool get isMobile => this == DashboardLayoutVariant.mobile; -} From 83c1c5e8eaf07d1961cb589337e808c02ae4bb17 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 18:18:54 +0800 Subject: [PATCH 20/26] refactor(dashboard): implement Strategy pattern + IoC for layout - Create DashboardLayoutStrategy interface and 7 concrete strategies: - MobileLayoutStrategy - DesktopHorizontalLayoutStrategy - DesktopVerticalLayoutStrategy - DesktopNoLanPortsLayoutStrategy - TabletLayoutStrategy - TabletHorizontalLayoutStrategy - TabletVerticalLayoutStrategy - Create DashboardLayoutContext (IoC container) for widget injection - Create DashboardLayoutFactory for O(1) strategy lookup - Refactor DashboardHomeView to delegate layout to strategies - Create DashboardLoadingWrapper for shared loading state handling - Refactor all dashboard components to use DashboardLoadingWrapper: - port_and_speed.dart - home_title.dart - networks.dart (also simplified to StatelessWidget) - wifi_grid.dart - internet_status.dart - quick_panel.dart - Reduce dashboard_home_view.dart from 367 to ~118 lines (-68%) This eliminates scattered if-else layout logic and centralizes layout decisions in Strategy classes following IoC principles. --- .../remote_assistance_dialog.dart | 2 +- lib/page/dashboard/_dashboard.dart | 1 + .../dashboard/models/dashboard_layout.dart | 15 +- .../dashboard/strategies/_strategies.dart | 10 + .../strategies/dashboard_layout_context.dart | 99 ++++++ .../strategies/dashboard_layout_factory.dart | 39 +++ .../strategies/dashboard_layout_strategy.dart | 21 ++ .../desktop_horizontal_layout_strategy.dart | 65 ++++ .../desktop_no_lan_ports_layout_strategy.dart | 77 +++++ .../desktop_vertical_layout_strategy.dart | 64 ++++ .../strategies/mobile_layout_strategy.dart | 44 +++ .../tablet_horizontal_layout_strategy.dart | 67 ++++ .../strategies/tablet_layout_strategy.dart | 81 +++++ .../tablet_vertical_layout_strategy.dart | 63 ++++ .../views/components/_components.dart | 19 ++ .../components/dashboard_loading_wrapper.dart | 59 ++++ .../views/components/home_title.dart | 104 +++--- .../views/components/internet_status.dart | 308 ++++++++---------- .../dashboard/views/components/networks.dart | 38 +-- .../views/components/port_and_speed.dart | 169 +++------- .../views/components/quick_panel.dart | 173 +++++----- .../dashboard/views/components/wifi_grid.dart | 33 +- .../dashboard/views/dashboard_home_view.dart | 269 ++------------- .../views/instant_verify_view.dart | 2 +- lib/page/vpn/views/vpn_status_tile.dart | 2 +- 25 files changed, 1110 insertions(+), 714 deletions(-) create mode 100644 lib/page/dashboard/strategies/_strategies.dart create mode 100644 lib/page/dashboard/strategies/dashboard_layout_context.dart create mode 100644 lib/page/dashboard/strategies/dashboard_layout_factory.dart create mode 100644 lib/page/dashboard/strategies/dashboard_layout_strategy.dart create mode 100644 lib/page/dashboard/strategies/desktop_horizontal_layout_strategy.dart create mode 100644 lib/page/dashboard/strategies/desktop_no_lan_ports_layout_strategy.dart create mode 100644 lib/page/dashboard/strategies/desktop_vertical_layout_strategy.dart create mode 100644 lib/page/dashboard/strategies/mobile_layout_strategy.dart create mode 100644 lib/page/dashboard/strategies/tablet_horizontal_layout_strategy.dart create mode 100644 lib/page/dashboard/strategies/tablet_layout_strategy.dart create mode 100644 lib/page/dashboard/strategies/tablet_vertical_layout_strategy.dart create mode 100644 lib/page/dashboard/views/components/_components.dart create mode 100644 lib/page/dashboard/views/components/dashboard_loading_wrapper.dart 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 9278a6aaf..f196f19c6 100644 --- a/lib/page/dashboard/_dashboard.dart +++ b/lib/page/dashboard/_dashboard.dart @@ -1,3 +1,4 @@ export 'views/_views.dart'; export 'providers/_providers.dart'; export 'models/dashboard_layout.dart'; +export 'strategies/_strategies.dart'; diff --git a/lib/page/dashboard/models/dashboard_layout.dart b/lib/page/dashboard/models/dashboard_layout.dart index 0e16609ab..0cc431e3d 100644 --- a/lib/page/dashboard/models/dashboard_layout.dart +++ b/lib/page/dashboard/models/dashboard_layout.dart @@ -17,7 +17,13 @@ enum DashboardLayoutVariant { desktopNoLanPorts, /// Tablet layout - optimized for mid-size screens (flexible 2-column) - tablet; + 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( @@ -30,7 +36,12 @@ enum DashboardLayoutVariant { } if (context.isTabletLayout) { - return DashboardLayoutVariant.tablet; + if (!hasLanPort) { + return DashboardLayoutVariant.tablet; + } + return isHorizontalLayout + ? DashboardLayoutVariant.tabletHorizontal + : DashboardLayoutVariant.tabletVertical; } if (!hasLanPort) { diff --git a/lib/page/dashboard/strategies/_strategies.dart b/lib/page/dashboard/strategies/_strategies.dart new file mode 100644 index 000000000..2e027ec7c --- /dev/null +++ b/lib/page/dashboard/strategies/_strategies.dart @@ -0,0 +1,10 @@ +export 'dashboard_layout_context.dart'; +export 'dashboard_layout_strategy.dart'; +export 'dashboard_layout_factory.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/dashboard_layout_context.dart b/lib/page/dashboard/strategies/dashboard_layout_context.dart new file mode 100644 index 000000000..c47b17ba8 --- /dev/null +++ b/lib/page/dashboard/strategies/dashboard_layout_context.dart @@ -0,0 +1,99 @@ +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'; + +/// Configuration for port and speed widget building. +class PortAndSpeedConfig { + /// Direction of port layout (horizontal or vertical). + 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 = Axis.horizontal, + 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; + + 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, + }); + + /// Convenience getter for column width calculation. + double colWidth(int columns) => context.colWidth(columns); +} 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/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..0164cf4df --- /dev/null +++ b/lib/page/dashboard/views/components/_components.dart @@ -0,0 +1,19 @@ +/// Dashboard home components barrel file. +library; + +export 'dashboard_loading_wrapper.dart'; +export 'dashboard_tile.dart'; +export 'external_speed_test_links.dart'; +export 'firmware_update_countdown_dialog.dart'; +export 'home_title.dart'; +export 'internal_speed_test_result.dart'; +export 'internet_status.dart'; +export 'loading_tile.dart'; +export 'networks.dart'; +export 'port_and_speed.dart'; +export 'port_status_widget.dart'; +export 'quick_panel.dart'; +export 'remote_assistance_animation.dart'; +export 'shimmer.dart'; +export 'wifi_card.dart'; +export 'wifi_grid.dart'; diff --git a/lib/page/dashboard/views/components/dashboard_loading_wrapper.dart b/lib/page/dashboard/views/components/dashboard_loading_wrapper.dart new file mode 100644 index 000000000..f08042063 --- /dev/null +++ b/lib/page/dashboard/views/components/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/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/home_title.dart b/lib/page/dashboard/views/components/home_title.dart index 1eb85edcd..9c1e865c6 100644 --- a/lib/page/dashboard/views/components/home_title.dart +++ b/lib/page/dashboard/views/components/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/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/internet_status.dart b/lib/page/dashboard/views/components/internet_status.dart index aea55060d..b112cc048 100644 --- a/lib/page/dashboard/views/components/internet_status.dart +++ b/lib/page/dashboard/views/components/internet_status.dart @@ -9,9 +9,9 @@ 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/dashboard/views/components/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/page/dashboard/views/components/loading_tile.dart'; import 'package:privacy_gui/utils.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -28,193 +28,173 @@ class _InternetConnectionWidgetState extends ConsumerState { @override Widget build(BuildContext context) { + return DashboardLoadingWrapper( + loadingHeight: 150, + builder: (context, ref) => _buildContent(context, ref), + ); + } + + Widget _buildContent(BuildContext context, WidgetRef ref) { 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 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 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, + 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: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Row( + 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: [ - Icon( - Icons.circle, - color: isOnline - ? Theme.of(context) - .extension()! - .semanticSuccess - : Theme.of(context) - .colorScheme - .surfaceContainerHighest, - size: 16.0, + AppText.titleSmall( + isOnline + ? loc(context).internetOnline + : loc(context).internetOffline, ), - 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(); - }); - }, - ), - ); - }, - ), + if (geolocationState.value?.name.isNotEmpty == true) ...[ + AppGap.sm(), + SharedWidgets.geolocationWidget( + context, + geolocationState.value?.name ?? '', + geolocationState.value?.displayLocationText ?? ''), + ], ], ), ), - 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), - ), + 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(); + }); + }, + ), + ); + }, ), - 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), - }, + ], + ), + ), + 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: [ - 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); - }, - ), - ] - ], + 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 index aedd9de52..a991a2d03 100644 --- a/lib/page/dashboard/views/components/networks.dart +++ b/lib/page/dashboard/views/components/networks.dart @@ -6,39 +6,30 @@ 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/dashboard/views/components/loading_tile.dart'; +import 'package:privacy_gui/page/dashboard/views/components/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'; -class DashboardNetworks extends ConsumerStatefulWidget { +class DashboardNetworks extends ConsumerWidget { const DashboardNetworks({super.key}); @override - ConsumerState createState() => _DashboardNetworksState(); -} + Widget build(BuildContext context, WidgetRef ref) { + return DashboardLoadingWrapper( + builder: (context, ref) => _buildContent(context, ref), + ); + } -class _DashboardNetworksState extends ConsumerState { - @override - Widget build(BuildContext context) { + Widget _buildContent(BuildContext context, WidgetRef ref) { final state = ref.watch(dashboardHomeProvider); final topologyState = ref.watch(instantTopologyProvider); - final isLoading = - (ref.watch(pollingProvider).value?.isReady ?? false) == false; - - if (isLoading) { - return AppCard( - padding: EdgeInsets.zero, - child: SizedBox(width: double.infinity, child: const LoadingTile()), - ); - } // Convert topology data to ui_kit format final meshTopology = TopologyAdapter.convert(topologyState.root.children); @@ -87,7 +78,6 @@ class _DashboardNetworksState extends ConsumerState { final hasLanPort = ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; - // Determine layout variant // Determine layout variant final layoutVariant = DashboardLayoutVariant.fromContext( context, @@ -95,7 +85,8 @@ class _DashboardNetworksState extends ConsumerState { isHorizontalLayout: state.isHorizontalLayout, ); final useVerticalLayout = - layoutVariant == DashboardLayoutVariant.desktopVertical; + layoutVariant == DashboardLayoutVariant.desktopVertical || + layoutVariant == DashboardLayoutVariant.tabletVertical; final titleSection = Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -106,15 +97,10 @@ class _DashboardNetworksState extends ConsumerState { ); final infoTilesSection = Row( - mainAxisSize: useVerticalLayout ? MainAxisSize.min : MainAxisSize.max, children: [ - useVerticalLayout - ? _nodesInfoTile(context, ref, topologyState) - : Expanded(child: _nodesInfoTile(context, ref, topologyState)), + Expanded(child: _nodesInfoTile(context, ref, topologyState)), AppGap.gutter(), - useVerticalLayout - ? _devicesInfoTile(context, ref, topologyState) - : Expanded(child: _devicesInfoTile(context, ref, topologyState)), + Expanded(child: _devicesInfoTile(context, ref, topologyState)), ], ); diff --git a/lib/page/dashboard/views/components/port_and_speed.dart b/lib/page/dashboard/views/components/port_and_speed.dart index fb311fa81..1600aa749 100644 --- a/lib/page/dashboard/views/components/port_and_speed.dart +++ b/lib/page/dashboard/views/components/port_and_speed.dart @@ -2,97 +2,101 @@ 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/core/data/providers/polling_provider.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/dashboard/models/dashboard_layout.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/dashboard_loading_wrapper.dart'; import 'package:privacy_gui/page/dashboard/views/components/external_speed_test_links.dart'; import 'package:privacy_gui/page/dashboard/views/components/internal_speed_test_result.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; import 'package:privacy_gui/page/dashboard/views/components/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. class DashboardHomePortAndSpeed extends ConsumerWidget { - const DashboardHomePortAndSpeed({super.key}); + const DashboardHomePortAndSpeed({ + super.key, + required this.config, + }); + + /// Configuration provided by the parent Strategy. + final PortAndSpeedConfig config; @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 hasLanPort = state.lanPortConnections.isNotEmpty; - - if (isLoading) { - return AppCard( - padding: EdgeInsets.zero, - child: SizedBox( - width: double.infinity, - height: 250, - child: const LoadingTile(), - ), - ); - } - - // Determine layout variant - final layoutVariant = DashboardLayoutVariant.fromContext( - context, - hasLanPort: hasLanPort, - isHorizontalLayout: horizontalLayout, + return DashboardLoadingWrapper( + loadingHeight: 250, + builder: (context, ref) { + final state = ref.watch(dashboardHomeProvider); + return _buildLayout(context, ref, state); + }, ); - - return _buildLayout(context, ref, state, layoutVariant); } Widget _buildLayout( BuildContext context, WidgetRef ref, DashboardHomeState state, - DashboardLayoutVariant variant, ) { final hasLanPort = state.lanPortConnections.isNotEmpty; + final isVertical = config.direction == Axis.vertical; - // Configure layout parameters based on variant - final config = _LayoutConfig.fromVariant(variant, hasLanPort); + // Calculate minimum height based on config + final minHeight = _calculateMinHeight(isVertical, hasLanPort); return Container( width: double.infinity, - constraints: BoxConstraints(minHeight: config.minHeight), + constraints: BoxConstraints(minHeight: minHeight), child: AppCard( padding: EdgeInsets.zero, child: Column( - mainAxisSize: config.mainAxisSize, - mainAxisAlignment: config.mainAxisAlignment, + 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, variant), + child: _buildPortsSection(context, state), ), ), - SizedBox( - width: double.infinity, - height: config.speedTestHeight, - child: _buildSpeedTestSection(context, ref, state, hasLanPort), - ), + if (config.showSpeedTest) + SizedBox( + width: double.infinity, + height: config.speedTestHeight, + child: _buildSpeedTestSection(context, ref, state, hasLanPort), + ), ], ), ), ); } + double _calculateMinHeight(bool isVertical, bool hasLanPort) { + if (isVertical) { + return 360; + } + if (!hasLanPort) { + return 256; + } + return 110; + } + Widget _buildPortsSection( BuildContext context, DashboardHomeState state, - DashboardLayoutVariant variant, ) { final hasLanPort = state.lanPortConnections.isNotEmpty; - final isVertical = variant == DashboardLayoutVariant.desktopVertical; + final isVertical = config.direction == Axis.vertical; // Build LAN port widgets final lanPorts = state.lanPortConnections.mapIndexed((index, e) { @@ -116,7 +120,7 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { hasLanPorts: hasLanPort, ); - // Arrange based on layout variant + // Arrange based on config direction if (isVertical) { return Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -178,82 +182,3 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { ); } } - -/// Configuration for different layout variants. -class _LayoutConfig { - final double minHeight; - final double? portsHeight; - final double? speedTestHeight; - final EdgeInsets portsPadding; - final MainAxisAlignment mainAxisAlignment; - final MainAxisSize mainAxisSize; - - const _LayoutConfig({ - required this.minHeight, - this.portsHeight, - this.speedTestHeight, - required this.portsPadding, - this.mainAxisAlignment = MainAxisAlignment.start, - this.mainAxisSize = MainAxisSize.max, - }); - - factory _LayoutConfig.fromVariant( - DashboardLayoutVariant variant, bool hasLanPort) { - switch (variant) { - case DashboardLayoutVariant.mobile: - return _LayoutConfig( - minHeight: 0, - portsHeight: null, - speedTestHeight: null, - portsPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.xxl, - ), - mainAxisSize: MainAxisSize.min, - ); - case DashboardLayoutVariant.tablet: - return _LayoutConfig( - minHeight: 0, - portsHeight: null, // Flexible - speedTestHeight: null, // Flexible - portsPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.md, // Tighter than mobile? - ), - mainAxisSize: MainAxisSize.min, - ); - case DashboardLayoutVariant.desktopHorizontal: - return _LayoutConfig( - minHeight: 110, - portsHeight: 224, - speedTestHeight: 112, - portsPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xxxl, - ), - ); - case DashboardLayoutVariant.desktopVertical: - return _LayoutConfig( - minHeight: 360, - portsHeight: 752, - speedTestHeight: null, - portsPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xxl, - ), - mainAxisAlignment: MainAxisAlignment.spaceBetween, - ); - case DashboardLayoutVariant.desktopNoLanPorts: - return _LayoutConfig( - minHeight: 256, - portsHeight: 120, - speedTestHeight: 132, - portsPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.md, - ), - mainAxisSize: MainAxisSize.min, - ); - } - } -} diff --git a/lib/page/dashboard/views/components/quick_panel.dart b/lib/page/dashboard/views/components/quick_panel.dart index 86777f13c..48eaf3652 100644 --- a/lib/page/dashboard/views/components/quick_panel.dart +++ b/lib/page/dashboard/views/components/quick_panel.dart @@ -4,17 +4,16 @@ 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/dashboard/views/components/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:privacy_gui/page/dashboard/views/components/loading_tile.dart'; import 'package:ui_kit_library/ui_kit.dart'; class DashboardQuickPanel extends ConsumerStatefulWidget { @@ -28,101 +27,93 @@ class DashboardQuickPanel extends ConsumerStatefulWidget { class _DashboardQuickPanelState extends ConsumerState { @override Widget build(BuildContext context) { + return DashboardLoadingWrapper( + loadingHeight: 150, + builder: (context, ref) => _buildContent(context, ref), + ); + } + + Widget _buildContent(BuildContext context, WidgetRef ref) { 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; + final master = ref.watch(instantTopologyProvider).root.children.first; bool isSupportNodeLight = serviceHelper.isSupportLedMode(); bool isCognitive = isCognitiveMeshRouter( - modelNumber: master?.data.model ?? '', - hardwareVersion: master?.data.hardwareVersion ?? '1'); + modelNumber: master.data.model, + hardwareVersion: master.data.hardwareVersion); - 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'), - ] - ], + 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'), + ] + ], + ), + ); } Widget toggleTileWidget({ diff --git a/lib/page/dashboard/views/components/wifi_grid.dart b/lib/page/dashboard/views/components/wifi_grid.dart index e36883dd8..30ec09ec9 100644 --- a/lib/page/dashboard/views/components/wifi_grid.dart +++ b/lib/page/dashboard/views/components/wifi_grid.dart @@ -1,8 +1,7 @@ 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/providers/dashboard_home_provider.dart'; -import 'package:privacy_gui/page/dashboard/views/components/loading_tile.dart'; +import 'package:privacy_gui/page/dashboard/views/components/dashboard_loading_wrapper.dart'; import 'package:privacy_gui/page/dashboard/views/components/wifi_card.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -19,10 +18,22 @@ class _DashboardWiFiGridState extends ConsumerState { @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) { final items = ref.watch(dashboardHomeProvider.select((value) => value.wifis)); - final isLoading = - (ref.watch(pollingProvider).value?.isReady ?? false) == false; final crossAxisCount = (context.isMobileLayout || context.isTabletLayout) ? 1 : 2; // Use layout gutter for horizontal spacing to match Dashboard Layout @@ -37,18 +48,8 @@ class _DashboardWiFiGridState extends ConsumerState { ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; final canBeDisabled = enabledWiFiCount > 1 || hasLanPort; - final gridHeight = isLoading - ? itemHeight * 2 + mainSpacing * 1 - : mainAxisCount * itemHeight + - ((mainAxisCount == 0 ? 1 : mainAxisCount) - 1) * AppSpacing.lg + - 100; - - if (isLoading) { - return SizedBox( - height: gridHeight, - child: AppCard(padding: EdgeInsets.zero, child: LoadingTile()), - ); - } + final gridHeight = mainAxisCount * itemHeight + + ((mainAxisCount == 0 ? 1 : mainAxisCount) - 1) * AppSpacing.lg; return SizedBox( height: gridHeight, diff --git a/lib/page/dashboard/views/dashboard_home_view.dart b/lib/page/dashboard/views/dashboard_home_view.dart index 1f8bcd3d0..c8ff0cc44 100644 --- a/lib/page/dashboard/views/dashboard_home_view.dart +++ b/lib/page/dashboard/views/dashboard_home_view.dart @@ -10,15 +10,8 @@ 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/firmware_update_countdown_dialog.dart'; +import 'package:privacy_gui/page/dashboard/views/components/_components.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 +30,6 @@ class _DashboardHomeViewState extends ConsumerState { void initState() { super.initState(); firmware = ref.read(firmwareUpdateProvider.notifier); - // _pushNotificationCheck(); _firmwareUpdateCheck(); ref.read(menuController).setTo(NaviType.home); } @@ -45,10 +37,10 @@ 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 hasLanPort = state.lanPortConnections.isNotEmpty; + final isHorizontalLayout = state.isHorizontalLayout; + final isSupportVPN = getIt.get().isSupportVPN(); return UiKitPageView.withSliver( scrollable: true, @@ -58,231 +50,38 @@ 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(), - tablet: (context) => _tabletLayout(context), - ), - ); - } + child: (childContext, constraints) { + // 1. Determine layout variant (single source of truth) + final variant = DashboardLayoutVariant.fromContext( + childContext, + hasLanPort: hasLanPort, + isHorizontalLayout: isHorizontalLayout, + ); - Widget _desktopNoLanPortsLayout(BuildContext layoutContext) { - return Column( - children: [ - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: layoutContext.colWidth(8), - child: InternetConnectionWidget()), - AppGap.gutter(), - SizedBox( - width: layoutContext.colWidth(4), - child: DashboardHomePortAndSpeed()), - ], - ), - ), - 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(), - ], - ), - ), - AppGap.gutter(), - SizedBox( - width: layoutContext.colWidth(4), - child: Column( - children: [ - DashboardNetworks(), - AppGap.lg(), - if (getIt.get().isSupportVPN()) ...[ - VPNStatusTile(), - AppGap.lg(), - ], - DashboardQuickPanel(), - // _networkInfoTiles(state, isLoading), - ], - )), - ], - ), - ); - } + // 2. Build layout context (IoC - widgets built here, passed to strategy) + final layoutContext = DashboardLayoutContext( + context: childContext, + ref: ref, + state: state, + hasLanPort: hasLanPort, + isHorizontalLayout: isHorizontalLayout, + title: const DashboardHomeTitle(), + internetWidget: const InternetConnectionWidget(), + networksWidget: const DashboardNetworks(), + wifiGrid: const DashboardWiFiGrid(), + quickPanel: const DashboardQuickPanel(), + vpnTile: isSupportVPN ? const VPNStatusTile() : null, + buildPortAndSpeed: (config) => + DashboardHomePortAndSpeed(config: config), + ); - Widget _desktopVerticalLayout(BuildContext layoutContext) { - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: layoutContext.colWidth(3), - child: Column( - children: [ - DashboardHomePortAndSpeed(), - AppGap.lg(), - DashboardQuickPanel(), - ], - ), - ), - AppGap.gutter(), - Expanded( - child: Column( - children: [ - InternetConnectionWidget(), - AppGap.lg(), - DashboardNetworks(), - AppGap.lg(), - if (getIt.get().isSupportVPN()) ...[ - VPNStatusTile(), - AppGap.lg(), - ], - DashboardWiFiGrid(), - ], - ), - ), - ], - ), - ); - } - - 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(), - ], - ); - } - - Widget _tabletLayout(BuildContext layoutContext) { - return Column( - children: [ - DashboardHomeTitle(), - AppGap.xl(), - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 1, - child: InternetConnectionWidget(), - ), - AppGap.gutter(), - Expanded( - flex: 1, - child: DashboardHomePortAndSpeed(), - ), - ], - ), - ), - AppGap.lg(), - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 1, - child: Column( - children: [ - DashboardNetworks(), - AppGap.lg(), - DashboardQuickPanel(), - ], - ), - ), - AppGap.gutter(), - Expanded( - flex: 1, - child: Column( - children: [ - if (getIt.get().isSupportVPN()) ...[ - VPNStatusTile(), - AppGap.lg(), - ], - DashboardWiFiGrid(), - ], - ), - ), - ], - ), - ), - ], + // 3. Delegate to strategy + final strategy = DashboardLayoutFactory.create(variant); + return strategy.build(layoutContext); + }, ); } diff --git a/lib/page/instant_verify/views/instant_verify_view.dart b/lib/page/instant_verify/views/instant_verify_view.dart index 34698f689..7f78c1ecd 100644 --- a/lib/page/instant_verify/views/instant_verify_view.dart +++ b/lib/page/instant_verify/views/instant_verify_view.dart @@ -22,7 +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/port_status_widget.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'; 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 { From 02e020a5b24f7dfa166793e56d45f08ceef69713 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 18:59:53 +0800 Subject: [PATCH 21/26] feat(dashboard): Add layout constraint system for customizable dashboard layouts Phase 1 - Core models: - DisplayMode: compact/normal/expanded enum - HeightStrategy: intrinsic/columnBased/aspectRatio sealed class - WidgetGridConstraints: column-based layout constraints - WidgetSpec: widget specification with mode-based constraints - DashboardWidgetSpecs: predefined specs for all dashboard widgets Phase 2 - Layout resolver: - GridLayoutResolver: calculates widget sizes using UI Kit's colWidth API - Extended DashboardLayoutContext with constraint helpers Phase 4 - User preferences: - DashboardLayoutPreferences: persisted user layout preferences - dashboardPreferencesProvider: Riverpod notifier for preferences This implements the foundation for the dashboard layout customization system. Phase 3 (component DisplayMode support) and Phase 5 (settings UI) remain for future work. --- lib/page/dashboard/_dashboard.dart | 2 +- lib/page/dashboard/models/_models.dart | 7 + .../models/dashboard_layout_preferences.dart | 71 ++++++++ .../models/dashboard_widget_specs.dart | 171 ++++++++++++++++++ lib/page/dashboard/models/display_mode.dart | 13 ++ .../dashboard/models/height_strategy.dart | 63 +++++++ .../models/widget_grid_constraints.dart | 68 +++++++ lib/page/dashboard/models/widget_spec.dart | 33 ++++ lib/page/dashboard/providers/_providers.dart | 1 + .../dashboard_preferences_provider.dart | 53 ++++++ .../dashboard/strategies/_strategies.dart | 1 + .../strategies/dashboard_layout_context.dart | 52 ++++++ .../strategies/grid_layout_resolver.dart | 92 ++++++++++ 13 files changed, 626 insertions(+), 1 deletion(-) create mode 100644 lib/page/dashboard/models/_models.dart create mode 100644 lib/page/dashboard/models/dashboard_layout_preferences.dart create mode 100644 lib/page/dashboard/models/dashboard_widget_specs.dart create mode 100644 lib/page/dashboard/models/display_mode.dart create mode 100644 lib/page/dashboard/models/height_strategy.dart create mode 100644 lib/page/dashboard/models/widget_grid_constraints.dart create mode 100644 lib/page/dashboard/models/widget_spec.dart create mode 100644 lib/page/dashboard/providers/dashboard_preferences_provider.dart create mode 100644 lib/page/dashboard/strategies/grid_layout_resolver.dart diff --git a/lib/page/dashboard/_dashboard.dart b/lib/page/dashboard/_dashboard.dart index f196f19c6..37747358c 100644 --- a/lib/page/dashboard/_dashboard.dart +++ b/lib/page/dashboard/_dashboard.dart @@ -1,4 +1,4 @@ export 'views/_views.dart'; export 'providers/_providers.dart'; -export 'models/dashboard_layout.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_preferences.dart b/lib/page/dashboard/models/dashboard_layout_preferences.dart new file mode 100644 index 000000000..2f000f2ec --- /dev/null +++ b/lib/page/dashboard/models/dashboard_layout_preferences.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'package:equatable/equatable.dart'; +import 'display_mode.dart'; + +/// 使用者的 Dashboard 佈局偏好 +/// +/// 儲存各元件的顯示模式設定。 +class DashboardLayoutPreferences extends Equatable { + /// 各元件的顯示模式(keyed by widget ID) + final Map widgetModes; + + const DashboardLayoutPreferences({ + this.widgetModes = const {}, + }); + + /// 取得元件模式(預設 normal) + DisplayMode getMode(String widgetId) => + widgetModes[widgetId] ?? DisplayMode.normal; + + /// 更新元件模式 + DashboardLayoutPreferences setMode(String widgetId, DisplayMode mode) { + return DashboardLayoutPreferences( + widgetModes: {...widgetModes, widgetId: mode}, + ); + } + + /// 重設所有模式為預設 + DashboardLayoutPreferences reset() { + return const DashboardLayoutPreferences(); + } + + /// JSON 序列化 + Map toJson() => { + 'widgetModes': widgetModes.map((k, v) => MapEntry(k, v.name)), + }; + + /// JSON 反序列化 + factory DashboardLayoutPreferences.fromJson(Map json) { + final modesJson = json['widgetModes'] as Map?; + if (modesJson == null) { + return const DashboardLayoutPreferences(); + } + + final modes = {}; + for (final entry in modesJson.entries) { + try { + modes[entry.key] = DisplayMode.values.byName(entry.value as String); + } catch (_) { + // Ignore invalid values + } + } + return DashboardLayoutPreferences(widgetModes: modes); + } + + /// 從 JSON 字串解析 + factory DashboardLayoutPreferences.fromJsonString(String jsonString) { + try { + return DashboardLayoutPreferences.fromJson( + jsonDecode(jsonString) as Map, + ); + } catch (_) { + return const DashboardLayoutPreferences(); + } + } + + /// 轉換為 JSON 字串 + String toJsonString() => jsonEncode(toJson()); + + @override + List get props => [widgetModes]; +} 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..34c3bf2c5 --- /dev/null +++ b/lib/page/dashboard/models/dashboard_widget_specs.dart @@ -0,0 +1,171 @@ +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 狀態', + 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.columnBased(1.5), + ), + }, + ); + + // --------------------------------------------------------------------------- + // Networks (節點狀態) + // --------------------------------------------------------------------------- + static const networks = WidgetSpec( + id: 'networks', + displayName: '網路節點', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 3, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.columnBased(1.5), + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.columnBased(2.0), + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 6, + heightStrategy: HeightStrategy.columnBased(2.5), + ), + }, + ); + + // --------------------------------------------------------------------------- + // WiFi Grid + // --------------------------------------------------------------------------- + static const wifiGrid = WidgetSpec( + id: 'wifi_grid', + displayName: 'WiFi 網路', + 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: '快速設定', + 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.columnBased(2.0), + ), + }, + ); + + // --------------------------------------------------------------------------- + // Port and Speed + // --------------------------------------------------------------------------- + static const portAndSpeed = WidgetSpec( + id: 'port_and_speed', + displayName: '連接埠與速度', + 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: 8, + heightStrategy: HeightStrategy.columnBased(1.5), + ), + }, + ); + + // --------------------------------------------------------------------------- + // 所有規格列表(用於設定 UI 迭代) + // --------------------------------------------------------------------------- + static const List all = [ + internetStatus, + networks, + wifiGrid, + quickPanel, + portAndSpeed, + ]; + + /// 根據 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/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..da4711291 --- /dev/null +++ b/lib/page/dashboard/providers/dashboard_preferences_provider.dart @@ -0,0 +1,53 @@ +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. 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(); + } + + /// 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 index 2e027ec7c..d1f62f129 100644 --- a/lib/page/dashboard/strategies/_strategies.dart +++ b/lib/page/dashboard/strategies/_strategies.dart @@ -1,6 +1,7 @@ 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'; diff --git a/lib/page/dashboard/strategies/dashboard_layout_context.dart b/lib/page/dashboard/strategies/dashboard_layout_context.dart index c47b17ba8..6e5ed8aa6 100644 --- a/lib/page/dashboard/strategies/dashboard_layout_context.dart +++ b/lib/page/dashboard/strategies/dashboard_layout_context.dart @@ -3,6 +3,10 @@ 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/display_mode.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). @@ -79,6 +83,15 @@ class DashboardLayoutContext { /// a properly configured port and speed widget. final Widget Function(PortAndSpeedConfig config) buildPortAndSpeed; + // --------------------------------------------------------------------------- + // Grid Constraint System + // --------------------------------------------------------------------------- + + /// Display modes for each widget (keyed by widget ID). + /// + /// Used by the grid constraint system to determine widget sizing. + final Map displayModes; + const DashboardLayoutContext({ required this.context, required this.ref, @@ -92,8 +105,47 @@ class DashboardLayoutContext { required this.quickPanel, this.vpnTile, required this.buildPortAndSpeed, + this.displayModes = 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 display mode for a widget spec. + DisplayMode getModeFor(WidgetSpec spec) => + displayModes[spec.id] ?? DisplayMode.normal; + + /// Gets the resolved column count for a widget. + int getColumnsFor(WidgetSpec spec, {int? availableColumns}) => + resolver.resolveColumns(spec, getModeFor(spec), + availableColumns: availableColumns); + + /// Gets the resolved width for a widget. + double getWidthFor(WidgetSpec spec, {int? availableColumns}) => resolver + .resolveWidth(spec, getModeFor(spec), availableColumns: availableColumns); + + /// Gets the resolved height for a widget (null = intrinsic). + double? getHeightFor(WidgetSpec spec, {int? availableColumns}) => + resolver.resolveHeight(spec, getModeFor(spec), + availableColumns: availableColumns); + + /// Wraps a widget with size constraints based on its spec. + Widget wrapWidget( + Widget child, { + required WidgetSpec spec, + int? availableColumns, + }) => + resolver.wrapWithConstraints( + child, + spec: spec, + mode: getModeFor(spec), + availableColumns: availableColumns, + ); } 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..674287508 --- /dev/null +++ b/lib/page/dashboard/strategies/grid_layout_resolver.dart @@ -0,0 +1,92 @@ +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'; + +/// 佈局解析器 +/// +/// 負責根據元件約束和目前螢幕狀態,計算實際應使用的欄數和尺寸。 +/// 僅讀取 UI Kit 的公開 API,不修改 UI Kit。 +class GridLayoutResolver { + final BuildContext context; + + const GridLayoutResolver(this.context); + + /// 目前的最大欄數(4/8/12) + int get currentMaxColumns => context.currentMaxColumns; + + /// 計算元件應使用的欄數 + /// + /// [spec] 元件規格 + /// [mode] 顯示模式 + /// [availableColumns] 可用欄數(用於巢狀佈局,預設為 currentMaxColumns) + int resolveColumns( + WidgetSpec spec, + DisplayMode mode, { + int? availableColumns, + }) { + final constraints = spec.getConstraints(mode); + final maxCols = availableColumns ?? currentMaxColumns; + + // 按比例縮放 + final scaled = constraints.scaleToMaxColumns(maxCols); + + // 確保在約束範圍內 + final scaledMin = constraints.scaleMinToMaxColumns(maxCols); + final scaledMax = constraints.scaleMaxToMaxColumns(maxCols); + + return scaled.clamp(scaledMin, scaledMax); + } + + /// 計算元件寬度 + double resolveWidth( + WidgetSpec spec, + DisplayMode mode, { + int? availableColumns, + }) { + final columns = + resolveColumns(spec, mode, availableColumns: availableColumns); + return context.colWidth(columns); + } + + /// 計算元件高度 + /// + /// 回傳 null 表示使用 intrinsic sizing + double? resolveHeight( + WidgetSpec spec, + DisplayMode mode, { + int? availableColumns, + }) { + final constraints = spec.getConstraints(mode); + final singleColWidth = context.colWidth(1); + final width = resolveWidth(spec, mode, availableColumns: availableColumns); + + return switch (constraints.heightStrategy) { + IntrinsicHeightStrategy() => null, + ColumnBasedHeightStrategy(multiplier: final m) => singleColWidth * m, + AspectRatioHeightStrategy(ratio: final r) => width / r, + }; + } + + /// 建立約束後的 SizedBox 包裝 + /// + /// 高度為 null 時不設定高度約束 + Widget wrapWithConstraints( + Widget child, { + required WidgetSpec spec, + required DisplayMode mode, + int? availableColumns, + }) { + final width = resolveWidth(spec, mode, availableColumns: availableColumns); + final height = + resolveHeight(spec, mode, availableColumns: availableColumns); + + return SizedBox( + width: width, + height: height, + child: child, + ); + } +} From 6f81fed3959703f609838d33a8bbcdf44e4d8c06 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 20:57:46 +0800 Subject: [PATCH 22/26] feat(dashboard): Add DisplayMode support to all components and settings UI Phase 3 - Component DisplayMode support: - InternetConnectionWidget: Added displayMode parameter with loading height variants - DashboardNetworks: Added displayMode parameter with loading height variants - DashboardWiFiGrid: Added displayMode parameter - DashboardQuickPanel: Added displayMode parameter with loading height variants - DashboardHomePortAndSpeed: Added displayMode parameter with loading height variants Phase 5 - Settings UI: - Created DashboardLayoutSettingsPanel for customizing widget display modes - Uses SegmentedButton for compact/normal/expanded selection - Integrates with dashboardPreferencesProvider for persistence - Added to barrel exports All components now support DisplayMode.compact/normal/expanded rendering modes. The settings panel allows users to customize each widget's display mode. --- .../views/components/_components.dart | 1 + .../dashboard_layout_settings_panel.dart | 108 ++++++++++++++++++ .../views/components/internet_status.dart | 25 +++- .../dashboard/views/components/networks.dart | 23 +++- .../views/components/port_and_speed.dart | 20 +++- .../views/components/quick_panel.dart | 25 +++- .../dashboard/views/components/wifi_grid.dart | 14 ++- 7 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 lib/page/dashboard/views/components/dashboard_layout_settings_panel.dart diff --git a/lib/page/dashboard/views/components/_components.dart b/lib/page/dashboard/views/components/_components.dart index 0164cf4df..d66fc32f8 100644 --- a/lib/page/dashboard/views/components/_components.dart +++ b/lib/page/dashboard/views/components/_components.dart @@ -1,6 +1,7 @@ /// Dashboard home components barrel file. library; +export 'dashboard_layout_settings_panel.dart'; export 'dashboard_loading_wrapper.dart'; export 'dashboard_tile.dart'; export 'external_speed_test_links.dart'; diff --git a/lib/page/dashboard/views/components/dashboard_layout_settings_panel.dart b/lib/page/dashboard/views/components/dashboard_layout_settings_panel.dart new file mode 100644 index 000000000..3f9a16f91 --- /dev/null +++ b/lib/page/dashboard/views/components/dashboard_layout_settings_panel.dart @@ -0,0 +1,108 @@ +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/providers/dashboard_preferences_provider.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Settings panel for customizing dashboard layout. +/// +/// Allows users to select display modes for each dashboard widget. +class DashboardLayoutSettingsPanel extends ConsumerWidget { + const DashboardLayoutSettingsPanel({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final preferences = ref.watch(dashboardPreferencesProvider); + + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + AppText.titleMedium('Dashboard Layout'), + AppGap.xl(), + AppText.bodySmall('Customize how dashboard widgets are displayed.'), + AppGap.xxl(), + ...DashboardWidgetSpecs.all.map((spec) { + final currentMode = preferences.getMode(spec.id); + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.lg), + child: _buildWidgetModeSelector( + context, + ref, + spec.displayName, + spec.id, + currentMode, + ), + ); + }), + AppGap.lg(), + Align( + alignment: Alignment.centerRight, + child: AppButton.text( + label: 'Reset to Defaults', + onTap: () { + ref + .read(dashboardPreferencesProvider.notifier) + .resetToDefaults(); + }, + ), + ), + ], + ), + ); + } + + Widget _buildWidgetModeSelector( + BuildContext context, + WidgetRef ref, + String label, + String widgetId, + DisplayMode currentMode, + ) { + return Row( + children: [ + Expanded( + flex: 2, + child: AppText.labelMedium(label), + ), + Expanded( + flex: 3, + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: DisplayMode.compact, + label: Text('Compact'), + icon: Icon(Icons.view_compact_rounded, size: 18), + ), + ButtonSegment( + value: DisplayMode.normal, + label: Text('Normal'), + icon: Icon(Icons.view_agenda_rounded, size: 18), + ), + ButtonSegment( + value: DisplayMode.expanded, + label: Text('Expanded'), + icon: Icon(Icons.view_stream_rounded, size: 18), + ), + ], + selected: {currentMode}, + onSelectionChanged: (Set selection) { + ref + .read(dashboardPreferencesProvider.notifier) + .setWidgetMode(widgetId, selection.first); + }, + showSelectedIcon: false, + style: ButtonStyle( + textStyle: WidgetStatePropertyAll( + Theme.of(context).textTheme.labelSmall, + ), + visualDensity: VisualDensity.compact, + ), + ), + ), + ], + ); + } +} diff --git a/lib/page/dashboard/views/components/internet_status.dart b/lib/page/dashboard/views/components/internet_status.dart index b112cc048..324c3e28f 100644 --- a/lib/page/dashboard/views/components/internet_status.dart +++ b/lib/page/dashboard/views/components/internet_status.dart @@ -8,6 +8,7 @@ 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/dashboard_loading_wrapper.dart'; import 'package:privacy_gui/page/instant_topology/providers/_providers.dart'; @@ -16,8 +17,20 @@ 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}); + const InternetConnectionWidget({ + super.key, + this.displayMode = DisplayMode.normal, + }); + + /// The display mode for this widget + final DisplayMode displayMode; @override ConsumerState createState() => @@ -29,11 +42,19 @@ class _InternetConnectionWidgetState @override Widget build(BuildContext context) { return DashboardLoadingWrapper( - loadingHeight: 150, + 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) { final wanStatus = ref.watch(internetStatusProvider); final isOnline = wanStatus == InternetStatus.online; diff --git a/lib/page/dashboard/views/components/networks.dart b/lib/page/dashboard/views/components/networks.dart index a991a2d03..52a83453d 100644 --- a/lib/page/dashboard/views/components/networks.dart +++ b/lib/page/dashboard/views/components/networks.dart @@ -17,16 +17,37 @@ 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}); + 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) { final state = ref.watch(dashboardHomeProvider); final topologyState = ref.watch(instantTopologyProvider); diff --git a/lib/page/dashboard/views/components/port_and_speed.dart b/lib/page/dashboard/views/components/port_and_speed.dart index 1600aa749..ee30f5f0d 100644 --- a/lib/page/dashboard/views/components/port_and_speed.dart +++ b/lib/page/dashboard/views/components/port_and_speed.dart @@ -3,6 +3,7 @@ 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'; @@ -19,19 +20,28 @@ import 'package:ui_kit_library/ui_kit.dart'; /// 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: 250, + loadingHeight: _getLoadingHeight(), builder: (context, ref) { final state = ref.watch(dashboardHomeProvider); return _buildLayout(context, ref, state); @@ -39,6 +49,14 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { ); } + double _getLoadingHeight() { + return switch (displayMode) { + DisplayMode.compact => 150, + DisplayMode.normal => 250, + DisplayMode.expanded => 350, + }; + } + Widget _buildLayout( BuildContext context, WidgetRef ref, diff --git a/lib/page/dashboard/views/components/quick_panel.dart b/lib/page/dashboard/views/components/quick_panel.dart index 48eaf3652..9b425862b 100644 --- a/lib/page/dashboard/views/components/quick_panel.dart +++ b/lib/page/dashboard/views/components/quick_panel.dart @@ -7,6 +7,7 @@ import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.da 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/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'; @@ -16,8 +17,20 @@ 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}); + const DashboardQuickPanel({ + super.key, + this.displayMode = DisplayMode.normal, + }); + + /// The display mode for this widget + final DisplayMode displayMode; @override ConsumerState createState() => @@ -28,11 +41,19 @@ class _DashboardQuickPanelState extends ConsumerState { @override Widget build(BuildContext context) { return DashboardLoadingWrapper( - loadingHeight: 150, + 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) { final privacyState = ref.watch(instantPrivacyProvider); final nodeLightState = ref.watch(nodeLightSettingsProvider); diff --git a/lib/page/dashboard/views/components/wifi_grid.dart b/lib/page/dashboard/views/components/wifi_grid.dart index 30ec09ec9..835c60d15 100644 --- a/lib/page/dashboard/views/components/wifi_grid.dart +++ b/lib/page/dashboard/views/components/wifi_grid.dart @@ -1,13 +1,25 @@ 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/dashboard_loading_wrapper.dart'; import 'package:privacy_gui/page/dashboard/views/components/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}); + const DashboardWiFiGrid({ + super.key, + this.displayMode = DisplayMode.normal, + }); + + /// The display mode for this widget + final DisplayMode displayMode; @override ConsumerState createState() => _DashboardWiFiGridState(); From cd783091a1e53b3fdbc6a6684f20869d357fcdab Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 21:00:24 +0800 Subject: [PATCH 23/26] feat(dashboard): Integrate preferences into DashboardHomeView - Watch dashboardPreferencesProvider in DashboardHomeView - Pass displayModes map to DashboardLayoutContext - Pass individual displayMode to each widget component - Widgets now respond to user preference settings The dashboard now respects user layout preferences stored in SharedPreferences. --- .../dashboard/views/dashboard_home_view.dart | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/page/dashboard/views/dashboard_home_view.dart b/lib/page/dashboard/views/dashboard_home_view.dart index c8ff0cc44..ee0367a04 100644 --- a/lib/page/dashboard/views/dashboard_home_view.dart +++ b/lib/page/dashboard/views/dashboard_home_view.dart @@ -38,10 +38,21 @@ class _DashboardHomeViewState extends ConsumerState { Widget build(BuildContext context) { MediaQuery.of(context); final state = ref.watch(dashboardHomeProvider); + final preferences = ref.watch(dashboardPreferencesProvider); final hasLanPort = state.lanPortConnections.isNotEmpty; final isHorizontalLayout = state.isHorizontalLayout; final isSupportVPN = getIt.get().isSupportVPN(); + // Get display modes from preferences + final internetMode = + preferences.getMode(DashboardWidgetSpecs.internetStatus.id); + final networksMode = preferences.getMode(DashboardWidgetSpecs.networks.id); + final wifiMode = preferences.getMode(DashboardWidgetSpecs.wifiGrid.id); + final quickPanelMode = + preferences.getMode(DashboardWidgetSpecs.quickPanel.id); + final portAndSpeedMode = + preferences.getMode(DashboardWidgetSpecs.portAndSpeed.id); + return UiKitPageView.withSliver( scrollable: true, onRefresh: () async { @@ -68,14 +79,17 @@ class _DashboardHomeViewState extends ConsumerState { state: state, hasLanPort: hasLanPort, isHorizontalLayout: isHorizontalLayout, + displayModes: preferences.widgetModes, title: const DashboardHomeTitle(), - internetWidget: const InternetConnectionWidget(), - networksWidget: const DashboardNetworks(), - wifiGrid: const DashboardWiFiGrid(), - quickPanel: const DashboardQuickPanel(), + internetWidget: InternetConnectionWidget(displayMode: internetMode), + networksWidget: DashboardNetworks(displayMode: networksMode), + wifiGrid: DashboardWiFiGrid(displayMode: wifiMode), + quickPanel: DashboardQuickPanel(displayMode: quickPanelMode), vpnTile: isSupportVPN ? const VPNStatusTile() : null, - buildPortAndSpeed: (config) => - DashboardHomePortAndSpeed(config: config), + buildPortAndSpeed: (config) => DashboardHomePortAndSpeed( + config: config, + displayMode: portAndSpeedMode, + ), ); // 3. Delegate to strategy From 6c66bcb2ea9fd9c1e2b3913325d9415b40b070cc Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Wed, 7 Jan 2026 21:03:41 +0800 Subject: [PATCH 24/26] feat(dashboard): Add Dashboard Layout settings to menu - Added 'Dashboard Layout' menu option in DashboardMenuView - Shows DashboardLayoutSettingsPanel in a dialog on tap - Uses widgets icon for the menu item - Added components barrel export to menu view imports Users can now access layout customization from the menu. --- .../dashboard/views/dashboard_menu_view.dart | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/page/dashboard/views/dashboard_menu_view.dart b/lib/page/dashboard/views/dashboard_menu_view.dart index dbb3f1cc6..326bdb62a 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'; @@ -133,12 +134,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; From 4864d2dae619ab9a2dda275dcc68aac25a8cccbc Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Fri, 9 Jan 2026 00:03:25 +0800 Subject: [PATCH 25/26] fix(ui): resolve phase 2 and 3 issues including topology clipping, device detail truncation, and missing labels --- .../dmz/views/dmz_settings_view.dart | 37 +- .../views/ipv6_port_service_list_view.dart | 1 + .../widgets/wan_forms/bridge_form.dart | 3 +- .../wan_forms/ipv6/automatic_ipv6_form.dart | 46 +- .../views/dhcp_server_view.dart | 40 +- .../static_routing/static_routing_view.dart | 3 +- lib/page/components/shortcuts/dialogs.dart | 4 +- .../models/dashboard_layout_preferences.dart | 194 ++++++- .../models/dashboard_widget_specs.dart | 53 +- .../dashboard/models/grid_widget_config.dart | 77 +++ .../dashboard_preferences_provider.dart | 27 +- .../custom_dashboard_layout_strategy.dart | 37 ++ .../strategies/dashboard_layout_context.dart | 142 +++++- .../strategies/grid_layout_resolver.dart | 73 ++- .../views/components/_components.dart | 33 +- .../{ => core}/dashboard_loading_wrapper.dart | 2 +- .../components/{ => core}/dashboard_tile.dart | 2 +- .../components/{ => core}/loading_tile.dart | 0 .../dashboard_layout_settings_panel.dart | 108 ---- .../firmware_update_countdown_dialog.dart | 0 .../views/components/internet_status.dart | 221 -------- .../dashboard_layout_settings_panel.dart | 291 +++++++++++ .../dashboard/views/components/shimmer.dart | 197 -------- .../components/{ => widgets}/home_title.dart | 2 +- .../components/widgets/internet_status.dart | 476 ++++++++++++++++++ .../components/{ => widgets}/networks.dart | 108 +++- .../parts}/external_speed_test_links.dart | 0 .../parts}/internal_speed_test_result.dart | 0 .../parts}/port_status_widget.dart | 0 .../parts}/remote_assistance_animation.dart | 0 .../{ => widgets/parts}/wifi_card.dart | 0 .../{ => widgets}/port_and_speed.dart | 176 ++++++- .../components/{ => widgets}/quick_panel.dart | 213 +++++++- .../components/{ => widgets}/wifi_grid.dart | 74 ++- .../dashboard/views/dashboard_home_view.dart | 58 ++- .../dashboard/views/dashboard_menu_view.dart | 17 +- .../views/instant_admin_view.dart | 13 +- .../views/device_detail_view.dart | 9 +- .../views/instant_topology_view.dart | 4 +- lib/page/nodes/views/node_detail_view.dart | 3 + .../mac_filter/mac_filtered_devices_view.dart | 6 +- .../views/mac_filter/mac_filtering_view.dart | 1 + .../dashboard_home_view_test.dart | 10 +- .../views/components/loading_tile_test.dart | 2 +- 44 files changed, 2019 insertions(+), 744 deletions(-) create mode 100644 lib/page/dashboard/models/grid_widget_config.dart create mode 100644 lib/page/dashboard/strategies/custom_dashboard_layout_strategy.dart rename lib/page/dashboard/views/components/{ => core}/dashboard_loading_wrapper.dart (95%) rename lib/page/dashboard/views/components/{ => core}/dashboard_tile.dart (90%) rename lib/page/dashboard/views/components/{ => core}/loading_tile.dart (100%) delete mode 100644 lib/page/dashboard/views/components/dashboard_layout_settings_panel.dart rename lib/page/dashboard/views/components/{ => dialogs}/firmware_update_countdown_dialog.dart (100%) delete mode 100644 lib/page/dashboard/views/components/internet_status.dart create mode 100644 lib/page/dashboard/views/components/settings/dashboard_layout_settings_panel.dart delete mode 100644 lib/page/dashboard/views/components/shimmer.dart rename lib/page/dashboard/views/components/{ => widgets}/home_title.dart (97%) create mode 100644 lib/page/dashboard/views/components/widgets/internet_status.dart rename lib/page/dashboard/views/components/{ => widgets}/networks.dart (71%) rename lib/page/dashboard/views/components/{ => widgets/parts}/external_speed_test_links.dart (100%) rename lib/page/dashboard/views/components/{ => widgets/parts}/internal_speed_test_result.dart (100%) rename lib/page/dashboard/views/components/{ => widgets/parts}/port_status_widget.dart (100%) rename lib/page/dashboard/views/components/{ => widgets/parts}/remote_assistance_animation.dart (100%) rename lib/page/dashboard/views/components/{ => widgets/parts}/wifi_card.dart (100%) rename lib/page/dashboard/views/components/{ => widgets}/port_and_speed.dart (51%) rename lib/page/dashboard/views/components/{ => widgets}/quick_panel.dart (50%) rename lib/page/dashboard/views/components/{ => widgets}/wifi_grid.dart (59%) 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/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/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/dashboard/models/dashboard_layout_preferences.dart b/lib/page/dashboard/models/dashboard_layout_preferences.dart index 2f000f2ec..4ad4e97ea 100644 --- a/lib/page/dashboard/models/dashboard_layout_preferences.dart +++ b/lib/page/dashboard/models/dashboard_layout_preferences.dart @@ -1,58 +1,198 @@ import 'dart:convert'; import 'package:equatable/equatable.dart'; +import 'dashboard_widget_specs.dart'; import 'display_mode.dart'; +import 'grid_widget_config.dart'; -/// 使用者的 Dashboard 佈局偏好 +/// User's Dashboard layout preferences. /// -/// 儲存各元件的顯示模式設定。 +/// Stores widget display modes and grid configurations for custom layouts. class DashboardLayoutPreferences extends Equatable { - /// 各元件的顯示模式(keyed by widget ID) - final Map widgetModes; + /// 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.widgetModes = const {}, + this.useCustomLayout = false, + this.widgetConfigs = const {}, }); - /// 取得元件模式(預設 normal) - DisplayMode getMode(String widgetId) => - widgetModes[widgetId] ?? DisplayMode.normal; + // --------------------------------------------------------------------------- + // 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( - widgetModes: {...widgetModes, widgetId: mode}, + useCustomLayout: useCustomLayout, + widgetConfigs: newConfigs, ); } - /// 重設所有模式為預設 + /// Reset all preferences to defaults DashboardLayoutPreferences reset() { return const DashboardLayoutPreferences(); } - /// JSON 序列化 + // --------------------------------------------------------------------------- + // 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() => { - 'widgetModes': widgetModes.map((k, v) => MapEntry(k, v.name)), + 'useCustomLayout': useCustomLayout, + 'widgetConfigs': widgetConfigs.map( + (k, v) => MapEntry(k, v.toJson()), + ), }; - /// JSON 反序列化 + /// JSON deserialization factory DashboardLayoutPreferences.fromJson(Map json) { - final modesJson = json['widgetModes'] as Map?; - if (modesJson == null) { - return const DashboardLayoutPreferences(); + 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, + ); } - final modes = {}; - for (final entry in modesJson.entries) { - try { - modes[entry.key] = DisplayMode.values.byName(entry.value as String); - } catch (_) { - // Ignore invalid values + // 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 DashboardLayoutPreferences(widgetModes: modes); + + return const DashboardLayoutPreferences(); } - /// 從 JSON 字串解析 + /// Parse from JSON string factory DashboardLayoutPreferences.fromJsonString(String jsonString) { try { return DashboardLayoutPreferences.fromJson( @@ -63,9 +203,9 @@ class DashboardLayoutPreferences extends Equatable { } } - /// 轉換為 JSON 字串 + /// Convert to JSON string String toJsonString() => jsonEncode(toJson()); @override - List get props => [widgetModes]; + 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 index 34c3bf2c5..edad46301 100644 --- a/lib/page/dashboard/models/dashboard_widget_specs.dart +++ b/lib/page/dashboard/models/dashboard_widget_specs.dart @@ -15,7 +15,7 @@ abstract class DashboardWidgetSpecs { // --------------------------------------------------------------------------- static const internetStatus = WidgetSpec( id: 'internet_status', - displayName: 'Internet 狀態', + displayName: 'Internet Status', constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 4, @@ -33,7 +33,7 @@ abstract class DashboardWidgetSpecs { minColumns: 8, maxColumns: 12, preferredColumns: 12, - heightStrategy: HeightStrategy.columnBased(1.5), + heightStrategy: HeightStrategy.intrinsic(), ), }, ); @@ -43,25 +43,25 @@ abstract class DashboardWidgetSpecs { // --------------------------------------------------------------------------- static const networks = WidgetSpec( id: 'networks', - displayName: '網路節點', + displayName: 'Networks', constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 3, maxColumns: 4, preferredColumns: 4, - heightStrategy: HeightStrategy.columnBased(1.5), + heightStrategy: HeightStrategy.intrinsic(), ), DisplayMode.normal: WidgetGridConstraints( minColumns: 4, maxColumns: 4, preferredColumns: 4, - heightStrategy: HeightStrategy.columnBased(2.0), + heightStrategy: HeightStrategy.intrinsic(), ), DisplayMode.expanded: WidgetGridConstraints( minColumns: 4, maxColumns: 6, preferredColumns: 6, - heightStrategy: HeightStrategy.columnBased(2.5), + heightStrategy: HeightStrategy.intrinsic(), ), }, ); @@ -71,7 +71,7 @@ abstract class DashboardWidgetSpecs { // --------------------------------------------------------------------------- static const wifiGrid = WidgetSpec( id: 'wifi_grid', - displayName: 'WiFi 網路', + displayName: 'Wi-Fi Networks', constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 6, @@ -99,7 +99,7 @@ abstract class DashboardWidgetSpecs { // --------------------------------------------------------------------------- static const quickPanel = WidgetSpec( id: 'quick_panel', - displayName: '快速設定', + displayName: 'Quick Panel', constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 3, @@ -117,7 +117,7 @@ abstract class DashboardWidgetSpecs { minColumns: 4, maxColumns: 6, preferredColumns: 6, - heightStrategy: HeightStrategy.columnBased(2.0), + heightStrategy: HeightStrategy.intrinsic(), ), }, ); @@ -127,7 +127,7 @@ abstract class DashboardWidgetSpecs { // --------------------------------------------------------------------------- static const portAndSpeed = WidgetSpec( id: 'port_and_speed', - displayName: '連接埠與速度', + displayName: 'Ports & Speed', constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 4, @@ -136,7 +136,7 @@ abstract class DashboardWidgetSpecs { heightStrategy: HeightStrategy.intrinsic(), ), DisplayMode.normal: WidgetGridConstraints( - minColumns: 6, + minColumns: 4, maxColumns: 8, preferredColumns: 8, heightStrategy: HeightStrategy.intrinsic(), @@ -145,7 +145,7 @@ abstract class DashboardWidgetSpecs { minColumns: 8, maxColumns: 12, preferredColumns: 8, - heightStrategy: HeightStrategy.columnBased(1.5), + heightStrategy: HeightStrategy.intrinsic(), ), }, ); @@ -159,8 +159,37 @@ abstract class DashboardWidgetSpecs { 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) { 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/providers/dashboard_preferences_provider.dart b/lib/page/dashboard/providers/dashboard_preferences_provider.dart index da4711291..c9293bd14 100644 --- a/lib/page/dashboard/providers/dashboard_preferences_provider.dart +++ b/lib/page/dashboard/providers/dashboard_preferences_provider.dart @@ -14,7 +14,8 @@ final dashboardPreferencesProvider = /// Notifier for managing Dashboard layout preferences /// /// Handles loading, saving, and updating user preferences for widget -/// display modes. Preferences are persisted to SharedPreferences. +/// display modes, visibility, column spans, and ordering. +/// Preferences are persisted to SharedPreferences. class DashboardPreferencesNotifier extends Notifier { @override @@ -38,6 +39,30 @@ class DashboardPreferencesNotifier 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(); 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 index 6e5ed8aa6..6f4b99f30 100644 --- a/lib/page/dashboard/strategies/dashboard_layout_context.dart +++ b/lib/page/dashboard/strategies/dashboard_layout_context.dart @@ -3,14 +3,17 @@ 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). - final Axis direction; + /// If null, the widget will determine direction based on available width. + final Axis? direction; /// Whether to show the speed test section. final bool showSpeedTest; @@ -25,7 +28,7 @@ class PortAndSpeedConfig { final EdgeInsets portsPadding; const PortAndSpeedConfig({ - this.direction = Axis.horizontal, + this.direction, this.showSpeedTest = true, this.portsHeight, this.speedTestHeight, @@ -87,10 +90,10 @@ class DashboardLayoutContext { // Grid Constraint System // --------------------------------------------------------------------------- - /// Display modes for each widget (keyed by widget ID). + /// Widget configurations (keyed by widget ID). /// - /// Used by the grid constraint system to determine widget sizing. - final Map displayModes; + /// Used by the grid constraint system to determine widget sizing and display mode. + final Map widgetConfigs; const DashboardLayoutContext({ required this.context, @@ -105,7 +108,7 @@ class DashboardLayoutContext { required this.quickPanel, this.vpnTile, required this.buildPortAndSpeed, - this.displayModes = const {}, + this.widgetConfigs = const {}, }); /// Convenience getter for column width calculation. @@ -118,34 +121,123 @@ class DashboardLayoutContext { /// 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) => - displayModes[spec.id] ?? DisplayMode.normal; + DisplayMode getModeFor(WidgetSpec spec) => getConfigFor(spec).displayMode; /// Gets the resolved column count for a widget. - int getColumnsFor(WidgetSpec spec, {int? availableColumns}) => - resolver.resolveColumns(spec, getModeFor(spec), - availableColumns: availableColumns); + 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}) => resolver - .resolveWidth(spec, getModeFor(spec), availableColumns: availableColumns); + 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}) => - resolver.resolveHeight(spec, getModeFor(spec), - availableColumns: availableColumns); - - /// Wraps a widget with size constraints based on its spec. + 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, - }) => - resolver.wrapWithConstraints( - child, - spec: spec, - mode: getModeFor(spec), - availableColumns: 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/grid_layout_resolver.dart b/lib/page/dashboard/strategies/grid_layout_resolver.dart index 674287508..eace5f4ae 100644 --- a/lib/page/dashboard/strategies/grid_layout_resolver.dart +++ b/lib/page/dashboard/strategies/grid_layout_resolver.dart @@ -5,63 +5,84 @@ import '../models/display_mode.dart'; import '../models/height_strategy.dart'; import '../models/widget_spec.dart'; -/// 佈局解析器 +/// Layout Resolver /// -/// 負責根據元件約束和目前螢幕狀態,計算實際應使用的欄數和尺寸。 -/// 僅讀取 UI Kit 的公開 API,不修改 UI Kit。 +/// 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); - /// 目前的最大欄數(4/8/12) + /// Current maximum columns (4/8/12) int get currentMaxColumns => context.currentMaxColumns; - /// 計算元件應使用的欄數 + /// Calculate columns for a widget /// - /// [spec] 元件規格 - /// [mode] 顯示模式 - /// [availableColumns] 可用欄數(用於巢狀佈局,預設為 currentMaxColumns) + /// [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); + final columns = resolveColumns( + spec, + mode, + availableColumns: availableColumns, + overrideColumns: overrideColumns, + ); return context.colWidth(columns); } - /// 計算元件高度 + /// Calculate height for a widget /// - /// 回傳 null 表示使用 intrinsic sizing + /// 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); + final width = resolveWidth( + spec, + mode, + availableColumns: availableColumns, + overrideColumns: overrideColumns, + ); return switch (constraints.heightStrategy) { IntrinsicHeightStrategy() => null, @@ -70,18 +91,28 @@ class GridLayoutResolver { }; } - /// 建立約束後的 SizedBox 包裝 + /// Build constrained SizedBox wrapper /// - /// 高度為 null 時不設定高度約束 + /// 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); - final height = - resolveHeight(spec, mode, availableColumns: availableColumns); + final width = resolveWidth( + spec, + mode, + availableColumns: availableColumns, + overrideColumns: overrideColumns, + ); + final height = resolveHeight( + spec, + mode, + availableColumns: availableColumns, + overrideColumns: overrideColumns, + ); return SizedBox( width: width, diff --git a/lib/page/dashboard/views/components/_components.dart b/lib/page/dashboard/views/components/_components.dart index d66fc32f8..362497745 100644 --- a/lib/page/dashboard/views/components/_components.dart +++ b/lib/page/dashboard/views/components/_components.dart @@ -1,20 +1,19 @@ /// Dashboard home components barrel file. library; -export 'dashboard_layout_settings_panel.dart'; -export 'dashboard_loading_wrapper.dart'; -export 'dashboard_tile.dart'; -export 'external_speed_test_links.dart'; -export 'firmware_update_countdown_dialog.dart'; -export 'home_title.dart'; -export 'internal_speed_test_result.dart'; -export 'internet_status.dart'; -export 'loading_tile.dart'; -export 'networks.dart'; -export 'port_and_speed.dart'; -export 'port_status_widget.dart'; -export 'quick_panel.dart'; -export 'remote_assistance_animation.dart'; -export 'shimmer.dart'; -export 'wifi_card.dart'; -export 'wifi_grid.dart'; +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/dashboard_loading_wrapper.dart b/lib/page/dashboard/views/components/core/dashboard_loading_wrapper.dart similarity index 95% rename from lib/page/dashboard/views/components/dashboard_loading_wrapper.dart rename to lib/page/dashboard/views/components/core/dashboard_loading_wrapper.dart index f08042063..c1b3f1295 100644 --- a/lib/page/dashboard/views/components/dashboard_loading_wrapper.dart +++ b/lib/page/dashboard/views/components/core/dashboard_loading_wrapper.dart @@ -1,7 +1,7 @@ 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/loading_tile.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. 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/dashboard_layout_settings_panel.dart b/lib/page/dashboard/views/components/dashboard_layout_settings_panel.dart deleted file mode 100644 index 3f9a16f91..000000000 --- a/lib/page/dashboard/views/components/dashboard_layout_settings_panel.dart +++ /dev/null @@ -1,108 +0,0 @@ -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/providers/dashboard_preferences_provider.dart'; -import 'package:ui_kit_library/ui_kit.dart'; - -/// Settings panel for customizing dashboard layout. -/// -/// Allows users to select display modes for each dashboard widget. -class DashboardLayoutSettingsPanel extends ConsumerWidget { - const DashboardLayoutSettingsPanel({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final preferences = ref.watch(dashboardPreferencesProvider); - - return AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - AppText.titleMedium('Dashboard Layout'), - AppGap.xl(), - AppText.bodySmall('Customize how dashboard widgets are displayed.'), - AppGap.xxl(), - ...DashboardWidgetSpecs.all.map((spec) { - final currentMode = preferences.getMode(spec.id); - return Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.lg), - child: _buildWidgetModeSelector( - context, - ref, - spec.displayName, - spec.id, - currentMode, - ), - ); - }), - AppGap.lg(), - Align( - alignment: Alignment.centerRight, - child: AppButton.text( - label: 'Reset to Defaults', - onTap: () { - ref - .read(dashboardPreferencesProvider.notifier) - .resetToDefaults(); - }, - ), - ), - ], - ), - ); - } - - Widget _buildWidgetModeSelector( - BuildContext context, - WidgetRef ref, - String label, - String widgetId, - DisplayMode currentMode, - ) { - return Row( - children: [ - Expanded( - flex: 2, - child: AppText.labelMedium(label), - ), - Expanded( - flex: 3, - child: SegmentedButton( - segments: const [ - ButtonSegment( - value: DisplayMode.compact, - label: Text('Compact'), - icon: Icon(Icons.view_compact_rounded, size: 18), - ), - ButtonSegment( - value: DisplayMode.normal, - label: Text('Normal'), - icon: Icon(Icons.view_agenda_rounded, size: 18), - ), - ButtonSegment( - value: DisplayMode.expanded, - label: Text('Expanded'), - icon: Icon(Icons.view_stream_rounded, size: 18), - ), - ], - selected: {currentMode}, - onSelectionChanged: (Set selection) { - ref - .read(dashboardPreferencesProvider.notifier) - .setWidgetMode(widgetId, selection.first); - }, - showSelectedIcon: false, - style: ButtonStyle( - textStyle: WidgetStatePropertyAll( - Theme.of(context).textTheme.labelSmall, - ), - visualDensity: VisualDensity.compact, - ), - ), - ), - ], - ); - } -} diff --git a/lib/page/dashboard/views/components/firmware_update_countdown_dialog.dart b/lib/page/dashboard/views/components/dialogs/firmware_update_countdown_dialog.dart similarity index 100% rename from lib/page/dashboard/views/components/firmware_update_countdown_dialog.dart rename to lib/page/dashboard/views/components/dialogs/firmware_update_countdown_dialog.dart 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 324c3e28f..000000000 --- a/lib/page/dashboard/views/components/internet_status.dart +++ /dev/null @@ -1,221 +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/models/display_mode.dart'; -import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; -import 'package:privacy_gui/page/dashboard/views/components/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) { - 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); - }, - ), - ] - ], - ), - ]), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ); - } -} 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 97% rename from lib/page/dashboard/views/components/home_title.dart rename to lib/page/dashboard/views/components/widgets/home_title.dart index 9c1e865c6..409978584 100644 --- a/lib/page/dashboard/views/components/home_title.dart +++ b/lib/page/dashboard/views/components/widgets/home_title.dart @@ -5,7 +5,7 @@ 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/localization/localization_hook.dart'; import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; -import 'package:privacy_gui/page/dashboard/views/components/dashboard_loading_wrapper.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'; 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/networks.dart b/lib/page/dashboard/views/components/widgets/networks.dart similarity index 71% rename from lib/page/dashboard/views/components/networks.dart rename to lib/page/dashboard/views/components/widgets/networks.dart index 52a83453d..624267697 100644 --- a/lib/page/dashboard/views/components/networks.dart +++ b/lib/page/dashboard/views/components/widgets/networks.dart @@ -10,7 +10,7 @@ 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/dashboard_loading_wrapper.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'; @@ -49,6 +49,76 @@ class DashboardNetworks extends ConsumerWidget { } 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); @@ -86,6 +156,42 @@ class DashboardNetworks extends ConsumerWidget { ); } + /// 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, diff --git a/lib/page/dashboard/views/components/external_speed_test_links.dart b/lib/page/dashboard/views/components/widgets/parts/external_speed_test_links.dart similarity index 100% rename from lib/page/dashboard/views/components/external_speed_test_links.dart rename to lib/page/dashboard/views/components/widgets/parts/external_speed_test_links.dart diff --git a/lib/page/dashboard/views/components/internal_speed_test_result.dart b/lib/page/dashboard/views/components/widgets/parts/internal_speed_test_result.dart similarity index 100% rename from lib/page/dashboard/views/components/internal_speed_test_result.dart rename to lib/page/dashboard/views/components/widgets/parts/internal_speed_test_result.dart diff --git a/lib/page/dashboard/views/components/port_status_widget.dart b/lib/page/dashboard/views/components/widgets/parts/port_status_widget.dart similarity index 100% rename from lib/page/dashboard/views/components/port_status_widget.dart rename to lib/page/dashboard/views/components/widgets/parts/port_status_widget.dart 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/wifi_card.dart b/lib/page/dashboard/views/components/widgets/parts/wifi_card.dart similarity index 100% rename from lib/page/dashboard/views/components/wifi_card.dart rename to lib/page/dashboard/views/components/widgets/parts/wifi_card.dart diff --git a/lib/page/dashboard/views/components/port_and_speed.dart b/lib/page/dashboard/views/components/widgets/port_and_speed.dart similarity index 51% rename from lib/page/dashboard/views/components/port_and_speed.dart rename to lib/page/dashboard/views/components/widgets/port_and_speed.dart index ee30f5f0d..30c3f2fa5 100644 --- a/lib/page/dashboard/views/components/port_and_speed.dart +++ b/lib/page/dashboard/views/components/widgets/port_and_speed.dart @@ -7,10 +7,10 @@ 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/dashboard_loading_wrapper.dart'; -import 'package:privacy_gui/page/dashboard/views/components/external_speed_test_links.dart'; -import 'package:privacy_gui/page/dashboard/views/components/internal_speed_test_result.dart'; -import 'package:privacy_gui/page/dashboard/views/components/port_status_widget.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'; @@ -61,16 +61,125 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { 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 = config.direction == Axis.vertical; + final isVertical = direction == Axis.vertical; // Calculate minimum height based on config - final minHeight = _calculateMinHeight(isVertical, hasLanPort); + // 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), + // constraints: BoxConstraints(minHeight: minHeight), child: AppCard( padding: EdgeInsets.zero, child: Column( @@ -84,7 +193,7 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { height: config.portsHeight, child: Padding( padding: config.portsPadding, - child: _buildPortsSection(context, state), + child: _buildPortsSection(context, state, direction), ), ), if (config.showSpeedTest) @@ -99,22 +208,55 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { ); } - double _calculateMinHeight(bool isVertical, bool hasLanPort) { - if (isVertical) { - return 360; - } - if (!hasLanPort) { - return 256; - } - return 110; + /// 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 = config.direction == Axis.vertical; + final isVertical = direction == Axis.vertical; // Build LAN port widgets final lanPorts = state.lanPortConnections.mapIndexed((index, e) { diff --git a/lib/page/dashboard/views/components/quick_panel.dart b/lib/page/dashboard/views/components/widgets/quick_panel.dart similarity index 50% rename from lib/page/dashboard/views/components/quick_panel.dart rename to lib/page/dashboard/views/components/widgets/quick_panel.dart index 9b425862b..2a8c19ba5 100644 --- a/lib/page/dashboard/views/components/quick_panel.dart +++ b/lib/page/dashboard/views/components/widgets/quick_panel.dart @@ -8,7 +8,7 @@ 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/dashboard_loading_wrapper.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'; @@ -55,6 +55,119 @@ class _DashboardQuickPanelState extends ConsumerState { } 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; @@ -137,6 +250,104 @@ class _DashboardQuickPanelState extends ConsumerState { ); } + /// 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, diff --git a/lib/page/dashboard/views/components/wifi_grid.dart b/lib/page/dashboard/views/components/widgets/wifi_grid.dart similarity index 59% rename from lib/page/dashboard/views/components/wifi_grid.dart rename to lib/page/dashboard/views/components/widgets/wifi_grid.dart index 835c60d15..9425c60cb 100644 --- a/lib/page/dashboard/views/components/wifi_grid.dart +++ b/lib/page/dashboard/views/components/widgets/wifi_grid.dart @@ -2,8 +2,8 @@ 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/dashboard_loading_wrapper.dart'; -import 'package:privacy_gui/page/dashboard/views/components/wifi_card.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. @@ -44,6 +44,44 @@ class _DashboardWiFiGridState extends ConsumerState { } 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 = @@ -85,6 +123,38 @@ class _DashboardWiFiGridState extends ConsumerState { ); } + /// 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, diff --git a/lib/page/dashboard/views/dashboard_home_view.dart b/lib/page/dashboard/views/dashboard_home_view.dart index ee0367a04..dcf7a6a63 100644 --- a/lib/page/dashboard/views/dashboard_home_view.dart +++ b/lib/page/dashboard/views/dashboard_home_view.dart @@ -11,6 +11,7 @@ import 'package:privacy_gui/page/components/styled/menus/widgets/menu_holder.dar 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/dashboard/strategies/custom_dashboard_layout_strategy.dart'; import 'package:privacy_gui/page/vpn/views/vpn_status_tile.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'; @@ -43,15 +44,26 @@ class _DashboardHomeViewState extends ConsumerState { final isHorizontalLayout = state.isHorizontalLayout; final isSupportVPN = getIt.get().isSupportVPN(); - // Get display modes from preferences - final internetMode = - preferences.getMode(DashboardWidgetSpecs.internetStatus.id); - final networksMode = preferences.getMode(DashboardWidgetSpecs.networks.id); - final wifiMode = preferences.getMode(DashboardWidgetSpecs.wifiGrid.id); - final quickPanelMode = - preferences.getMode(DashboardWidgetSpecs.quickPanel.id); - final portAndSpeedMode = - preferences.getMode(DashboardWidgetSpecs.portAndSpeed.id); + // 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, @@ -73,27 +85,43 @@ class _DashboardHomeViewState extends ConsumerState { ); // 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, - displayModes: preferences.widgetModes, + widgetConfigs: preferences.widgetConfigs, title: const DashboardHomeTitle(), - internetWidget: InternetConnectionWidget(displayMode: internetMode), - networksWidget: DashboardNetworks(displayMode: networksMode), - wifiGrid: DashboardWiFiGrid(displayMode: wifiMode), - quickPanel: DashboardQuickPanel(displayMode: quickPanelMode), + internetWidget: InternetConnectionWidget( + key: ValueKey('internet-$internetMode-$useCustom'), + displayMode: internetMode, + ), + networksWidget: DashboardNetworks( + key: ValueKey('networks-$networksMode-$useCustom'), + displayMode: networksMode, + ), + wifiGrid: DashboardWiFiGrid( + key: ValueKey('wifi-$wifiMode-$useCustom'), + displayMode: wifiMode, + ), + 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, ), ); // 3. Delegate to strategy - final strategy = DashboardLayoutFactory.create(variant); + final strategy = useCustom + ? const CustomDashboardLayoutStrategy() + : DashboardLayoutFactory.create(variant); return strategy.build(layoutContext); }, ); diff --git a/lib/page/dashboard/views/dashboard_menu_view.dart b/lib/page/dashboard/views/dashboard_menu_view.dart index 326bdb62a..6285fb863 100644 --- a/lib/page/dashboard/views/dashboard_menu_view.dart +++ b/lib/page/dashboard/views/dashboard_menu_view.dart @@ -79,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, @@ -134,14 +135,14 @@ 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(); - }, - ), + // AppListTile( + // title: AppText.bodyMedium('Dashboard Layout'), + // leading: const AppIcon.font(AppFontIcons.widgets), + // onTap: () { + // Navigator.of(context).maybePop(); + // _showLayoutSettingsDialog(); + // }, + // ), ], ), ), 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_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/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/wifi_settings/views/mac_filter/mac_filtered_devices_view.dart b/lib/page/wifi_settings/views/mac_filter/mac_filtered_devices_view.dart index 4fecb145f..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 @@ -188,7 +188,11 @@ class _FilteredDevicesViewState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ - AppText.labelLarge(device.name), + AppText.labelLarge( + device.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), AppGap.xs(), AppText.bodyMedium(device.macAddress), ], 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/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() { From 681ff9356d89aa508ddd0b9673e0c24d3430aa5d Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Fri, 9 Jan 2026 00:31:46 +0800 Subject: [PATCH 26/26] fix(ui): resolve renderflex overflow in instant verify and upgrade ui_kit to v2.10.3 --- .../views/instant_verify_view.dart | 82 +++++++++---------- pubspec.yaml | 12 +-- 2 files changed, 45 insertions(+), 49 deletions(-) diff --git a/lib/page/instant_verify/views/instant_verify_view.dart b/lib/page/instant_verify/views/instant_verify_view.dart index 7f78c1ecd..6ee959d97 100644 --- a/lib/page/instant_verify/views/instant_verify_view.dart +++ b/lib/page/instant_verify/views/instant_verify_view.dart @@ -723,51 +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: 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 - ), + return 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: 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 ), - ], - ), + ), + ], ), - ], - )), - ); + ), + ], + )); } Widget _headerWidget(String title, [Widget? action]) { diff --git a/pubspec.yaml b/pubspec.yaml index 0e7994287..8f607e774 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,16 +65,16 @@ dependencies: usp_protocol_common: path: packages/usp_protocol_common - # ui_kit_library: - # git: - # url: https://github.com/linksys/privacyGUI-UI-kit.git - # ref: v2.10.1 ui_kit_library: - path: ../../ui_kit + git: + url: https://github.com/linksys/privacyGUI-UI-kit.git + 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