diff --git a/.agent/workflows/manual-testing.md b/.agent/workflows/manual-testing.md new file mode 100644 index 000000000..72dd600e1 --- /dev/null +++ b/.agent/workflows/manual-testing.md @@ -0,0 +1,169 @@ +--- +description: Manual testing workflow for PrivacyGUI web application with browser automation +--- + +# Manual Testing Workflow + +This workflow guides the process of manually testing the PrivacyGUI web application using browser automation. + +## Prerequisites + +- Ensure you are in the PrivacyGUI project directory +- Have the router/mock server accessible at localhost +- User should provide the router password when prompted + +## Steps + +### 1. Start the Web Server + +1. Execute background command: `nohup flutter run -d web-server --web-port 61672 --web-browser-flag --disable-web-security --dart-define force=local --target lib/main.dart < /dev/null > server.log 2>&1 & echo $! > flutter_pid.txt` +2. Pause for 15 seconds (to give Flutter time to start). +3. Read the last 20 lines of `server.log`. +4. Check the Log for "listening on" or URL information. +5. If successful, proceed with the test task; otherwise, report an error. + +### 2. Open Browser and Navigate + +Use the `browser_subagent` tool to: +1. Navigate to `https://localhost` (IMPORTANT: Do NOT include the port number 61672, use default HTTPS port) +2. If a certificate warning appears, bypass it by clicking "Advanced" → "Proceed to localhost" +3. Wait for the page to fully load + +### 3. Determine Login State + +Check the current URL/page state: + +#### If Login Page (`#/localLoginPassword`) +1. The page shows "Log in" with a "Router Password" input field +2. **Ask the user for the password** if not provided +3. Click the password input field +4. Type the provided password +5. Click "Log in" button or press Enter +6. Wait for dashboard to load + +#### If PNP Flow (Plug and Play Setup) +The PnP flow is detected when the URL contains `/pnp` or shows setup wizard UI. + +**PnP Flow States** (reference: `doc/pnp/pnp-flow.md`): + +| State | Description | Expected UI | +|-------|-------------|-------------| +| `AdminInitializing` | Initial checks running | Loading spinner | +| `AdminUnconfigured` | Router not yet configured | "Continue" button shown | +| `AdminAwaitingPassword` | Password required | Password input field | +| `AdminInternetConnected` | Connected, auto-advancing | Brief loading then wizard | +| `WizardConfiguring` | Setup wizard steps | PnpStepper with WiFi/Guest/Network config | +| `WizardSaved` | Configuration saved | Success message | +| `WizardWifiReady` | Setup complete | "Done" button to go to dashboard | +| `NoInternetRoute` | No internet connection | Troubleshooter views | + +**PnP Step Components**: +- **Step 1**: WiFi Configuration (SSID, Password) +- **Step 2**: Guest Network (Optional) +- **Step 3**: Network Settings (Name It, Topology) +- **Final**: Firmware check and completion + +### 4. Execute Test Instructions + +Based on user's test instructions: +1. Navigate to the specified page/feature +2. Perform the requested actions (click buttons, fill forms, etc.) +3. Capture screenshots at key points +4. Record any errors or unexpected behaviors + +**Common Test Areas**: +- **Dashboard**: Home, Menu, Network status +- **Instant-Devices**: Device list, filters, editing +- **Instant-WiFi**: WiFi settings, SSID changes +- **Instant-Admin**: Router password, firmware, timezone +- **Instant-Topology**: Node management, LED settings +- **Advanced Settings**: Detailed configuration +- **PnP Troubleshooter**: ISP settings, modem checks + +### 5. Generate Test Results + +After completing the test, generate a comprehensive report: + +#### Create Test Report Artifact + +Save to: `/test_report.md` + +Include: +1. **Test Summary**: Date, time, test scope +2. **Environment**: Browser, URL, router model +3. **Test Steps Executed**: Numbered list of actions taken +4. **Results**: + - ✅ Passed items + - ❌ Failed items with details + - ⚠️ Warnings or observations +5. **Screenshots**: Embed captured screenshots using `![description](path)` +6. **Video Recording**: Reference the browser recording `.webp` file +7. **Recommendations**: Any suggested fixes or improvements + +#### Example Report Structure + +```markdown +# Manual Test Report + +**Date**: YYYY-MM-DD HH:MM +**Tester**: Gemini Browser Agent +**Build**: Flutter Web + +## Test Scope +[Description of what was tested] + +## Test Environment +- URL: https://localhost/#/... +- Router/Mock: [Model info] +- Browser: Chrome (headless) + +## Test Steps & Results + +| Step | Action | Expected | Actual | Status | +|------|--------|----------|--------|--------| +| 1 | Login with password | Dashboard loads | Dashboard loaded | ✅ | +| 2 | Click Menu | Menu opens | Menu opened | ✅ | +| ... | ... | ... | ... | ... | + +## Screenshots + +![Dashboard after login](path/to/screenshot.png) + +## Video Recording + +Browser session recorded at: [path/to/recording.webp] + +## Issues Found +[List any bugs or issues discovered] + +## Recommendations +[Suggested fixes or improvements] +``` + +### 6. Cleanup (Optional) + +If the test is complete and you want to stop the server: +```bash +if [ -f flutter_pid.txt ]; then + kill $(cat flutter_pid.txt) + rm flutter_pid.txt + echo "Server stopped" +else + echo "PID file not found" +fi +``` + +## Tips + +- **SSL Issues**: Always use `https://localhost` instead of `http://localhost` for API calls to work properly +- **Flutter Web Rendering**: DOM may appear empty due to Flutter's canvas rendering; use pixel-based clicking +- **Wait Times**: Allow 3-5 seconds after navigation for Flutter to render +- **Screenshots**: Capture at every significant state change for documentation +- **PnP Testing**: May require router reset or mock mode to trigger PnP flow + +## Related Documentation + +- PnP Flow: `doc/pnp/pnp-flow.md` +- PnP Overview: `doc/pnp/pnp.md` +- Test Cases: `doc/tests/cases/` +- Screenshot Tests: `doc/screenshot_test/` \ No newline at end of file diff --git a/.agent/workflows/service-decoupling-audit.md b/.agent/workflows/service-decoupling-audit.md new file mode 100644 index 000000000..c26463e0f --- /dev/null +++ b/.agent/workflows/service-decoupling-audit.md @@ -0,0 +1,193 @@ +--- +description: Audit service layer decoupling from JNAP and document service contracts for future protocol migration +--- + +# Service Decoupling Audit Workflow + +This workflow checks the current state of service-layer decoupling from JNAP-specific implementations and documents service contracts for future USP/TR migration. + +## Prerequisites + +- Access to `lib/core/data/services/` directory +- Access to `lib/page/**/services/` directories +- Understanding of JNAP actions in `lib/core/jnap/actions/` + +--- + +## Step 1: Identify All Services + +// turbo +```bash +cd /Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI +find lib -name "*_service.dart" -type f | head -50 +``` + +List all service files and categorize them: +- **Core Services**: `lib/core/data/services/` +- **Feature Services**: `lib/page/**/services/` + +--- + +## Step 2: Check JNAP Coupling in Each Service + +For each service file, check for direct JNAP dependencies: + +// turbo +```bash +cd /Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI +grep -l "JNAPAction\|JNAPResult\|JNAPSuccess\|JNAPError\|JNAPTransaction" lib/core/data/services/*.dart lib/page/**/services/*.dart 2>/dev/null | head -30 +``` + +**Decoupling Criteria:** +| Level | Criteria | Status | +|-------|----------|--------| +| 🔴 High Coupling | Service directly uses `JNAPAction`, `JNAPResult` | Needs refactoring | +| 🟡 Medium Coupling | Service uses `RouterRepository` but abstracts JNAP | Acceptable | +| 🟢 Low Coupling | Service uses protocol-agnostic interfaces | Ideal | + +--- + +## Step 3: Analyze RouterRepository Usage + +Check how services interact with RouterRepository: + +// turbo +```bash +cd /Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI +grep -n "routerRepositoryProvider\|RouterRepository" lib/core/data/services/*.dart lib/page/**/services/*.dart 2>/dev/null | head -50 +``` + +Document the usage pattern: +- `send()` - Single JNAP command +- `transaction()` - Batch JNAP commands +- `scheduledCommand()` - Polling JNAP commands + +--- + +## Step 4: Document Service Contracts + +For each service, create a contract document with the following template: + +```markdown +## [ServiceName] + +**Location**: `lib/path/to/service.dart` + +**Responsibility**: Brief description of what this service does + +**Dependencies**: +- RouterRepository (JNAP communication) +- Other services or providers + +### Read Operations +| Operation | Description | JNAP Action(s) | +|-----------|-------------|----------------| +| getXxx() | Description | JNAPAction.xxx | + +### Write Operations +| Operation | Description | JNAP Action(s) | +|-----------|-------------|----------------| +| setXxx() | Description | JNAPAction.xxx | + +### Data Models +- Input: List of input types +- Output: List of output/state types + +### Side Effects +- Does this trigger device restart? +- Does this require polling for completion? +``` + +--- + +## Step 5: Generate Service-JNAP Mapping Table + +// turbo +```bash +cd /Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI +grep -h "JNAPAction\." lib/core/data/services/*.dart lib/page/**/services/*.dart 2>/dev/null | grep -o "JNAPAction\.[a-zA-Z]*" | sort | uniq -c | sort -rn | head -30 +``` + +Create a mapping table for migration planning: + +| JNAP Action | Used By Service(s) | USP Equivalent (TBD) | +|-------------|-------------------|----------------------| +| getDeviceInfo | DashboardManagerService, PollingService | Device.DeviceInfo. | +| getDevices | DeviceManagerService, PollingService | Device.Hosts.Host. | +| ... | ... | ... | + +--- + +## Step 6: Check Domain Model Independence + +Verify that domain models don't expose JNAP-specific structures: + +// turbo +```bash +cd /Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI +grep -l "fromMap\|fromJson" lib/core/jnap/models/*.dart | head -20 +``` + +**Model Checklist:** +- [ ] Field names are domain-specific, not JNAP-specific +- [ ] Factory constructors handle data transformation +- [ ] Models are in `lib/core/jnap/models/` (acceptable for now) + +--- + +## Step 7: Generate Audit Report + +Create a summary report at: +`/Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/docs/service-decoupling-audit.md` + +Report should include: +1. **Executive Summary**: Overall decoupling status +2. **Service Inventory**: List of all services with coupling level +3. **JNAP Action Usage**: Which actions are used and where +4. **Migration Readiness**: Services ready for protocol swap +5. **Recommendations**: Priority actions for better decoupling + +--- + +## Step 8: Update Service Documentation + +For services with missing contracts, add documentation comments: + +```dart +/// [ServiceName] - Brief description +/// +/// ## Responsibilities +/// - List of responsibilities +/// +/// ## JNAP Actions Used +/// - `JNAPAction.xxx` - Purpose +/// - `JNAPAction.yyy` - Purpose +/// +/// ## Future Migration Notes +/// - USP equivalent operations (when known) +/// - Special considerations +class ServiceName { + // ... +} +``` + +--- + +## Output Artifacts + +After completing this workflow, you should have: + +1. **Service Inventory List** - All services with their locations +2. **Coupling Assessment** - Red/Yellow/Green status for each service +3. **JNAP Action Mapping** - Complete list of JNAP actions and their services +4. **Service Contracts** - Documented interfaces for each service +5. **Audit Report** - Summary document for planning + +--- + +## When to Run This Workflow + +- Before starting USP/TR integration planning +- After adding new services +- During quarterly architecture reviews +- Before major refactoring efforts diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index b92431e61..4a99a53ec 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -12,6 +12,11 @@ on: options: - demo - staging + custom_layout: + description: 'Enable custom dashboard layout' + required: false + default: true + type: boolean # 設定 GitHub Pages 所需的權限 permissions: @@ -56,6 +61,7 @@ jobs: run: | flutter build web \ --release \ + --dart-define=custom_layout=${{ inputs.custom_layout }} \ --target=lib/main_demo.dart \ --base-href "/PrivacyGUI/demo/" diff --git a/.vscode/launch.json b/.vscode/launch.json index 010901722..236d82810 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -30,7 +30,9 @@ ], "toolArgs": [ "--dart-define", - "force=local" + "force=local", + "--dart-define", + "custom_layout=true" ], "program": "lib/main.dart" } diff --git a/doc/architecture_analysis_2026-01-05.md b/doc/architecture_analysis_2026-01-05.md new file mode 100644 index 000000000..1b095f182 --- /dev/null +++ b/doc/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/doc/architecture_analysis_2026-01-16.md b/doc/architecture_analysis_2026-01-16.md new file mode 100644 index 000000000..c61ec7c31 --- /dev/null +++ b/doc/architecture_analysis_2026-01-16.md @@ -0,0 +1,609 @@ +# PrivacyGUI 專案架構完整分析報告 + +本報告詳細分析 PrivacyGUI 專案的整體架構,聚焦於 **Clean Architecture**、**分層架構** 以及 **領域解耦** 三大面向。 + +--- + +## 1. 高階架構圖 (High-Level Architecture) + +```mermaid +graph TB + subgraph External["外部服務"] + Router["Router / JNAP"] + Cloud["Linksys Cloud"] + USP["USP Protocol"] + end + + subgraph PresentationLayer["展示層 Presentation Layer"] + Views["Views
(Flutter Widgets)"] + Components["共用元件
(page/components/)"] + UIKit["UI Kit Library
(外部 package)"] + end + + subgraph ApplicationLayer["應用層 Application Layer"] + PageProviders["頁面 Providers
(page/*/providers/)"] + GlobalProviders["全局 Providers
(lib/providers/)"] + CoreProviders["核心 Providers
(core/jnap/providers/)"] + end + + subgraph ServiceLayer["服務層 Service Layer"] + PageServices["頁面 Services
(page/*/services/)"] + AuthService["認證服務
(providers/auth/auth_service.dart)"] + CloudService["雲端服務
(core/cloud/linksys_device_cloud_service.dart)"] + end + + subgraph DataLayer["資料層 Data Layer"] + RouterRepo["RouterRepository
(core/jnap/router_repository.dart)"] + CloudRepo["LinksysCloudRepository
(core/cloud/linksys_cloud_repository.dart)"] + JnapModels["JNAP Models
(core/jnap/models/)"] + CloudModels["Cloud Models
(core/cloud/model/)"] + Cache["快取層
(core/cache/)"] + end + + subgraph PackagesLayer["獨立套件 Packages"] + UspCore["usp_client_core"] + UspCommon["usp_protocol_common"] + end + + Views --> Components + Views --> UIKit + Views --> PageProviders + + PageProviders --> PageServices + PageProviders --> GlobalProviders + PageProviders --> CoreProviders + + GlobalProviders --> CoreProviders + + PageServices --> RouterRepo + PageServices --> JnapModels + AuthService --> RouterRepo + CloudService --> CloudRepo + + RouterRepo --> Router + RouterRepo --> Cache + CloudRepo --> Cloud + RouterRepo -.-> UspCore + UspCore --> USP + + style PresentationLayer fill:#e1f5fe + style ApplicationLayer fill:#fff3e0 + style ServiceLayer fill:#f3e5f5 + style DataLayer fill:#e8f5e9 + style PackagesLayer fill:#fce4ec +``` + +--- + +## 2. 專案目錄結構與職責 + +``` +PrivacyGUI/ +├── lib/ +│ ├── main.dart # 應用程式入口 +│ ├── app.dart # MaterialApp 配置 +│ ├── di.dart # 依賴注入配置 +│ │ +│ ├── core/ # 📦 核心基礎設施層 (173 files) +│ │ ├── jnap/ # JNAP 協議層 (76 files) +│ │ │ ├── actions/ # JNAP 指令定義 +│ │ │ ├── command/ # 指令執行器 +│ │ │ ├── models/ # JNAP 資料模型 (55 files) +│ │ │ ├── providers/ # 核心狀態管理 +│ │ │ └── router_repository.dart # 主要 Repository +│ │ ├── cloud/ # 雲端服務層 (31 files) +│ │ ├── cache/ # 快取機制 (6 files) +│ │ ├── data/ # 共享資料層 +│ │ │ ├── providers/ # 資料狀態管理 +│ │ │ └── services/ # 資料服務 +│ │ ├── http/ # HTTP 客戶端 +│ │ ├── usp/ # USP 協議層 (11 files) +│ │ └── utils/ # 工具函數 +│ │ +│ ├── page/ # 📱 頁面功能模組 (453 files) +│ │ ├── dashboard/ # 控制面板 +│ │ ├── wifi_settings/ # WiFi 設定 +│ │ ├── advanced_settings/ # 進階設定 (136 files) +│ │ │ ├── dmz/ # ⭐ 範例模組 (完整分層) +│ │ │ ├── firewall/ +│ │ │ ├── port_forwarding/ +│ │ │ └── ... +│ │ ├── instant_device/ # 裝置管理 +│ │ ├── instant_topology/ # 網路拓撲 +│ │ ├── nodes/ # 節點管理 +│ │ └── ... # (共 21 個功能模組) +│ │ +│ ├── providers/ # 🔗 全局狀態管理 (25 files) +│ │ ├── auth/ # 認證狀態 (8 files) +│ │ ├── connectivity/ # 連線狀態 +│ │ └── app_settings/ # 應用設定 +│ │ +│ ├── route/ # 🗺️ 路由配置 (14 files) +│ │ ├── router_provider.dart # 路由狀態管理 +│ │ ├── route_*.dart # 各頁面路由定義 +│ │ └── constants.dart # 路由常數 +│ │ +│ ├── constants/ # 常數定義 (13 files) +│ ├── util/ # 工具類 (30 files) +│ └── l10n/ # 國際化 (26 files) +│ +└── packages/ # 📦 獨立套件 + ├── usp_client_core/ # USP 協議核心 + └── usp_protocol_common/ # USP 協議共用 +``` + +--- + +## 3. Clean Architecture 分層分析 + +### 3.1 四層架構模型 + +```mermaid +graph LR + subgraph Layer1["Layer 1: Data Layer"] + direction TB + M1["JNAP Models"] + M2["Cloud Models"] + M3["Protocol Serialization
(toMap/fromMap)"] + end + + subgraph Layer2["Layer 2: Service Layer"] + direction TB + S1["Data ↔ UI Model Conversion"] + S2["Protocol Handling"] + S3["Business Logic"] + end + + subgraph Layer3["Layer 3: Application Layer"] + direction TB + P1["Riverpod Notifiers"] + P2["UI State Management"] + P3["Reactive Subscriptions"] + end + + subgraph Layer4["Layer 4: Presentation Layer"] + direction TB + V1["Flutter Widgets"] + V2["UI Components"] + V3["User Interactions"] + end + + Layer1 --> Layer2 + Layer2 --> Layer3 + Layer3 --> Layer4 + + style Layer1 fill:#c8e6c9 + style Layer2 fill:#e1bee7 + style Layer3 fill:#fff9c4 + style Layer4 fill:#bbdefb +``` + +### 3.2 層次職責定義 + +| 層次 | 位置 | 職責 | 可引用的層次 | +|------|------|------|--------------| +| **Data Layer** | `core/jnap/models/`, `core/cloud/model/` | 協議資料模型、序列化/反序列化 | 無 (最底層) | +| **Service Layer** | `page/*/services/`, `providers/auth/auth_service.dart` | Data ↔ UI 模型轉換、協議處理 | Data Layer | +| **Application Layer** | `page/*/providers/`, `lib/providers/`, `core/*/providers/` | 狀態管理、反應式訂閱 | Service Layer | +| **Presentation Layer** | `page/*/views/`, `page/components/` | Flutter Widgets、使用者互動 | Application Layer | + +--- + +## 4. 模組區塊圖 (Module Block Diagram) + +### 4.1 功能模組總覽 + +```mermaid +graph TB + subgraph CoreModules["核心模組 (lib/core/)"] + JNAP["JNAP 協議
76 files"] + Cloud["雲端服務
31 files"] + Data["資料層
18 files"] + Cache["快取
6 files"] + HTTP["HTTP
5 files"] + USP["USP
11 files"] + end + + subgraph FeatureModules["功能模組 (lib/page/)"] + Dashboard["Dashboard
74 files"] + WiFi["WiFi Settings
36 files"] + Advanced["Advanced Settings
136 files"] + Device["Instant Device
16 files"] + Topology["Instant Topology
13 files"] + Nodes["Nodes
22 files"] + Setup["Instant Setup
29 files"] + Admin["Instant Admin
18 files"] + VPN["VPN
8 files"] + Health["Health Check
14 files"] + Login["Login
10 files"] + end + + subgraph SharedModules["共享模組"] + GlobalProviders["全局 Providers
(lib/providers/)"] + Route["路由
(lib/route/)"] + Components["共用元件
(page/components/)"] + end + + subgraph Packages["獨立套件"] + UspClient["usp_client_core"] + UspCommon["usp_protocol_common"] + end + + FeatureModules --> CoreModules + FeatureModules --> SharedModules + SharedModules --> CoreModules + CoreModules --> Packages + + style CoreModules fill:#e8f5e9 + style FeatureModules fill:#e3f2fd + style SharedModules fill:#fff3e0 + style Packages fill:#fce4ec +``` + +### 4.2 範例模組結構 (DMZ - 最佳實踐) + +```mermaid +graph TB + subgraph DMZModule["page/advanced_settings/dmz/"] + Views["views/
dmz_view.dart
dmz_settings_view.dart"] + Providers["providers/
dmz_provider.dart
dmz_state.dart
dmz_ui_settings.dart"] + Services["services/
dmz_service.dart"] + Barrel["_dmz.dart
(Barrel Export)"] + end + + subgraph Dependencies["Dependencies"] + CoreJNAP["core/jnap/models/
dmz_settings.dart"] + RouterRepo["core/jnap/
router_repository.dart"] + UIModels["UI Models
(provider 內定義)"] + end + + Views --> Providers + Providers --> Services + Providers --> UIModels + Services --> CoreJNAP + Services --> RouterRepo + + style DMZModule fill:#c8e6c9,stroke:#2e7d32 + style Views fill:#bbdefb + style Providers fill:#fff9c4 + style Services fill:#e1bee7 +``` + +--- + +## 5. 領域解耦分析 + +### 5.1 解耦評估矩陣 + +| 模組 | 分層完整性 | 依賴方向 | 模型隔離 | 評分 | +|------|------------|----------|----------|------| +| **AI 模組** (`lib/ai/`) | ✅ 完整 | ✅ 正確 | ✅ 抽象介面 | ⭐⭐⭐⭐⭐ | +| **USP 套件** (`packages/`) | ✅ 獨立 | ✅ 正確 | ✅ 完全隔離 | ⭐⭐⭐⭐⭐ | +| **DMZ 模組** | ✅ 完整 | ✅ 正確 | ✅ UI 模型 | ⭐⭐⭐⭐⭐ | +| **Auth 模組** | ✅ 完整 | ✅ 正確 | ✅ Service 層 | ⭐⭐⭐⭐ | +| **WiFi Settings** | ✅ 完整 | ⚠️ 跨頁面 | ✅ UI 模型 | ⭐⭐⭐⭐ | +| **Dashboard** | ✅ 完整 | ⚠️ 跨頁面 | ⚠️ 部分違規 | ⭐⭐⭐ | +| **Nodes** | ✅ 完整 | ⚠️ 跨頁面 | ✅ UI 模型 | ⭐⭐⭐⭐ | + +### 5.2 依賴關係圖 + +```mermaid +graph LR + subgraph CorrectFlow["✅ 正確的依賴方向"] + direction TB + V1["Views"] --> P1["Providers"] + P1 --> S1["Services"] + S1 --> D1["Data Models"] + end + + subgraph Violations["⚠️ 違規依賴"] + direction TB + P2["add_nodes_provider"] -.-> |直接引用| D2["BackHaulInfoData"] + P3["pnp_provider"] -.-> |直接引用| D3["AutoConfigurationSettings"] + P4["wifi_bundle_provider"] -.-> |跨頁面| P5["dashboard_home_provider"] + end + + style CorrectFlow fill:#c8e6c9 + style Violations fill:#ffcdd2 +``` + +### 5.3 跨模組依賴熱點 + +```mermaid +graph TD + subgraph HotSpots["高耦合熱點"] + WBP["wifi_bundle_provider"] + DHP["dashboard_home_provider"] + HCP["health_check_provider"] + DLP["device_list_provider"] + end + + WBP --> |讀取 lanPortConnections| DHP + DHP --> |監聽健康檢查| HCP + WBP --> |需要 privacy state| IPP["instant_privacy_state"] + DFLP["device_filtered_list_provider"] --> |需要 WiFi 資訊| WBP + NDP["node_detail_provider"] --> |需要裝置列表| DLP + + style WBP fill:#ffab91 + style DHP fill:#ffab91 +``` + +--- + +## 6. Data Flow 資料流分析 + +### 6.1 JNAP 指令執行流程 + +```mermaid +sequenceDiagram + participant V as View + participant P as Provider + participant S as Service + participant R as RouterRepository + participant J as JNAP Router + + V->>P: 觸發動作 (e.g., 儲存設定) + P->>S: 調用 Service 方法 + S->>S: 將 UI Model 轉換為 Data Model + S->>R: send(JNAPAction, data) + R->>J: HTTP POST /JNAP/ + J-->>R: Response (JSON) + R-->>S: JNAPResult + S->>S: 將 Data Model 轉換為 UI Model + S-->>P: UI Model + P->>P: 更新狀態 + P-->>V: 通知 rebuild +``` + +### 6.2 狀態管理架構 + +```mermaid +graph TB + subgraph StateManagement["Riverpod 狀態管理"] + subgraph PageState["頁面狀態"] + PN["Page Notifiers
(StateNotifier)"] + PS["Page State
(Freezed models)"] + end + + subgraph GlobalState["全局狀態"] + AM["AuthManager"] + DM["DashboardManager"] + DevM["DeviceManager"] + PM["PollingManager"] + end + + subgraph CoreState["核心狀態"] + WAN["WAN Provider"] + FW["Firmware Provider"] + SE["SideEffect Provider"] + end + end + + PN --> PS + PN --> GlobalState + PN --> CoreState + GlobalState --> CoreState + + style PageState fill:#bbdefb + style GlobalState fill:#fff9c4 + style CoreState fill:#c8e6c9 +``` + +--- + +## 7. 協議抽象層 + +### 7.1 多協議支援架構 + +```mermaid +graph TB + subgraph AbstractionLayer["抽象層"] + IProvider["IRouterCommandProvider
(lib/ai/abstraction/)"] + end + + subgraph Implementations["實現層"] + JNAPImpl["JNAP Implementation"] + USPImpl["USP Implementation"] + end + + subgraph Protocols["協議層"] + JNAP["JNAP Protocol
(core/jnap/)"] + USP["USP Protocol
(packages/usp_client_core/)"] + Bridge["USP Bridge
(core/usp/)"] + end + + IProvider --> JNAPImpl + IProvider --> USPImpl + JNAPImpl --> JNAP + USPImpl --> Bridge + Bridge --> USP + + style AbstractionLayer fill:#e1bee7 + style Implementations fill:#fff9c4 + style Protocols fill:#c8e6c9 +``` + +### 7.2 AI 模組架構 (MCP 模式) + +```mermaid +graph LR + subgraph AIModule["lib/ai/"] + Orchestrator["AI Orchestrator"] + Abstraction["IRouterCommandProvider"] + Commands["Router Commands"] + Resources["Router Resources"] + end + + subgraph MCPPattern["MCP-like Pattern"] + ListTools["listCommands()"] + CallTool["execute()"] + ListRes["listResources()"] + ReadRes["readResource()"] + end + + Orchestrator --> Abstraction + Abstraction --> ListTools + Abstraction --> CallTool + Abstraction --> ListRes + Abstraction --> ReadRes + + style AIModule fill:#e8f5e9 +``` + +--- + +## 8. 問題識別與改進建議 + +### 8.1 主要問題分類 + +```mermaid +pie title 架構問題分布 + "Provider 直接引用 Data Model" : 4 + "跨頁面 Provider 依賴" : 7 + "巨型檔案" : 4 + "缺少 Service 層" : 2 +``` + +### 8.2 改進優先級 + +| 優先級 | 問題 | 影響範圍 | 建議修復時程 | +|--------|------|----------|--------------| +| **P0** | Provider 直接引用 Data 模型 | 1 個檔案 | 1 週 | +| **P1** | 跨頁面 Provider 依賴 | 3 個檔案 | 2-3 週 | +| **P2** | 巨型檔案拆分 | 4 個檔案 | 按需進行 | + +--- + +## 9. 詳細問題檔案清單 + +> [!IMPORTANT] +> 完整的架構違規詳細報告請參閱 [architecture-violations-detail.md](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/doc/audit/architecture-violations-detail.md),包含具體的程式碼行號、違規程式碼片段與建議修復方式。 + +### 🔴 P0: RouterRepository 在 Views 中直接使用 + +| 檔案 | 行號 | 問題 | 修復方式 | +|------|------|------|----------| +| [prepare_dashboard_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/dashboard/views/prepare_dashboard_view.dart) | 78-86 | 直接使用 RouterRepository 與 JNAPAction | 建立 DashboardPrepareService | +| [router_assistant_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/ai_assistant/views/router_assistant_view.dart) | 9-12 | 在 View 檔案中定義 Provider | 移動至 providers/ 目錄 | +| [local_network_settings_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/advanced_settings/local_network_settings/views/local_network_settings_view.dart) | 270, 308 | 直接呼叫 `getLocalIP()` | 透過 Provider 暴露 | +| [pnp_no_internet_connection_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/instant_setup/troubleshooter/views/pnp_no_internet_connection_view.dart) | 119 | 直接檢查 `isLoggedIn()` | 使用 AuthProvider | + +--- + +### 🔴 P0: JNAPAction 在非 Services 中使用 + +| 檔案 | 行號 | 問題 | 修復方式 | +|------|------|------|----------| +| [prepare_dashboard_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/dashboard/views/prepare_dashboard_view.dart) | 82 | 直接使用 `JNAPAction.getDeviceInfo` | 封裝至 Service | +| [select_network_provider.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/select_network/providers/select_network_provider.dart) | 56 | 直接使用 `JNAPAction.isAdminPasswordDefault` | 建立 SelectNetworkService | + +--- + +### 🟠 P1: 跨頁面 Provider 依賴 + +| 來源檔案 | 被引用檔案 | 行號 | 問題描述 | 狀態 | +|----------|------------|------|----------|------| +| [device_filtered_list_provider.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/instant_device/providers/device_filtered_list_provider.dart) | `wifi_bundle_provider` | 9, 83-91 | 跨 `instant_device` → `wifi_settings` 讀取 WiFi SSID 列表 | ✅ 已修復 | +| [wifi_bundle_provider.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/wifi_settings/providers/wifi_bundle_provider.dart) | `instant_privacy_state` | 9, 60-61 | 跨 `wifi_settings` → `instant_privacy` 引用 State 類型 | ✅ 已修復 | +| [displayed_mac_filtering_devices_provider.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/wifi_settings/providers/displayed_mac_filtering_devices_provider.dart) | `instant_device/_instant_device` | 2 | 跨模組取得裝置資訊 | ✅ 已修復 | + +**device_filtered_list_provider.dart 問題程式碼:** +```dart +// line 9 - 跨頁面引用 +import 'package:privacy_gui/page/wifi_settings/providers/wifi_bundle_provider.dart'; + +// line 83-91 - 直接讀取其他頁面 Provider 狀態 +List getWifiNames() { + final wifiState = ref.read(wifiBundleProvider); + return [ + ...wifiState.settings.current.wifiList.mainWiFi.map((e) => e.ssid), + wifiState.settings.current.wifiList.guestWiFi.ssid, + ]; +} +``` + +**建議修復:** 將 WiFi SSID 列表提取到 `core/data/providers/wifi_radios_provider.dart` 或創建共享的 `lib/providers/wifi_names_provider.dart`。 + +--- + +### 🟡 P2: 巨型檔案 (需拆分) + +| 檔案 | 大小 | 問題 | 建議拆分方式 | +|------|------|------|--------------| +| [jnap_tr181_mapper.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/core/usp/jnap_tr181_mapper.dart) | ~42KB | JNAP ↔ TR-181 映射邏輯過於集中 | 按功能域拆分 (WiFi, Device, Network) | +| [router_provider.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/route/router_provider.dart) | ~19KB | 路由邏輯與認證邏輯混合 | 分離 `auth_guard.dart` 與 `route_config.dart` | +| [router_repository.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/core/jnap/router_repository.dart) | ~15KB | 多種命令類型處理混合 | 拆分 HTTP/BT/Remote 命令處理 | +| [linksys_cloud_repository.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/core/cloud/linksys_cloud_repository.dart) | ~16KB | 雲端功能過於集中 | 按功能拆分 (Auth, Device, User) | + +--- + +### ✅ 已修復的良好範例 + +| 模組 | 結構 | 特點 | +|------|------|------| +| [dashboard/](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/dashboard/) | providers + services + views | `dashboard_home_provider.dart` 已使用 Service 層 | +| [dmz/](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/advanced_settings/dmz/) | providers + services + views | 完整 4 層分離,是最佳範例 | +| [add_nodes/](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/nodes/providers/add_nodes_provider.dart) | providers + services | 已委派給 `add_nodes_service.dart` | +| [nodes/](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/nodes/) | providers + services + state | `NodeLightSettings` 已重構為 Clean Architecture | +| [nodes/](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/nodes/) | providers + services + state | `NodeLightSettings` 已重構為 Clean Architecture | +| [ai/](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/ai/) | abstraction + orchestrator | 使用 `IRouterCommandProvider` 抽象介面 | +| **Cross-Page Refs** | Shared Models in Core | `DeviceListItem`, `InstantPrivacySettings` 已移動至核心層共享 | + +--- + +## 10. 具體改進方案 + +### 方案 A: 提取共享狀態到核心層 + +```mermaid +graph LR + subgraph Before["目前"] + WBP1["wifi_bundle_provider"] --> DHP1["dashboard_home_provider"] + end + + subgraph After["改進後"] + WBP2["wifi_bundle_provider"] --> CSP["connectivity_status_provider
(核心共享層)"] + DHP2["dashboard_home_provider"] --> CSP + end + + style Before fill:#ffcdd2 + style After fill:#c8e6c9 +``` + +### 方案 B: 建立模組 Barrel Export + +```dart +// lib/page/wifi_settings/_wifi_settings.dart (Barrel Export) +// 只暴露公開 API + +export 'providers/wifi_bundle_provider.dart' show wifiBundleProvider; +export 'models/wifi_status.dart'; +// 隱藏內部實現細節 +``` + +--- + +## 9. 總結評分 + +| 維度 | 評分 | 說明 | +|------|------|------| +| 整體架構設計 | ⭐⭐⭐⭐ | 4 層架構清晰,有文件化規範 | +| 協議抽象 | ⭐⭐⭐⭐⭐ | AI、USP 模組解耦優秀 | +| 頁面模組解耦 | ⭐⭐⭐ | 存在跨模組依賴問題 | +| Provider 層純淨度 | ⭐⭐⭐ | 5 處 Data Model 違規 | +| 模組邊界清晰度 | ⭐⭐⭐ | Barrel export 使用不一致 | + +**總體評分: 3.6 / 5 ⭐** + +專案架構設計良好,核心模組 (AI、USP、DMZ) 展現了優秀的解耦實踐。主要改進重點在於: +1. Provider 層不應直接引用 Data Model +2. 減少跨功能模組的 Provider 依賴 +3. 統一建立模組 Barrel Export 機制 + +--- + +## 10. 參考資源 + +- 現有架構分析: [architecture_analysis_2026-01-05.md](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/doc/architecture_analysis_2026-01-05.md) +- DMZ 重構規範: [specs/002-dmz-refactor/](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/specs/002-dmz-refactor/) +- UI Kit Library: [privacyGUI-UI-kit](file:///Users/austin.chang/flutter-workspaces/ui_kit) diff --git a/doc/audit/architecture-violations-detail.md b/doc/audit/architecture-violations-detail.md new file mode 100644 index 000000000..2d1afdb59 --- /dev/null +++ b/doc/audit/architecture-violations-detail.md @@ -0,0 +1,330 @@ +# PrivacyGUI 架構違規清單 (Architecture Violations Report) + +**報告日期**: 2026-01-16 +**目的**: 記錄所有不符合 Clean Architecture 原則的程式碼,以便進行有計畫的重構 + +--- + +## 違規統計摘要 + +| 違規類型 | 數量 | 嚴重性 | +|----------|------|--------| +| RouterRepository 在 Views 中使用 | ~~4~~ 2 | 🔴 高 | +| RouterRepository 在 Providers 中使用 | 2 | 🟡 中 | +| JNAPAction 在非 Services 中使用 | ~~2~~ 1 | 🔴 高 | +| JNAP Models 在 Views 中引用 | 4 | 🟡 中 | +| **總計** | **~~12~~ 9** | - | + +--- + +## 🔴 P0: RouterRepository 在 Views 中直接使用 + +### 違規原則 +Views (展示層) 不應直接存取 RouterRepository (資料層),應透過 Provider → Service 的路徑。 + +--- + +### 1. `prepare_dashboard_view.dart` ✅ 已修復 + +**檔案路徑**: [lib/page/dashboard/views/prepare_dashboard_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/dashboard/views/prepare_dashboard_view.dart) + +> [!NOTE] +> **修復狀態**: ✅ 已於 2026-01-16 修復 +> +> **修復方式**: 在 `SessionService` 新增 `forceFetchDeviceInfo()` 方法,將 JNAP 操作封裝在 Service 層。 + +**原違規行號**: 78-86 + +**原違規程式碼**: +```dart +} else if (loginType == LoginType.local) { + logger.i('PREPARE LOGIN:: local'); + final routerRepository = ref.read(routerRepositoryProvider); // ❌ 直接讀取 + + final newSerialNumber = await routerRepository + .send( + JNAPAction.getDeviceInfo, // ❌ 直接使用 JNAPAction + fetchRemote: true, + ) + .then( + (value) => NodeDeviceInfo.fromJson(value.output).serialNumber); +``` + +**修復後程式碼**: +```dart +} else if (loginType == LoginType.local) { + logger.i('PREPARE LOGIN:: local'); + // Use sessionProvider.forceFetchDeviceInfo() instead of direct RouterRepository access + // This adheres to Clean Architecture: View -> Provider -> Service -> Repository + final deviceInfo = await ref + .read(sessionProvider.notifier) + .forceFetchDeviceInfo(); // ✅ 透過 Provider/Service + await ref + .read(sessionProvider.notifier) + .saveSelectedNetwork(deviceInfo.serialNumber, ''); +} +``` + +**相關測試**: +- [session_service_test.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/test/core/data/services/session_service_test.dart) - `forceFetchDeviceInfo` 測試群組 +- [session_provider_test.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/test/core/data/providers/session_provider_test.dart) - `forceFetchDeviceInfo` 測試群組 + +--- + +### 2. `router_assistant_view.dart` ✅ 已修復 + +**檔案路徑**: [lib/page/ai_assistant/views/router_assistant_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/ai_assistant/views/router_assistant_view.dart) + +> [!NOTE] +> **修復狀態**: ✅ 已於 2026-01-16 修復 +> +> **修復方式**: 將 `routerCommandProviderProvider` 移動到專用的 Provider 檔案 `lib/page/ai_assistant/providers/router_command_provider.dart`,並在 View 中導入使用。 + +**相關變更**: +- [router_command_provider.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/ai_assistant/providers/router_command_provider.dart) - 新建立的 Provider 檔案 +- [router_assistant_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/ai_assistant/views/router_assistant_view.dart) - 移除 View 內的 Provider 定義 + +--- + +### 3. `local_network_settings_view.dart` ✅ 已修復 + +**檔案路徑**: [lib/page/advanced_settings/local_network_settings/views/local_network_settings_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/advanced_settings/local_network_settings/views/local_network_settings_view.dart) + +> [!NOTE] +> **修復狀態**: ✅ 已於 2026-01-16 修復 +> +> **修復方式**: 將 `getLocalIp()` 函數改為接受 `ProviderReader` 型別,支援 `Ref` 與 `WidgetRef` 共用。 + +**原違規行號**: 270, 308 + +**原違規程式碼**: +```dart +// Line 270 - 在 _saveSettings 錯誤處理中 +final currentUrl = ref.read(routerRepositoryProvider).getLocalIP(); // ❌ + +// Line 308 - 在 _finishSaveSettings 中 +final currentUrl = ref.read(routerRepositoryProvider).getLocalIP(); // ❌ +``` + +**修復後程式碼**: +```dart +// 使用平台感知的 getLocalIp 工具函數 +final currentUrl = getLocalIp(ref.read); // ✅ 不再依賴 RouterRepository +``` + +**相關變更**: +- [get_local_ip.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/core/utils/ip_getter/get_local_ip.dart) - 新增 `ProviderReader` typedef +- [mobile_get_local_ip.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/core/utils/ip_getter/mobile_get_local_ip.dart) - 更新簽名 +- [web_get_local_ip.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/core/utils/ip_getter/web_get_local_ip.dart) - 更新簽名 + +--- + +### 4. `pnp_no_internet_connection_view.dart` ✅ 已修復 + +**檔案路徑**: [lib/page/instant_setup/troubleshooter/views/pnp_no_internet_connection_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/instant_setup/troubleshooter/views/pnp_no_internet_connection_view.dart) + +```dart +// 使用 AuthProvider 檢查登入狀態 +final loginType = ref.read(authProvider.select((value) => value.value?.loginType)); +if (loginType != null && loginType != LoginType.none) { + goRoute(RouteNamed.pnpIspTypeSelection); +} + +// 或透過 PnpProvider 暴露狀態 +if (ref.read(pnpProvider.notifier).isLoggedIn) { + goRoute(RouteNamed.pnpIspTypeSelection); +} +``` + +--- + +## 🟡 P1: RouterRepository 在 Providers 中直接使用 + +### 違規原則 +Providers (應用層) 應透過 Service (服務層) 存取 RouterRepository,而不是直接呼叫。 + +--- + +### 1. `select_network_provider.dart` ✅ 已修復 + +**檔案路徑**: [lib/page/select_network/providers/select_network_provider.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/select_network/providers/select_network_provider.dart) + +> [!NOTE] +> **修復狀態**: ✅ 已於 2026-01-16 修復 +> +> **修復方式**: 建立了 `NetworkAvailabilityService` 並將 `select_network_provider.dart` 中的 `RouterRepository` 依賴轉移至該 Service。 + +**原違規行號**: 54-64 + +**原違規程式碼**: +```dart +Future _checkNetworkOnline(CloudNetworkModel network) async { + final routerRepository = ref.read(routerRepositoryProvider); // ❌ + bool isOnline = await routerRepository + .send(JNAPAction.isAdminPasswordDefault, // ❌ 直接使用 JNAPAction + extraHeaders: { + kJNAPNetworkId: network.network.networkId, + }, + type: CommandType.remote, + fetchRemote: true, + cacheLevel: CacheLevel.noCache) + .then((value) => value.result == 'OK') + .onError((error, stackTrace) => false); + //... +} +``` + + final result = await _repository.send( + JNAPAction.isAdminPasswordDefault, + extraHeaders: {kJNAPNetworkId: networkId}, + type: CommandType.remote, + fetchRemote: true, + cacheLevel: CacheLevel.noCache, + ); + return result.result == 'OK'; + } catch (_) { + return false; + } + } +} +``` + +--- + +### 2. `channelfinder_provider.dart` ✅ 已修復 + +**檔案路徑**: [lib/page/wifi_settings/providers/channelfinder_provider.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/wifi_settings/providers/channelfinder_provider.dart) + +> [!NOTE] +> **修復狀態**: ✅ 已於 2026-01-16 修復 +> +> **修復方式**: 將 `channelFinderServiceProvider` 定義移動至 Service 檔案 `channel_finder_service.dart` 中,解決了組織結構上的違規。 + +**原違規行號**: 7-9 + +**原違規程式碼**: +```dart +final channelFinderServiceProvider = Provider((ref) { + return ChannelFinderService(ref.watch(routerRepositoryProvider)); // ⚠️ +}); +``` + +**相關變更**: +- [channel_finder_service.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/wifi_settings/services/channel_finder_service.dart) - 包含 Provider 定義 +- [channelfinder_provider.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/wifi_settings/providers/channelfinder_provider.dart) - 移除 Provider 定義與 Repo 依賴 + +--- + +## 🟡 P2: JNAP Models 在 Views 中引用 + +### 違規原則 +Views 應使用 UI Models,不應直接引用 JNAP Data Models。 + +--- + +### 1. `login_local_view.dart` + +**檔案路徑**: [lib/page/login/views/login_local_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/login/views/login_local_view.dart) + +**違規行號**: 8 + +**違規程式碼**: +```dart +import 'package:privacy_gui/core/jnap/models/device_info.dart'; // ❌ +``` + +**問題描述**: View 引用 JNAP 資料模型 + +--- + +### 2. `prepare_dashboard_view.dart` + +**檔案路徑**: [lib/page/dashboard/views/prepare_dashboard_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/dashboard/views/prepare_dashboard_view.dart) + +**違規行號**: 16 + +**違規程式碼**: +```dart +import 'package:privacy_gui/core/jnap/models/device_info.dart'; // ❌ +``` + +**問題描述**: View 引用 JNAP 資料模型 (與 P0 #1 相關) + +--- + +### 3. `firmware_update_process_view.dart` + +**檔案路徑**: [lib/page/firmware_update/views/firmware_update_process_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/firmware_update/views/firmware_update_process_view.dart) + +**違規行號**: 4 + +**違規程式碼**: +```dart +import 'package:privacy_gui/core/jnap/models/firmware_update_status.dart'; // ❌ +``` + +**問題描述**: View 引用 JNAP 資料模型 + +--- + +### 4. `instant_admin_view.dart` + +**檔案路徑**: [lib/page/instant_admin/views/instant_admin_view.dart](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/instant_admin/views/instant_admin_view.dart) + +**違規行號**: 7 + +**違規程式碼**: +```dart +import 'package:privacy_gui/core/jnap/models/firmware_update_settings.dart'; // ❌ +``` + +**問題描述**: View 引用 JNAP 資料模型 + +--- + +## 修復優先級建議 + +| 優先級 | 違規 | 預估工時 | 影響範圍 | 狀態 | +|--------|------|----------|----------|------| +| **P0-1** | `prepare_dashboard_view.dart` | 2-4 小時 | 登入流程 | ✅ 已修復 | +| **P0-2** | `pnp_no_internet_connection_view.dart` | 1-2 小時 | PnP 流程 | ✅ 已修復 | +| **P0-3** | `local_network_settings_view.dart` | 1-2 小時 | 網路設定 | ✅ 已修復 | +| **P0-4** | `router_assistant_view.dart` | 1 小時 | AI 助手 | ✅ 已修復 | +| **P1-1** | `select_network_provider.dart` | 2-3 小時 | 網路選擇 | ✅ 已修復 | +| **P1-2** | `channelfinder_provider.dart` | 30 分鐘 | WiFi 最佳化 | ✅ 已修復 | +| **P2** | JNAP Models imports | 各 30 分鐘 | 低風險 | 待修復 | + +--- + +## 最佳實踐範例 + +### DMZ 模組 (參考範例) + +``` +lib/page/advanced_settings/dmz/ +├── _dmz.dart # Barrel Export +├── views/ +│ ├── dmz_view.dart # ✅ 只引用 Provider +│ └── dmz_settings_view.dart +├── providers/ +│ ├── _providers.dart # Barrel Export +│ ├── dmz_settings_provider.dart # ✅ 透過 Service 存取資料 +│ ├── dmz_settings_state.dart # ✅ UI Models +│ └── dmz_status.dart +└── services/ + └── dmz_settings_service.dart # ✅ 封裝所有 JNAP 操作 +``` + +**關鍵原則**: +1. ✅ Views 只引用 Providers +2. ✅ Providers 透過 Services 存取 RouterRepository +3. ✅ Services 負責 Data Model ↔ UI Model 轉換 +4. ✅ UI Models 與 JNAP Data Models 完全隔離 + +--- + +## 相關文件 + +- [service-decoupling-audit.md](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/doc/audit/service-decoupling-audit.md) - 服務解耦審計 (更廣泛的分析) +- [architecture_analysis_2026-01-16.md](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/doc/architecture_analysis_2026-01-16.md) - 整體架構分析 +- [DMZ Service](file:///Users/austin.chang/flutter-workspaces/privacyGUI/PrivacyGUI/lib/page/advanced_settings/dmz/services/dmz_settings_service.dart) - 最佳實踐範例 diff --git a/doc/dashboard/dashboard_custom_layout_comprehensive_report_en.md b/doc/dashboard/dashboard_custom_layout_comprehensive_report_en.md new file mode 100644 index 000000000..b9a74c31b --- /dev/null +++ b/doc/dashboard/dashboard_custom_layout_comprehensive_report_en.md @@ -0,0 +1,799 @@ +# Dashboard Custom Layout Comprehensive Report + +This document integrates the System Architecture Analysis, Functional Implementation, Design Principles Evaluation, and In-depth Design Trade-offs for the PrivacyGUI Dashboard Custom Layout. It aims to provide a complete technical reference for developers, testers, and architects. + +--- + +# Part 1: System Architecture Analysis + +## 1. System Architecture Overview + +```mermaid +flowchart TD + subgraph Views["Views Layer"] + SDV["SliverDashboardView"] + DLSP["DashboardLayoutSettingsPanel"] + end + + subgraph Providers["Providers Layer (State Management)"] + SDCP["SliverDashboardControllerNotifier"] + DPP["DashboardPreferencesNotifier"] + end + + subgraph Factories["Factories Layer"] + LIF["LayoutItemFactory"] + DWF["DashboardWidgetFactory"] + end + + subgraph Models["Models Layer"] + WS["WidgetSpec"] + WGC["WidgetGridConstraints"] + HS["HeightStrategy"] + DM["DisplayMode"] + DLP["DashboardLayoutPreferences"] + end + + subgraph External["External Dependencies"] + SP["SharedPreferences"] + SliverPkg["sliver_dashboard Package"] + end + + SDV --> SDCP + SDV --> DPP + SDV --> DWF + DLSP --> SDCP + DLSP --> DPP + + SDCP --> LIF + SDCP --> SP + SDCP --> SliverPkg + + DPP --> SP + DPP --> DLP + + LIF --> WS + LIF --> WGC + LIF --> DM + + WS --> WGC + WS --> DM + WGC --> HS +``` + +--- + +## 2. Core Components + +### 2.1 Models Layer + +#### 2.1.1 `DisplayMode` + +**File**: `lib/page/dashboard/models/display_mode.dart` + +Defines three display density levels for components: + +| Mode | Description | +|------|-------------| +| `compact` | Minimal display, key information only | +| `normal` | Default standard display | +| `expanded` | Full information display | + +--- + +#### 2.1.2 `HeightStrategy` + +**File**: `lib/page/dashboard/models/height_strategy.dart` + +Uses **sealed class** for type-safe pattern matching: + +```mermaid +classDiagram + class HeightStrategy { + <> + } + class IntrinsicHeightStrategy { + +Let component determine its own height + } + class ColumnBasedHeightStrategy { + +multiplier: double + +height = columnWidth × multiplier + } + class AspectRatioHeightStrategy { + +ratio: double + +Fixed aspect ratio + } + + HeightStrategy <|-- IntrinsicHeightStrategy + HeightStrategy <|-- ColumnBasedHeightStrategy + HeightStrategy <|-- AspectRatioHeightStrategy +``` + +**Factory Methods**: +- `HeightStrategy.intrinsic()` - Content-adaptive +- `HeightStrategy.columnBased(multiplier)` - Based on column width multiplier +- `HeightStrategy.strict(rows)` - Fixed row count (semantic alias) +- `HeightStrategy.aspectRatio(ratio)` - Fixed ratio + +--- + +#### 2.1.3 `WidgetGridConstraints` + +**File**: `lib/page/dashboard/models/widget_grid_constraints.dart` + +Constraint system based on a **12-column layout**: + +| Property | Description | +|----------|-------------| +| `minColumns` | Minimum columns (1-12) | +| `maxColumns` | Maximum columns | +| `preferredColumns` | Default/preferred columns | +| `minHeightRows` | Minimum height in rows | +| `maxHeightRows` | Maximum height in rows | +| `heightStrategy` | Height calculation strategy | + +**Key Methods**: +- `scaleToMaxColumns(target)` - Scale proportionally to target column count +- `getPreferredHeightCells()` - Calculate preferred height in cells +- `getHeightRange()` - Get height range (min, max) + +--- + +#### 2.1.4 `WidgetSpec` + +**File**: `lib/page/dashboard/models/widget_spec.dart` + +Complete specification for each Dashboard component: + +```dart +class WidgetSpec { + final String id; // Unique identifier + final String displayName; // Display name + final String? description; // Description + final Map constraints; // Constraints per mode + final bool canHide; // Whether can be hidden + final List requirements; // Feature requirements +} +``` + +**Requirement System (`WidgetRequirement`)**: +- `none` - No special requirements +- `vpnSupported` - Requires VPN feature support + +--- + +#### 2.1.5 `DashboardWidgetSpecs` + +**File**: `lib/page/dashboard/models/dashboard_widget_specs.dart` + +Static definitions of all Dashboard component specs: + +| Category | Widgets | +|----------|---------| +| **Standard Widgets** | `internetStatus`, `networks`, `wifiGrid`, `quickPanel`, `portAndSpeed`, `vpn` | +| **Atomic Widgets** (Custom Layout) | `internetStatusOnly`, `masterNodeInfo`, `ports`, `speedTest`, `networkStats`, `topology`, `wifiGrid`, `quickPanel`, `vpn` | + +**Dynamic Constraints - Ports Widget**: + +```mermaid +flowchart TD + A{hasLanPort?} -->|No| B["_portsNoLanConstraints
Minimal height"] + A -->|Yes| C{isHorizontal?} + C -->|Yes| D["_portsHorizontalConstraints
Width priority"] + C -->|No| E["_portsVerticalConstraints
Height priority"] +``` + +Dynamically selects constraints via `getPortsSpec()` method. + +--- + +#### 2.1.6 `DashboardLayoutPreferences` + +**File**: `lib/page/dashboard/models/dashboard_layout_preferences.dart` + +User's Dashboard layout preferences: + +```dart +class DashboardLayoutPreferences { + final bool useCustomLayout; // Enable custom layout + final Map widgetConfigs; // Widget configurations +} +``` + +**Features**: +- Manage `DisplayMode` per widget +- Control widget visibility +- Widget ordering +- JSON serialization/deserialization (with legacy format migration) + +--- + +### 2.2 Providers Layer + +#### 2.2.1 `SliverDashboardControllerNotifier` + +**File**: `lib/page/dashboard/providers/sliver_dashboard_controller_provider.dart` + +Controller managing the drag-drop grid layout: + +```mermaid +stateDiagram-v2 + [*] --> Initialize + Initialize --> LoadFromStorage: Has saved data + Initialize --> CreateDefault: No saved data + + LoadFromStorage --> Ready + CreateDefault --> OptimizeLayout + OptimizeLayout --> Ready + + Ready --> EditMode: enterEditMode() + EditMode --> Ready: exitEditMode(save=true) + EditMode --> RestoreSnapshot: exitEditMode(save=false) + RestoreSnapshot --> Ready + + Ready --> Reset: resetLayout() + Reset --> CreateDefault +``` + +**Key Methods**: + +| Method | Function | +|--------|----------| +| `saveLayout()` | Save layout to SharedPreferences | +| `resetLayout()` | Reset to default layout | +| `updateItemConstraints()` | Update widget constraints | +| `updateItemSize()` | Force update widget size | +| `addWidget()` | Add a widget | +| `removeWidget()` | Remove a widget | + +**IoC Pattern - WidgetSpecResolver**: + +```dart +typedef WidgetSpecResolver = WidgetSpec Function(WidgetSpec defaultSpec); +``` + +Used to inject dynamic constraint logic at the composition root. + +--- + +#### 2.2.2 `DashboardPreferencesNotifier` + +**File**: `lib/page/dashboard/providers/dashboard_preferences_provider.dart` + +Manages Dashboard layout preferences: + +| Method | Function | +|--------|----------| +| `setWidgetMode()` | Set widget display mode | +| `toggleCustomLayout()` | Toggle custom layout | +| `restoreSnapshot()` | Restore snapshot | +| `resetWidgetModes()` | Reset display modes | + +--- + +### 2.3 Factories Layer + +#### 2.3.1 `LayoutItemFactory` + +**File**: `lib/page/dashboard/providers/layout_item_factory.dart` + +Converts `WidgetSpec` to `sliver_dashboard`'s `LayoutItem`: + +```dart +static LayoutItem fromSpec( + WidgetSpec spec, { + required int x, y, + int? w, h, + DisplayMode displayMode, +}) +``` + +**Default Layout**: + +``` +┌─────────────┬─────────────┬─────────────┐ +│ Internet │ Master │ Quick Panel │ y=0 +│ (4x2) │ (4x4) │ (4x3) │ +├─────────────┤ ├─────────────┤ y=2 +│ │ │ NetworkStats│ +│ Ports │ │ (4x2) │ y=3 +│ (4x6) ├─────────────┼─────────────┤ y=4 +│ │ SpeedTest │ Topology │ +│ │ (4x4) │ (4x4) │ +├─────────────┴─────────────┴─────────────┤ y=10 +│ WiFi Grid (8x2) │ +└─────────────────────────────────────────┘ +``` + +--- + +#### 2.3.2 `DashboardWidgetFactory` + +**File**: `lib/page/dashboard/factories/dashboard_widget_factory.dart` + +Unified Widget construction factory: + +- `buildAtomicWidget()` - Build widget by ID +- `shouldWrapInCard()` - Determine if AppCard wrapper is needed +- `getSpec()` - Get widget spec + +--- + +### 2.4 Views Layer + +#### 2.4.1 `SliverDashboardView` + +**File**: `lib/page/dashboard/views/sliver_dashboard_view.dart` + +Main drag-drop Dashboard view: + +```mermaid +flowchart TD + subgraph EditMode["Edit Mode"] + ET["Edit Toolbar"] + JJ["JiggleShake Effect"] + DM["DisplayMode Dropdown"] + RB["Remove Button"] + end + + subgraph NormalMode["Normal Mode"] + W["Widgets"] + end + + subgraph Features["Key Features"] + SE["Snapshot/Edit/Cancel"] + RC["Resize Constraint Enforcement"] + LS["Layout Settings Dialog"] + end + + SV["SliverDashboardView"] + SV --> EditMode + SV --> NormalMode + SV --> Features +``` + +**Edit Mode Features**: +1. **Enter Edit** - Snapshot current layout and preferences +2. **Cancel Edit** - Restore snapshot +3. **Save Edit** - Persist changes +4. **Resize Constraint** - `_handleResizeEnd()` enforces min/max constraints + +--- + +#### 2.4.2 `DashboardLayoutSettingsPanel` + +**File**: `lib/page/dashboard/views/components/settings/dashboard_layout_settings_panel.dart` + +Settings panel features: +- Toggle Standard/Custom layout +- Show hidden widgets with add option +- Reset layout to defaults + +--- + +## 3. Data Flow + +### 3.1 Layout Initialization + +```mermaid +sequenceDiagram + participant App + participant SDCP as SliverDashboardController + participant SP as SharedPreferences + participant LIF as LayoutItemFactory + participant DWS as DashboardWidgetSpecs + + App->>SDCP: Create Provider + SDCP->>SP: Read saved layout + alt Has saved data + SP-->>SDCP: Layout JSON + SDCP->>SDCP: importLayout() + else No saved data + SDCP->>LIF: createDefaultLayout(specResolver) + LIF->>DWS: Get widget specs + DWS-->>LIF: WidgetSpecs + LIF-->>SDCP: List + SDCP->>SDCP: optimizeLayout() + end +``` + +### 3.2 Edit Mode Flow + +```mermaid +sequenceDiagram + participant User + participant SDV as SliverDashboardView + participant SDCP as SliverDashboardController + participant DPP as DashboardPreferences + + User->>SDV: Click Edit + SDV->>SDCP: exportLayout() + SDV->>DPP: Read current preferences + SDV->>SDV: Save snapshot + SDV->>SDCP: setEditMode(true) + + Note over SDV: User drag/drop/resize + + alt Save + User->>SDV: Click Check + SDV->>SDCP: setEditMode(false) + else Cancel + User->>SDV: Click Close + SDV->>SDCP: importLayout(snapshot) + SDV->>SDCP: saveLayout() + SDV->>DPP: restoreSnapshot() + end +``` + +--- + +## 4. Design Patterns Summary + +| Pattern | Application | Description | +|---------|-------------|-------------| +| **IoC (Inversion of Control)** | `WidgetSpecResolver` | Dynamic constraint injection | +| **Factory Pattern** | `LayoutItemFactory`, `DashboardWidgetFactory` | Centralized construction logic | +| **Strategy Pattern** | `HeightStrategy` | Interchangeable height calculation | +| **Snapshot/Memento** | Edit Mode | Support undo/cancel operations | +| **Repository Pattern** | SharedPreferences access | Data persistence abstraction | + +--- + +## 5. File Structure + +``` +lib/page/dashboard/ +├── models/ +│ ├── display_mode.dart # Display mode enum +│ ├── height_strategy.dart # Height strategy sealed class +│ ├── widget_grid_constraints.dart # Grid constraints +│ ├── widget_spec.dart # Widget spec +│ ├── dashboard_widget_specs.dart # All widget specs definition +│ ├── dashboard_layout_preferences.dart # Layout preferences +│ └── grid_widget_config.dart # Single widget config +│ +├── providers/ +│ ├── sliver_dashboard_controller_provider.dart # Layout controller +│ ├── dashboard_preferences_provider.dart # Preferences provider +│ └── layout_item_factory.dart # Layout item factory +│ +├── factories/ +│ └── dashboard_widget_factory.dart # Widget construction factory +│ +├── views/ +│ ├── sliver_dashboard_view.dart # Main view +│ └── components/ +│ └── settings/ +│ └── dashboard_layout_settings_panel.dart # Settings panel +│ +└── strategies/ # (Other layout strategies) +``` + +--- + +## 6. Key Technical Points + +### 6.1 Constraint Enforcement + +`_handleResizeEnd()` implements constraint protection, ensuring widgets cannot be resized beyond their spec limits. + +### 6.2 Dynamic Ports Constraints + +Dynamically selects different constraint sets based on hardware state (LAN connection, horizontal layout). + +### 6.3 Snapshot Restoration + +Snapshots layout and preferences when entering edit mode; fully restores on cancel. + +### 6.4 12-Column Responsive System + +All constraints are based on a 12-column design, automatically scaling to different screen widths. + +--- +--- + +# Part 2: Design Principles & Testability Evaluation + +## 1. Design Principles Assessment (SOLID) + +### Single Responsibility Principle (SRP) - **Excellent** +* **`WidgetSpec`**: Solely responsible for defining static component specifications and constraints, containing no business logic. +* **`LayoutItemFactory`**: Focuses strictly on transforming specifications (`WidgetSpec`) into layout items (`LayoutItem`), keeping responsibilities clear and single. +* **`SliverDashboardControllerNotifier`**: Focuses on managing runtime layout state (positions, dimensions) and persistence, handling no UI rendering directly. +* **`DashboardWidgetFactory`**: Responsible for UI component construction and mapping, separated from layout logic. + +### Open/Closed Principle (OCP) - **Good** +* Adding new widgets only requires defining them in `DashboardWidgetSpecs` and adding a case in `DashboardWidgetFactory`, without modifying core layout logic or controllers. +* `HeightStrategy` uses sealed classes. While this limits external extension, it provides excellent type safety and compile-time checking for the finite set of layout strategies required, which fits the current needs well. + +### Dependency Inversion Principle (DIP) - **Good** +* `LayoutItemFactory` acts on dynamic constraint logic (like hardware-dependent Ports specs) via the `WidgetSpecResolver` function interface injection, rather than depending directly on concrete Providers or Stores. This fully decouples the factory logic, making it highly testable. + +--- + +## 2. Testability Assessment + +The overall architecture exhibits high testability, primarily due to the purity of core logic and separation of state. + +| Component | Testability | Description | +|-----------|-------------|-------------| +| **`LayoutItemFactory`** | ⭐⭐⭐⭐⭐ (High) | Core methods like `fromSpec` and `createDefaultLayout` are Pure Functions with no external dependencies, making unit testing extremely easy. | +| **`WidgetGridConstraints`** | ⭐⭐⭐⭐⭐ (High) | Simple data class; constraint calculation logic (`scaleToMaxColumns`) is easy to verify. | +| **`DashboardWidgetSpecs`** | ⭐⭐⭐⭐⭐ (High) | Static constant definitions; tests can ensure all widgets have constraints defined for all DisplayModes to prevent regressions. | +| **`SliverDashboardController`** | ⭐⭐⭐⭐ (Med-High) | Depends on `SharedPreferences` and `Ref`, but can be easily integration-tested using Riverpod's `ProviderContainer` and Mock SharedPreferences. | + +--- + +## 3. Current Status & Gaps + +While the architecture itself is highly conducive to testing, the current codebase **severely lacks unit tests for this module**. + +### Identified Test Gaps +1. **Missing `LayoutItemFactory` Tests**: + - Verify correct default layout generation. + - Verify `fromSpec` correctly handles constraint multiple conversions for all DisplayModes. + - Verify dynamic Ports constraint resolution (`WidgetSpecResolver`) behavior. +2. **Missing `WidgetGridConstraints` Tests**: + - Verify scaling logic from 12 columns to 8/4 columns. + - Verify height calculation strategies (`HeightStrategy`). +3. **Missing `DashboardLayoutPreferences` Tests**: + - Verify JSON serialization/deserialization, especially legacy data migration compatibility. + - Verify sorting and visibility toggle logic. +4. **Missing `SliverDashboardController` Integration Tests**: + - Verify `addWidget` / `removeWidget` / `updateItemConstraints` updates state and triggers save correctly. + - Verify the layout optimization algorithm (`optimizeLayout`) effectively fills gaps. + +## 4. Recommended Actions + +Since the core logic (`LayoutItemFactory`) consists of pure functions, it is recommended to prioritize adding unit tests for this part to ensure a solid layout foundation. + +Recommended test files to add: +- `test/page/dashboard/providers/layout_item_factory_test.dart` +- `test/page/dashboard/models/widget_grid_constraints_test.dart` +- `test/page/dashboard/models/dashboard_layout_preferences_test.dart` + +--- +--- + +# Part 3: Design Deep Dive & Parameters + +## 1. Architecture Redundancy & Dependency Analysis + +After in-depth analysis, the current design **shows no significant code duplication**, but employs a "Dual Track" strategy for **data models and state management**, which is a deliberate design trade-off. + +### 1.1 Dual Layout State +The system maintains two separate layout states serving different use cases: + +1. **Standard Layout**: + - **Source of Truth**: `DashboardPreferences` (`order`, `columnSpan`, `visible`). + - **Characteristics**: Responsive Flow/List layout, adapts to device automatically. + - **Components**: Primarily uses `Composite Widgets` (e.g., `internet_status` containing router info). +2. **Custom Layout**: + - **Source of Truth**: `SliverDashboardController` (Stored in separate JSON key). + - **Characteristics**: Free-form Drag & Drop Bento Grid, absolute positioning (`x`, `y`) & sizing (`w`, `h`). + - **Components**: Primarily uses `Atomic Widgets` (e.g., `internet_status_only` separated from `master_node_info`). + +### 1.2 Partial State Sharing +There is a nuanced design choice here: **Some widgets share state, while others do not**. + +* **Independent State Widgets**: + * Standard Layout uses `PortAndSpeed` (Integrated), Custom Layout uses `Ports` + `SpeedTest` (Separated). + * **Result**: Different IDs, so DisplayMode changes are isolated. This is correct as separated widgets have different display logic. +* **Shared State Widgets**: + * `WiFi Grid`, `Quick Panel`, `VPN` share the same ID across both layouts. + * **Result**: If a user switches WiFi Grid to `Compact` in "Custom Layout", it **will also become Compact** in "Standard Layout". + * **Analysis**: This is not code duplication but an **intended UX design**. It ensures consistency for universal widgets, but developers must be aware of this interaction. + +### 1.3 `GridWidgetConfig` vs. `LayoutItem` +* `GridWidgetConfig.columnSpan`: Used strictly for **Standard Layout** width control. +* `LayoutItem.w`: Used strictly for **Custom Layout** width control. +* **Conclusion**: While seemingly storing two "widths", they apply to completely different layout engines (Flow vs Grid), representing specialized configurations for different strategies rather than redundancy. + +--- + +## 2. Widget Grid Constraints Deep Dive + +`WidgetGridConstraints` defines the behavioral limits of a component within the 12-column grid system. + +### 2.1 Width Parameters (Columns) + +All values are based on a **12-column system**. + +| Parameter | Description | Detailed Usage | +|:---:|:---:|:---| +| **`minColumns`** | **Minimum Columns** | **Shrink Limit**.
The widget width will never go below this value during user resize or responsive scaling, ensuring content readability. | +| **`maxColumns`** | **Maximum Columns** | **Grow Limit**.
Limits how wide a widget can get, preventing small widgets (like switches) from stretching excessively. | +| **`preferredColumns`** | **Preferred/Default** | **Initial Width**.
1. Default width when adding widget.
2. Restored width on layout reset.
3. Base calculation width for Standard Layout. | + +### 2.2 Height Parameters (Rows) + +Height units are **Grid Cells**, not pixels. + +| Parameter | Description | Detailed Usage | +|:---:|:---:|:---| +| **`minHeightRows`** | **Minimum Height Rows** | **Vertical Shrink Limit**.
Ensures enough vertical space to display core content without crushing it. | +| **`maxHeightRows`** | **Maximum Height Rows** | **Vertical Grow Limit**.
Prevents users from stretching widgets too tall, causing layout fragmentation. | +| **`heightStrategy`** | **Height Strategy** | **Preferred Height Algorithm**.
How to calculate the default height when width changes? See table below. | + +### 2.3 HeightStrategy Types + +| Strategy | Parameter | Description | Use Case | +|:---|:---:|:---|:---| +| **`Strict`** | `rows` (double) | **Fixed Rows**.
Height remains constant specifically in row units, regardless of width. | Lists, text-heavy widgets where height is independent of width. | +| **`ColumnBased`** | `multiplier` | **Width Multiplier**.
`Height = Width * Multiplier`.
Height scales proportionally with width. | Blocks needing visual weight maintenance; wider means taller. | +| **`AspectRatio`** | `ratio` | **Aspect Ratio**.
`Width / Height = Ratio`.
Strictly maintains shape. | Topology views or image-based widgets to prevent distortion. | +| **`Intrinsic`** | None | **Content Adaptive**.
Height determined by content (falls back to minHeight in Grid). | Used mostly in Standard Layouts. | + +--- + +## 3. Relationship between HeightStrategy and Min/Max Height (FAQ) + +### 3.1 Is it Duplicate Design? +**No, it differentiates between "Ideal Value" and "Boundary Values".** + +* **`HeightStrategy` (Ideal/Default Value)**: Defines the "perfect" or "default" height of a component when **no external constraints** are applied. + * *Example: "This chart looks best at a 4:3 aspect ratio."* +* **`min/maxHeightRows` (Boundary Values)**: Defines the **allowable range** during resizing. + * *Example: "Regardless of ratio, height cannot be less than 1 row or more than 6 rows."* + +**Why do we need both?** +If we only had Min/Max, the system wouldn't know what height to assign when a widget is "Added" or "Reset" (Should it be min? max? or average?). `HeightStrategy` provides this initial anchor point. + +### 3.2 Detailed Analysis of HeightStrategy Use Cases + +Your observation is excellent! `LayoutItemFactory` indeed allows passing `w` and `h` to override defaults. This introduces the distinction between "**Contextual Default**" and "**Component Native Default**". + +Here are three key scenarios explaining when Strategy is used versus when Factory Override is used: + +#### **Scenario A: Default Layout Initialization (Uses Factory Override)** +* **Context**: When the user opens the App for the first time, or clicks "Reset Layout". +* **Behavior**: Although each widget has its own `HeightStrategy` (ideal height), to make all widgets fit perfectly like a puzzle (Bento Grid), the designer **manually specifies specific heights** in `createDefaultLayout`. +* **Code**: + ```dart + // Even if InternetStatus Strategy suggests height 2 + // To match QuickPanel on the right, designer forces height 2 + items.add(fromSpec(spec, ..., h: 2)); + ``` +* **Conclusion**: `HeightStrategy` is **ignored** here in favor of the designer's "puzzle logic". + +#### **Scenario B: User Adds Widget (Uses HeightStrategy)** +* **Context**: User manually deleted a widget and adds it back from the "Hidden List" via `+`. +* **Behavior**: When "added back," the widget is no longer part of that perfect default puzzle but joins as an independent entity. The system does not specify `h` but lets the widget decide. +* **Code**: (`SliverDashboardController.addWidget`) + ```dart + // No w or h passed, fully relies on Strategy + LayoutItemFactory.fromSpec(spec, ...); + ``` +* **Conclusion**: **Uses `HeightStrategy`** to calculate the most suitable initial size for the widget. + +#### **Scenario C: Display Mode Switching (Uses HeightStrategy)** +* **Context**: User feels `Compact` mode is too small and manually switches to `Normal` mode. +* **Behavior**: The old `Compact` height is no longer valid. The system needs to know what the widget "should" look like in `Normal` mode. +* **Code**: (`SliverDashboardView._updateDisplayMode`) + ```dart + // Get new mode Constraints and calculate Strategy preferred height + final preferredH = constraints.getPreferredHeightCells(...); + // Update widget dimensions + ``` +* **Conclusion**: **Uses `HeightStrategy`** to determine the height for the new mode. + +### 4.3 Why doesn't Width (Columns) need a Strategy? +You asked a very keen question: "Why is Height so complex, while Width is just a simple `preferredColumns` number?" + +**Core Reason: Grid Systems are typically "Width-Dominant".** + +In modern Responsive Design: + +1. **Width is the "Independent Variable"**: + * Width is usually dictated by the **External Environment** (Screen Size) or **User Intent** (Manual Resizing). + * For example: On mobile, width is forced to 100%; on tablet, maybe 50%. + * Therefore, Width doesn't need complex internal calculation; it is typically "given" or "constrained" (Min/Max Constraints). + +2. **Height is the "Dependent Variable"**: + * Height helps accommodate the consequences of the chosen Width. + * When Width narrows (Mobile), text wraps, and images shrink. To display full content, Height must **extend downwards**. + * This is why Height needs a `Strategy` (Algorithm). It must constantly answer: "Given that my Width is now X, how tall do I need to be to function properly?" + +--- + +## 4. Design Trade-offs + +### 4.1 Why not just use `preferredRows`? (Like `preferredColumns`) +You raised a great alternative: Since we have `preferredColumns` for width, why not just have a static `preferredRows` integer for height? + +This touches on the choice between **Static Values** and **Dynamic Algorithms**. + +#### **Reason 1: Height often depends on Width (Aspect Ratio Dependency)** +In a grid system, Width is usually the "Independent Variable" (user decides width), while Height is the "Dependent Variable". +* **Case (Topology)**: + * This map-like widget needs to maintain a visual aspect ratio. + * Suppose we set a static `preferredRows = 4`. + * When width is 4 columns, 4x4 (Square) looks good. + * If the user expands width to 8 columns, a static height of 4 creates an 8x4 (Wide Rectangle) shape, crushing the internal graph or leaving massive whitespace. + * **Solution**: Using `AspectRatioStrategy(1.0)` ensures that when width becomes 8, height automatically calculates to 8, preparing the perfect square container. + +#### **Reason 2: Dynamic Hardware State** +* **Case (Ports)**: + * The height of the Ports widget depends on **LAN port count**. + * **With LAN**: Needs two rows of content (WAN + LAN), requiring more height. + * **No LAN (Mesh Node)**: Only needs one row (WAN/Backhaul), height is halved. + * Using a static `preferredRows` would force us to write `if (hasLan) 4 else 2` logic scattered in the Layout layer. + * **Solution**: `HeightStrategy.intrinsic()` allows the widget to report height based on internal content (LAN presence), encapsulating business logic inside the component rather than the layout engine. + +### 4.2 Why not use Layout Factory's preferred values for Scenarios B/C? +`LayoutItemFactory` can indeed accept an `h` parameter, but in **Scenario B (Add)** and **Scenario C (Mode Switch)**, we lack "Context". + +* **In Scenario A (Default Layout)**: The designer has a "God View" of the entire canvas, knowing exactly that `Internet` needs to be height 2 to align with `QuickPanel` on the right. +* **In Scenarios B/C**: This is "Runtime". The widget is added dynamically. The system doesn't know what's next to it or the user's layout intent. The safest default implementation is to revert to the component's "Nature (`HeightStrategy`)", letting it appear in its most optimal intrinsic form, then letting the user adjust. + +--- + +# Part 4: Testing Strategy & Implementation + +## 1. Methodology + +The Dashboard Custom Layout adopts the "Test Pyramid" strategy, focusing heavily on unit tests for pure logic components, supplemented by integration tests for critical paths. + +### 1.1 Test Levels +1. **Unit Tests**: + * **Goal**: Target "Pure Functions" and "Data Models" with no external dependencies. + * **SUT (System Under Test)**: `LayoutItemFactory`, `WidgetGridConstraints`, `DashboardLayoutPreferences`. + * **Advantage**: Extremely fast execution, covers all boundary conditions (e.g., 12-column scaling algorithms, JSON migration). +2. **Integration Tests**: + * **Goal**: Verify component interactions and Side Effects, especially data persistence. + * **SUT**: `SliverDashboardController` + `SharedPreferences (Mock)`. + * **Advantage**: Ensures critical user features like Save, Load, and Reset function correctly. + +--- + +## 2. Implementation Details + +### 2.1 Models Layer Testing +Logic verification for data models: + +* **`WidgetGridConstraints`**: + * Verify `scaleToMaxColumns`: Ensure correct proportional scaling across Desktop (12), Tablet (8), and Mobile (4) columns. + * Verify `HeightStrategy`: Ensure correct height calculation for all strategies (`Strict`, `AspectRatio`, `Intrinsic`). +* **`DashboardLayoutPreferences`**: + * Verify JSON Serialization/Deserialization: Ensure `GridWidgetConfig` persists correctly. + * Verify Legacy Migration: Ensure seamless migration from old `widgetModes` format to the new structure. + +### 2.2 Factories Layer Testing +Verification of core transformation logic: + +* **`LayoutItemFactory`**: + * Verify `fromSpec`: Test transformation from `WidgetSpec` to `LayoutItem`, including `min/max` constraint propagation. + * Verify Override Logic: Test if passing manual `w`/`h` correctly overrides defaults (Scenario A). + * Verify `createDefaultLayout`: Ensure default layout contains the correct widget list and initial positions. + * Verify IoC Injection: Test if `WidgetSpecResolver` successfully injects dynamic constraints (e.g., simulating Ports hardware state). + +### 2.3 Integration Layer Testing +Verification of interactions between controller and storage layer: + +* **`SliverDashboardController`**: + * **Environment Simulation**: Use `mocktail` to mock Riverpod `Ref` and `SharedPreferences.setMockInitialValues` for disk storage. + * **CRUD Verification**: Test `addWidget` and `removeWidget` to confirm they update both in-memory `state` and on-disk `SharedPreferences`. + * **Constraint Updates**: Test `updateItemConstraints`, verifying that if a new `minW` exceeds current `w`, the `clamp` mechanism automatically corrects the size. + * **Reset Flow**: Verify `resetLayout` correctly clears disk data and restores the default layout. + +--- + +## 3. How to Run Tests + +Execute using standard Flutter test commands: + +```bash +# Run all Dashboard related tests +flutter test test/page/dashboard/ + +# Run specific test files +flutter test test/page/dashboard/providers/layout_item_factory_test.dart +flutter test test/page/dashboard/models/widget_grid_constraints_test.dart +flutter test test/page/dashboard/providers/sliver_dashboard_controller_test.dart +``` + +--- + +## 4. Verification Results + +As of the latest version, all unit and integration tests for the Custom Layout have passed, confirming the stability and correctness of the architecture. + diff --git a/lib/constants/_constants.dart b/lib/constants/_constants.dart index 451abaf1e..09b9b1b6c 100644 --- a/lib/constants/_constants.dart +++ b/lib/constants/_constants.dart @@ -4,3 +4,4 @@ export 'error_code.dart'; export 'cloud_const.dart'; export 'build_config.dart'; export 'color_const.dart'; +export 'defaults.dart'; diff --git a/lib/constants/build_config.dart b/lib/constants/build_config.dart index 36b8a1466..86a94c5c9 100644 --- a/lib/constants/build_config.dart +++ b/lib/constants/build_config.dart @@ -46,6 +46,8 @@ class BuildConfig { static bool showColumnOverlay = const bool.fromEnvironment('overlay', defaultValue: false); static const bool caLogin = bool.fromEnvironment('ca', defaultValue: false); + static const bool customLayout = + bool.fromEnvironment('custom_layout', defaultValue: false); static const int refreshTimeInterval = int.fromEnvironment('refresh_time', defaultValue: 60); diff --git a/lib/constants/defaults.dart b/lib/constants/defaults.dart new file mode 100644 index 000000000..3bc94df4c --- /dev/null +++ b/lib/constants/defaults.dart @@ -0,0 +1,4 @@ +/// Default configuration values used throughout the application. +const defaultAdminPassword = 'admin'; +const defaultWiFiSecurity = + 'WPA2'; // Example, adding for context if needed later diff --git a/lib/core/cloud/linksys_cloud_repository.dart b/lib/core/cloud/linksys_cloud_repository.dart index b23a60bba..113688424 100644 --- a/lib/core/cloud/linksys_cloud_repository.dart +++ b/lib/core/cloud/linksys_cloud_repository.dart @@ -29,7 +29,7 @@ import 'package:privacy_gui/core/utils/ip_getter/ip_getter.dart'; final cloudRepositoryProvider = Provider((ref) => LinksysCloudRepository( httpClient: LinksysHttpClient(getHost: () { if (BuildConfig.forceCommandType == ForceCommand.local) { - var localIP = getLocalIp(ref); + var localIP = getLocalIp(ref.read); localIP = localIP.startsWith('http') ? localIP : 'https://$localIP'; return localIP; } @@ -38,7 +38,7 @@ final cloudRepositoryProvider = Provider((ref) => LinksysCloudRepository( if (routerType == RouterType.others) { return null; } else { - var localIP = getLocalIp(ref); + var localIP = getLocalIp(ref.read); localIP = localIP.startsWith('http') ? localIP : 'https://$localIP'; return localIP; } diff --git a/lib/core/cloud/linksys_device_cloud_service.dart b/lib/core/cloud/linksys_device_cloud_service.dart index 627f49ae0..42bcccf8b 100644 --- a/lib/core/cloud/linksys_device_cloud_service.dart +++ b/lib/core/cloud/linksys_device_cloud_service.dart @@ -18,7 +18,7 @@ import 'package:privacy_gui/core/utils/ip_getter/ip_getter.dart'; final deviceCloudServiceProvider = Provider((ref) => DeviceCloudService( httpClient: LinksysHttpClient(getHost: () { if (BuildConfig.forceCommandType == ForceCommand.local) { - var localIP = getLocalIp(ref); + var localIP = getLocalIp(ref.read); localIP = localIP.startsWith('http') ? localIP : 'https://$localIP'; return localIP; } @@ -27,7 +27,7 @@ final deviceCloudServiceProvider = Provider((ref) => DeviceCloudService( if (routerType == RouterType.others) { return null; } else { - var localIP = getLocalIp(ref); + var localIP = getLocalIp(ref.read); localIP = localIP.startsWith('http') ? localIP : 'https://$localIP'; return localIP; } diff --git a/lib/core/data/providers/device_info_provider.dart b/lib/core/data/providers/device_info_provider.dart index 481a50ab9..fb9a28f4f 100644 --- a/lib/core/data/providers/device_info_provider.dart +++ b/lib/core/data/providers/device_info_provider.dart @@ -1,7 +1,8 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/jnap_device_info_raw.dart'; import 'package:privacy_gui/core/jnap/models/soft_sku_settings.dart'; import 'package:privacy_gui/core/data/providers/polling_provider.dart'; import 'package:privacy_gui/core/data/providers/polling_helpers.dart'; @@ -15,7 +16,7 @@ final deviceInfoProvider = Provider((ref) { final deviceInfoOutput = getPollingOutput(pollingData, JNAPAction.getDeviceInfo); if (deviceInfoOutput != null) { - deviceInfo = NodeDeviceInfo.fromJson(deviceInfoOutput); + deviceInfo = JnapDeviceInfoRaw.fromJson(deviceInfoOutput).toUIModel(); } final skuOutput = diff --git a/lib/core/data/providers/firmware_update_provider.dart b/lib/core/data/providers/firmware_update_provider.dart index 3dfad1c93..89478c420 100644 --- a/lib/core/data/providers/firmware_update_provider.dart +++ b/lib/core/data/providers/firmware_update_provider.dart @@ -48,6 +48,16 @@ class FirmwareUpdateNotifier extends Notifier { state = state.copyWith(settings: updatedSettings); } + /// Sets the firmware update policy to auto or manual. + /// This method encapsulates the JNAP policy constants for View layer usage. + Future setAutoUpdateEnabled(bool enabled) async { + await setFirmwareUpdatePolicy( + enabled + ? FirmwareUpdateSettings.firmwareUpdatePolicyAuto + : FirmwareUpdateSettings.firmwareUpdatePolicyManual, + ); + } + Future fetchAvailableFirmwareUpdates() async { final cachedCandidates = ref.read(firmwareUpdateCandidateProvider); final statusRecord = await _firmwareUpdateService diff --git a/lib/core/data/providers/firmware_update_state.dart b/lib/core/data/providers/firmware_update_state.dart index 12bc3e0b0..37164c709 100644 --- a/lib/core/data/providers/firmware_update_state.dart +++ b/lib/core/data/providers/firmware_update_state.dart @@ -13,6 +13,10 @@ class FirmwareUpdateState extends Equatable { final bool isRetryMaxReached; final bool isWaitingChildrenAfterUpdating; + /// Returns true if firmware update policy is set to auto + bool get isAutoUpdateEnabled => + settings.updatePolicy == FirmwareUpdateSettings.firmwareUpdatePolicyAuto; + const FirmwareUpdateState({ required this.settings, required this.nodesStatus, diff --git a/lib/core/data/providers/session_provider.dart b/lib/core/data/providers/session_provider.dart index fe4554f3d..381a7f6bf 100644 --- a/lib/core/data/providers/session_provider.dart +++ b/lib/core/data/providers/session_provider.dart @@ -1,6 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/constants/_constants.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/models/device_info.dart'; import 'package:privacy_gui/core/data/providers/device_info_provider.dart'; import 'package:privacy_gui/core/data/services/session_service.dart'; import 'package:privacy_gui/core/utils/bench_mark.dart'; @@ -91,6 +91,50 @@ class SessionNotifier extends Notifier { await pref.setString(pSelectedNetworkId, networkId); ref.read(selectedNetworkIdProvider.notifier).state = networkId; } + + /// Force fetches device info from router, bypassing all caches. + /// + /// This method always makes an API call to get fresh device info, + /// regardless of any cached values. Use this when you need guaranteed + /// fresh data, such as: + /// - During initial session setup (prepare dashboard) + /// - After configuration changes + /// - When validating router connectivity + /// + /// Returns: Fresh [NodeDeviceInfo] from router + /// + /// Throws: [ServiceError] on API failure + Future forceFetchDeviceInfo() async { + final benchMark = BenchMarkLogger(name: 'forceFetchDeviceInfo'); + benchMark.start(); + final service = ref.read(sessionServiceProvider); + final nodeDeviceInfo = await service.forceFetchDeviceInfo(); + benchMark.end(); + return nodeDeviceInfo; + } + + /// Fetches device info and initializes router services (better actions). + /// + /// This method should be called during login/session initialization to: + /// 1. Fetch fresh device info from router + /// 2. Configure the JNAP action system for the connected router + /// 3. Return device info for UI display + /// + /// Use this instead of [checkDeviceInfo] when you need to ensure + /// buildBetterActions is called with the current router's services. + /// + /// Returns: [NodeDeviceInfo] UI model + /// + /// Throws: [ServiceError] on API failure + Future fetchDeviceInfoAndInitializeServices() async { + final benchMark = + BenchMarkLogger(name: 'fetchDeviceInfoAndInitializeServices'); + benchMark.start(); + final service = ref.read(sessionServiceProvider); + final nodeDeviceInfo = await service.fetchDeviceInfoAndInitializeServices(); + benchMark.end(); + return nodeDeviceInfo; + } } /// State provider for the currently selected network ID. diff --git a/lib/core/data/providers/side_effect_provider.dart b/lib/core/data/providers/side_effect_provider.dart index 2ff7c7957..e54f474a8 100644 --- a/lib/core/data/providers/side_effect_provider.dart +++ b/lib/core/data/providers/side_effect_provider.dart @@ -33,7 +33,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:privacy_gui/constants/_constants.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/jnap/command/base_command.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/jnap_device_info_raw.dart'; import 'package:privacy_gui/core/jnap/models/wan_status.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; @@ -338,7 +339,8 @@ class SideEffectNotifier extends Notifier { cacheLevel: CacheLevel.noCache, timeoutMs: 3000, retries: 0) - .then((response) => NodeDeviceInfo.fromJson(response.output)); + .then((response) => + JnapDeviceInfoRaw.fromJson(response.output).toUIModel()); _updateProgress( int currentRetry, diff --git a/lib/core/data/services/session_service.dart b/lib/core/data/services/session_service.dart index 7840ffd3f..03e837af5 100644 --- a/lib/core/data/services/session_service.dart +++ b/lib/core/data/services/session_service.dart @@ -1,7 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/jnap_device_info_raw.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; @@ -39,7 +40,8 @@ class SessionService { fetchRemote: true, retries: 0, ); - final nodeDeviceInfo = NodeDeviceInfo.fromJson(result.output); + final nodeDeviceInfo = + JnapDeviceInfoRaw.fromJson(result.output).toUIModel(); if (expectedSerialNumber.isNotEmpty && expectedSerialNumber != nodeDeviceInfo.serialNumber) { @@ -80,7 +82,7 @@ class SessionService { retries: 0, timeoutMs: 3000, ); - return NodeDeviceInfo.fromJson(result.output); + return JnapDeviceInfoRaw.fromJson(result.output).toUIModel(); } on JNAPError catch (e) { throw _mapJnapError(e); } @@ -95,4 +97,62 @@ class SessionService { _ => UnexpectedError(originalError: error, message: error.result), }; } + + // === Force Fetch Device Info === + + /// Force fetches device info from router, bypassing all caches. + /// + /// Unlike [checkDeviceInfo], this method always makes an API call + /// regardless of cached values. Use this when you need guaranteed + /// fresh data, such as during initial session setup or after + /// configuration changes. + /// + /// Returns: Fresh [NodeDeviceInfo] from router API call + /// + /// Throws: [ServiceError] on API failure + Future forceFetchDeviceInfo() async { + try { + final result = await _routerRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + ); + return JnapDeviceInfoRaw.fromJson(result.output).toUIModel(); + } on JNAPError catch (e) { + throw _mapJnapError(e); + } + } + + // === Session Initialization === + + /// Fetches device info and initializes router services. + /// + /// This method: + /// 1. Fetches fresh device info from router + /// 2. Calls [buildBetterActions] with the services list + /// 3. Returns the UI model for display + /// + /// Use this method during login/session initialization to ensure + /// the JNAP action system is properly configured for the connected router. + /// + /// Returns: [NodeDeviceInfo] UI model + /// + /// Throws: [ServiceError] on API failure + Future fetchDeviceInfoAndInitializeServices() async { + try { + final result = await _routerRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + retries: 0, + timeoutMs: 3000, + ); + final rawInfo = JnapDeviceInfoRaw.fromJson(result.output); + + // Initialize better actions with router's supported services + buildBetterActions(rawInfo.services); + + return rawInfo.toUIModel(); + } on JNAPError catch (e) { + throw _mapJnapError(e); + } + } } diff --git a/lib/core/jnap/models/jnap_device_info_raw.dart b/lib/core/jnap/models/jnap_device_info_raw.dart new file mode 100644 index 000000000..0d54ea03b --- /dev/null +++ b/lib/core/jnap/models/jnap_device_info_raw.dart @@ -0,0 +1,68 @@ +import 'package:privacy_gui/core/models/device_info.dart'; + +/// Raw DeviceInfo response from JNAP protocol layer +/// +/// This class is used to parse the raw response from JNAP GetDeviceInfo API. +/// It contains the complete JNAP response structure, including the services list. +/// +/// Use [toUIModel] method to convert to [NodeDeviceInfo] for UI layer usage. +class JnapDeviceInfoRaw { + const JnapDeviceInfoRaw({ + required this.modelNumber, + required this.firmwareVersion, + required this.description, + required this.firmwareDate, + required this.manufacturer, + required this.serialNumber, + required this.hardwareVersion, + required this.services, + }); + + factory JnapDeviceInfoRaw.fromJson(Map json) { + return JnapDeviceInfoRaw( + modelNumber: json['modelNumber'] ?? '', + firmwareVersion: json['firmwareVersion'] ?? '', + description: json['description'] ?? '', + firmwareDate: json['firmwareDate'] ?? '', + manufacturer: json['manufacturer'] ?? '', + serialNumber: json['serialNumber'] ?? '', + hardwareVersion: json['hardwareVersion'] ?? '', + services: List.from(json['services'] ?? []), + ); + } + + final String modelNumber; + final String firmwareVersion; + final String description; + final String firmwareDate; + final String manufacturer; + final String serialNumber; + final String hardwareVersion; + final List services; + + /// Converts to UI Model + NodeDeviceInfo toUIModel() { + return NodeDeviceInfo( + modelNumber: modelNumber, + firmwareVersion: firmwareVersion, + description: description, + firmwareDate: firmwareDate, + manufacturer: manufacturer, + serialNumber: serialNumber, + hardwareVersion: hardwareVersion, + ); + } + + Map toJson() { + return { + 'modelNumber': modelNumber, + 'firmwareVersion': firmwareVersion, + 'description': description, + 'firmwareDate': firmwareDate, + 'manufacturer': manufacturer, + 'serialNumber': serialNumber, + 'hardwareVersion': hardwareVersion, + 'services': services, + }; + } +} diff --git a/lib/core/jnap/models/network.dart b/lib/core/jnap/models/network.dart index d26b31328..eefd3bdfd 100644 --- a/lib/core/jnap/models/network.dart +++ b/lib/core/jnap/models/network.dart @@ -1,6 +1,6 @@ // import 'package:equatable/equatable.dart'; // import 'package:privacy_gui/core/jnap/models/device.dart'; -// import 'package:privacy_gui/core/jnap/models/device_info.dart'; +// import 'package:privacy_gui/core/models/device_info.dart'; // import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; // import 'package:privacy_gui/core/jnap/models/radio_info.dart'; // import 'package:privacy_gui/core/jnap/models/wan_status.dart'; diff --git a/lib/core/jnap/router_repository.dart b/lib/core/jnap/router_repository.dart index 2e21af42d..9a20da1fd 100644 --- a/lib/core/jnap/router_repository.dart +++ b/lib/core/jnap/router_repository.dart @@ -46,7 +46,7 @@ class CommandWrap { Map data; } -const defaultAdminPassword = 'admin'; +// defaultAdminPassword moved to constants/defaults.dart final routerRepositoryProvider = Provider((ref) { return RouterRepository(ref); @@ -455,7 +455,7 @@ class RouterRepository { extension RouterRepositoryUtil on RouterRepository { String getLocalIP() { - return getLocalIp(ref); + return getLocalIp(ref.read); } String _getNetworkId() { diff --git a/lib/core/jnap/models/device_info.dart b/lib/core/models/device_info.dart similarity index 74% rename from lib/core/jnap/models/device_info.dart rename to lib/core/models/device_info.dart index bee8057d3..ca650c1c6 100644 --- a/lib/core/jnap/models/device_info.dart +++ b/lib/core/models/device_info.dart @@ -1,5 +1,11 @@ import 'package:equatable/equatable.dart'; +/// UI layer DeviceInfo Model +/// +/// This class is used to pass device information between Provider and View layers. +/// It does not contain JNAP protocol details (such as services list). +/// +/// For raw JNAP response, use [JnapDeviceInfoRaw]. class NodeDeviceInfo extends Equatable { const NodeDeviceInfo({ required this.modelNumber, @@ -9,22 +15,8 @@ class NodeDeviceInfo extends Equatable { required this.manufacturer, required this.serialNumber, required this.hardwareVersion, - required this.services, }); - factory NodeDeviceInfo.fromJson(Map json) { - return NodeDeviceInfo( - modelNumber: json['modelNumber'], - firmwareVersion: json['firmwareVersion'], - description: json['description'], - firmwareDate: json['firmwareDate'], - manufacturer: json['manufacturer'], - serialNumber: json['serialNumber'], - hardwareVersion: json['hardwareVersion'], - services: List.from(json['services']), - ); - } - final String modelNumber; final String firmwareVersion; final String description; @@ -32,7 +24,6 @@ class NodeDeviceInfo extends Equatable { final String manufacturer; final String serialNumber; final String hardwareVersion; - final List services; Map toJson() { return { @@ -43,7 +34,6 @@ class NodeDeviceInfo extends Equatable { 'manufacturer': manufacturer, 'serialNumber': serialNumber, 'hardwareVersion': hardwareVersion, - 'services': services, }..removeWhere((key, value) => value == null); } @@ -55,7 +45,6 @@ class NodeDeviceInfo extends Equatable { String? manufacturer, String? serialNumber, String? hardwareVersion, - List? services, }) { return NodeDeviceInfo( modelNumber: modelNumber ?? this.modelNumber, @@ -65,7 +54,6 @@ class NodeDeviceInfo extends Equatable { manufacturer: manufacturer ?? this.manufacturer, serialNumber: serialNumber ?? this.serialNumber, hardwareVersion: hardwareVersion ?? this.hardwareVersion, - services: services ?? this.services, ); } diff --git a/lib/core/models/device_list_item.dart b/lib/core/models/device_list_item.dart new file mode 100644 index 000000000..2804939cb --- /dev/null +++ b/lib/core/models/device_list_item.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/data/providers/device_manager_state.dart'; + +class DeviceListItem extends Equatable { + final String deviceId; + final String name; + final String icon; + final String upstreamDevice; + final String upstreamDeviceID; + final String upstreamIcon; + final String ipv4Address; + final String ipv6Address; + final String macAddress; + final String manufacturer; + final String model; + final String operatingSystem; + final String band; + final int signalStrength; + final bool isOnline; + final bool isWired; + final WifiConnectionType type; + final String ssid; + final bool isMLO; + + const DeviceListItem({ + this.deviceId = '', + this.name = '', + this.icon = '', + this.upstreamDevice = '', + this.upstreamDeviceID = '', + this.upstreamIcon = '', + this.ipv4Address = '', + this.ipv6Address = '', + this.macAddress = '', + this.manufacturer = '', + this.model = '', + this.operatingSystem = '', + this.band = '', + this.signalStrength = 0, + this.isOnline = false, + this.isWired = false, + this.type = WifiConnectionType.main, + this.ssid = '', + this.isMLO = false, + }); + + DeviceListItem copyWith({ + String? deviceId, + String? name, + String? icon, + String? upstreamDevice, + String? upstreamDeviceID, + String? upstreamIcon, + String? ipv4Address, + String? ipv6Address, + String? macAddress, + String? manufacturer, + String? model, + String? operatingSystem, + String? band, + int? signalStrength, + bool? isOnline, + bool? isWired, + WifiConnectionType? type, + String? ssid, + bool? isMLO, + }) { + return DeviceListItem( + deviceId: deviceId ?? this.deviceId, + name: name ?? this.name, + icon: icon ?? this.icon, + upstreamDevice: upstreamDevice ?? this.upstreamDevice, + upstreamDeviceID: upstreamDeviceID ?? this.upstreamDeviceID, + upstreamIcon: upstreamIcon ?? this.upstreamIcon, + ipv4Address: ipv4Address ?? this.ipv4Address, + ipv6Address: ipv6Address ?? this.ipv6Address, + macAddress: macAddress ?? this.macAddress, + manufacturer: manufacturer ?? this.manufacturer, + model: model ?? this.model, + operatingSystem: operatingSystem ?? this.operatingSystem, + band: band ?? this.band, + signalStrength: signalStrength ?? this.signalStrength, + isOnline: isOnline ?? this.isOnline, + isWired: isWired ?? this.isWired, + type: type ?? this.type, + ssid: ssid ?? this.ssid, + isMLO: isMLO ?? this.isMLO, + ); + } + + @override + List get props { + return [ + deviceId, + name, + icon, + upstreamDevice, + upstreamDeviceID, + upstreamIcon, + ipv4Address, + ipv6Address, + macAddress, + manufacturer, + model, + operatingSystem, + band, + signalStrength, + isOnline, + isWired, + type, + ssid, + isMLO, + ]; + } + + Map toMap() { + return { + 'deviceId': deviceId, + 'name': name, + 'icon': icon, + 'upstreamDevice': upstreamDevice, + 'upstreamDeviceID': upstreamDeviceID, + 'upstreamIcon': upstreamIcon, + 'ipv4Address': ipv4Address, + 'ipv6Address': ipv6Address, + 'macAddress': macAddress, + 'manufacturer': manufacturer, + 'model': model, + 'operatingSystem': operatingSystem, + 'band': band, + 'signalStrength': signalStrength, + 'isOnline': isOnline, + 'isWired': isWired, + 'type': type.value, + 'ssid': ssid, + 'isMLO': isMLO, + }; + } + + factory DeviceListItem.fromMap(Map map) { + return DeviceListItem( + deviceId: map['deviceId'] as String, + name: map['name'] as String, + icon: map['icon'] as String, + upstreamDevice: map['upstreamDevice'] as String, + upstreamDeviceID: map['upstreamDeviceID'] as String, + upstreamIcon: map['upstreamIcon'] as String, + ipv4Address: map['ipv4Address'] as String, + ipv6Address: map['ipv6Address'] as String, + macAddress: map['macAddress'] as String, + manufacturer: map['manufacturer'] as String, + model: map['model'] as String, + operatingSystem: map['operatingSystem'] as String, + band: map['band'] as String, + signalStrength: map['signalStrength'] as int, + isOnline: map['isOnline'] as bool, + isWired: map['isWired'] as bool, + type: + WifiConnectionType.values.firstWhereOrNull((e) => e == map['type']) ?? + WifiConnectionType.main, + ssid: map['ssid'] as String, + isMLO: map['isMLO'] ?? false, + ); + } + + String toJson() => json.encode(toMap()); + + factory DeviceListItem.fromJson(String source) => + DeviceListItem.fromMap(json.decode(source) as Map); +} diff --git a/lib/core/models/privacy_settings.dart b/lib/core/models/privacy_settings.dart new file mode 100644 index 000000000..72061069b --- /dev/null +++ b/lib/core/models/privacy_settings.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; + +enum MacFilterMode { + disabled, + allow, + deny, + ; + + static MacFilterMode reslove(String value) => switch (value.toLowerCase()) { + 'disabled' => MacFilterMode.disabled, + 'allow' => MacFilterMode.allow, + 'deny' => MacFilterMode.deny, + _ => MacFilterMode.disabled, + }; + + bool get isEnabled => this != MacFilterMode.disabled; +} + +class InstantPrivacyStatus extends Equatable { + final MacFilterMode mode; + + const InstantPrivacyStatus({required this.mode}); + + factory InstantPrivacyStatus.init() { + return const InstantPrivacyStatus(mode: MacFilterMode.disabled); + } + + @override + List get props => [mode]; + + InstantPrivacyStatus copyWith({MacFilterMode? mode}) { + return InstantPrivacyStatus(mode: mode ?? this.mode); + } + + Map toMap() { + return { + 'mode': mode.name, + }; + } + + factory InstantPrivacyStatus.fromMap(Map map) { + return InstantPrivacyStatus( + mode: MacFilterMode.reslove(map['mode']), + ); + } + + String toJson() => json.encode(toMap()); + + factory InstantPrivacyStatus.fromJson(String source) => + InstantPrivacyStatus.fromMap(json.decode(source) as Map); +} + +class InstantPrivacySettings extends Equatable { + final MacFilterMode mode; + final List macAddresses; + final List denyMacAddresses; + final int maxMacAddresses; + final List bssids; + final String? myMac; + + @override + List get props => [ + mode, + macAddresses, + denyMacAddresses, + maxMacAddresses, + bssids, + myMac, + ]; + + const InstantPrivacySettings({ + required this.mode, + required this.macAddresses, + required this.denyMacAddresses, + required this.maxMacAddresses, + this.bssids = const [], + this.myMac, + }); + + factory InstantPrivacySettings.init() { + return const InstantPrivacySettings( + mode: MacFilterMode.disabled, + macAddresses: [], + denyMacAddresses: [], + maxMacAddresses: 32, + ); + } + + InstantPrivacySettings copyWith({ + MacFilterMode? mode, + List? macAddresses, + List? denyMacAddresses, + int? maxMacAddresses, + List? bssids, + String? myMac, + }) { + return InstantPrivacySettings( + mode: mode ?? this.mode, + macAddresses: macAddresses ?? this.macAddresses, + denyMacAddresses: denyMacAddresses ?? this.denyMacAddresses, + maxMacAddresses: maxMacAddresses ?? this.maxMacAddresses, + bssids: bssids ?? this.bssids, + myMac: myMac ?? this.myMac, + ); + } + + Map toMap() { + return { + 'mode': mode.name, + 'macAddresses': macAddresses, + 'denyMacAddresses': denyMacAddresses, + 'maxMacAddresses': maxMacAddresses, + 'bssids': bssids, + 'myMac': myMac, + }..removeWhere((key, value) => value == null); + } + + factory InstantPrivacySettings.fromMap(Map map) { + return InstantPrivacySettings( + mode: MacFilterMode.reslove(map['mode']), + macAddresses: List.from(map['macAddresses']), + denyMacAddresses: List.from(map['denyMacAddresses']), + maxMacAddresses: map['maxMacAddresses'] as int, + bssids: map['bssids'] == null ? [] : List.from(map['bssids']), + myMac: map['myMac'], + ); + } + + String toJson() => json.encode(toMap()); + + factory InstantPrivacySettings.fromJson(String source) => + InstantPrivacySettings.fromMap( + json.decode(source) as Map); + + @override + bool get stringify => true; +} diff --git a/lib/core/utils/ip_getter/get_local_ip.dart b/lib/core/utils/ip_getter/get_local_ip.dart index e79802079..98f940900 100644 --- a/lib/core/utils/ip_getter/get_local_ip.dart +++ b/lib/core/utils/ip_getter/get_local_ip.dart @@ -1,7 +1,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -String getLocalIp(Ref ref) => +/// Type definition for a provider reader function. +/// +/// This allows both [Ref] (from providers) and [WidgetRef] (from widgets) +/// to be used with the same function, since both have a compatible `read` method. +/// +/// Example usage: +/// ```dart +/// // In a Provider +/// final ip = getLocalIp(ref.read); +/// +/// // In a Widget +/// final ip = getLocalIp(ref.read); +/// ``` +typedef ProviderReader = T Function(ProviderListenable); + +String getLocalIp(ProviderReader read) => throw UnsupportedError('[Platform ERROR] Get Local IP'); -String getFullLocation(Ref ref) => +String getFullLocation(ProviderReader read) => throw UnsupportedError('[Platform ERROR] Get Full Location'); diff --git a/lib/core/utils/ip_getter/ip_getter.dart b/lib/core/utils/ip_getter/ip_getter.dart index 24bfe5571..98fb3b0e5 100644 --- a/lib/core/utils/ip_getter/ip_getter.dart +++ b/lib/core/utils/ip_getter/ip_getter.dart @@ -6,7 +6,11 @@ /// ```dart /// import 'package:privacy_gui/core/utils/ip_getter/ip_getter.dart'; /// -/// final ip = getLocalIp(ref); +/// // In a Provider (with Ref) +/// final ip = getLocalIp(ref.read); +/// +/// // In a Widget (with WidgetRef) +/// final ip = getLocalIp(ref.read); /// ``` /// /// The correct platform implementation is automatically selected at compile time. diff --git a/lib/core/utils/ip_getter/mobile_get_local_ip.dart b/lib/core/utils/ip_getter/mobile_get_local_ip.dart index 204d5db08..a8d5a7f50 100644 --- a/lib/core/utils/ip_getter/mobile_get_local_ip.dart +++ b/lib/core/utils/ip_getter/mobile_get_local_ip.dart @@ -1,8 +1,9 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/providers/connectivity/connectivity_provider.dart'; -String getLocalIp(Ref ref) => - ref.read(connectivityProvider).connectivityInfo.gatewayIp ?? ''; +import 'get_local_ip.dart'; -String getFullLocation(Ref ref) => +String getLocalIp(ProviderReader read) => + read(connectivityProvider).connectivityInfo.gatewayIp ?? ''; + +String getFullLocation(ProviderReader read) => throw UnsupportedError('[Platform ERROR] Get Full Location'); diff --git a/lib/core/utils/ip_getter/web_get_local_ip.dart b/lib/core/utils/ip_getter/web_get_local_ip.dart index 9f2002567..7e0f8f237 100644 --- a/lib/core/utils/ip_getter/web_get_local_ip.dart +++ b/lib/core/utils/ip_getter/web_get_local_ip.dart @@ -1,14 +1,16 @@ import 'package:web/web.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/constants/build_config.dart'; import 'package:privacy_gui/providers/connectivity/connectivity_provider.dart'; -String getLocalIp(Ref ref) => BuildConfig.forceCommandType == ForceCommand.local - ? window.location.host - : (ref.read(connectivityProvider).connectivityInfo.gatewayIp ?? ''); +import 'get_local_ip.dart'; -String getFullLocation(Ref ref) => +String getLocalIp(ProviderReader read) => + BuildConfig.forceCommandType == ForceCommand.local + ? window.location.host + : (read(connectivityProvider).connectivityInfo.gatewayIp ?? ''); + +String getFullLocation(ProviderReader read) => BuildConfig.forceCommandType == ForceCommand.local ? window.location.toString() : ''; diff --git a/lib/demo/demo_app.dart b/lib/demo/demo_app.dart index 9765f49e3..1dd4a3b6a 100644 --- a/lib/demo/demo_app.dart +++ b/lib/demo/demo_app.dart @@ -13,7 +13,7 @@ import 'package:privacy_gui/l10n/gen/app_localizations.dart'; import 'package:ui_kit_library/ui_kit.dart'; import 'package:privacy_gui/theme/theme_json_config.dart'; -import 'widgets/demo_theme_settings_fab.dart'; +import 'package:privacy_gui/demo/providers/demo_theme_config_provider.dart'; /// Demo version of the Linksys application. /// @@ -50,13 +50,13 @@ class _DemoLinksysAppState extends ConsumerState { final systemLocaleStr = Intl.getCurrentLocale(); final systemLocale = Locale(getLanguageData(systemLocaleStr)['value']); - // Watch demo theme config for dynamic updates + // Watch configuration and UI state final demoConfig = ref.watch(demoThemeConfigProvider); // Use demo seedColor if set, otherwise fall back to appSettings final effectiveSeedColor = demoConfig.seedColor ?? appSettings.themeColor; - // Build dynamic theme from demo config + // Build dynamic theme (Same as before) final themeData = _buildThemeData( brightness: appSettings.themeMode == ThemeMode.dark ? Brightness.dark @@ -65,18 +65,29 @@ class _DemoLinksysAppState extends ConsumerState { globalOverlay: demoConfig.globalOverlay, visualEffects: demoConfig.visualEffects, userThemeColor: effectiveSeedColor, + primary: demoConfig.primary, + secondary: demoConfig.secondary, + tertiary: demoConfig.tertiary, + surface: demoConfig.surface, + error: demoConfig.error, + overrides: demoConfig.overrides, ); - + // ... Dark Theme Build ... final darkTheme = _buildThemeData( brightness: Brightness.dark, style: demoConfig.style, globalOverlay: demoConfig.globalOverlay, visualEffects: demoConfig.visualEffects, userThemeColor: effectiveSeedColor, + primary: demoConfig.primary, + secondary: demoConfig.secondary, + tertiary: demoConfig.tertiary, + surface: demoConfig.surface, + error: demoConfig.error, + overrides: demoConfig.overrides, ); - // Update GetIt with the new dark theme so BottomNavigationMenu (TopBar) picks it up. - // This is safe because GetIt lookup is synchronous and we update before subtree builds. + // Update GetIt ... if (getIt.isRegistered(instanceName: 'darkThemeData')) { getIt.unregister(instanceName: 'darkThemeData'); } @@ -96,18 +107,20 @@ class _DemoLinksysAppState extends ConsumerState { context, Stack( children: [ + // Main App Content (Full Screen) + // Note: Theme Panel and FAB are now provided by the ShellRoute + // in demo_router_provider.dart AppRootContainer( route: _currentRoute, child: child, ), + // Demo mode banner const Positioned( top: 0, right: 0, child: _DemoModeBanner(), ), - // Theme settings FAB - const DemoThemeSettingsFab(), ], ), ), @@ -125,6 +138,13 @@ class _DemoLinksysAppState extends ConsumerState { GlobalOverlayType? globalOverlay, int visualEffects = AppThemeConfig.effectAll, Color? userThemeColor, + // Add override params + Color? primary, + Color? secondary, + Color? tertiary, + Color? surface, + Color? error, + AppThemeOverrides? overrides, }) { // Get base JSON config final themeConfig = getIt(); @@ -148,6 +168,21 @@ class _DemoLinksysAppState extends ConsumerState { '#${userThemeColor.toARGB32().toRadixString(16).substring(2)}'; } + // Inject Granular Color Overrides (Standard Layer) + String? colorToHex(Color? c) => + c != null ? '#${c.toARGB32().toRadixString(16).substring(2)}' : null; + + if (primary != null) dynamicJson['primary'] = colorToHex(primary); + if (secondary != null) dynamicJson['secondary'] = colorToHex(secondary); + if (tertiary != null) dynamicJson['tertiary'] = colorToHex(tertiary); + if (surface != null) dynamicJson['surface'] = colorToHex(surface); + if (error != null) dynamicJson['error'] = colorToHex(error); + + // Inject Advanced Overrides (Semantic & Component Layer) + if (overrides != null) { + dynamicJson['overrides'] = overrides.toJson(); + } + // Build design theme final designTheme = CustomDesignTheme.fromJson(dynamicJson); diff --git a/lib/demo/providers/demo_overrides.dart b/lib/demo/providers/demo_overrides.dart index d2de7e0c1..ca59ec9d6 100644 --- a/lib/demo/providers/demo_overrides.dart +++ b/lib/demo/providers/demo_overrides.dart @@ -19,6 +19,8 @@ import 'package:privacy_gui/providers/auth/auth_provider.dart'; import 'package:privacy_gui/providers/connectivity/connectivity_info.dart'; import 'package:privacy_gui/providers/connectivity/connectivity_provider.dart'; import 'package:privacy_gui/providers/connectivity/connectivity_state.dart'; +import 'package:privacy_gui/route/router_provider.dart'; +import 'demo_router_provider.dart'; /// Demo provider overrides for the Demo application. /// @@ -48,6 +50,9 @@ class DemoProviders { // 4. Polling: Auto-start pollingProvider.overrideWith(() => _DemoPollingNotifier()), + // 5. Router: Wrap with ShellRoute for Theme Panel Overlay + routerProvider.overrideWithProvider(demoRouterProvider), + // 5. Geolocation: Bypass cloud service call geolocationProvider.overrideWith(() => _DemoGeolocationNotifier()), diff --git a/lib/demo/providers/demo_router_provider.dart b/lib/demo/providers/demo_router_provider.dart new file mode 100644 index 000000000..391206dca --- /dev/null +++ b/lib/demo/providers/demo_router_provider.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/route/router_provider.dart'; +import 'package:privacy_gui/route/router_logger.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:privacy_gui/demo/theme_studio/theme_studio_fab.dart'; +import 'package:privacy_gui/demo/theme_studio/theme_studio_panel.dart'; +import 'package:privacy_gui/demo/providers/demo_ui_provider.dart'; + +/// Overrides the main routerProvider for the Demo Application. +/// +/// This custom router wraps all existing application routes [appRoutes] in a +/// [ShellRoute]. This allows the persistent Theme Studio Panel and FAB +/// to overlay the application content while sharing the same navigation context. +/// +/// Crucially, because the Panel is part of the Route hierarchy (via Shell), +/// any [showDialog] call (which pushes a route to the Root Navigator) +/// will naturally sit ON TOP of this ShellRoute, solving Z-Index issues where +/// dialogs would otherwise appear underneath the Panel. +final demoRouterProvider = Provider((ref) { + final router = RouterNotifier(ref); + + return GoRouter( + navigatorKey: routerKey, + refreshListenable: router, + observers: [ref.read(routerLoggerProvider)], + initialLocation: '/', + routes: [ + ShellRoute( + builder: (context, state, child) { + return Stack( + fit: StackFit.expand, // Ensure stack fills the screen + children: [ + // Main Application Page Content + child, + + // Theme Studio Panel (Animated Overlay) + Consumer( + builder: (context, ref, _) { + final isOpen = ref.watch(demoUIProvider).isThemePanelOpen; + return AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOutCubic, + top: 0, + bottom: 0, + right: isOpen ? 0 : -500, + width: 500, + child: const Material( + elevation: 16, + child: ThemeStudioPanel(), + ), + ); + }, + ), + + // Theme Studio FAB + const Positioned( + bottom: 16, + right: 16, + child: ThemeStudioFab(), + ), + ], + ); + }, + routes: appRoutes, // Reuse the standard app routes + ), + ], + // Reuse the exact same redirect logic as the main app + redirect: (context, state) { + if (state.matchedLocation == '/') { + return router.autoConfigurationLogic(state); + } else if (state.matchedLocation == RoutePath.localLoginPassword) { + router.autoConfigurationLogic(state); + return router.redirectLogic(state); + } else if (state.matchedLocation.startsWith('/pnp')) { + return router.goPnpPath(state); + } else if (state.matchedLocation.startsWith('/autoParentFirstLogin')) { + return state.uri.toString(); + } + return router.redirectLogic(state); + }, + debugLogDiagnostics: true, + ); +}); diff --git a/lib/demo/providers/demo_theme_config_provider.dart b/lib/demo/providers/demo_theme_config_provider.dart new file mode 100644 index 000000000..af204862f --- /dev/null +++ b/lib/demo/providers/demo_theme_config_provider.dart @@ -0,0 +1,597 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Provider for dynamic theme configuration in demo mode. +final demoThemeConfigProvider = + StateNotifierProvider((ref) { + return DemoThemeConfigNotifier(); +}); + +/// Demo theme configuration state. +class DemoThemeConfig { + final String style; + final GlobalOverlayType? globalOverlay; + final int visualEffects; + final Color? seedColor; + + // Granular Material Colors (Standard Layer) + final Color? primary; + final Color? secondary; + final Color? tertiary; + final Color? outline; + final Color? surface; + final Color? error; + + // Advanced Overrides (Semantic & Component Layer) + final AppThemeOverrides? overrides; + + const DemoThemeConfig({ + this.style = 'glass', + this.globalOverlay, + this.visualEffects = AppThemeConfig.effectAll, + this.seedColor, + this.primary, + this.secondary, + this.tertiary, + this.outline, + this.surface, + this.error, + this.overrides, + }); + + /// Creates a copy of this config with the given fields replaced. + DemoThemeConfig copyWith({ + String? style, + GlobalOverlayType? globalOverlay, + bool clearOverlay = false, + int? visualEffects, + Color? seedColor, + bool clearSeedColor = false, + Color? primary, + bool clearPrimary = false, + Color? secondary, + bool clearSecondary = false, + Color? tertiary, + bool clearTertiary = false, + Color? outline, + bool clearOutline = false, + Color? surface, + bool clearSurface = false, + Color? error, + bool clearError = false, + AppThemeOverrides? overrides, + bool clearOverrides = false, + }) { + return DemoThemeConfig( + style: style ?? this.style, + globalOverlay: + clearOverlay ? null : (globalOverlay ?? this.globalOverlay), + visualEffects: visualEffects ?? this.visualEffects, + seedColor: clearSeedColor ? null : (seedColor ?? this.seedColor), + primary: clearPrimary ? null : (primary ?? this.primary), + secondary: clearSecondary ? null : (secondary ?? this.secondary), + tertiary: clearTertiary ? null : (tertiary ?? this.tertiary), + outline: clearOutline ? null : (outline ?? this.outline), + surface: clearSurface ? null : (surface ?? this.surface), + error: clearError ? null : (error ?? this.error), + overrides: clearOverrides ? null : (overrides ?? this.overrides), + ); + } + + /// Serialize to JSON for export + Map toJson() { + String? colorToHex(Color? color) { + if (color == null) return null; + return '#${color.toARGB32().toRadixString(16).padLeft(8, '0').toUpperCase()}'; + } + + return { + 'style': style, + 'globalOverlay': globalOverlay?.name, + 'visualEffects': visualEffects, + 'seedColor': colorToHex(seedColor), + 'primary': colorToHex(primary), + 'secondary': colorToHex(secondary), + 'tertiary': colorToHex(tertiary), + 'outline': colorToHex(outline), + 'surface': colorToHex(surface), + 'error': colorToHex(error), + 'overrides': overrides?.toJson(), + }; + } + + /// deserialize from JSON for import + factory DemoThemeConfig.fromJson(Map json) { + Color? parseHex(dynamic value) { + if (value is String) { + try { + final hex = value.replaceAll('#', ''); + if (hex.length == 6) { + return Color(int.parse('FF$hex', radix: 16)); + } else if (hex.length == 8) { + return Color(int.parse(hex, radix: 16)); + } + } catch (_) {} + } + return null; + } + + // Parse overrides using UI Kit's native structure logic would be ideal, + // but AppThemeConfig.fromJson logic is internal. + // We can reconstruct AppThemeOverrides manually or depend on its fromJson. + // Checking AppThemeOverrides.fromJson... it exists in UI Kit. + // We need to parse the map carefully. + AppThemeOverrides? parsedOverrides; + if (json['overrides'] is Map) { + final ovJson = json['overrides'] as Map; + // Reconstruct sub-objects (assuming UI Kit classes have fromJson or we use helper) + // Actually standard usage allows AppThemeOverrides.fromJson(ovJson) + try { + parsedOverrides = AppThemeOverrides.fromJson(ovJson); + } catch (_) { + // Fallback manual parsing if needed, but lets rely on standard + final semanticJson = ovJson['semantic'] as Map?; + final paletteJson = ovJson['palette'] as Map?; + final surfaceJson = ovJson['surface'] as Map?; + final componentJson = ovJson['component'] as Map?; + + parsedOverrides = AppThemeOverrides( + semantic: semanticJson != null + ? SemanticOverrides.fromJson(semanticJson) + : null, + palette: paletteJson != null + ? PaletteColorOverride.fromJson(paletteJson) + : null, + surface: surfaceJson != null + ? SurfaceOverrides.fromJson(surfaceJson) + : null, + component: componentJson != null + ? ComponentOverrides.fromJson(componentJson) + : null, + ); + } + } + + return DemoThemeConfig( + style: json['style'] as String? ?? 'glass', + globalOverlay: json['globalOverlay'] != null + ? GlobalOverlayType.values.firstWhere( + (e) => e.name == json['globalOverlay'], + orElse: () => GlobalOverlayType.none, + ) + : null, + visualEffects: json['visualEffects'] as int? ?? AppThemeConfig.effectAll, + seedColor: parseHex(json['seedColor']), + primary: parseHex(json['primary']), + secondary: parseHex(json['secondary']), + tertiary: parseHex(json['tertiary']), + outline: parseHex(json['outline']), + surface: parseHex(json['surface']), + error: parseHex(json['error']), + overrides: parsedOverrides, + ); + } +} + +/// Notifier for demo theme configuration. +class DemoThemeConfigNotifier extends StateNotifier { + DemoThemeConfigNotifier() : super(const DemoThemeConfig()); + + // === Import / Export === + + void importConfig(Map json) { + try { + state = DemoThemeConfig.fromJson(json); + } catch (e) { + debugPrint('Error importing theme config: $e'); + // In a real app we might want to throw or return false + } + } + + void importConfigString(String jsonStr) { + try { + final json = jsonDecode(jsonStr); + importConfig(json); + } catch (e) { + debugPrint('Error parsing theme config json: $e'); + } + } + + void reset() { + state = const DemoThemeConfig(); + } + + // === Basic === + + void setStyle(String style) { + state = state.copyWith(style: style); + } + + void setGlobalOverlay(GlobalOverlayType? overlay) { + state = state.copyWith( + globalOverlay: overlay, + clearOverlay: overlay == null, + ); + } + + void setVisualEffects(int effects) { + state = state.copyWith(visualEffects: effects); + } + + void toggleVisualEffect(int flag) { + final current = state.visualEffects; + final newEffects = (current & flag) != 0 + ? current & ~flag // Remove flag + : current | flag; // Add flag + state = state.copyWith(visualEffects: newEffects); + } + + void setSeedColor(Color? color) { + state = state.copyWith( + seedColor: color, + clearSeedColor: color == null, + ); + } + + // === Granular Colors === + + void setPrimary(Color? color) { + state = state.copyWith( + primary: color, + clearPrimary: color == null, + ); + } + + void setSecondary(Color? color) { + state = state.copyWith( + secondary: color, + clearSecondary: color == null, + ); + } + + void setTertiary(Color? color) { + state = state.copyWith( + tertiary: color, + clearTertiary: color == null, + ); + } + + void setOutline(Color? color) { + state = state.copyWith( + outline: color, + clearOutline: color == null, + ); + } + + void setSurface(Color? color) { + state = state.copyWith( + surface: color, + clearSurface: color == null, + ); + } + + void setError(Color? color) { + state = state.copyWith( + error: color, + clearError: color == null, + ); + } + + // === Semantics === + + void updateSemanticOverrides({ + Color? success, + Color? warning, + Color? danger, + Color? info, + }) { + final currentSemantics = state.overrides?.semantic; + final newSemantics = SemanticOverrides( + success: success ?? currentSemantics?.success, + warning: warning ?? currentSemantics?.warning, + error: danger ?? currentSemantics?.error, + info: info ?? currentSemantics?.info, + ); + + state = state.copyWith( + overrides: AppThemeOverrides( + semantic: newSemantics, + palette: state.overrides?.palette, + surface: state.overrides?.surface, + component: state.overrides?.component, + ), + ); + } + + // === Component Overrides === + + void updateLoaderColors({ + Color? primaryColor, + Color? backgroundColor, + LoaderType? type, + }) { + final current = state.overrides?.component?.loader; + final newLoader = LoaderColorOverride( + type: type ?? current?.type, + primaryColor: primaryColor ?? current?.primaryColor, + backgroundColor: backgroundColor ?? current?.backgroundColor, + ); + _updateComponent((c) => ComponentOverrides( + loader: newLoader, + skeleton: c?.skeleton, + topology: c?.topology, + toggle: c?.toggle, + toast: c?.toast, + input: c?.input, + button: c?.button, + dialog: c?.dialog, + navigation: c?.navigation, + tabs: c?.tabs, + menu: c?.menu, + appBar: c?.appBar, + stepper: c?.stepper, + breadcrumb: c?.breadcrumb, + chipGroup: c?.chipGroup, + carousel: c?.carousel, + expansionPanel: c?.expansionPanel, + table: c?.table, + slideAction: c?.slideAction, + sheet: c?.sheet, + badge: c?.badge, + avatar: c?.avatar, + card: c?.card, + divider: c?.divider, + pinInput: c?.pinInput, + passwordInput: c?.passwordInput, + rangeInput: c?.rangeInput, + styledText: c?.styledText, + bottomBar: c?.bottomBar, + pageMenu: c?.pageMenu, + )); + } + + void updateSkeletonColors({ + Color? baseColor, + Color? highlightColor, + SkeletonAnimationType? animationType, + }) { + final current = state.overrides?.component?.skeleton; + final newSkeleton = SkeletonColorOverride( + animationType: animationType ?? current?.animationType, + baseColor: baseColor ?? current?.baseColor, + highlightColor: highlightColor ?? current?.highlightColor, + ); + _updateComponent((c) => ComponentOverrides( + skeleton: newSkeleton, + loader: c?.loader, + topology: c?.topology, + toggle: c?.toggle, + toast: c?.toast, + input: c?.input, + button: c?.button, + dialog: c?.dialog, + navigation: c?.navigation, + tabs: c?.tabs, + menu: c?.menu, + appBar: c?.appBar, + stepper: c?.stepper, + breadcrumb: c?.breadcrumb, + chipGroup: c?.chipGroup, + carousel: c?.carousel, + expansionPanel: c?.expansionPanel, + table: c?.table, + slideAction: c?.slideAction, + sheet: c?.sheet, + badge: c?.badge, + avatar: c?.avatar, + card: c?.card, + divider: c?.divider, + pinInput: c?.pinInput, + passwordInput: c?.passwordInput, + rangeInput: c?.rangeInput, + styledText: c?.styledText, + bottomBar: c?.bottomBar, + pageMenu: c?.pageMenu, + )); + } + + void updateToggleColors({ + Color? activeTrackColor, + Color? activeThumbColor, + Color? inactiveTrackColor, + Color? inactiveThumbColor, + }) { + final current = state.overrides?.component?.toggle; + final newToggle = ToggleColorOverride( + activeTrackColor: activeTrackColor ?? current?.activeTrackColor, + activeThumbColor: activeThumbColor ?? current?.activeThumbColor, + inactiveTrackColor: inactiveTrackColor ?? current?.inactiveTrackColor, + inactiveThumbColor: inactiveThumbColor ?? current?.inactiveThumbColor, + ); + _updateComponent((c) => ComponentOverrides( + toggle: newToggle, + loader: c?.loader, + skeleton: c?.skeleton, + topology: c?.topology, + toast: c?.toast, + input: c?.input, + button: c?.button, + dialog: c?.dialog, + navigation: c?.navigation, + tabs: c?.tabs, + menu: c?.menu, + appBar: c?.appBar, + stepper: c?.stepper, + breadcrumb: c?.breadcrumb, + chipGroup: c?.chipGroup, + carousel: c?.carousel, + expansionPanel: c?.expansionPanel, + table: c?.table, + slideAction: c?.slideAction, + sheet: c?.sheet, + badge: c?.badge, + avatar: c?.avatar, + card: c?.card, + divider: c?.divider, + pinInput: c?.pinInput, + passwordInput: c?.passwordInput, + rangeInput: c?.rangeInput, + styledText: c?.styledText, + bottomBar: c?.bottomBar, + pageMenu: c?.pageMenu, + )); + } + + void updateToastColors({ + Color? backgroundColor, + Color? textColor, + Color? borderColor, + }) { + final current = state.overrides?.component?.toast; + final newToast = ToastColorOverride( + backgroundColor: backgroundColor ?? current?.backgroundColor, + textColor: textColor ?? current?.textColor, + borderColor: borderColor ?? current?.borderColor, + ); + _updateComponent((c) => ComponentOverrides( + toast: newToast, + loader: c?.loader, + skeleton: c?.skeleton, + topology: c?.topology, + toggle: c?.toggle, + input: c?.input, + button: c?.button, + dialog: c?.dialog, + navigation: c?.navigation, + tabs: c?.tabs, + menu: c?.menu, + appBar: c?.appBar, + stepper: c?.stepper, + breadcrumb: c?.breadcrumb, + chipGroup: c?.chipGroup, + carousel: c?.carousel, + expansionPanel: c?.expansionPanel, + table: c?.table, + slideAction: c?.slideAction, + sheet: c?.sheet, + badge: c?.badge, + avatar: c?.avatar, + card: c?.card, + divider: c?.divider, + pinInput: c?.pinInput, + passwordInput: c?.passwordInput, + rangeInput: c?.rangeInput, + styledText: c?.styledText, + bottomBar: c?.bottomBar, + pageMenu: c?.pageMenu, + )); + } + + void updateTopologyColors({ + Color? gatewayNormalBackgroundColor, + Color? gatewayNormalBorderColor, + Color? gatewayNormalIconColor, + Color? gatewayNormalGlowColor, + Color? extenderNormalBackgroundColor, + Color? extenderNormalBorderColor, + Color? extenderNormalIconColor, + Color? extenderNormalGlowColor, + Color? clientNormalBackgroundColor, + Color? clientNormalBorderColor, + Color? clientNormalIconColor, + Color? clientNormalGlowColor, + Color? ethernetLinkColor, + Color? wifiStrongColor, + Color? wifiMediumColor, + Color? wifiWeakColor, + LinkAnimationType? ethernetAnimationType, + LinkAnimationType? wifiAnimationType, + MeshNodeRendererType? gatewayRenderer, + MeshNodeRendererType? extenderRenderer, + MeshNodeRendererType? clientRenderer, + }) { + final current = state.overrides?.component?.topology; + final newTopology = TopologyColorOverride( + gatewayRenderer: gatewayRenderer ?? current?.gatewayRenderer, + extenderRenderer: extenderRenderer ?? current?.extenderRenderer, + clientRenderer: clientRenderer ?? current?.clientRenderer, + gatewayNormalBackgroundColor: + gatewayNormalBackgroundColor ?? current?.gatewayNormalBackgroundColor, + gatewayNormalBorderColor: + gatewayNormalBorderColor ?? current?.gatewayNormalBorderColor, + gatewayNormalIconColor: + gatewayNormalIconColor ?? current?.gatewayNormalIconColor, + gatewayNormalGlowColor: + gatewayNormalGlowColor ?? current?.gatewayNormalGlowColor, + extenderNormalBackgroundColor: extenderNormalBackgroundColor ?? + current?.extenderNormalBackgroundColor, + extenderNormalBorderColor: + extenderNormalBorderColor ?? current?.extenderNormalBorderColor, + extenderNormalIconColor: + extenderNormalIconColor ?? current?.extenderNormalIconColor, + extenderNormalGlowColor: + extenderNormalGlowColor ?? current?.extenderNormalGlowColor, + clientNormalBackgroundColor: + clientNormalBackgroundColor ?? current?.clientNormalBackgroundColor, + clientNormalBorderColor: + clientNormalBorderColor ?? current?.clientNormalBorderColor, + clientNormalIconColor: + clientNormalIconColor ?? current?.clientNormalIconColor, + clientNormalGlowColor: + clientNormalGlowColor ?? current?.clientNormalGlowColor, + ethernetLinkColor: ethernetLinkColor ?? current?.ethernetLinkColor, + wifiStrongColor: wifiStrongColor ?? current?.wifiStrongColor, + wifiMediumColor: wifiMediumColor ?? current?.wifiMediumColor, + wifiWeakColor: wifiWeakColor ?? current?.wifiWeakColor, + ethernetAnimationType: + ethernetAnimationType ?? current?.ethernetAnimationType, + wifiAnimationType: wifiAnimationType ?? current?.wifiAnimationType, + ); + _updateComponent((c) => ComponentOverrides( + topology: newTopology, + loader: c?.loader, + skeleton: c?.skeleton, + toggle: c?.toggle, + toast: c?.toast, + input: c?.input, + button: c?.button, + dialog: c?.dialog, + navigation: c?.navigation, + tabs: c?.tabs, + menu: c?.menu, + appBar: c?.appBar, + stepper: c?.stepper, + breadcrumb: c?.breadcrumb, + chipGroup: c?.chipGroup, + carousel: c?.carousel, + expansionPanel: c?.expansionPanel, + table: c?.table, + slideAction: c?.slideAction, + sheet: c?.sheet, + badge: c?.badge, + avatar: c?.avatar, + card: c?.card, + divider: c?.divider, + pinInput: c?.pinInput, + passwordInput: c?.passwordInput, + rangeInput: c?.rangeInput, + styledText: c?.styledText, + bottomBar: c?.bottomBar, + pageMenu: c?.pageMenu, + )); + } + + void _updateComponent( + ComponentOverrides Function(ComponentOverrides? current) builder) { + state = state.copyWith( + overrides: AppThemeOverrides( + semantic: state.overrides?.semantic, + palette: state.overrides?.palette, + surface: state.overrides?.surface, + component: builder(state.overrides?.component), + ), + ); + } +} diff --git a/lib/demo/providers/demo_ui_provider.dart b/lib/demo/providers/demo_ui_provider.dart new file mode 100644 index 000000000..6dabaf9b1 --- /dev/null +++ b/lib/demo/providers/demo_ui_provider.dart @@ -0,0 +1,31 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// UI state for the Demo App (non-persistent). +class DemoUIState { + final bool isThemePanelOpen; + + const DemoUIState({this.isThemePanelOpen = false}); + + DemoUIState copyWith({bool? isThemePanelOpen}) { + return DemoUIState( + isThemePanelOpen: isThemePanelOpen ?? this.isThemePanelOpen, + ); + } +} + +class DemoUIStateNotifier extends StateNotifier { + DemoUIStateNotifier() : super(const DemoUIState()); + + void toggleThemePanel() { + state = state.copyWith(isThemePanelOpen: !state.isThemePanelOpen); + } + + void setThemePanelOpen(bool isOpen) { + state = state.copyWith(isThemePanelOpen: isOpen); + } +} + +final demoUIProvider = + StateNotifierProvider((ref) { + return DemoUIStateNotifier(); +}); diff --git a/lib/demo/theme_studio/tabs/components_tab.dart b/lib/demo/theme_studio/tabs/components_tab.dart new file mode 100644 index 000000000..407b3ec86 --- /dev/null +++ b/lib/demo/theme_studio/tabs/components_tab.dart @@ -0,0 +1,318 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/demo/providers/demo_theme_config_provider.dart'; +import '../widgets/section_header.dart'; +import '../widgets/compact_color_picker.dart'; + +class ComponentsTab extends ConsumerWidget { + const ComponentsTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final config = ref.watch(demoThemeConfigProvider); + final components = config.overrides?.component; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- Loader --- + const SectionHeader(title: 'Loader Style'), + const SizedBox(height: 16), + + // Circular Loaders + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelMedium('Circular Types'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: LoaderType.values + .where((t) => t.isCircularType) + .map((type) { + final isSelected = components?.loader?.type == type; + return AppTag( + label: type.name.toUpperCase(), + isSelected: isSelected, + onTap: () { + ref + .read(demoThemeConfigProvider.notifier) + .updateLoaderColors(type: type); + }, + ); + }).toList(), + ), + ], + ), + ), + const SizedBox(width: 16), + // Circular Preview + Column( + children: [ + AppText.bodySmall('Circular'), + const SizedBox(height: 4), + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: AppLoader(variant: LoaderVariant.circular), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 16), + + // Linear Loaders + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelMedium('Linear Types'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: LoaderType.values + .where((t) => t.isLinearType) + .map((type) { + final isSelected = components?.loader?.type == type; + return AppTag( + label: type.name.toUpperCase(), + isSelected: isSelected, + onTap: () { + ref + .read(demoThemeConfigProvider.notifier) + .updateLoaderColors(type: type); + }, + ); + }).toList(), + ), + ], + ), + ), + const SizedBox(width: 16), + // Linear Preview + Column( + children: [ + AppText.bodySmall('Linear'), + const SizedBox(height: 4), + Container( + width: 120, + height: 60, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(8), + ), + child: const Center( + child: AppLoader(variant: LoaderVariant.linear), + ), + ), + ], + ), + ], + ), + + const SizedBox(height: 12), + // Loader Colors (Shared) + Row( + children: [ + CompactColorPicker( + label: 'Primary', + color: components?.loader?.primaryColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateLoaderColors(primaryColor: c), + ), + const SizedBox(width: 8), + CompactColorPicker( + label: 'Background', + color: components?.loader?.backgroundColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateLoaderColors(backgroundColor: c), + ), + ], + ), + + // --- Skeleton --- + const SectionHeader(title: 'Skeleton Style'), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: SkeletonAnimationType.values.map((type) { + final isSelected = components?.skeleton?.animationType == type; + return AppTag( + label: type.name.toUpperCase(), + isSelected: isSelected, + onTap: () { + ref + .read(demoThemeConfigProvider.notifier) + .updateSkeletonColors(animationType: type); + }, + ); + }).toList(), + ), + const SizedBox(height: 12), + Row( + children: [ + CompactColorPicker( + label: 'Base', + color: components?.skeleton?.baseColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateSkeletonColors(baseColor: c), + ), + const SizedBox(width: 12), + CompactColorPicker( + label: 'Highlight', + color: components?.skeleton?.highlightColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateSkeletonColors(highlightColor: c), + ), + ], + ), + const SizedBox(height: 12), + // Live Preview + AppCard( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + AppSkeleton(width: 120, height: 16), + SizedBox(height: 8), + AppSkeleton(width: double.infinity, height: 12), + SizedBox(height: 4), + AppSkeleton(width: 200, height: 12), + ], + ), + ), + const SizedBox(height: 24), + + // --- Toggle --- + const SectionHeader(title: 'Toggle Style'), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelSmall('Active'), + const SizedBox(height: 4), + Row( + children: [ + CompactColorPicker( + label: 'Track', + color: components?.toggle?.activeTrackColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateToggleColors(activeTrackColor: c), + ), + const SizedBox(width: 8), + CompactColorPicker( + label: 'Thumb', + color: components?.toggle?.activeThumbColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateToggleColors(activeThumbColor: c), + ), + ], + ), + ], + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelSmall('Inactive'), + const SizedBox(height: 4), + Row( + children: [ + CompactColorPicker( + label: 'Track', + color: components?.toggle?.inactiveTrackColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateToggleColors(inactiveTrackColor: c), + ), + const SizedBox(width: 8), + CompactColorPicker( + label: 'Thumb', + color: components?.toggle?.inactiveThumbColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateToggleColors(inactiveThumbColor: c), + ), + ], + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + children: const [ + AppSwitch(value: true, onChanged: null), + SizedBox(width: 16), + AppSwitch(value: false, onChanged: null), + ], + ), + const SizedBox(height: 24), + + // --- Toast --- + const SectionHeader(title: 'Toast Style'), + const SizedBox(height: 8), + Row( + children: [ + CompactColorPicker( + label: 'Background', + color: components?.toast?.backgroundColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateToastColors(backgroundColor: c), + ), + const SizedBox(width: 12), + CompactColorPicker( + label: 'Text', + color: components?.toast?.textColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateToastColors(textColor: c), + ), + const SizedBox(width: 12), + CompactColorPicker( + label: 'Border', + color: components?.toast?.borderColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateToastColors(borderColor: c), + ), + ], + ), + const SizedBox(height: 24), + ], + ); + } +} diff --git a/lib/demo/theme_studio/tabs/design_tab.dart b/lib/demo/theme_studio/tabs/design_tab.dart new file mode 100644 index 000000000..ae9aee3f8 --- /dev/null +++ b/lib/demo/theme_studio/tabs/design_tab.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/demo/providers/demo_theme_config_provider.dart'; +import '../widgets/section_header.dart'; + +class DesignTab extends ConsumerWidget { + const DesignTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final config = ref.watch(demoThemeConfigProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader(title: 'Visual Style'), + const SizedBox(height: 8), + _buildStyleSelector(context, ref, config), + const SizedBox(height: 24), + const SectionHeader(title: 'Global Overlay'), + const SizedBox(height: 8), + _buildOverlaySelector(context, ref, config), + const SizedBox(height: 24), + const SectionHeader(title: 'Visual Effects'), + const SizedBox(height: 8), + _buildVisualEffectsToggles(context, ref, config), + ], + ); + } + + Widget _buildStyleSelector( + BuildContext context, WidgetRef ref, DemoThemeConfig config) { + final styles = ['glass', 'aurora', 'brutal', 'flat', 'neumorphic', 'pixel']; + return Wrap( + spacing: 8, + runSpacing: 8, + children: styles.map((style) { + return AppTag( + label: style[0].toUpperCase() + style.substring(1), + isSelected: config.style == style, + onTap: () { + ref.read(demoThemeConfigProvider.notifier).setStyle(style); + }, + ); + }).toList(), + ); + } + + Widget _buildOverlaySelector( + BuildContext context, WidgetRef ref, DemoThemeConfig config) { + final overlays = [ + (null, 'None'), + (GlobalOverlayType.snow, 'Snow'), + (GlobalOverlayType.hacker, 'Matrix'), + (GlobalOverlayType.noiseOverlay, 'Noise'), + (GlobalOverlayType.crtShader, 'CRT'), + (GlobalOverlayType.auroraGlow, 'Aurora'), + (GlobalOverlayType.liquid, 'Liquid'), + ]; + + return Wrap( + spacing: 8, + runSpacing: 8, + children: overlays.map((item) { + final (overlay, label) = item; + return AppTag( + label: label, + isSelected: config.globalOverlay == overlay, + onTap: () { + ref + .read(demoThemeConfigProvider.notifier) + .setGlobalOverlay(overlay); + }, + ); + }).toList(), + ); + } + + Widget _buildVisualEffectsToggles( + BuildContext context, WidgetRef ref, DemoThemeConfig config) { + final effects = [ + (AppThemeConfig.effectDirectionalShadow, 'Shadow'), + (AppThemeConfig.effectGradientBorder, 'Gradient'), + (AppThemeConfig.effectBlur, 'Blur'), + (AppThemeConfig.effectNoiseTexture, 'Noise'), + (AppThemeConfig.effectShimmer, 'Shimmer'), + (AppThemeConfig.effectTopologyAnimation, 'Topology'), + ]; + + return Wrap( + spacing: 8, + runSpacing: 8, + children: effects.map((item) { + final (flag, label) = item; + final isEnabled = (config.visualEffects & flag) != 0; + return AppTag( + label: label, + isSelected: isEnabled, + onTap: () { + ref.read(demoThemeConfigProvider.notifier).toggleVisualEffect(flag); + }, + ); + }).toList(), + ); + } +} diff --git a/lib/demo/theme_studio/tabs/palette_tab.dart b/lib/demo/theme_studio/tabs/palette_tab.dart new file mode 100644 index 000000000..4efa78253 --- /dev/null +++ b/lib/demo/theme_studio/tabs/palette_tab.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/demo/providers/demo_theme_config_provider.dart'; +import '../widgets/section_header.dart'; +import '../widgets/color_circle.dart'; +import '../widgets/color_picker_dialog.dart'; + +class PaletteTab extends ConsumerWidget { + const PaletteTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final config = ref.watch(demoThemeConfigProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader(title: 'Seed Color (Base Palette)'), + const SizedBox(height: 8), + _buildSeedColorSelector(context, ref, config), + const SizedBox(height: 24), + const SectionHeader(title: 'Advanced Overrides'), + const SizedBox(height: 16), + _buildColorOverrideRow( + context: context, + label: 'Primary', + color: config.primary, + onChanged: (c) => + ref.read(demoThemeConfigProvider.notifier).setPrimary(c), + ), + const SizedBox(height: 12), + _buildColorOverrideRow( + context: context, + label: 'Secondary', + color: config.secondary, + onChanged: (c) => + ref.read(demoThemeConfigProvider.notifier).setSecondary(c), + ), + const SizedBox(height: 12), + _buildColorOverrideRow( + context: context, + label: 'Tertiary', + color: config.tertiary, + onChanged: (c) => + ref.read(demoThemeConfigProvider.notifier).setTertiary(c), + ), + const SizedBox(height: 12), + _buildColorOverrideRow( + context: context, + label: 'Surface', + color: config.surface, + onChanged: (c) => + ref.read(demoThemeConfigProvider.notifier).setSurface(c), + ), + const SizedBox(height: 12), + _buildColorOverrideRow( + context: context, + label: 'Outline', + color: config.outline, + onChanged: (c) => + ref.read(demoThemeConfigProvider.notifier).setOutline(c), + ), + const SizedBox(height: 12), + _buildColorOverrideRow( + context: context, + label: 'Error', + color: config.error, + onChanged: (c) => + ref.read(demoThemeConfigProvider.notifier).setError(c), + ), + ], + ); + } + + Widget _buildSeedColorSelector( + BuildContext context, WidgetRef ref, DemoThemeConfig config) { + final presetColors = [ + const Color(0xFF0870EA), // Blue (default) + const Color(0xFF8E08EA), // Purple + const Color(0xFFE91E63), // Pink + const Color(0xFFFF5722), // Deep Orange + const Color(0xFFFF9800), // Orange + const Color(0xFF4CAF50), // Green + const Color(0xFF009688), // Teal + const Color(0xFF607D8B), // Blue Grey + ]; + + final isCustom = + config.seedColor != null && !presetColors.contains(config.seedColor); + + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ColorCircle( + color: null, + isSelected: config.seedColor == null, + label: 'Default', + onTap: () => + ref.read(demoThemeConfigProvider.notifier).setSeedColor(null), + ), + ...presetColors.map((color) { + return ColorCircle( + color: color, + isSelected: config.seedColor == color, + onTap: () => + ref.read(demoThemeConfigProvider.notifier).setSeedColor(color), + ); + }), + ColorCircle( + key: const ValueKey('seed-custom'), + color: isCustom ? config.seedColor : null, + isSelected: isCustom, + label: 'Custom', + onTap: () => showColorPickerDialog( + context: context, + currentColor: config.seedColor ?? Colors.blue, + onPick: (c) { + ref.read(demoThemeConfigProvider.notifier).setSeedColor(c); + }, + ), + isCustomIcon: true, + ), + ], + ); + } + + Widget _buildColorOverrideRow({ + required BuildContext context, + required String label, + required Color? color, + required ValueChanged onChanged, + }) { + return Row( + children: [ + SizedBox( + width: 80, + child: AppText.labelMedium(label), + ), + ColorCircle( + key: ValueKey('color-override-$label'), + color: color, + isSelected: true, + showLabel: false, + onTap: () => showColorPickerDialog( + context: context, + currentColor: color, + onPick: onChanged, + ), + ), + const SizedBox(width: 12), + if (color != null) + AppIconButton.icon( + icon: const Icon(Icons.close, size: 16), + onTap: () => onChanged(null), + ), + if (color == null) + AppText.bodySmall( + 'Default', + color: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ], + ); + } +} diff --git a/lib/demo/theme_studio/tabs/status_tab.dart b/lib/demo/theme_studio/tabs/status_tab.dart new file mode 100644 index 000000000..71e61da50 --- /dev/null +++ b/lib/demo/theme_studio/tabs/status_tab.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/demo/providers/demo_theme_config_provider.dart'; +import '../widgets/section_header.dart'; +import '../widgets/color_circle.dart'; +import '../widgets/color_picker_dialog.dart'; + +class StatusTab extends ConsumerWidget { + const StatusTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final config = ref.watch(demoThemeConfigProvider); + final semantics = config.overrides?.semantic; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionHeader(title: 'Status Colors'), + const SizedBox(height: 16), + _buildColorOverrideRow( + context: context, + label: 'Success', + color: semantics?.success, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateSemanticOverrides(success: c), + ), + const SizedBox(height: 12), + _buildColorOverrideRow( + context: context, + label: 'Warning', + color: semantics?.warning, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateSemanticOverrides(warning: c), + ), + const SizedBox(height: 12), + _buildColorOverrideRow( + context: context, + label: 'Danger', + color: semantics?.error, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateSemanticOverrides(danger: c), + ), + const SizedBox(height: 12), + _buildColorOverrideRow( + context: context, + label: 'Info', + color: semantics?.info, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateSemanticOverrides(info: c), + ), + ], + ); + } + + Widget _buildColorOverrideRow({ + required BuildContext context, + required String label, + required Color? color, + required ValueChanged onChanged, + }) { + return Row( + children: [ + SizedBox( + width: 80, + child: AppText.labelMedium(label), + ), + ColorCircle( + key: ValueKey('color-override-$label'), + color: color, + isSelected: true, + showLabel: false, + onTap: () => showColorPickerDialog( + context: context, + currentColor: color, + onPick: onChanged, + ), + ), + const SizedBox(width: 12), + if (color != null) + AppIconButton.icon( + icon: const Icon(Icons.close, size: 16), + onTap: () => onChanged(null), + ), + if (color == null) + AppText.bodySmall( + 'Default', + color: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ], + ); + } +} diff --git a/lib/demo/theme_studio/tabs/topology_tab.dart b/lib/demo/theme_studio/tabs/topology_tab.dart new file mode 100644 index 000000000..cac520348 --- /dev/null +++ b/lib/demo/theme_studio/tabs/topology_tab.dart @@ -0,0 +1,400 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/demo/providers/demo_theme_config_provider.dart'; +import '../widgets/section_header.dart'; +import '../widgets/compact_color_picker.dart'; + +class TopologyTab extends ConsumerWidget { + const TopologyTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final config = ref.watch(demoThemeConfigProvider); + final override = config.overrides?.component?.topology; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- Expanded Live Preview --- + Container( + height: 300, + width: double.infinity, + decoration: BoxDecoration( + border: + Border.all(color: Theme.of(context).colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(12), + color: Theme.of(context).colorScheme.surface, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: _buildTopologyPreview(context, override: override), + ), + ), + const SizedBox(height: 24), + + // --- Link Style --- + const SectionHeader(title: 'Link Animations & Colors'), + const SizedBox(height: 16), + _buildLinkStyleSection(context, ref, override), + const SizedBox(height: 24), + + // --- Node Style --- + const SectionHeader(title: 'Node Animations & Colors'), + const SizedBox(height: 16), + _buildNodeStyleSection(context, ref, override), + ], + ); + } + + Widget _buildTopologyPreview(BuildContext context, + {TopologyColorOverride? override}) { + final now = DateTime.now(); + + final gw = MeshNode( + id: 'gw', + name: 'Gateway', + type: MeshNodeType.gateway, + status: MeshNodeStatus.online, + signalQuality: SignalQuality.strong, + ); + + // Extender with Ethernet Backhaul + final exEth = MeshNode( + id: 'ex_eth', + parentId: 'gw', + name: 'Ex (Eth)', + type: MeshNodeType.extender, + status: MeshNodeStatus.online, + signalQuality: SignalQuality.wired, + ); + + // Extender with WiFi Backhaul (Medium) + final exWifi = MeshNode( + id: 'ex_wifi', + parentId: 'gw', + name: 'Ex (WiFi)', + type: MeshNodeType.extender, + status: MeshNodeStatus.online, + signalQuality: SignalQuality.medium, + ); + + // Client with Strong Signal + final clStrong = MeshNode( + id: 'cl_strong', + parentId: 'ex_eth', + name: 'Strong', + type: MeshNodeType.client, + status: MeshNodeStatus.online, + signalQuality: SignalQuality.strong, + ); + + // Client with Weak Signal + final clWeak = MeshNode( + id: 'cl_weak', + parentId: 'ex_wifi', + name: 'Weak', + type: MeshNodeType.client, + status: MeshNodeStatus.online, + signalQuality: SignalQuality.weak, + ); + + // Client with Medium Signal (Moved to Ex Eth to balance layout) + final clMed = MeshNode( + id: 'cl_med', + parentId: 'ex_eth', + name: 'Medium', + type: MeshNodeType.client, + status: MeshNodeStatus.online, + signalQuality: SignalQuality.medium, + ); + + // Offline Client + final clOff = MeshNode( + id: 'cl_off', + // parentId: 'gw', + name: 'Offline', + type: MeshNodeType.client, + status: MeshNodeStatus.offline, + signalQuality: SignalQuality.unknown, + ); + + final nodes = [gw, exEth, exWifi, clStrong, clWeak, clMed, clOff]; + final links = [ + MeshLink( + sourceId: 'gw', + targetId: 'ex_eth', + connectionType: ConnectionType.ethernet, + throughput: 1000, + ), + MeshLink( + sourceId: 'gw', + targetId: 'ex_wifi', + connectionType: ConnectionType.wifi, + rssi: -65, // Medium (-50 to -70) + throughput: 150, + ), + MeshLink( + sourceId: 'ex_eth', + targetId: 'cl_strong', + connectionType: ConnectionType.wifi, + rssi: -40, // Strong > -50 + throughput: 300, + ), + MeshLink( + sourceId: 'ex_wifi', + targetId: 'cl_weak', + connectionType: ConnectionType.wifi, + rssi: -80, // Weak < -70 + throughput: 50, + ), + MeshLink( + sourceId: 'ex_eth', + targetId: 'cl_med', + connectionType: ConnectionType.wifi, + rssi: -60, // Medium (-50 to -70) + throughput: 100, + ), + ]; + + final topology = MeshTopology( + nodes: nodes, + links: links, + lastUpdated: now, + ); + + return AppTopology( + key: ValueKey(override?.hashCode ?? 0), + topology: topology, + interactive: false, + enableAnimation: true, + viewMode: TopologyViewMode.graph, + ); + } + + Widget _buildLinkStyleSection( + BuildContext context, WidgetRef ref, TopologyColorOverride? override) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Ethernet Row + Row( + children: [ + SizedBox( + width: 100, + child: AppText.labelMedium('Ethernet'), + ), + const SizedBox(width: 16), + Expanded( + child: Wrap( + spacing: 8, + children: LinkAnimationType.values.map((type) { + final isSelected = (override?.ethernetAnimationType ?? + LinkAnimationType.none) == + type; + return AppTag( + label: type.name.toUpperCase(), + isSelected: isSelected, + onTap: () { + ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(ethernetAnimationType: type); + }, + ); + }).toList(), + ), + ), + CompactColorPicker( + label: 'Color', + color: override?.ethernetLinkColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(ethernetLinkColor: c), + ), + ], + ), + const SizedBox(height: 16), + // WiFi Row (Animations shared, colors split) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: AppText.labelMedium('WiFi'), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + children: LinkAnimationType.values.map((type) { + final isSelected = (override?.wifiAnimationType ?? + LinkAnimationType.none) == + type; + return AppTag( + label: type.name.toUpperCase(), + isSelected: isSelected, + onTap: () { + ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(wifiAnimationType: type); + }, + ); + }).toList(), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + CompactColorPicker( + label: 'Strong', + color: override?.wifiStrongColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(wifiStrongColor: c), + ), + CompactColorPicker( + label: 'Medium', + color: override?.wifiMediumColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(wifiMediumColor: c), + ), + CompactColorPicker( + label: 'Weak', + color: override?.wifiWeakColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(wifiWeakColor: c), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ); + } + + Widget _buildNodeStyleSection( + BuildContext context, WidgetRef ref, TopologyColorOverride? override) { + return Column( + children: [ + _buildNodeConfigRow( + context, + 'Gateway', + override?.gatewayRenderer, + (type) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(gatewayRenderer: type), + [ + CompactColorPicker( + label: 'Bg', + color: override?.gatewayNormalBackgroundColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(gatewayNormalBackgroundColor: c), + ), + CompactColorPicker( + label: 'Icon', + color: override?.gatewayNormalIconColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(gatewayNormalIconColor: c)), + ], + ), + const SizedBox(height: 16), + _buildNodeConfigRow( + context, + 'Extender', + override?.extenderRenderer, + (type) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(extenderRenderer: type), + [ + CompactColorPicker( + label: 'Bg', + color: override?.extenderNormalBackgroundColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(extenderNormalBackgroundColor: c), + ), + CompactColorPicker( + label: 'Icon', + color: override?.extenderNormalIconColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(extenderNormalIconColor: c)), + ], + ), + const SizedBox(height: 16), + _buildNodeConfigRow( + context, + 'Client', + override?.clientRenderer, + (type) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(clientRenderer: type), + [ + CompactColorPicker( + label: 'Bg', + color: override?.clientNormalBackgroundColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(clientNormalBackgroundColor: c), + ), + CompactColorPicker( + label: 'Icon', + color: override?.clientNormalIconColor, + onChanged: (c) => ref + .read(demoThemeConfigProvider.notifier) + .updateTopologyColors(clientNormalIconColor: c)), + ], + ), + ], + ); + } + + Widget _buildNodeConfigRow( + BuildContext context, + String label, + MeshNodeRendererType? currentType, + ValueChanged onTypeChanged, + List colorPickers, + ) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 100, child: AppText.labelMedium(label)), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: MeshNodeRendererType.values.map((type) { + final isSelected = + (currentType ?? MeshNodeRendererType.ripple) == type; + return AppTag( + label: type.name.toUpperCase(), + isSelected: isSelected, + onTap: () => onTypeChanged(type), + ); + }).toList(), + ), + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: colorPickers), + ], + ), + ), + ], + ); + } +} diff --git a/lib/demo/theme_studio/theme_studio_fab.dart b/lib/demo/theme_studio/theme_studio_fab.dart new file mode 100644 index 000000000..5f5f96ef8 --- /dev/null +++ b/lib/demo/theme_studio/theme_studio_fab.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/demo/providers/demo_ui_provider.dart'; + +/// A simple FAB that toggles the Theme Studio panel. +class ThemeStudioFab extends ConsumerWidget { + const ThemeStudioFab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOpen = ref.watch(demoUIProvider).isThemePanelOpen; + + return AppIconButton.primary( + icon: AppIcon.font( + isOpen ? AppFontIcons.close : AppFontIcons.widgets, + ), + onTap: () { + ref.read(demoUIProvider.notifier).toggleThemePanel(); + }, + ); + } +} diff --git a/lib/demo/theme_studio/theme_studio_panel.dart b/lib/demo/theme_studio/theme_studio_panel.dart new file mode 100644 index 000000000..c21646e96 --- /dev/null +++ b/lib/demo/theme_studio/theme_studio_panel.dart @@ -0,0 +1,248 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/demo/providers/demo_theme_config_provider.dart'; +import 'package:privacy_gui/route/router_provider.dart'; + +import 'tabs/design_tab.dart'; +import 'tabs/palette_tab.dart'; +import 'tabs/status_tab.dart'; +import 'tabs/components_tab.dart'; +import 'tabs/topology_tab.dart'; + +/// The dedicated settings panel content. +class ThemeStudioPanel extends ConsumerStatefulWidget { + const ThemeStudioPanel({super.key}); + + @override + ConsumerState createState() => _ThemeStudioPanelState(); +} + +class _ThemeStudioPanelState extends ConsumerState { + int _selectedTabIndex = 0; + + @override + Widget build(BuildContext context) { + final config = ref.watch(demoThemeConfigProvider); + + return Material( + color: Theme.of(context).colorScheme.surface, + elevation: 0, + child: Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + width: 1, + ), + ), + ), + child: Column( + children: [ + // Header with Actions + _buildHeader(context), + const AppDivider(), + + // Tabs + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: AppTabs( + initialIndex: _selectedTabIndex, + isScrollable: true, + displayMode: TabDisplayMode.underline, + tabs: const [ + TabItem(label: 'Design', icon: Icons.brush_outlined), + TabItem(label: 'Palette', icon: Icons.palette_outlined), + TabItem(label: 'Status', icon: Icons.traffic_outlined), + TabItem(label: 'Components', icon: Icons.extension_outlined), + TabItem(label: 'Topology', icon: Icons.hub_outlined), + ], + onTabChanged: (index) { + setState(() => _selectedTabIndex = index); + }, + ), + ), + const AppDivider(), + + // Scrollable Content + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: _buildTabContent(context, config), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + const AppText( + 'Theme Studio', + variant: AppTextVariant.titleMedium, + fontWeight: FontWeight.bold, + ), + const Spacer(), + AppIconButton.icon( + icon: AppIcon.font(Icons.download_outlined, size: 20), + tooltip: 'Import Config', + onTap: () => _showImportDialog(context), + ), + AppIconButton.icon( + icon: AppIcon.font(Icons.upload_outlined, size: 20), + tooltip: 'Export Config', + onTap: () => _showExportDialog(context), + ), + AppIconButton.icon( + icon: AppIcon.font(Icons.refresh_outlined, size: 20), + tooltip: 'Reset to Defaults', + onTap: () { + ref.read(demoThemeConfigProvider.notifier).reset(); + }, + ), + ], + ), + ); + } + + Widget _buildTabContent(BuildContext context, DemoThemeConfig config) { + switch (_selectedTabIndex) { + case 0: + return const DesignTab(); + case 1: + return const PaletteTab(); + case 2: + return const StatusTab(); + case 3: + return const ComponentsTab(); + case 4: + return const TopologyTab(); + default: + return const SizedBox(); + } + } + + void _showExportDialog(BuildContext context) { + final config = ref.read(demoThemeConfigProvider); + final jsonString = + const JsonEncoder.withIndent(' ').convert(config.toJson()); + final dialogContext = routerKey.currentContext ?? context; + + showDialog( + useRootNavigator: true, + context: dialogContext, + builder: (context) { + return AlertDialog( + title: const Text('Export Configuration'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText( + jsonString, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AppButton.text( + label: 'Close', + onTap: () => Navigator.of(context).pop(), + ), + const SizedBox(width: 8), + AppButton.primary( + label: 'Copy to Clipboard', + onTap: () async { + await Clipboard.setData(ClipboardData(text: jsonString)); + if (context.mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(dialogContext).showSnackBar( + const SnackBar( + content: Text('Config copied to clipboard!')), + ); + } + }, + ), + ], + ), + ], + ), + ); + }, + ); + } + + void _showImportDialog(BuildContext context) { + final controller = TextEditingController(); + final dialogContext = routerKey.currentContext ?? context; + + showDialog( + useRootNavigator: true, + context: dialogContext, + builder: (context) { + return AlertDialog( + title: const Text('Import Configuration'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: controller, + maxLines: 8, + decoration: const InputDecoration( + hintText: 'Paste JSON here...', + border: OutlineInputBorder(), + ), + style: TextStyle( + fontFamily: 'monospace', + fontSize: 14, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AppButton.text( + label: 'Cancel', + onTap: () => Navigator.of(context).pop(), + ), + const SizedBox(width: 8), + AppButton.primary( + label: 'Import', + onTap: () { + try { + final json = jsonDecode(controller.text); + ref + .read(demoThemeConfigProvider.notifier) + .importConfig(json); + Navigator.of(context).pop(); + ScaffoldMessenger.of(dialogContext).showSnackBar( + const SnackBar(content: Text('Theme imported!')), + ); + } catch (e) { + ScaffoldMessenger.of(dialogContext).showSnackBar( + SnackBar(content: Text('Invalid JSON: $e')), + ); + } + }, + ), + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/demo/theme_studio/widgets/color_circle.dart b/lib/demo/theme_studio/widgets/color_circle.dart new file mode 100644 index 000000000..aa97cd11d --- /dev/null +++ b/lib/demo/theme_studio/widgets/color_circle.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +class ColorCircle extends StatelessWidget { + final Color? color; + final bool isSelected; + final VoidCallback onTap; + final String? label; + final bool showLabel; + final bool isCustomIcon; + + const ColorCircle({ + super.key, + required this.color, + required this.isSelected, + required this.onTap, + this.label, + this.showLabel = true, + this.isCustomIcon = false, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Column( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color ?? Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? Theme.of(context).primaryColor + : Colors.grey.shade300, + width: isSelected ? 3 : 1, + ), + ), + child: isCustomIcon + ? Icon(Icons.add, + color: Theme.of(context).colorScheme.onSurface) + : (color == null + ? Icon(Icons.colorize, + size: 20, color: Colors.grey.withValues(alpha: 0.5)) + : null), + ), + if (showLabel && label != null) ...[ + const SizedBox(height: 4), + AppText.bodySmall(label!), + ], + ], + ), + ); + } +} diff --git a/lib/demo/theme_studio/widgets/color_picker_dialog.dart b/lib/demo/theme_studio/widgets/color_picker_dialog.dart new file mode 100644 index 000000000..d217d2d77 --- /dev/null +++ b/lib/demo/theme_studio/widgets/color_picker_dialog.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:privacy_gui/route/router_provider.dart'; +import 'color_circle.dart'; +import 'simple_color_picker.dart'; + +void showColorPickerDialog({ + required BuildContext context, + required Color? currentColor, + required ValueChanged onPick, +}) { + // Use routerKey context to ensure dialog shows over the app + final dialogContext = routerKey.currentContext ?? context; + + showDialog( + useRootNavigator: true, + context: dialogContext, + builder: (context) { + final colors = [ + Colors.red, + Colors.pink, + Colors.purple, + Colors.deepPurple, + Colors.indigo, + Colors.blue, + Colors.lightBlue, + Colors.cyan, + Colors.teal, + Colors.green, + Colors.lightGreen, + Colors.lime, + Colors.yellow, + Colors.amber, + Colors.orange, + Colors.deepOrange, + Colors.brown, + Colors.grey, + Colors.blueGrey, + Colors.black, + Colors.white, + ]; + + return DefaultTabController( + length: 2, + child: AlertDialog( + title: const Text('Pick Color'), + content: SizedBox( + width: 400, + height: 400, + child: Column( + children: [ + const TabBar( + labelColor: Colors.blue, + unselectedLabelColor: Colors.grey, + tabs: [ + Tab(text: 'Presets'), + Tab(text: 'Custom'), + ], + ), + const SizedBox(height: 16), + Expanded( + child: TabBarView( + children: [ + // Presets + SingleChildScrollView( + child: Wrap( + spacing: 12, + runSpacing: 12, + children: colors.map((c) { + return ColorCircle( + key: ValueKey('color-override-${c.toString()}'), + color: c, + isSelected: currentColor == c, + showLabel: false, + onTap: () { + onPick(c); + Navigator.of(context).pop(); + }, + ); + }).toList(), + ), + ), + // Custom Selector + SimpleColorPicker( + initialColor: currentColor ?? Colors.blue, + onColorChanged: onPick, + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); +} diff --git a/lib/demo/theme_studio/widgets/compact_color_picker.dart b/lib/demo/theme_studio/widgets/compact_color_picker.dart new file mode 100644 index 000000000..e503fc48b --- /dev/null +++ b/lib/demo/theme_studio/widgets/compact_color_picker.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'color_picker_dialog.dart'; + +class CompactColorPicker extends StatelessWidget { + final String label; + final Color? color; + final ValueChanged onChanged; + + const CompactColorPicker({ + super.key, + required this.label, + required this.color, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + showColorPickerDialog( + context: context, + currentColor: color, + onPick: onChanged, + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: color ?? Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.outlineVariant), + ), + child: color == null ? const Icon(Icons.colorize, size: 14) : null, + ), + const SizedBox(width: 8), + AppText.bodySmall(label), + ], + ), + ); + } +} diff --git a/lib/demo/theme_studio/widgets/section_header.dart b/lib/demo/theme_studio/widgets/section_header.dart new file mode 100644 index 000000000..b01bda3a6 --- /dev/null +++ b/lib/demo/theme_studio/widgets/section_header.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +class SectionHeader extends StatelessWidget { + final String title; + + const SectionHeader({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return AppText( + title, + variant: AppTextVariant.titleSmall, + fontWeight: FontWeight.bold, + ); + } +} diff --git a/lib/demo/theme_studio/widgets/simple_color_picker.dart b/lib/demo/theme_studio/widgets/simple_color_picker.dart new file mode 100644 index 000000000..6f68388ba --- /dev/null +++ b/lib/demo/theme_studio/widgets/simple_color_picker.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class SimpleColorPicker extends StatefulWidget { + final Color initialColor; + final ValueChanged onColorChanged; + + const SimpleColorPicker({ + super.key, + required this.initialColor, + required this.onColorChanged, + }); + + @override + State createState() => _SimpleColorPickerState(); +} + +class _SimpleColorPickerState extends State { + late HSVColor _hsvColor; + late TextEditingController _hexController; + + @override + void initState() { + super.initState(); + _hsvColor = HSVColor.fromColor(widget.initialColor); + _hexController = TextEditingController( + text: _toHex(widget.initialColor), + ); + } + + @override + void dispose() { + _hexController.dispose(); + super.dispose(); + } + + void _updateColor(HSVColor color) { + setState(() { + _hsvColor = color; + _hexController.text = _toHex(color.toColor()); + }); + widget.onColorChanged(color.toColor()); + } + + void _onHexChanged(String value) { + if (value.length == 8) { + try { + final color = Color(int.parse('0x$value')); + setState(() { + _hsvColor = HSVColor.fromColor(color); + }); + widget.onColorChanged(color); + } catch (_) {} + } + } + + String _toHex(Color color) { + return color.toARGB32().toRadixString(16).padLeft(8, '0').toUpperCase(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Preview + Container( + height: 48, + decoration: BoxDecoration( + color: _hsvColor.toColor(), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + ), + const SizedBox(height: 16), + + // Hue + _buildSlider( + label: 'H', + value: _hsvColor.hue, + max: 360, + onChanged: (v) => _updateColor(_hsvColor.withHue(v)), + colors: [ + const Color(0xFFFF0000), + const Color(0xFFFFFF00), + const Color(0xFF00FF00), + const Color(0xFF00FFFF), + const Color(0xFF0000FF), + const Color(0xFFFF00FF), + const Color(0xFFFF0000), + ], + ), + + const SizedBox(height: 12), + + // Saturation + _buildSlider( + label: 'S', + value: _hsvColor.saturation, + max: 1.0, + onChanged: (v) => _updateColor(_hsvColor.withSaturation(v)), + colors: [ + Colors.white, + HSVColor.fromAHSV(1.0, _hsvColor.hue, 1.0, _hsvColor.value) + .toColor(), + ], + ), + + const SizedBox(height: 12), + + // Value + _buildSlider( + label: 'V', + value: _hsvColor.value, + max: 1.0, + onChanged: (v) => _updateColor(_hsvColor.withValue(v)), + colors: [ + Colors.black, + HSVColor.fromAHSV(1.0, _hsvColor.hue, _hsvColor.saturation, 1.0) + .toColor(), + ], + ), + + const SizedBox(height: 16), + + // Hex Input + TextField( + controller: _hexController, + decoration: const InputDecoration( + labelText: 'Hex (AARRGGBB)', + border: OutlineInputBorder(), + isDense: true, + ), + style: const TextStyle(fontFamily: 'monospace'), + onChanged: _onHexChanged, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9A-Fa-f]')), + LengthLimitingTextInputFormatter(8), + ], + ), + ], + ); + } + + Widget _buildSlider({ + required String label, + required double value, + required double max, + required ValueChanged onChanged, + required List colors, + }) { + return Row( + children: [ + SizedBox( + width: 24, + child: + Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + Expanded( + child: Container( + height: 24, + decoration: BoxDecoration( + gradient: LinearGradient(colors: colors), + borderRadius: BorderRadius.circular(12), + ), + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 24, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 12, elevation: 2), + overlayShape: SliderComponentShape.noOverlay, + thumbColor: Colors.white, + activeTrackColor: Colors.transparent, + inactiveTrackColor: Colors.transparent, + ), + child: Slider( + value: value, + max: max, + onChanged: onChanged, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/demo/widgets/demo_theme_settings_fab.dart b/lib/demo/widgets/demo_theme_settings_fab.dart deleted file mode 100644 index 49ca844a5..000000000 --- a/lib/demo/widgets/demo_theme_settings_fab.dart +++ /dev/null @@ -1,353 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:ui_kit_library/ui_kit.dart'; - -/// Provider for dynamic theme configuration in demo mode. -final demoThemeConfigProvider = - StateNotifierProvider((ref) { - return DemoThemeConfigNotifier(); -}); - -/// Demo theme configuration state. -class DemoThemeConfig { - final String style; - final GlobalOverlayType? globalOverlay; - final int visualEffects; - final Color? seedColor; - - const DemoThemeConfig({ - this.style = 'glass', - this.globalOverlay, - this.visualEffects = AppThemeConfig.effectAll, - this.seedColor, - }); - - DemoThemeConfig copyWith({ - String? style, - GlobalOverlayType? globalOverlay, - bool clearOverlay = false, - int? visualEffects, - Color? seedColor, - bool clearSeedColor = false, - }) { - return DemoThemeConfig( - style: style ?? this.style, - globalOverlay: - clearOverlay ? null : (globalOverlay ?? this.globalOverlay), - visualEffects: visualEffects ?? this.visualEffects, - seedColor: clearSeedColor ? null : (seedColor ?? this.seedColor), - ); - } -} - -/// Notifier for demo theme configuration. -class DemoThemeConfigNotifier extends StateNotifier { - DemoThemeConfigNotifier() : super(const DemoThemeConfig()); - - void setStyle(String style) { - state = state.copyWith(style: style); - } - - void setGlobalOverlay(GlobalOverlayType? overlay) { - if (overlay == null) { - state = state.copyWith(clearOverlay: true); - } else { - state = state.copyWith(globalOverlay: overlay); - } - } - - void setVisualEffects(int effects) { - state = state.copyWith(visualEffects: effects); - } - - void toggleVisualEffect(int flag) { - final current = state.visualEffects; - final newEffects = (current & flag) != 0 - ? current & ~flag // Remove flag - : current | flag; // Add flag - state = state.copyWith(visualEffects: newEffects); - } - - void setSeedColor(Color? color) { - if (color == null) { - state = state.copyWith(clearSeedColor: true); - } else { - state = state.copyWith(seedColor: color); - } - } -} - -/// A floating action button for theme settings in demo mode. -class DemoThemeSettingsFab extends ConsumerStatefulWidget { - const DemoThemeSettingsFab({super.key}); - - @override - ConsumerState createState() => - _DemoThemeSettingsFabState(); -} - -class _DemoThemeSettingsFabState extends ConsumerState { - bool _isExpanded = false; - - // Preset color palette - static const List _presetColors = [ - Color(0xFF0870EA), // Blue (default) - Color(0xFF8E08EA), // Purple - Color(0xFFE91E63), // Pink - Color(0xFFFF5722), // Deep Orange - Color(0xFFFF9800), // Orange - Color(0xFF4CAF50), // Green - Color(0xFF009688), // Teal - Color(0xFF00BCD4), // Cyan - Color(0xFF3F51B5), // Indigo - Color(0xFF607D8B), // Blue Grey - Color(0xFF795548), // Brown - Color(0xFF9E9E9E), // Grey - ]; - - @override - Widget build(BuildContext context) { - final config = ref.watch(demoThemeConfigProvider); - - return AppDraggable( - initialPosition: Alignment.bottomRight, - enableSnapping: true, - padding: const EdgeInsets.all(16.0), - builder: (context, isDragging, alignment) { - // Adapt layout based on side (Left aligned -> Panel starts at left) - final isLeft = alignment.x < 0; - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - isLeft ? CrossAxisAlignment.start : CrossAxisAlignment.end, - children: [ - // Expanded menu - if (_isExpanded) ...[ - _buildSettingsPanel(context, config), - const SizedBox(height: 8), - ], - // Main FAB - AppIconButton.primary( - icon: AppIcon.font( - _isExpanded ? AppFontIcons.close : AppFontIcons.widgets, - ), - onTap: () => setState(() => _isExpanded = !_isExpanded), - ), - ], - ); - }, - ); - } - - Widget _buildSettingsPanel(BuildContext context, DemoThemeConfig config) { - return SizedBox( - width: 300, - child: AppCard( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - const AppText( - 'Theme Settings', - variant: AppTextVariant.titleMedium, - fontWeight: FontWeight.bold, - ), - const SizedBox(height: 12), - const AppDivider(), - const SizedBox(height: 12), - - // Seed Color selector - AppText.labelMedium('Seed Color'), - const SizedBox(height: 8), - _buildSeedColorSelector(context, config), - const SizedBox(height: 16), - - // Style selector - AppText.labelMedium('Style'), - const SizedBox(height: 8), - _buildStyleSelector(context, config), - const SizedBox(height: 16), - - // Global Overlay selector - AppText.labelMedium('Global Overlay'), - const SizedBox(height: 8), - _buildOverlaySelector(context, config), - const SizedBox(height: 16), - - // Visual Effects toggles - AppText.labelMedium('Visual Effects'), - const SizedBox(height: 8), - _buildVisualEffectsToggles(context, config), - ], - ), - ), - ), - ); - } - - Widget _buildSeedColorSelector(BuildContext context, DemoThemeConfig config) { - final currentColor = config.seedColor; - - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - // Default option (use JSON config) - _ColorCircle( - color: null, - isSelected: currentColor == null, - label: 'Default', - onTap: () { - ref.read(demoThemeConfigProvider.notifier).setSeedColor(null); - }, - ), - // Preset colors - ..._presetColors.map((color) { - return _ColorCircle( - color: color, - isSelected: currentColor?.toARGB32() == color.toARGB32(), - onTap: () { - ref.read(demoThemeConfigProvider.notifier).setSeedColor(color); - }, - ); - }), - ], - ); - } - - Widget _buildStyleSelector(BuildContext context, DemoThemeConfig config) { - final styles = ['glass', 'aurora', 'brutal', 'flat', 'neumorphic', 'pixel']; - - return Wrap( - spacing: 6, - runSpacing: 6, - children: styles.map((style) { - final isSelected = config.style == style; - return AppTag( - label: style[0].toUpperCase() + style.substring(1), - isSelected: isSelected, - onTap: () { - ref.read(demoThemeConfigProvider.notifier).setStyle(style); - }, - ); - }).toList(), - ); - } - - Widget _buildOverlaySelector(BuildContext context, DemoThemeConfig config) { - final overlays = [ - (null, 'None'), - (GlobalOverlayType.snow, 'Snow'), - (GlobalOverlayType.hacker, 'Matrix'), - (GlobalOverlayType.noiseOverlay, 'Noise'), - (GlobalOverlayType.crtShader, 'CRT'), - (GlobalOverlayType.auroraGlow, 'Aurora'), - (GlobalOverlayType.liquid, 'Liquid'), - ]; - - return Wrap( - spacing: 6, - runSpacing: 6, - children: overlays.map((item) { - final (overlay, label) = item; - final isSelected = config.globalOverlay == overlay; - return AppTag( - label: label, - isSelected: isSelected, - onTap: () { - ref - .read(demoThemeConfigProvider.notifier) - .setGlobalOverlay(overlay); - }, - ); - }).toList(), - ); - } - - Widget _buildVisualEffectsToggles( - BuildContext context, DemoThemeConfig config) { - // Use actual effect constants from AppThemeConfig - final effects = [ - (AppThemeConfig.effectDirectionalShadow, 'Shadow'), - (AppThemeConfig.effectGradientBorder, 'Gradient'), - (AppThemeConfig.effectBlur, 'Blur'), - (AppThemeConfig.effectNoiseTexture, 'Noise'), - (AppThemeConfig.effectShimmer, 'Shimmer'), - (AppThemeConfig.effectTopologyAnimation, 'Topology'), - ]; - - return Wrap( - spacing: 6, - runSpacing: 6, - children: effects.map((item) { - final (flag, label) = item; - final isEnabled = (config.visualEffects & flag) != 0; - return AppTag( - label: label, - isSelected: isEnabled, - onTap: () { - ref.read(demoThemeConfigProvider.notifier).toggleVisualEffect(flag); - }, - ); - }).toList(), - ); - } -} - -/// A circular color selector widget. -class _ColorCircle extends StatelessWidget { - final Color? color; - final bool isSelected; - final String? label; - final VoidCallback onTap; - - const _ColorCircle({ - required this.color, - required this.isSelected, - required this.onTap, - this.label, - }); - - @override - Widget build(BuildContext context) { - const size = 28.0; - final isDefault = color == null; - final colorScheme = Theme.of(context).colorScheme; - - // Calculate effective styles based on selection state - final borderColor = isSelected - ? colorScheme.primary - : (isDefault ? colorScheme.outline : color!.withValues(alpha: 0.5)); - - final borderWidth = isSelected ? 3.0 : 1.0; - - return AppInteractionSensor( - onTap: onTap, - child: Container( - width: size, - height: size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isDefault ? Colors.transparent : color, - border: Border.all( - color: borderColor, - width: borderWidth, - ), - ), - child: isDefault - ? Center( - child: AppIcon.font( - Icons.auto_fix_high, - size: 14, - color: colorScheme.outline, - ), - ) - : null, - ), - ); - } -} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fb46383cf..b48ea2057 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -117,6 +117,35 @@ "dashboardSupportCallbackTitle": "Get a Callback", "dashboardSupportFAQDesc": "Read most common help articles sorted by topic", "dashboardSupportFAQTitle": "View FAQ", + "dashboardSettings": "Dashboard Settings", + "dashboardEnableCustomLayout": "Enable Custom Layout", + "dashboardHiddenWidgets": "Hidden Widgets", + "dashboardResetLayout": "Reset Layout", + "dashboardLayoutOptimized": "Layout optimized", + "dashboardLayoutResetToDefaults": "Layout reset to defaults", + "dashboardUnknownWidget": "Unknown: {widgetId}", + "@dashboardUnknownWidget": { + "placeholders": { + "widgetId": { + "type": "String", + "example": "widget_id" + } + } + }, + "dashboardSpeedHistory": "Speed History ({count} runs)", + "@dashboardSpeedHistory": { + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "dashboardStartSpeedTest": "Start Speed Test", + "dashboardChangeViewMode": "Change View Mode", + "beta": "BETA", + "topology": "Topology", + "noDevicesConnected": "No devices connected", "dateAndTime": "Date & Time", "daylightSavingsTime": "Automatically adjust for Daylight Savings Time", "ddns": "DDNS", diff --git a/lib/page/advanced_settings/local_network_settings/views/local_network_settings_view.dart b/lib/page/advanced_settings/local_network_settings/views/local_network_settings_view.dart index 865aa0592..a0bf11629 100644 --- a/lib/page/advanced_settings/local_network_settings/views/local_network_settings_view.dart +++ b/lib/page/advanced_settings/local_network_settings/views/local_network_settings_view.dart @@ -4,8 +4,8 @@ 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/errors/service_error.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/core/utils/extension.dart'; +import 'package:privacy_gui/core/utils/ip_getter/ip_getter.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/advanced_settings/local_network_settings/views/dhcp_server_view.dart'; @@ -267,7 +267,7 @@ class _LocalNetworkSettingsViewState ).catchError((error) { if (!mounted) return; final state = ref.read(localNetworkSettingProvider); - final currentUrl = ref.read(routerRepositoryProvider).getLocalIP(); + final currentUrl = getLocalIp(ref.read); final regex = RegExp(r'(www\.)?myrouter\.info'); // check is url start with www.myrouter.info or myrouter.info @@ -305,7 +305,7 @@ class _LocalNetworkSettingsViewState return; } final state = ref.read(localNetworkSettingProvider); - final currentUrl = ref.read(routerRepositoryProvider).getLocalIP(); + final currentUrl = getLocalIp(ref.read); final regex = RegExp(r'(www\.)?myrouter\.info'); // check is url start with www.myrouter.info or myrouter.info if (regex.hasMatch(currentUrl)) { diff --git a/lib/page/ai_assistant/providers/router_command_provider.dart b/lib/page/ai_assistant/providers/router_command_provider.dart new file mode 100644 index 000000000..aa4bc4c54 --- /dev/null +++ b/lib/page/ai_assistant/providers/router_command_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/ai/_ai.dart'; +import 'package:privacy_gui/core/jnap/router_repository.dart'; + +/// Provider for the command provider. +final routerCommandProviderProvider = Provider((ref) { + final router = ref.watch(routerRepositoryProvider); + return JnapCommandProvider(router); +}); diff --git a/lib/page/ai_assistant/views/router_assistant_view.dart b/lib/page/ai_assistant/views/router_assistant_view.dart index 89082ca27..ac9052a88 100644 --- a/lib/page/ai_assistant/views/router_assistant_view.dart +++ b/lib/page/ai_assistant/views/router_assistant_view.dart @@ -4,13 +4,7 @@ import 'package:generative_ui/generative_ui.dart'; import 'package:ui_kit_library/ui_kit.dart'; import 'package:privacy_gui/ai/_ai.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; - -/// Provider for the command provider. -final routerCommandProviderProvider = Provider((ref) { - final router = ref.watch(routerRepositoryProvider); - return JnapCommandProvider(router); -}); +import 'package:privacy_gui/page/ai_assistant/providers/router_command_provider.dart'; /// Main view for the Router AI Assistant. /// diff --git a/lib/page/dashboard/factories/dashboard_widget_factory.dart b/lib/page/dashboard/factories/dashboard_widget_factory.dart new file mode 100644 index 000000000..6f9e6210a --- /dev/null +++ b/lib/page/dashboard/factories/dashboard_widget_factory.dart @@ -0,0 +1,78 @@ +import 'package:flutter/widgets.dart'; +import '../models/dashboard_widget_specs.dart'; +import '../models/display_mode.dart'; +import '../models/widget_spec.dart'; +import '../strategies/dashboard_layout_context.dart'; // For PortAndSpeedConfig +import '../views/components/_components.dart'; + +/// Unified Dashboard Widget Factory +/// +/// Centralized management for: +/// - Widget ID → Widget mapping +/// - AppCard wrapping rules +/// - DisplayMode handling +class DashboardWidgetFactory { + DashboardWidgetFactory._(); + + /// Builds an atomic widget based on ID and DisplayMode. + static Widget? buildAtomicWidget( + String id, { + DisplayMode displayMode = DisplayMode.normal, + }) { + // Note: Standard Widgets use the same classes as Custom Widgets for now, + // but they are instantiated with different context in DashboardHomeView. + // For Custom Layout (where this factory is primarily used), we map IDs to widgets. + + return switch (id) { + // --- Custom Layout Widgets --- + 'internet_status_only' => CustomInternetStatus(displayMode: displayMode), + 'master_node_info' => CustomMasterNodeInfo(displayMode: displayMode), + 'ports' => CustomPorts(displayMode: displayMode), + 'speed_test' => CustomSpeedTest(displayMode: displayMode), + 'network_stats' => CustomNetworkStats(displayMode: displayMode), + 'topology' => CustomTopology(displayMode: displayMode), + + // Isolated Custom Widgets + 'wifi_grid_custom' => CustomWiFiGrid(displayMode: displayMode), + 'quick_panel_custom' => CustomQuickPanel(displayMode: displayMode), + 'vpn_custom' => CustomVPN(displayMode: displayMode), + + // --- Legacy/Standard IDs (Fallback if needed, or if shared logic remains) --- + 'wifi_grid' => + CustomWiFiGrid(displayMode: displayMode), // Keep for safety + 'quick_panel' => + CustomQuickPanel(displayMode: displayMode), // Keep for safety + 'vpn' => CustomVPN(displayMode: displayMode), // Keep for safety + + // --- Composite Widgets (Standard Layout Only - usually not built via this factory) --- + 'internet_status' => InternetConnectionWidget(displayMode: displayMode), + 'port_and_speed' => DashboardHomePortAndSpeed( + displayMode: displayMode, + config: const PortAndSpeedConfig( + direction: null, // Auto-detect based on width + showSpeedTest: true, + ), + ), + 'networks' => DashboardNetworks(displayMode: displayMode), + _ => null, + }; + } + + /// Determines if this widget should be wrapped in an AppCard. + /// + /// Some widgets (like WiFi Grid, VPN) manage their own card styling. + static bool shouldWrapInCard(String id) { + return switch (id) { + 'wifi_grid' => false, + 'wifi_grid_custom' => false, + 'vpn' => false, + 'vpn_custom' => false, + _ => true, + }; + } + + /// Gets the widget spec (used for constraint lookup). + static WidgetSpec? getSpec(String id) { + return DashboardWidgetSpecs.getById(id); + } +} diff --git a/lib/page/dashboard/models/dashboard_layout_preferences.dart b/lib/page/dashboard/models/dashboard_layout_preferences.dart index 4ad4e97ea..7495c28f1 100644 --- a/lib/page/dashboard/models/dashboard_layout_preferences.dart +++ b/lib/page/dashboard/models/dashboard_layout_preferences.dart @@ -51,6 +51,35 @@ class DashboardLayoutPreferences extends Equatable { return allConfigs; } + /// Get custom layout widgets in order (atomic widgets only, no VPN) + /// + /// Used by the settings panel to show only atomic widgets for Custom Layout. + /// Get custom layout widgets in order (atomic widgets only, no VPN in settings usually, but here strict to custom list) + /// + /// Used by the settings panel to show only atomic widgets for Custom Layout. + List get customWidgetsOrdered { + // We want all widgets defined in DashboardWidgetSpecs.customWidgets. + // VPN is typically allowed in custom layout, so we include it. + // If we need to filter specific ones out (like if VPN is not supported), logic belongs elsewhere or via requirements. + // However, the original code filtered out VPN explicitly from the ordered list for some reason. + // If the intention is "editable widgets", then VPN is usually editable. + // Let's stick to returning configs for all declared customWidgets. + // Note: The previous logic filtered out `DashboardWidgetSpecs.vpn.id`. + // Now we have `vpn_custom`. We should check if we need to filter `vpn_custom`. + // Assuming we want to show all available atomic widgets. + + final atomicSpecs = DashboardWidgetSpecs.customWidgets; + + // Note: The original code had: .where((spec) => spec.id != DashboardWidgetSpecs.vpn.id) + // This implies VPN wasn't meant to be reorderable/toggleable in the simplified list? + // Or maybe it was just not part of the "custom atomic" set at that time. + // For now, let's include everything in customWidgets, as that list is now explicit. + + final allConfigs = atomicSpecs.map((spec) => getConfig(spec.id)).toList(); + allConfigs.sort((a, b) => a.order.compareTo(b.order)); + return allConfigs; + } + // --------------------------------------------------------------------------- // Configuration Setters // --------------------------------------------------------------------------- @@ -92,7 +121,7 @@ class DashboardLayoutPreferences extends Equatable { )); } - /// Reorder widgets + /// Reorder widgets (all widgets) DashboardLayoutPreferences reorder(int oldIndex, int newIndex) { final ordered = allWidgetsOrdered.toList(); if (oldIndex < 0 || oldIndex >= ordered.length) return this; @@ -113,6 +142,28 @@ class DashboardLayoutPreferences extends Equatable { ); } + /// Reorder custom widgets (atomic widgets only) + DashboardLayoutPreferences reorderCustomWidget(int oldIndex, int newIndex) { + final ordered = customWidgetsOrdered.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); + + // Update order for custom widgets only + final newConfigs = Map.from(widgetConfigs); + for (var i = 0; i < ordered.length; i++) { + final config = ordered[i]; + newConfigs[config.widgetId] = config.copyWith(order: i); + } + + return DashboardLayoutPreferences( + useCustomLayout: useCustomLayout, + widgetConfigs: newConfigs, + ); + } + /// Reset all preferences to defaults DashboardLayoutPreferences reset() { return const DashboardLayoutPreferences(); diff --git a/lib/page/dashboard/models/dashboard_widget_specs.dart b/lib/page/dashboard/models/dashboard_widget_specs.dart index edad46301..8acd5f066 100644 --- a/lib/page/dashboard/models/dashboard_widget_specs.dart +++ b/lib/page/dashboard/models/dashboard_widget_specs.dart @@ -3,10 +3,10 @@ import 'height_strategy.dart'; import 'widget_grid_constraints.dart'; import 'widget_spec.dart'; -/// Dashboard 所有元件的規格定義 +/// Specifications for all Dashboard components. /// -/// 定義各元件在不同 [DisplayMode] 下的 grid 約束。 -/// 所有 column 數值基於 12-column 設計。 +/// Defines grid constraints for each component across different [DisplayMode]s. +/// All column values are based on a 12-column layout. abstract class DashboardWidgetSpecs { DashboardWidgetSpecs._(); @@ -15,53 +15,60 @@ abstract class DashboardWidgetSpecs { // --------------------------------------------------------------------------- static const internetStatus = WidgetSpec( id: 'internet_status', - displayName: 'Internet Status', + displayName: 'Internet Status (Combined)', + description: + 'Combined status including internet connectivity and master router info.', + canHide: false, constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 4, maxColumns: 6, preferredColumns: 6, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, ), DisplayMode.normal: WidgetGridConstraints( minColumns: 6, maxColumns: 8, preferredColumns: 8, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(4.0), + minHeightRows: 2, ), DisplayMode.expanded: WidgetGridConstraints( minColumns: 8, maxColumns: 12, preferredColumns: 12, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(4.0), + minHeightRows: 2, ), }, ); // --------------------------------------------------------------------------- - // Networks (節點狀態) + // Networks (Node Status) // --------------------------------------------------------------------------- static const networks = WidgetSpec( id: 'networks', displayName: 'Networks', + description: 'Combined view of network topology and device counts.', constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 3, maxColumns: 4, preferredColumns: 4, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(2.0), ), DisplayMode.normal: WidgetGridConstraints( minColumns: 4, maxColumns: 4, preferredColumns: 4, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(6.0), ), DisplayMode.expanded: WidgetGridConstraints( minColumns: 4, maxColumns: 6, preferredColumns: 6, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(8.0), ), }, ); @@ -72,24 +79,28 @@ abstract class DashboardWidgetSpecs { static const wifiGrid = WidgetSpec( id: 'wifi_grid', displayName: 'Wi-Fi Networks', + description: 'Overview of Wi-Fi networks and guest access.', constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 6, maxColumns: 8, preferredColumns: 8, - heightStrategy: HeightStrategy.aspectRatio(4.0), // 橫向卡片 + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, ), DisplayMode.normal: WidgetGridConstraints( minColumns: 8, maxColumns: 12, preferredColumns: 8, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: + HeightStrategy.strict(5.0), // 2 rows of cards (176px * 2 + spacing) ), DisplayMode.expanded: WidgetGridConstraints( - minColumns: 12, + minColumns: 6, maxColumns: 12, - preferredColumns: 12, - heightStrategy: HeightStrategy.intrinsic(), + preferredColumns: 8, + heightStrategy: HeightStrategy.strict(7.0), + minHeightRows: 7, ), }, ); @@ -100,24 +111,28 @@ abstract class DashboardWidgetSpecs { static const quickPanel = WidgetSpec( id: 'quick_panel', displayName: 'Quick Panel', + description: 'Quick access to common settings and actions.', constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 3, maxColumns: 4, preferredColumns: 4, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, ), DisplayMode.normal: WidgetGridConstraints( minColumns: 4, maxColumns: 4, preferredColumns: 4, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(3.0), + minHeightRows: 3, ), DisplayMode.expanded: WidgetGridConstraints( minColumns: 4, maxColumns: 6, preferredColumns: 6, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(4.0), + minHeightRows: 2, ), }, ); @@ -128,32 +143,33 @@ abstract class DashboardWidgetSpecs { static const portAndSpeed = WidgetSpec( id: 'port_and_speed', displayName: 'Ports & Speed', + description: 'Combined view of ethernet ports and speed test results.', constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 4, maxColumns: 6, preferredColumns: 6, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(2.0), ), DisplayMode.normal: WidgetGridConstraints( minColumns: 4, maxColumns: 8, preferredColumns: 8, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(4.0), ), DisplayMode.expanded: WidgetGridConstraints( minColumns: 8, maxColumns: 12, preferredColumns: 8, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(4.0), ), }, ); // --------------------------------------------------------------------------- - // 所有規格列表(用於設定 UI 迭代) + // Standard Widgets (used in Standard Layout) // --------------------------------------------------------------------------- - static const List all = [ + static const List standardWidgets = [ internetStatus, networks, wifiGrid, @@ -163,34 +179,443 @@ abstract class DashboardWidgetSpecs { ]; // --------------------------------------------------------------------------- - // VPN (if supported) + // Atomic Widgets (used in Custom/Bento Layout) + // --------------------------------------------------------------------------- + + /// Internet status only (online/offline, geolocation, uptime) + static const internetStatusOnly = WidgetSpec( + id: 'internet_status_only', + displayName: 'Internet Status', + description: 'Shows internet connectivity status only.', + canHide: false, + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: HeightStrategy.intrinsic(), + minHeightRows: 2, + maxHeightRows: 6, + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 4, + maxColumns: 8, + preferredColumns: 6, + heightStrategy: HeightStrategy.strict(3.0), + minHeightRows: 3, + maxHeightRows: 4, + ), + }, + ); + + /// Master node info (router image, model, serial, firmware) + /// Master node info (router image, model, serial, firmware) + static const masterNodeInfo = WidgetSpec( + id: 'master_node_info', + displayName: 'Master Router', + description: 'Information about the master node (model, serial, firmware).', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 8, + preferredColumns: 5, + heightStrategy: HeightStrategy.strict(4.0), + minHeightRows: 3, + maxHeightRows: 5, + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 6, + maxColumns: 12, + preferredColumns: 8, + heightStrategy: HeightStrategy.strict(6.0), + minHeightRows: 5, + maxHeightRows: 8, + ), + }, + ); + + /// Ports status (LAN + WAN) + static const ports = WidgetSpec( + id: 'ports', + displayName: 'Ports', + description: 'Status of LAN and WAN ethernet ports.', + constraints: _portsVerticalConstraints, + ); + + // --------------------------------------------------------------------------- + // Ports Constraints Variations + // --------------------------------------------------------------------------- + + /// Constraints for "No LAN" (WAN only) - Minimal height + static const _portsNoLanConstraints = { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 3, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, + maxHeightRows: 2, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(2.0), + minHeightRows: 4, + maxHeightRows: 4, + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 4, + maxColumns: 8, + preferredColumns: 6, + heightStrategy: HeightStrategy.strict(2.0), + minHeightRows: 4, + maxHeightRows: 4, + ), + }; + + /// Constraints for Horizontal Layout (Row) - Wider, less height + static const _portsHorizontalConstraints = { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 6, + maxColumns: 12, + preferredColumns: 8, + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, + maxHeightRows: 2, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 8, + maxColumns: 12, + preferredColumns: 12, + heightStrategy: HeightStrategy.strict(2.0), + minHeightRows: 4, + maxHeightRows: 6, + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 8, + maxColumns: 12, + preferredColumns: 12, + heightStrategy: HeightStrategy.strict(2.0), + minHeightRows: 4, + maxHeightRows: 6, + ), + }; + + /// Constraints for Vertical Layout (Column) - Narrower, more height + static const _portsVerticalConstraints = { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 3, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, + maxHeightRows: 2, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 8, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(6.0), // Default 6 rows + minHeightRows: 6, + maxHeightRows: 12, + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 6, + maxColumns: 12, + preferredColumns: 8, + heightStrategy: HeightStrategy.strict(8.0), // Match minHeightRows + minHeightRows: 8, + maxHeightRows: 12, + ), + }; + + /// Get ports spec with dynamic constraints based on state + static WidgetSpec getPortsSpec({ + required bool hasLanPort, + required bool isHorizontal, + }) { + Map selectedConstraints; + + if (!hasLanPort) { + selectedConstraints = _portsNoLanConstraints; + } else if (isHorizontal) { + selectedConstraints = _portsHorizontalConstraints; + } else { + selectedConstraints = _portsVerticalConstraints; + } + + return WidgetSpec( + id: ports.id, + displayName: ports.displayName, + description: ports.description, + canHide: ports.canHide, + requirements: ports.requirements, + constraints: selectedConstraints, + ); + } + + /// Speed test results + static const speedTest = WidgetSpec( + id: 'speed_test', + displayName: 'Speed Test', + description: 'Internet speed test results and history.', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(1.0), // Changed from 2.0 + minHeightRows: 1, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(4.0), + minHeightRows: 4, // Explicitly enforce minimum height + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 6, + maxColumns: 12, + preferredColumns: 8, + heightStrategy: HeightStrategy.strict( + 6.0), // Increased from 4.0 to maintain hierarchy + minHeightRows: 6, + ), + }, + ); + + /// Network stats (nodes/devices count) + static const networkStats = WidgetSpec( + id: 'network_stats', + displayName: 'Network Stats', + description: 'Counts of connected nodes and devices.', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(2.0), + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 4, + maxColumns: 8, + preferredColumns: 6, + heightStrategy: HeightStrategy.strict(2.0), + ), + }, + ); + + /// Mesh topology tree view + static const topology = WidgetSpec( + id: 'topology', + displayName: 'Topology', + description: 'Visual map of your mesh network topology.', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 8, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(4.0), + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 8, + maxColumns: 12, + preferredColumns: 8, + heightStrategy: HeightStrategy.strict(6.0), + minHeightRows: 5, + ), + }, + ); + + // --------------------------------------------------------------------------- + // Custom Layout Specific Widgets (Duplicated for Isolation) + // --------------------------------------------------------------------------- + + /// Wi-Fi Grid for Custom Layout + static const wifiGridCustom = WidgetSpec( + id: 'wifi_grid_custom', + displayName: 'Wi-Fi Networks', + description: 'Overview of Wi-Fi networks and guest access.', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 6, + maxColumns: 8, + preferredColumns: 8, + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 8, + maxColumns: 12, + preferredColumns: 8, + heightStrategy: + HeightStrategy.strict(5.0), // 2 rows of cards (176px * 2 + spacing) + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 6, + maxColumns: 12, + preferredColumns: 8, + heightStrategy: HeightStrategy.strict(7.0), + minHeightRows: 7, + ), + }, + ); + + /// Quick Panel for Custom Layout + static const quickPanelCustom = WidgetSpec( + id: 'quick_panel_custom', + displayName: 'Quick Panel', + description: 'Quick access to common settings and actions.', + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 3, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(3.0), + minHeightRows: 3, + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 6, + heightStrategy: HeightStrategy.strict(3.0), + minHeightRows: 3, + ), + }, + ); + + /// VPN for Custom Layout + static const vpnCustom = WidgetSpec( + id: 'vpn_custom', + displayName: 'VPN', + description: 'VPN connection status.', + requirements: [WidgetRequirement.vpnSupported], + constraints: { + DisplayMode.compact: WidgetGridConstraints( + minColumns: 3, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, + ), + DisplayMode.normal: WidgetGridConstraints( + minColumns: 4, + maxColumns: 4, + preferredColumns: 4, + heightStrategy: HeightStrategy.strict(4.0), + ), + DisplayMode.expanded: WidgetGridConstraints( + minColumns: 4, + maxColumns: 6, + preferredColumns: 6, + heightStrategy: HeightStrategy.strict(4.0), + ), + }, + ); + + /// Custom layout widgets (atomic components) + static const List customWidgets = [ + internetStatusOnly, + masterNodeInfo, + ports, + speedTest, + networkStats, + topology, + wifiGridCustom, + quickPanelCustom, + vpnCustom, + ]; + + // --------------------------------------------------------------------------- + // All Specs List (for UI iteration) // --------------------------------------------------------------------------- + static const List all = [ + internetStatus, + networks, + wifiGrid, + quickPanel, + portAndSpeed, + vpn, + // Atomic widgets + internetStatusOnly, + masterNodeInfo, + ports, + speedTest, + networkStats, + topology, + wifiGridCustom, + quickPanelCustom, + vpnCustom, + ]; + + // ... (Standard VPN def remains but is now used only for standard list logic if any) + // Actually we need to keep standard VPN def for standard lists static const vpn = WidgetSpec( id: 'vpn', displayName: 'VPN', + description: 'VPN connection status.', + requirements: [WidgetRequirement.vpnSupported], constraints: { DisplayMode.compact: WidgetGridConstraints( minColumns: 3, maxColumns: 4, preferredColumns: 4, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(1.0), + minHeightRows: 1, ), DisplayMode.normal: WidgetGridConstraints( minColumns: 4, maxColumns: 4, preferredColumns: 4, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(4.0), ), DisplayMode.expanded: WidgetGridConstraints( minColumns: 4, maxColumns: 6, preferredColumns: 6, - heightStrategy: HeightStrategy.intrinsic(), + heightStrategy: HeightStrategy.strict(4.0), ), }, ); - /// 根據 ID 查詢規格 + /// Get spec by ID static WidgetSpec? getById(String id) { for (final spec in all) { if (spec.id == id) return spec; diff --git a/lib/page/dashboard/models/display_mode.dart b/lib/page/dashboard/models/display_mode.dart index 7ee3fff2f..4f84a72ab 100644 --- a/lib/page/dashboard/models/display_mode.dart +++ b/lib/page/dashboard/models/display_mode.dart @@ -1,13 +1,13 @@ -/// 元件顯示模式 +/// Component Display Mode /// -/// 定義 Dashboard 元件的三種顯示密度級別。 +/// Defines three levels of display density for Dashboard components. enum DisplayMode { - /// 最小化顯示,只顯示關鍵資訊 + /// Compact display, showing only key information compact, - /// 預設標準顯示 + /// Default standard display normal, - /// 放大顯示,完整資訊 + /// Expanded display, full information expanded, } diff --git a/lib/page/dashboard/models/height_strategy.dart b/lib/page/dashboard/models/height_strategy.dart index ac33b6671..30dfb768e 100644 --- a/lib/page/dashboard/models/height_strategy.dart +++ b/lib/page/dashboard/models/height_strategy.dart @@ -1,27 +1,33 @@ -/// 高度計算策略 +/// Height Calculation Strategy /// -/// 定義 Dashboard 元件的高度計算方式。 -/// 使用 sealed class 確保類型安全的模式匹配。 +/// Defines how the height of Dashboard components is calculated. +/// Uses a sealed class to ensure type-safe pattern matching. sealed class HeightStrategy { const HeightStrategy(); - /// 讓元件自己決定高度(intrinsic sizing) + /// Let the component determine its own height (intrinsic sizing) const factory HeightStrategy.intrinsic() = IntrinsicHeightStrategy; - /// 高度 = 單個 column 寬度 × 倍數 + /// Height = Single column width * multiplier /// - /// 例:multiplier=2.0 表示高度為 2 個 column 寬度 + /// Example: multiplier=2.0 means height is 2 times the column width const factory HeightStrategy.columnBased(double multiplier) = ColumnBasedHeightStrategy; - /// 固定寬高比 + /// Specialized for Bento Grid: Force specify the "Row Span" of the grid /// - /// [ratio] = width / height,例:16/9 = 1.78 + /// Logically equivalent to [ColumnBasedHeightStrategy], but semantically clearer. + /// Example: rows=2 means the component occupies 2 units of height. + const factory HeightStrategy.strict(double rows) = ColumnBasedHeightStrategy; + + /// Fixed Aspect Ratio + /// + /// [ratio] = width / height, e.g., 16/9 = 1.78 const factory HeightStrategy.aspectRatio(double ratio) = AspectRatioHeightStrategy; } -/// 讓元件自行決定高度 +/// Let component determine height class IntrinsicHeightStrategy extends HeightStrategy { const IntrinsicHeightStrategy(); @@ -32,9 +38,9 @@ class IntrinsicHeightStrategy extends HeightStrategy { int get hashCode => runtimeType.hashCode; } -/// 基於欄寬倍數的高度 +/// Height based on column width multiplier class ColumnBasedHeightStrategy extends HeightStrategy { - /// 欄寬倍數,高度 = singleColumnWidth * multiplier + /// Column width multiplier, height = singleColumnWidth * multiplier final double multiplier; const ColumnBasedHeightStrategy(this.multiplier); @@ -47,9 +53,9 @@ class ColumnBasedHeightStrategy extends HeightStrategy { int get hashCode => multiplier.hashCode; } -/// 固定寬高比 +/// Fixed Aspect Ratio class AspectRatioHeightStrategy extends HeightStrategy { - /// 寬高比 (width / height) + /// Aspect ratio (width / height) final double ratio; const AspectRatioHeightStrategy(this.ratio); diff --git a/lib/page/dashboard/models/widget_grid_constraints.dart b/lib/page/dashboard/models/widget_grid_constraints.dart index 492ae1d76..094e78d56 100644 --- a/lib/page/dashboard/models/widget_grid_constraints.dart +++ b/lib/page/dashboard/models/widget_grid_constraints.dart @@ -1,55 +1,86 @@ import 'dart:math'; import 'height_strategy.dart'; -/// 基於 12-column grid 的元件約束 +/// Grid constraints based on a 12-column layout /// -/// 所有 column 數值都是基於 12-column 設計, -/// 會自動按比例縮放到目前的 currentMaxColumns(4/8/12)。 +/// All column values are designed based on 12 columns, +/// and will automatically scale to the current currentMaxColumns (4/8/12). class WidgetGridConstraints { - /// 最小佔用欄數(基於 12-column) + /// Minimum occupied columns (based on 12-column) final int minColumns; - /// 最大佔用欄數(基於 12-column) + /// Maximum occupied columns (based on 12-column) final int maxColumns; - /// 理想/預設佔用欄數(基於 12-column) + /// Preferred/Default occupied columns (based on 12-column) final int preferredColumns; - /// 高度計算策略 + /// Minimum row height constraints (Optional, default 1) + final int minHeightRows; + + /// Maximum row height constraints (Optional, default 12) + final int maxHeightRows; + + /// Height Calculation Strategy final HeightStrategy heightStrategy; const WidgetGridConstraints({ required this.minColumns, required this.maxColumns, required this.preferredColumns, - this.heightStrategy = const HeightStrategy.intrinsic(), + required this.heightStrategy, + this.minHeightRows = 1, + this.maxHeightRows = 12, }) : assert(minColumns >= 1 && minColumns <= 12), assert(maxColumns >= minColumns && maxColumns <= 12), assert( - preferredColumns >= minColumns && preferredColumns <= maxColumns); + preferredColumns >= minColumns && preferredColumns <= maxColumns), + assert(maxHeightRows >= minHeightRows); - /// 按比例縮放到目標 column 數 + /// Scale proportionally to target max columns /// - /// 例:preferredColumns=6 在 desktop(12) = 6 - /// 在 tablet(8) = 6 * 8 / 12 = 4 + /// Example: preferredColumns=6 on desktop(12) = 6 + /// on tablet(8) = 6 * 8 / 12 = 4 int scaleToMaxColumns(int targetMaxColumns) { return (preferredColumns * targetMaxColumns / 12) .round() .clamp(1, targetMaxColumns); } - /// 縮放 minColumns 到目標 column 數 + /// Scale minColumns to target max columns int scaleMinToMaxColumns(int targetMaxColumns) { return max(1, (minColumns * targetMaxColumns / 12).round()); } - /// 縮放 maxColumns 到目標 column 數 + /// Scale maxColumns to target max columns int scaleMaxToMaxColumns(int targetMaxColumns) { return (maxColumns * targetMaxColumns / 12) .round() .clamp(1, targetMaxColumns); } + /// Calculate preferred height in grid (in cells) + /// + /// Used for StaggeredGrid and sliver_dashboard height settings. + /// [columns] is the actual occupied columns (used for AspectRatio calculation) + int getPreferredHeightCells({int? columns}) { + final cols = columns ?? preferredColumns; + return switch (heightStrategy) { + ColumnBasedHeightStrategy(:final multiplier) => multiplier.ceil(), + AspectRatioHeightStrategy(:final ratio) => + (cols / ratio).ceil().clamp(1, 12), + IntrinsicHeightStrategy() => + minHeightRows.clamp(2, 6), // Unified default: 2-6 + }; + } + + /// Get height range (min, max) + /// + /// Used for sliver_dashboard LayoutItem constraints + (double min, double max) getHeightRange() { + return (minHeightRows.toDouble(), maxHeightRows.toDouble()); + } + @override bool operator ==(Object other) => other is WidgetGridConstraints && diff --git a/lib/page/dashboard/models/widget_spec.dart b/lib/page/dashboard/models/widget_spec.dart index 09be4440d..a61032b79 100644 --- a/lib/page/dashboard/models/widget_spec.dart +++ b/lib/page/dashboard/models/widget_spec.dart @@ -1,33 +1,67 @@ import 'display_mode.dart'; import 'widget_grid_constraints.dart'; -/// 元件規格定義 +/// Component Specification Definition /// -/// 每種 DisplayMode 對應不同的 grid 約束。 +/// Each DisplayMode corresponds to different grid constraints. +/// Runtime requirements for a widget to be available. +enum WidgetRequirement { + none, + vpnSupported, +} + class WidgetSpec { - /// 元件唯一識別碼 + /// Unique component ID final String id; - /// 顯示名稱(用於設定 UI) + /// Display name (for Settings UI) final String displayName; - /// 各 DisplayMode 的約束定義 + /// Brief description of the widget's function. + final String? description; + + /// Constraint definitions for each DisplayMode final Map constraints; + /// Whether the widget can be hidden by the user. + /// + /// Defaults to true. Set to false for mandatory widgets (e.g. Internet Status). + final bool canHide; + + /// List of requirements for this widget to be available. + final List requirements; + const WidgetSpec({ required this.id, required this.displayName, required this.constraints, + this.description, + this.canHide = true, + this.requirements = const [], }); - /// 取得指定模式的約束,若無則回傳 normal 模式 + /// Get constraints for specified mode, fallback to normal mode if missing WidgetGridConstraints getConstraints(DisplayMode mode) => constraints[mode] ?? constraints[DisplayMode.normal]!; @override bool operator ==(Object other) => - other is WidgetSpec && other.id == id && other.displayName == displayName; + other is WidgetSpec && + other.id == id && + other.displayName == displayName && + other.canHide == canHide && + _listEquals(other.requirements, requirements); @override - int get hashCode => Object.hash(id, displayName); + int get hashCode => + Object.hash(id, displayName, canHide, Object.hashAll(requirements)); + + bool _listEquals(List? a, List? b) { + if (a == null) return b == null; + if (b == null || a.length != b.length) return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } } diff --git a/lib/page/dashboard/providers/dashboard_home_state.dart b/lib/page/dashboard/providers/dashboard_home_state.dart index 70c1a6b0e..442648bbd 100644 --- a/lib/page/dashboard/providers/dashboard_home_state.dart +++ b/lib/page/dashboard/providers/dashboard_home_state.dart @@ -139,6 +139,8 @@ class DashboardHomeState extends Equatable { final List wifis; final String? wanType; final String? detectedWANType; + final String? cpuLoad; + final String? memoryLoad; const DashboardHomeState({ this.isFirstPolling = false, @@ -151,6 +153,8 @@ class DashboardHomeState extends Equatable { this.wifis = const [], this.wanType, this.detectedWANType, + this.cpuLoad, + this.memoryLoad, }); Map toMap() { @@ -165,6 +169,8 @@ class DashboardHomeState extends Equatable { 'wifis': wifis.map((x) => x.toMap()).toList(), 'wanType': wanType, 'detectedWANType': detectedWANType, + 'cpuLoad': cpuLoad, + 'memoryLoad': memoryLoad, }; } @@ -181,6 +187,8 @@ class DashboardHomeState extends Equatable { map['wifis']?.map((x) => DashboardWiFiUIModel.fromMap(x))), wanType: map['wanType'], detectedWANType: map['detectedWANType'], + cpuLoad: map['cpuLoad'], + memoryLoad: map['memoryLoad'], ); } @@ -205,6 +213,8 @@ class DashboardHomeState extends Equatable { wifis, wanType, detectedWANType, + cpuLoad, + memoryLoad, ]; } @@ -219,6 +229,8 @@ class DashboardHomeState extends Equatable { List? wifis, ValueGetter? wanType, ValueGetter? detectedWANType, + ValueGetter? cpuLoad, + ValueGetter? memoryLoad, }) { return DashboardHomeState( isFirstPolling: isFirstPolling ?? this.isFirstPolling, @@ -234,12 +246,14 @@ class DashboardHomeState extends Equatable { wanType: wanType != null ? wanType() : this.wanType, detectedWANType: detectedWANType != null ? detectedWANType() : this.detectedWANType, + cpuLoad: cpuLoad != null ? cpuLoad() : this.cpuLoad, + memoryLoad: memoryLoad != null ? memoryLoad() : this.memoryLoad, ); } @override String toString() { - return 'DashboardHomeState(isFirstPolling: $isFirstPolling, isHorizontalLayout: $isHorizontalLayout, masterIcon: $masterIcon, isAnyNodesOffline: $isAnyNodesOffline, uptime: $uptime, wanPortConnection: $wanPortConnection, lanPortConnections: $lanPortConnections, wifis: $wifis, wanType: $wanType, detectedWANType: $detectedWANType)'; + return 'DashboardHomeState(isFirstPolling: $isFirstPolling, isHorizontalLayout: $isHorizontalLayout, masterIcon: $masterIcon, isAnyNodesOffline: $isAnyNodesOffline, uptime: $uptime, wanPortConnection: $wanPortConnection, lanPortConnections: $lanPortConnections, wifis: $wifis, wanType: $wanType, detectedWANType: $detectedWANType, cpuLoad: $cpuLoad, memoryLoad: $memoryLoad)'; } } diff --git a/lib/page/dashboard/providers/dashboard_preferences_provider.dart b/lib/page/dashboard/providers/dashboard_preferences_provider.dart index c9293bd14..53f32db91 100644 --- a/lib/page/dashboard/providers/dashboard_preferences_provider.dart +++ b/lib/page/dashboard/providers/dashboard_preferences_provider.dart @@ -57,6 +57,18 @@ class DashboardPreferencesNotifier await _saveToPrefs(); } + /// Reorder custom widgets (atomic widgets only) + Future reorderCustomWidget(int oldIndex, int newIndex) async { + state = state.reorderCustomWidget(oldIndex, newIndex); + await _saveToPrefs(); + } + + /// Restore preferences from a snapshot + Future restoreSnapshot(DashboardLayoutPreferences snapshot) async { + state = snapshot; + await _saveToPrefs(); + } + /// Toggle custom layout usage Future toggleCustomLayout(bool enabled) async { state = state.toggleCustomLayout(enabled); @@ -75,4 +87,13 @@ class DashboardPreferencesNotifier final prefs = await SharedPreferences.getInstance(); await prefs.remove(_prefsKey); } + + /// Reset only widget display modes to defaults, preserving custom layout toggle + Future resetWidgetModes() async { + state = DashboardLayoutPreferences( + useCustomLayout: state.useCustomLayout, + // displayModes will be empty (defaults) + ); + await _saveToPrefs(); + } } diff --git a/lib/page/dashboard/providers/layout_item_factory.dart b/lib/page/dashboard/providers/layout_item_factory.dart new file mode 100644 index 000000000..bcbe11f97 --- /dev/null +++ b/lib/page/dashboard/providers/layout_item_factory.dart @@ -0,0 +1,209 @@ +import 'package:sliver_dashboard/sliver_dashboard.dart'; + +import '../models/display_mode.dart'; +import '../models/dashboard_widget_specs.dart'; +import '../models/widget_spec.dart'; + +/// Function type for resolving WidgetSpecs dynamically. +/// +/// Used by [LayoutItemFactory.createDefaultLayout] to allow callers to +/// provide pre-resolved specs (e.g., dynamic Ports constraints based on +/// hardware state). +typedef WidgetSpecResolver = WidgetSpec Function(WidgetSpec defaultSpec); + +/// Factory for creating LayoutItems from DashboardWidgetSpecs. +/// +/// Converts UI Kit spec constraints (minColumns, maxColumns, heightStrategy) +/// to sliver_dashboard's LayoutItem format (minW, maxW, minH, maxH). +/// +/// This factory follows IoC (Inversion of Control): +/// - It does NOT resolve dynamic constraints internally +/// - Callers are responsible for providing already-resolved WidgetSpecs +/// - Use [specResolver] in [createDefaultLayout] for dynamic specs +class LayoutItemFactory { + LayoutItemFactory._(); + + /// Create a LayoutItem from a WidgetSpec with position and display mode. + /// + /// [spec] - The widget specification containing constraints (should be + /// pre-resolved if dynamic constraints are needed) + /// [x] - Grid X position (column) + /// [y] - Grid Y position (row) + /// [w] - Initial width in grid slots (defaults to preferredColumns) + /// [h] - Initial height in grid slots (defaults from HeightStrategy) + /// [displayMode] - The display mode to use for constraints + static LayoutItem fromSpec( + WidgetSpec spec, { + required int x, + required int y, + int? w, + int? h, + DisplayMode displayMode = DisplayMode.normal, + }) { + final constraints = spec.constraints[displayMode]; + if (constraints == null) { + // Fallback to default constraints + return LayoutItem( + id: spec.id, + x: x, + y: y, + w: w ?? 4, + h: h ?? 2, + ); + } + + // Calculate dimensions from spec + final preferredWidth = w ?? constraints.preferredColumns; + final preferredHeight = + h ?? constraints.getPreferredHeightCells(columns: preferredWidth); + + return LayoutItem( + id: spec.id, + x: x, + y: y, + w: preferredWidth, + h: preferredHeight, + minW: constraints.minColumns, + maxW: constraints.maxColumns.toDouble(), + minH: constraints.minHeightRows, + maxH: constraints.maxHeightRows.toDouble(), + ); + } + + /// Create default layout from customWidgets specs. + /// + /// Uses a smart placement algorithm to position widgets. + /// + /// [specResolver] - Optional function to resolve dynamic specs. + /// If provided, each spec will be passed through this + /// resolver before creating the LayoutItem. This allows + /// callers to inject dynamic constraints (e.g., Ports + /// constraints based on hardware state) following IoC. + /// If null, uses the default static specs. + static List createDefaultLayout({ + DisplayMode displayMode = DisplayMode.normal, + WidgetSpecResolver? specResolver, + }) { + final items = []; + + // Helper to resolve spec (uses resolver if provided, otherwise identity) + WidgetSpec resolve(WidgetSpec spec) => specResolver?.call(spec) ?? spec; + + // Get resolved Ports spec for layout calculations + final portsSpec = resolve(DashboardWidgetSpecs.ports); + final portsConstraints = portsSpec.constraints[displayMode]; + final portsPreferredW = portsConstraints?.preferredColumns ?? 4; + final portsPreferredH = + portsConstraints?.getPreferredHeightCells(columns: portsPreferredW) ?? + 6; + + // Layout based on the target screenshot: + // ┌─────────────┬─────────────┬─────────────┐ + // │ Internet │ Master │ Quick Panel │ y=0 + // │ (4x2) │ (4x6) │ (4x3) │ + // ├─────────────┤ ├─────────────┤ y=2 + // │ │ │ NetworkStats│ + // │ Ports │ │ (4x2) │ y=3 + // │ (4x6+) ├─────────────┼─────────────┤ y=4/5 + // │ │ SpeedTest │ Topology │ + // │ │ (4x4) │ (4x2) │ + // ├─────────────┴─────────────┴─────────────┤ y=10 + // │ WiFi Grid (8x2) │ + // └─────────────────────────────────────────┘ + + // Row 0: Internet (top-left) + items.add(fromSpec( + resolve(DashboardWidgetSpecs.internetStatusOnly), + x: 0, + y: 0, + w: 4, + h: 2, + displayMode: displayMode, + )); + + // Row 0: Master Router (top-middle, spans 4 rows) + items.add(fromSpec( + resolve(DashboardWidgetSpecs.masterNodeInfo), + x: 4, + y: 0, + w: 4, + h: 4, + displayMode: displayMode, + )); + + // Row 0: Quick Panel (top-right) + items.add(fromSpec( + resolve(DashboardWidgetSpecs.quickPanelCustom), + x: 8, + y: 0, + w: 4, + h: 3, + displayMode: displayMode, + )); + + // Row 2: Ports (left side, below Internet) + items.add(fromSpec( + portsSpec, + x: 0, + y: 2, + w: portsPreferredW, + h: portsPreferredH, + displayMode: displayMode, + )); + + // Row 3: Network Stats (right side, below Quick Panel) + items.add(fromSpec( + resolve(DashboardWidgetSpecs.networkStats), + x: 8, + y: 3, + w: 4, + h: 2, + displayMode: displayMode, + )); + + // Row 5: Topology (right side, below Network Stats) + items.add(fromSpec( + resolve(DashboardWidgetSpecs.topology), + x: 8, + y: 5, + w: 4, + h: 4, + displayMode: displayMode, + )); + + // Row 6: SpeedTest (middle, below Master Router) + final speedTestSpec = resolve(DashboardWidgetSpecs.speedTest); + items.add(fromSpec( + speedTestSpec, + x: 4, + y: 6, + w: 4, + h: 4, + displayMode: displayMode, + )); + + // Calculate bottom Y (max of Ports, SpeedTest, Topology) + final bottomY = [ + 2 + portsPreferredH, // Ports ends at y=2+h + 6 + 4, // SpeedTest ends at y=10 + 5 + 2, // Topology ends at y=7 + ].reduce((a, b) => a > b ? a : b); + + // WiFi Grid (spans across bottom) + items.add(fromSpec( + resolve(DashboardWidgetSpecs.wifiGridCustom), + x: 0, + y: bottomY, + w: 8, + // h: removed to use preferredHeight from spec (5 in Normal mode) + displayMode: displayMode, + )); + + return items; + } + + /// Get available spec by ID for building widgets. + static WidgetSpec? getSpecById(String id) { + return DashboardWidgetSpecs.getById(id); + } +} diff --git a/lib/page/dashboard/providers/sliver_dashboard_controller_provider.dart b/lib/page/dashboard/providers/sliver_dashboard_controller_provider.dart new file mode 100644 index 000000000..7e1e01635 --- /dev/null +++ b/lib/page/dashboard/providers/sliver_dashboard_controller_provider.dart @@ -0,0 +1,295 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sliver_dashboard/sliver_dashboard.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/widget_spec.dart'; +import 'package:privacy_gui/page/dashboard/models/widget_grid_constraints.dart'; + +import 'layout_item_factory.dart'; +import 'dashboard_home_provider.dart'; + +const _sliverDashboardLayoutKey = 'sliver_dashboard_layout'; + +/// Provider for the Sliver Dashboard Controller. +/// +/// Manages the drag-drop grid layout for the custom dashboard. +final sliverDashboardControllerProvider = StateNotifierProvider< + SliverDashboardControllerNotifier, DashboardController>( + (ref) => SliverDashboardControllerNotifier(ref), +); + +/// Notifier for managing the Sliver Dashboard Controller. +class SliverDashboardControllerNotifier + extends StateNotifier { + final Ref _ref; + + SliverDashboardControllerNotifier(this._ref) + : super(_createDefaultController()) { + _initializeLayout(); + } + + /// Creates a spec resolver based on hardware state. + /// + /// This follows IoC by creating the resolver at the composition root + /// and injecting it into the factory. + WidgetSpecResolver _createSpecResolver({ + required bool hasLanPort, + required bool isHorizontalLayout, + }) { + return (WidgetSpec defaultSpec) { + // Only Ports widget needs dynamic resolution currently + if (defaultSpec.id == DashboardWidgetSpecs.ports.id) { + return DashboardWidgetSpecs.getPortsSpec( + hasLanPort: hasLanPort, + isHorizontal: hasLanPort && isHorizontalLayout, + ); + } + return defaultSpec; + }; + } + + /// Gets the current spec resolver based on hardware state. + WidgetSpecResolver _getCurrentSpecResolver() { + final dashboardState = _ref.read(dashboardHomeProvider); + final hasLanPort = dashboardState.lanPortConnections.isNotEmpty; + final isHorizontalLayout = dashboardState.isHorizontalLayout; + + return _createSpecResolver( + hasLanPort: hasLanPort, + isHorizontalLayout: isHorizontalLayout, + ); + } + + static DashboardController _createDefaultController({ + WidgetSpecResolver? specResolver, + }) { + return DashboardController( + initialSlotCount: 12, + initialLayout: LayoutItemFactory.createDefaultLayout( + specResolver: specResolver, + ), + ); + } + + /// Initialize layout: load from storage or create with dynamic constraints. + Future _initializeLayout() async { + final prefs = await SharedPreferences.getInstance(); + final layoutJson = prefs.getString(_sliverDashboardLayoutKey); + + if (layoutJson != null) { + // Load saved layout + try { + final layoutData = jsonDecode(layoutJson) as List; + state.importLayout(layoutData); + } catch (e) { + debugPrint('Failed to load sliver dashboard layout: $e'); + // On failure, recreate with dynamic constraints + _recreateWithDynamicConstraints(); + } + } else { + // No saved layout - create new with dynamic constraints + _recreateWithDynamicConstraints(); + } + } + + /// Recreate controller with dynamic constraints based on current hardware state. + void _recreateWithDynamicConstraints() { + state = _createDefaultController( + specResolver: _getCurrentSpecResolver(), + ); + // Optimize layout to fill gaps and compact the grid + state.optimizeLayout(); + } + + /// Save layout to SharedPreferences. + Future saveLayout() async { + final prefs = await SharedPreferences.getInstance(); + final layoutData = state.exportLayout(); + await prefs.setString(_sliverDashboardLayoutKey, jsonEncode(layoutData)); + } + + /// Reset layout to defaults using current hardware configuration. + Future resetLayout() async { + // Get current hardware state + final dashboardState = _ref.read(dashboardHomeProvider); + final hasLanPort = dashboardState.lanPortConnections.isNotEmpty; + final isHorizontalLayout = dashboardState.isHorizontalLayout; + + // Create resolver with current hardware config (IoC) + final resolver = _createSpecResolver( + hasLanPort: hasLanPort, + isHorizontalLayout: isHorizontalLayout, + ); + + state = _createDefaultController(specResolver: resolver); + // Optimize layout to fill gaps and compact the grid + state.optimizeLayout(); + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_sliverDashboardLayoutKey); + } + + /// Update item constraints (min/max width) for a specific display mode. + /// + /// This is used to sync the controller's internal constraints with the + /// visual display mode (e.g., when switching to Expanded). + Future updateItemConstraints( + String id, + DisplayMode mode, { + WidgetGridConstraints? overrideConstraints, + }) async { + WidgetGridConstraints? constraints; + + if (overrideConstraints != null) { + constraints = overrideConstraints; + } else { + // Lookup spec from all specs (since no static helper exists) + WidgetSpec? spec; + try { + spec = DashboardWidgetSpecs.all.firstWhere((s) => s.id == id); + constraints = spec.constraints[mode]; + } catch (_) { + return; + } + } + + if (constraints == null) return; + + // Export current layout, modify the specific item, and re-import + final currentLayout = state.exportLayout(); + bool changed = false; + + final newLayout = currentLayout.map((item) { + if (item['id'] == id) { + final mutableItem = Map.from(item); + // Update constraints using camelCase keys (standard package convention) + mutableItem['minW'] = constraints!.minColumns; + mutableItem['maxW'] = constraints.maxColumns.toDouble(); + + // Also ensure current width respects new min/max + num w = mutableItem['w'] ?? 1; + if (w < constraints.minColumns) { + mutableItem['w'] = constraints.minColumns; + } + if (w > constraints.maxColumns) { + mutableItem['w'] = constraints.maxColumns; // Clamp + } + + changed = true; + return mutableItem; + } + return item; + }).toList(); + + if (changed) { + state.importLayout(newLayout); + await saveLayout(); + } + } + + /// Force update an item's size (w/h). + /// + /// Used to correct invalid sizes after a resize operation (e.g. enforcing minWidth). + Future updateItemSize(String id, int w, int h) async { + final currentLayout = state.exportLayout(); + bool changed = false; + + final newLayout = currentLayout.map((item) { + if (item['id'] == id) { + final mutableItem = Map.from(item); + if (mutableItem['w'] != w || mutableItem['h'] != h) { + mutableItem['w'] = w; + mutableItem['h'] = h; + changed = true; + } + return mutableItem; + } + return item; + }).toList(); + + if (changed) { + state.importLayout(newLayout); + await saveLayout(); + } + } + + /// Add a widget to the dashboard layout. + /// + /// Appends the widget to the bottom of the current layout. + Future addWidget(String id) async { + // 1. Check if already exists + final currentLayout = state.exportLayout(); + if (currentLayout.any((item) => (item as Map)['id'] == id)) { + return; // Already exists + } + + // 2. Get spec + final spec = DashboardWidgetSpecs.getById(id); + if (spec == null) return; + + // 3. Calculate position (bottom) + int maxY = 0; + if (currentLayout.isNotEmpty) { + for (final item in currentLayout) { + final map = item as Map; + final y = map['y'] as int; + final h = map['h'] as int; + if (y + h > maxY) { + maxY = y + h; + } + } + } + + // 4. Create new item + // Use LayoutItemFactory logic but manually construct map since we are dealing with export/import format + // Or better, creating a LayoutItem and adding to controller? + // Controller.add() expects LayoutItem. But we want to persist details like minW/maxW. + // LayoutItemFactory returns LayoutItem. + // The exportLayout returns List (Maps). + // Let's create LayoutItem first to get correct dimensions. + + final item = LayoutItemFactory.fromSpec( + spec, + x: 0, + y: maxY, + displayMode: DisplayMode.normal, // Default to normal when adding + ); + + // 5. Append via import mechanism + // Construct map manually to ensure all properties (min/max) are set + final newItemMap = { + 'id': item.id, + 'x': item.x, + 'y': item.y, + 'w': item.w, + 'h': item.h, + // Use camelCase for package compatibility if needed, but package usually handles underscores too? + // Looking at updateItemConstraints, we used 'minW'. + 'minW': item.minW, + 'maxW': item.maxW, + 'minH': item.minH, + 'maxH': item.maxH, + }; + + final newLayout = [...currentLayout, newItemMap]; + + state.importLayout(newLayout); + await saveLayout(); + } + + /// Remove a widget from the dashboard layout. + Future removeWidget(String id) async { + final currentLayout = state.exportLayout(); + final newLayout = + currentLayout.where((item) => (item as Map)['id'] != id).toList(); + + if (newLayout.length != currentLayout.length) { + state.importLayout(newLayout); + await saveLayout(); + } + } +} diff --git a/lib/page/dashboard/services/dashboard_home_service.dart b/lib/page/dashboard/services/dashboard_home_service.dart index cd41800b6..bd53faca2 100644 --- a/lib/page/dashboard/services/dashboard_home_service.dart +++ b/lib/page/dashboard/services/dashboard_home_service.dart @@ -89,6 +89,8 @@ class DashboardHomeService { isHorizontalLayout: horizontalPortLayout, wanType: wanType, detectedWANType: detectedWANType, + cpuLoad: systemStatsState.cpuLoad, + memoryLoad: systemStatsState.memoryLoad, ); } diff --git a/lib/page/dashboard/strategies/custom_dashboard_layout_strategy.dart b/lib/page/dashboard/strategies/custom_dashboard_layout_strategy.dart deleted file mode 100644 index c4f176c19..000000000 --- a/lib/page/dashboard/strategies/custom_dashboard_layout_strategy.dart +++ /dev/null @@ -1,37 +0,0 @@ -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 6f4b99f30..d3d012828 100644 --- a/lib/page/dashboard/strategies/dashboard_layout_context.dart +++ b/lib/page/dashboard/strategies/dashboard_layout_context.dart @@ -163,6 +163,20 @@ class DashboardLayoutContext { ); } + /// Wraps a widget in a standard AppCard. + /// + /// Used by layout strategies to provide a consistent visual container + /// for "stripped" widgets. + Widget wrapWithStandardCard( + Widget child, { + EdgeInsetsGeometry? padding, + }) { + return AppCard( + padding: padding, + child: child, + ); + } + /// Wraps a widget with size constraints based on its spec and user preferences. Widget wrapWidget( Widget child, { @@ -213,31 +227,69 @@ class DashboardLayoutContext { if (vpnTile != null) DashboardWidgetSpecs.vpn.id: vpnTile!, }; + /// Get a widget by its ID. + Widget? getWidgetById(String id) => _allWidgets[id]; + + /// Get ordered list of visible widget specs. + List get orderedVisibleSpecs { + final widgets = _allWidgets; + + // 1. Get ordered specs from configs + final orderedSpecs = DashboardWidgetSpecs.standardWidgets.toList() + ..sort((a, b) { + final configA = getConfigFor(a); + final configB = getConfigFor(b); + return configA.order.compareTo(configB.order); + }); + + // 2. Filter visible + 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); + }).toList(); + } + /// 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() + final orderedSpecs = DashboardWidgetSpecs.standardWidgets.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(); + // 2. Filter visible & wrap + return orderedSpecs.where((spec) { + final config = getConfigFor(spec); + return config.visible; + }).map((spec) { + final child = _getStandardWidgetById(spec.id); + if (child == null) return const SizedBox.shrink(); + + // Wrap based on mode constraints + // For Standard Layout, we provide a basic minHeight to ensure visibility + // The actual height is determined by the content (Standard Layout is usually a Column/ListView) + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 120), + child: child, + ); + }).toList(); + } + + Widget? _getStandardWidgetById(String id) { + if (id == DashboardWidgetSpecs.internetStatus.id) return internetWidget; + if (id == DashboardWidgetSpecs.networks.id) return networksWidget; + if (id == DashboardWidgetSpecs.wifiGrid.id) return wifiGrid; + if (id == DashboardWidgetSpecs.quickPanel.id) return quickPanel; + if (id == DashboardWidgetSpecs.vpn.id) return vpnTile; + if (id == DashboardWidgetSpecs.portAndSpeed.id) { + // For standard layouts in ordered list (e.g. mobile), use default config + return buildPortAndSpeed(const PortAndSpeedConfig()); + } + return null; } } diff --git a/lib/page/dashboard/strategies/desktop_vertical_layout_strategy.dart b/lib/page/dashboard/strategies/desktop_vertical_layout_strategy.dart index d83e68d10..0a5ab2f89 100644 --- a/lib/page/dashboard/strategies/desktop_vertical_layout_strategy.dart +++ b/lib/page/dashboard/strategies/desktop_vertical_layout_strategy.dart @@ -28,7 +28,7 @@ class DesktopVerticalLayoutStrategy extends DashboardLayoutStrategy { children: [ ctx.buildPortAndSpeed(const PortAndSpeedConfig( direction: Axis.vertical, - showSpeedTest: false, + showSpeedTest: true, portsHeight: 752, portsPadding: EdgeInsets.symmetric( horizontal: AppSpacing.sm, diff --git a/lib/page/dashboard/strategies/grid_layout_resolver.dart b/lib/page/dashboard/strategies/grid_layout_resolver.dart index eace5f4ae..42a66ef3d 100644 --- a/lib/page/dashboard/strategies/grid_layout_resolver.dart +++ b/lib/page/dashboard/strategies/grid_layout_resolver.dart @@ -91,6 +91,35 @@ class GridLayoutResolver { }; } + /// Calculate grid main axis cell count (height in grid units) + /// + /// Returns null for intrinsic sizing (use StaggeredGridTile.fit) + num? resolveGridMainAxisCellCount( + WidgetSpec spec, + DisplayMode mode, { + int? availableColumns, + int? overrideColumns, + }) { + final constraints = spec.getConstraints(mode); + final columns = resolveColumns( + spec, + mode, + availableColumns: availableColumns, + overrideColumns: overrideColumns, + ); + + return switch (constraints.heightStrategy) { + IntrinsicHeightStrategy() => null, + // In column-based strategy, the multiplier is essentially the row count + // relative to a single column width. + ColumnBasedHeightStrategy(multiplier: final m) => m, + // ratio = width / height + // height = width / ratio + // heightUnits = columnUnits / ratio + AspectRatioHeightStrategy(ratio: final r) => columns / r, + }; + } + /// Build constrained SizedBox wrapper /// /// Height is null when using intrinsic sizing diff --git a/lib/page/dashboard/views/components/_components.dart b/lib/page/dashboard/views/components/_components.dart index 362497745..62104384f 100644 --- a/lib/page/dashboard/views/components/_components.dart +++ b/lib/page/dashboard/views/components/_components.dart @@ -4,16 +4,34 @@ library; export 'settings/dashboard_layout_settings_panel.dart'; export 'core/dashboard_loading_wrapper.dart'; export 'core/dashboard_tile.dart'; -export 'widgets/parts/external_speed_test_links.dart'; +export 'core/display_mode_widget.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'; + +// Shared parts +export 'widgets/parts/external_speed_test_links.dart'; +export 'widgets/parts/internal_speed_test_result.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'; + +// Shared widgets +export 'widgets/home_title.dart'; + +// Composite widgets (for fixed layouts) +export 'widgets/composite/internet_status.dart'; +export 'widgets/composite/networks.dart'; +export 'widgets/composite/port_and_speed.dart'; +export 'widgets/composite/quick_panel.dart'; +export 'widgets/composite/wifi_grid.dart'; + +// Atomic widgets (for custom layout / Bento Grid) +export 'widgets/atomic/internet_status_only.dart'; // CustomInternetStatus +export 'widgets/atomic/master_node_info.dart'; +export 'widgets/atomic/ports.dart'; +export 'widgets/atomic/speed_test.dart'; +export 'widgets/atomic/network_stats.dart'; +export 'widgets/atomic/topology.dart'; // CustomTopology +export 'widgets/atomic/custom_wifi_grid.dart'; +export 'widgets/atomic/custom_quick_panel.dart'; +export 'widgets/atomic/custom_vpn.dart'; diff --git a/lib/page/dashboard/views/components/core/dashboard_loading_wrapper.dart b/lib/page/dashboard/views/components/core/dashboard_loading_wrapper.dart index c1b3f1295..1122d4d72 100644 --- a/lib/page/dashboard/views/components/core/dashboard_loading_wrapper.dart +++ b/lib/page/dashboard/views/components/core/dashboard_loading_wrapper.dart @@ -46,10 +46,18 @@ class DashboardLoadingWrapper extends ConsumerWidget { if (isLoading) { return AppCard( padding: EdgeInsets.zero, - child: SizedBox( - width: loadingWidth ?? double.infinity, - height: loadingHeight, - child: const LoadingTile(), + child: LayoutBuilder( + builder: (context, constraints) { + final effectiveHeight = constraints.hasBoundedHeight && + constraints.maxHeight < loadingHeight + ? constraints.maxHeight + : loadingHeight; + return SizedBox( + width: loadingWidth ?? double.infinity, + height: effectiveHeight, + child: const LoadingTile(), + ); + }, ), ); } diff --git a/lib/page/dashboard/views/components/core/display_mode_widget.dart b/lib/page/dashboard/views/components/core/display_mode_widget.dart new file mode 100644 index 000000000..25dcc99eb --- /dev/null +++ b/lib/page/dashboard/views/components/core/display_mode_widget.dart @@ -0,0 +1,140 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'dashboard_loading_wrapper.dart'; + +/// Mixin for widgets that support display modes. +/// +/// Provides a common interface for Compact/Normal/Expanded rendering. +mixin DisplayModeAware { + DisplayMode get displayMode; +} + +/// Base class for atomic dashboard widgets (stateless). +/// +/// Provides: +/// - Unified displayMode parameter +/// - Automatic loading wrapper integration +/// - Standard build pattern with mode-specific builders +/// +/// Usage: +/// ```dart +/// class CustomInternetStatus extends DisplayModeConsumerWidget { +/// const CustomInternetStatus({super.key, super.displayMode}); +/// +/// @override +/// double getLoadingHeight(DisplayMode mode) => switch (mode) { +/// DisplayMode.compact => 40, +/// DisplayMode.normal => 80, +/// DisplayMode.expanded => 100, +/// }; +/// +/// @override +/// Widget buildCompactView(BuildContext context, WidgetRef ref) { +/// // Compact implementation +/// } +/// // ... other build methods +/// } +/// ``` +abstract class DisplayModeConsumerWidget extends ConsumerWidget + with DisplayModeAware { + const DisplayModeConsumerWidget({ + super.key, + this.displayMode = DisplayMode.normal, + }); + + @override + final DisplayMode displayMode; + + /// Loading height for each display mode. + double getLoadingHeight(DisplayMode mode); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DashboardLoadingWrapper( + loadingHeight: getLoadingHeight(displayMode), + builder: (context, ref) => buildContent(context, ref), + ); + } + + /// Build content based on current displayMode. + Widget buildContent(BuildContext context, WidgetRef ref) { + return switch (displayMode) { + DisplayMode.compact => buildCompactView(context, ref), + DisplayMode.normal => buildNormalView(context, ref), + DisplayMode.expanded => buildExpandedView(context, ref), + }; + } + + /// Override to build compact view. + Widget buildCompactView(BuildContext context, WidgetRef ref); + + /// Override to build normal view. + Widget buildNormalView(BuildContext context, WidgetRef ref); + + /// Override to build expanded view. + Widget buildExpandedView(BuildContext context, WidgetRef ref); +} + +/// Base class for atomic dashboard widgets (stateful). +/// +/// Use when your widget needs local state (e.g., tooltip visibility). +abstract class DisplayModeConsumerStatefulWidget extends ConsumerStatefulWidget + with DisplayModeAware { + const DisplayModeConsumerStatefulWidget({ + super.key, + this.displayMode = DisplayMode.normal, + }); + + @override + final DisplayMode displayMode; +} + +/// Mixin for State classes of DisplayModeConsumerStatefulWidget. +/// +/// Usage: +/// ```dart +/// class _CustomWiFiGridState extends ConsumerState +/// with DisplayModeStateMixin { +/// +/// @override +/// double getLoadingHeight(DisplayMode mode) => 200; +/// +/// @override +/// Widget buildCompactView(BuildContext context, WidgetRef ref) { +/// // implementation +/// } +/// // ... +/// } +/// ``` +mixin DisplayModeStateMixin + on ConsumerState { + /// Loading height for each display mode. + double getLoadingHeight(DisplayMode mode); + + @override + Widget build(BuildContext context) { + return DashboardLoadingWrapper( + loadingHeight: getLoadingHeight(widget.displayMode), + builder: (context, ref) => buildContent(context, ref), + ); + } + + /// Build content based on current displayMode. + 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), + }; + } + + /// Override to build compact view. + Widget buildCompactView(BuildContext context, WidgetRef ref); + + /// Override to build normal view. + Widget buildNormalView(BuildContext context, WidgetRef ref); + + /// Override to build expanded view. + Widget buildExpandedView(BuildContext context, WidgetRef ref); +} diff --git a/lib/page/dashboard/views/components/core/loading_tile.dart b/lib/page/dashboard/views/components/core/loading_tile.dart index fa71415ef..d00e31475 100644 --- a/lib/page/dashboard/views/components/core/loading_tile.dart +++ b/lib/page/dashboard/views/components/core/loading_tile.dart @@ -188,33 +188,60 @@ class _LoadingTileState extends State { final baseColor = widget.baseColor; final highlightColor = widget.shimmerColor; - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppSkeleton( - width: double.infinity, - height: 24, - baseColor: baseColor, - highlightColor: highlightColor, + // Use LayoutBuilder to adapt content based on available space + return LayoutBuilder( + builder: (context, constraints) { + final content = Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + AppSkeleton( + width: double.infinity, + height: 24, + baseColor: baseColor, + highlightColor: highlightColor, + ), + if (constraints.maxHeight > 80) ...[ + AppGap.lg(), + AppSkeleton( + width: 200, + height: 16, + baseColor: baseColor, + highlightColor: highlightColor, + ), + ], + if (constraints.maxHeight > 120) ...[ + AppGap.sm(), + AppSkeleton( + width: 150, + height: 16, + baseColor: baseColor, + highlightColor: highlightColor, + ), + ], + ], ), - AppGap.lg(), - AppSkeleton( - width: 200, - height: 16, - baseColor: baseColor, - highlightColor: highlightColor, - ), - AppGap.sm(), - AppSkeleton( - width: 150, - height: 16, - baseColor: baseColor, - highlightColor: highlightColor, - ), - ], - ), + ); + + // If height is very small, just show a single centered skeleton bar + if (constraints.maxHeight < 56) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: AppSkeleton( + width: double.infinity, + height: 24, // Consistent height + baseColor: baseColor, + highlightColor: highlightColor, + ), + ), + ); + } + + return content; + }, ); } } diff --git a/lib/page/dashboard/views/components/effects/jiggle_shake.dart b/lib/page/dashboard/views/components/effects/jiggle_shake.dart new file mode 100644 index 000000000..702142ab7 --- /dev/null +++ b/lib/page/dashboard/views/components/effects/jiggle_shake.dart @@ -0,0 +1,101 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; + +/// Applies a random jiggle (shake) animation to its child when [active]. +/// +/// Used in dashboard edit mode to indicate editable items. +class JiggleShake extends StatefulWidget { + final bool active; + final Widget child; + final double degrees; + + const JiggleShake({ + super.key, + required this.active, + required this.child, + this.degrees = 0.5, + }); + + @override + State createState() => _JiggleShakeState(); +} + +class _JiggleShakeState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _animation; + final _random = Random(); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 140), + vsync: this, + ); + + // Randomize the shake direction/offset slightly so items don't shake in perfect sync + final bool startPositive = _random.nextBool(); + final double maxRad = widget.degrees * (pi / 180); + + _animation = Tween( + begin: startPositive ? -maxRad : maxRad, + end: startPositive ? maxRad : -maxRad, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + + if (widget.active) { + _startShaking(); + } + } + + @override + void didUpdateWidget(covariant JiggleShake oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.active != oldWidget.active) { + if (widget.active) { + _startShaking(); + } else { + _stopShaking(); + } + } + } + + void _startShaking() { + // Add a tiny random delay before starting to enhance the organic feel + Future.delayed(Duration(milliseconds: _random.nextInt(50)), () { + if (mounted && widget.active) { + _controller.repeat(reverse: true); + } + }); + } + + void _stopShaking() { + _controller.stop(); + _controller.animateTo(0.5); // Reset to center (approx) or just reset + _controller.reset(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Optimization: If not active and not animating, return child directly (or Rotation with 0) + // But RotationTransition with 0 turns is cheap. + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + // If inactive, force 0 rotation + final turns = widget.active ? _animation.value : 0.0; + return Transform.rotate( + angle: turns, + child: child, + ); + }, + child: widget.child, + ); + } +} diff --git a/lib/page/dashboard/views/components/fixed_layout/external_speed_test_links.dart b/lib/page/dashboard/views/components/fixed_layout/external_speed_test_links.dart new file mode 100644 index 000000000..e0a813033 --- /dev/null +++ b/lib/page/dashboard/views/components/fixed_layout/external_speed_test_links.dart @@ -0,0 +1,139 @@ +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'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Widget for external speed test links (Fast.com, Cloudflare). +class ExternalSpeedTestLinks extends StatelessWidget { + final DashboardHomeState state; + + const ExternalSpeedTestLinks({super.key, required this.state}); + + @override + Widget build(BuildContext context) { + final horizontalLayout = state.isHorizontalLayout; + final hasLanPort = state.lanPortConnections.isNotEmpty; + final isVerticalDesktop = + hasLanPort && !horizontalLayout && !context.isMobileLayout; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + border: Border.all(color: Colors.transparent), + borderRadius: BorderRadius.circular(12).copyWith( + topLeft: Radius.circular(0), + topRight: Radius.circular(0), + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xl, + vertical: AppSpacing.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(context, horizontalLayout, hasLanPort), + AppGap.xs(), + isVerticalDesktop + ? _buildVerticalButtons(context) + : _buildHorizontalButtons(context), + AppGap.xs(), + AppText.bodyExtraSmall(loc(context).speedTestExternalOthers), + ], + ), + ); + } + + Widget _buildHeader( + BuildContext context, bool horizontalLayout, bool hasLanPort) { + final speedTitle = AppText.titleMedium(loc(context).speedTextTileStart); + final infoIcon = InkWell( + child: AppIcon.font( + AppFontIcons.infoCircle, + color: Theme.of(context).colorScheme.primary, + ), + onTap: () => openUrl('https://support.linksys.com/kb/article/79-en/'), + ); + final speedDesc = + AppText.labelSmall(loc(context).speedTestExternalTileLabel); + + final showRowHeader = + context.isMobileLayout || (hasLanPort && horizontalLayout); + + if (showRowHeader) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: speedTitle), + infoIcon, + speedDesc, + ], + ); + } + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align(alignment: AlignmentDirectional.centerStart, child: speedTitle), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + infoIcon, + AppGap.sm(), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: speedDesc, + ), + ), + ], + ), + ], + ); + } + + Widget _buildVerticalButtons(BuildContext context) { + return SizedBox( + width: 144, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + spacing: AppSpacing.sm, + children: [ + AppButton( + label: loc(context).speedTestExternalTileCloudFlare, + onTap: () => openUrl('https://speed.cloudflare.com/'), + ), + AppButton( + label: loc(context).speedTestExternalTileFast, + onTap: () => openUrl('https://www.fast.com'), + ), + ], + ), + ); + } + + Widget _buildHorizontalButtons(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: AppSpacing.lg, + children: [ + Expanded( + child: AppButton( + label: loc(context).speedTestExternalTileCloudFlare, + onTap: () => openUrl('https://speed.cloudflare.com/'), + ), + ), + Expanded( + child: AppButton( + label: loc(context).speedTestExternalTileFast, + onTap: () => openUrl('https://www.fast.com'), + ), + ), + ], + ); + } +} diff --git a/lib/page/dashboard/views/components/fixed_layout/internal_speed_test_result.dart b/lib/page/dashboard/views/components/fixed_layout/internal_speed_test_result.dart new file mode 100644 index 000000000..4b3f2ebc5 --- /dev/null +++ b/lib/page/dashboard/views/components/fixed_layout/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/fixed_layout/internet_status.dart b/lib/page/dashboard/views/components/fixed_layout/internet_status.dart new file mode 100644 index 000000000..4c7a6b299 --- /dev/null +++ b/lib/page/dashboard/views/components/fixed_layout/internet_status.dart @@ -0,0 +1,491 @@ +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 FixedInternetConnectionWidget extends ConsumerStatefulWidget { + const FixedInternetConnectionWidget({ + super.key, + this.displayMode = DisplayMode.normal, + this.useAppCard = true, + }); + + /// The display mode for this widget + final DisplayMode displayMode; + + /// Whether to wrap the content in an AppCard (default true). + /// Set to false for layouts that provide their own containers. + final bool useAppCard; + + @override + ConsumerState createState() => + _FixedInternetConnectionWidgetState(); +} + +class _FixedInternetConnectionWidgetState + 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 => 40, + 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 content = 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(), + // 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(); + }); + }, + ), + ), + ], + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: const EdgeInsets.all(AppSpacing.sm), + child: content, + ); + } + + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + + /// 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'; + + final content = 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); + }, + ), + ] + ], + ), + ]), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: content, + ); + } + return content; + } + + /// 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'; + + final content = 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); + }, + ), + ] + ], + ), + ]), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: content, + ); + } + return content; + } + + /// 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/fixed_layout/networks.dart b/lib/page/dashboard/views/components/fixed_layout/networks.dart new file mode 100644 index 000000000..6ee03da47 --- /dev/null +++ b/lib/page/dashboard/views/components/fixed_layout/networks.dart @@ -0,0 +1,432 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; +import 'package:privacy_gui/core/data/providers/firmware_update_provider.dart'; +import 'package:privacy_gui/core/data/providers/node_internet_status_provider.dart'; +import 'package:privacy_gui/core/utils/devices.dart'; +import 'package:privacy_gui/core/utils/topology_adapter.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/dashboard/_dashboard.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/dashboard_loading_wrapper.dart'; +import 'package:privacy_gui/page/instant_topology/providers/_providers.dart'; +import 'package:privacy_gui/page/instant_topology/views/model/topology_model.dart'; +import 'package:privacy_gui/page/nodes/providers/node_detail_id_provider.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Widget displaying network topology and nodes. +/// +/// Supports three display modes: +/// - [DisplayMode.compact]: Minimal view with node/device counts only +/// - [DisplayMode.normal]: Standard view with topology tree +/// - [DisplayMode.expanded]: Full view with detailed topology +class FixedDashboardNetworks extends ConsumerWidget { + const FixedDashboardNetworks({ + super.key, + this.displayMode = DisplayMode.normal, + this.useAppCard = true, + }); + + /// The display mode for this widget + final DisplayMode displayMode; + + /// Whether to wrap the content in an AppCard (default true). + final bool useAppCard; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DashboardLoadingWrapper( + loadingHeight: _getLoadingHeight(), + builder: (context, ref) => _buildContent(context, ref), + ); + } + + double _getLoadingHeight() { + return switch (displayMode) { + DisplayMode.compact => 120, + DisplayMode.normal => 250, + DisplayMode.expanded => 350, + }; + } + + Widget _buildContent(BuildContext context, WidgetRef ref) { + return switch (displayMode) { + DisplayMode.compact => _buildCompactView(context, ref), + DisplayMode.normal => _buildNormalView(context, ref), + DisplayMode.expanded => _buildExpandedView(context, ref), + }; + } + + /// Compact view: Nodes and devices count displayed side by side + Widget _buildCompactView(BuildContext context, WidgetRef ref) { + final topologyState = ref.watch(instantTopologyProvider); + final nodes = topologyState.root.children.firstOrNull?.toFlatList() ?? []; + final hasOffline = nodes.any((element) => !element.data.isOnline); + final externalDeviceCount = ref + .watch(deviceManagerProvider) + .externalDevices + .where((e) => e.isOnline()) + .length; + + final content = 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, + ), + ], + ), + ), + ), + ], + ), + ); + + if (useAppCard) { + return AppCard( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + + /// Normal view: Standard view with topology tree (existing implementation) + Widget _buildNormalView(BuildContext context, WidgetRef ref) { + final state = ref.watch(dashboardHomeProvider); + final topologyState = ref.watch(instantTopologyProvider); + + // Convert topology data to ui_kit format + final meshTopology = TopologyAdapter.convert(topologyState.root.children); + + // Calculate topology height + const topologyItemHeight = 72.0; + const treeViewBaseHeight = 72.0; + final routerLength = + topologyState.root.children.firstOrNull?.toFlatList().length ?? 1; + final double nodeTopologyHeight = context.isMobileLayout + ? routerLength * topologyItemHeight + : min(routerLength * topologyItemHeight, 3 * topologyItemHeight); + final showAllTopology = context.isMobileLayout || routerLength <= 3; + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppGap.lg(), + _buildNetworkHeader(context, ref, state), + SizedBox( + height: nodeTopologyHeight + treeViewBaseHeight, + child: _buildTopologyView( + context, + ref, + meshTopology, + topologyState, + showAllTopology, + ), + ), + ], + ); + + if (useAppCard) { + return AppCard( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + + /// 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; + + final content = 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 + ), + ), + ], + ); + + if (useAppCard) { + return AppCard( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + + /// Unified network header with title, firmware status, and info tiles. + Widget _buildNetworkHeader( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + ) { + final topologyState = ref.watch(instantTopologyProvider); + final wanStatus = ref.watch(internetStatusProvider); + final newFirmware = _hasNewFirmware(ref); + final isOnline = wanStatus == InternetStatus.online; + final hasLanPort = + ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; + + // Determine layout variant + final layoutVariant = DashboardLayoutVariant.fromContext( + context, + hasLanPort: hasLanPort, + isHorizontalLayout: state.isHorizontalLayout, + ); + final useVerticalLayout = + layoutVariant == DashboardLayoutVariant.desktopVertical || + layoutVariant == DashboardLayoutVariant.tabletVertical; + + final titleSection = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.titleSmall(loc(context).myNetwork), + if (isOnline) _firmwareStatusWidget(context, newFirmware), + ], + ); + + final infoTilesSection = Row( + children: [ + Expanded(child: _nodesInfoTile(context, ref, topologyState)), + AppGap.gutter(), + Expanded(child: _devicesInfoTile(context, ref, topologyState)), + ], + ); + + // Desktop vertical layout: title on left, info tiles on right + if (useVerticalLayout) { + return Row( + children: [ + titleSection, + const Spacer(), + Expanded(flex: 3, child: infoTilesSection), + ], + ); + } + + // Mobile and desktop horizontal layout: title on top, info tiles below + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + titleSection, + AppGap.xxl(), + infoTilesSection, + ], + ); + } + + Widget _buildTopologyView( + BuildContext context, + WidgetRef ref, + MeshTopology meshTopology, + InstantTopologyState topologyState, + bool showAllTopology, + ) { + return AppTopology( + topology: meshTopology, + viewMode: TopologyViewMode.tree, + enableAnimation: !showAllTopology, + onNodeTap: TopologyAdapter.wrapNodeTapCallback( + topologyState.root.children, + (RouterTreeNode node) { + if (node.data.isOnline) { + ref.read(nodeDetailIdProvider.notifier).state = node.data.deviceId; + context.pushNamed(RouteNamed.nodeDetails); + } + }, + ), + indent: 16.0, + treeConfig: TopologyTreeConfiguration( + preferAnimationNode: false, + showType: false, + showStatusText: false, + showStatusIndicator: true, + titleBuilder: (meshNode) => meshNode.name, + subtitleBuilder: (meshNode) { + final model = meshNode.extra; + final deviceCount = + meshNode.metadata?['connectedDeviceCount'] as int? ?? 0; + final deviceLabel = + deviceCount <= 1 ? loc(context).device : loc(context).devices; + + if (model == null || model.isEmpty) { + return '$deviceCount $deviceLabel'; + } + return '$model • $deviceCount $deviceLabel'; + }, + ), + ); + } + + bool _hasNewFirmware(WidgetRef ref) { + final nodesStatus = + ref.watch(firmwareUpdateProvider.select((value) => value.nodesStatus)); + return nodesStatus?.any((element) => element.availableUpdate != null) ?? + false; + } + + Widget _firmwareStatusWidget(BuildContext context, bool newFirmware) { + return InkWell( + onTap: newFirmware + ? () => context.pushNamed(RouteNamed.firmwareUpdateDetail) + : null, + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + newFirmware + ? AppText.labelMedium( + loc(context).updateFirmware, + color: Theme.of(context).colorScheme.primary, + ) + : AppStyledText(text: loc(context).dashboardFirmwareUpdateToDate), + newFirmware + ? AppIcon.font( + AppFontIcons.cloudDownload, + color: Theme.of(context).colorScheme.primary, + ) + : AppIcon.font( + AppFontIcons.check, + color: Theme.of(context) + .extension() + ?.semanticSuccess ?? + Colors.green, + ), + ], + ), + ); + } + + Widget _nodesInfoTile( + BuildContext context, WidgetRef ref, InstantTopologyState state) { + final nodes = state.root.children.firstOrNull?.toFlatList() ?? []; + final hasOffline = nodes.any((element) => !element.data.isOnline); + return _infoTile( + icon: hasOffline + ? AppIcon.font(AppFontIcons.infoCircle, + color: Theme.of(context).colorScheme.error) + : AppIcon.font(AppFontIcons.networkNode), + text: nodes.length == 1 ? loc(context).node : loc(context).nodes, + count: nodes.length, + onTap: () { + ref.read(topologySelectedIdProvider.notifier).state = ''; + context.pushNamed(RouteNamed.menuInstantTopology); + }, + ); + } + + Widget _devicesInfoTile( + BuildContext context, WidgetRef ref, InstantTopologyState state) { + final externalDeviceCount = ref + .watch(deviceManagerProvider) + .externalDevices + .where((e) => e.isOnline()) + .length; + + return _infoTile( + text: + externalDeviceCount == 1 ? loc(context).device : loc(context).devices, + count: externalDeviceCount, + icon: AppIcon.font(AppFontIcons.devices), + onTap: () => context.pushNamed(RouteNamed.menuInstantDevices), + ); + } + + Widget _infoTile({ + required String text, + required int count, + required Widget icon, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible(child: AppText.titleSmall('$count')), + icon, + ], + ), + AppGap.lg(), + AppText.bodySmall(text), + ], + ), + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/fixed_layout/port_and_speed.dart b/lib/page/dashboard/views/components/fixed_layout/port_and_speed.dart new file mode 100644 index 000000000..52cdfca6b --- /dev/null +++ b/lib/page/dashboard/views/components/fixed_layout/port_and_speed.dart @@ -0,0 +1,370 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/constants/build_config.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +import 'package:privacy_gui/page/dashboard/strategies/dashboard_layout_context.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/dashboard_loading_wrapper.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/external_speed_test_links.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/internal_speed_test_result.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/port_status_widget.dart'; +import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; +import 'package:privacy_gui/page/health_check/widgets/speed_test_widget.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Dashboard widget showing port connections and speed test results. +/// +/// This component follows IoC (Inversion of Control) - configuration is +/// provided by the parent [DashboardLayoutStrategy] via [PortAndSpeedConfig], +/// rather than self-determining layout based on variant. +/// +/// Supports three display modes: +/// - [DisplayMode.compact]: Minimal port status only +/// - [DisplayMode.normal]: Ports with speed test +/// - [DisplayMode.expanded]: Detailed port and speed info +class FixedDashboardHomePortAndSpeed extends ConsumerWidget { + const FixedDashboardHomePortAndSpeed({ + super.key, + required this.config, + this.displayMode = DisplayMode.normal, + this.useAppCard = true, + }); + + /// Configuration provided by the parent Strategy. + final PortAndSpeedConfig config; + + /// The display mode for this widget + final DisplayMode displayMode; + + /// Whether to wrap the content in an AppCard (default true). + final bool useAppCard; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DashboardLoadingWrapper( + loadingHeight: _getLoadingHeight(), + builder: (context, ref) { + final state = ref.watch(dashboardHomeProvider); + return _buildLayout(context, ref, state); + }, + ); + } + + double _getLoadingHeight() { + return switch (displayMode) { + DisplayMode.compact => 150, + DisplayMode.normal => 250, + DisplayMode.expanded => 350, + }; + } + + Widget _buildLayout( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + ) { + if (displayMode == DisplayMode.compact) { + return _buildCompactView(context, ref, state); + } + + // Bypass LayoutBuilder if direction is explicit (fixes IntrinsicHeight errors in legacy layouts) + if (config.direction != null) { + return displayMode == DisplayMode.normal + ? _buildNormalView( + context, ref, state, config.direction!, const BoxConstraints()) + : _buildExpandedView(context, ref, state, config.direction!); + } + + // For Normal and Expanded, we might need auto-direction logic + return LayoutBuilder( + builder: (context, constraints) { + // Determine direction: + // 1. Explicit config + // 2. Auto-detect based on width (breakpoint at 6 columns) + // Min columns updated to 4. + // 4-5 cols -> Vertical. + // 6+ cols -> Horizontal. + final Axis direction = constraints.maxWidth < context.colWidth(6) + ? Axis.vertical + : Axis.horizontal; + + return displayMode == DisplayMode.normal + ? _buildNormalView(context, ref, state, direction, constraints) + : _buildExpandedView(context, ref, state, direction); + }, + ); + } + + /// Compact view: Port status icons only, no speed test + Widget _buildCompactView( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + ) { + final content = 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, + ), + ], + ); + + if (useAppCard) { + return AppCard( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: content, + ); + } + + return Padding( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: content, + ); + } + + Widget _compactPortIcon( + BuildContext context, { + required String label, + required bool isConnected, + required bool isWan, + }) { + return Tooltip( + message: '$label: ${isConnected ? "Connected" : "Disconnected"}', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isWan ? Icons.public : Icons.lan, + size: 20, + color: isConnected + ? Theme.of(context) + .extension() + ?.colorScheme + .semanticSuccess ?? + Colors.green + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + AppGap.xs(), + AppText.labelSmall( + label, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ); + } + + /// Normal view: Ports with speed test (existing implementation) + Widget _buildNormalView( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + Axis direction, + BoxConstraints constraints, + ) { + final hasLanPort = state.lanPortConnections.isNotEmpty; + final isVertical = direction == Axis.vertical; + + // Calculate minimum height based on config + // If auto-layout, we rely on intrinsic sizing primarily, but can keep minHeight for consistency if needed. + // final minHeight = _calculateMinHeight(isVertical, hasLanPort); + + final content = SizedBox( + width: double.infinity, + // constraints: BoxConstraints(minHeight: minHeight), + child: Column( + mainAxisSize: + config.portsHeight == null ? MainAxisSize.min : MainAxisSize.max, + mainAxisAlignment: isVertical + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.start, + children: [ + SizedBox( + height: config.portsHeight, + child: Padding( + padding: config.portsPadding, + child: _buildPortsSection(context, state, direction), + ), + ), + if (config.showSpeedTest) + SizedBox( + width: double.infinity, + height: config.speedTestHeight, + child: _buildSpeedTestSection(context, ref, state, hasLanPort), + ), + ], + ), + ); + + if (useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: content, + ); + } + return content; + } + + /// Expanded view: Detailed port and speed info + Widget _buildExpandedView( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + Axis direction, + ) { + final hasLanPort = state.lanPortConnections.isNotEmpty; + + final content = Container( + width: double.infinity, + constraints: BoxConstraints(minHeight: 400), + 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), + ), + ], + ], + ), + ); + + if (useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: content, + ); + } + return content; + } + + Widget _buildPortsSection( + BuildContext context, + DashboardHomeState state, + Axis direction, + ) { + final hasLanPort = state.lanPortConnections.isNotEmpty; + final isVertical = direction == Axis.vertical; + + // Build LAN port widgets + final lanPorts = state.lanPortConnections.mapIndexed((index, e) { + final port = PortStatusWidget( + connection: e == 'None' ? null : e, + label: loc(context).indexedPort(index + 1), + isWan: false, + hasLanPorts: hasLanPort, + ); + return isVertical + ? Padding(padding: const EdgeInsets.only(bottom: 36.0), child: port) + : Expanded(child: port); + }).toList(); + + // Build WAN port widget + final wanPort = PortStatusWidget( + connection: + state.wanPortConnection == 'None' ? null : state.wanPortConnection, + label: loc(context).wan, + isWan: true, + hasLanPorts: hasLanPort, + ); + + // Arrange based on config direction + if (isVertical) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ...lanPorts, + wanPort, + ], + ); + } else { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...lanPorts, + Expanded(child: wanPort), + ], + ); + } + } + + Widget _buildSpeedTestSection( + BuildContext context, + WidgetRef ref, + DashboardHomeState state, + bool hasLanPort, + ) { + final isRemote = BuildConfig.isRemote(); + final isHealthCheckSupported = + ref.watch(healthCheckProvider).isSpeedTestModuleSupported; + + if (isHealthCheckSupported) { + return hasLanPort + ? Column( + children: [ + const Divider(), + const SpeedTestWidget( + showDetails: false, + showInfoPanel: true, + showStepDescriptions: false, + showLatestOnIdle: true, + layout: SpeedTestLayout.vertical, + ), + AppGap.xxl(), + ], + ) + : InternalSpeedTestResult(state: state); + } + + return Tooltip( + message: loc(context).featureUnavailableInRemoteMode, + child: Opacity( + opacity: isRemote ? 0.5 : 1, + child: AbsorbPointer( + absorbing: isRemote, + child: ExternalSpeedTestLinks(state: state), + ), + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/fixed_layout/port_status_widget.dart b/lib/page/dashboard/views/components/fixed_layout/port_status_widget.dart new file mode 100644 index 000000000..cf39a3c6f --- /dev/null +++ b/lib/page/dashboard/views/components/fixed_layout/port_status_widget.dart @@ -0,0 +1,198 @@ +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 - use colorFilter for connected state (green) + final isConnected = connection != null; + final portColor = isConnected + ? Theme.of(context).extension()?.semanticSuccess ?? + Colors.green + : Theme.of(context).colorScheme.surfaceContainerHighest; + + final portImage = Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: isConnected + ? Assets.images.imgPortOn.svg( + width: 40, + height: 40, + colorFilter: ColorFilter.mode(portColor, BlendMode.srcIn), + semanticsLabel: 'port status image', + ) + : Assets.images.imgPortOff.svg( + width: 40, + height: 40, + colorFilter: ColorFilter.mode(portColor, BlendMode.srcIn), + semanticsLabel: 'port status image', + ), + ); + + // Connection speed info + Widget? connectionInfo; + if (connection != null) { + connectionInfo = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + hasLanPorts ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon.font( + AppFontIcons.bidirectional, + color: Theme.of(context).colorScheme.primary, + ), + hasLanPorts + ? AppText.bodySmall(connection!) + : AppText.bodyMedium(connection!), + ], + ), + AppText.bodySmall( + loc(context).connectedSpeed, + textAlign: TextAlign.center, + ), + ], + ); + } + + // WAN label + final wanLabel = isWan ? AppText.labelMedium(loc(context).internet) : null; + + // Build appropriate layout based on hasLanPorts + if (hasLanPorts) { + return _buildVerticalLayout( + context, + isMobile, + portLabelWidgets, + portImage, + connectionInfo, + wanLabel, + ); + } else { + return _buildHorizontalLayout( + context, + isMobile, + portLabelWidgets, + portImage, + connectionInfo, + wanLabel, + ); + } + } + + Widget _buildVerticalLayout( + BuildContext context, + bool isMobile, + List portLabelWidgets, + Widget portImage, + Widget? connectionInfo, + Widget? wanLabel, + ) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + isMobile + ? Column(children: portLabelWidgets) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: portLabelWidgets, + ), + ], + ), + portImage, + if (connectionInfo != null) connectionInfo, + if (wanLabel != null) wanLabel, + ], + ); + } + + Widget _buildHorizontalLayout( + BuildContext context, + bool isMobile, + List portLabelWidgets, + Widget portImage, + Widget? connectionInfo, + Widget? wanLabel, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + isMobile + ? Column(children: portLabelWidgets) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: portLabelWidgets, + ), + ], + ), + portImage, + ], + ), + AppGap.md(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (connectionInfo != null) connectionInfo, + if (wanLabel != null) wanLabel, + ], + ), + ), + ], + ); + } +} diff --git a/lib/page/dashboard/views/components/fixed_layout/quick_panel.dart b/lib/page/dashboard/views/components/fixed_layout/quick_panel.dart new file mode 100644 index 000000000..8726b7d18 --- /dev/null +++ b/lib/page/dashboard/views/components/fixed_layout/quick_panel.dart @@ -0,0 +1,457 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; + +import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.dart'; +import 'package:privacy_gui/core/utils/nodes.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/dashboard_loading_wrapper.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_device_list_provider.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_provider.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; +import 'package:privacy_gui/page/instant_topology/providers/instant_topology_provider.dart'; +import 'package:privacy_gui/page/nodes/providers/node_detail_state.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Quick actions panel for the dashboard. +/// +/// Supports three display modes: +/// - [DisplayMode.compact]: Minimal toggles only +/// - [DisplayMode.normal]: Standard toggle list +/// - [DisplayMode.expanded]: Expanded toggles with descriptions +class FixedDashboardQuickPanel extends ConsumerStatefulWidget { + const FixedDashboardQuickPanel({ + super.key, + this.displayMode = DisplayMode.normal, + this.useAppCard = true, + }); + + /// The display mode for this widget + final DisplayMode displayMode; + + /// Whether to wrap the content in an AppCard (default true). + final bool useAppCard; + + @override + ConsumerState createState() => + _FixedDashboardQuickPanelState(); +} + +class _FixedDashboardQuickPanelState + extends ConsumerState { + @override + Widget build(BuildContext context) { + return DashboardLoadingWrapper( + loadingHeight: _getLoadingHeight(), + builder: (context, ref) => _buildContent(context, ref), + ); + } + + double _getLoadingHeight() { + return switch (widget.displayMode) { + DisplayMode.compact => 100, + DisplayMode.normal => 150, + DisplayMode.expanded => 200, + }; + } + + Widget _buildContent(BuildContext context, WidgetRef ref) { + return switch (widget.displayMode) { + DisplayMode.compact => _buildCompactView(context, ref), + DisplayMode.normal => _buildNormalView(context, ref), + DisplayMode.expanded => _buildExpandedView(context, ref), + }; + } + + /// Compact view: Icon-only toggles in a row + Widget _buildCompactView(BuildContext context, WidgetRef ref) { + final privacyState = ref.watch(instantPrivacyProvider); + final nodeLightState = ref.watch(nodeLightSettingsProvider); + final master = ref.watch(instantTopologyProvider).root.children.first; + bool isSupportNodeLight = serviceHelper.isSupportLedMode(); + bool isCognitive = isCognitiveMeshRouter( + modelNumber: master.data.model, + hardwareVersion: master.data.hardwareVersion); + + final content = 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.isNightModeEnabled, + label: loc(context).nightMode, + onToggle: (value) { + final notifier = ref.read(nodeLightSettingsProvider.notifier); + if (value) { + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: true, allDayOff: false)); + } else { + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: false, allDayOff: false)); + } + doSomethingWithSpinner(context, notifier.save()); + }, + ), + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: content, + ); + } + return Padding( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: content, + ); + } + + Widget _compactToggle( + BuildContext context, { + required IconData icon, + required bool isActive, + required String label, + VoidCallback? onTap, + required void Function(bool) onToggle, + }) { + return Tooltip( + message: label, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + child: AppIcon.font( + icon, + size: 24, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + AppGap.xs(), + SizedBox( + width: 48, + height: 28, + child: FittedBox( + child: AppSwitch( + value: isActive, + onChanged: onToggle, + ), + ), + ), + ], + ), + ); + } + + /// Normal view: Standard toggle list (existing implementation) + Widget _buildNormalView(BuildContext context, WidgetRef ref) { + final privacyState = ref.watch(instantPrivacyProvider); + final nodeLightState = ref.watch(nodeLightSettingsProvider); + final master = ref.watch(instantTopologyProvider).root.children.first; + bool isSupportNodeLight = serviceHelper.isSupportLedMode(); + bool isCognitive = isCognitiveMeshRouter( + modelNumber: master.data.model, + hardwareVersion: master.data.hardwareVersion); + + final content = 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.isNightModeEnabled, + 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(nodeLightState.copyWith( + isNightModeEnabled: true, allDayOff: false)); + } else { + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: false, allDayOff: false)); + } + doSomethingWithSpinner(context, notifier.save()); + }, + tips: loc(context).nightModeTips, + semantics: 'quick night mode switch'), + ] + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: content, + ), + ); + } + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: content, + ); + } + + /// 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); + + final content = 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.isNightModeEnabled, + onChanged: (value) { + final notifier = ref.read(nodeLightSettingsProvider.notifier); + if (value) { + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: true, allDayOff: false)); + } else { + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: false, allDayOff: false)); + } + doSomethingWithSpinner(context, notifier.save()); + }, + ), + ] + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: content, + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: content, + ); + } + + Widget _expandedToggleTile( + BuildContext context, { + required String title, + required String description, + required bool value, + VoidCallback? onTap, + required void Function(bool) onChanged, + }) { + return InkWell( + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelLarge(title), + AppGap.sm(), + AppText.bodySmall( + description, + color: Theme.of(context).colorScheme.onSurfaceVariant, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + AppGap.lg(), + AppSwitch(value: value, onChanged: onChanged), + ], + ), + ); + } + + Widget toggleTileWidget({ + required String title, + Widget? leading, + String? subTitle, + VoidCallback? onTap, + required bool value, + required void Function(bool value)? onChanged, + String? tips, + String? semantics, + }) { + return SizedBox( + height: 60, + child: InkWell( + focusColor: Colors.transparent, + splashColor: Theme.of(context).colorScheme.primary, + onTap: onTap, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Wrap( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AppText.labelLarge(title), + if (subTitle != null) AppText.bodySmall(subTitle), + ], + ), + if (leading != null) ...[ + SizedBox( + width: AppSpacing.xs, + ), + leading, + ], + SizedBox( + width: AppSpacing.sm, + ), + if (tips != null) + Tooltip( + message: tips, + child: Icon( + Icons.info_outline, + semanticLabel: '{$semantics} icon', + color: Theme.of(context).colorScheme.primary, + ), + ) + ], + ), + ), + AppSwitch( + key: ValueKey(semantics), + value: value, + onChanged: onChanged, + ), + ], + ), + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/fixed_layout/wifi_card.dart b/lib/page/dashboard/views/components/fixed_layout/wifi_card.dart new file mode 100644 index 000000000..d817010c2 --- /dev/null +++ b/lib/page/dashboard/views/components/fixed_layout/wifi_card.dart @@ -0,0 +1,333 @@ +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_selector.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; + final EdgeInsetsGeometry? padding; + final bool isCompact; + + const WiFiCard({ + super.key, + required this.item, + required this.index, + required this.canBeDisabled, + this.tooltipVisible = false, + this.padding, + this.onTooltipVisibilityChanged, + this.isCompact = false, + }); + + @override + ConsumerState createState() => _WiFiCardState(); +} + +class _WiFiCardState extends ConsumerState { + final qrBtnKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + if (widget.isCompact) { + return _buildCompactCard(context); + } + + return LayoutBuilder(builder: (context, constraint) { + return AppCard( + padding: widget.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 _buildCompactCard(BuildContext context) { + return AppCard( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + onTap: () { + context.pushNamed(RouteNamed.menuIncredibleWiFi, + extra: {'wifiIndex': widget.index, 'guest': widget.item.isGuest}); + }, + child: Row( + children: [ + // Band Icon + Container( + padding: const EdgeInsets.all(AppSpacing.xs), + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(Icons.wifi, + size: 16, color: Theme.of(context).colorScheme.primary), + ), + AppGap.sm(), + // Band Name + Expanded( + child: AppText.labelMedium( + widget.item.isGuest + ? loc(context).guestWifi + : widget.item.radios + .map((e) => e.replaceAll('RADIO_', '')) + .join('/'), + ), + ), + // Switch + AppSwitch( + value: widget.item.isEnabled, + onChanged: widget.item.isGuest || + !widget.item.isEnabled || + widget.canBeDisabled + ? (value) => _handleWifiToggled(value) + : null, + ), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: AppText.bodyMedium( + widget.item.isGuest + ? loc(context).guestWifi + : loc(context).wifiBand(widget.item.radios + .map((e) => e.replaceAll('RADIO_', '')) + .join('/')), + ), + ), + AppSwitch( + value: widget.item.isEnabled, + onChanged: widget.item.isGuest || + !widget.item.isEnabled || + widget.canBeDisabled + ? (value) => _handleWifiToggled(value) + : null, + ), + ], + ); + } + + Widget _buildSSID() { + return FittedBox( + child: AppText.titleMedium(widget.item.ssid), + ); + } + + Widget _buildFooter(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Row( + children: [ + AppIcon.font(AppFontIcons.devices), + AppGap.sm(), + Flexible( + child: AppText.labelLarge( + loc(context).nDevices(widget.item.numOfConnectedDevices), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + Align( + alignment: AlignmentDirectional.centerEnd, + child: _buildTooltip(context), + ), + ], + ); + } + + Widget _buildTooltip(BuildContext context) { + return AppTooltip( + position: AxisDirection.left, + visible: widget.item.isEnabled ? widget.tooltipVisible : false, + maxWidth: 200, + maxHeight: 200, + padding: EdgeInsets.zero, + content: Container( + color: Colors.white, + height: 200, + width: 200, + child: QrImageView( + data: WiFiCredential( + ssid: widget.item.ssid, + password: widget.item.password, + type: SecurityType.wpa, + ).generate(), + ), + ), + child: MouseRegion( + onEnter: widget.item.isEnabled + ? (_) => widget.onTooltipVisibilityChanged?.call(true) + : null, + onExit: (_) => widget.onTooltipVisibilityChanged?.call(false), + child: SizedBox( + width: 80, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + child: AppIconButton.small( + key: qrBtnKey, + styleVariant: ButtonStyleVariant.text, + icon: AppIcon.font(AppFontIcons.qrCode), + onTap: () => _showWiFiShareModal(context), + ), + ) + ], + ), + ), + ), + ); + } + + Future _handleWifiToggled(bool value) async { + final result = await showSwitchWifiDialog(); + if (result) { + if (!mounted) return; + showSpinnerDialog(context); + final wifiProvider = ref.read(wifiBundleProvider.notifier); + await wifiProvider.fetch(); + + if (widget.item.isGuest) { + await _saveGuestWifi(wifiProvider, value); + } else { + await _saveMainWifi(wifiProvider, value); + } + } + } + + Future _saveGuestWifi( + WifiBundleNotifier wifiProvider, bool value) async { + wifiProvider.setWiFiEnabled(value); + await wifiProvider.save().then((_) { + if (mounted) context.pop(); + }).catchError((error, stackTrace) { + if (!mounted) return; + showRouterNotFoundAlert(context, ref, onComplete: () => context.pop()); + }, test: (error) => error is ServiceSideEffectError).onError((error, _) { + if (mounted) context.pop(); + }); + } + + Future _saveMainWifi( + WifiBundleNotifier wifiProvider, bool value) async { + await wifiProvider + .saveToggleEnabled(radios: widget.item.radios, enabled: value) + .then((_) { + if (mounted) context.pop(); + }).catchError((error, stackTrace) { + if (!mounted) return; + showRouterNotFoundAlert(context, ref, onComplete: () => context.pop()); + }, test: (error) => error is ServiceSideEffectError).onError((error, _) { + if (mounted) context.pop(); + }); + } + + Future showSwitchWifiDialog() async { + return await showSimpleAppDialog( + context, + title: loc(context).wifiListSaveModalTitle, + scrollable: true, + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.bodyMedium(loc(context).wifiListSaveModalDesc), + if (!widget.item.isGuest && widget.item.isEnabled) + ..._disableGuestBandWarning(), + AppGap.lg(), + AppText.bodyMedium(loc(context).doYouWantToContinue), + ], + ), + actions: [ + AppButton.text(label: loc(context).cancel, onTap: () => context.pop()), + AppButton.text(label: loc(context).ok, onTap: () => context.pop(true)), + ], + ); + } + + List _disableGuestBandWarning() { + final guestWifiItem = + ref.read(dashboardHomeProvider).wifis.firstWhere((e) => e.isGuest); + final currentRadio = widget.item.radios.first; + return guestWifiItem.isEnabled + ? [ + AppGap.sm(), + AppText.labelMedium( + loc(context).disableBandWarning( + WifiRadioBand.getByValue(currentRadio).bandName), + ) + ] + : []; + } + + void _showWiFiShareModal(BuildContext context) { + showSimpleAppDialog( + context, + title: loc(context).shareWiFi, + scrollable: true, + content: WiFiShareDetailView( + ssid: widget.item.ssid, + password: widget.item.password, + ), + actions: [ + AppButton.text(label: loc(context).close, onTap: () => context.pop()), + AppButton.text( + label: loc(context).downloadQR, + onTap: () async { + createWiFiQRCode(WiFiCredential( + ssid: widget.item.ssid, + password: widget.item.password, + type: SecurityType.wpa, + )).then((imageBytes) { + exportFileFromBytes( + fileName: 'share_wifi_${widget.item.ssid}.png', + utf8Bytes: imageBytes, + ); + }); + }, + ), + ], + ); + } +} diff --git a/lib/page/dashboard/views/components/fixed_layout/wifi_grid.dart b/lib/page/dashboard/views/components/fixed_layout/wifi_grid.dart new file mode 100644 index 000000000..9748ebe29 --- /dev/null +++ b/lib/page/dashboard/views/components/fixed_layout/wifi_grid.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_provider.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/dashboard_loading_wrapper.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/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 FixedDashboardWiFiGrid extends ConsumerStatefulWidget { + const FixedDashboardWiFiGrid({ + super.key, + this.displayMode = DisplayMode.normal, + }); + + /// The display mode for this widget + final DisplayMode displayMode; + + @override + ConsumerState createState() => + _FixedDashboardWiFiGridState(); +} + +class _FixedDashboardWiFiGridState + extends ConsumerState { + Map toolTipVisible = {}; + + @override + Widget build(BuildContext context) { + return DashboardLoadingWrapper( + loadingHeight: _calculateLoadingHeight(context), + builder: (context, ref) => _buildContent(context, ref), + ); + } + + double _calculateLoadingHeight(BuildContext context) { + const itemHeight = 176.0; + final mainSpacing = + AppLayoutConfig.gutter(MediaQuery.of(context).size.width); + return itemHeight * 2 + mainSpacing * 1; + } + + Widget _buildContent(BuildContext context, WidgetRef ref) { + return switch (widget.displayMode) { + DisplayMode.compact => _buildCompactView(context, ref), + DisplayMode.normal => _buildNormalView(context, ref), + DisplayMode.expanded => _buildExpandedView(context, ref), + }; + } + + /// Compact view: Vertical list of simplified cards (Band & Switch only) + 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; + + // Use SingleChildScrollView to allow scrolling if content exceeds height + return SingleChildScrollView( + child: Wrap( + spacing: AppSpacing.md, + runSpacing: AppSpacing.sm, + children: items.asMap().entries.map((entry) { + final index = entry.key; + // Calculate width for 2 items per row logic, or just let them wrap? + // If we want "tight", let them take intrinsic width or fixed width? + // Since it's a grid cell, maybe full width fraction? + // User said "horizontal arrangement". + // Let's wrapping LayoutBuilder to determine available width? + // For simplicity, let's try Wrap with Intrinsic Width first, + // but WiFiCard generally expands. + // We need to constrain the width of items in Wrap. + return SizedBox( + width: 180, // Approximate width for visual balance + child: _buildWiFiCard(items, index, canBeDisabled, isCompact: true), + ); + }).toList(), + ), + ); + } + + /// Normal view: 2-column grid (existing implementation) + Widget _buildNormalView(BuildContext context, WidgetRef ref) { + final items = + ref.watch(dashboardHomeProvider.select((value) => value.wifis)); + final crossAxisCount = + (context.isMobileLayout || context.isTabletLayout) ? 1 : 2; + // Use layout gutter for horizontal spacing to match Dashboard Layout + final mainSpacing = + AppLayoutConfig.gutter(MediaQuery.of(context).size.width); + const itemHeight = 176.0; + final mainAxisCount = (items.length / crossAxisCount).ceil(); + + final enabledWiFiCount = + items.where((e) => !e.isGuest && e.isEnabled).length; + final hasLanPort = + ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; + final canBeDisabled = enabledWiFiCount > 1 || hasLanPort; + + final gridHeight = mainAxisCount * itemHeight + + ((mainAxisCount == 0 ? 1 : mainAxisCount) - 1) * AppSpacing.lg; + + // Use SingleChildScrollView to handle overflow when Bento Grid container + // is smaller than the calculated gridHeight + return SingleChildScrollView( + child: SizedBox( + height: gridHeight, + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: AppSpacing.lg, + crossAxisSpacing: mainSpacing, + mainAxisExtent: itemHeight, + ), + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: items.length, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: _buildWiFiCard(items, index, canBeDisabled), + ); + }, + ), + ), + ); + } + + /// Expanded view: Single column with larger cards + Widget _buildExpandedView(BuildContext context, WidgetRef ref) { + final items = + ref.watch(dashboardHomeProvider.select((value) => value.wifis)); + const itemHeight = 200.0; + + final enabledWiFiCount = + items.where((e) => !e.isGuest && e.isEnabled).length; + final hasLanPort = + ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; + final canBeDisabled = enabledWiFiCount > 1 || hasLanPort; + + final gridHeight = items.length * itemHeight + + (items.isEmpty ? 0 : (items.length - 1)) * AppSpacing.lg; + + return SizedBox( + height: gridHeight, + child: ListView.separated( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: items.length, + separatorBuilder: (_, __) => AppGap.lg(), + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: _buildWiFiCard(items, index, canBeDisabled), + ); + }, + ), + ); + } + + Widget _buildWiFiCard( + List items, + int index, + bool canBeDisabled, { + EdgeInsetsGeometry? padding, + bool isCompact = false, + }) { + 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, + isCompact: isCompact, + onTooltipVisibilityChanged: (visible) { + setState(() { + // Hide all other tooltips when showing one + if (visible) { + for (var key in toolTipVisible.keys) { + toolTipVisible[key] = false; + } + } + toolTipVisible[visibilityKey] = visible; + }); + }, + padding: padding, + ); + } +} 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 index c9c1bb1e6..6616d7e60 100644 --- a/lib/page/dashboard/views/components/settings/dashboard_layout_settings_panel.dart +++ b/lib/page/dashboard/views/components/settings/dashboard_layout_settings_panel.dart @@ -1,291 +1,199 @@ 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/dashboard/providers/dashboard_preferences_provider.dart'; +import 'package:privacy_gui/page/dashboard/providers/sliver_dashboard_controller_provider.dart'; +import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; +import 'package:privacy_gui/di.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) +/// - Toggle between Standard (Adaptive) and Custom (Drag & Drop) layouts. +/// - Reset layout to defaults. 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(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + 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); - }, + // Custom Layout Toggle + AppCard( + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + title: + AppText.labelLarge(loc(context).dashboardEnableCustomLayout), + 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); + + // When toggling OFF, exit edit mode and close panel + if (!value) { + final controller = + ref.read(sliverDashboardControllerProvider); + controller.setEditMode(false); + + if (context.mounted) { + Navigator.pop(context, 'toggle_off'); + } + } + }, ), - AppGap.lg(), + ), + 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.', - ), - ), - ], + // Info Message + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xl), + child: AppCard( + child: Row( + children: [ + Icon(Icons.info_outline, + color: Theme.of(context).colorScheme.primary), + AppGap.md(), + Expanded( + child: AppText.bodySmall( + preferences.useCustomLayout + ? 'You are using the customizable dashboard. Tap the "Edit" button on the dashboard to move and resize widgets.' + : 'Standard layout automatically optimizes widget placement based on your screen size.', + ), ), - ), - ), - - // 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(), + 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(); - }, - ), - ), - ], - ), + if (preferences.useCustomLayout) _buildHiddenWidgets(context, ref), + + // Reset Button - Only enabled when custom layout is active + Align( + alignment: Alignment.centerRight, + child: AppButton.text( + label: loc(context).dashboardResetLayout, + onTap: preferences.useCustomLayout + ? () async { + // 1. Reset the custom layout positions + await ref + .read(sliverDashboardControllerProvider.notifier) + .resetLayout(); + + // 2. Reset widget display modes to defaults + await ref + .read(dashboardPreferencesProvider.notifier) + .resetWidgetModes(); + + // 3. Close dialog and signal View to exit edit mode + if (context.mounted) { + Navigator.pop(context, 'reset'); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Layout reset to defaults'), + duration: Duration(seconds: 2), + ), + ); + } + } + : null, + ), + ), + ], ), ); } -} - -/// 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; + Widget _buildHiddenWidgets(BuildContext context, WidgetRef ref) { + final controller = ref.watch(sliverDashboardControllerProvider); + // Use exportLayout() to get current Items since 'items' property might not be directly exposed or up to date in this context + // Actually, DashboardController usually has 'items'. Let's try to access it. + // If dynamic, safer to use exportLayout which returns List. + final currentLayout = controller.exportLayout(); + final currentIds = + currentLayout.map((e) => (e as Map)['id'] as String).toSet(); - @override - Widget build(BuildContext context, WidgetRef ref) { - // 1. Calculate constraints - final constraints = spec.getConstraints(config.displayMode); - final minColumns = constraints.minColumns; - final defaultColumns = constraints.preferredColumns; + final hiddenSpecs = DashboardWidgetSpecs.all.where((spec) { + if (!_checkRequirements(spec)) return false; + return !currentIds.contains(spec.id); + }).toList(); - // 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); + if (hiddenSpecs.isEmpty) return const SizedBox.shrink(); - // 3. Calculate divisions for slider (steps between min and max) - final divisions = 12 - minColumns; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelLarge(loc(context).dashboardHiddenWidgets), + AppGap.sm(), + AppCard( + padding: EdgeInsets.zero, + child: Column( + children: hiddenSpecs.map((spec) { + return ListTile( + title: AppText.bodyMedium(spec.displayName), + subtitle: spec.description != null + ? AppText.bodySmall( + spec.description!, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ) + : null, + trailing: AppIconButton( + icon: const Icon(Icons.add_circle_outline), + onTap: () async { + await ref + .read(sliverDashboardControllerProvider.notifier) + .addWidget(spec.id); - 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); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Added ${spec.displayName}'), + duration: const Duration(seconds: 1), + ), + ); + } }, ), - ], - ), - // 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, - ), - ), - ], - ), - ], - ], + ); + }).toList(), + ), ), - ), + AppGap.xl(), + ], ); } + + bool _checkRequirements(WidgetSpec spec) { + if (spec.requirements.isEmpty) return true; + + for (final req in spec.requirements) { + switch (req) { + case WidgetRequirement.vpnSupported: + if (!getIt.get().isSupportVPN()) return false; + break; + case WidgetRequirement.none: + break; + } + } + return true; + } } diff --git a/lib/page/dashboard/views/components/widgets/atomic/custom_quick_panel.dart b/lib/page/dashboard/views/components/widgets/atomic/custom_quick_panel.dart new file mode 100644 index 000000000..81a28142d --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/atomic/custom_quick_panel.dart @@ -0,0 +1,383 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; + +import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.dart'; +import 'package:privacy_gui/core/utils/nodes.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/display_mode_widget.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_device_list_provider.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_provider.dart'; +import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; +import 'package:privacy_gui/page/instant_topology/providers/instant_topology_provider.dart'; +import 'package:privacy_gui/page/nodes/providers/node_detail_state.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Atomic Quick Panel widget for custom layout (Bento Grid). +/// +/// - Compact: Icon-only toggles in a row +/// - Normal: Standard toggle list +/// - Expanded: Toggles with full descriptions +class CustomQuickPanel extends DisplayModeConsumerStatefulWidget { + const CustomQuickPanel({ + super.key, + super.displayMode, + }); + + @override + ConsumerState createState() => _CustomQuickPanelState(); +} + +class _CustomQuickPanelState extends ConsumerState + with DisplayModeStateMixin { + @override + double getLoadingHeight(DisplayMode mode) => switch (mode) { + DisplayMode.compact => 80, + DisplayMode.normal => 150, + DisplayMode.expanded => 200, + }; + + @override + Widget buildCompactView(BuildContext context, WidgetRef ref) { + final privacyState = ref.watch(instantPrivacyProvider); + final nodeLightState = ref.watch(nodeLightSettingsProvider); + final (isCognitive, isSupportNodeLight) = _getNodeLightSupport(ref); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, // Reduced from lg + vertical: AppSpacing.xs, // Reduced from md + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _compactToggle( + context, + ref, + icon: Icons.shield, + isActive: privacyState.status.mode == MacFilterMode.allow, + label: loc(context).instantPrivacy, + onTap: () => context.pushNamed(RouteNamed.menuInstantPrivacy), + onToggle: (value) => _handlePrivacyToggle(context, ref, value), + badge: Container( + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1), + decoration: BoxDecoration( + color: Theme.of(context) + .extension()! + .semanticWarning + .withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'BETA', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontSize: 8, + fontWeight: FontWeight.bold, + color: Theme.of(context) + .extension()! + .semanticWarning, + ), + ), + ), + ), + if (isCognitive && isSupportNodeLight) + _compactToggle( + context, + ref, + icon: AppFontIcons.darkMode, + isActive: nodeLightState.isNightModeEnabled, + label: loc(context).nightMode, + onToggle: (value) => _handleNightModeToggle(context, ref, value), + ), + ], + ), + ); + } + + @override + Widget buildNormalView(BuildContext context, WidgetRef ref) { + final privacyState = ref.watch(instantPrivacyProvider); + final nodeLightState = ref.watch(nodeLightSettingsProvider); + final (isCognitive, isSupportNodeLight) = _getNodeLightSupport(ref); + + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _toggleTile( + context, + ref, + title: loc(context).instantPrivacy, + value: privacyState.status.mode == MacFilterMode.allow, + tips: loc(context).instantPrivacyInfo, + leading: AppBadge( + label: 'BETA', + color: Theme.of(context) + .extension()! + .semanticWarning, + ), + onTap: () => context.pushNamed(RouteNamed.menuInstantPrivacy), + onChanged: (value) => _handlePrivacyToggle(context, ref, value), + ), + if (isCognitive && isSupportNodeLight) ...[ + const Divider(height: 48, thickness: 1.0), + _toggleTile( + context, + ref, + title: loc(context).nightMode, + value: nodeLightState.isNightModeEnabled, + subTitle: _getNightModeSubtitle(context, ref), + tips: loc(context).nightModeTips, + onChanged: (value) => _handleNightModeToggle(context, ref, value), + ), + ] + ], + ), + ); + } + + @override + Widget buildExpandedView(BuildContext context, WidgetRef ref) { + final privacyState = ref.watch(instantPrivacyProvider); + final nodeLightState = ref.watch(nodeLightSettingsProvider); + final (isCognitive, isSupportNodeLight) = _getNodeLightSupport(ref); + + return SingleChildScrollView( + padding: const 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) => _handlePrivacyToggle(context, ref, value), + badge: AppBadge( + label: 'BETA', + color: Theme.of(context) + .extension()! + .semanticWarning, + ), + ), + if (isCognitive && isSupportNodeLight) ...[ + const Divider(height: 32), + _expandedToggleTile( + context, + title: loc(context).nightMode, + description: loc(context).nightModeTips, + value: nodeLightState.isNightModeEnabled, + onChanged: (value) => _handleNightModeToggle(context, ref, value), + ), + ] + ], + ), + ); + } + + // Helper methods + (bool, bool) _getNodeLightSupport(WidgetRef ref) { + final master = ref.watch(instantTopologyProvider).root.children.first; + final isSupportNodeLight = serviceHelper.isSupportLedMode(); + final isCognitive = isCognitiveMeshRouter( + modelNumber: master.data.model, + hardwareVersion: master.data.hardwareVersion, + ); + return (isCognitive, isSupportNodeLight); + } + + String? _getNightModeSubtitle(BuildContext context, WidgetRef ref) { + final status = ref.read(nodeLightSettingsProvider.notifier).currentStatus; + return switch (status) { + NodeLightStatus.night => loc(context).nightModeTime, + NodeLightStatus.off => loc(context).allDayOff, + _ => null, + }; + } + + void _handlePrivacyToggle(BuildContext context, WidgetRef ref, bool 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()); + } + }); + } + + void _handleNightModeToggle(BuildContext context, WidgetRef ref, bool value) { + final notifier = ref.read(nodeLightSettingsProvider.notifier); + final state = ref.read(nodeLightSettingsProvider); + if (value) { + notifier.setSettings( + state.copyWith(isNightModeEnabled: true, allDayOff: false)); + } else { + notifier.setSettings( + state.copyWith(isNightModeEnabled: false, allDayOff: false)); + } + doSomethingWithSpinner(context, notifier.save()); + } + + // UI Components + Widget _compactToggle( + BuildContext context, + WidgetRef ref, { + required IconData icon, + required bool isActive, + required String label, + VoidCallback? onTap, + required void Function(bool) onToggle, + Widget? badge, + }) { + return Tooltip( + message: label, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + AppIcon.font( + icon, + size: 20, + color: isActive + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + if (badge != null) + Positioned( + top: -8, + right: -8, // Align to top right corner + child: badge, + ), + ], + ), + ), + AppGap.sm(), + SizedBox( + width: 36, + height: 24, + child: FittedBox( + child: AppSwitch(value: isActive, onChanged: onToggle), + ), + ), + ], + ), + ); + } + + Widget _toggleTile( + BuildContext context, + WidgetRef ref, { + required String title, + required bool value, + Widget? leading, + String? subTitle, + String? tips, + VoidCallback? onTap, + required void Function(bool) onChanged, + }) { + return SizedBox( + height: 60, + child: InkWell( + focusColor: Colors.transparent, + splashColor: Theme.of(context).colorScheme.primary, + onTap: onTap, + child: Row( + children: [ + Expanded( + child: Wrap( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AppText.labelLarge(title), + if (subTitle != null) AppText.bodySmall(subTitle), + ], + ), + if (leading != null) ...[ + const SizedBox(width: AppSpacing.xs), + leading, + ], + const SizedBox(width: AppSpacing.sm), + if (tips != null) + Tooltip( + message: tips, + child: Icon( + Icons.info_outline, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + AppSwitch(value: value, onChanged: onChanged), + ], + ), + ), + ); + } + + Widget _expandedToggleTile( + BuildContext context, { + required String title, + required String description, + required bool value, + VoidCallback? onTap, + required void Function(bool) onChanged, + Widget? badge, + }) { + return InkWell( + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AppText.labelLarge(title), + if (badge != null) ...[ + AppGap.sm(), + badge, + ], + ], + ), + AppGap.sm(), + AppText.bodySmall( + description, + color: Theme.of(context).colorScheme.onSurfaceVariant, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + AppGap.lg(), + AppSwitch(value: value, onChanged: onChanged), + ], + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/widgets/atomic/custom_vpn.dart b/lib/page/dashboard/views/components/widgets/atomic/custom_vpn.dart new file mode 100644 index 000000000..f8d68112d --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/atomic/custom_vpn.dart @@ -0,0 +1,96 @@ +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/components/shortcuts/dialogs.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/display_mode_widget.dart'; +import 'package:privacy_gui/page/vpn/models/vpn_models.dart'; +import 'package:privacy_gui/page/vpn/providers/vpn_notifier.dart'; +import 'package:privacy_gui/page/vpn/views/vpn_status_tile.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Atomic widget displaying VPN status. +/// +/// For custom layout (Bento Grid) only. +class CustomVPN extends DisplayModeConsumerWidget { + const CustomVPN({ + super.key, + super.displayMode, + }); + + @override + double getLoadingHeight(DisplayMode mode) => switch (mode) { + DisplayMode.compact => 80, + DisplayMode.normal => 230, + DisplayMode.expanded => 230, + }; + + @override + Widget buildCompactView(BuildContext context, WidgetRef ref) { + // Compact: Simple toggle row + final vpnState = ref.watch(vpnProvider); + final isEnabled = vpnState.settings.serviceSettings.enabled; + final status = vpnState.status.tunnelStatus; + final isConnected = status == IPsecStatus.connected; + + return AppCard( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xs, + ), + onTap: () { + context.pushNamed(RouteNamed.settingsVPN); + }, + child: Row( + children: [ + Icon( + Icons.vpn_key, + color: isConnected + ? Theme.of(context).extension()!.semanticSuccess + : Theme.of(context).colorScheme.onSurface, + ), + AppGap.md(), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelLarge(loc(context).vpn), + AppText.labelSmall( + isConnected ? 'Connected' : 'Disconnected', + color: isConnected + ? Theme.of(context) + .extension()! + .semanticSuccess + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + AppSwitch( + value: isEnabled, + onChanged: (value) { + final settings = vpnState.settings.serviceSettings; + final notifier = ref.read(vpnProvider.notifier); + notifier.setVPNService(settings.copyWith(enabled: value)); + + doSomethingWithSpinner(context, notifier.save()); + }, + ), + ], + ), + ); + } + + @override + Widget buildNormalView(BuildContext context, WidgetRef ref) { + return const VPNStatusTile(); + } + + @override + Widget buildExpandedView(BuildContext context, WidgetRef ref) { + return const VPNStatusTile(); + } +} diff --git a/lib/page/dashboard/views/components/widgets/atomic/custom_wifi_grid.dart b/lib/page/dashboard/views/components/widgets/atomic/custom_wifi_grid.dart new file mode 100644 index 000000000..460ca7dd2 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/atomic/custom_wifi_grid.dart @@ -0,0 +1,328 @@ +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/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/views/components/core/display_mode_widget.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/parts/wifi_card.dart'; +import 'package:privacy_gui/page/wifi_settings/models/wifi_enums.dart'; +import 'package:privacy_gui/page/wifi_settings/providers/wifi_bundle_provider.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Atomic WiFi Grid widget for custom layout (Bento Grid). +/// +/// - Compact: Wrapped cards in minimal size +/// - Normal: 2-column grid +/// - Expanded: Single column with larger cards +class CustomWiFiGrid extends DisplayModeConsumerStatefulWidget { + const CustomWiFiGrid({ + super.key, + super.displayMode, + }); + + @override + ConsumerState createState() => _CustomWiFiGridState(); +} + +class _CustomWiFiGridState extends ConsumerState + with DisplayModeStateMixin { + Map toolTipVisible = {}; + + @override + @override + double getLoadingHeight(DisplayMode mode) { + const itemHeight = 176.0; + final mainSpacing = + AppLayoutConfig.gutter(MediaQuery.of(context).size.width); + return switch (mode) { + DisplayMode.compact => 80, + DisplayMode.normal => itemHeight * 2 + mainSpacing, + DisplayMode.expanded => itemHeight * 3 + mainSpacing * 2, + }; + } + + @override + Widget buildCompactView(BuildContext context, WidgetRef ref) { + final items = + ref.watch(dashboardHomeProvider.select((value) => value.wifis)); + if (items.isEmpty) return const SizedBox(); + + return Row( + children: items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + final isLast = index == items.length - 1; + + return Expanded( + child: Padding( + padding: EdgeInsets.only(right: isLast ? 0 : AppSpacing.md), + child: AppInkWell( + customColor: Colors.transparent, + customBorderWidth: 0, + onTap: () => _handleWifiToggled(item), + borderRadius: BorderRadius.circular(AppRadius.md), + child: AppSurface( + variant: SurfaceVariant.base, + borderRadius: AppRadius.md, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + item.isGuest ? Icons.wifi_lock : Icons.wifi, + size: 16, + color: item.isEnabled + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.38), + ), + if (!item.isGuest && item.radios.isNotEmpty) ...[ + AppGap.sm(), + Expanded( + child: AppText.labelSmall( + item.radios + .map((e) => e.replaceAll('RADIO_', '')) + .join(', '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ] else + const Spacer(), + if (item.numOfConnectedDevices > 0) ...[ + AppGap.xs(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${item.numOfConnectedDevices}', + style: TextStyle( + fontSize: 10, + color: Theme.of(context) + .colorScheme + .onPrimaryContainer, + ), + ), + ), + ], + ], + ), + AppGap.xs(), + AppText.labelMedium( + item.ssid, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ); + } + + Future _handleWifiToggled(DashboardWiFiUIModel item) async { + final result = await _showSwitchWifiDialog(item); + if (result) { + if (!mounted) return; + showSpinnerDialog(context); + final wifiProvider = ref.read(wifiBundleProvider.notifier); + await wifiProvider.fetch(); + + if (item.isGuest) { + await _saveGuestWifi(wifiProvider, !item.isEnabled); + } else { + await _saveMainWifi(wifiProvider, item.radios, !item.isEnabled); + } + } + } + + 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, List radios, bool value) async { + await wifiProvider + .saveToggleEnabled(radios: 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(DashboardWiFiUIModel item) 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 (!item.isGuest && item.isEnabled) + ..._disableGuestBandWarning(item), + 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(DashboardWiFiUIModel item) { + final guestWifiItem = + ref.read(dashboardHomeProvider).wifis.firstWhere((e) => e.isGuest); + final currentRadio = item.radios.first; + return guestWifiItem.isEnabled + ? [ + AppGap.sm(), + AppText.labelMedium( + loc(context).disableBandWarning( + WifiRadioBand.getByValue(currentRadio).bandName), + ) + ] + : []; + } + + @override + Widget buildNormalView(BuildContext context, WidgetRef ref) { + final items = + ref.watch(dashboardHomeProvider.select((value) => value.wifis)); + final crossAxisCount = + (context.isMobileLayout || context.isTabletLayout) ? 1 : 2; + final mainSpacing = + AppLayoutConfig.gutter(MediaQuery.of(context).size.width); + const itemHeight = 176.0; + final mainAxisCount = (items.length / crossAxisCount).ceil(); + final canBeDisabled = _canDisableWiFi(ref, items); + + final gridHeight = mainAxisCount * itemHeight + + ((mainAxisCount == 0 ? 1 : mainAxisCount) - 1) * AppSpacing.lg; + + return SingleChildScrollView( + child: 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) => SizedBox( + height: itemHeight, + child: _buildWiFiCard(items, index, canBeDisabled), + ), + ), + ), + ); + } + + @override + Widget buildExpandedView(BuildContext context, WidgetRef ref) { + final items = + ref.watch(dashboardHomeProvider.select((value) => value.wifis)); + const itemHeight = 200.0; + final canBeDisabled = _canDisableWiFi(ref, items); + + 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) => SizedBox( + height: itemHeight, + child: _buildWiFiCard(items, index, canBeDisabled, isExpanded: true), + ), + ), + ); + } + + bool _canDisableWiFi(WidgetRef ref, List items) { + final enabledWiFiCount = + items.where((e) => !e.isGuest && e.isEnabled).length; + final hasLanPort = + ref.read(dashboardHomeProvider).lanPortConnections.isNotEmpty; + return enabledWiFiCount > 1 || hasLanPort; + } + + Widget _buildWiFiCard( + List items, + int index, + bool canBeDisabled, { + bool isCompact = false, + bool isExpanded = false, + }) { + 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, + isCompact: isCompact, + isExpanded: isExpanded, + onTooltipVisibilityChanged: (visible) { + setState(() { + if (visible) { + for (var key in toolTipVisible.keys) { + toolTipVisible[key] = false; + } + } + toolTipVisible[visibilityKey] = visible; + }); + }, + ); + } +} diff --git a/lib/page/dashboard/views/components/widgets/atomic/internet_status_only.dart b/lib/page/dashboard/views/components/widgets/atomic/internet_status_only.dart new file mode 100644 index 000000000..d6d5e63c6 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/atomic/internet_status_only.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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/display_mode_widget.dart'; +import 'package:privacy_gui/utils.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Atomic widget displaying internet connection status. +/// +/// For custom layout (Bento Grid) only. +/// Shows: Online/Offline indicator, geolocation (normal/expanded). +/// Expanded mode features animated data flow visualization. +class CustomInternetStatus extends DisplayModeConsumerStatefulWidget { + const CustomInternetStatus({ + super.key, + super.displayMode, + }); + + @override + ConsumerState createState() => + _CustomInternetStatusState(); +} + +class _CustomInternetStatusState extends ConsumerState + with DisplayModeStateMixin, TickerProviderStateMixin { + late AnimationController _flowController; + + @override + void initState() { + super.initState(); + _flowController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(); + } + + @override + void dispose() { + _flowController.dispose(); + super.dispose(); + } + + @override + double getLoadingHeight(DisplayMode mode) => switch (mode) { + DisplayMode.compact => 80, + DisplayMode.normal => 100, + DisplayMode.expanded => 180, + }; + + @override + Widget buildCompactView(BuildContext context, WidgetRef ref) { + final isOnline = _isOnline(ref); + + return Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _statusIndicator(context, isOnline), + AppGap.sm(), + AppText.labelLarge( + isOnline + ? loc(context).internetOnline + : loc(context).internetOffline, + ), + ], + ), + if (!Utils.isMobilePlatform()) _refreshButton(context, ref), + ], + ), + ); + } + + @override + Widget buildNormalView(BuildContext context, WidgetRef ref) { + final isOnline = _isOnline(ref); + final geolocationState = ref.watch(geolocationProvider); + + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + _statusIndicator(context, isOnline), + AppGap.sm(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + AppText.labelLarge( + isOnline + ? loc(context).internetOnline + : loc(context).internetOffline, + ), + if (geolocationState.value?.name.isNotEmpty == true) ...[ + AppGap.xs(), + SharedWidgets.geolocationWidget( + context, + geolocationState.value?.name ?? '', + geolocationState.value?.displayLocationText ?? '', + ), + ], + ], + ), + ), + if (!Utils.isMobilePlatform()) _refreshButton(context, ref), + ], + ), + ); + } + + @override + Widget buildExpandedView(BuildContext context, WidgetRef ref) { + final isOnline = _isOnline(ref); + final geolocationState = ref.watch(geolocationProvider); + final colorScheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Animated Data Flow Visualization + Expanded( + child: Center( + child: _DataFlowWidget( + isOnline: isOnline, + animation: _flowController, + primaryColor: colorScheme.primary, + inactiveColor: colorScheme.surfaceContainerHighest, + secondaryColor: colorScheme.tertiary, + masterIcon: ref.watch(dashboardHomeProvider).masterIcon, + ), + ), + ), + AppGap.md(), + // Status Text + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _statusIndicator(context, isOnline), + AppGap.sm(), + AppText.titleMedium( + isOnline + ? loc(context).internetOnline + : loc(context).internetOffline, + ), + ], + ), + // Geolocation + if (geolocationState.value?.name.isNotEmpty == true) ...[ + AppGap.sm(), + SharedWidgets.geolocationWidget( + context, + geolocationState.value?.name ?? '', + geolocationState.value?.displayLocationText ?? '', + ), + ], + AppGap.md(), + // Refresh button centered + if (!Utils.isMobilePlatform()) _refreshButton(context, ref), + ], + ), + ); + } + + // Helper methods + bool _isOnline(WidgetRef ref) => + ref.watch(internetStatusProvider) == InternetStatus.online; + + Widget _statusIndicator(BuildContext context, bool isOnline) => Icon( + Icons.circle, + color: isOnline + ? Theme.of(context).extension()!.semanticSuccess + : Theme.of(context).colorScheme.surfaceContainerHighest, + size: 12.0, + ); + + Widget _refreshButton(BuildContext context, WidgetRef ref) => + AnimatedRefreshContainer( + builder: (controller) => AppIconButton( + icon: AppIcon.font( + AppFontIcons.refresh, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + onTap: () { + controller.repeat(); + ref.read(pollingProvider.notifier).forcePolling().then((_) { + controller.stop(); + }); + }, + ), + ); +} + +/// Clean data flow widget using Flutter Icons +class _DataFlowWidget extends StatelessWidget { + final bool isOnline; + final Animation animation; + final Color primaryColor; + final Color secondaryColor; + final Color inactiveColor; + final String masterIcon; + + const _DataFlowWidget({ + required this.isOnline, + required this.animation, + required this.primaryColor, + required this.secondaryColor, + required this.inactiveColor, + required this.masterIcon, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Router Image + SharedWidgets.resolveRouterImage( + context, + masterIcon, + size: 48, + ), + // Animated connection + SizedBox( + width: 120, + child: isOnline ? _buildAnimatedDots() : _buildOfflineLine(), + ), + // Cloud Icon - with gradient and glow + isOnline + ? Container( + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: secondaryColor.withValues(alpha: 0.3), + blurRadius: 12, + spreadRadius: 2, + ), + ], + ), + child: ShaderMask( + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [primaryColor, secondaryColor], + ).createShader(bounds), + child: const Icon( + Icons.cloud, + size: 48, + color: Colors.white, + ), + ), + ) + : Icon( + Icons.cloud_outlined, + size: 48, + color: inactiveColor, + ), + ], + ); + } + + Widget _buildAnimatedDots() { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(3, (index) { + // Staggered animation for each dot + final dotProgress = (animation.value + index * 0.33) % 1.0; + final opacity = _calculateOpacity(dotProgress); + final scale = 0.6 + (0.4 * _calculateScale(dotProgress)); + + // Gradient color based on position (0=primary, 2=secondary) + final gradientRatio = index / 2.0; + final dotColor = + Color.lerp(primaryColor, secondaryColor, gradientRatio)!; + + return Transform.scale( + scale: scale, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + dotColor.withValues(alpha: opacity), + dotColor.withValues(alpha: opacity * 0.5), + ], + ), + boxShadow: [ + BoxShadow( + color: dotColor.withValues(alpha: opacity * 0.3), + blurRadius: 4, + spreadRadius: 1, + ), + ], + ), + ), + ); + }), + ); + }, + ); + } + + Widget _buildOfflineLine() { + return Stack( + alignment: Alignment.center, + children: [ + // Dashed line + Container( + height: 2, + color: inactiveColor, + ), + // X mark + Icon( + Icons.close, + size: 24, + color: inactiveColor, + ), + ], + ); + } + + double _calculateOpacity(double progress) { + // Wave-like opacity: peaks in the middle + return 0.3 + 0.7 * (1 - (2 * progress - 1).abs()); + } + + double _calculateScale(double progress) { + // Subtle pulse effect + return 0.8 + 0.2 * (1 - (2 * progress - 1).abs()); + } +} diff --git a/lib/page/dashboard/views/components/widgets/atomic/master_node_info.dart b/lib/page/dashboard/views/components/widgets/atomic/master_node_info.dart new file mode 100644 index 000000000..23ccbbf4e --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/atomic/master_node_info.dart @@ -0,0 +1,507 @@ +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/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/providers/dashboard_home_state.dart'; +import 'package:privacy_gui/page/dashboard/views/components/core/display_mode_widget.dart'; +import 'package:privacy_gui/page/instant_topology/providers/_providers.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'; + +/// Atomic widget displaying master router information. +/// +/// For custom layout (Bento Grid) only. +/// - Compact: Router image + Location name +/// - Normal: Standard list of details (Model, SN, FW) +/// - Expanded: Detailed view with larger typography/spacing +class CustomMasterNodeInfo extends DisplayModeConsumerWidget { + const CustomMasterNodeInfo({ + super.key, + super.displayMode, + }); + + @override + double getLoadingHeight(DisplayMode mode) => switch (mode) { + DisplayMode.compact => 80, + DisplayMode.normal => 150, + DisplayMode.expanded => 200, + }; + + @override + Widget buildCompactView(BuildContext context, WidgetRef ref) { + // Compact: Just image and location/name centered + final master = ref.watch(instantTopologyProvider).root.children.first; + final masterIcon = ref.watch(dashboardHomeProvider).masterIcon; + + return AppInkWell( + customColor: Colors.transparent, + customBorderWidth: 0, + onTap: () => _navigateToNodeDetail(context, ref, master.data.deviceId), + borderRadius: BorderRadius.circular(AppRadius.md), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 48, + child: SharedWidgets.resolveRouterImage( + context, + masterIcon, + size: 48, + ), + ), + AppGap.md(), + Flexible( + child: AppText.labelMedium( + master.data.location, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } + + @override + Widget buildNormalView(BuildContext context, WidgetRef ref) { + return _buildDetailView(context, ref, isExpanded: false); + } + + @override + Widget buildExpandedView(BuildContext context, WidgetRef ref) { + return _buildDetailView(context, ref, isExpanded: true); + } + + Widget _buildDetailView( + BuildContext context, + WidgetRef ref, { + required bool isExpanded, + }) { + final master = ref.watch(instantTopologyProvider).root.children.first; + final dashboardState = ref.watch(dashboardHomeProvider); + final masterIcon = dashboardState.masterIcon; + final wanPortConnection = dashboardState.wanPortConnection; + final isMasterOffline = + master.data.isOnline == false || wanPortConnection == 'None'; + + // Expanded mode: Unified layout with larger image + if (isExpanded) { + return _buildExpandedLayout( + context, + ref, + master: master, + dashboardState: dashboardState, + masterIcon: masterIcon, + isMasterOffline: isMasterOffline, + ); + } + + // Normal mode: Standard horizontal layout + return _buildNormalLayout( + context, + ref, + master: master, + masterIcon: masterIcon, + isMasterOffline: isMasterOffline, + ); + } + + /// Expanded layout: Larger image with unified info sections + Widget _buildExpandedLayout( + BuildContext context, + WidgetRef ref, { + required dynamic master, + required DashboardHomeState dashboardState, + required String masterIcon, + required bool isMasterOffline, + }) { + return AppInkWell( + customColor: Colors.transparent, + customBorderWidth: 0, + onTap: () => _navigateToNodeDetail(context, ref, master.data.deviceId), + borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Left: Large Router Image + Location + Uptime + SizedBox( + width: 160, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Router Image - Larger size + SharedWidgets.resolveRouterImage( + context, + masterIcon, + size: 140, + ), + AppGap.md(), + // Location Name + AppText.titleMedium( + master.data.location, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + // Uptime under image + if (dashboardState.uptime != null) ...[ + AppGap.md(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppIcon.font(Icons.access_time, size: 14), + AppGap.xs(), + AppText.labelSmall( + _formatUptime(context, dashboardState.uptime!), + ), + ], + ), + ], + ], + ), + ), + AppGap.xl(), + // Right: Device Info + System Stats + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Device Info Section + _buildInfoSection( + context, + items: [ + ( + loc(context).connection, + isMasterOffline + ? '--' + : (master.data.isWiredConnection == true) + ? loc(context).wired + : loc(context).wireless + ), + (loc(context).model, master.data.model), + (loc(context).serialNo, master.data.serialNumber), + if (master.data.ipAddress.isNotEmpty) + ('IP', master.data.ipAddress), + if (master.data.macAddress.isNotEmpty) + ('MAC', master.data.macAddress), + ('FW', master.data.fwVersion), + ], + ), + AppGap.lg(), + // System Stats Section + _buildSystemStatsSection(context, dashboardState), + ], + ), + ), + ], + ), + ), + ); + } + + /// Info section with label-value pairs in a unified style + Widget _buildInfoSection( + BuildContext context, { + required List<(String, String)> items, + }) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: items.map((item) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 80, + child: AppText.labelSmall( + '${item.$1}:', + color: colorScheme.onSurfaceVariant, + ), + ), + Expanded( + child: AppText.bodySmall( + item.$2, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(), + ), + ); + } + + /// System stats section with CPU and Memory bars + Widget _buildSystemStatsSection( + BuildContext context, DashboardHomeState state) { + final colorScheme = Theme.of(context).colorScheme; + final appColorScheme = Theme.of(context).extension(); + + // Don't show if no data available + if (state.cpuLoad == null && state.memoryLoad == null) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // CPU Utilization + if (state.cpuLoad != null) ...[ + _buildUtilizationBar( + context, + label: loc(context).cpuUtilization, + value: _parseLoadPercentage(state.cpuLoad!), + color: colorScheme.primary, + ), + ], + // Memory Utilization + if (state.memoryLoad != null) ...[ + if (state.cpuLoad != null) AppGap.md(), + _buildUtilizationBar( + context, + label: loc(context).memoryUtilization, + value: _parseLoadPercentage(state.memoryLoad!), + color: appColorScheme?.semanticSuccess ?? colorScheme.tertiary, + ), + ], + ], + ), + ); + } + + /// Normal layout: Standard horizontal layout (unchanged) + Widget _buildNormalLayout( + BuildContext context, + WidgetRef ref, { + required dynamic master, + required String masterIcon, + required bool isMasterOffline, + }) { + return AppInkWell( + customColor: Colors.transparent, + customBorderWidth: 0, + onTap: () => _navigateToNodeDetail(context, ref, master.data.deviceId), + borderRadius: BorderRadius.circular(AppRadius.md), + child: Row( + children: [ + // Router Image + SizedBox( + width: 90, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SharedWidgets.resolveRouterImage( + context, + masterIcon, + size: 80, + ), + ], + ), + ), + // Details Table + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + AppText.titleSmall(master.data.location), + AppGap.md(), + Table( + border: const TableBorder(), + columnWidths: const { + 0: IntrinsicColumnWidth(), + 1: FlexColumnWidth(), + }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + _buildRow( + context, + loc(context).connection, + isMasterOffline + ? '--' + : (master.data.isWiredConnection == true) + ? loc(context).wired + : loc(context).wireless, + ), + _buildRow( + context, + loc(context).model, + master.data.model, + ), + _buildRow( + context, + loc(context).serialNo, + master.data.serialNumber, + ), + if (master.data.ipAddress.isNotEmpty) + _buildRow( + context, + 'IP Address', + master.data.ipAddress, + ), + if (master.data.macAddress.isNotEmpty) + _buildRow( + context, + 'MAC Address', + master.data.macAddress, + ), + _buildFirmwareRow( + context, + master.data.fwVersion, + !isMasterOffline && master.data.fwUpToDate == false, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + /// Build a utilization bar with label and percentage + Widget _buildUtilizationBar( + BuildContext context, { + required String label, + required double value, + required Color color, + }) { + final colorScheme = Theme.of(context).colorScheme; + final percentage = (value * 100).clamp(0, 100).toInt(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AppText.labelSmall(label), + AppText.labelSmall('$percentage%'), + ], + ), + AppGap.xs(), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: value.clamp(0, 1), + backgroundColor: colorScheme.surfaceContainerHighest, + valueColor: AlwaysStoppedAnimation(color), + minHeight: 8, + ), + ), + ], + ); + } + + /// Parse JNAP load string to percentage (0.0 - 1.0) + /// JNAP returns values like "0.23" representing 23% + double _parseLoadPercentage(String loadString) { + final parsed = double.tryParse(loadString) ?? 0.0; + // JNAP returns as decimal (0.23 = 23%), so we use it directly + return parsed.clamp(0.0, 1.0); + } + + /// Format uptime seconds to human readable string + String _formatUptime(BuildContext context, int seconds) { + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + + if (days > 0) { + return '${days}d ${hours}h ${minutes}m'; + } else if (hours > 0) { + return '${hours}h ${minutes}m'; + } else { + return '${minutes}m'; + } + } + + TableRow _buildRow(BuildContext context, String label, String value) { + return TableRow( + children: [ + Padding( + padding: const EdgeInsets.only(right: AppSpacing.md, bottom: 4), + child: AppText.labelMedium('$label:'), + ), + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: AppText.bodyMedium(value), + ), + ], + ); + } + + TableRow _buildFirmwareRow( + BuildContext context, String version, bool hasUpdate) { + return TableRow( + children: [ + Padding( + padding: const EdgeInsets.only(right: AppSpacing.md, bottom: 4), + child: AppText.labelMedium('${loc(context).fwVersion}:'), + ), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: AppSpacing.sm, + children: [ + AppText.bodyMedium(version), + if (hasUpdate) + SharedWidgets.nodeFirmwareStatusWidget( + context, + true, + () { + context.pushNamed(RouteNamed.firmwareUpdateDetail); + }, + ), + ], + ), + ], + ); + } + + void _navigateToNodeDetail( + BuildContext context, WidgetRef ref, String deviceId) { + ref.read(nodeDetailIdProvider.notifier).state = deviceId; + context.pushNamed(RouteNamed.nodeDetails); + } +} + +// Keep old name as alias for backward compatibility +@Deprecated('Use CustomMasterNodeInfo instead') +typedef DashboardMasterNodeInfo = CustomMasterNodeInfo; diff --git a/lib/page/dashboard/views/components/widgets/atomic/network_stats.dart b/lib/page/dashboard/views/components/widgets/atomic/network_stats.dart new file mode 100644 index 000000000..4ba782fa1 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/atomic/network_stats.dart @@ -0,0 +1,466 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; +import 'package:privacy_gui/core/utils/devices.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/views/components/core/display_mode_widget.dart'; +import 'package:privacy_gui/page/instant_topology/providers/_providers.dart'; +import 'package:privacy_gui/route/constants.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Atomic widget displaying network stats (nodes/devices count). +/// +/// For custom layout (Bento Grid) only. +/// - Compact: Simple row with icons and counts. +/// - Normal/Expanded: Two visual blocks for Nodes and Devices. +class CustomNetworkStats extends DisplayModeConsumerWidget { + const CustomNetworkStats({ + super.key, + super.displayMode, + }); + + @override + double getLoadingHeight(DisplayMode mode) => switch (mode) { + DisplayMode.compact => 80, + DisplayMode.normal => 100, + DisplayMode.expanded => 120, + }; + + @override + Widget buildCompactView(BuildContext context, WidgetRef ref) { + // Compact: Minimal row with icons and numbers + final (nodesCount, hasOffline, externalDeviceCount) = _getStats(ref); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildCompactItem( + context, + icon: hasOffline + ? AppIcon.font(AppFontIcons.infoCircle, + color: Theme.of(context).colorScheme.error) + : AppIcon.font(AppFontIcons.networkNode), + count: nodesCount, + label: loc(context).nodes, + onTap: () { + ref.read(topologySelectedIdProvider.notifier).state = ''; + context.pushNamed(RouteNamed.menuInstantTopology); + }, + ), + Container( + width: 1, + height: 24, + color: Theme.of(context).colorScheme.outlineVariant, + ), + _buildCompactItem( + context, + icon: AppIcon.font(AppFontIcons.devices), + count: externalDeviceCount, + label: loc(context).devices, + onTap: () => context.pushNamed(RouteNamed.menuInstantDevices), + ), + ], + ), + ); + } + + @override + Widget buildNormalView(BuildContext context, WidgetRef ref) { + return _buildBlockLayout(context, ref, isExpanded: false); + } + + @override + Widget buildExpandedView(BuildContext context, WidgetRef ref) { + return _buildExpandedBlockLayout(context, ref); + } + + Widget _buildBlockLayout( + BuildContext context, + WidgetRef ref, { + required bool isExpanded, + }) { + final (nodesCount, hasOffline, externalDeviceCount) = _getStats(ref); + + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + Expanded( + child: _infoBlock( + context, + icon: hasOffline + ? AppIcon.font(AppFontIcons.infoCircle, + color: Theme.of(context).colorScheme.error) + : AppIcon.font(AppFontIcons.networkNode), + count: nodesCount, + label: nodesCount == 1 ? loc(context).node : loc(context).nodes, + onTap: () { + ref.read(topologySelectedIdProvider.notifier).state = ''; + context.pushNamed(RouteNamed.menuInstantTopology); + }, + ), + ), + AppGap.md(), + Expanded( + child: _infoBlock( + context, + icon: AppIcon.font(AppFontIcons.devices), + count: externalDeviceCount, + label: externalDeviceCount == 1 + ? loc(context).device + : loc(context).devices, + onTap: () => context.pushNamed(RouteNamed.menuInstantDevices), + ), + ), + ], + ), + ); + } + + Widget _buildExpandedBlockLayout(BuildContext context, WidgetRef ref) { + final stats = _getDetailedStats(ref); + final colorScheme = Theme.of(context).colorScheme; + final appColorScheme = Theme.of(context).extension(); + + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + // Nodes Block + Expanded( + child: _chartInfoBlock( + context, + title: stats.totalNodes.toString(), + subtitle: stats.totalNodes == 1 + ? loc(context).node + : loc(context).nodes, + chartData: [ + if (stats.onlineNodes > 0) + _ChartSegment(stats.onlineNodes.toDouble(), + appColorScheme?.semanticSuccess ?? colorScheme.primary), + if (stats.offlineNodes > 0) + _ChartSegment( + stats.offlineNodes.toDouble(), colorScheme.error), + ], + onTap: () { + ref.read(topologySelectedIdProvider.notifier).state = ''; + context.pushNamed(RouteNamed.menuInstantTopology); + }, + legend: [ + if (stats.onlineNodes > 0) + _LegendItem( + loc(context).online, + appColorScheme?.semanticSuccess ?? colorScheme.primary, + stats.onlineNodes), + if (stats.offlineNodes > 0) + _LegendItem('Offline', colorScheme.error, stats.offlineNodes), + ], + ), + ), + AppGap.md(), + // Devices Block + Expanded( + child: _chartInfoBlock( + context, + title: stats.totalDevices.toString(), + subtitle: stats.totalDevices == 1 + ? loc(context).device + : loc(context).devices, + chartData: [ + if (stats.wiredDevices > 0) + _ChartSegment( + stats.wiredDevices.toDouble(), colorScheme.primary), + if (stats.wirelessDevices > 0) + _ChartSegment( + stats.wirelessDevices.toDouble(), colorScheme.tertiary), + ], + onTap: () => context.pushNamed(RouteNamed.menuInstantDevices), + legend: [ + if (stats.wiredDevices > 0) + _LegendItem(loc(context).wired, colorScheme.primary, + stats.wiredDevices), + if (stats.wirelessDevices > 0) + _LegendItem(loc(context).wireless, colorScheme.tertiary, + stats.wirelessDevices), + ], + ), + ), + ], + ), + ); + } + + Widget _buildCompactItem( + BuildContext context, { + required Widget icon, + required int count, + required String label, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: Row( + children: [ + icon, + AppGap.sm(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.titleSmall('$count'), + AppText.labelSmall(label, + color: Theme.of(context).colorScheme.onSurfaceVariant), + ], + ), + ], + ), + ); + } + + Widget _infoBlock( + BuildContext context, { + required Widget icon, + required int count, + required String label, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AppText.titleLarge('$count'), + icon, + ], + ), + AppGap.sm(), + AppText.bodyMedium(label), + ], + ), + ), + ); + } + + Widget _chartInfoBlock( + BuildContext context, { + required String title, + required String subtitle, + required List<_ChartSegment> chartData, + required List<_LegendItem> legend, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // Chart + SizedBox( + width: 50, + height: 50, + child: CustomPaint( + painter: _DonutChartPainter( + segments: chartData, + width: 6, + backgroundColor: + Theme.of(context).colorScheme.surfaceContainerHighest, + ), + child: Center( + child: AppText.labelMedium(title), + ), + ), + ), + AppGap.md(), + // Legend + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.titleSmall(subtitle), + AppGap.xs(), + ...legend.map((item) => Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: item.color, + shape: BoxShape.circle, + ), + ), + AppGap.xs(), + Expanded( + child: AppText.labelSmall( + '${item.label} (${item.count})', + maxLines: 1, + overflow: TextOverflow.ellipsis, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + )), + ], + ), + ), + ], + ), + ), + ); + } + + (int, bool, int) _getStats(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 (nodes.length, hasOffline, externalDeviceCount); + } + + _DetailedStats _getDetailedStats(WidgetRef ref) { + // Nodes + final topologyState = ref.watch(instantTopologyProvider); + final nodes = topologyState.root.children.firstOrNull?.toFlatList() ?? []; + final totalNodes = nodes.length; + final offlineNodes = nodes.where((e) => !e.data.isOnline).length; + final onlineNodes = totalNodes - offlineNodes; + + // Devices + final devices = ref + .watch(deviceManagerProvider) + .externalDevices + .where((e) => e.isOnline()); + final totalDevices = devices.length; + final wiredDevices = devices + .where((d) => d.getConnectionType() == DeviceConnectionType.wired) + .length; + final wirelessDevices = devices + .where((d) => d.getConnectionType() == DeviceConnectionType.wireless) + .length; + + return _DetailedStats( + totalNodes: totalNodes, + onlineNodes: onlineNodes, + offlineNodes: offlineNodes, + totalDevices: totalDevices, + wiredDevices: wiredDevices, + wirelessDevices: wirelessDevices, + ); + } +} + +class _DetailedStats { + final int totalNodes; + final int onlineNodes; + final int offlineNodes; + final int totalDevices; + final int wiredDevices; + final int wirelessDevices; + + _DetailedStats({ + required this.totalNodes, + required this.onlineNodes, + required this.offlineNodes, + required this.totalDevices, + required this.wiredDevices, + required this.wirelessDevices, + }); +} + +class _ChartSegment { + final double value; + final Color color; + + _ChartSegment(this.value, this.color); +} + +class _LegendItem { + final String label; + final Color color; + final int count; + + _LegendItem(this.label, this.color, this.count); +} + +class _DonutChartPainter extends CustomPainter { + final List<_ChartSegment> segments; + final double width; + final Color backgroundColor; + + _DonutChartPainter({ + required this.segments, + required this.width, + required this.backgroundColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (math.min(size.width, size.height) - width) / 2; + final rect = Rect.fromCircle(center: center, radius: radius); + + final bgPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.stroke + ..strokeWidth = width; + + canvas.drawCircle(center, radius, bgPaint); + + double total = segments.fold(0, (sum, item) => sum + item.value); + if (total == 0) return; + + double startAngle = -math.pi / 2; + + for (var segment in segments) { + final sweepAngle = (segment.value / total) * 2 * math.pi; + final paint = Paint() + ..color = segment.color + ..style = PaintingStyle.stroke + ..strokeWidth = width + ..strokeCap = StrokeCap.round; + + canvas.drawArc(rect, startAngle, sweepAngle, false, paint); + startAngle += sweepAngle; + } + } + + @override + bool shouldRepaint(_DonutChartPainter oldDelegate) => + segments != oldDelegate.segments; +} + +// Keep old name as alias for backward compatibility +@Deprecated('Use CustomNetworkStats instead') +typedef DashboardNetworkStats = CustomNetworkStats; diff --git a/lib/page/dashboard/views/components/widgets/atomic/ports.dart b/lib/page/dashboard/views/components/widgets/atomic/ports.dart new file mode 100644 index 000000000..0a3e397b9 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/atomic/ports.dart @@ -0,0 +1,261 @@ +import 'package:collection/collection.dart'; +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/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/display_mode_widget.dart'; +import 'package:privacy_gui/page/dashboard/views/components/widgets/parts/port_status_widget.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_home_state.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Atomic widget displaying port status (LAN + WAN). +/// +/// For custom layout (Bento Grid) only. +/// - Compact: Minimal port icons in a row +/// - Normal: Standard port display (auto horizontal/vertical) +/// - Expanded: Detailed port info with labels +/// +/// The widget automatically adapts layout (horizontal/vertical) based on: +/// 1. Device port configuration (hasLanPort, isHorizontalLayout) +/// 2. Available container space (aspect ratio) +class CustomPorts extends DisplayModeConsumerWidget { + const CustomPorts({ + super.key, + super.displayMode, + }); + + @override + double getLoadingHeight(DisplayMode mode) => switch (mode) { + DisplayMode.compact => 80, + DisplayMode.normal => 150, + DisplayMode.expanded => 200, + }; + + @override + Widget buildCompactView(BuildContext context, WidgetRef ref) { + final state = ref.watch(dashboardHomeProvider); + final hasLanPort = state.lanPortConnections.isNotEmpty; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // LAN ports + ...state.lanPortConnections.mapIndexed((index, e) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: _compactPortIcon( + context, + label: 'LAN${index + 1}', + isConnected: e != 'None', + isWan: false, + ), + ); + }), + // WAN port + if (hasLanPort) const SizedBox(width: AppSpacing.md), + _compactPortIcon( + context, + label: loc(context).wan, + isConnected: state.wanPortConnection != 'None', + isWan: true, + ), + ], + ), + ); + } + + @override + Widget buildNormalView(BuildContext context, WidgetRef ref) { + return _buildAdaptivePortLayout(context, ref); + } + + @override + Widget buildExpandedView(BuildContext context, WidgetRef ref) { + return _buildAdaptivePortLayout(context, ref); + } + + /// Builds adaptive port layout based on container aspect ratio + Widget _buildAdaptivePortLayout( + BuildContext context, + WidgetRef ref, { + bool showTitle = false, + }) { + final state = ref.watch(dashboardHomeProvider); + final hasLanPort = state.lanPortConnections.isNotEmpty; + final preferHorizontal = hasLanPort && state.isHorizontalLayout; + + return LayoutBuilder( + builder: (context, constraints) { + // Determine layout based on aspect ratio and preference + final aspectRatio = constraints.maxWidth / constraints.maxHeight; + + // Thresholds for layout decision + // - If device prefers horizontal and has enough width, use horizontal + // - Otherwise use vertical (which can scroll if needed) + final useHorizontal = preferHorizontal && aspectRatio > 1.2; + + Widget portContent; + if (!hasLanPort) { + // No LAN port: horizontal compact layout + portContent = _buildNoLanPortLayout(context, state); + } else if (useHorizontal) { + portContent = _buildHorizontalLayout(context, state); + } else { + portContent = _buildVerticalLayout(context, state); + } + + if (showTitle) { + // Use SingleChildScrollView to prevent overflow in expanded mode + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only( + left: AppSpacing.lg, + top: AppSpacing.lg, + bottom: AppSpacing.md, + ), + child: AppText.titleSmall(loc(context).ports), + ), + portContent, + ], + ), + ); + } + + return portContent; + }, + ); + } + + Widget _buildNoLanPortLayout(BuildContext context, DashboardHomeState state) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: PortStatusWidget( + connection: state.wanPortConnection == 'None' + ? null + : state.wanPortConnection, + label: loc(context).wan, + isWan: true, + hasLanPorts: false, + ), + ), + ); + } + + Widget _buildHorizontalLayout( + BuildContext context, DashboardHomeState state) { + final lanPorts = state.lanPortConnections.mapIndexed((index, e) { + return Expanded( + child: PortStatusWidget( + connection: e == 'None' ? null : e, + label: loc(context).indexedPort(index + 1), + isWan: false, + hasLanPorts: true, + ), + ); + }).toList(); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.all(AppSpacing.lg), + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: MediaQuery.of(context).size.width * 0.3, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...lanPorts, + Expanded( + child: PortStatusWidget( + connection: state.wanPortConnection == 'None' + ? null + : state.wanPortConnection, + label: loc(context).wan, + isWan: true, + hasLanPorts: true, + ), + ), + ], + ), + ), + ); + } + + Widget _buildVerticalLayout(BuildContext context, DashboardHomeState state) { + final lanPorts = state.lanPortConnections.mapIndexed((index, e) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.lg), + child: PortStatusWidget( + connection: e == 'None' ? null : e, + label: loc(context).indexedPort(index + 1), + isWan: false, + hasLanPorts: true, + ), + ); + }).toList(); + + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ...lanPorts, + PortStatusWidget( + connection: state.wanPortConnection == 'None' + ? null + : state.wanPortConnection, + label: loc(context).wan, + isWan: true, + hasLanPorts: 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, + ), + ], + ), + ); + } +} diff --git a/lib/page/dashboard/views/components/widgets/atomic/speed_test.dart b/lib/page/dashboard/views/components/widgets/atomic/speed_test.dart new file mode 100644 index 000000000..f899fe2b9 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/atomic/speed_test.dart @@ -0,0 +1,593 @@ +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/views/components/core/display_mode_widget.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/health_check/models/health_check_enum.dart'; +import 'package:privacy_gui/page/health_check/models/speed_test_ui_model.dart'; +import 'package:privacy_gui/page/health_check/providers/health_check_provider.dart'; +import 'package:privacy_gui/page/health_check/providers/health_check_state.dart'; +import 'package:privacy_gui/page/health_check/widgets/speed_test_widget.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Atomic widget displaying speed test results. +/// +/// For custom layout (Bento Grid) only. +class CustomSpeedTest extends DisplayModeConsumerWidget { + const CustomSpeedTest({ + super.key, + super.displayMode, + }); + + @override + double getLoadingHeight(DisplayMode mode) => switch (mode) { + DisplayMode.compact => 80, + DisplayMode.normal => 200, + DisplayMode.expanded => 300, + }; + + @override + Widget buildCompactView(BuildContext context, WidgetRef ref) { + // Compact: Minimal view with just download/upload stats + controls + final healthCheckState = ref.watch(healthCheckProvider); + final result = healthCheckState.latestSpeedTest ?? + healthCheckState.result ?? + SpeedTestUIModel.empty(); + final isRunning = healthCheckState.status == HealthCheckStatus.running; + + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + 2, // Very tight vertical padding for 1-row + AppSpacing.md, + 2, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildCompactStat( + context, + icon: Icons.arrow_downward, + value: result.downloadSpeed, + unit: result.downloadUnit, + color: Theme.of(context).colorScheme.primary, + ), + Container( + width: 1, + height: 20, // Reduced height + color: Theme.of(context).dividerColor, + ), + _buildCompactStat( + context, + icon: Icons.arrow_upward, + value: result.uploadSpeed, + unit: result.uploadUnit, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + AppGap.xs(), // Reduced gap + SizedBox( + width: 28, // Reduced button size + height: 28, + child: isRunning + ? const CircularProgressIndicator(strokeWidth: 2) + : IconButton( + onPressed: () { + ref + .read(healthCheckProvider.notifier) + .runHealthCheck(Module.speedtest); + }, + icon: const Icon(Icons.play_arrow, size: 20), + padding: EdgeInsets.zero, + color: Theme.of(context).colorScheme.primary, + tooltip: 'Start Speed Test', + ), + ), + ], + ), + // Removed gap for tighter layout + if (healthCheckState.status != HealthCheckStatus.idle) + Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + _getStatusText(context, healthCheckState), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + ); + } + + String _getStatusText(BuildContext context, HealthCheckState state) { + if (state.status == HealthCheckStatus.running) { + if (state.step == HealthCheckStep.latency) { + return 'Measuring Latency...'; + } + if (state.step == HealthCheckStep.downloadBandwidth) { + return 'Downloading...'; + } + if (state.step == HealthCheckStep.uploadBandwidth) { + return 'Uploading...'; + } + return 'Running...'; + } + if (state.status == HealthCheckStatus.complete) { + return 'Completed'; + } + if (state.status == HealthCheckStatus.idle) { + if (state.latestSpeedTest != null) { + return 'Last run: ${state.latestSpeedTest!.timestamp}'; + } + return 'Ready'; + } + return ''; + } + + Widget _buildCompactStat( + BuildContext context, { + required IconData icon, + required String value, + required String unit, + required Color color, + }) { + return Row( + children: [ + Icon(icon, size: 16, color: color.withValues(alpha: 0.8)), + AppGap.sm(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelLarge(value), + AppText.labelSmall( + unit, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ], + ); + } + + @override + Widget buildNormalView(BuildContext context, WidgetRef ref) { + return _buildContent(context, ref, isCompact: false); + } + + @override + Widget buildExpandedView(BuildContext context, WidgetRef ref) { + return _buildContent(context, ref, isCompact: false, isExpanded: true); + } + + Widget _buildContent( + BuildContext context, + WidgetRef ref, { + bool isCompact = false, + bool isExpanded = false, + }) { + final state = ref.watch(dashboardHomeProvider); + final hasLanPort = state.lanPortConnections.isNotEmpty; + final isRemote = BuildConfig.isRemote(); + final healthCheckState = ref.watch(healthCheckProvider); + final isHealthCheckSupported = healthCheckState.isSpeedTestModuleSupported; + + if (isHealthCheckSupported) { + // Internal Speed Test + if (hasLanPort) { + if (isExpanded) { + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top Section: Meter + Key Stats + // Top Section: Meter + Key Stats + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Meter (Left 60%) + Expanded( + flex: 6, + child: Center( + child: SpeedTestWidget( + showDetails: false, + showInfoPanel: + false, // Hide built-in info, we show manual + showStepDescriptions: false, + showLatestOnIdle: true, + layout: SpeedTestLayout.vertical, + meterSize: + 180, // Slightly larger meter since we have space + showResultSummary: + false, // Hide redundant summary to prevent overflow + ), + ), + ), + const VerticalDivider(indent: 10, endIndent: 10), + // Big Stats (Right 40%) + Expanded( + flex: 4, + child: Padding( + padding: const EdgeInsets.only(left: AppSpacing.lg), + child: _BigStatsPanel( + state: healthCheckState, + result: healthCheckState.result ?? + healthCheckState.latestSpeedTest ?? + SpeedTestUIModel.empty(), + ), + ), + ), + ], + ), + const Divider(height: AppSpacing.xl), + + // Middle Section: Details (Date, Server, Ping) + _DetailsPanel( + result: healthCheckState.result ?? + healthCheckState.latestSpeedTest ?? + SpeedTestUIModel.empty(), + ), + + const SizedBox(height: AppSpacing.lg), + + // Bottom Section: History Chart (Taller) + SizedBox( + height: 220, // Increased height + child: _HistoryChart( + history: healthCheckState.historicalSpeedTests, + ), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: SpeedTestWidget( + showDetails: false, + showInfoPanel: + false, // Hide info panel in Normal mode for cleaner look + showStepDescriptions: false, + // Show latest results in both Normal and Expanded modes + showLatestOnIdle: true, + layout: SpeedTestLayout.vertical, + // Dynamic meter size: 240 for Normal (Vertical layout in 250px height) + meterSize: 240, + ), + ); + } else { + return InternalSpeedTestResult(state: state); + } + } + + // External Links (Legacy/Remote) + return Tooltip( + message: loc(context).featureUnavailableInRemoteMode, + child: Opacity( + opacity: isRemote ? 0.5 : 1, + child: AbsorbPointer( + absorbing: isRemote, + child: ExternalSpeedTestLinks(state: state), + ), + ), + ); + } +} + +class _HistoryChart extends StatelessWidget { + final List history; + + const _HistoryChart({required this.history}); + + @override + Widget build(BuildContext context) { + if (history.isEmpty) { + return Center( + child: AppText.bodyMedium( + 'No history available', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + + // Prepare data points (reversed because history is usually new -> old) + // We want display left (old) -> right (new) + final data = history.reversed.toList(); + + // Max 10 points to keep chart readable + final displayData = + data.length > 10 ? data.sublist(data.length - 10) : data; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.titleSmall('Speed History (${displayData.length} runs)'), + const SizedBox(height: AppSpacing.sm), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + return CustomPaint( + size: Size(constraints.maxWidth, constraints.maxHeight), + painter: _HistoryChartPainter( + context: context, + data: displayData, + ), + ); + }, + ), + ), + const SizedBox(height: AppSpacing.xs), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _LegendItem( + color: Theme.of(context).colorScheme.primary, + label: loc(context).download), + const SizedBox(width: AppSpacing.lg), + _LegendItem( + color: Theme.of(context).colorScheme.secondary, + label: loc(context).upload), + ], + ), + ], + ); + } +} + +class _LegendItem extends StatelessWidget { + final Color color; + final String label; + + const _LegendItem({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: AppSpacing.xs), + AppText.labelSmall(label), + ], + ); + } +} + +class _HistoryChartPainter extends CustomPainter { + final BuildContext context; + final List data; + + _HistoryChartPainter({required this.context, required this.data}); + + @override + void paint(Canvas canvas, Size size) { + if (data.isEmpty) return; + + final padding = 20.0; + final chartRect = Rect.fromLTWH( + padding, padding, size.width - padding * 2, size.height - padding * 2); + + // Extract values (parse string if kbps is missing, but simpler to rely on parsing logic or just 0) + // SpeedTestUIModel has downloadSpeed string "xxx.x" and units. + // Ideally we use uploadBandwidthKbps if available. + final downloads = data + .map((e) => (e.downloadBandwidthKbps ?? 0) / 1024.0) + .toList(); // Mbps + final uploads = + data.map((e) => (e.uploadBandwidthKbps ?? 0) / 1024.0).toList(); // Mbps + + // If kbps is null/0, try parsing string (fallback) + for (int i = 0; i < data.length; i++) { + if (downloads[i] == 0) { + downloads[i] = double.tryParse(data[i].downloadSpeed) ?? 0; + } + if (uploads[i] == 0) { + uploads[i] = double.tryParse(data[i].uploadSpeed) ?? 0; + } + } + + final allValues = [...downloads, ...uploads]; + final maxY = allValues.isEmpty + ? 100.0 + : allValues.reduce((a, b) => a > b ? a : b) * 1.2; // 20% buffer + + final primaryColor = Theme.of(context).colorScheme.primary; + final secondaryColor = Theme.of(context).colorScheme.secondary; + final gridColor = + Theme.of(context).colorScheme.outlineVariant.withOpacity(0.5); + + // Draw Grid + final paintGrid = Paint() + ..color = gridColor + ..strokeWidth = 1; + + // Horizontal lines (0, 50%, 100%) + canvas.drawLine(Offset(chartRect.left, chartRect.bottom), + Offset(chartRect.right, chartRect.bottom), paintGrid); + canvas.drawLine( + Offset(chartRect.left, chartRect.top + chartRect.height * 0.5), + Offset(chartRect.right, chartRect.top + chartRect.height * 0.5), + paintGrid); + canvas.drawLine(Offset(chartRect.left, chartRect.top), + Offset(chartRect.right, chartRect.top), paintGrid); + + // Draw Lines + _drawLine(canvas, chartRect, downloads, maxY, primaryColor, true); + _drawLine(canvas, chartRect, uploads, maxY, secondaryColor, false); + } + + void _drawLine(Canvas canvas, Rect rect, List values, double maxY, + Color color, bool isFilled) { + if (values.isEmpty) return; + + final path = Path(); + final stepX = rect.width / (values.length - 1 == 0 ? 1 : values.length - 1); + + for (int i = 0; i < values.length; i++) { + final x = rect.left + i * stepX; + final y = + rect.bottom - (values[i] / (maxY == 0 ? 1 : maxY)) * rect.height; + if (i == 0) { + path.moveTo(x, y); + } else { + path.lineTo(x, y); + } + + // Draw point + canvas.drawCircle(Offset(x, y), 3, Paint()..color = color); + } + + // Stroke + canvas.drawPath( + path, + Paint() + ..color = color + ..strokeWidth = 2 + ..style = PaintingStyle.stroke); + + // Fill (Optional, maybe gradient?) + if (isFilled) { + path.lineTo(rect.right, rect.bottom); + path.lineTo(rect.left, rect.bottom); + path.close(); + canvas.drawPath( + path, + Paint() + ..color = color.withOpacity(0.1) + ..style = PaintingStyle.fill); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +class _BigStatsPanel extends StatelessWidget { + final HealthCheckState state; + final SpeedTestUIModel result; + + const _BigStatsPanel({required this.state, required this.result}); + + @override + Widget build(BuildContext context) { + if (result.timestamp == '--' && state.status == HealthCheckStatus.idle) { + return Center( + child: AppText.bodyMedium( + 'Run a speed test to see details', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status Text (e.g. "Running...", "Completed") + if (state.status == HealthCheckStatus.running) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: AppText.titleSmall( + _getStatusText(context, state), + color: Theme.of(context).colorScheme.primary, + ), + ), + + // Main Result (Download / Upload) - Using Wrap to prevent overflow + Wrap( + spacing: AppSpacing.lg, + runSpacing: AppSpacing.sm, + children: [ + _buildBigStat(context, 'Download', result.downloadSpeed, + result.downloadUnit, Theme.of(context).colorScheme.primary), + _buildBigStat(context, 'Upload', result.uploadSpeed, + result.uploadUnit, Theme.of(context).colorScheme.secondary), + ], + ), + ], + ); + } + + String _getStatusText(BuildContext context, HealthCheckState state) { + if (state.step == HealthCheckStep.latency) return 'Measuring Latency...'; + if (state.step == HealthCheckStep.downloadBandwidth) + return 'Downloading...'; + if (state.step == HealthCheckStep.uploadBandwidth) return 'Uploading...'; + return 'Running...'; + } + + Widget _buildBigStat(BuildContext context, String label, String value, + String unit, Color color) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelSmall(label, + color: Theme.of(context).colorScheme.onSurfaceVariant), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + AppText.headlineSmall(value, color: color), + const SizedBox(width: 4), + AppText.labelSmall(unit, color: color), + ], + ), + ], + ); + } +} + +class _DetailsPanel extends StatelessWidget { + final SpeedTestUIModel result; + + const _DetailsPanel({required this.result}); + + @override + Widget build(BuildContext context) { + // Detailed Info + return Wrap( + spacing: AppSpacing.xl, + runSpacing: AppSpacing.md, + children: [ + _buildDetailItem(context, 'Date', result.timestamp), + _buildDetailItem(context, 'Server', result.serverId), + _buildDetailItem(context, 'Ping', '${result.latency} ms'), + ], + ); + } + + Widget _buildDetailItem(BuildContext context, String label, String value) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelSmall(label, + color: Theme.of(context).colorScheme.onSurfaceVariant), + AppText.titleSmall(value), + ], + ); + } +} diff --git a/lib/page/dashboard/views/components/widgets/atomic/topology.dart b/lib/page/dashboard/views/components/widgets/atomic/topology.dart new file mode 100644 index 000000000..1aa960840 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/atomic/topology.dart @@ -0,0 +1,209 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/core/utils/topology_adapter.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/views/components/core/display_mode_widget.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'; + +/// Atomic widget displaying mesh topology. +/// +/// For custom layout (Bento Grid) only. +/// - Compact: Minimal tree view +/// - Normal: Standard tree view +/// - Expanded: Graph view for visualization +class CustomTopology extends DisplayModeConsumerWidget { + const CustomTopology({ + super.key, + super.displayMode, + }); + + @override + double getLoadingHeight(DisplayMode mode) => switch (mode) { + DisplayMode.compact => 80, + DisplayMode.normal => 200, + DisplayMode.expanded => 400, + }; + + @override + Widget buildCompactView(BuildContext context, WidgetRef ref) { + final topologyState = ref.watch(instantTopologyProvider); + final routerLength = + topologyState.root.children.firstOrNull?.toFlatList().length ?? 0; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xs, + ), + child: AppInkWell( + customColor: Colors.transparent, + customBorderWidth: 0, + onTap: () => context.pushNamed(RouteNamed.menuInstantTopology), + borderRadius: BorderRadius.circular(AppRadius.md), + child: Row( + children: [ + Icon( + Icons.hub, + color: Theme.of(context).colorScheme.primary, + ), + AppGap.md(), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelLarge(loc(context).topology), + AppText.labelSmall( + '$routerLength ${routerLength <= 1 ? loc(context).node : loc(context).nodes}', + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ], + ), + ), + ); + } + + @override + Widget buildNormalView(BuildContext context, WidgetRef ref) { + return _buildTopologyView(context, ref, isExpanded: false); + } + + @override + Widget buildExpandedView(BuildContext context, WidgetRef ref) { + return _buildTopologyView(context, ref, isExpanded: true); + } + + Widget _buildTopologyView( + BuildContext context, + WidgetRef ref, { + required bool isExpanded, + }) { + final topologyState = ref.watch(instantTopologyProvider); + final meshTopology = TopologyAdapter.convert(topologyState.root.children); + + const topologyItemHeight = 72.0; + const treeViewBaseHeight = 72.0; + final routerLength = + topologyState.root.children.firstOrNull?.toFlatList().length ?? 1; + + final double nodeTopologyHeight = isExpanded + ? double.infinity + : (context.isMobileLayout + ? routerLength * topologyItemHeight + : min(routerLength * topologyItemHeight, 3 * topologyItemHeight)); + + final showAllTopology = context.isMobileLayout || routerLength <= 3; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: SizedBox( + height: isExpanded + ? double.infinity + : (nodeTopologyHeight + treeViewBaseHeight), + child: AppTopology( + topology: meshTopology, + viewMode: + isExpanded ? TopologyViewMode.graph : TopologyViewMode.tree, + enableAnimation: isExpanded || !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) => _buildSubtitle(context, meshNode), + ), + nodeContentBuilder: _buildGraphNodeContent, + clientVisibility: ClientVisibility.onHover, + nodeTapFilter: (node) => !node.isInternet, + interactive: + false, // Disable interaction to prevent dashboard scroll hijacking + ), + )); + } + + String _buildSubtitle(BuildContext context, MeshNode 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'; + } + + Widget _buildGraphNodeContent(BuildContext context, MeshNode meshNode, + NodeStyle style, bool isOffline) { + final deviceCount = meshNode.metadata?['connectedDeviceCount'] ?? 0; + + Widget content; + if (meshNode.image != null) { + content = Image(image: meshNode.image!, fit: BoxFit.contain); + } else { + content = Icon( + meshNode.iconData ?? Icons.router, + color: style.iconColor, + size: style.size * 0.5, + ); + } + + final paddedContent = Padding( + padding: const EdgeInsets.all(12.0), + child: content, + ); + + if (deviceCount > 0) { + return Stack( + children: [ + paddedContent, + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of(context).colorScheme.onPrimaryContainer, + width: 1, + ), + ), + child: Text( + '$deviceCount', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ); + } + return paddedContent; + } +} diff --git a/lib/page/dashboard/views/components/widgets/composite/internet_status.dart b/lib/page/dashboard/views/components/widgets/composite/internet_status.dart new file mode 100644 index 000000000..5f424e583 --- /dev/null +++ b/lib/page/dashboard/views/components/widgets/composite/internet_status.dart @@ -0,0 +1,498 @@ +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, + this.useAppCard = true, + }); + + /// The display mode for this widget + final DisplayMode displayMode; + + /// Whether to wrap the content in an AppCard (default true). + /// Set to false for layouts that provide their own containers. + final bool useAppCard; + + @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 => 40, + 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 content = 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(), + // 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() + .whenComplete(() => controller.stop()); + }, + ), + ), + ], + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: const EdgeInsets.all(AppSpacing.sm), + child: content, + ); + } + + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + + /// 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 topology = ref.watch(instantTopologyProvider); + // Guard against empty children list to prevent crash + if (topology.root.children.isEmpty) { + return const SizedBox.shrink(); + } + final master = topology.root.children.first; + final masterIcon = ref.watch(dashboardHomeProvider).masterIcon; + final wanPortConnection = + ref.watch(dashboardHomeProvider).wanPortConnection; + final isMasterOffline = + master.data.isOnline == false || wanPortConnection == 'None'; + + final content = 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() + .whenComplete(() => 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); + }, + ), + ] + ], + ), + ]), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: content, + ); + } + return content; + } + + /// 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 topology = ref.watch(instantTopologyProvider); + // Guard against empty children list to prevent crash + if (topology.root.children.isEmpty) { + return const SizedBox.shrink(); + } + final master = topology.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'; + + final content = 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() + .whenComplete(() => 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); + }, + ), + ] + ], + ), + ]), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: content, + ); + } + return content; + } + + /// Format uptime in human-readable format + String _formatUptime(int seconds) { + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + + if (days > 0) { + return '${loc(context).uptime}: ${days}d ${hours}h'; + } else if (hours > 0) { + return '${loc(context).uptime}: ${hours}h ${minutes}m'; + } else { + return '${loc(context).uptime}: ${minutes}m'; + } + } +} diff --git a/lib/page/dashboard/views/components/widgets/networks.dart b/lib/page/dashboard/views/components/widgets/composite/networks.dart similarity index 79% rename from lib/page/dashboard/views/components/widgets/networks.dart rename to lib/page/dashboard/views/components/widgets/composite/networks.dart index 624267697..10af3bdb8 100644 --- a/lib/page/dashboard/views/components/widgets/networks.dart +++ b/lib/page/dashboard/views/components/widgets/composite/networks.dart @@ -27,11 +27,15 @@ class DashboardNetworks extends ConsumerWidget { const DashboardNetworks({ super.key, this.displayMode = DisplayMode.normal, + this.useAppCard = true, }); /// The display mode for this widget final DisplayMode displayMode; + /// Whether to wrap the content in an AppCard (default true). + final bool useAppCard; + @override Widget build(BuildContext context, WidgetRef ref) { return DashboardLoadingWrapper( @@ -67,54 +71,63 @@ class DashboardNetworks extends ConsumerWidget { .where((e) => e.isOnline()) .length; - return AppCard( - child: InkWell( - onTap: () => context.pushNamed(RouteNamed.menuInstantTopology), - child: Row( - children: [ - // Nodes section - Expanded( + final content = 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: [ - hasOffline - ? AppIcon.font(AppFontIcons.infoCircle, - color: Theme.of(context).colorScheme.error, size: 18) - : AppIcon.font(AppFontIcons.networkNode, size: 18), + AppIcon.font(AppFontIcons.devices, size: 18), AppGap.sm(), - AppText.titleMedium('${nodes.length}'), + AppText.titleMedium('$externalDeviceCount'), AppGap.xs(), AppText.bodySmall( - nodes.length == 1 ? loc(context).node : loc(context).nodes, + externalDeviceCount == 1 + ? loc(context).device + : loc(context).devices, ), ], ), ), - 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, - ), - ], - ), - ), - ), - ], - ), + ), + ], ), ); + + if (useAppCard) { + return AppCard( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); } /// Normal view: Standard view with topology tree (existing implementation) @@ -135,24 +148,34 @@ class DashboardNetworks extends ConsumerWidget { : min(routerLength * topologyItemHeight, 3 * topologyItemHeight); final showAllTopology = context.isMobileLayout || routerLength <= 3; - return AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppGap.lg(), - _buildNetworkHeader(context, ref, state), - SizedBox( - height: nodeTopologyHeight + treeViewBaseHeight, - child: _buildTopologyView( - context, - ref, - meshTopology, - topologyState, - showAllTopology, - ), + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppGap.lg(), + _buildNetworkHeader(context, ref, state), + SizedBox( + height: nodeTopologyHeight + treeViewBaseHeight, + child: _buildTopologyView( + context, + ref, + meshTopology, + topologyState, + showAllTopology, ), - ], - ), + ), + ], + ); + + if (useAppCard) { + return AppCard( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, ); } @@ -171,24 +194,33 @@ class DashboardNetworks extends ConsumerWidget { 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 - ), + final content = 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 ), - ], - ), + ), + ], + ); + + if (useAppCard) { + return AppCard( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, + ); + } + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: content, ); } diff --git a/lib/page/dashboard/views/components/widgets/port_and_speed.dart b/lib/page/dashboard/views/components/widgets/composite/port_and_speed.dart similarity index 77% rename from lib/page/dashboard/views/components/widgets/port_and_speed.dart rename to lib/page/dashboard/views/components/widgets/composite/port_and_speed.dart index 30c3f2fa5..70fb518db 100644 --- a/lib/page/dashboard/views/components/widgets/port_and_speed.dart +++ b/lib/page/dashboard/views/components/widgets/composite/port_and_speed.dart @@ -30,6 +30,7 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { super.key, required this.config, this.displayMode = DisplayMode.normal, + this.useAppCard = true, }); /// Configuration provided by the parent Strategy. @@ -38,6 +39,9 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { /// The display mode for this widget final DisplayMode displayMode; + /// Whether to wrap the content in an AppCard (default true). + final bool useAppCard; + @override Widget build(BuildContext context, WidgetRef ref) { return DashboardLoadingWrapper( @@ -100,33 +104,45 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { WidgetRef ref, DashboardHomeState state, ) { - return AppCard( + final content = 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, + ), + ], + ); + + if (useAppCard) { + return AppCard( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: content, + ); + } + + return Padding( 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, - ), - ], - ), + child: content, ); } @@ -177,35 +193,40 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { // If auto-layout, we rely on intrinsic sizing primarily, but can keep minHeight for consistency if needed. // final minHeight = _calculateMinHeight(isVertical, hasLanPort); - return Container( + final content = Container( width: double.infinity, // constraints: BoxConstraints(minHeight: minHeight), - child: AppCard( - padding: EdgeInsets.zero, - child: Column( - mainAxisSize: - config.portsHeight == null ? MainAxisSize.min : MainAxisSize.max, - mainAxisAlignment: isVertical - ? MainAxisAlignment.spaceBetween - : MainAxisAlignment.start, - children: [ + child: Column( + mainAxisSize: + config.portsHeight == null ? MainAxisSize.min : MainAxisSize.max, + mainAxisAlignment: isVertical + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.start, + children: [ + SizedBox( + height: config.portsHeight, + child: Padding( + padding: config.portsPadding, + child: _buildPortsSection(context, state, direction), + ), + ), + if (config.showSpeedTest) SizedBox( - height: config.portsHeight, - child: Padding( - padding: config.portsPadding, - child: _buildPortsSection(context, state, direction), - ), + 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), - ), - ], - ), + ], ), ); + + if (useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: content, + ); + } + return content; } /// Expanded view: Detailed port and speed info @@ -217,37 +238,42 @@ class DashboardHomePortAndSpeed extends ConsumerWidget { ) { final hasLanPort = state.lanPortConnections.isNotEmpty; - return Container( + final content = 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 + 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: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.titleSmall(loc(context).ports), - AppGap.lg(), - _buildPortsSection(context, state, direction), - ], - ), + child: _buildSpeedTestSection(context, ref, state, hasLanPort), ), - if (config.showSpeedTest) ...[ - const Divider(), - Padding( - padding: EdgeInsets.all(AppSpacing.xl), - child: _buildSpeedTestSection(context, ref, state, hasLanPort), - ), - ], ], - ), + ], ), ); + + if (useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: content, + ); + } + return content; } Widget _buildPortsSection( diff --git a/lib/page/dashboard/views/components/widgets/quick_panel.dart b/lib/page/dashboard/views/components/widgets/composite/quick_panel.dart similarity index 59% rename from lib/page/dashboard/views/components/widgets/quick_panel.dart rename to lib/page/dashboard/views/components/widgets/composite/quick_panel.dart index 2a8c19ba5..29a31b62a 100644 --- a/lib/page/dashboard/views/components/widgets/quick_panel.dart +++ b/lib/page/dashboard/views/components/widgets/composite/quick_panel.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; -import 'package:privacy_gui/core/jnap/models/node_light_settings.dart'; + import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.dart'; import 'package:privacy_gui/core/utils/nodes.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; @@ -27,11 +27,15 @@ class DashboardQuickPanel extends ConsumerStatefulWidget { const DashboardQuickPanel({ super.key, this.displayMode = DisplayMode.normal, + this.useAppCard = true, }); /// The display mode for this widget final DisplayMode displayMode; + /// Whether to wrap the content in an AppCard (default true). + final bool useAppCard; + @override ConsumerState createState() => _DashboardQuickPanelState(); @@ -72,58 +76,71 @@ class _DashboardQuickPanelState extends ConsumerState { 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 + final content = 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: Icons.shield, - isActive: privacyState.status.mode == MacFilterMode.allow, - label: loc(context).instantPrivacy, - onTap: () => context.pushNamed(RouteNamed.menuInstantPrivacy), + icon: AppFontIcons.darkMode, + isActive: nodeLightState.isNightModeEnabled, + label: loc(context).nightMode, 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()); - } - }); + final notifier = ref.read(nodeLightSettingsProvider.notifier); + if (value) { + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: true, allDayOff: false)); + } else { + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: false, allDayOff: false)); + } + 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()); - }, - ), - ], + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + child: content, + ); + } + return Padding( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, ), + child: content, ); } @@ -176,105 +193,26 @@ class _DashboardQuickPanelState extends ConsumerState { modelNumber: master.data.model, hardwareVersion: master.data.hardwareVersion); - return AppCard( - padding: EdgeInsets.all(AppSpacing.xxl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - toggleTileWidget( - title: loc(context).instantPrivacy, - value: privacyState.status.mode == MacFilterMode.allow, - leading: AppBadge( - label: 'BETA', - color: Theme.of(context) - .extension()! - .semanticWarning, - ), - onTap: () { - context.pushNamed(RouteNamed.menuInstantPrivacy); - }, - onChanged: (value) { - showInstantPrivacyConfirmDialog(context, value).then((isOk) { - if (isOk != true) { - return; - } - final notifier = ref.read(instantPrivacyProvider.notifier); - if (value) { - final macAddressList = ref - .read(instantPrivacyDeviceListProvider) - .map((e) => e.macAddress.toUpperCase()) - .toList(); - notifier.setMacAddressList(macAddressList); - } - notifier.setEnable(value); - if (context.mounted) { - doSomethingWithSpinner(context, notifier.save()); - } - }); - }, - tips: loc(context).instantPrivacyInfo, - semantics: 'quick instant privacy switch'), - if (isCognitive && isSupportNodeLight) ...[ - const Divider( - height: 48, - thickness: 1.0, - ), - toggleTileWidget( - title: loc(context).nightMode, - value: nodeLightState.isNightModeEnable, - subTitle: ref - .read(nodeLightSettingsProvider.notifier) - .currentStatus == - NodeLightStatus.night - ? loc(context).nightModeTime - : ref - .read(nodeLightSettingsProvider.notifier) - .currentStatus == - NodeLightStatus.off - ? loc(context).allDayOff - : null, - onChanged: (value) { - final notifier = ref.read(nodeLightSettingsProvider.notifier); - if (value) { - notifier.setSettings(NodeLightSettings.night()); - } else { - notifier.setSettings(NodeLightSettings.on()); - } - doSomethingWithSpinner(context, notifier.save()); - }, - tips: loc(context).nightModeTips, - semantics: 'quick night mode switch'), - ] - ], - ), - ); - } - - /// Expanded view: Toggles with full descriptions - Widget _buildExpandedView(BuildContext context, WidgetRef ref) { - final privacyState = ref.watch(instantPrivacyProvider); - final nodeLightState = ref.watch(nodeLightSettingsProvider); - final master = ref.watch(instantTopologyProvider).root.children.first; - bool isSupportNodeLight = serviceHelper.isSupportLedMode(); - bool isCognitive = isCognitiveMeshRouter( - modelNumber: master.data.model, - hardwareVersion: master.data.hardwareVersion); - - return AppCard( - padding: EdgeInsets.all(AppSpacing.xxl), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _expandedToggleTile( - context, + final content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + toggleTileWidget( title: loc(context).instantPrivacy, - description: loc(context).instantPrivacyInfo, value: privacyState.status.mode == MacFilterMode.allow, - onTap: () => context.pushNamed(RouteNamed.menuInstantPrivacy), + 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; + if (isOk != true) { + return; + } final notifier = ref.read(instantPrivacyProvider.notifier); if (value) { final macAddressList = ref @@ -289,27 +227,132 @@ class _DashboardQuickPanelState extends ConsumerState { } }); }, + tips: loc(context).instantPrivacyInfo, + semantics: 'quick instant privacy switch'), + if (isCognitive && isSupportNodeLight) ...[ + const Divider( + height: 48, + thickness: 1.0, ), - if (isCognitive && isSupportNodeLight) ...[ - const Divider(height: 32), - _expandedToggleTile( - context, + toggleTileWidget( title: loc(context).nightMode, - description: loc(context).nightModeTips, - value: nodeLightState.isNightModeEnable, + value: nodeLightState.isNightModeEnabled, + 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()); + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: true, allDayOff: false)); } else { - notifier.setSettings(NodeLightSettings.on()); + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: false, allDayOff: false)); } doSomethingWithSpinner(context, notifier.save()); }, - ), - ] - ], - ), + tips: loc(context).nightModeTips, + semantics: 'quick night mode switch'), + ] + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: content, + ), + ); + } + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: content, + ); + } + + /// 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); + + final content = 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.isNightModeEnabled, + onChanged: (value) { + final notifier = ref.read(nodeLightSettingsProvider.notifier); + if (value) { + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: true, allDayOff: false)); + } else { + notifier.setSettings(nodeLightState.copyWith( + isNightModeEnabled: false, allDayOff: false)); + } + doSomethingWithSpinner(context, notifier.save()); + }, + ), + ] + ], + ); + + if (widget.useAppCard) { + return AppCard( + padding: EdgeInsets.zero, + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: content, + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.xxl), + child: content, ); } diff --git a/lib/page/dashboard/views/components/widgets/wifi_grid.dart b/lib/page/dashboard/views/components/widgets/composite/wifi_grid.dart similarity index 71% rename from lib/page/dashboard/views/components/widgets/wifi_grid.dart rename to lib/page/dashboard/views/components/widgets/composite/wifi_grid.dart index 9425c60cb..0352e8b89 100644 --- a/lib/page/dashboard/views/components/widgets/wifi_grid.dart +++ b/lib/page/dashboard/views/components/widgets/composite/wifi_grid.dart @@ -51,7 +51,7 @@ class _DashboardWiFiGridState extends ConsumerState { }; } - /// Compact view: Horizontal scrollable small cards + /// Compact view: Vertical list of simplified cards (Band & Switch only) Widget _buildCompactView(BuildContext context, WidgetRef ref) { final items = ref.watch(dashboardHomeProvider.select((value) => value.wifis)); @@ -61,21 +61,26 @@ class _DashboardWiFiGridState extends ConsumerState { 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) { + // Use SingleChildScrollView to allow scrolling if content exceeds height + return SingleChildScrollView( + child: Wrap( + spacing: AppSpacing.md, + runSpacing: AppSpacing.sm, + children: items.asMap().entries.map((entry) { + final index = entry.key; + // Calculate width for 2 items per row logic, or just let them wrap? + // If we want "tight", let them take intrinsic width or fixed width? + // Since it's a grid cell, maybe full width fraction? + // User said "horizontal arrangement". + // Let's wrapping LayoutBuilder to determine available width? + // For simplicity, let's try Wrap with Intrinsic Width first, + // but WiFiCard generally expands. + // We need to constrain the width of items in Wrap. return SizedBox( - width: 200, - height: compactHeight, - child: _buildWiFiCard(items, index, canBeDisabled), + width: 180, // Approximate width for visual balance + child: _buildWiFiCard(items, index, canBeDisabled, isCompact: true), ); - }, + }).toList(), ), ); } @@ -101,24 +106,28 @@ class _DashboardWiFiGridState extends ConsumerState { final gridHeight = mainAxisCount * itemHeight + ((mainAxisCount == 0 ? 1 : mainAxisCount) - 1) * AppSpacing.lg; - return SizedBox( - height: gridHeight, - child: GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: AppSpacing.lg, - crossAxisSpacing: mainSpacing, - mainAxisExtent: itemHeight, + // Use SingleChildScrollView to handle overflow when Bento Grid container + // is smaller than the calculated gridHeight + return SingleChildScrollView( + child: 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), + ); + }, ), - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - itemCount: items.length, - itemBuilder: (context, index) { - return SizedBox( - height: itemHeight, - child: _buildWiFiCard(items, index, canBeDisabled), - ); - }, ), ); } @@ -158,8 +167,10 @@ class _DashboardWiFiGridState extends ConsumerState { Widget _buildWiFiCard( List items, int index, - bool canBeDisabled, - ) { + bool canBeDisabled, { + EdgeInsetsGeometry? padding, + bool isCompact = false, + }) { final item = items[index]; final visibilityKey = '${item.ssid}${item.radios.join()}${item.isGuest}'; final isVisible = toolTipVisible[visibilityKey] ?? false; @@ -169,6 +180,7 @@ class _DashboardWiFiGridState extends ConsumerState { item: item, index: index, canBeDisabled: canBeDisabled, + isCompact: isCompact, onTooltipVisibilityChanged: (visible) { setState(() { // Hide all other tooltips when showing one @@ -180,6 +192,7 @@ class _DashboardWiFiGridState extends ConsumerState { toolTipVisible[visibilityKey] = visible; }); }, + padding: padding, ); } } diff --git a/lib/page/dashboard/views/components/widgets/home_title.dart b/lib/page/dashboard/views/components/widgets/home_title.dart index cdb1393a6..64faa3967 100644 --- a/lib/page/dashboard/views/components/widgets/home_title.dart +++ b/lib/page/dashboard/views/components/widgets/home_title.dart @@ -1,11 +1,13 @@ 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/router_time_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/core/dashboard_loading_wrapper.dart'; +import 'package:privacy_gui/page/dashboard/views/components/settings/dashboard_layout_settings_panel.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'; @@ -14,12 +16,19 @@ import 'package:ui_kit_library/ui_kit.dart'; import 'package:privacy_gui/page/components/composed/app_list_card.dart'; class DashboardHomeTitle extends ConsumerWidget { - const DashboardHomeTitle({super.key}); + final VoidCallback? onEditPressed; + final bool showSettings; + + const DashboardHomeTitle({ + super.key, + this.onEditPressed, + this.showSettings = true, + }); @override Widget build(BuildContext context, WidgetRef ref) { return DashboardLoadingWrapper( - loadingHeight: 150, + loadingHeight: 60, builder: (context, ref) => _buildContent(context, ref), ); } @@ -38,10 +47,9 @@ class DashboardHomeTitle extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: Text( + child: AppText.titleLarge( helloString(context, localTime), overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleLarge, ), ), InkWell( @@ -60,18 +68,23 @@ class DashboardHomeTitle extends ConsumerWidget { 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, - ), + child: AppText.bodyMedium( + loc(context).formalDateTime(localTime, localTime), + maxLines: 1, + overflow: TextOverflow.ellipsis, + color: Theme.of(context).colorScheme.onSurface), ), ], ), ), + if (showSettings && BuildConfig.customLayout) ...[ + AppGap.lg(), + AppIconButton.icon( + size: AppButtonSize.small, + icon: const Icon(Icons.settings_outlined), + onTap: onEditPressed ?? () => _openLayoutSettings(context), + ), + ], ], ), if (!isOnline) _troubleshooting(context, ref), @@ -79,6 +92,22 @@ class DashboardHomeTitle extends ConsumerWidget { ); } + void _openLayoutSettings(BuildContext context) { + showDialog( + context: context, + builder: (context) => AppDialog( + title: AppText.titleMedium(loc(context).dashboardSettings), + content: const DashboardLayoutSettingsPanel(), + actions: [ + AppButton( + label: loc(context).close, + onTap: () => Navigator.pop(context), + ), + ], + ), + ); + } + Widget _troubleshooting(BuildContext context, WidgetRef ref) { return Padding( padding: const EdgeInsets.only( diff --git a/lib/page/dashboard/views/components/widgets/internet_status.dart b/lib/page/dashboard/views/components/widgets/internet_status.dart deleted file mode 100644 index a947053eb..000000000 --- a/lib/page/dashboard/views/components/widgets/internet_status.dart +++ /dev/null @@ -1,476 +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/core/dashboard_loading_wrapper.dart'; -import 'package:privacy_gui/page/instant_topology/providers/_providers.dart'; -import 'package:privacy_gui/route/constants.dart'; -import 'package:privacy_gui/utils.dart'; - -import 'package:ui_kit_library/ui_kit.dart'; - -/// Widget displaying internet connection status. -/// -/// Supports three display modes: -/// - [DisplayMode.compact]: Only status indicator and IP -/// - [DisplayMode.normal]: Full display with router info -/// - [DisplayMode.expanded]: Extra details like uptime -class InternetConnectionWidget extends ConsumerStatefulWidget { - const InternetConnectionWidget({ - super.key, - this.displayMode = DisplayMode.normal, - }); - - /// The display mode for this widget - final DisplayMode displayMode; - - @override - ConsumerState createState() => - _InternetConnectionWidgetState(); -} - -class _InternetConnectionWidgetState - extends ConsumerState { - @override - Widget build(BuildContext context) { - return DashboardLoadingWrapper( - loadingHeight: _getLoadingHeight(), - builder: (context, ref) => _buildContent(context, ref), - ); - } - - double _getLoadingHeight() { - return switch (widget.displayMode) { - DisplayMode.compact => 80, - DisplayMode.normal => 150, - DisplayMode.expanded => 200, - }; - } - - Widget _buildContent(BuildContext context, WidgetRef ref) { - return switch (widget.displayMode) { - DisplayMode.compact => _buildCompactView(context, ref), - DisplayMode.normal => _buildNormalView(context, ref), - DisplayMode.expanded => _buildExpandedView(context, ref), - }; - } - - /// Compact view: Single row with status indicator + Online/Offline + location - Widget _buildCompactView(BuildContext context, WidgetRef ref) { - final wanStatus = ref.watch(internetStatusProvider); - final isOnline = wanStatus == InternetStatus.online; - final geolocationState = ref.watch(geolocationProvider); - - return AppCard( - child: Row( - children: [ - // Status indicator - Icon( - Icons.circle, - color: isOnline - ? Theme.of(context).extension()!.semanticSuccess - : Theme.of(context).colorScheme.surfaceContainerHighest, - size: 12.0, - ), - AppGap.sm(), - // Status text - AppText.labelLarge( - isOnline - ? loc(context).internetOnline - : loc(context).internetOffline, - ), - const Spacer(), - // Location info - if (geolocationState.value?.name.isNotEmpty == true) - AppText.bodySmall( - geolocationState.value?.displayLocationText ?? '', - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - // Refresh button (non-mobile only) - if (!Utils.isMobilePlatform()) ...[ - AppGap.md(), - AnimatedRefreshContainer( - builder: (controller) => AppIconButton( - icon: AppIcon.font(AppFontIcons.refresh, size: 16), - onTap: () { - controller.repeat(); - ref.read(pollingProvider.notifier).forcePolling().then((_) { - controller.stop(); - }); - }, - ), - ), - ], - ], - ), - ); - } - - /// Normal view: Full display with router info (current implementation) - Widget _buildNormalView(BuildContext context, WidgetRef ref) { - final wanStatus = ref.watch(internetStatusProvider); - final isOnline = wanStatus == InternetStatus.online; - final geolocationState = ref.watch(geolocationProvider); - final master = ref.watch(instantTopologyProvider).root.children.first; - final masterIcon = ref.watch(dashboardHomeProvider).masterIcon; - final wanPortConnection = - ref.watch(dashboardHomeProvider).wanPortConnection; - final isMasterOffline = - master.data.isOnline == false || wanPortConnection == 'None'; - - return AppCard( - padding: EdgeInsets.zero, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.circle, - color: isOnline - ? Theme.of(context) - .extension()! - .semanticSuccess - : Theme.of(context).colorScheme.surfaceContainerHighest, - size: 16.0, - ), - AppGap.sm(), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.titleSmall( - isOnline - ? loc(context).internetOnline - : loc(context).internetOffline, - ), - if (geolocationState.value?.name.isNotEmpty == true) ...[ - AppGap.sm(), - SharedWidgets.geolocationWidget( - context, - geolocationState.value?.name ?? '', - geolocationState.value?.displayLocationText ?? ''), - ], - ], - ), - ), - if (!Utils.isMobilePlatform()) - AnimatedRefreshContainer( - builder: (controller) { - return Padding( - padding: const EdgeInsets.all(0.0), - child: AppIconButton( - icon: AppIcon.font( - AppFontIcons.refresh, - ), - onTap: () { - controller.repeat(); - ref - .read(pollingProvider.notifier) - .forcePolling() - .then((value) { - controller.stop(); - }); - }, - ), - ); - }, - ), - ], - ), - ), - Container( - key: const ValueKey('master'), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, - border: Border.all(color: Colors.transparent), - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(8), - bottomRight: Radius.circular(8), - ), - ), - child: Row( - children: [ - SizedBox( - width: context.isMobileLayout ? 120 : 90, - child: SharedWidgets.resolveRouterImage(context, masterIcon, - size: 112), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - top: AppSpacing.lg, - bottom: AppSpacing.lg, - left: AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - AppText.titleMedium(master.data.location), - AppGap.lg(), - Table( - border: const TableBorder(), - columnWidths: const { - 0: FlexColumnWidth(1), - 1: FlexColumnWidth(2), - }, - children: [ - TableRow(children: [ - AppText.labelLarge('${loc(context).connection}:'), - AppText.bodyMedium(isMasterOffline - ? '--' - : (master.data.isWiredConnection == true) - ? loc(context).wired - : loc(context).wireless), - ]), - TableRow(children: [ - AppText.labelLarge('${loc(context).model}:'), - AppText.bodyMedium(master.data.model), - ]), - TableRow(children: [ - AppText.labelLarge('${loc(context).serialNo}:'), - AppText.bodyMedium(master.data.serialNumber), - ]), - TableRow(children: [ - AppText.labelLarge('${loc(context).fwVersion}:'), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - AppText.bodyMedium(master.data.fwVersion), - if (!isMasterOffline) ...[ - AppGap.lg(), - SharedWidgets.nodeFirmwareStatusWidget( - context, - master.data.fwUpToDate == false, - () { - context.pushNamed( - RouteNamed.firmwareUpdateDetail); - }, - ), - ] - ], - ), - ]), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - /// Expanded view: Normal view + uptime info - Widget _buildExpandedView(BuildContext context, WidgetRef ref) { - final wanStatus = ref.watch(internetStatusProvider); - final isOnline = wanStatus == InternetStatus.online; - final geolocationState = ref.watch(geolocationProvider); - final master = ref.watch(instantTopologyProvider).root.children.first; - final masterIcon = ref.watch(dashboardHomeProvider).masterIcon; - final wanPortConnection = - ref.watch(dashboardHomeProvider).wanPortConnection; - final uptime = ref.watch(dashboardHomeProvider).uptime; - final isMasterOffline = - master.data.isOnline == false || wanPortConnection == 'None'; - - return AppCard( - padding: EdgeInsets.zero, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - Icons.circle, - color: isOnline - ? Theme.of(context) - .extension()! - .semanticSuccess - : Theme.of(context).colorScheme.surfaceContainerHighest, - size: 16.0, - ), - AppGap.sm(), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.titleSmall( - isOnline - ? loc(context).internetOnline - : loc(context).internetOffline, - ), - if (geolocationState.value?.name.isNotEmpty == true) ...[ - AppGap.sm(), - SharedWidgets.geolocationWidget( - context, - geolocationState.value?.name ?? '', - geolocationState.value?.displayLocationText ?? ''), - ], - // Uptime info (expanded only) - if (uptime != null && isOnline) ...[ - AppGap.md(), - Row( - children: [ - AppIcon.font(Icons.access_time, size: 14), - AppGap.xs(), - AppText.bodySmall( - _formatUptime(uptime), - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ], - ), - ], - ], - ), - ), - if (!Utils.isMobilePlatform()) - AnimatedRefreshContainer( - builder: (controller) { - return Padding( - padding: const EdgeInsets.all(0.0), - child: AppIconButton( - icon: AppIcon.font(AppFontIcons.refresh), - onTap: () { - controller.repeat(); - ref - .read(pollingProvider.notifier) - .forcePolling() - .then((_) { - controller.stop(); - }); - }, - ), - ); - }, - ), - ], - ), - ), - Container( - key: const ValueKey('master_expanded'), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, - border: Border.all(color: Colors.transparent), - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(8), - bottomRight: Radius.circular(8), - ), - ), - child: Row( - children: [ - SizedBox( - width: context.isMobileLayout ? 120 : 90, - child: SharedWidgets.resolveRouterImage(context, masterIcon, - size: 112), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - top: AppSpacing.lg, - bottom: AppSpacing.lg, - left: AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - AppText.titleMedium(master.data.location), - AppGap.lg(), - Table( - border: const TableBorder(), - columnWidths: const { - 0: FlexColumnWidth(1), - 1: FlexColumnWidth(2), - }, - children: [ - TableRow(children: [ - AppText.labelLarge('${loc(context).connection}:'), - AppText.bodyMedium(isMasterOffline - ? '--' - : (master.data.isWiredConnection == true) - ? loc(context).wired - : loc(context).wireless), - ]), - TableRow(children: [ - AppText.labelLarge('${loc(context).model}:'), - AppText.bodyMedium(master.data.model), - ]), - TableRow(children: [ - AppText.labelLarge('${loc(context).serialNo}:'), - AppText.bodyMedium(master.data.serialNumber), - ]), - TableRow(children: [ - AppText.labelLarge('${loc(context).fwVersion}:'), - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - AppText.bodyMedium(master.data.fwVersion), - if (!isMasterOffline) ...[ - AppGap.lg(), - SharedWidgets.nodeFirmwareStatusWidget( - context, - master.data.fwUpToDate == false, - () { - context.pushNamed( - RouteNamed.firmwareUpdateDetail); - }, - ), - ] - ], - ), - ]), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - /// Format uptime in human-readable format - String _formatUptime(int seconds) { - final days = seconds ~/ 86400; - final hours = (seconds % 86400) ~/ 3600; - final minutes = (seconds % 3600) ~/ 60; - - if (days > 0) { - return '${loc(context).uptime}: ${days}d ${hours}h'; - } else if (hours > 0) { - return '${loc(context).uptime}: ${hours}h ${minutes}m'; - } else { - return '${loc(context).uptime}: ${minutes}m'; - } - } -} diff --git a/lib/page/dashboard/views/components/widgets/parts/port_status_widget.dart b/lib/page/dashboard/views/components/widgets/parts/port_status_widget.dart index 6f4f5afb0..cf39a3c6f 100644 --- a/lib/page/dashboard/views/components/widgets/parts/port_status_widget.dart +++ b/lib/page/dashboard/views/components/widgets/parts/port_status_widget.dart @@ -44,18 +44,26 @@ class PortStatusWidget extends StatelessWidget { ], ]; - // Port image + // Port image - use colorFilter for connected state (green) + final isConnected = connection != null; + final portColor = isConnected + ? Theme.of(context).extension()?.semanticSuccess ?? + Colors.green + : Theme.of(context).colorScheme.surfaceContainerHighest; + final portImage = Padding( padding: const EdgeInsets.all(AppSpacing.sm), - child: connection == null - ? Assets.images.imgPortOff.svg( + child: isConnected + ? Assets.images.imgPortOn.svg( width: 40, height: 40, + colorFilter: ColorFilter.mode(portColor, BlendMode.srcIn), semanticsLabel: 'port status image', ) - : Assets.images.imgPortOn.svg( + : Assets.images.imgPortOff.svg( width: 40, height: 40, + colorFilter: ColorFilter.mode(portColor, BlendMode.srcIn), semanticsLabel: 'port status image', ), ); diff --git a/lib/page/dashboard/views/components/widgets/parts/wifi_card.dart b/lib/page/dashboard/views/components/widgets/parts/wifi_card.dart index 6e9e552ad..0e5d745e2 100644 --- a/lib/page/dashboard/views/components/widgets/parts/wifi_card.dart +++ b/lib/page/dashboard/views/components/widgets/parts/wifi_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; @@ -13,7 +14,12 @@ import 'package:privacy_gui/util/qr_code.dart'; import 'package:privacy_gui/util/wifi_credential.dart'; import 'package:privacy_gui/util/export_selector/export_selector.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:collection/collection.dart'; import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; +import 'package:privacy_gui/core/data/providers/device_manager_state.dart'; +import 'package:privacy_gui/core/utils/devices.dart'; +import 'package:privacy_gui/page/instant_device/extensions/icon_device_category_ext.dart'; /// A card widget displaying WiFi network information with toggle and share. class WiFiCard extends ConsumerStatefulWidget { @@ -22,6 +28,8 @@ class WiFiCard extends ConsumerStatefulWidget { final bool canBeDisabled; final bool tooltipVisible; final ValueChanged? onTooltipVisibilityChanged; + final EdgeInsetsGeometry? padding; + final bool isCompact; const WiFiCard({ super.key, @@ -29,9 +37,14 @@ class WiFiCard extends ConsumerStatefulWidget { required this.index, required this.canBeDisabled, this.tooltipVisible = false, + this.padding, this.onTooltipVisibilityChanged, + this.isCompact = false, + this.isExpanded = false, }); + final bool isExpanded; + @override ConsumerState createState() => _WiFiCardState(); } @@ -41,10 +54,18 @@ class _WiFiCardState extends ConsumerState { @override Widget build(BuildContext context) { + if (widget.isCompact) { + return _buildCompactCard(context); + } + return LayoutBuilder(builder: (context, constraint) { + if (widget.isExpanded) { + return _buildExpandedCard(context); + } return AppCard( - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.xxl, horizontal: AppSpacing.xxl), + padding: widget.padding ?? + const EdgeInsets.symmetric( + vertical: AppSpacing.xxl, horizontal: AppSpacing.xxl), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -63,6 +84,52 @@ class _WiFiCardState extends ConsumerState { }); } + Widget _buildCompactCard(BuildContext context) { + return AppCard( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + onTap: () { + context.pushNamed(RouteNamed.menuIncredibleWiFi, + extra: {'wifiIndex': widget.index, 'guest': widget.item.isGuest}); + }, + child: Row( + children: [ + // Band Icon + Container( + padding: const EdgeInsets.all(AppSpacing.xs), + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(Icons.wifi, + size: 16, color: Theme.of(context).colorScheme.primary), + ), + AppGap.sm(), + // Band Name + Expanded( + child: AppText.labelMedium( + widget.item.isGuest + ? loc(context).guestWifi + : widget.item.radios + .map((e) => e.replaceAll('RADIO_', '')) + .join('/'), + ), + ), + // Switch + AppSwitch( + value: widget.item.isEnabled, + onChanged: widget.item.isGuest || + !widget.item.isEnabled || + widget.canBeDisabled + ? (value) => _handleWifiToggled(value) + : null, + ), + ], + ), + ); + } + Widget _buildHeader(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -275,4 +342,279 @@ class _WiFiCardState extends ConsumerState { ], ); } + + Widget _buildExpandedCard(BuildContext context) { + return AppCard( + padding: const EdgeInsets.all(AppSpacing.xxl), + onTap: () { + context.pushNamed(RouteNamed.menuIncredibleWiFi, + extra: {'wifiIndex': widget.index, 'guest': widget.item.isGuest}); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: SSID + Toggle + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.titleLarge( + widget.item.ssid, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + AppGap.xs(), + AppText.labelMedium( + widget.item.isGuest + ? loc(context).guestWifi + : loc(context).wifiBand(widget.item.radios + .map((e) => e.replaceAll('RADIO_', '')) + .join('/')), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ), + ), + AppGap.md(), + AppSwitch( + value: widget.item.isEnabled, + onChanged: widget.item.isGuest || + !widget.item.isEnabled || + widget.canBeDisabled + ? (value) => _handleWifiToggled(value) + : null, + ), + ], + ), + const Spacer(), + + // Middle: Password + Device Preview + if (widget.item.isEnabled) ...[ + _ExpandedPasswordSection( + password: widget.item.password, + onShare: () => _showWiFiShareModal(context), + ), + const Spacer(), + _ExpandedDevicePreview(item: widget.item), + ], + ], + ), + ); + } +} + +class _ExpandedPasswordSection extends StatefulWidget { + final String password; + final VoidCallback onShare; + + const _ExpandedPasswordSection({ + required this.password, + required this.onShare, + }); + + @override + State<_ExpandedPasswordSection> createState() => + _ExpandedPasswordSectionState(); +} + +class _ExpandedPasswordSectionState extends State<_ExpandedPasswordSection> { + bool _isVisible = false; + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.hardEdge, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Row( + children: [ + Icon(Icons.key, + size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + AppGap.sm(), + Expanded( + child: AppText.bodyMedium( + _isVisible ? widget.password : '••••••••', + // TextStyle support removed from AppText, using default bodyMedium style + ), + ), + AppIconButton.small( + styleVariant: ButtonStyleVariant.text, + icon: Icon(_isVisible ? Icons.visibility_off : Icons.visibility, + size: 18), + onTap: () => setState(() => _isVisible = !_isVisible), + ), + AppIconButton.small( + styleVariant: ButtonStyleVariant.text, + icon: const Icon(Icons.copy, size: 18), + onTap: () { + // Copy to clipboard + Clipboard.setData(ClipboardData(text: widget.password)); + }, + ), + Container( + height: 20, + width: 1, + color: Theme.of(context).colorScheme.outlineVariant, + margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + ), + AppIconButton.small( + styleVariant: ButtonStyleVariant.text, + icon: const Icon(Icons.qr_code, size: 18), + onTap: widget.onShare, + ), + ], + ), + ); + } +} + +class _ExpandedDevicePreview extends ConsumerWidget { + final DashboardWiFiUIModel item; + + const _ExpandedDevicePreview({required this.item}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (item.numOfConnectedDevices == 0) { + return Row( + children: [ + Icon(Icons.devices, + size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + AppGap.sm(), + AppText.bodySmall( + loc(context).noDevicesConnected, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ); + } + + // Get devices for this network + final devices = ref + .watch(deviceManagerProvider) + .externalDevices + .where((d) { + if (!d.isOnline()) return false; + + final isCorrectNetwork = item.isGuest + ? d.connectedWifiType == WifiConnectionType.guest + : d.connectedWifiType == WifiConnectionType.main; + + if (!isCorrectNetwork) return false; + + if (item.isGuest) return true; + + // Filter by band matching item.radios + return d.connections.any((conn) { + final interface = d.knownInterfaces + ?.firstWhereOrNull((i) => i.macAddress == conn.macAddress); + final band = interface?.band; + if (band == null) return false; + + return item.radios.any((r) { + if (r.contains('2.4') && band.contains('2.4')) return true; + if (r.contains('5') && band.contains('5')) return true; + if (r.contains('6') && band.contains('6')) return true; + return false; + }); + }); + }) + .take(3) + .toList(); + + return Row( + children: [ + Icon(Icons.devices, + size: 16, color: Theme.of(context).colorScheme.primary), + AppGap.sm(), + AppText.labelMedium( + loc(context).nDevices(item.numOfConnectedDevices), + color: Theme.of(context).colorScheme.primary, + ), + AppGap.md(), + Expanded( + child: SizedBox( + height: 24, + child: Stack( + children: [ + for (var i = 0; i < devices.length; i++) + Positioned( + left: i * 16.0, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 2, + ), + ), + child: Center( + child: Icon( + devices[i].iconData, + size: 14, + color: Theme.of(context).colorScheme.onSurface, + )), + ), + ), + if (item.numOfConnectedDevices > 3) + Positioned( + left: 3 * 16.0, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 2, + ), + ), + child: Center( + child: AppText.labelSmall( + '+${item.numOfConnectedDevices - 3}', + // style: const TextStyle(fontSize: 10), // removed style + ), + ), + ), + ) + ], + ), + ), + ), + ], + ); + } +} + +extension LinksysDeviceIconExt on LinksysDevice { + IconData get iconData { + final userDeviceType = properties + .firstWhereOrNull((p) => p.name == 'userDeviceType' || p.name == 'icon') + ?.value; + + if (userDeviceType != null) { + return IconDeviceCategoryExt.resolveByName(userDeviceType); + } + + // Check if it is a phone + if (friendlyName?.toLowerCase().contains('phone') ?? false) { + return AppFontIcons.smartPhone; + } + + return AppFontIcons.genericDevice; + } } diff --git a/lib/page/dashboard/views/dashboard_home_view.dart b/lib/page/dashboard/views/dashboard_home_view.dart index 41f57ad5a..f8cfe1d4c 100644 --- a/lib/page/dashboard/views/dashboard_home_view.dart +++ b/lib/page/dashboard/views/dashboard_home_view.dart @@ -11,10 +11,17 @@ 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/dashboard/views/components/fixed_layout/internet_status.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/networks.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/port_and_speed.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/quick_panel.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/wifi_grid.dart'; +import 'package:privacy_gui/page/dashboard/views/sliver_dashboard_view.dart'; import 'package:privacy_gui/page/vpn/views/vpn_status_tile.dart'; import 'package:privacy_gui/core/utils/assign_ip/assign_ip.dart'; +import 'package:privacy_gui/page/dashboard/providers/sliver_dashboard_controller_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:ui_kit_library/ui_kit.dart'; class DashboardHomeView extends ConsumerStatefulWidget { const DashboardHomeView({Key? key}) : super(key: key); @@ -43,27 +50,28 @@ class _DashboardHomeViewState extends ConsumerState { final isHorizontalLayout = state.isHorizontalLayout; final isSupportVPN = getIt.get().isSupportVPN(); + // LISTEN for dynamic constraints changes (Decoupled from SliverDashboardView) + ref.listen( + dashboardHomeProvider.select((s) => s.lanPortConnections.isNotEmpty), + (_, __) => _updatePortsConstraints(), + ); + ref.listen( + dashboardHomeProvider.select((s) => s.isHorizontalLayout), + (_, __) => _updatePortsConstraints(), + ); + // 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; + if (useCustom) { + // SliverDashboardView now contains TopBar and SafeArea internally + // using DashboardOverlay + CustomScrollView + SliverDashboard architecture + return const SliverDashboardView(); + } + // Standard Layout: use strategy pattern return UiKitPageView.withSliver( scrollable: true, onRefresh: () async { @@ -71,12 +79,10 @@ class _DashboardHomeViewState extends ConsumerState { }, appBarStyle: UiKitAppBarStyle.none, backState: UiKitBackState.none, - padding: EdgeInsets.only( - top: 32.0, - bottom: 16.0, - ), + padding: + const EdgeInsets.only(top: AppSpacing.xxl, bottom: AppSpacing.md), child: (childContext, constraints) { - // 1. Determine layout variant (single source of truth) + // 1. Determine layout variant (Standard only) final variant = DashboardLayoutVariant.fromContext( childContext, hasLanPort: hasLanPort, @@ -84,7 +90,6 @@ 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, @@ -93,34 +98,36 @@ class _DashboardHomeViewState extends ConsumerState { isHorizontalLayout: isHorizontalLayout, widgetConfigs: preferences.widgetConfigs, title: const DashboardHomeTitle(), - internetWidget: InternetConnectionWidget( - key: ValueKey('internet-$internetMode-$useCustom'), - displayMode: internetMode, + internetWidget: FixedInternetConnectionWidget( + key: const ValueKey('internet'), + displayMode: DisplayMode.normal, + useAppCard: true, ), - networksWidget: DashboardNetworks( - key: ValueKey('networks-$networksMode-$useCustom'), - displayMode: networksMode, + networksWidget: FixedDashboardNetworks( + key: const ValueKey('networks'), + displayMode: DisplayMode.normal, + useAppCard: true, ), - wifiGrid: DashboardWiFiGrid( - key: ValueKey('wifi-$wifiMode-$useCustom'), - displayMode: wifiMode, + wifiGrid: FixedDashboardWiFiGrid( + key: const ValueKey('wifi'), + displayMode: DisplayMode.normal, ), - quickPanel: DashboardQuickPanel( - key: ValueKey('quick-$quickPanelMode-$useCustom'), - displayMode: quickPanelMode, + quickPanel: FixedDashboardQuickPanel( + key: const ValueKey('quick'), + displayMode: DisplayMode.normal, + useAppCard: true, ), vpnTile: isSupportVPN ? const VPNStatusTile() : null, - buildPortAndSpeed: (config) => DashboardHomePortAndSpeed( - key: ValueKey('port-$portAndSpeedMode-$useCustom'), + buildPortAndSpeed: (config) => FixedDashboardHomePortAndSpeed( + key: const ValueKey('port'), config: config, - displayMode: portAndSpeedMode, + displayMode: DisplayMode.normal, + useAppCard: true, ), ); - // 3. Delegate to strategy - final strategy = useCustom - ? const CustomDashboardLayoutStrategy() - : DashboardLayoutFactory.create(variant); + // 3. Delegate to strategy (Standard only) + final strategy = DashboardLayoutFactory.create(variant); return strategy.build(layoutContext); }, ); @@ -149,4 +156,32 @@ class _DashboardHomeViewState extends ConsumerState { }); }); } + + /// Helper to get spec with dynamic constraints based on state + WidgetSpec? _getDynamicSpec(String id) { + if (id == DashboardWidgetSpecs.ports.id) { + final state = ref.read(dashboardHomeProvider); + return DashboardWidgetSpecs.getPortsSpec( + hasLanPort: state.lanPortConnections.isNotEmpty, + isHorizontal: state.isHorizontalLayout, + ); + } + return DashboardWidgetSpecs.getById(id); + } + + /// Update constraints for Ports widget when layout context changes + void _updatePortsConstraints() { + final id = DashboardWidgetSpecs.ports.id; + final spec = _getDynamicSpec(id); + if (spec == null) return; + + final preferences = ref.read(dashboardPreferencesProvider); + final mode = preferences.getMode(id); + + ref.read(sliverDashboardControllerProvider.notifier).updateItemConstraints( + id, + mode, + overrideConstraints: spec.constraints[mode], + ); + } } diff --git a/lib/page/dashboard/views/dashboard_menu_view.dart b/lib/page/dashboard/views/dashboard_menu_view.dart index 6285fb863..2f7649d25 100644 --- a/lib/page/dashboard/views/dashboard_menu_view.dart +++ b/lib/page/dashboard/views/dashboard_menu_view.dart @@ -14,7 +14,6 @@ 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'; @@ -135,32 +134,12 @@ class _DashboardMenuViewState extends ConsumerState { context.pushNamed(RouteNamed.addNodes); }, ), - // AppListTile( - // title: AppText.bodyMedium('Dashboard Layout'), - // leading: const AppIcon.font(AppFontIcons.widgets), - // onTap: () { - // Navigator.of(context).maybePop(); - // _showLayoutSettingsDialog(); - // }, - // ), ], ), ), ); } - void _showLayoutSettingsDialog() { - showDialog( - context: context, - builder: (context) => Dialog( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500), - child: const DashboardLayoutSettingsPanel(), - ), - ), - ); - } - List createMenuItems() { // final isCloudLogin = // ref.watch(authProvider).value?.loginType == LoginType.remote; diff --git a/lib/page/dashboard/views/prepare_dashboard_view.dart b/lib/page/dashboard/views/prepare_dashboard_view.dart index 62c146bd8..cd6e3925e 100644 --- a/lib/page/dashboard/views/prepare_dashboard_view.dart +++ b/lib/page/dashboard/views/prepare_dashboard_view.dart @@ -2,9 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/cache/linksys_cache_manager.dart'; import 'package:go_router/go_router.dart'; -import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/data/providers/session_provider.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; +import 'package:privacy_gui/core/models/device_info.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/components/views/arguments_view.dart'; @@ -13,7 +12,6 @@ import 'package:privacy_gui/providers/auth/ra_session_provider.dart'; import 'package:privacy_gui/providers/connectivity/_connectivity.dart'; import 'package:privacy_gui/providers/connectivity/connectivity_provider.dart'; import 'package:privacy_gui/constants/_constants.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; import 'package:privacy_gui/core/data/providers/polling_provider.dart'; import 'package:privacy_gui/page/select_network/providers/select_network_provider.dart'; import 'package:privacy_gui/route/constants.dart'; @@ -75,18 +73,13 @@ class _PrepareDashboardViewState extends ConsumerState { } } else if (loginType == LoginType.local) { logger.i('PREPARE LOGIN:: local'); - final routerRepository = ref.read(routerRepositoryProvider); - - final newSerialNumber = await routerRepository - .send( - JNAPAction.getDeviceInfo, - fetchRemote: true, - ) - .then( - (value) => NodeDeviceInfo.fromJson(value.output).serialNumber); + // Use sessionProvider.forceFetchDeviceInfo() instead of direct RouterRepository access + // This adheres to Clean Architecture: View -> Provider -> Service -> Repository + final deviceInfo = + await ref.read(sessionProvider.notifier).forceFetchDeviceInfo(); await ref .read(sessionProvider.notifier) - .saveSelectedNetwork(newSerialNumber, ''); + .saveSelectedNetwork(deviceInfo.serialNumber, ''); } logger.d('Go to dashboard'); await ProviderContainer() @@ -94,10 +87,8 @@ class _PrepareDashboardViewState extends ConsumerState { .loadCache(serialNumber: serialNumber ?? ''); final nodeDeviceInfo = await ref .read(sessionProvider.notifier) - .checkDeviceInfo(null) + .fetchDeviceInfoAndInitializeServices() .then((nodeDeviceInfo) { - // Build/Update better actions - buildBetterActions(nodeDeviceInfo.services); return nodeDeviceInfo; }).onError((error, stackTrace) => null); if (nodeDeviceInfo != null) { diff --git a/lib/page/dashboard/views/sliver_dashboard_view.dart b/lib/page/dashboard/views/sliver_dashboard_view.dart new file mode 100644 index 000000000..fe137d0c8 --- /dev/null +++ b/lib/page/dashboard/views/sliver_dashboard_view.dart @@ -0,0 +1,554 @@ +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/components/styled/top_bar.dart'; +import 'package:privacy_gui/page/dashboard/models/dashboard_widget_specs.dart'; +import 'package:privacy_gui/page/dashboard/views/components/_components.dart'; +import 'package:privacy_gui/page/dashboard/views/components/effects/jiggle_shake.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/models/widget_spec.dart'; +import 'package:privacy_gui/page/dashboard/factories/dashboard_widget_factory.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_preferences_provider.dart'; +import 'package:privacy_gui/page/dashboard/models/dashboard_layout_preferences.dart'; +import 'package:sliver_dashboard/sliver_dashboard.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +import '../providers/dashboard_home_provider.dart'; +import '../providers/sliver_dashboard_controller_provider.dart'; + +/// Drag-and-drop dashboard view using sliver_dashboard. +/// +/// This replaces CustomDashboardLayoutStrategy when edit mode is active. +class SliverDashboardView extends ConsumerStatefulWidget { + const SliverDashboardView({super.key}); + + @override + ConsumerState createState() => + _SliverDashboardViewState(); +} + +class _SliverDashboardViewState extends ConsumerState { + bool _isEditMode = false; + List? _initialLayoutSnapshot; + DashboardLayoutPreferences? _initialPrefsSnapshot; + + void _enterEditMode() { + final controller = ref.read(sliverDashboardControllerProvider); + _initialLayoutSnapshot = controller.exportLayout(); + _initialPrefsSnapshot = ref.read(dashboardPreferencesProvider); + + setState(() { + _isEditMode = true; + }); + controller.setEditMode(true); + } + + void _exitEditMode({bool save = true}) async { + final controller = ref.read(sliverDashboardControllerProvider); + + if (!save) { + // Restore initial layout + if (_initialLayoutSnapshot != null) { + controller.importLayout(_initialLayoutSnapshot!); + await ref.read(sliverDashboardControllerProvider.notifier).saveLayout(); + } + + // Restore initial preferences (display modes) + if (_initialPrefsSnapshot != null) { + await ref + .read(dashboardPreferencesProvider.notifier) + .restoreSnapshot(_initialPrefsSnapshot!); + } + } + + setState(() { + _isEditMode = false; + _initialLayoutSnapshot = null; + _initialPrefsSnapshot = null; + }); + controller.setEditMode(false); + } + + @override + Widget build(BuildContext context) { + final controller = ref.watch(sliverDashboardControllerProvider); + final preferences = ref.watch(dashboardPreferencesProvider); + + // Use UI Kit's currentMaxColumns to stay synchronized with main padding + // This gets the correct column count accounting for page margins + final uiKitColumns = context.currentMaxColumns; + + // ScrollController for DashboardOverlay + final scrollController = ScrollController(); + + // Grid style only applied to the dashboard area + final editModeGridStyle = GridStyle( + lineColor: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), + lineWidth: 1, + fillColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), + ); + + return SafeArea( + child: Column( + children: [ + // TopBar - fixed at top (most important navigation element) + const TopBar(), + + // Edit Toolbar (visible only in edit mode) - fixed below TopBar + if (_isEditMode) _buildEditToolbar(context), + + // Home Title - fixed below toolbar + Padding( + padding: EdgeInsets.symmetric( + horizontal: context.pageMargin, + vertical: AppSpacing.md, + ), + child: DashboardHomeTitle( + showSettings: !_isEditMode, + onEditPressed: _isEditMode ? null : _enterEditMode, + ), + ), + + // Dashboard grid area - scrollable with grid background only here + Expanded( + child: DashboardOverlay( + controller: controller, + scrollController: scrollController, + itemBuilder: (context, item) { + final mode = preferences.getMode(item.id); + return _buildItemWidget(context, item, mode, _isEditMode); + }, + slotAspectRatio: 1.0, + mainAxisSpacing: AppSpacing.lg, + crossAxisSpacing: AppSpacing.lg, + padding: EdgeInsets.symmetric(horizontal: context.pageMargin), + // Grid background only in the dashboard area + gridStyle: _isEditMode ? editModeGridStyle : null, + onItemResizeEnd: (item) { + _handleResizeEnd(context, item); + }, + child: CustomScrollView( + controller: scrollController, + slivers: [ + // Dashboard grid - SliverDashboard with padding + SliverPadding( + padding: + EdgeInsets.symmetric(horizontal: context.pageMargin), + sliver: SliverDashboard( + itemBuilder: (context, item) { + final mode = preferences.getMode(item.id); + return _buildItemWidget( + context, item, mode, _isEditMode); + }, + slotAspectRatio: 1.0, + mainAxisSpacing: AppSpacing.lg, + crossAxisSpacing: AppSpacing.lg, + breakpoints: {0: uiKitColumns}, + gridStyle: _isEditMode ? editModeGridStyle : null, + ), + ), + + // Bottom padding + const SliverToBoxAdapter( + child: SizedBox(height: AppSpacing.md), + ), + ], + ), + ), + ), + ], + ), + ); + } + + void _handleResizeEnd(BuildContext context, LayoutItem item) { + // Reactive Constraint Enforcement + // This fixes the issue where items can be resized smaller than minWidth against grid edges. + final preferences = ref.read(dashboardPreferencesProvider); + final mode = preferences.getMode(item.id); + + WidgetSpec? spec; + + // Special handling for 'ports' widget - use dynamic constraints + if (item.id == DashboardWidgetSpecs.ports.id) { + final dashboardState = ref.read(dashboardHomeProvider); + final hasLanPort = dashboardState.lanPortConnections.isNotEmpty; + final isHorizontal = hasLanPort && dashboardState.isHorizontalLayout; + spec = DashboardWidgetSpecs.getPortsSpec( + hasLanPort: hasLanPort, + isHorizontal: isHorizontal, + ); + } else { + try { + spec = DashboardWidgetSpecs.all.firstWhere((s) => s.id == item.id); + } catch (_) { + return; + } + } + + final constraints = spec.constraints[mode]; + if (constraints == null) return; + + bool violated = false; + int newW = item.w; + int newH = item.h; + + // Enforce Width Constraints + if (item.w < constraints.minColumns) { + newW = constraints.minColumns; + violated = true; + } + if (item.w > constraints.maxColumns) { + newW = constraints.maxColumns; + violated = true; + } + + // Enforce Height Constraints using minHeightRows/maxHeightRows range + // This allows resizing within the defined range instead of locking to strict height + if (item.h < constraints.minHeightRows) { + newH = constraints.minHeightRows; + violated = true; + } + if (item.h > constraints.maxHeightRows) { + newH = constraints.maxHeightRows; + violated = true; + } + + if (violated) { + ref + .read(sliverDashboardControllerProvider.notifier) + .updateItemSize(item.id, newW, newH); + } + // Always save the layout after a resize operation, whether it was + // a valid user resize or an automatic correction. + ref.read(sliverDashboardControllerProvider.notifier).saveLayout(); + } +// ... (rest of class) + + Widget _buildEditToolbar(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: context.pageMargin, + vertical: AppSpacing.sm, + ), + child: AppCard( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + AppText.labelLarge( + 'Edit Layout', + color: Theme.of(context).colorScheme.primary, + ), + const Spacer(), + // Optimize Button - Compact the grid by filling gaps + AppIconButton( + icon: const Icon(Icons.auto_fix_high), + onTap: () { + final controller = ref.read(sliverDashboardControllerProvider); + controller.optimizeLayout(); + ref + .read(sliverDashboardControllerProvider.notifier) + .saveLayout(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Layout optimized'), + duration: Duration(seconds: 1), + ), + ); + } + }, + ), + AppGap.sm(), + // Settings Button + AppIconButton( + icon: const Icon(Icons.tune), + onTap: () => _openLayoutSettings(context), + ), + AppGap.sm(), + // Cancel Button + AppIconButton( + icon: const Icon(Icons.close), + onTap: () => _exitEditMode(save: false), + ), + AppGap.sm(), + // Save/Done Button + AppIconButton( + icon: const Icon(Icons.check), + onTap: () => _exitEditMode(save: true), + ), + ], + ), + ), + ); + } + + Future _openLayoutSettings(BuildContext context) async { + final result = await showDialog( + context: context, + builder: (context) => AppDialog( + title: AppText.titleMedium(loc(context).dashboardSettings), + content: const DashboardLayoutSettingsPanel(), + actions: [ + AppButton( + label: loc(context).close, + onTap: () => Navigator.pop(context), + ), + ], + ), + ); + + // If reset or toggle_off was triggered, exit edit mode to sync state + if ((result == 'reset' || result == 'toggle_off') && mounted) { + setState(() { + _isEditMode = false; + _initialLayoutSnapshot = null; + _initialPrefsSnapshot = null; + }); + } + } + + Widget _buildItemWidget(BuildContext context, LayoutItem item, + DisplayMode displayMode, bool isEditMode) { + // Use factory to build widget + final widget = DashboardWidgetFactory.buildAtomicWidget( + item.id, + displayMode: displayMode, + ); + + // Handle unknown widget + if (widget == null) { + return AppCard( + child: Center( + child: + AppText.bodyMedium(loc(context).dashboardUnknownWidget(item.id)), + ), + ); + } + + // Wrap in AppCard based on factory rules + final Widget displayedWidget; + if (!DashboardWidgetFactory.shouldWrapInCard(item.id)) { + displayedWidget = widget; + } else { + displayedWidget = AppCard( + padding: EdgeInsets.zero, + child: widget, + ); + } + + // If in Edit Mode, overlay a "View Mode" toggle button + if (isEditMode) { + return JiggleShake( + active: true, + child: Stack( + fit: StackFit.expand, + clipBehavior: Clip.none, + children: [ + // The widget itself (blocked interactions) + AbsorbPointer( + absorbing: true, + child: displayedWidget, + ), + Positioned( + right: 8, + top: 8, + child: Theme( + data: Theme.of(context).copyWith( + // Use dark surface for menu contrast if needed, or default + popupMenuTheme: PopupMenuThemeData( + color: Theme.of(context).colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + child: PopupMenuButton( + initialValue: displayMode, + tooltip: loc(context).dashboardChangeViewMode, + onSelected: (mode) => _updateDisplayMode(context, item, mode), + offset: const Offset(0, 32), + itemBuilder: (context) => DisplayMode.values.map((mode) { + final isSelected = mode == displayMode; + return PopupMenuItem( + value: mode, + height: 40, + child: Row( + children: [ + Icon( + _getModeIcon(mode), + size: 16, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + ), + AppGap.sm(), + Text( + mode.name.toUpperCase(), + style: TextStyle( + fontSize: 12, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + ), + ), + ], + ), + ); + }).toList(), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + // Shadow removed from prev step + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getModeIcon(displayMode), + size: 14, + color: + Theme.of(context).colorScheme.onPrimaryContainer, + ), + AppGap.xs(), + Icon( + Icons.arrow_drop_down, + size: 14, + color: + Theme.of(context).colorScheme.onPrimaryContainer, + ), + ], + ), + ), + ), + ), + ), + + // Remove Button (Top-Left) - Only if canHide is true + if (DashboardWidgetSpecs.getById(item.id)?.canHide ?? true) + Positioned( + left: 8, + top: 8, + child: GestureDetector( + onTap: () async { + await ref + .read(sliverDashboardControllerProvider.notifier) + .removeWidget(item.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Removed ${item.id}'), + duration: const Duration(seconds: 1), + ), + ); + } + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 4, + ) + ], + ), + child: Icon( + Icons.close, + size: 14, + color: Theme.of(context).colorScheme.onError, + ), + ), + ), + ), + ], + ), + ); + } + + // Non-edit mode + return displayedWidget; + } + + IconData _getModeIcon(DisplayMode mode) { + return switch (mode) { + DisplayMode.compact => Icons.view_compact, + DisplayMode.normal => Icons.view_module, + DisplayMode.expanded => Icons.view_quilt, + }; + } + + void _updateDisplayMode( + BuildContext context, LayoutItem item, DisplayMode nextMode) { + // 1. Update preferences provider (unified state) + ref + .read(dashboardPreferencesProvider.notifier) + .setWidgetMode(item.id, nextMode); + + // 2. Get specs to update constraints + final spec = DashboardWidgetSpecs.getById(item.id); + if (spec == null) return; + + final constraints = spec.getConstraints(nextMode); + + // Use unified height calculation + final preferredW = constraints.preferredColumns; + final preferredH = constraints.getPreferredHeightCells(columns: preferredW); + final (minH, maxH) = constraints.getHeightRange(); + + // 3. Update the item via Export -> Modify -> Import -> Save + final controller = ref.read(sliverDashboardControllerProvider); + final List currentLayout = controller.exportLayout(); + + final index = + currentLayout.indexWhere((itemMap) => itemMap['id'] == item.id); + if (index != -1) { + // Create modified item map + final Map oldItem = + Map.from(currentLayout[index]); + + // FIX: Reset size to preferred dimensions for the new mode + // This ensures the item starts in a valid state (w >= minW) + oldItem['w'] = preferredW; + oldItem['h'] = preferredH; + + // Update constraints using BOTH keys ensures coverage + oldItem['min_w'] = constraints.minColumns.toInt(); + oldItem['minW'] = constraints.minColumns.toInt(); + + oldItem['max_w'] = constraints.maxColumns.toDouble(); + oldItem['maxW'] = constraints.maxColumns.toDouble(); + + oldItem['min_h'] = minH.toInt(); + oldItem['minH'] = minH.toInt(); + + oldItem['max_h'] = maxH; + oldItem['maxH'] = maxH; + + // Lock Logic (Removed): MinWidth is now enforced via onItemResizeEnd hook + // Topology is now resizable but constrained. + bool canResize = true; + oldItem['resizable'] = canResize; + oldItem['isResizable'] = canResize; + oldItem['resizeable'] = canResize; + + // Update list + currentLayout[index] = oldItem; + + // Apply and Save + controller.importLayout(currentLayout); + ref.read(sliverDashboardControllerProvider.notifier).saveLayout(); + } + } +} diff --git a/lib/page/firmware_update/views/firmware_update_process_view.dart b/lib/page/firmware_update/views/firmware_update_process_view.dart index 49e4db27e..ec257bcb7 100644 --- a/lib/page/firmware_update/views/firmware_update_process_view.dart +++ b/lib/page/firmware_update/views/firmware_update_process_view.dart @@ -1,9 +1,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/jnap/models/firmware_update_status.dart'; -import 'package:privacy_gui/core/data/providers/device_manager_state.dart'; -import 'package:privacy_gui/core/utils/devices.dart'; +import 'package:privacy_gui/page/firmware_update/models/firmware_update_ui_model.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -50,7 +48,7 @@ enum FirmwareUpdateStep { } class FirmwareUpdateProcessView extends ConsumerStatefulWidget { - final (LinksysDevice, FirmwareUpdateStatus)? current; + final FirmwareUpdateUIModel? current; const FirmwareUpdateProcessView({super.key, this.current}); @override @@ -62,17 +60,15 @@ class _FirmwareUpdateProcessViewState extends ConsumerState { @override Widget build(BuildContext context) { - final step = FirmwareUpdateStep.resolve( - widget.current?.$2.pendingOperation?.operation ?? '0'); - final percent = widget.current?.$2.pendingOperation?.progressPercent; + final step = FirmwareUpdateStep.resolve(widget.current?.operation ?? '0'); + final percent = widget.current?.progressPercent; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Center(child: AppLoader()), AppGap.lg(), if (percent != null) ...[ - AppText.labelLarge( - '${widget.current?.$1.getDeviceName() ?? ''} - $percent%'), + AppText.labelLarge('${widget.current?.deviceName ?? ''} - $percent%'), AppGap.lg(), AppText.titleSmall(step.getTitle(context)), AppGap.lg(), diff --git a/lib/page/health_check/widgets/speed_test_widget.dart b/lib/page/health_check/widgets/speed_test_widget.dart index e23bcf344..930941822 100644 --- a/lib/page/health_check/widgets/speed_test_widget.dart +++ b/lib/page/health_check/widgets/speed_test_widget.dart @@ -43,6 +43,9 @@ class SpeedTestWidget extends ConsumerWidget { /// The size of the animated meter. final double? meterSize; + /// If true, shows the result summary (download/upload) below the meter when complete. + final bool showResultSummary; + const SpeedTestWidget({ super.key, this.showDetails = true, @@ -51,6 +54,7 @@ class SpeedTestWidget extends ConsumerWidget { this.showStepDescriptions = true, this.showLatestOnIdle = true, this.meterSize, + this.showResultSummary = true, }); @override @@ -60,12 +64,14 @@ class SpeedTestWidget extends ConsumerWidget { // Determine the main content based on the current health check status. final mainContent = switch (healthCheckState.status) { HealthCheckStatus.idle => (showLatestOnIdle && latestPolledResult != null) - ? Column( - children: [ - _startButton(context, ref), - infoView(context, healthCheckState, latestPolledResult), - ], - ) + ? (showInfoPanel + ? Column( + children: [ + _startButton(context, ref), + infoView(context, healthCheckState, latestPolledResult), + ], + ) + : _startButton(context, ref, lastResult: latestPolledResult)) : _startButton(context, ref), HealthCheckStatus.running => _runningContent(context, healthCheckState, ref), @@ -191,44 +197,119 @@ class SpeedTestWidget extends ConsumerWidget { child: AppGauge( size: meterSize ?? context.colWidth(3), value: meterValueMbps, // Value must be in Mbps for the meter scale - markers: const [ - 0, - 1, - 5, - 10, - 20, - 30, - 50, - 75, - 100 - ], // Markers are in Mbps + // Reduce clutter: show minimal or no labels if meter is small + markers: (meterSize ?? 220) < 130 + ? const [0, 100] + : const [ + 0, + 1, + 5, + 10, + 20, + 30, + 50, + 75, + 100 + ], // Markers are in Mbps centerBuilder: (context, value) { // The content inside the meter (e.g., live speed). + final isSmall = (meterSize ?? 220) < 130; + final titleText = switch (state.step) { + HealthCheckStep.downloadBandwidth => loc(context).download, + HealthCheckStep.uploadBandwidth => loc(context).upload, + _ => '', + }; + return Column( mainAxisSize: MainAxisSize.min, children: [ - AppText.titleSmall(switch (state.step) { - HealthCheckStep.downloadBandwidth => loc(context).download, - HealthCheckStep.uploadBandwidth => loc(context).upload, - _ => '', - }), - AppText.displayLarge( - state.step == HealthCheckStep.latency ? '—' : bandwidthValue), + isSmall + ? Text( + titleText, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontSize: 10), + ) + : AppText.titleSmall(titleText), + isSmall + ? Text( + state.step == HealthCheckStep.latency + ? '—' + : bandwidthValue, + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(fontSize: 24, height: 1.0), + ) + : AppText.headlineMedium( + state.step == HealthCheckStep.latency + ? '—' + : bandwidthValue, + ), if (state.step != HealthCheckStep.latency) - AppText.bodyMedium('${bandwidthUnit}ps'), + isSmall + ? Text( + '${bandwidthUnit}ps', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 10), + ) + : AppText.bodyMedium('${bandwidthUnit}ps'), ], ); }, bottomBuilder: (context, value) { // The content below the meter (e.g., ping or "Test Again" button). - return state.status == HealthCheckStatus.complete - ? AppButton.text( - label: loc(context).testAgain, - onTap: () => ref - .read(healthCheckProvider.notifier) - .runHealthCheck(Module.speedtest), - ) - : pingView(context, state.step, result.latency); + if (state.status == HealthCheckStatus.complete) { + if (!showResultSummary) { + return IconButton( + onPressed: () => ref + .read(healthCheckProvider.notifier) + .runHealthCheck(Module.speedtest), + icon: Icon(Icons.replay, + color: Theme.of(context).colorScheme.primary), + tooltip: loc(context).testAgain, + ); + } + // Show single-line result with tap-to-retry + return InkWell( + onTap: () => ref + .read(healthCheckProvider.notifier) + .runHealthCheck(Module.speedtest), + borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.arrow_downward, + size: 14, color: Theme.of(context).colorScheme.primary), + AppGap.xs(), + AppText.titleSmall(result.downloadSpeed), + AppGap.md(), + Icon(Icons.arrow_upward, + size: 14, color: Theme.of(context).colorScheme.primary), + AppGap.xs(), + AppText.titleSmall(result.uploadSpeed), + AppGap.md(), + Container( + width: 1, + height: 12, + color: Theme.of(context).colorScheme.outlineVariant, + ), + AppGap.md(), + Icon(Icons.replay, + size: 20, color: Theme.of(context).colorScheme.primary), + ], + ), + ), + ); + } else { + return pingView(context, state.step, result.latency); + } }, ), ); @@ -265,8 +346,11 @@ class SpeedTestWidget extends ConsumerWidget { fontColor: Theme.of(context).colorScheme.primary) else ...[ // Show the final latency value. - AppText.bodyMedium('$latency ms', - color: Theme.of(context).colorScheme.primary), + Text( + '$latency ms', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 12, color: Theme.of(context).colorScheme.primary), + ), ], ], ), @@ -406,7 +490,8 @@ class SpeedTestWidget extends ConsumerWidget { } /// Builds the initial "Go" button to start the test. - Widget _startButton(BuildContext context, WidgetRef ref) { + Widget _startButton(BuildContext context, WidgetRef ref, + {SpeedTestUIModel? lastResult}) { return Container( alignment: Alignment.center, child: AppGauge( @@ -445,6 +530,36 @@ class SpeedTestWidget extends ConsumerWidget { ), ); }, + bottomBuilder: (lastResult != null && !showInfoPanel) + ? (context, value) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.arrow_downward, + size: 14, + color: Theme.of(context).colorScheme.primary), + AppGap.xs(), + AppText.titleSmall(lastResult.downloadSpeed), + AppGap.md(), + Container( + width: 1, + height: 12, + color: Theme.of(context).colorScheme.outlineVariant, + ), + AppGap.md(), + Icon(Icons.arrow_upward, + size: 14, + color: Theme.of(context).colorScheme.primary), + AppGap.xs(), + AppText.titleSmall(lastResult.uploadSpeed), + ], + ), + ); + } + : null, value: 0, ), ); diff --git a/lib/page/instant_admin/providers/manual_firmware_update_provider.dart b/lib/page/instant_admin/providers/manual_firmware_update_provider.dart index deb9c9cbf..4498707bb 100644 --- a/lib/page/instant_admin/providers/manual_firmware_update_provider.dart +++ b/lib/page/instant_admin/providers/manual_firmware_update_provider.dart @@ -39,7 +39,7 @@ class ManualFirmwareUpdateNotifier Future manualFirmwareUpdate(String filename, List bytes) async { final localPassword = ref.read(authProvider).value?.localPassword; - final localIp = getLocalIp(ref); + final localIp = getLocalIp(ref.read); ref.read(pollingProvider.notifier).stopPolling(); return ref .read(manualFirmwareUpdateServiceProvider) diff --git a/lib/page/instant_admin/views/instant_admin_view.dart b/lib/page/instant_admin/views/instant_admin_view.dart index 65a115b7b..dfad0c655 100644 --- a/lib/page/instant_admin/views/instant_admin_view.dart +++ b/lib/page/instant_admin/views/instant_admin_view.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:go_router/go_router.dart'; import 'package:privacy_gui/constants/build_config.dart'; -import 'package:privacy_gui/core/jnap/models/firmware_update_settings.dart'; import 'package:privacy_gui/core/data/providers/device_info_provider.dart'; import 'package:privacy_gui/core/data/providers/firmware_update_provider.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; @@ -67,9 +66,8 @@ class _InstantAdminViewState extends ConsumerState { @override Widget build(BuildContext context) { final routerPasswordState = ref.watch(routerPasswordProvider); - final isFwAutoUpdate = ref.watch(firmwareUpdateProvider - .select((value) => value.settings.updatePolicy)) == - FirmwareUpdateSettings.firmwareUpdatePolicyAuto; + final isFwAutoUpdate = ref.watch( + firmwareUpdateProvider.select((value) => value.isAutoUpdateEnabled)); final deviceInfoState = ref.watch(deviceInfoProvider); final timezoneState = ref.watch(timezoneProvider); final powerTableState = ref.watch(powerTableProvider); @@ -148,9 +146,7 @@ class _InstantAdminViewState extends ConsumerState { context, ref .read(firmwareUpdateProvider.notifier) - .setFirmwareUpdatePolicy(value - ? FirmwareUpdateSettings.firmwareUpdatePolicyAuto - : FirmwareUpdateSettings.firmwareUpdatePolicyManual), + .setAutoUpdateEnabled(value), ); }, ), diff --git a/lib/page/instant_device/providers/device_filtered_list_provider.dart b/lib/page/instant_device/providers/device_filtered_list_provider.dart index e8c421ef9..58dcce131 100644 --- a/lib/page/instant_device/providers/device_filtered_list_provider.dart +++ b/lib/page/instant_device/providers/device_filtered_list_provider.dart @@ -6,7 +6,7 @@ import 'package:privacy_gui/core/utils/devices.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/page/instant_device/providers/device_filtered_list_state.dart'; import 'package:privacy_gui/page/instant_device/providers/device_list_provider.dart'; -import 'package:privacy_gui/page/wifi_settings/providers/wifi_bundle_provider.dart'; + import 'package:privacy_gui/util/extensions.dart'; final filteredDeviceListProvider = Provider((ref) { @@ -80,14 +80,14 @@ class DeviceFilterConfigNotifier extends Notifier { } List getWifiNames() { - final wifiState = ref.read(wifiBundleProvider); + final wifiRadiosState = ref.read(wifiRadiosProvider); return [ - ...wifiState.settings.current.wifiList.mainWiFi - .map((e) => e.ssid) + ...wifiRadiosState.mainRadios + .map((e) => e.settings.ssid) .toList() .unique(), - wifiState.settings.current.wifiList.guestWiFi.ssid, - ]; + ...wifiRadiosState.guestRadios.map((e) => e.guestSSID).toList().unique(), + ].unique(); } List getBands([String? deviceUUID]) { diff --git a/lib/page/instant_device/providers/device_list_state.dart b/lib/page/instant_device/providers/device_list_state.dart index 7e006a743..08a2e9f54 100644 --- a/lib/page/instant_device/providers/device_list_state.dart +++ b/lib/page/instant_device/providers/device_list_state.dart @@ -1,10 +1,9 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:convert'; -import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/models/device_list_item.dart'; -import 'package:privacy_gui/core/data/providers/device_manager_state.dart'; +export 'package:privacy_gui/core/models/device_list_item.dart'; class DeviceListState extends Equatable { final List devices; @@ -48,171 +47,3 @@ class DeviceListState extends Equatable { @override List get props => [devices]; } - -class DeviceListItem extends Equatable { - final String deviceId; - final String name; - final String icon; - final String upstreamDevice; - final String upstreamDeviceID; - final String upstreamIcon; - final String ipv4Address; - final String ipv6Address; - final String macAddress; - final String manufacturer; - final String model; - final String operatingSystem; - final String band; - final int signalStrength; - final bool isOnline; - final bool isWired; - final WifiConnectionType type; - final String ssid; - final bool isMLO; - - const DeviceListItem({ - this.deviceId = '', - this.name = '', - this.icon = '', - this.upstreamDevice = '', - this.upstreamDeviceID = '', - this.upstreamIcon = '', - this.ipv4Address = '', - this.ipv6Address = '', - this.macAddress = '', - this.manufacturer = '', - this.model = '', - this.operatingSystem = '', - this.band = '', - this.signalStrength = 0, - this.isOnline = false, - this.isWired = false, - this.type = WifiConnectionType.main, - this.ssid = '', - this.isMLO = false, - }); - - DeviceListItem copyWith({ - String? deviceId, - String? name, - String? icon, - String? upstreamDevice, - String? upstreamDeviceID, - String? upstreamIcon, - String? ipv4Address, - String? ipv6Address, - String? macAddress, - String? manufacturer, - String? model, - String? operatingSystem, - String? band, - int? signalStrength, - bool? isOnline, - bool? isWired, - WifiConnectionType? type, - String? ssid, - bool? isMLO, - }) { - return DeviceListItem( - deviceId: deviceId ?? this.deviceId, - name: name ?? this.name, - icon: icon ?? this.icon, - upstreamDevice: upstreamDevice ?? this.upstreamDevice, - upstreamDeviceID: upstreamDeviceID ?? this.upstreamDeviceID, - upstreamIcon: upstreamIcon ?? this.upstreamIcon, - ipv4Address: ipv4Address ?? this.ipv4Address, - ipv6Address: ipv6Address ?? this.ipv6Address, - macAddress: macAddress ?? this.macAddress, - manufacturer: manufacturer ?? this.manufacturer, - model: model ?? this.model, - operatingSystem: operatingSystem ?? this.operatingSystem, - band: band ?? this.band, - signalStrength: signalStrength ?? this.signalStrength, - isOnline: isOnline ?? this.isOnline, - isWired: isWired ?? this.isWired, - type: type ?? this.type, - ssid: ssid ?? this.ssid, - isMLO: isMLO ?? this.isMLO, - ); - } - - @override - List get props { - return [ - deviceId, - name, - icon, - upstreamDevice, - upstreamDeviceID, - upstreamIcon, - ipv4Address, - ipv6Address, - macAddress, - manufacturer, - model, - operatingSystem, - band, - signalStrength, - isOnline, - isWired, - type, - ssid, - isMLO, - ]; - } - - Map toMap() { - return { - 'deviceId': deviceId, - 'name': name, - 'icon': icon, - 'upstreamDevice': upstreamDevice, - 'upstreamDeviceID': upstreamDeviceID, - 'upstreamIcon': upstreamIcon, - 'ipv4Address': ipv4Address, - 'ipv6Address': ipv6Address, - 'macAddress': macAddress, - 'manufacturer': manufacturer, - 'model': model, - 'operatingSystem': operatingSystem, - 'band': band, - 'signalStrength': signalStrength, - 'isOnline': isOnline, - 'isWired': isWired, - 'type': type.value, - 'ssid': ssid, - 'isMLO': isMLO, - }; - } - - factory DeviceListItem.fromMap(Map map) { - return DeviceListItem( - deviceId: map['deviceId'] as String, - name: map['name'] as String, - icon: map['icon'] as String, - upstreamDevice: map['upstreamDevice'] as String, - upstreamDeviceID: map['upstreamDeviceID'] as String, - upstreamIcon: map['upstreamIcon'] as String, - ipv4Address: map['ipv4Address'] as String, - ipv6Address: map['ipv6Address'] as String, - macAddress: map['macAddress'] as String, - manufacturer: map['manufacturer'] as String, - model: map['model'] as String, - operatingSystem: map['operatingSystem'] as String, - band: map['band'] as String, - signalStrength: map['signalStrength'] as int, - isOnline: map['isOnline'] as bool, - isWired: map['isWired'] as bool, - type: - WifiConnectionType.values.firstWhereOrNull((e) => e == map['type']) ?? - WifiConnectionType.main, - ssid: map['ssid'] as String, - isMLO: map['isMLO'] ?? false, - ); - } - - String toJson() => json.encode(toMap()); - - factory DeviceListItem.fromJson(String source) => - DeviceListItem.fromMap(json.decode(source) as Map); -} diff --git a/lib/page/instant_privacy/providers/instant_privacy_state.dart b/lib/page/instant_privacy/providers/instant_privacy_state.dart index 3870d0a03..3605ae7db 100644 --- a/lib/page/instant_privacy/providers/instant_privacy_state.dart +++ b/lib/page/instant_privacy/providers/instant_privacy_state.dart @@ -1,145 +1,10 @@ import 'dart:convert'; -import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/models/privacy_settings.dart'; +export 'package:privacy_gui/core/models/privacy_settings.dart'; import 'package:privacy_gui/providers/feature_state.dart'; import 'package:privacy_gui/providers/preservable.dart'; -enum MacFilterMode { - disabled, - allow, - deny, - ; - - static MacFilterMode reslove(String value) => switch (value.toLowerCase()) { - 'disabled' => MacFilterMode.disabled, - 'allow' => MacFilterMode.allow, - 'deny' => MacFilterMode.deny, - _ => MacFilterMode.disabled, - }; - - bool get isEnabled => this != MacFilterMode.disabled; -} - -class InstantPrivacyStatus extends Equatable { - final MacFilterMode mode; - - const InstantPrivacyStatus({required this.mode}); - - factory InstantPrivacyStatus.init() { - return const InstantPrivacyStatus(mode: MacFilterMode.disabled); - } - - @override - List get props => [mode]; - - InstantPrivacyStatus copyWith({MacFilterMode? mode}) { - return InstantPrivacyStatus(mode: mode ?? this.mode); - } - - Map toMap() { - return { - 'mode': mode.name, - }; - } - - factory InstantPrivacyStatus.fromMap(Map map) { - return InstantPrivacyStatus( - mode: MacFilterMode.reslove(map['mode']), - ); - } - - String toJson() => json.encode(toMap()); - - factory InstantPrivacyStatus.fromJson(String source) => - InstantPrivacyStatus.fromMap(json.decode(source) as Map); -} - -class InstantPrivacySettings extends Equatable { - final MacFilterMode mode; - final List macAddresses; - final List denyMacAddresses; - final int maxMacAddresses; - final List bssids; - final String? myMac; - - @override - List get props => [ - mode, - macAddresses, - denyMacAddresses, - maxMacAddresses, - bssids, - myMac, - ]; - - const InstantPrivacySettings({ - required this.mode, - required this.macAddresses, - required this.denyMacAddresses, - required this.maxMacAddresses, - this.bssids = const [], - this.myMac, - }); - - factory InstantPrivacySettings.init() { - return const InstantPrivacySettings( - mode: MacFilterMode.disabled, - macAddresses: [], - denyMacAddresses: [], - maxMacAddresses: 32, - ); - } - - InstantPrivacySettings copyWith({ - MacFilterMode? mode, - List? macAddresses, - List? denyMacAddresses, - int? maxMacAddresses, - List? bssids, - String? myMac, - }) { - return InstantPrivacySettings( - mode: mode ?? this.mode, - macAddresses: macAddresses ?? this.macAddresses, - denyMacAddresses: denyMacAddresses ?? this.denyMacAddresses, - maxMacAddresses: maxMacAddresses ?? this.maxMacAddresses, - bssids: bssids ?? this.bssids, - myMac: myMac ?? this.myMac, - ); - } - - Map toMap() { - return { - 'mode': mode.name, - 'macAddresses': macAddresses, - 'denyMacAddresses': denyMacAddresses, - 'maxMacAddresses': maxMacAddresses, - 'bssids': bssids, - 'myMac': myMac, - }..removeWhere((key, value) => value == null); - } - - factory InstantPrivacySettings.fromMap(Map map) { - return InstantPrivacySettings( - mode: MacFilterMode.reslove(map['mode']), - macAddresses: List.from(map['macAddresses']), - denyMacAddresses: List.from(map['denyMacAddresses']), - maxMacAddresses: map['maxMacAddresses'] as int, - bssids: map['bssids'] == null ? [] : List.from(map['bssids']), - myMac: map['myMac'], - ); - } - - String toJson() => json.encode(toMap()); - - factory InstantPrivacySettings.fromJson(String source) => - InstantPrivacySettings.fromMap( - json.decode(source) as Map); - - @override - bool get stringify => true; -} - class InstantPrivacyState extends FeatureState { const InstantPrivacyState({ diff --git a/lib/page/instant_safety/services/instant_safety_service.dart b/lib/page/instant_safety/services/instant_safety_service.dart index 72621cc6a..dc7ac8fcf 100644 --- a/lib/page/instant_safety/services/instant_safety_service.dart +++ b/lib/page/instant_safety/services/instant_safety_service.dart @@ -2,7 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/jnap/command/base_command.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/jnap_device_info_raw.dart'; import 'package:privacy_gui/core/jnap/models/lan_settings.dart'; import 'package:privacy_gui/core/jnap/models/set_lan_settings.dart'; import 'package:privacy_gui/core/data/providers/polling_provider.dart'; @@ -161,7 +161,7 @@ class InstantSafetyService { } try { - final info = NodeDeviceInfo.fromJson(deviceInfo); + final info = JnapDeviceInfoRaw.fromJson(deviceInfo).toUIModel(); final compatibilityItems = _compatibilityMap.where((e) { final regex = RegExp(e.modelRegExp, caseSensitive: false); return regex.hasMatch(info.modelNumber); diff --git a/lib/page/instant_setup/providers/mock_pnp_providers.dart b/lib/page/instant_setup/providers/mock_pnp_providers.dart index ec0c1b9c3..2e91ed9c2 100644 --- a/lib/page/instant_setup/providers/mock_pnp_providers.dart +++ b/lib/page/instant_setup/providers/mock_pnp_providers.dart @@ -1,5 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; +// removed unused import import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/page/instant_setup/model/pnp_step.dart'; import 'package:privacy_gui/page/instant_setup/models/pnp_ui_models.dart'; @@ -8,6 +8,7 @@ import 'package:privacy_gui/page/instant_setup/providers/pnp_provider.dart'; import 'package:privacy_gui/page/instant_setup/providers/pnp_state.dart'; import 'package:privacy_gui/page/instant_setup/providers/pnp_step_state.dart'; import 'package:privacy_gui/page/instant_setup/services/pnp_service.dart'; +import 'package:privacy_gui/constants/_constants.dart'; /// Base Mock Notifier with common simulation logic for PnP flows. /// This class extends [BasePnpNotifier] and provides mock implementations @@ -268,6 +269,9 @@ class BaseMockPnpNotifier extends BasePnpNotifier { state.defaultSettings?.guestWifiPassword ?? 'mock-guest-password' ); } + + @override + bool get isLoggedIn => true; // Default to logged in for mocks } /// --- Scenario: Unconfigured Router (Factory Reset) --- diff --git a/lib/page/instant_setup/providers/pnp_provider.dart b/lib/page/instant_setup/providers/pnp_provider.dart index 6933e3e24..5c7307845 100644 --- a/lib/page/instant_setup/providers/pnp_provider.dart +++ b/lib/page/instant_setup/providers/pnp_provider.dart @@ -7,6 +7,7 @@ import 'package:privacy_gui/page/instant_setup/providers/pnp_state.dart'; import 'package:privacy_gui/page/instant_setup/providers/pnp_step_state.dart'; import 'package:privacy_gui/page/instant_setup/services/pnp_service.dart'; import 'package:privacy_gui/page/instant_setup/models/pnp_ui_models.dart'; +import 'package:privacy_gui/providers/auth/auth_provider.dart'; import '../troubleshooter/providers/pnp_troubleshooter_provider.dart'; /// The main Riverpod provider for the PnP feature. @@ -172,6 +173,11 @@ abstract class BasePnpNotifier extends Notifier { /// Returns a record containing the name and password. ({String name, String password}) getDefaultGuestWiFiNameAndPassPhrase(); + /// Checks if the user is currently logged in to the router. + /// + /// Returns true if login type is not [LoginType.none]. + bool get isLoggedIn; + //endregion } @@ -502,4 +508,10 @@ class PnpNotifier extends BasePnpNotifier { ); } } + + @override + bool get isLoggedIn { + final loginType = ref.read(authProvider).value?.loginType; + return loginType != null && loginType != LoginType.none; + } } diff --git a/lib/page/instant_setup/services/pnp_service.dart b/lib/page/instant_setup/services/pnp_service.dart index 6b956c46b..4592b291b 100644 --- a/lib/page/instant_setup/services/pnp_service.dart +++ b/lib/page/instant_setup/services/pnp_service.dart @@ -8,7 +8,7 @@ import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; import 'package:privacy_gui/core/jnap/actions/jnap_transaction.dart'; import 'package:privacy_gui/core/jnap/command/base_command.dart'; import 'package:privacy_gui/core/jnap/models/auto_configuration_settings.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/jnap_device_info_raw.dart'; import 'package:privacy_gui/core/jnap/models/firmware_update_settings.dart'; import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; import 'package:privacy_gui/core/jnap/models/node_light_settings.dart'; @@ -50,7 +50,7 @@ class ConfigurationResult { class PnpService with AvailabilityChecker { final Ref _ref; - NodeDeviceInfo? _rawDeviceInfo; + JnapDeviceInfoRaw? _rawDeviceInfo; Map _rawData = {}; PnpService(this._ref); @@ -103,8 +103,8 @@ class PnpService with AvailabilityChecker { return (uiModel, capabilities); } - /// Fetches the raw NodeDeviceInfo from the router repository. - Future _fetchRawDeviceInfo() async { + /// Fetches the raw JnapDeviceInfoRaw from the router repository. + Future _fetchRawDeviceInfo() async { try { final result = await _ref.read(routerRepositoryProvider).send( JNAPAction.getDeviceInfo, @@ -112,7 +112,7 @@ class PnpService with AvailabilityChecker { retries: 10, timeoutMs: 3000, ); - return NodeDeviceInfo.fromJson(result.output); + return JnapDeviceInfoRaw.fromJson(result.output); } catch (e) { logger.e('[PnP]: Service - Failed to fetch device info.', error: e); throw ExceptionFetchDeviceInfo(); @@ -340,7 +340,7 @@ class PnpService with AvailabilityChecker { cacheLevel: CacheLevel.noCache, retries: 0, timeoutMs: 3000); - final deviceInfo = NodeDeviceInfo.fromJson(result.output); + final deviceInfo = JnapDeviceInfoRaw.fromJson(result.output).toUIModel(); final isConnected = _rawDeviceInfo?.serialNumber == deviceInfo.serialNumber; if (!isConnected) { diff --git a/lib/page/instant_setup/troubleshooter/views/pnp_no_internet_connection_view.dart b/lib/page/instant_setup/troubleshooter/views/pnp_no_internet_connection_view.dart index cb0ece603..43880fcb4 100644 --- a/lib/page/instant_setup/troubleshooter/views/pnp_no_internet_connection_view.dart +++ b/lib/page/instant_setup/troubleshooter/views/pnp_no_internet_connection_view.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:privacy_gui/constants/_constants.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/components/ui_kit_page_view.dart'; @@ -116,7 +116,7 @@ class _PnpNoInternetConnectionState .checkAdminPassword(attachedPassword); } catch (_) {} } - if (ref.read(routerRepositoryProvider).isLoggedIn()) { + if (ref.read(pnpProvider.notifier).isLoggedIn) { goRoute(RouteNamed.pnpIspTypeSelection); } else { final isLoggedIn = await goRoute(RouteNamed.pnpIspAuth); diff --git a/lib/page/login/auto_parent/services/auto_parent_first_login_service.dart b/lib/page/login/auto_parent/services/auto_parent_first_login_service.dart index c9323d58b..32eabdd82 100644 --- a/lib/page/login/auto_parent/services/auto_parent_first_login_service.dart +++ b/lib/page/login/auto_parent/services/auto_parent_first_login_service.dart @@ -27,9 +27,17 @@ final autoParentFirstLoginServiceProvider = /// - Stateless (no internal state) /// - Dependencies injected via constructor class AutoParentFirstLoginService { - AutoParentFirstLoginService(this._routerRepository); + AutoParentFirstLoginService(this._routerRepository, + {RetryStrategy? retryStrategy}) + : _retryStrategy = retryStrategy ?? + ExponentialBackoffRetryStrategy( + maxRetries: 5, + initialDelay: const Duration(seconds: 2), + maxDelay: const Duration(seconds: 2), + ); final RouterRepository _routerRepository; + final RetryStrategy _retryStrategy; /// Sets userAcknowledgedAutoConfiguration flag on the router. /// @@ -121,13 +129,7 @@ class AutoParentFirstLoginService { /// Throws: Nothing (returns false on all error conditions) Future checkInternetConnection() async { // Make up to 5 attempts to check internet connection total 10 seconds - final retryStrategy = ExponentialBackoffRetryStrategy( - maxRetries: 5, - initialDelay: const Duration(seconds: 2), - maxDelay: const Duration(seconds: 2), - ); - - return retryStrategy.execute(() async { + return _retryStrategy.execute(() async { final result = await _routerRepository.send( JNAPAction.getInternetConnectionStatus, fetchRemote: true, diff --git a/lib/page/login/views/login_local_view.dart b/lib/page/login/views/login_local_view.dart index 6b27a3bdd..a97c23b34 100644 --- a/lib/page/login/views/login_local_view.dart +++ b/lib/page/login/views/login_local_view.dart @@ -4,8 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:privacy_gui/constants/error_code.dart'; -import 'package:privacy_gui/core/jnap/actions/better_action.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; import 'package:privacy_gui/core/data/providers/session_provider.dart'; import 'package:privacy_gui/page/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/page/components/styled/bottom_bar.dart'; @@ -37,7 +35,6 @@ class _LoginViewState extends ConsumerState { bool isCountdownJustFinished = false; bool _showPassword = false; late AuthNotifier auth; - NodeDeviceInfo? _deviceInfo; final TextEditingController _passwordController = TextEditingController(); String? _p; @@ -51,14 +48,15 @@ class _LoginViewState extends ConsumerState { doSomethingWithSpinner(context, Future.doWhile(() => !mounted)) .then((value) { _getAdminPasswordHint(); - ref.read(sessionProvider.notifier).checkDeviceInfo(null).then((value) { - _deviceInfo = value; - buildBetterActions(value.services); + ref + .read(sessionProvider.notifier) + .fetchDeviceInfoAndInitializeServices() + .then((_) { if (_p != null) { _passwordController.text = _p!; _doLogin(); } else { - _getAdminPasswordAuthStatus(value.services); + _getAdminPasswordAuthStatus(); } }); }); @@ -77,9 +75,7 @@ class _LoginViewState extends ConsumerState { if (widget.args != oldWidget.args) { if (widget.args['reset'] == true) { _passwordController.clear(); - auth - .getAdminPasswordAuthStatus(_deviceInfo?.services ?? []) - .then((value) { + auth.getAdminPasswordAuthStatus().then((value) { // If the delay time is null, it means the status has been reset // Clear the timer and reset the state if (value != null) { @@ -278,8 +274,8 @@ class _LoginViewState extends ConsumerState { auth.getPasswordHint(); } - void _getAdminPasswordAuthStatus(List services) { - auth.getAdminPasswordAuthStatus(services).then((result) { + void _getAdminPasswordAuthStatus() { + auth.getAdminPasswordAuthStatus().then((result) { if (result != null) { // Create the error and the countdown has yet to be triggered final JNAPError jnapError = JNAPError( diff --git a/lib/page/nodes/providers/node_light_settings_provider.dart b/lib/page/nodes/providers/node_light_settings_provider.dart index 44fc6b6f9..9ac189e32 100644 --- a/lib/page/nodes/providers/node_light_settings_provider.dart +++ b/lib/page/nodes/providers/node_light_settings_provider.dart @@ -1,46 +1,39 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/jnap/models/node_light_settings.dart'; -import 'package:privacy_gui/page/nodes/services/node_light_settings_service.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/page/nodes/providers/node_detail_state.dart'; +import 'package:privacy_gui/page/nodes/providers/node_light_state.dart'; +import 'package:privacy_gui/page/nodes/services/node_light_settings_service.dart'; final nodeLightSettingsProvider = - NotifierProvider( + NotifierProvider( () => NodeLightSettingsNotifier()); -class NodeLightSettingsNotifier extends Notifier { +class NodeLightSettingsNotifier extends Notifier { @override - NodeLightSettings build() { - return NodeLightSettings(isNightModeEnable: false); + NodeLightState build() { + return NodeLightState.initial(); } - Future fetch([bool forceRemote = false]) async { + /// Fetches the latest settings from the router and updates the state. + Future fetch({bool forceRemote = false}) async { final service = ref.read(nodeLightSettingsServiceProvider); - state = await service.fetchSettings(forceRemote: forceRemote); - logger.d('[State]:[NodeLightSettings]: ${state.toJson()}'); + state = await service.fetchState(forceRemote: forceRemote); + logger.d('[State]:[NodeLightSettings]: Updated to $state'); return state; } - Future save() async { + /// Saves the current state configurations to the router. + Future save() async { final service = ref.read(nodeLightSettingsServiceProvider); - state = await service.saveSettings(state); + state = await service.saveState(state); return state; } - void setSettings(NodeLightSettings settings) { + /// Updates the local state (e.g., from UI interactions) before saving. + void setSettings(NodeLightState settings) { state = settings; } - /// Returns the current node light status based on settings. - /// This provides a UI-friendly enum value derived from the raw settings. - NodeLightStatus get currentStatus { - if ((state.allDayOff ?? false) || - (state.startHour == 0 && state.endHour == 24)) { - return NodeLightStatus.off; - } else if (!state.isNightModeEnable) { - return NodeLightStatus.on; - } else { - return NodeLightStatus.night; - } - } + /// Helper getter for UI consumption, consistent with previous API + NodeLightStatus get currentStatus => state.status; } diff --git a/lib/page/nodes/providers/node_light_state.dart b/lib/page/nodes/providers/node_light_state.dart new file mode 100644 index 000000000..b845ac0f9 --- /dev/null +++ b/lib/page/nodes/providers/node_light_state.dart @@ -0,0 +1,49 @@ +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/page/nodes/providers/node_detail_state.dart'; + +/// Pure UI state for Node Light (LED) settings. +class NodeLightState extends Equatable { + final bool isNightModeEnabled; + final int startHour; + final int endHour; + final bool allDayOff; + + const NodeLightState({ + this.isNightModeEnabled = false, + this.startHour = 20, + this.endHour = 6, + this.allDayOff = false, + }); + + /// Status derived from settings logic + NodeLightStatus get status { + if (allDayOff) { + return NodeLightStatus.off; + } + if (isNightModeEnabled) { + return NodeLightStatus.night; + } + return NodeLightStatus.on; + } + + /// Initial state + factory NodeLightState.initial() => const NodeLightState(); + + @override + List get props => + [isNightModeEnabled, startHour, endHour, allDayOff]; + + NodeLightState copyWith({ + bool? isNightModeEnabled, + int? startHour, + int? endHour, + bool? allDayOff, + }) { + return NodeLightState( + isNightModeEnabled: isNightModeEnabled ?? this.isNightModeEnabled, + startHour: startHour ?? this.startHour, + endHour: endHour ?? this.endHour, + allDayOff: allDayOff ?? this.allDayOff, + ); + } +} diff --git a/lib/page/nodes/services/node_light_settings_service.dart b/lib/page/nodes/services/node_light_settings_service.dart index f632dd129..d58db1ca8 100644 --- a/lib/page/nodes/services/node_light_settings_service.dart +++ b/lib/page/nodes/services/node_light_settings_service.dart @@ -5,6 +5,7 @@ import 'package:privacy_gui/core/jnap/models/node_light_settings.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/utils/logger.dart'; +import 'package:privacy_gui/page/nodes/providers/node_light_state.dart'; final nodeLightSettingsServiceProvider = Provider((ref) { @@ -14,55 +15,43 @@ final nodeLightSettingsServiceProvider = /// Service for LED night mode settings operations. /// /// Handles JNAP communication for retrieving and persisting -/// LED night mode configuration. Stateless - all state management -/// is delegated to NodeLightSettingsNotifier. +/// LED night mode configuration. Handles conversion between +/// core JNAP data ([NodeLightSettings]) and UI state ([NodeLightState]). class NodeLightSettingsService { final RouterRepository _routerRepository; NodeLightSettingsService(this._routerRepository); - /// Retrieves current LED night mode settings from router. + /// Retrieves current LED night mode settings from router data. /// /// [forceRemote] - If true, bypasses cache and fetches from device. - /// Default: false (may use cached data). /// - /// Returns: [NodeLightSettings] with current configuration. - /// - /// Throws: - /// - [UnauthorizedError] if authentication fails - /// - [UnexpectedError] for other JNAP errors - Future fetchSettings({bool forceRemote = false}) async { + /// Returns: [NodeLightState] converted from router response. + Future fetchState({bool forceRemote = false}) async { try { final result = await _routerRepository.send( JNAPAction.getLedNightModeSetting, auth: true, fetchRemote: forceRemote, ); - final settings = NodeLightSettings.fromMap(result.output); - logger.d('[Service]:[NodeLightSettings]: Fetched ${settings.toJson()}'); - return settings; + final rawSettings = NodeLightSettings.fromMap(result.output); + final state = _toState(rawSettings); + logger.d( + '[Service]:[NodeLightSettings]: Fetched State $state from $rawSettings'); + return state; } on JNAPError catch (e) { throw _mapJnapError(e); } } - /// Persists LED night mode settings to router. - /// - /// [settings] - The settings to save. All non-null fields are sent. - /// - /// Returns: [NodeLightSettings] - Refreshed settings after save - /// (fetches from device to confirm). + /// Persists UI state to router. /// - /// Throws: - /// - [UnauthorizedError] if authentication fails - /// - [UnexpectedError] for other JNAP errors + /// [state] - The UI state to save. /// - /// Behavior: - /// - Sends Enable, StartingTime, EndingTime to router - /// - Automatically re-fetches after save to sync state - /// - Null fields are excluded from request - Future saveSettings(NodeLightSettings settings) async { + /// Returns: [NodeLightState] - Refreshed state after save. + Future saveState(NodeLightState state) async { try { + final settings = _fromState(state); await _routerRepository.send( JNAPAction.setLedNightModeSetting, data: { @@ -72,14 +61,56 @@ class NodeLightSettingsService { }..removeWhere((key, value) => value == null), auth: true, ); - logger.d('[Service]:[NodeLightSettings]: Saved ${settings.toJson()}'); + logger + .d('[Service]:[NodeLightSettings]: Saved State $state as $settings'); // Re-fetch to get confirmed state from device - return fetchSettings(forceRemote: true); + return fetchState(forceRemote: true); } on JNAPError catch (e) { throw _mapJnapError(e); } } + // --- Adapters --- + + NodeLightState _toState(NodeLightSettings settings) { + return NodeLightState( + isNightModeEnabled: settings.isNightModeEnable, + startHour: settings.startHour ?? 0, + endHour: settings.endHour ?? 0, + // allDayOff log derived from start==0 && end==24 or specific logic if JNAP supports it directly, + // but strictly speaking Model -> UI mapping for allDayOff depends on business rule. + // node_light_settings.dart L37 in original provider implied: + // (state.allDayOff ?? false) || (state.startHour == 0 && state.endHour == 24) + allDayOff: (settings.allDayOff == true) || + (settings.startHour == 0 && settings.endHour == 24), + ); + } + + NodeLightSettings _fromState(NodeLightState state) { + // If allDayOff is true, JNAP usually expects Start=0, End=24 and Enable=true (Night Mode covers whole day = OFF) + // Or maybe Enable=false means ON? + // Let's check original logic: + // status==off => allDayOff. + // In Original: NodeLightStatus.off if allDayOff || (0,24). + // NodeLightStatus.night if isNightModeEnable && !off. + // NodeLightStatus.on if !isNightModeEnable. + + // If UI state says allDayOff: + if (state.allDayOff) { + return NodeLightSettings( + isNightModeEnable: true, + startHour: 0, + endHour: 24, + allDayOff: true, + ); + } + return NodeLightSettings( + isNightModeEnable: state.isNightModeEnabled, + startHour: state.startHour, + endHour: state.endHour, + ); + } + /// Maps JNAP errors to ServiceError types. ServiceError _mapJnapError(JNAPError error) { return switch (error.result) { diff --git a/lib/page/nodes/views/node_detail_view.dart b/lib/page/nodes/views/node_detail_view.dart index 77915e085..cc591389f 100644 --- a/lib/page/nodes/views/node_detail_view.dart +++ b/lib/page/nodes/views/node_detail_view.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; -import 'package:privacy_gui/core/jnap/models/node_light_settings.dart'; + import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; import 'package:privacy_gui/core/data/providers/firmware_update_provider.dart'; import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.dart'; @@ -726,13 +726,23 @@ class _NodeDetailViewState extends ConsumerState ], ); }, event: () async { - ref - .read(nodeLightSettingsProvider.notifier) - .setSettings(NodeLightSettings.fromStatus(nodeLightStatus)); - await ref - .read(nodeLightSettingsProvider.notifier) - .save() - .then((_) => showChangesSavedSnackBar()); + final state = ref.read(nodeLightSettingsProvider); + final notifier = ref.read(nodeLightSettingsProvider.notifier); + switch (nodeLightStatus) { + case NodeLightStatus.night: + notifier.setSettings( + state.copyWith(isNightModeEnabled: true, allDayOff: false)); + break; + case NodeLightStatus.off: + notifier.setSettings( + state.copyWith(isNightModeEnabled: false, allDayOff: true)); + break; + case NodeLightStatus.on: + notifier.setSettings( + state.copyWith(isNightModeEnabled: false, allDayOff: false)); + break; + } + await notifier.save().then((_) => showChangesSavedSnackBar()); }); } } diff --git a/lib/page/select_network/providers/select_network_provider.dart b/lib/page/select_network/providers/select_network_provider.dart index e5e28080c..8faa67beb 100644 --- a/lib/page/select_network/providers/select_network_provider.dart +++ b/lib/page/select_network/providers/select_network_provider.dart @@ -1,13 +1,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/constants/_constants.dart'; -import 'package:privacy_gui/constants/jnap_const.dart'; import 'package:privacy_gui/core/cloud/linksys_cloud_repository.dart'; -import 'package:privacy_gui/core/jnap/actions/better_action.dart'; -import 'package:privacy_gui/core/jnap/command/base_command.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/core/utils/nodes.dart'; import 'package:privacy_gui/page/select_network/models/cloud_network_model.dart'; +import 'package:privacy_gui/page/select_network/services/network_availability_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; final selectNetworkProvider = @@ -51,17 +48,9 @@ class SelectNetworkNotifier extends AsyncNotifier { Future _checkNetworkOnline( CloudNetworkModel network) async { - final routerRepository = ref.read(routerRepositoryProvider); - bool isOnline = await routerRepository - .send(JNAPAction.isAdminPasswordDefault, - extraHeaders: { - kJNAPNetworkId: network.network.networkId, - }, - type: CommandType.remote, - fetchRemote: true, - cacheLevel: CacheLevel.noCache) - .then((value) => value.result == 'OK') - .onError((error, stackTrace) => false); + final networkService = ref.read(networkAvailabilityServiceProvider); + final isOnline = + await networkService.checkNetworkOnline(network.network.networkId); final cloudNetworkModel = CloudNetworkModel( network: network.network, isOnline: isOnline, diff --git a/lib/page/select_network/services/network_availability_service.dart b/lib/page/select_network/services/network_availability_service.dart new file mode 100644 index 000000000..7d5263d45 --- /dev/null +++ b/lib/page/select_network/services/network_availability_service.dart @@ -0,0 +1,32 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +// removed unused import +import 'package:privacy_gui/constants/jnap_const.dart'; +import 'package:privacy_gui/core/jnap/actions/better_action.dart'; +import 'package:privacy_gui/core/jnap/command/base_command.dart'; +import 'package:privacy_gui/core/jnap/router_repository.dart'; + +final networkAvailabilityServiceProvider = + Provider((ref) { + final routerRepository = ref.watch(routerRepositoryProvider); + return NetworkAvailabilityService(routerRepository); +}); + +class NetworkAvailabilityService { + final RouterRepository _routerRepository; + + NetworkAvailabilityService(this._routerRepository); + + /// Checks if the network with [networkId] is online by sending a remote query. + Future checkNetworkOnline(String networkId) async { + return _routerRepository + .send(JNAPAction.isAdminPasswordDefault, + extraHeaders: { + kJNAPNetworkId: networkId, + }, + type: CommandType.remote, + fetchRemote: true, + cacheLevel: CacheLevel.noCache) + .then((value) => value.result == 'OK') + .onError((error, stackTrace) => false); + } +} diff --git a/lib/page/wifi_settings/providers/channelfinder_provider.dart b/lib/page/wifi_settings/providers/channelfinder_provider.dart index 2a000b14c..3710716a7 100644 --- a/lib/page/wifi_settings/providers/channelfinder_provider.dart +++ b/lib/page/wifi_settings/providers/channelfinder_provider.dart @@ -1,13 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; +// removed unused import import 'package:privacy_gui/page/wifi_settings/_wifi_settings.dart'; import 'package:privacy_gui/page/wifi_settings/services/channel_finder_service.dart'; -final channelFinderServiceProvider = Provider((ref) { - return ChannelFinderService(ref.watch(routerRepositoryProvider)); -}); - final channelFinderProvider = NotifierProvider( () => ChannelFinderNotifier()); diff --git a/lib/page/wifi_settings/providers/displayed_mac_filtering_devices_provider.dart b/lib/page/wifi_settings/providers/displayed_mac_filtering_devices_provider.dart index 7616f2b1f..6bf30c9e1 100644 --- a/lib/page/wifi_settings/providers/displayed_mac_filtering_devices_provider.dart +++ b/lib/page/wifi_settings/providers/displayed_mac_filtering_devices_provider.dart @@ -1,13 +1,33 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/page/instant_device/_instant_device.dart'; +import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; +import 'package:privacy_gui/core/models/device_list_item.dart'; +import 'package:privacy_gui/core/utils/devices.dart'; // for getDeviceLocation extension import 'package:privacy_gui/page/wifi_settings/providers/wifi_bundle_provider.dart'; import 'package:privacy_gui/page/wifi_settings/services/wifi_settings_service.dart'; final macFilteringDeviceListProvider = Provider((ref) { - final deviceListState = ref.watch(deviceListProvider); + final deviceManagerState = ref.watch(deviceManagerProvider); final wifiBundleState = ref.watch(wifiBundleProvider); - final deviceList = - deviceListState.devices.where((device) => !device.isWired).toList(); + + // Filter for wireless devices only, as MAC filtering typically applies to wireless + // Using externalDevices to include both online and offline devices if standard + // or use logic similar to device manager. + // Original code used deviceListState.devices.where((!isWired)) + // deviceListState comes from externalDevices. + + final deviceList = deviceManagerState.externalDevices + .where((device) => + device.connectionType == 'wireless') // Simplified check or use helper + .map((device) => DeviceListItem( + name: device.getDeviceLocation(), + macAddress: device.getMacAddress(), + // Other fields are not strictly used by MacFilteringView/InputView for filtering display + // but populating basic ones is good practice. + isOnline: device.isOnline(), + isWired: false, + )) + .toList(); + final macAddresses = wifiBundleState.current.privacy.denyMacAddresses; final bssidList = wifiBundleState.current.privacy.bssids; return ref.read(wifiSettingsServiceProvider).getFilteredDeviceList( diff --git a/lib/page/wifi_settings/providers/wifi_bundle_provider.dart b/lib/page/wifi_settings/providers/wifi_bundle_provider.dart index 3d8b7c65c..fc15d175c 100644 --- a/lib/page/wifi_settings/providers/wifi_bundle_provider.dart +++ b/lib/page/wifi_settings/providers/wifi_bundle_provider.dart @@ -6,7 +6,7 @@ import 'package:privacy_gui/core/jnap/actions/jnap_service_supported.dart'; import 'package:privacy_gui/core/data/providers/wifi_radios_provider.dart'; import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; import 'package:privacy_gui/core/data/providers/ethernet_ports_provider.dart'; -import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; +import 'package:privacy_gui/core/models/privacy_settings.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_advanced_state.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_bundle_state.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_item.dart'; diff --git a/lib/page/wifi_settings/providers/wifi_bundle_state.dart b/lib/page/wifi_settings/providers/wifi_bundle_state.dart index efec2059c..fdf4e2ac0 100644 --- a/lib/page/wifi_settings/providers/wifi_bundle_state.dart +++ b/lib/page/wifi_settings/providers/wifi_bundle_state.dart @@ -1,5 +1,5 @@ import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; +import 'package:privacy_gui/core/models/privacy_settings.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_advanced_state.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_state.dart'; import 'package:privacy_gui/providers/feature_state.dart'; diff --git a/lib/page/wifi_settings/services/channel_finder_service.dart b/lib/page/wifi_settings/services/channel_finder_service.dart index 2278d2d86..501c1acad 100644 --- a/lib/page/wifi_settings/services/channel_finder_service.dart +++ b/lib/page/wifi_settings/services/channel_finder_service.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/jnap/command/base_command.dart'; import 'package:privacy_gui/core/data/providers/device_manager_state.dart'; @@ -9,6 +10,10 @@ import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/page/wifi_settings/providers/channel_data.dart'; import 'package:privacy_gui/page/wifi_settings/providers/channelfinder_info.dart'; +final channelFinderServiceProvider = Provider((ref) { + return ChannelFinderService(ref.watch(routerRepositoryProvider)); +}); + class ChannelFinderService { final RouterRepository _repo; final int pollingInterval = 5000; diff --git a/lib/page/wifi_settings/services/wifi_settings_service.dart b/lib/page/wifi_settings/services/wifi_settings_service.dart index 7e4b8cbb3..8a6c19c96 100644 --- a/lib/page/wifi_settings/services/wifi_settings_service.dart +++ b/lib/page/wifi_settings/services/wifi_settings_service.dart @@ -12,8 +12,8 @@ import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/core/utils/devices.dart'; import 'package:privacy_gui/core/utils/extension.dart'; -import 'package:privacy_gui/page/instant_device/providers/device_list_state.dart'; -import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; +import 'package:privacy_gui/core/models/device_list_item.dart'; +import 'package:privacy_gui/core/models/privacy_settings.dart'; import 'package:privacy_gui/page/wifi_settings/providers/guest_wifi_item.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_advanced_state.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_bundle_state.dart'; diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart index ed11716be..017ae6c06 100644 --- a/lib/providers/auth/auth_provider.dart +++ b/lib/providers/auth/auth_provider.dart @@ -8,13 +8,11 @@ import 'package:privacy_gui/core/cloud/model/error_response.dart'; import 'package:privacy_gui/core/cloud/model/guardians_remote_assistance.dart'; import 'package:privacy_gui/core/cloud/model/region_code.dart'; import 'package:privacy_gui/core/http/linksys_http_client.dart'; -import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/data/providers/session_provider.dart'; import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; import 'package:privacy_gui/core/data/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/core/jnap/router_repository.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/providers/auth/auth_service.dart'; import 'package:privacy_gui/providers/auth/auth_state.dart'; @@ -307,22 +305,14 @@ class AuthNotifier extends AsyncNotifier { /// Retrieves admin password auth status by delegating to AuthService. /// /// This method maintains backward compatibility while delegating to AuthService. - Future?> getAdminPasswordAuthStatus( - List services) async { - final result = await _authService.getAdminPasswordAuthStatus(services); + Future?> getAdminPasswordAuthStatus() async { + final result = await _authService.getAdminPasswordAuthStatus(); return result.when( success: (status) => status, failure: (_) => null, // Return null on failure for backward compatibility ); } - Future getDeviceInfo() async { - final routerRepository = ref.read(routerRepositoryProvider); - await routerRepository.send( - JNAPAction.getDeviceInfo, - ); - } - /// Performs logout by delegating to AuthService for credential cleanup. /// /// This method delegates credential clearing to AuthService while maintaining diff --git a/lib/providers/auth/auth_service.dart b/lib/providers/auth/auth_service.dart index 691740c2f..106ecf490 100644 --- a/lib/providers/auth/auth_service.dart +++ b/lib/providers/auth/auth_service.dart @@ -8,8 +8,8 @@ import 'package:privacy_gui/core/cloud/linksys_cloud_repository.dart'; import 'package:privacy_gui/core/cloud/model/cloud_session_model.dart'; import 'package:privacy_gui/core/cloud/model/error_response.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/command/base_command.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/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; @@ -338,6 +338,11 @@ class AuthService { loginType: LoginType.local, localPassword: password, )); + } on JNAPError catch (e) { + // Rethrow JNAPError to preserve error data (e.g., attemptsRemaining, delayTimeRemaining) + // This is needed for the View layer to display countdown and attempts correctly + logger.e('[AuthService]: Local login failed: $e'); + rethrow; } catch (e) { logger.e('[AuthService]: Local login failed: $e'); return AuthFailure(NetworkError(message: e.toString())); @@ -425,26 +430,14 @@ class AuthService { /// Retrieves the admin password authentication status from the router. /// - /// This method checks if the router supports the admin password auth status - /// feature and retrieves the current authentication status if supported. - /// - /// Parameters: - /// - [services]: List of supported JNAP service names + /// This method retrieves the current authentication status for password + /// lockout tracking (delay time, remaining attempts). /// /// Returns: - /// - [AuthSuccess] with status map if feature is supported and retrieval succeeded - /// - [AuthSuccess] with null if feature is not supported + /// - [AuthSuccess] with status map if retrieval succeeded /// - [AuthFailure] with appropriate error if retrieval failed - Future?>> getAdminPasswordAuthStatus( - List services) async { + Future?>> getAdminPasswordAuthStatus() async { try { - // Check if the feature is supported - if (serviceHelper.isSupportAdminPasswordAuthStatus(services) != true) { - logger.d( - '[AuthService]: Admin password auth status not supported by router'); - return const AuthSuccess(null); - } - final result = await _routerRepository.send( JNAPAction.getAdminPasswordAuthStatus, ); diff --git a/lib/providers/connectivity/services/connectivity_service.dart b/lib/providers/connectivity/services/connectivity_service.dart index c78259fde..692fbf477 100644 --- a/lib/providers/connectivity/services/connectivity_service.dart +++ b/lib/providers/connectivity/services/connectivity_service.dart @@ -4,7 +4,7 @@ import 'package:privacy_gui/core/errors/jnap_error_mapper.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/core/jnap/command/base_command.dart'; import 'package:privacy_gui/core/jnap/extensions/_extensions.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/jnap_device_info_raw.dart'; import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/providers/connectivity/connectivity_info.dart'; @@ -50,7 +50,7 @@ class ConnectivityService { cacheLevel: CacheLevel.noCache, ) .then( - (value) => NodeDeviceInfo.fromJson(value.output).serialNumber) + (value) => JnapDeviceInfoRaw.fromJson(value.output).serialNumber) .onError((error, stackTrace) => ''); if (routerSN.isEmpty) { diff --git a/lib/route/router_provider.dart b/lib/route/router_provider.dart index c2e50b5f4..377237f2f 100644 --- a/lib/route/router_provider.dart +++ b/lib/route/router_provider.dart @@ -6,13 +6,11 @@ import 'package:go_router/go_router.dart'; import 'package:privacy_gui/constants/build_config.dart'; import 'package:privacy_gui/constants/pref_key.dart'; import 'package:privacy_gui/core/cache/linksys_cache_manager.dart'; -import 'package:privacy_gui/core/jnap/actions/better_action.dart'; import 'package:privacy_gui/page/instant_setup/models/pnp_ui_models.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/models/device_info.dart'; import 'package:privacy_gui/core/data/providers/session_provider.dart'; import 'package:privacy_gui/core/data/providers/device_info_provider.dart'; import 'package:privacy_gui/core/data/providers/polling_provider.dart'; -import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/page/advanced_settings/local_network_settings/providers/dhcp_reservations_provider.dart'; import 'package:privacy_gui/page/advanced_settings/_advanced_settings.dart'; @@ -85,6 +83,35 @@ enum LocalWhereToGo { ; } +final appRoutes = [ + localLoginRoute, + autoParentFirstLoginRoute, + cloudLoginAuthRoute, + cloudLoginRoute, + homeRoute, + // ref.read(otpRouteProvider), + LinksysRoute( + name: RouteNamed.prepareDashboard, + path: RoutePath.prepareDashboard, + config: LinksysRouteConfig( + column: ColumnGrid(column: 4, centered: true), + ), + builder: (context, state) => const PrepareDashboardView(), + ), + LinksysRoute( + name: RouteNamed.selectNetwork, + path: RoutePath.selectNetwork, + config: const LinksysRouteConfig( + noNaviRail: true, + ), + builder: (context, state) => const SelectNetworkView(), + ), + dashboardRoute, + pnpRoute, + pnpTroubleshootingRoute, + addNodesRoute, +]; + final routerKey = GlobalKey(); final routerProvider = Provider((ref) { final router = RouterNotifier(ref); @@ -93,47 +120,20 @@ final routerProvider = Provider((ref) { refreshListenable: router, observers: [ref.read(routerLoggerProvider)], initialLocation: '/', - routes: [ - localLoginRoute, - autoParentFirstLoginRoute, - cloudLoginAuthRoute, - cloudLoginRoute, - homeRoute, - // ref.read(otpRouteProvider), - LinksysRoute( - name: RouteNamed.prepareDashboard, - path: RoutePath.prepareDashboard, - config: LinksysRouteConfig( - column: ColumnGrid(column: 4, centered: true), - ), - builder: (context, state) => const PrepareDashboardView(), - ), - LinksysRoute( - name: RouteNamed.selectNetwork, - path: RoutePath.selectNetwork, - config: const LinksysRouteConfig( - noNaviRail: true, - ), - builder: (context, state) => const SelectNetworkView(), - ), - dashboardRoute, - pnpRoute, - pnpTroubleshootingRoute, - addNodesRoute, - ], + routes: appRoutes, redirect: (context, state) { if (state.matchedLocation == '/') { - return router._autoConfigurationLogic(state); + return router.autoConfigurationLogic(state); } else if (state.matchedLocation == RoutePath.localLoginPassword) { - router._autoConfigurationLogic(state); - return router._redirectLogic(state); + router.autoConfigurationLogic(state); + return router.redirectLogic(state); } else if (state.matchedLocation.startsWith('/pnp')) { - return router._goPnpPath(state); + return router.goPnpPath(state); } else if (state.matchedLocation.startsWith('/autoParentFirstLogin')) { // bypass auto parent first login page return state.uri.toString(); } - return router._redirectLogic(state); + return router.redirectLogic(state); }, debugLogDiagnostics: true, ); @@ -156,7 +156,7 @@ class RouterNotifier extends ChangeNotifier { super.dispose(); } - Future _autoConfigurationLogic(GoRouterState state) async { + Future autoConfigurationLogic(GoRouterState state) async { await _ref.read(connectivityProvider.notifier).forceUpdate(); final loginType = _ref.read(authProvider .select((value) => value.value?.loginType ?? LoginType.none)); @@ -222,20 +222,20 @@ class RouterNotifier extends ChangeNotifier { if (whereToGo == LocalWhereToGo.pnp) { // PnP case - await _ref.read(authProvider.notifier).logout(); - return _goPnp(state.uri.query); + return goPnp(state.uri.query); } else if (whereToGo == LocalWhereToGo.firstTimeLogin) { // First Time Login case - if (!_ref.read(autoParentFirstLoginStateProvider)) { await _ref.read(authProvider.notifier).logout(); } - return _goFirstTimeLogin(state); + return goFirstTimeLogin(state); } else { // Login case - - return _authCheck(state); + return authCheck(state); } } - Future _redirectLogic(GoRouterState state) async { + Future redirectLogic(GoRouterState state) async { final loginType = _ref.watch(authProvider.select((data) => data.value?.loginType)); @@ -278,23 +278,23 @@ class RouterNotifier extends ChangeNotifier { : await (_prepare(state).then((_) => null)); } - FutureOr _goPnp(String query) { + FutureOr goPnp(String query) { FlutterNativeSplash.remove(); final queryParams = query.isEmpty - ? Uri.tryParse(getFullLocation(_ref))?.query ?? '' + ? Uri.tryParse(getFullLocation(_ref.read))?.query ?? '' : query; final path = '${RoutePath.pnp}?$queryParams'; logger.i('[Route]: Go to PnP, URI=$path'); return path; } - FutureOr _goFirstTimeLogin(GoRouterState state) { + FutureOr goFirstTimeLogin(GoRouterState state) { logger.i('[Route]: Mark First Time Login'); _ref.read(autoParentFirstLoginStateProvider.notifier).state = true; - return _authCheck(state); + return authCheck(state); } - Future _authCheck(GoRouterState state) { + Future authCheck(GoRouterState state) { return _ref.read(authProvider.notifier).init().then((authState) async { logger.i( '[Route]: Check credentials done: Login type = ${authState?.loginType}'); @@ -333,9 +333,9 @@ class RouterNotifier extends ChangeNotifier { : '${RoutePath.cloudLoginAuth}?$query'; } - FutureOr _goPnpPath(GoRouterState state) { + FutureOr goPnpPath(GoRouterState state) { if (_ref.read(pnpProvider).deviceInfo == null) { - return _goPnp(state.uri.query); + return goPnp(state.uri.query); } else { // bypass any pnp views return state.uri.toString(); @@ -370,11 +370,10 @@ class RouterNotifier extends ChangeNotifier { logger.d('[Prepare]: device info check - $serialNumber'); final nodeDeviceInfo = await _ref .read(sessionProvider.notifier) - .checkDeviceInfo(serialNumber) + .fetchDeviceInfoAndInitializeServices() .then((nodeDeviceInfo) { - // Build/Update better actions - logger.d('[Prepare]: build better actions'); - buildBetterActions(nodeDeviceInfo.services); + logger.d( + '[Prepare]: Services initialized via fetchDeviceInfoAndInitializeServices'); return nodeDeviceInfo; }).onError((error, stackTrace) => null); @@ -437,15 +436,12 @@ class RouterNotifier extends ChangeNotifier { return null; } - final routerRepository = _ref.read(routerRepositoryProvider); + // Use sessionProvider.forceFetchDeviceInfo() instead of direct RouterRepository access + // This adheres to Clean Architecture: Route -> Provider -> Service -> Repository + final deviceInfo = + await _ref.read(sessionProvider.notifier).forceFetchDeviceInfo(); + final newSerialNumber = deviceInfo.serialNumber; - final newSerialNumber = await routerRepository - .send( - JNAPAction.getDeviceInfo, - fetchRemote: true, - ) - .then( - (value) => NodeDeviceInfo.fromJson(value.output).serialNumber); if (serialNumber == newSerialNumber) { return null; } diff --git a/pubspec.yaml b/pubspec.yaml index f285d2aa2..61ddeb243 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,13 +68,11 @@ dependencies: ui_kit_library: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.10.4 - # ui_kit_library: - # path: ../../ui_kit + ref: v2.10.6 generative_ui: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.10.4 + ref: v2.10.6 path: generative_ui flutter_blue_plus: ^1.4.0 crypto: ^3.0.2 @@ -92,6 +90,7 @@ dependencies: web: ^1.1.1 file_picker: ^8.0.7 flutter_staggered_grid_view: ^0.7.0 + sliver_dashboard: ^0.9.0 async: ^2.11.0 path: ^1.9.0 flutter_dotenv: ^5.0.2 diff --git a/test/common/test_helper.dart b/test/common/test_helper.dart index c54f0c95f..77cdc9365 100644 --- a/test/common/test_helper.dart +++ b/test/common/test_helper.dart @@ -6,7 +6,6 @@ import 'package:go_router/go_router.dart'; import 'package:mockito/mockito.dart'; import 'package:privacy_gui/core/cloud/providers/geolocation/geolocation_state.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/core/data/providers/node_internet_status_provider.dart'; import 'package:privacy_gui/di.dart'; import 'package:privacy_gui/l10n/gen/app_localizations.dart'; @@ -85,6 +84,7 @@ import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_provi import 'package:privacy_gui/page/instant_topology/providers/instant_topology_provider.dart'; import 'package:privacy_gui/core/cloud/providers/geolocation/geolocation_provider.dart'; import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.dart'; +import 'package:privacy_gui/page/nodes/providers/node_light_state.dart'; import 'package:privacy_gui/core/data/providers/polling_provider.dart'; import 'package:privacy_gui/page/vpn/providers/vpn_notifier.dart'; import 'package:privacy_gui/page/instant_device/providers/device_list_provider.dart'; @@ -295,7 +295,7 @@ class TestHelper { when(mockGeolocationNotifer.build()).thenAnswer( (_) async => GeolocationState.fromMap(geolocationTestState)); when(mockNodeLightSettingsNotifier.build()) - .thenReturn(NodeLightSettings(isNightModeEnable: false)); + .thenReturn(NodeLightState.initial()); when(mockPollingNotifier.build()).thenReturn( CoreTransactionData(lastUpdate: 0, isReady: true, data: const {})); when(mockVPNNotifier.build()).thenReturn(VPNTestState.defaultState); diff --git a/test/core/data/providers/session_provider_test.dart b/test/core/data/providers/session_provider_test.dart index f5882f1ff..3e8617f07 100644 --- a/test/core/data/providers/session_provider_test.dart +++ b/test/core/data/providers/session_provider_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/constants/pref_key.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/jnap_device_info_raw.dart'; import 'package:privacy_gui/core/data/providers/session_provider.dart'; import 'package:privacy_gui/core/data/providers/device_info_provider.dart'; import 'package:privacy_gui/core/data/services/session_service.dart'; @@ -101,9 +101,9 @@ void main() { test('delegates to service.checkRouterIsBack with currentSN', () async { // Arrange - final expectedDeviceInfo = NodeDeviceInfo.fromJson( + final expectedDeviceInfo = JnapDeviceInfoRaw.fromJson( SessionTestData.createDeviceInfoSuccess(serialNumber: 'MOCK_SN').output, - ); + ).toUIModel(); when(() => mockService.checkRouterIsBack('MOCK_SN')) .thenAnswer((_) async => expectedDeviceInfo); @@ -130,9 +130,9 @@ void main() { pPnpConfiguredSN: 'PNP_SN', }); - final expectedDeviceInfo = NodeDeviceInfo.fromJson( + final expectedDeviceInfo = JnapDeviceInfoRaw.fromJson( SessionTestData.createDeviceInfoSuccess(serialNumber: 'PNP_SN').output, - ); + ).toUIModel(); when(() => mockService.checkRouterIsBack('PNP_SN')) .thenAnswer((_) async => expectedDeviceInfo); @@ -197,10 +197,10 @@ void main() { group('SessionNotifier - checkDeviceInfo', () { test('delegates to service.checkDeviceInfo with cached state', () async { // Arrange - final cachedDeviceInfo = NodeDeviceInfo.fromJson( + final cachedDeviceInfo = JnapDeviceInfoRaw.fromJson( SessionTestData.createDeviceInfoSuccess(serialNumber: 'CACHED_SN') .output, - ); + ).toUIModel(); final deviceInfoState = DeviceInfoState(deviceInfo: cachedDeviceInfo); when(() => mockService.checkDeviceInfo(cachedDeviceInfo)) @@ -225,10 +225,10 @@ void main() { test('delegates to service.checkDeviceInfo with null when no cache', () async { // Arrange - final freshDeviceInfo = NodeDeviceInfo.fromJson( + final freshDeviceInfo = JnapDeviceInfoRaw.fromJson( SessionTestData.createDeviceInfoSuccess(serialNumber: 'FRESH_SN') .output, - ); + ).toUIModel(); when(() => mockService.checkDeviceInfo(null)) .thenAnswer((_) async => freshDeviceInfo); @@ -269,4 +269,103 @@ void main() { ); }); }); + + group('SessionNotifier - forceFetchDeviceInfo', () { + test('delegates to service.forceFetchDeviceInfo', () async { + // Arrange + final freshDeviceInfo = JnapDeviceInfoRaw.fromJson( + SessionTestData.createDeviceInfoSuccess(serialNumber: 'FRESH_SN') + .output, + ).toUIModel(); + + when(() => mockService.forceFetchDeviceInfo()) + .thenAnswer((_) async => freshDeviceInfo); + + container = ProviderContainer( + overrides: [ + sessionServiceProvider.overrideWithValue(mockService), + deviceInfoProvider.overrideWithValue(const DeviceInfoState()), + ], + ); + + // Act + final result = + await container.read(sessionProvider.notifier).forceFetchDeviceInfo(); + + // Assert + verify(() => mockService.forceFetchDeviceInfo()).called(1); + expect(result.serialNumber, equals('FRESH_SN')); + }); + + test('always fetches fresh data, ignoring cache', () async { + // Arrange - Cached device info exists but should be ignored + final cachedDeviceInfo = JnapDeviceInfoRaw.fromJson( + SessionTestData.createDeviceInfoSuccess(serialNumber: 'CACHED_SN') + .output, + ).toUIModel(); + final freshDeviceInfo = JnapDeviceInfoRaw.fromJson( + SessionTestData.createDeviceInfoSuccess(serialNumber: 'FRESH_SN') + .output, + ).toUIModel(); + + when(() => mockService.forceFetchDeviceInfo()) + .thenAnswer((_) async => freshDeviceInfo); + + container = ProviderContainer( + overrides: [ + sessionServiceProvider.overrideWithValue(mockService), + deviceInfoProvider + .overrideWithValue(DeviceInfoState(deviceInfo: cachedDeviceInfo)), + ], + ); + + // Act + final result = + await container.read(sessionProvider.notifier).forceFetchDeviceInfo(); + + // Assert - Should return fresh data, not cached + expect(result.serialNumber, equals('FRESH_SN')); + verify(() => mockService.forceFetchDeviceInfo()).called(1); + }); + + test('propagates UnauthorizedError from service', () async { + // Arrange + when(() => mockService.forceFetchDeviceInfo()).thenThrow( + const UnauthorizedError(), + ); + + container = ProviderContainer( + overrides: [ + sessionServiceProvider.overrideWithValue(mockService), + deviceInfoProvider.overrideWithValue(const DeviceInfoState()), + ], + ); + + // Act & Assert + await expectLater( + () => container.read(sessionProvider.notifier).forceFetchDeviceInfo(), + throwsA(isA()), + ); + }); + + test('propagates ResourceNotFoundError from service', () async { + // Arrange + when(() => mockService.forceFetchDeviceInfo()).thenThrow( + const ResourceNotFoundError(), + ); + + container = ProviderContainer( + overrides: [ + sessionServiceProvider.overrideWithValue(mockService), + deviceInfoProvider.overrideWithValue(const DeviceInfoState()), + ], + ); + + // Act & Assert + await expectLater( + () => container.read(sessionProvider.notifier).forceFetchDeviceInfo(), + throwsA(isA()), + ); + }); + }); } diff --git a/test/core/data/services/session_service_test.dart b/test/core/data/services/session_service_test.dart index 7a007af07..22c96b3c2 100644 --- a/test/core/data/services/session_service_test.dart +++ b/test/core/data/services/session_service_test.dart @@ -2,7 +2,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/jnap_device_info_raw.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/data/services/session_service.dart'; @@ -136,10 +137,10 @@ void main() { // T044: checkDeviceInfo returns cached value immediately when available test('returns cached value immediately when available', () async { // Arrange - final cachedDeviceInfo = NodeDeviceInfo.fromJson( + final cachedDeviceInfo = JnapDeviceInfoRaw.fromJson( SessionTestData.createDeviceInfoSuccess(serialNumber: 'CACHED_SN') .output, - ); + ).toUIModel(); // Act final result = await service.checkDeviceInfo(cachedDeviceInfo); @@ -197,4 +198,83 @@ void main() { ); }); }); + + group('SessionService - forceFetchDeviceInfo', () { + // T047: forceFetchDeviceInfo always makes API call + test('always makes API call to fetch fresh device info', () async { + // Arrange + final deviceInfoOutput = + SessionTestData.createDeviceInfoSuccess(serialNumber: 'FRESH_SN') + .output; + + when(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + )) + .thenAnswer( + (_) async => JNAPSuccess(result: 'OK', output: deviceInfoOutput)); + + // Act + final result = await service.forceFetchDeviceInfo(); + + // Assert + expect(result, isA()); + expect(result.serialNumber, equals('FRESH_SN')); + verify(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + )).called(1); + }); + + // T048: forceFetchDeviceInfo throws UnauthorizedError on unauthorized + test('throws UnauthorizedError on _ErrorUnauthorized', () async { + // Arrange + when(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + )) + .thenThrow(const JNAPError( + result: '_ErrorUnauthorized', error: 'Unauthorized')); + + // Act & Assert + expect( + () => service.forceFetchDeviceInfo(), + throwsA(isA()), + ); + }); + + // T049: forceFetchDeviceInfo throws ResourceNotFoundError + test('throws ResourceNotFoundError on ErrorDeviceNotFound', () async { + // Arrange + when(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + )) + .thenThrow(const JNAPError( + result: 'ErrorDeviceNotFound', error: 'Device not found')); + + // Act & Assert + expect( + () => service.forceFetchDeviceInfo(), + throwsA(isA()), + ); + }); + + // T050: forceFetchDeviceInfo throws UnexpectedError on unknown error + test('throws UnexpectedError on unknown JNAP error', () async { + // Arrange + when(() => mockRouterRepository.send( + JNAPAction.getDeviceInfo, + fetchRemote: true, + )) + .thenThrow(const JNAPError( + result: 'UnknownError', error: 'Something went wrong')); + + // Act & Assert + expect( + () => service.forceFetchDeviceInfo(), + throwsA(isA()), + ); + }); + }); } diff --git a/test/core/jnap/providers/node_light_settings_provider_test.dart b/test/core/jnap/providers/node_light_settings_provider_test.dart index 042e2f392..6f5a20bda 100644 --- a/test/core/jnap/providers/node_light_settings_provider_test.dart +++ b/test/core/jnap/providers/node_light_settings_provider_test.dart @@ -1,7 +1,7 @@ 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/models/node_light_settings.dart'; +import 'package:privacy_gui/page/nodes/providers/node_light_state.dart'; import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.dart'; import 'package:privacy_gui/page/nodes/services/node_light_settings_service.dart'; import 'package:privacy_gui/page/nodes/providers/node_detail_state.dart'; @@ -14,7 +14,7 @@ void main() { late ProviderContainer container; setUpAll(() { - registerFallbackValue(NodeLightSettings.on()); + registerFallbackValue(NodeLightState.initial()); }); setUp(() { @@ -31,79 +31,93 @@ void main() { }); group('NodeLightSettingsNotifier - delegation', () { - test('fetch() calls service.fetchSettings() and updates state', () async { + test('fetch() calls service.fetchState() and updates state', () async { // Arrange - final expectedSettings = NodeLightSettings.night(); - when(() => - mockService.fetchSettings(forceRemote: any(named: 'forceRemote'))) - .thenAnswer((_) async => expectedSettings); + const expectedState = NodeLightState( + isNightModeEnabled: true, + startHour: 20, + endHour: 8, + allDayOff: false); + when(() => mockService.fetchState(forceRemote: any(named: 'forceRemote'))) + .thenAnswer((_) async => expectedState); // Act final notifier = container.read(nodeLightSettingsProvider.notifier); final result = await notifier.fetch(); // Assert - verify(() => mockService.fetchSettings(forceRemote: false)).called(1); - expect(result, expectedSettings); - expect(container.read(nodeLightSettingsProvider), expectedSettings); + verify(() => mockService.fetchState(forceRemote: false)).called(1); + expect(result, expectedState); + expect(container.read(nodeLightSettingsProvider), expectedState); }); test('fetch(true) passes forceRemote=true to service', () async { // Arrange - final expectedSettings = NodeLightSettings.night(); - when(() => - mockService.fetchSettings(forceRemote: any(named: 'forceRemote'))) - .thenAnswer((_) async => expectedSettings); + const expectedState = NodeLightState( + isNightModeEnabled: true, + startHour: 20, + endHour: 8, + allDayOff: false); + when(() => mockService.fetchState(forceRemote: any(named: 'forceRemote'))) + .thenAnswer((_) async => expectedState); // Act final notifier = container.read(nodeLightSettingsProvider.notifier); - await notifier.fetch(true); + await notifier.fetch(forceRemote: true); // Assert - verify(() => mockService.fetchSettings(forceRemote: true)).called(1); + verify(() => mockService.fetchState(forceRemote: true)).called(1); }); - test('save() calls service.saveSettings(state) and updates state', - () async { + test('save() calls service.saveState(state) and updates state', () async { // Arrange - final settingsToSave = NodeLightSettings.night(); - final savedSettings = NodeLightSettings.night(); - - when(() => mockService.saveSettings(any())) - .thenAnswer((_) async => savedSettings); + const stateToSave = NodeLightState( + isNightModeEnabled: true, + startHour: 20, + endHour: 8, + allDayOff: false); + const savedState = NodeLightState( + isNightModeEnabled: true, + startHour: 20, + endHour: 8, + allDayOff: false); + + when(() => mockService.saveState(any())) + .thenAnswer((_) async => savedState); // Act final notifier = container.read(nodeLightSettingsProvider.notifier); - notifier.setSettings(settingsToSave); + notifier.setSettings(stateToSave); final result = await notifier.save(); // Assert - verify(() => mockService.saveSettings(settingsToSave)).called(1); - expect(result, savedSettings); - expect(container.read(nodeLightSettingsProvider), savedSettings); + verify(() => mockService.saveState(stateToSave)).called(1); + expect(result, savedState); + expect(container.read(nodeLightSettingsProvider), savedState); }); test('setSettings() updates state directly without service call', () { // Arrange - final newSettings = NodeLightSettings.off(); + const newState = NodeLightState( + isNightModeEnabled: false, startHour: 0, endHour: 0, allDayOff: true); // Act final notifier = container.read(nodeLightSettingsProvider.notifier); - notifier.setSettings(newSettings); + notifier.setSettings(newState); // Assert - just verify state was updated directly - expect(container.read(nodeLightSettingsProvider), newSettings); + expect(container.read(nodeLightSettingsProvider), newState); }); }); group('NodeLightSettingsNotifier - currentStatus getter', () { - test('returns NodeLightStatus.on when isNightModeEnable=false', () { + test('returns NodeLightStatus.on when isNightModeEnabled=false', () { // Arrange - final settings = NodeLightSettings.on(); + const state = NodeLightState(isNightModeEnabled: false, allDayOff: false); // Act final notifier = container.read(nodeLightSettingsProvider.notifier); - notifier.setSettings(settings); + notifier.setSettings(state); final status = notifier.currentStatus; // Assert @@ -112,42 +126,48 @@ void main() { test('returns NodeLightStatus.off when allDayOff=true', () { // Arrange - const settings = NodeLightSettings( - isNightModeEnable: true, + const state = NodeLightState( + isNightModeEnabled: true, allDayOff: true, ); // Act final notifier = container.read(nodeLightSettingsProvider.notifier); - notifier.setSettings(settings); + notifier.setSettings(state); final status = notifier.currentStatus; // Assert expect(status, NodeLightStatus.off); }); - test('returns NodeLightStatus.off when startHour=0 and endHour=24', () { + test( + 'returns NodeLightStatus.off when startHour=0 and endHour=24 and enabled=true? (No, depends on implementation)', + () { + // NOTE: Original test checked "returns NodeLightStatus.off when startHour=0 and endHour=24" using NodeLightSettings.off(). + // In Refactor, logic is mostly boolean flags. + // NodeLightState.off() factory logic was: isNightModeEnable=false, allDayOff=true. + // Let's test the `NodeLightStatus.off` condition which is `allDayOff` in my implementation (or !night && allDayOff). + // Arrange - final settings = NodeLightSettings.off(); + // Let's create a state that would be logically OFF. + const state = NodeLightState(isNightModeEnabled: false, allDayOff: true); // Act final notifier = container.read(nodeLightSettingsProvider.notifier); - notifier.setSettings(settings); + notifier.setSettings(state); final status = notifier.currentStatus; // Assert expect(status, NodeLightStatus.off); }); - test( - 'returns NodeLightStatus.night when isNightModeEnable=true with partial schedule', - () { + test('returns NodeLightStatus.night when isNightModeEnabled=true', () { // Arrange - final settings = NodeLightSettings.night(); + const state = NodeLightState(isNightModeEnabled: true, allDayOff: false); // Act final notifier = container.read(nodeLightSettingsProvider.notifier); - notifier.setSettings(settings); + notifier.setSettings(state); final status = notifier.currentStatus; // Assert @@ -158,10 +178,12 @@ void main() { group('NodeLightSettingsNotifier - save flow', () { test('setSettings() followed by save() persists correct values', () async { // Arrange - final settingsToSet = NodeLightSettings.off(); - final savedSettings = NodeLightSettings.off(); + const settingsToSet = + NodeLightState(isNightModeEnabled: false, allDayOff: true); + const savedSettings = + NodeLightState(isNightModeEnabled: false, allDayOff: true); - when(() => mockService.saveSettings(any())) + when(() => mockService.saveState(any())) .thenAnswer((_) async => savedSettings); // Act @@ -170,21 +192,22 @@ void main() { await notifier.save(); // Assert - verify(() => mockService.saveSettings(settingsToSet)).called(1); + verify(() => mockService.saveState(settingsToSet)).called(1); }); test('state updates after successful save', () async { // Arrange - final settingsToSet = NodeLightSettings.night(); - const returnedSettings = NodeLightSettings( - isNightModeEnable: true, + const settingsToSet = + NodeLightState(isNightModeEnabled: true, allDayOff: false); + const returnedState = NodeLightState( + isNightModeEnabled: true, startHour: 20, endHour: 8, - allDayOff: false, // Server might add this field + allDayOff: false, ); - when(() => mockService.saveSettings(any())) - .thenAnswer((_) async => returnedSettings); + when(() => mockService.saveState(any())) + .thenAnswer((_) async => returnedState); // Act final notifier = container.read(nodeLightSettingsProvider.notifier); @@ -192,7 +215,7 @@ void main() { await notifier.save(); // Assert - expect(container.read(nodeLightSettingsProvider), returnedSettings); + expect(container.read(nodeLightSettingsProvider), returnedState); }); }); } diff --git a/test/core/jnap/services/node_light_settings_service_test.dart b/test/core/jnap/services/node_light_settings_service_test.dart index 8950824e8..02645329c 100644 --- a/test/core/jnap/services/node_light_settings_service_test.dart +++ b/test/core/jnap/services/node_light_settings_service_test.dart @@ -2,9 +2,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/jnap/actions/better_action.dart'; -import 'package:privacy_gui/core/jnap/models/node_light_settings.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/page/nodes/services/node_light_settings_service.dart'; +import 'package:privacy_gui/page/nodes/providers/node_light_state.dart'; +import 'package:privacy_gui/page/nodes/providers/node_detail_state.dart'; import '../../../mocks/test_data/node_light_settings_test_data.dart'; @@ -23,8 +24,8 @@ void main() { service = NodeLightSettingsService(mockRepository); }); - group('NodeLightSettingsService - fetchSettings', () { - test('returns NodeLightSettings from JNAP response', () async { + group('NodeLightSettingsService - fetchState', () { + test('returns NodeLightState converted from JNAP response', () async { // Arrange when(() => mockRepository.send( any(), @@ -35,12 +36,13 @@ void main() { (_) async => NodeLightSettingsTestData.createNightModeSettings()); // Act - final result = await service.fetchSettings(); + final result = await service.fetchState(); // Assert - expect(result.isNightModeEnable, true); + expect(result.isNightModeEnabled, true); expect(result.startHour, 20); expect(result.endHour, 8); + expect(result.status, NodeLightStatus.night); }); test('passes forceRemote=false by default', () async { @@ -54,7 +56,7 @@ void main() { (_) async => NodeLightSettingsTestData.createLedOnSettings()); // Act - await service.fetchSettings(); + await service.fetchState(); // Assert verify(() => mockRepository.send( @@ -75,7 +77,7 @@ void main() { (_) async => NodeLightSettingsTestData.createLedOnSettings()); // Act - await service.fetchSettings(forceRemote: true); + await service.fetchState(forceRemote: true); // Assert verify(() => mockRepository.send( @@ -95,7 +97,7 @@ void main() { // Act & Assert expect( - () => service.fetchSettings(), + () => service.fetchState(), throwsA(isA()), ); }); @@ -112,16 +114,20 @@ void main() { // Act & Assert expect( - () => service.fetchSettings(), + () => service.fetchState(), throwsA(isA()), ); }); }); - group('NodeLightSettingsService - saveSettings', () { - test('sends correct data to JNAP and returns refreshed settings', () async { + group('NodeLightSettingsService - saveState', () { + test('converts state to JNAP Map and returns refreshed state', () async { // Arrange - final settingsToSave = NodeLightSettings.night(); + const stateToSave = NodeLightState( + isNightModeEnabled: true, + startHour: 20, + endHour: 8, + allDayOff: false); when(() => mockRepository.send( JNAPAction.setLedNightModeSetting, @@ -140,7 +146,7 @@ void main() { (_) async => NodeLightSettingsTestData.createNightModeSettings()); // Act - final result = await service.saveSettings(settingsToSave); + final result = await service.saveState(stateToSave); // Assert verify(() => mockRepository.send( @@ -160,13 +166,50 @@ void main() { fetchRemote: true, )).called(1); - expect(result.isNightModeEnable, true); + expect(result.isNightModeEnabled, true); + expect(result.startHour, 20); + expect(result.endHour, 8); }); - test('excludes null fields from request', () async { + test('sends correct data for All AllDayOff (LED OFF)', () async { // Arrange - final settingsToSave = - NodeLightSettings.on(); // Has null startHour/endHour + // "Off" state means allDayOff=true. + const stateToSave = + NodeLightState(isNightModeEnabled: false, allDayOff: true); + + when(() => mockRepository.send( + JNAPAction.setLedNightModeSetting, + data: any(named: 'data'), + auth: any(named: 'auth'), + )) + .thenAnswer( + (_) async => NodeLightSettingsTestData.createSaveSuccess()); + + when(() => mockRepository.send( + JNAPAction.getLedNightModeSetting, + auth: any(named: 'auth'), + fetchRemote: any(named: 'fetchRemote'), + )) + .thenAnswer( + (_) async => NodeLightSettingsTestData.createLedOffSettings()); + + // Act + await service.saveState(stateToSave); + + // Assert + // Implementation maps allDayOff to Enable=true, Start=0, End=24 + verify(() => mockRepository.send( + JNAPAction.setLedNightModeSetting, + data: {'Enable': true, 'StartingTime': 0, 'EndingTime': 24}, + auth: true, + )).called(1); + }); + + test('sends Enable=false when turning Night Mode OFF (LED ON)', () async { + // Arrange + const stateToSave = NodeLightState( + isNightModeEnabled: false, allDayOff: false); // Normal/On + // Defaults: startHour=20, endHour=6 when(() => mockRepository.send( JNAPAction.setLedNightModeSetting, @@ -185,19 +228,20 @@ void main() { (_) async => NodeLightSettingsTestData.createLedOnSettings()); // Act - await service.saveSettings(settingsToSave); + await service.saveState(stateToSave); - // Assert - only Enable should be sent, no StartingTime or EndingTime + // Assert + // Implementation sends current hours even if disabled verify(() => mockRepository.send( JNAPAction.setLedNightModeSetting, - data: {'Enable': false}, + data: {'Enable': false, 'StartingTime': 20, 'EndingTime': 6}, auth: true, )).called(1); }); test('throws UnauthorizedError on save auth failure', () async { // Arrange - final settingsToSave = NodeLightSettings.night(); + const stateToSave = NodeLightState(isNightModeEnabled: true); when(() => mockRepository.send( JNAPAction.setLedNightModeSetting, @@ -207,14 +251,14 @@ void main() { // Act & Assert expect( - () => service.saveSettings(settingsToSave), + () => service.saveState(stateToSave), throwsA(isA()), ); }); test('throws UnexpectedError on save generic error', () async { // Arrange - final settingsToSave = NodeLightSettings.night(); + const stateToSave = NodeLightState(isNightModeEnabled: true); when(() => mockRepository.send( JNAPAction.setLedNightModeSetting, @@ -226,7 +270,7 @@ void main() { // Act & Assert expect( - () => service.saveSettings(settingsToSave), + () => service.saveState(stateToSave), throwsA(isA()), ); }); diff --git a/test/core/retry_strategy/retry_test.dart b/test/core/retry_strategy/retry_test.dart index 05cd33dd0..c8398111c 100644 --- a/test/core/retry_strategy/retry_test.dart +++ b/test/core/retry_strategy/retry_test.dart @@ -4,14 +4,20 @@ import 'package:privacy_gui/core/retry_strategy/retry.dart'; void main() { group('RetryStrategy', () { test('should execute successfully on first attempt', () async { - final strategy = ExponentialBackoffRetryStrategy(maxRetries: 3); + final strategy = ExponentialBackoffRetryStrategy( + maxRetries: 3, + initialDelay: const Duration(milliseconds: 1), + ); final result = await strategy.execute(() async => 'success'); expect(result, 'success'); }); test('should retry until success', () async { int attempt = 0; - final strategy = ExponentialBackoffRetryStrategy(maxRetries: 3); + final strategy = ExponentialBackoffRetryStrategy( + maxRetries: 3, + initialDelay: const Duration(milliseconds: 1), + ); final result = await strategy.execute( () async { @@ -28,7 +34,10 @@ void main() { test('should throw MaxRetriesExceededException when max retries reached', () async { - final strategy = ExponentialBackoffRetryStrategy(maxRetries: 2); + final strategy = ExponentialBackoffRetryStrategy( + maxRetries: 2, + initialDelay: const Duration(milliseconds: 1), + ); expect( () => strategy.execute( @@ -40,7 +49,10 @@ void main() { test('should call onRetry callback for each retry', () async { int retryCount = 0; - final strategy = ExponentialBackoffRetryStrategy(maxRetries: 3); + final strategy = ExponentialBackoffRetryStrategy( + maxRetries: 3, + initialDelay: const Duration(milliseconds: 1), + ); try { await strategy.execute( @@ -156,7 +168,10 @@ void main() { test('should use shouldRetry callback to determine retry', () async { int attempt = 0; - final strategy = ExponentialBackoffRetryStrategy(maxRetries: 3); + final strategy = ExponentialBackoffRetryStrategy( + maxRetries: 3, + initialDelay: const Duration(milliseconds: 1), + ); final result = await strategy.execute( () async => attempt++, @@ -167,7 +182,7 @@ void main() { expect(attempt, 3); }); - test('should throw ShouldRetryException when shouldRetry returns true', + test('should throw MaxRetriesExceededException when shouldRetry returns true', () async { final strategy = ExponentialBackoffRetryStrategy(maxRetries: 0); diff --git a/test/core/utils/ip_getter_test.dart b/test/core/utils/ip_getter_test.dart new file mode 100644 index 000000000..86a75435e --- /dev/null +++ b/test/core/utils/ip_getter_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/utils/ip_getter/get_local_ip.dart'; +import 'package:privacy_gui/providers/connectivity/_connectivity.dart'; +import 'package:privacy_gui/providers/connectivity/connectivity_provider.dart'; + +/// Mock ConnectivityNotifier that allows setting initial state +class MockConnectivityNotifier extends Notifier + implements ConnectivityNotifier { + final ConnectivityState _initialState; + + MockConnectivityNotifier(this._initialState); + + @override + ConnectivityState build() => _initialState; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + group('ProviderReader typedef', () { + test('ProviderReader type is compatible with container.read', () { + // Arrange + final container = ProviderContainer(); + addTearDown(container.dispose); + + // Act - Verify that container.read can be assigned to ProviderReader + final ProviderReader reader = container.read; + + // Assert - The assignment should compile and work + expect(reader, isNotNull); + }); + + test('ProviderReader can read StateProvider', () { + // Arrange + final testProvider = StateProvider((ref) => 'test_value'); + final container = ProviderContainer(); + addTearDown(container.dispose); + + // Act + final ProviderReader reader = container.read; + final value = reader(testProvider); + + // Assert + expect(value, equals('test_value')); + }); + + test('ProviderReader can read Provider with complex state', () { + // Arrange + final container = ProviderContainer( + overrides: [ + connectivityProvider + .overrideWith(() => MockConnectivityNotifier(ConnectivityState( + hasInternet: true, + connectivityInfo: + ConnectivityInfo(gatewayIp: '192.168.1.1'), + ))), + ], + ); + addTearDown(container.dispose); + + // Act + final ProviderReader reader = container.read; + final state = reader(connectivityProvider); + + // Assert + expect(state.connectivityInfo.gatewayIp, equals('192.168.1.1')); + }); + + test('ProviderReader accesses same provider instance on multiple reads', + () { + // Arrange + final counterProvider = StateProvider((ref) => 0); + final container = ProviderContainer(); + addTearDown(container.dispose); + + // Act - Read twice using ProviderReader + final ProviderReader reader = container.read; + final value1 = reader(counterProvider); + + // Mutate state + container.read(counterProvider.notifier).state = 42; + + final value2 = reader(counterProvider); + + // Assert + expect(value1, equals(0)); + expect(value2, equals(42)); + }); + + test('Multiple ProviderReader references access same ProviderContainer', + () { + // Arrange - Simulates Ref.read and WidgetRef.read both accessing same container + final sharedProvider = StateProvider((ref) => 'shared_value'); + final container = ProviderContainer(); + addTearDown(container.dispose); + + // Act - Create two reader references (simulating Ref.read and WidgetRef.read) + final ProviderReader reader1 = container.read; + final ProviderReader reader2 = container.read; + + final value1 = reader1(sharedProvider); + final value2 = reader2(sharedProvider); + + // Assert - Both should get the same value from the same provider instance + expect(value1, equals(value2)); + expect(value1, equals('shared_value')); + }); + }); + + // Note: The actual getLocalIp function uses conditional exports + // (mobile_get_local_ip.dart, web_get_local_ip.dart) which are platform-specific. + // In a non-mobile/non-web test environment, the stub version throws UnsupportedError. + // The following tests verify the mobile implementation logic can be called + // with a ProviderReader, using direct implementation testing. + + group('Mobile getLocalIp implementation logic', () { + test('gatewayIp extraction from ConnectivityInfo', () { + // Arrange - Test the underlying logic that mobile_get_local_ip.dart uses + final container = ProviderContainer( + overrides: [ + connectivityProvider + .overrideWith(() => MockConnectivityNotifier(ConnectivityState( + hasInternet: true, + connectivityInfo: + ConnectivityInfo(gatewayIp: '192.168.0.1'), + ))), + ], + ); + addTearDown(container.dispose); + + // Act - Simulate what mobile_get_local_ip.dart does + final gatewayIp = + container.read(connectivityProvider).connectivityInfo.gatewayIp ?? ''; + + // Assert + expect(gatewayIp, equals('192.168.0.1')); + }); + + test('null gatewayIp returns empty string', () { + // Arrange + final container = ProviderContainer( + overrides: [ + connectivityProvider + .overrideWith(() => MockConnectivityNotifier(ConnectivityState( + hasInternet: false, + connectivityInfo: ConnectivityInfo(gatewayIp: null), + ))), + ], + ); + addTearDown(container.dispose); + + // Act - Simulate what mobile_get_local_ip.dart does + final gatewayIp = + container.read(connectivityProvider).connectivityInfo.gatewayIp ?? ''; + + // Assert + expect(gatewayIp, equals('')); + }); + + test('common IP address formats are preserved', () { + // Test common IP address formats + final testCases = [ + ('192.168.0.1', '192.168.0.1'), + ('10.0.0.254', '10.0.0.254'), + ('172.16.0.1', '172.16.0.1'), + ('localhost', 'localhost'), + ('https://192.168.1.1', 'https://192.168.1.1'), + (null, ''), + ]; + + for (final (input, expected) in testCases) { + final container = ProviderContainer( + overrides: [ + connectivityProvider + .overrideWith(() => MockConnectivityNotifier(ConnectivityState( + hasInternet: input != null, + connectivityInfo: ConnectivityInfo(gatewayIp: input), + ))), + ], + ); + addTearDown(container.dispose); + + // Act - Simulate mobile_get_local_ip.dart logic + final gatewayIp = + container.read(connectivityProvider).connectivityInfo.gatewayIp ?? + ''; + + // Assert + expect(gatewayIp, equals(expected), + reason: 'Input: $input should produce: $expected'); + } + }); + }); +} diff --git a/test/demo/demo_color_picker_test.dart b/test/demo/demo_color_picker_test.dart new file mode 100644 index 000000000..12d1db3f0 --- /dev/null +++ b/test/demo/demo_color_picker_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/demo/theme_studio/theme_studio_fab.dart'; +import 'package:privacy_gui/demo/theme_studio/theme_studio_panel.dart'; +import 'package:privacy_gui/demo/providers/demo_ui_provider.dart'; +import 'package:privacy_gui/route/router_provider.dart'; + +void main() { + testWidgets('Color picker dialog opens on tap', (tester) async { + final themeData = AppTheme.create( + brightness: Brightness.light, + seedColor: Colors.blue, + designThemeBuilder: (c) => CustomDesignTheme.fromJson({ + 'style': 'flat', + }), + ); + + // Set a large screen size to avoid overflow + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + navigatorKey: routerKey, // Use the real routerKey for test + theme: themeData, + home: Scaffold( + body: Stack( + fit: StackFit.expand, + children: [ + Positioned( + bottom: 16, + right: 16, + child: const ThemeStudioFab(), + ), + // Simulate Panel Overlay + Consumer(builder: (context, ref, _) { + final isOpen = ref.watch(demoUIProvider).isThemePanelOpen; + if (!isOpen) return const SizedBox(); + return const Positioned( + top: 0, + bottom: 0, + right: 0, + width: 500, + child: Material(child: ThemeStudioPanel()), + ); + }), + ], + ), + ), + ), + ), + ); + + // 1. Open FAB + await tester.tap(find.byType(ThemeStudioFab)); + await tester.pumpAndSettle(); + + // 2. Switch to Palette Tab + // Use AppTabs finder since TabBar is not used. + final paletteTab = find.descendant( + of: find.byType(AppTabs), + matching: find.text('Palette'), + ); + + await tester.tap(paletteTab.first); // Use first just in case + await tester.pumpAndSettle(); + + // 3. Find Primary Color Override Row + expect(find.text('Primary'), findsOneWidget); + + // 4. Tap the ColorCircle next to Primary using Key + final colorCircleFinder = + find.byKey(const ValueKey('color-override-Primary')); + expect(colorCircleFinder, findsOneWidget); + + await tester.tap(colorCircleFinder); + await tester.pumpAndSettle(); + + // 5. Verify Dialog is Open + expect(find.text('Pick Color'), findsOneWidget); + }); +} diff --git a/test/demo/demo_theme_config_test.dart b/test/demo/demo_theme_config_test.dart new file mode 100644 index 000000000..cc8805bc4 --- /dev/null +++ b/test/demo/demo_theme_config_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/demo/providers/demo_theme_config_provider.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +void main() { + group('DemoThemeConfig', () { + test('toJson and fromJson round trip preserves all fields', () { + final original = DemoThemeConfig( + style: 'brutal', + globalOverlay: GlobalOverlayType.hacker, + visualEffects: AppThemeConfig.effectBlur, + seedColor: const Color(0xFF123456), + primary: const Color(0xFFAABBCC), + surface: const Color(0xFF112233), + error: const Color(0xFF990000), + overrides: AppThemeOverrides( + semantic: SemanticOverrides( + success: const Color(0xFF00FF00), + warning: const Color(0xFFFFFF00), + ), + component: ComponentOverrides( + input: InputColorOverride( + outlineBorderColor: const Color(0xFF333333), + ), + ), + ), + ); + + final json = original.toJson(); + final restored = DemoThemeConfig.fromJson(json); + + expect(restored.style, original.style); + expect(restored.globalOverlay, original.globalOverlay); + expect(restored.visualEffects, original.visualEffects); + expect(restored.seedColor?.toARGB32(), original.seedColor?.toARGB32()); + expect(restored.primary?.toARGB32(), original.primary?.toARGB32()); + expect(restored.surface?.toARGB32(), original.surface?.toARGB32()); + expect(restored.error?.toARGB32(), original.error?.toARGB32()); + + // Check Overrides + expect(restored.overrides?.semantic?.success?.toARGB32(), + const Color(0xFF00FF00).toARGB32()); + expect(restored.overrides?.semantic?.warning?.toARGB32(), + const Color(0xFFFFFF00).toARGB32()); + expect( + restored.overrides?.component?.input?.outlineBorderColor?.toARGB32(), + const Color(0xFF333333).toARGB32()); + }); + + test('fromJson handles missing overrides gracefully', () { + final json = { + 'style': 'flat', + // 'overrides' is missing + }; + + final config = DemoThemeConfig.fromJson(json); + expect(config.style, 'flat'); + expect(config.overrides, isNull); + }); + + test('Notifier updates state correctly', () { + final notifier = DemoThemeConfigNotifier(); + + // Test basic update + notifier.setStyle('pixel'); + expect(notifier.state.style, 'pixel'); + + // Test granular color + notifier.setPrimary(Colors.red); + expect(notifier.state.primary, Colors.red); + + // Test semantic override + notifier.updateSemanticOverrides(success: Colors.green); + expect(notifier.state.overrides?.semantic?.success, Colors.green); + }); + }); +} diff --git a/test/mocks/auth_notifier_mocks.dart b/test/mocks/auth_notifier_mocks.dart index c440bf089..1d6e0c93d 100644 --- a/test/mocks/auth_notifier_mocks.dart +++ b/test/mocks/auth_notifier_mocks.dart @@ -275,12 +275,11 @@ class MockAuthNotifier extends _i2.AsyncNotifier<_i3.AuthState> ) as _i5.Future); @override - _i5.Future?> getAdminPasswordAuthStatus( - List? services) => + _i5.Future?> getAdminPasswordAuthStatus() => (super.noSuchMethod( Invocation.method( #getAdminPasswordAuthStatus, - [services], + [], ), returnValue: _i5.Future?>.value(), returnValueForMissingStub: _i5.Future?>.value(), diff --git a/test/mocks/node_light_settings_notifier_mocks.dart b/test/mocks/node_light_settings_notifier_mocks.dart index ca67ad66a..61d5257c8 100644 --- a/test/mocks/node_light_settings_notifier_mocks.dart +++ b/test/mocks/node_light_settings_notifier_mocks.dart @@ -7,7 +7,7 @@ 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/page/nodes/providers/node_light_state.dart' as _i3; import 'package:privacy_gui/page/nodes/providers/node_light_settings_provider.dart' as _i4; import 'package:privacy_gui/page/nodes/providers/node_detail_state.dart' as _i5; @@ -38,9 +38,9 @@ class _FakeNotifierProviderRef_0 extends _i1.SmartFake ); } -class _FakeNodeLightSettings_1 extends _i1.SmartFake - implements _i3.NodeLightSettings { - _FakeNodeLightSettings_1( +class _FakeNodeLightState_1 extends _i1.SmartFake + implements _i3.NodeLightState { + _FakeNodeLightState_1( Object parent, Invocation parentInvocation, ) : super( @@ -52,7 +52,7 @@ class _FakeNodeLightSettings_1 extends _i1.SmartFake /// A class which mocks [NodeLightSettingsNotifier]. /// /// See the documentation for Mockito's code generation for more information. -class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightSettings> +class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightState> with _i1.Mock implements _i4.NodeLightSettingsNotifier { @override @@ -63,34 +63,34 @@ class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightSettings> ) as _i5.NodeLightStatus); @override - _i2.NotifierProviderRef<_i3.NodeLightSettings> get ref => (super.noSuchMethod( + _i2.NotifierProviderRef<_i3.NodeLightState> get ref => (super.noSuchMethod( Invocation.getter(#ref), - returnValue: _FakeNotifierProviderRef_0<_i3.NodeLightSettings>( + returnValue: _FakeNotifierProviderRef_0<_i3.NodeLightState>( this, Invocation.getter(#ref), ), returnValueForMissingStub: - _FakeNotifierProviderRef_0<_i3.NodeLightSettings>( + _FakeNotifierProviderRef_0<_i3.NodeLightState>( this, Invocation.getter(#ref), ), - ) as _i2.NotifierProviderRef<_i3.NodeLightSettings>); + ) as _i2.NotifierProviderRef<_i3.NodeLightState>); @override - _i3.NodeLightSettings get state => (super.noSuchMethod( + _i3.NodeLightState get state => (super.noSuchMethod( Invocation.getter(#state), - returnValue: _FakeNodeLightSettings_1( + returnValue: _FakeNodeLightState_1( this, Invocation.getter(#state), ), - returnValueForMissingStub: _FakeNodeLightSettings_1( + returnValueForMissingStub: _FakeNodeLightState_1( this, Invocation.getter(#state), ), - ) as _i3.NodeLightSettings); + ) as _i3.NodeLightState); @override - set state(_i3.NodeLightSettings? value) => super.noSuchMethod( + set state(_i3.NodeLightState? value) => super.noSuchMethod( Invocation.setter( #state, value, @@ -99,60 +99,61 @@ class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightSettings> ); @override - _i3.NodeLightSettings build() => (super.noSuchMethod( + _i3.NodeLightState build() => (super.noSuchMethod( Invocation.method( #build, [], ), - returnValue: _FakeNodeLightSettings_1( + returnValue: _FakeNodeLightState_1( this, Invocation.method( #build, [], ), ), - returnValueForMissingStub: _FakeNodeLightSettings_1( + returnValueForMissingStub: _FakeNodeLightState_1( this, Invocation.method( #build, [], ), ), - ) as _i3.NodeLightSettings); + ) as _i3.NodeLightState); @override - _i6.Future<_i3.NodeLightSettings> fetch([bool? forceRemote = false]) => + _i6.Future<_i3.NodeLightState> fetch({bool forceRemote = false}) => (super.noSuchMethod( Invocation.method( #fetch, - [forceRemote], + [], + {#forceRemote: forceRemote}, ), - returnValue: - _i6.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( + returnValue: _i6.Future<_i3.NodeLightState>.value(_FakeNodeLightState_1( this, Invocation.method( #fetch, - [forceRemote], + [], + {#forceRemote: forceRemote}, ), )), returnValueForMissingStub: - _i6.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( + _i6.Future<_i3.NodeLightState>.value(_FakeNodeLightState_1( this, Invocation.method( #fetch, - [forceRemote], + [], + {#forceRemote: forceRemote}, ), )), - ) as _i6.Future<_i3.NodeLightSettings>); + ) as _i6.Future<_i3.NodeLightState>); @override - _i6.Future<_i3.NodeLightSettings> save() => (super.noSuchMethod( + _i6.Future<_i3.NodeLightState> save() => (super.noSuchMethod( Invocation.method( #save, [], ), - returnValue: - _i6.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( + returnValue: _i6.Future<_i3.NodeLightState>.value(_FakeNodeLightState_1( this, Invocation.method( #save, @@ -160,17 +161,17 @@ class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightSettings> ), )), returnValueForMissingStub: - _i6.Future<_i3.NodeLightSettings>.value(_FakeNodeLightSettings_1( + _i6.Future<_i3.NodeLightState>.value(_FakeNodeLightState_1( this, Invocation.method( #save, [], ), )), - ) as _i6.Future<_i3.NodeLightSettings>); + ) as _i6.Future<_i3.NodeLightState>); @override - void setSettings(_i3.NodeLightSettings? settings) => super.noSuchMethod( + void setSettings(_i3.NodeLightState? settings) => super.noSuchMethod( Invocation.method( #setSettings, [settings], @@ -181,8 +182,8 @@ class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightSettings> @override void listenSelf( void Function( - _i3.NodeLightSettings?, - _i3.NodeLightSettings, + _i3.NodeLightState?, + _i3.NodeLightState, )? listener, { void Function( Object, @@ -200,8 +201,8 @@ class MockNodeLightSettingsNotifier extends _i2.Notifier<_i3.NodeLightSettings> @override bool updateShouldNotify( - _i3.NodeLightSettings? previous, - _i3.NodeLightSettings? next, + _i3.NodeLightState? previous, + _i3.NodeLightState? next, ) => (super.noSuchMethod( Invocation.method( diff --git a/test/mocks/session_notifier_mocks.dart b/test/mocks/session_notifier_mocks.dart index 75e61386c..c1c40a354 100644 --- a/test/mocks/session_notifier_mocks.dart +++ b/test/mocks/session_notifier_mocks.dart @@ -7,7 +7,7 @@ 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/models/device_info.dart' as _i3; +import 'package:privacy_gui/core/models/device_info.dart' as _i3; import 'package:privacy_gui/core/data/providers/session_provider.dart' as _i4; // ignore_for_file: type=lint @@ -139,6 +139,30 @@ class MockSessionNotifier extends _i2.Notifier )), ) as _i5.Future<_i3.NodeDeviceInfo>); + @override + _i5.Future<_i3.NodeDeviceInfo> fetchDeviceInfoAndInitializeServices() => + (super.noSuchMethod( + Invocation.method( + #fetchDeviceInfoAndInitializeServices, + [], + ), + returnValue: _i5.Future<_i3.NodeDeviceInfo>.value(_FakeNodeDeviceInfo_1( + this, + Invocation.method( + #fetchDeviceInfoAndInitializeServices, + [], + ), + )), + returnValueForMissingStub: + _i5.Future<_i3.NodeDeviceInfo>.value(_FakeNodeDeviceInfo_1( + this, + Invocation.method( + #fetchDeviceInfoAndInitializeServices, + [], + ), + )), + ) as _i5.Future<_i3.NodeDeviceInfo>); + @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 bbb9b34ec..65e4cc35d 100644 --- a/test/mocks/test_data/dashboard_home_test_data.dart +++ b/test/mocks/test_data/dashboard_home_test_data.dart @@ -1,5 +1,5 @@ import 'package:privacy_gui/core/jnap/models/device.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/models/device_info.dart'; import 'package:privacy_gui/core/jnap/models/guest_radio_settings.dart'; import 'package:privacy_gui/core/jnap/models/radio_info.dart'; import 'package:privacy_gui/core/jnap/models/wan_status.dart'; @@ -423,16 +423,15 @@ class DashboardHomeTestData { String hardwareVersion = '1', String serialNumber = 'SN123456789', }) { - return NodeDeviceInfo.fromJson({ - 'serialNumber': serialNumber, - 'modelNumber': modelNumber, - 'hardwareVersion': hardwareVersion, - 'manufacturer': 'Linksys', - 'description': 'Test Router', - 'firmwareVersion': '1.0.0', - 'firmwareDate': '2024-01-01T00:00:00Z', - 'services': const ['http://linksys.com/jnap/core/Core'], - }); + return NodeDeviceInfo( + serialNumber: serialNumber, + modelNumber: modelNumber, + hardwareVersion: hardwareVersion, + manufacturer: 'Linksys', + description: 'Test Router', + firmwareVersion: '1.0.0', + firmwareDate: '2024-01-01T00:00:00Z', + ); } /// Create RouterWANStatus for testing diff --git a/test/page/dashboard/localizations/dashboard_home_view_test.dart b/test/page/dashboard/localizations/dashboard_home_view_test.dart index 26465831d..1619defa6 100644 --- a/test/page/dashboard/localizations/dashboard_home_view_test.dart +++ b/test/page/dashboard/localizations/dashboard_home_view_test.dart @@ -3,15 +3,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; -import 'package:privacy_gui/core/jnap/models/node_light_settings.dart'; +import 'package:privacy_gui/page/nodes/providers/node_light_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/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/dashboard/views/components/fixed_layout/networks.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/port_and_speed.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/quick_panel.dart'; +import 'package:privacy_gui/page/dashboard/views/components/fixed_layout/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'; @@ -99,20 +99,20 @@ void main() { } Future scrollToQuickPanel(WidgetTester tester) async { - final panel = find.byType(DashboardQuickPanel).first; + final panel = find.byType(FixedDashboardQuickPanel).first; await tester.ensureVisible(panel); await tester.pumpAndSettle(); } Future scrollToWifiGrid(WidgetTester tester) async { - final grid = find.byType(DashboardWiFiGrid).first; + final grid = find.byType(FixedDashboardWiFiGrid).first; await tester.ensureVisible(grid); await tester.pumpAndSettle(); } Future toggleInstantPrivacy(WidgetTester tester) async { await scrollToQuickPanel(tester); - final quickPanel = find.byType(DashboardQuickPanel).first; + final quickPanel = find.byType(FixedDashboardQuickPanel).first; final privacySwitch = find.descendant( of: quickPanel, matching: find.byType(AppSwitch), @@ -130,8 +130,8 @@ void main() { expect(find.byType(DashboardHomeTitle), findsOneWidget); expect(find.text(loc.internetOnline), findsOneWidget); - expect(find.byType(DashboardQuickPanel), findsOneWidget); - expect(find.byType(DashboardWiFiGrid), findsOneWidget); + expect(find.byType(FixedDashboardQuickPanel), findsOneWidget); + expect(find.byType(FixedDashboardWiFiGrid), findsOneWidget); }, screens: _noLanScreens, goldenFilename: 'DHOME-NOLAN_BASE_01_layout', @@ -146,7 +146,7 @@ void main() { .thenReturn(topologyTestData.testTopologySingalsSlaveState); await pumpDashboard(tester, screen); - expect(find.byType(DashboardNetworks), findsOneWidget); + expect(find.byType(FixedDashboardNetworks), findsOneWidget); }, screens: _noLanScreens, goldenFilename: 'DHOME-NOLAN_SIGNAL_01_signals', @@ -208,7 +208,8 @@ void main() { 'dashboard home view - node night mode enabled', (tester, screen) async { when(testHelper.mockNodeLightSettingsNotifier.build()).thenReturn( - NodeLightSettings(isNightModeEnable: true, startHour: 20, endHour: 8), + const NodeLightState( + isNightModeEnabled: true, startHour: 20, endHour: 8), ); final context = await pumpDashboard(tester, screen); @@ -245,7 +246,7 @@ void main() { (tester, screen) async { await pumpDashboard(tester, screen); expect(find.byType(DashboardHomeTitle), findsOneWidget); - expect(find.byType(DashboardWiFiGrid), findsOneWidget); + expect(find.byType(FixedDashboardWiFiGrid), findsOneWidget); }, screens: _verticalScreens, goldenFilename: 'DHOME-VERT_BASE_01_layout', @@ -282,7 +283,7 @@ void main() { ), ); await pumpDashboard(tester, screen); - expect(find.byType(DashboardHomePortAndSpeed), findsOneWidget); + expect(find.byType(FixedDashboardHomePortAndSpeed), findsOneWidget); }, screens: _verticalScreens, goldenFilename: 'DHOME-VERT_SPEED_INIT_01_init', @@ -344,7 +345,7 @@ void main() { (tester, screen) async { await pumpDashboard(tester, screen); await scrollToWifiGrid(tester); - final wifiGrid = find.byType(DashboardWiFiGrid).first; + final wifiGrid = find.byType(FixedDashboardWiFiGrid).first; final shareButton = find.descendant( of: wifiGrid, matching: find.byType(AppIconButton), diff --git a/test/page/dashboard/models/dashboard_layout_preferences_test.dart b/test/page/dashboard/models/dashboard_layout_preferences_test.dart new file mode 100644 index 000000000..fe26e2ced --- /dev/null +++ b/test/page/dashboard/models/dashboard_layout_preferences_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/dashboard/models/dashboard_layout_preferences.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/models/grid_widget_config.dart'; + +void main() { + group('DashboardLayoutPreferences', () { + const testWidgetId = 'test_widget'; + + test('Initial state is empty and uses standard layout', () { + const prefs = DashboardLayoutPreferences(); + expect(prefs.useCustomLayout, isFalse); + expect(prefs.widgetConfigs, isEmpty); + }); + + test('toggleCustomLayout updates state', () { + const prefs = DashboardLayoutPreferences(); + final updated = prefs.toggleCustomLayout(true); + expect(updated.useCustomLayout, isTrue); + // Configs should remain unchanged + expect(updated.widgetConfigs, isEmpty); + }); + + group('Widget Configuration', () { + test('getConfig creates default if not exists', () { + const prefs = DashboardLayoutPreferences(); + // Assume test_widget corresponds to some ID or just verify default creation logic + // Since test_widget is not in DashboardWidgetSpecs.all, default order is 0. + final config = prefs.getConfig(testWidgetId); + expect(config.widgetId, testWidgetId); + expect(config.order, 0); + expect(config.visible, isTrue); + expect(config.displayMode, DisplayMode.normal); + }); + + test('updateConfig stores configuration', () { + const prefs = DashboardLayoutPreferences(); + final config = GridWidgetConfig( + widgetId: testWidgetId, + order: 5, + displayMode: DisplayMode.expanded, + visible: false, + ); + + final updated = prefs.updateConfig(config); + expect(updated.widgetConfigs.length, 1); + expect(updated.widgetConfigs[testWidgetId], config); + }); + + test('setMode updates display mode', () { + const prefs = DashboardLayoutPreferences(); + final updated = prefs.setMode(testWidgetId, DisplayMode.compact); + expect(updated.getMode(testWidgetId), DisplayMode.compact); + }); + + test('setVisibility updates visibility', () { + const prefs = DashboardLayoutPreferences(); + final updated = prefs.setVisibility(testWidgetId, false); + expect(updated.isVisible(testWidgetId), isFalse); + }); + + test('setColumnSpan updates and clears span', () { + const prefs = DashboardLayoutPreferences(); + // Set span + final s1 = prefs.setColumnSpan(testWidgetId, 4); + expect(s1.getConfig(testWidgetId).columnSpan, 4); + + // Clear span + final s2 = s1.setColumnSpan(testWidgetId, null); + expect(s2.getConfig(testWidgetId).columnSpan, isNull); + }); + }); + + group('Serialization', () { + test('Json round trip works correctly', () { + const original = + DashboardLayoutPreferences(useCustomLayout: true, widgetConfigs: { + 'w1': GridWidgetConfig( + widgetId: 'w1', order: 1, displayMode: DisplayMode.compact), + 'w2': GridWidgetConfig( + widgetId: 'w2', + order: 2, + displayMode: DisplayMode.expanded, + visible: false), + }); + + final json = original.toJson(); + final decoded = DashboardLayoutPreferences.fromJson(json); + + expect(decoded, original); + }); + + test('Deserializes legacy format correctly', () { + // Legacy format: { widgetModes: { w1: "compact", w2: "expanded" } } + final legacyJson = { + "widgetModes": {"w1": "compact", "w2": "expanded"} + }; + + final prefs = DashboardLayoutPreferences.fromJson(legacyJson); + + expect(prefs.widgetConfigs.containsKey('w1'), isTrue); + expect(prefs.widgetConfigs['w1']!.displayMode, DisplayMode.compact); + // Order should be assigned sequentially starting from 0 + expect(prefs.widgetConfigs['w1']!.order, 0); + + expect(prefs.widgetConfigs.containsKey('w2'), isTrue); + expect(prefs.widgetConfigs['w2']!.displayMode, DisplayMode.expanded); + expect(prefs.widgetConfigs['w2']!.order, 1); + }); + + test('Deserializes partial legacy data safely', () { + // Invalid entries should be ignored + final legacyJson = { + "widgetModes": {"w1": "compact", "w2": "INVALID_MODE"} + }; + final prefs = DashboardLayoutPreferences.fromJson(legacyJson); + expect(prefs.widgetConfigs.containsKey('w1'), isTrue); + expect(prefs.widgetConfigs.containsKey('w2'), isFalse); + }); + + test('Handles invalid new format gracefully', () { + // GridWidgetConfig.fromJson might throw if required fields are missing, let's see how DashboardLayoutPreferences handles it. + // Source: try { ... } catch (_) { // Ignore invalid entries } + + // But "widgetId" is required. If missing, it definitely fails. + // Let's test providing valid minimal JSON + final validJson = { + "useCustomLayout": true, + "widgetConfigs": { + "w1": { + "widgetId": "w1", + "order": 1, + "visible": true, + "displayMode": "normal" + } + } + }; + final prefs = DashboardLayoutPreferences.fromJson(validJson); + expect(prefs.widgetConfigs.length, 1); + + // Now test with data that causes cast error + final castErrorJson = { + "useCustomLayout": true, + "widgetConfigs": { + "w1": {"widgetId": "w1", "order": "string_instead_of_int"} + } + }; + final prefs2 = DashboardLayoutPreferences.fromJson(castErrorJson); + expect(prefs2.widgetConfigs, isEmpty, + reason: "Should ignore entries causing casting errors"); + }); + }); + }); +} diff --git a/test/page/dashboard/models/widget_grid_constraints_test.dart b/test/page/dashboard/models/widget_grid_constraints_test.dart new file mode 100644 index 000000000..2702c55f3 --- /dev/null +++ b/test/page/dashboard/models/widget_grid_constraints_test.dart @@ -0,0 +1,208 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/dashboard/models/height_strategy.dart'; +import 'package:privacy_gui/page/dashboard/models/widget_grid_constraints.dart'; + +void main() { + group('WidgetGridConstraints', () { + test('Constructor assertions enforce 12-column limits', () { + expect( + () => WidgetGridConstraints( + minColumns: 0, + maxColumns: 12, + preferredColumns: 4, + heightStrategy: const HeightStrategy.intrinsic()), + throwsAssertionError, + reason: 'minColumns cannot be < 1', + ); + expect( + () => WidgetGridConstraints( + minColumns: 13, + maxColumns: 12, + preferredColumns: 4, + heightStrategy: const HeightStrategy.intrinsic()), + throwsAssertionError, + reason: 'minColumns cannot be > 12', + ); + expect( + () => WidgetGridConstraints( + minColumns: 4, + maxColumns: 2, + preferredColumns: 4, + heightStrategy: const HeightStrategy.intrinsic()), + throwsAssertionError, + reason: 'maxColumns cannot be less than minColumns', + ); + expect( + () => WidgetGridConstraints( + minColumns: 2, + maxColumns: 4, + preferredColumns: 6, + heightStrategy: const HeightStrategy.intrinsic()), + throwsAssertionError, + reason: 'preferredColumns must be within min/max range', + ); + }); + + group('Scaling logic (Based on 12-column system)', () { + final constraints = WidgetGridConstraints( + minColumns: 3, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: const HeightStrategy.intrinsic(), + ); + + test('scaleToMaxColumns translates preferred width correctly', () { + // Desktop (12 cols) -> Should remain 4 + expect(constraints.scaleToMaxColumns(12), 4); + // Tablet (8 cols) -> 4 * 8 / 12 = 2.66 -> round to 3 + expect(constraints.scaleToMaxColumns(8), 3); + // Mobile (4 cols) -> 4 * 4 / 12 = 1.33 -> round to 1 + expect(constraints.scaleToMaxColumns(4), 1); + }); + + test('scaleMinToMaxColumns translates minimum width correctly', () { + // Desktop (12 cols) -> min 3 + expect(constraints.scaleMinToMaxColumns(12), 3); + // Tablet (8 cols) -> 3 * 8 / 12 = 2 + expect(constraints.scaleMinToMaxColumns(8), 2); + // Mobile (4 cols) -> 3 * 4 / 12 = 1 + expect(constraints.scaleMinToMaxColumns(4), 1); + }); + + test('scaleMaxToMaxColumns translates maximum width correctly', () { + // Desktop (12 cols) -> max 6 + expect(constraints.scaleMaxToMaxColumns(12), 6); + // Tablet (8 cols) -> 6 * 8 / 12 = 4 + expect(constraints.scaleMaxToMaxColumns(8), 4); + // Mobile (4 cols) -> 6 * 4 / 12 = 2 + expect(constraints.scaleMaxToMaxColumns(4), 2); + }); + + test('Scaling should clamp to 1 at minimum', () { + final smallConstraints = WidgetGridConstraints( + minColumns: 1, + maxColumns: 2, + preferredColumns: 1, + heightStrategy: const HeightStrategy.intrinsic(), + ); + // Scale 1 to 4 cols: 1 * 4 / 12 = 0.33 -> round to 0 -> clamp to 1 + expect(smallConstraints.scaleToMaxColumns(4), 1); + }); + }); + + group('HeightStrategy calculations via getPreferredHeightCells', () { + test('Strict (ColumnBased) Strategy returns fixed rows', () { + const strictStrategy = HeightStrategy.strict(4); // multiplier 4 + final constr = WidgetGridConstraints( + minColumns: 1, + maxColumns: 4, + preferredColumns: 2, + heightStrategy: strictStrategy, + ); + + // ColumnBasedHeightStrategy logic: multiplier.ceil() + // Wait, looking at the code: + // ColumnBasedHeightStrategy(:final multiplier) => multiplier.ceil() + // It seems the strategy implementation in getPreferredHeightCells ignores 'columns' input + // and strictly uses the multiplier as the height in cells? + // Let's verify source code behavior. + // Source: `return switch (heightStrategy) { ColumnBasedHeightStrategy(:final multiplier) => multiplier.ceil(), ... }` + // Yes, it returns multiplier.ceil(). + + expect(constr.getPreferredHeightCells(), 4); + }); + + test('AspectRatio Strategy calculates height based on columns', () { + const ratioStrategy = + HeightStrategy.aspectRatio(2.0); // W/H = 2, so H = W/2 + final constr = WidgetGridConstraints( + minColumns: 1, + maxColumns: 12, + preferredColumns: 4, + heightStrategy: ratioStrategy, + ); + + // AspectRatio logic: (cols / ratio).ceil().clamp(1, 12) + // With default preferredColumns = 4: + // H = ceil(4 / 2.0) = 2 + expect(constr.getPreferredHeightCells(), 2); + + // With explicit columns input (e.g. resized width to 6) + // H = ceil(6 / 2.0) = 3 + expect(constr.getPreferredHeightCells(columns: 6), 3); + + // With explicit columns input (e.g. resized width to 9) + // H = ceil(9 / 2.0) = ceil(4.5) = 5 + expect(constr.getPreferredHeightCells(columns: 9), 5); + }); + + test('Intrinsic Strategy returns clamped default range', () { + const intrinsic = HeightStrategy.intrinsic(); + final constr = WidgetGridConstraints( + minColumns: 1, + maxColumns: 4, + preferredColumns: 2, + heightStrategy: intrinsic, + minHeightRows: 1, + ); + + // Source logic: minHeightRows.clamp(2, 6) + // If minHeightRows is 1, it clamps to 2. + expect(constr.getPreferredHeightCells(), 2); + }); + + test('Intrinsic Strategy respects minHeightRows if > 2', () { + const intrinsic = HeightStrategy.intrinsic(); + final constr = WidgetGridConstraints( + minColumns: 1, + maxColumns: 4, + preferredColumns: 2, + heightStrategy: intrinsic, + minHeightRows: 4, + ); + // minHeightRows is 4, so it should return 4 + expect(constr.getPreferredHeightCells(), 4); + }); + }); + + test('getHeightRange returns correct min/max logic', () { + final constr = WidgetGridConstraints( + minColumns: 1, + maxColumns: 4, + preferredColumns: 2, + heightStrategy: const HeightStrategy.intrinsic(), + minHeightRows: 3, + maxHeightRows: 8, + ); + + final (minH, maxH) = constr.getHeightRange(); + expect(minH, 3.0); + expect(maxH, 8.0); + }); + + test('Equality and HashCode', () { + final c1 = WidgetGridConstraints( + minColumns: 2, + maxColumns: 4, + preferredColumns: 3, + heightStrategy: const HeightStrategy.intrinsic(), + ); + final c2 = WidgetGridConstraints( + minColumns: 2, + maxColumns: 4, + preferredColumns: 3, + heightStrategy: const HeightStrategy.intrinsic(), + ); + final c3 = WidgetGridConstraints( + minColumns: 2, + maxColumns: 4, + preferredColumns: 3, + heightStrategy: const HeightStrategy.strict(4), + ); + + expect(c1, equals(c2)); + expect(c1.hashCode, equals(c2.hashCode)); + expect(c1, isNot(equals(c3))); + }); + }); +} diff --git a/test/page/dashboard/providers/layout_item_factory_test.dart b/test/page/dashboard/providers/layout_item_factory_test.dart new file mode 100644 index 000000000..a706aa2f6 --- /dev/null +++ b/test/page/dashboard/providers/layout_item_factory_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/models/height_strategy.dart'; +import 'package:privacy_gui/page/dashboard/models/widget_grid_constraints.dart'; +import 'package:privacy_gui/page/dashboard/models/widget_spec.dart'; +import 'package:privacy_gui/page/dashboard/providers/layout_item_factory.dart'; + +void main() { + group('LayoutItemFactory', () { + const testConstraints = WidgetGridConstraints( + minColumns: 2, + maxColumns: 6, + preferredColumns: 4, + heightStrategy: HeightStrategy.intrinsic(), + minHeightRows: 2, + maxHeightRows: 8, + ); + + const testSpec = WidgetSpec( + id: 'test_widget', + displayName: 'Test Widget', + constraints: { + DisplayMode.normal: testConstraints, + }, + ); + + test('fromSpec converts WidgetSpec to LayoutItem correctly', () { + final item = LayoutItemFactory.fromSpec( + testSpec, + x: 0, + y: 1, + displayMode: DisplayMode.normal, + ); + + expect(item.id, 'test_widget'); + expect(item.x, 0); + expect(item.y, 1); + + // Defaults to preferredColumns (4) and preferredHeight (intrinsic -> minHeightRows 2 clipped to [2,4] -> 2) + // wait intrinsic default logic in WidgetGridConstraints.getPreferredHeightCells was minHeightRows.clamp(2,6) -> 2.clamp(2,6) = 2 + expect(item.w, 4); + expect(item.h, 2); + + expect(item.minW, 2); + expect(item.maxW, 6.0); + expect(item.minH, 2); + expect(item.maxH, 8.0); + }); + + test('fromSpec allows overriding w/h (Scenario A - Manual Override)', () { + final item = LayoutItemFactory.fromSpec( + testSpec, + x: 0, + y: 1, + w: 6, + h: 5, + displayMode: DisplayMode.normal, + ); + + expect(item.w, 6); + expect(item.h, 5); + + // Constraints remain from spec + expect(item.minW, 2); + expect(item.maxW, 6.0); + }); + + test('fromSpec falls back to default if constraints missing', () { + const missingConstraintsSpec = WidgetSpec( + id: 'missing', + displayName: 'Missing', + constraints: {}, // No constraints for normal mode + ); + + final item = LayoutItemFactory.fromSpec(missingConstraintsSpec, + x: 0, y: 0, displayMode: DisplayMode.normal); + + expect(item.w, 4); // Fallback default + expect(item.h, 2); // Fallback default + }); + + group('Default Layout Generation', () { + // Since createDefaultLayout uses hardcoded DashboardWidgetSpecs keys, + // we can mainly verify the structure and presence of key items. + // We can also test the resolver injection. + + test('createDefaultLayout returns items with correct override logic', () { + // We just want to ensure it runs without error and returns non-empty list + // And check specific overrides where we know they happen (e.g. InternetStatus h=2) + + final layout = LayoutItemFactory.createDefaultLayout(); + expect(layout, isNotEmpty); + + // Internet Status Check (Row 0, Col 0, W4, H2) + final internet = + layout.firstWhere((i) => i.id == 'internet_status_only'); + expect(internet.x, 0); + expect(internet.y, 0); + expect(internet.w, 4); + expect(internet.h, 2, + reason: "Designer override should force height 2"); + + // Master Node (Row 0, Col 4, W4, H4) + final master = layout.firstWhere((i) => i.id == 'master_node_info'); + expect(master.x, 4); + expect(master.y, 0); + expect(master.h, 4); + }); + + test('createDefaultLayout uses specResolver for injection', () { + // Mock a resolver that changes constraints for one widget + // We'll mimic the Ports widget dynamic change case + + const modifiedConstraints = WidgetGridConstraints( + minColumns: 2, + maxColumns: 12, + preferredColumns: 8, // Force wider + heightStrategy: HeightStrategy.intrinsic(), + ); + + WidgetSpec myResolver(WidgetSpec spec) { + if (spec.id == 'ports') { + return WidgetSpec( + id: spec.id, + displayName: spec.displayName, + constraints: {DisplayMode.normal: modifiedConstraints}); + } + return spec; + } + + final layout = + LayoutItemFactory.createDefaultLayout(specResolver: myResolver); + final ports = layout.firstWhere((i) => i.id == 'ports'); + + // It should use our injected constraint's preferredColumns + expect(ports.w, 8); + }); + }); + }); +} diff --git a/test/page/dashboard/providers/sliver_dashboard_controller_test.dart b/test/page/dashboard/providers/sliver_dashboard_controller_test.dart new file mode 100644 index 000000000..b75846c77 --- /dev/null +++ b/test/page/dashboard/providers/sliver_dashboard_controller_test.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Import internal dependencies - Assuming path correctness based on file structure +import 'package:privacy_gui/page/dashboard/models/display_mode.dart'; +import 'package:privacy_gui/page/dashboard/models/height_strategy.dart'; +import 'package:privacy_gui/page/dashboard/models/widget_grid_constraints.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/providers/sliver_dashboard_controller_provider.dart'; + +// Mocks +class MockRef extends Mock implements Ref {} +// We don't need to mock DashboardController because it's the state being managed. +// But we might need to mock DashboardHomeState for provider reading. + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Fake DashboardHomeState + final fakeDashboardState = + DashboardHomeState(lanPortConnections: [], isHorizontalLayout: true); + + group('SliverDashboardController Integration', () { + late MockRef mockRef; + + setUp(() { + SharedPreferences.setMockInitialValues({}); // Reset prefs + mockRef = MockRef(); + + // Stub ref.read calls for dashboardHomeProvider + // Note: StateNotifierProvider.read is deprecated in favor of ref.read(provider) + // The controller implementation uses: _ref.read(dashboardHomeProvider) + when(() => mockRef.read(dashboardHomeProvider)) + .thenReturn(fakeDashboardState); + + // Stub ref.read for other internal calls if any? + // Looking at source: only dashboardHomeProvider is read. + }); + + test('Initializes with default layout when no saved data', () async { + // Create controller + final notifier = SliverDashboardControllerNotifier(mockRef); + // Wait for async init (it's called in constructor but async work happens) + // _initializeLayout is async but called in constructor without await. + // We must wait for microtasks. + await Future.delayed(Duration.zero); + + final state = notifier.state; // debugState acts as 'state' in tests + + expect(state.exportLayout(), isNotEmpty); + // Verify default layout content (e.g. InternetStatus is present) + final hasInternet = state + .exportLayout() + .any((i) => (i as Map)['id'] == 'internet_status_only'); + expect(hasInternet, isTrue); + }); + + test('Initializes with saved layout from SharedPreferences', () async { + // Pre-populate prefs + final savedLayout = [ + { + 'id': 'custom_w', + 'x': 0, + 'y': 0, + 'w': 4, + 'h': 2, + 'minW': 1, + 'maxW': 12, + 'minH': 1, + 'maxH': 12 + } + ]; + SharedPreferences.setMockInitialValues( + {'sliver_dashboard_layout': jsonEncode(savedLayout)}); + + final notifier = SliverDashboardControllerNotifier(mockRef); + await Future.delayed(Duration.zero); + + final state = notifier.state; + final layout = state.exportLayout(); + + expect(layout.length, 1); + expect((layout.first as Map)['id'], 'custom_w'); + }); + + test('addWidget appends item and saves to storage', () async { + final notifier = SliverDashboardControllerNotifier(mockRef); + await Future.delayed(Duration.zero); + + final initialCount = notifier.state.exportLayout().length; + + // Add a widget that definitely isn't in default layout? + // Default layout uses almost all widgets. + // Let's remove one first, then add it back. + await notifier.removeWidget('internet_status_only'); + final afterRemoveCount = notifier.state.exportLayout().length; + expect(afterRemoveCount, initialCount - 1); + + // Verify remove saved to prefs + final prefs = await SharedPreferences.getInstance(); + var savedJson = prefs.getString('sliver_dashboard_layout'); + expect(savedJson, isNotNull); + expect(savedJson, isNot(contains('internet_status_only'))); + + // Add back + await notifier.addWidget('internet_status_only'); + + final finalLayout = notifier.state.exportLayout(); + expect(finalLayout.length, initialCount); + + // Verify persistence + savedJson = prefs.getString('sliver_dashboard_layout'); + expect(savedJson, contains('internet_status_only')); + }); + + test('updateItemConstraints modifies constraints and clamps width', + () async { + final notifier = SliverDashboardControllerNotifier(mockRef); + await Future.delayed(Duration.zero); + + // Find internet status (usually w=4) + // Let's update its mode to Expanded (suppose expanded has minW=8) + // Need a real spec. 'internet_status_only' normal is min 2, max 12. + // Let's create a custom constraint override for testing. + + // Override: minW 8. Current W 4. Should clamp to 8. + await notifier.updateItemConstraints('internet_status_only', + DisplayMode.normal, // Mode irrelevant if overriding + overrideConstraints: const WidgetGridConstraints( + minColumns: 8, + maxColumns: 12, + preferredColumns: 8, + heightStrategy: HeightStrategy.intrinsic())); + + final layout = notifier.state.exportLayout(); + final item = layout + .firstWhere((i) => (i as Map)['id'] == 'internet_status_only') as Map; + + expect(item['minW'], 8); + expect(item['w'], 8, reason: "Should have clamped width 4 up to minW 8"); + + // Check persistence + final prefs = await SharedPreferences.getInstance(); + final savedJson = prefs.getString('sliver_dashboard_layout'); + expect(savedJson, contains('"w":8')); // Should verify encoded value + }); + + test('resetLayout restores defaults and clears storage', () async { + // 1. Create and Modify + final notifier = SliverDashboardControllerNotifier(mockRef); + await Future.delayed(Duration.zero); + await notifier.removeWidget('internet_status_only'); + + // Verify modified + expect( + notifier.state + .exportLayout() + .any((i) => (i as Map)['id'] == 'internet_status_only'), + isFalse); + + // 2. Reset + await notifier.resetLayout(); + + // 3. Verify Default Restored + expect( + notifier.state + .exportLayout() + .any((i) => (i as Map)['id'] == 'internet_status_only'), + isTrue); + + // 4. Verify Storage Cleared + final prefs = await SharedPreferences.getInstance(); + expect(prefs.containsKey('sliver_dashboard_layout'), isFalse); + }); + }); +} diff --git a/test/page/health_check/views/localizations/speed_test_view_test.dart b/test/page/health_check/views/localizations/speed_test_view_test.dart index d808dae0f..abea3d255 100644 --- a/test/page/health_check/views/localizations/speed_test_view_test.dart +++ b/test/page/health_check/views/localizations/speed_test_view_test.dart @@ -154,9 +154,8 @@ void main() { // The following line will fail if `speedTestResultUltra` is not in your localization files. expect(find.text(testHelper.loc(context).speedUltraDescription), findsOneWidget); - expect( - find.widgetWithText(AppButton, testHelper.loc(context).testAgain), - findsOneWidget); + // Implementation uses IconButton with replay icon instead of AppButton with text + expect(find.byIcon(Icons.replay), findsOneWidget); }, goldenFilename: 'STV-SUCCESS-01-ultra', helper: testHelper, @@ -251,8 +250,8 @@ void main() { expect(find.text(testHelper.loc(context).speedTestConfigurationError), findsOneWidget); - expect(find.widgetWithText(AppButton, testHelper.loc(context).testAgain), - findsOneWidget); + // Implementation uses IconButton with replay icon instead of AppButton with text + expect(find.byIcon(Icons.replay), findsOneWidget); }, goldenFilename: 'STV-ERROR-01-error_state', helper: testHelper, diff --git a/test/page/instant_setup/localizations/pnp_setup_view_test.dart b/test/page/instant_setup/localizations/pnp_setup_view_test.dart index 87c4bfb11..7a01e79c7 100644 --- a/test/page/instant_setup/localizations/pnp_setup_view_test.dart +++ b/test/page/instant_setup/localizations/pnp_setup_view_test.dart @@ -125,6 +125,9 @@ class FakePnpNotifier extends BasePnpNotifier { @override ({String name, String password}) getDefaultGuestWiFiNameAndPassPhrase() => (name: 'MyGuestWiFi', password: 'MyGuestPassword'); + + @override + bool get isLoggedIn => true; // Default to logged in for tests } void main() { diff --git a/test/page/instant_setup/providers/pnp_auth_test.dart b/test/page/instant_setup/providers/pnp_auth_test.dart new file mode 100644 index 000000000..95aa0dc58 --- /dev/null +++ b/test/page/instant_setup/providers/pnp_auth_test.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/instant_setup/providers/pnp_provider.dart'; +import 'package:privacy_gui/providers/auth/auth_provider.dart'; +import 'package:privacy_gui/providers/auth/auth_state.dart'; +import 'package:privacy_gui/providers/auth/auth_types.dart'; + +class MockAuthNotifier extends AuthNotifier { + final AuthState _initialState; + + MockAuthNotifier(this._initialState); + + @override + Future build() { + return Future.value(_initialState); + } +} + +void main() { + group('PnpNotifier Auth Logic', () { + test('isLoggedIn returns false when LoginType is none', () async { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => MockAuthNotifier(AuthState(loginType: LoginType.none)), + ), + ], + ); + + await container.read(authProvider.future); + final notifier = container.read(pnpProvider.notifier); + expect(notifier.isLoggedIn, isFalse); + }); + + test('isLoggedIn returns true when LoginType is local', () async { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => MockAuthNotifier(AuthState(loginType: LoginType.local)), + ), + ], + ); + + await container.read(authProvider.future); + final notifier = container.read(pnpProvider.notifier); + expect(notifier.isLoggedIn, isTrue); + }); + + test('isLoggedIn returns true when LoginType is remote', () async { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => MockAuthNotifier(AuthState(loginType: LoginType.remote)), + ), + ], + ); + + await container.read(authProvider.future); + final notifier = container.read(pnpProvider.notifier); + expect(notifier.isLoggedIn, isTrue); + }); + + // Removed incomplete test case for null/loading state as simple overrides are sufficient + }); +} diff --git a/test/page/login/auto_parent/services/auto_parent_first_login_service_test.dart b/test/page/login/auto_parent/services/auto_parent_first_login_service_test.dart index 2e53e1914..f58058dee 100644 --- a/test/page/login/auto_parent/services/auto_parent_first_login_service_test.dart +++ b/test/page/login/auto_parent/services/auto_parent_first_login_service_test.dart @@ -7,6 +7,8 @@ import 'package:privacy_gui/core/jnap/result/jnap_result.dart'; import 'package:privacy_gui/core/jnap/router_repository.dart'; import 'package:privacy_gui/page/login/auto_parent/services/auto_parent_first_login_service.dart'; +import 'package:privacy_gui/core/retry_strategy/retry.dart'; // import RetryStrategy + import '../../../../mocks/test_data/auto_parent_first_login_test_data.dart'; // Mocks @@ -24,7 +26,16 @@ void main() { setUp(() { mockRepository = MockRouterRepository(); - service = AutoParentFirstLoginService(mockRepository); + // Use LinearBackoffRetryStrategy with 0 delay for testing to speed up tests + final testRetryStrategy = LinearBackoffRetryStrategy( + maxRetries: 5, + initialDelay: Duration.zero, + increment: Duration.zero, + ); + service = AutoParentFirstLoginService( + mockRepository, + retryStrategy: testRetryStrategy, + ); }); // =========================================================================== diff --git a/test/page/login/localizations/login_local_view_test.dart b/test/page/login/localizations/login_local_view_test.dart index 921c5ca01..4a5d2f168 100644 --- a/test/page/login/localizations/login_local_view_test.dart +++ b/test/page/login/localizations/login_local_view_test.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; -import 'package:privacy_gui/core/jnap/models/device_info.dart'; +import 'package:privacy_gui/core/jnap/models/jnap_device_info_raw.dart'; import 'package:privacy_gui/page/login/views/login_local_view.dart'; import 'package:privacy_gui/providers/auth/auth_provider.dart'; import 'package:privacy_gui/route/route_model.dart'; @@ -28,7 +28,8 @@ void main() async { when(testHelper.mockSessionNotifier.checkDeviceInfo(null)).thenAnswer( (_) async => - NodeDeviceInfo.fromJson(jsonDecode(testDeviceInfo)['output']), + JnapDeviceInfoRaw.fromJson(jsonDecode(testDeviceInfo)['output']) + .toUIModel(), ); when(testHelper.mockAuthNotifier.build()).thenAnswer( (_) async => Future.value( @@ -37,7 +38,7 @@ void main() async { ); when(testHelper.mockAuthNotifier.getPasswordHint()) .thenAnswer((_) async {}); - when(testHelper.mockAuthNotifier.getAdminPasswordAuthStatus(any)) + when(testHelper.mockAuthNotifier.getAdminPasswordAuthStatus()) .thenAnswer((_) async => null); }); @@ -120,7 +121,7 @@ void main() async { testLocalizations( 'login local view - error countdown', (tester, screen) async { - when(testHelper.mockAuthNotifier.getAdminPasswordAuthStatus(any)) + when(testHelper.mockAuthNotifier.getAdminPasswordAuthStatus()) .thenAnswer((_) async { return { 'attemptsRemaining': 4, @@ -161,7 +162,7 @@ void main() async { testLocalizations( 'login local view - lockout message when attempts exhausted', (tester, screen) async { - when(testHelper.mockAuthNotifier.getAdminPasswordAuthStatus(any)) + when(testHelper.mockAuthNotifier.getAdminPasswordAuthStatus()) .thenAnswer((_) async { return { 'attemptsRemaining': 0, @@ -198,7 +199,7 @@ void main() async { 'login local view - generic incorrect password error', (tester, screen) async { // As long as one of the two parameters is missing, it will display generic incorrect password error - when(testHelper.mockAuthNotifier.getAdminPasswordAuthStatus(any)) + when(testHelper.mockAuthNotifier.getAdminPasswordAuthStatus()) .thenAnswer((_) async { return { 'attemptsRemaining': null, diff --git a/test/page/nodes/localizations/node_detail_view_test.dart b/test/page/nodes/localizations/node_detail_view_test.dart index 2bc744a0f..0fadb01bf 100644 --- a/test/page/nodes/localizations/node_detail_view_test.dart +++ b/test/page/nodes/localizations/node_detail_view_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; -import 'package:privacy_gui/core/jnap/models/node_light_settings.dart'; +import 'package:privacy_gui/page/nodes/providers/node_light_state.dart'; import 'package:privacy_gui/core/data/providers/firmware_update_state.dart'; import 'package:privacy_gui/core/utils/icon_rules.dart'; import 'package:privacy_gui/page/instant_device/providers/device_filtered_list_provider.dart'; @@ -205,8 +205,8 @@ void main() { 'node detail view - node light settings dialog', (tester, screen) async { when(testHelper.mockNodeLightSettingsNotifier.build()).thenReturn( - const NodeLightSettings( - isNightModeEnable: true, + const NodeLightState( + isNightModeEnabled: true, startHour: 20, endHour: 8, allDayOff: false, diff --git a/test/page/nodes/services/add_nodes_service_test.dart b/test/page/nodes/services/add_nodes_service_test.dart index b911439f8..04421de1b 100644 --- a/test/page/nodes/services/add_nodes_service_test.dart +++ b/test/page/nodes/services/add_nodes_service_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/data/providers/device_manager_state.dart'; diff --git a/test/page/wifi_settings/providers/displayed_mac_filtering_devices_provider_test.dart b/test/page/wifi_settings/providers/displayed_mac_filtering_devices_provider_test.dart index 5cc8c951c..a372922de 100644 --- a/test/page/wifi_settings/providers/displayed_mac_filtering_devices_provider_test.dart +++ b/test/page/wifi_settings/providers/displayed_mac_filtering_devices_provider_test.dart @@ -1,8 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:privacy_gui/page/instant_device/providers/device_list_state.dart'; -import 'package:privacy_gui/page/instant_device/providers/device_list_provider.dart'; +import 'package:privacy_gui/core/data/providers/device_manager_provider.dart'; +import 'package:privacy_gui/core/data/providers/device_manager_state.dart'; +import 'package:privacy_gui/core/jnap/models/device.dart'; +import 'package:privacy_gui/core/models/device_list_item.dart'; import 'package:privacy_gui/page/instant_privacy/providers/instant_privacy_state.dart'; import 'package:privacy_gui/page/wifi_settings/providers/displayed_mac_filtering_devices_provider.dart'; import 'package:privacy_gui/page/wifi_settings/providers/guest_wifi_item.dart'; @@ -16,10 +18,62 @@ import 'package:privacy_gui/providers/preservable.dart'; class MockWifiSettingsService extends Mock implements WifiSettingsService {} +// Mock notifiers for providers +class _MockDeviceManagerNotifier extends DeviceManagerNotifier { + final DeviceManagerState _state; + + _MockDeviceManagerNotifier(this._state); + + @override + DeviceManagerState build() => _state; +} + +class _MockWifiBundleNotifier extends WifiBundleNotifier { + final WifiBundleState _state; + + _MockWifiBundleNotifier(this._state); + + @override + WifiBundleState build() => _state; +} + void main() { late ProviderContainer container; late MockWifiSettingsService mockService; + // Helper to create LinksysDevice for mocking deviceManagerProvider + LinksysDevice createLinksysDevice({ + required String macAddress, + required String name, + required bool isWired, + }) { + // Mimic the minimal properties needed by the provider + // connectionType: 'wired' or 'wireless' (convention used in LinksysDevice) + // isOnline logic typically checks for existence of connections or similar property + // For this test, we assume connectionType logic is sufficient based on provider implementation + + // Construct a minimal LinksysDevice map and use fromMap, or manual constructor if public + // LinksysDevice constructor is public. + return LinksysDevice( + connections: const [], + properties: const [], + unit: const RawDeviceUnit(serialNumber: '', firmwareVersion: ''), + deviceID: macAddress, // Use MAC as ID for simplicity + maxAllowedProperties: 10, + model: const RawDeviceModel( + deviceType: 'Computer', // Required + modelDescription: '', + hardwareVersion: '', + manufacturer: '', + modelNumber: ''), + isAuthority: false, + lastChangeRevision: 1, + friendlyName: name, + knownMACAddresses: [macAddress], + connectionType: isWired ? 'wired' : 'wireless', + ); + } + WiFiItem createWifiItem() { return const WiFiItem( radioID: WifiRadioBand.radio_5_1, @@ -98,8 +152,8 @@ void main() { container = ProviderContainer( overrides: [ wifiSettingsServiceProvider.overrideWithValue(mockService), - deviceListProvider.overrideWith(() => - _MockDeviceListNotifier(const DeviceListState(devices: []))), + deviceManagerProvider.overrideWith( + () => _MockDeviceManagerNotifier(const DeviceManagerState())), wifiBundleProvider.overrideWith( () => _MockWifiBundleNotifier(createWifiBundleState())), ], @@ -117,7 +171,7 @@ void main() { }); test('calls service with correct parameters', () { - const testDevice = DeviceListItem( + final testDevice = createLinksysDevice( macAddress: 'AA:BB:CC:DD:EE:FF', name: 'TestDevice', isWired: false, @@ -126,8 +180,8 @@ void main() { container = ProviderContainer( overrides: [ wifiSettingsServiceProvider.overrideWithValue(mockService), - deviceListProvider.overrideWith(() => _MockDeviceListNotifier( - const DeviceListState(devices: [testDevice]))), + deviceManagerProvider.overrideWith(() => _MockDeviceManagerNotifier( + DeviceManagerState(deviceList: [testDevice]))), wifiBundleProvider .overrideWith(() => _MockWifiBundleNotifier(createWifiBundleState( denyMacAddresses: ['11:22:33:44:55:66'], @@ -152,12 +206,12 @@ void main() { }); test('filters out wired devices', () { - const wirelessDevice = DeviceListItem( + final wirelessDevice = createLinksysDevice( macAddress: 'AA:BB:CC:DD:EE:FF', name: 'WirelessDevice', isWired: false, ); - const wiredDevice = DeviceListItem( + final wiredDevice = createLinksysDevice( macAddress: '11:22:33:44:55:66', name: 'WiredDevice', isWired: true, @@ -166,8 +220,8 @@ void main() { container = ProviderContainer( overrides: [ wifiSettingsServiceProvider.overrideWithValue(mockService), - deviceListProvider.overrideWith(() => _MockDeviceListNotifier( - const DeviceListState(devices: [wirelessDevice, wiredDevice]))), + deviceManagerProvider.overrideWith(() => _MockDeviceManagerNotifier( + DeviceManagerState(deviceList: [wirelessDevice, wiredDevice]))), wifiBundleProvider.overrideWith( () => _MockWifiBundleNotifier(createWifiBundleState())), ], @@ -181,12 +235,17 @@ void main() { container.read(macFilteringDeviceListProvider); - // Verify only wireless device is passed - verify(() => mockService.getFilteredDeviceList( - allDevices: [wirelessDevice], + // capture the argument passed to allDevices + final captured = verify(() => mockService.getFilteredDeviceList( + allDevices: captureAny(named: 'allDevices'), macAddresses: any(named: 'macAddresses'), bssidList: any(named: 'bssidList'), - )).called(1); + )).captured; + + final passedDevices = captured.first as List; + expect(passedDevices.length, 1); + expect(passedDevices.first.name, 'WirelessDevice'); + expect(passedDevices.first.macAddress, 'AA:BB:CC:DD:EE:FF'); }); test('returns service result', () { @@ -198,8 +257,8 @@ void main() { container = ProviderContainer( overrides: [ wifiSettingsServiceProvider.overrideWithValue(mockService), - deviceListProvider.overrideWith(() => - _MockDeviceListNotifier(const DeviceListState(devices: []))), + deviceManagerProvider.overrideWith( + () => _MockDeviceManagerNotifier(const DeviceManagerState())), wifiBundleProvider.overrideWith( () => _MockWifiBundleNotifier(createWifiBundleState())), ], @@ -218,22 +277,3 @@ void main() { }); }); } - -// Mock notifiers for providers -class _MockDeviceListNotifier extends DeviceListNotifier { - final DeviceListState _state; - - _MockDeviceListNotifier(this._state); - - @override - DeviceListState build() => _state; -} - -class _MockWifiBundleNotifier extends WifiBundleNotifier { - final WifiBundleState _state; - - _MockWifiBundleNotifier(this._state); - - @override - WifiBundleState build() => _state; -} diff --git a/test/providers/auth/auth_provider_test.dart b/test/providers/auth/auth_provider_test.dart index a04b1256a..0893fbc5b 100644 --- a/test/providers/auth/auth_provider_test.dart +++ b/test/providers/auth/auth_provider_test.dart @@ -396,21 +396,19 @@ void main() { SharedPreferences.setMockInitialValues({}); final container = makeProviderContainer(); - final testServices = ['service1', 'service2']; final testStatus = {'isAdminPasswordDefault': false}; - when(() => mockAuthService.getAdminPasswordAuthStatus(testServices)) + when(() => mockAuthService.getAdminPasswordAuthStatus()) .thenAnswer((_) async => AuthSuccess(testStatus)); // Act final status = await container .read(authProvider.notifier) - .getAdminPasswordAuthStatus(testServices); + .getAdminPasswordAuthStatus(); // Assert expect(status, testStatus); - verify(() => mockAuthService.getAdminPasswordAuthStatus(testServices)) - .called(1); + verify(() => mockAuthService.getAdminPasswordAuthStatus()).called(1); container.dispose(); });