From fa54bc7913110a93d058e11c0cf505d98c3658f8 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Mon, 15 Jun 2026 17:01:20 +0800 Subject: [PATCH 1/7] refactor(errors): preserve diagnostic code/detail through ServiceError Error info (fault code + raw message) was lost when errors converged into ServiceError, on both conversion paths: - Path 1 (mapUspErrorToServiceError): empty-constructor subtypes like ResourceNotFoundError/UnauthorizedError dropped both code and message. - Path 2 (UspResultParser -> Usp*FailureError): kept only joined summary + failedPaths, discarding per-path errorCode/errorMessage. Changes: - ServiceError base class gains optional `code` (int?) and `detail` (String?) diagnostic fields; all subtypes accept them via super (except ServiceSideEffectError, a success-with-side-effect type). - Remove the per-subtype `message` field from InvalidInputError/NetworkError/ ConnectivityError/UnexpectedError/ServiceNotInitializedError and unify on the base `detail`; toString() now reads `detail`. InvalidInputError keeps `field`, UnexpectedError keeps `originalError`. - mapUspErrorToServiceError now passes code (faultCode/httpStatus) + detail into every mapped ServiceError. - UspCompleteFailureError/UspPartialFailureError store the full List failures (path + code + message); `failedPaths` becomes a derived getter for backward compatibility. - Mechanical caller updates across 14 services + tests: message: -> detail:, failedPaths: -> failures:, and .message reads -> .detail (login flow, diagnostics notifiers). --- lib/core/errors/service_error.dart | 104 ++++++++----- .../session/services/session_service.dart | 6 +- lib/core/usp/errors/usp_error.dart | 139 ++++++++++++------ .../admin/services/usp_admin_service.dart | 12 +- .../usp_system_info_data_service.dart | 2 +- .../admin/services/usp_time_data_service.dart | 2 +- .../orchestrator/dashboard_orchestrator.dart | 2 +- .../services/usp_devices_data_service.dart | 2 +- lib/page/dhcp/services/usp_dhcp_service.dart | 18 +-- lib/page/dmz/services/usp_dmz_service.dart | 8 +- .../services/usp_firewall_data_service.dart | 2 +- .../services/usp_firewall_service.dart | 4 +- .../firmware_local_upload_service.dart | 2 +- .../services/firmware_ws_upload_strategy.dart | 10 +- .../services/usp_firmware_update_service.dart | 12 +- .../services/instant_privacy_service.dart | 12 +- .../services/instant_safety_service.dart | 4 +- .../usp_internet_settings_notifier.dart | 7 +- .../usp_internet_settings_service.dart | 13 +- .../services/usp_wan_data_service.dart | 2 +- .../usp_ipv6_port_service_service.dart | 6 +- .../services/usp_dhcp_data_service.dart | 2 +- .../services/usp_ethernet_data_service.dart | 2 +- .../services/usp_lan_data_service.dart | 2 +- .../services/usp_local_network_service.dart | 4 +- lib/page/login/views/login_local_view.dart | 4 +- .../usp_port_forwarding_data_service.dart | 2 +- .../services/usp_port_forwarding_service.dart | 18 +-- .../usp_port_triggering_data_service.dart | 2 +- .../services/usp_static_routing_service.dart | 6 +- .../providers/manual_tools_notifier.dart | 14 +- .../providers/speed_test_notifier.dart | 6 +- .../unified_diagnostics_notifier.dart | 2 +- .../services/unified_diagnostics_service.dart | 2 +- .../providers/usp_wifi_settings_provider.dart | 2 +- .../services/usp_wifi_data_service.dart | 2 +- .../services/usp_wifi_settings_service.dart | 36 ++--- test/core/errors/service_error_test.dart | 12 +- .../providers/session_notifier_test.dart | 6 +- test/core/usp/errors/usp_error_test.dart | 21 +++ .../providers/usp_admin_notifier_test.dart | 6 +- .../usp_dhcp_reservations_notifier_test.dart | 10 +- .../dmz/providers/usp_dmz_notifier_test.dart | 4 +- .../providers/usp_firewall_notifier_test.dart | 4 +- .../firmware_update_notifier_test.dart | 6 +- .../firmware_http_upload_strategy_test.dart | 2 +- .../firmware_local_upload_service_test.dart | 2 +- .../instant_privacy_notifier_test.dart | 4 +- .../instant_safety_provider_test.dart | 2 +- .../usp_internet_settings_notifier_test.dart | 4 +- .../usp_ipv6_port_service_notifier_test.dart | 4 +- .../usp_local_network_notifier_test.dart | 4 +- ...sp_port_forwarding_page_notifier_test.dart | 4 +- .../usp_static_routing_notifier_test.dart | 4 +- .../usp_system_log_notifier_test.dart | 2 +- .../diagnostics_scope_service_test.dart | 4 +- .../usp_wifi_advanced_notifier_test.dart | 4 +- .../usp_wifi_settings_notifier_test.dart | 6 +- 58 files changed, 348 insertions(+), 242 deletions(-) diff --git a/lib/core/errors/service_error.dart b/lib/core/errors/service_error.dart index d6715616d..1939d4d17 100644 --- a/lib/core/errors/service_error.dart +++ b/lib/core/errors/service_error.dart @@ -1,3 +1,6 @@ +import 'package:privacy_gui/core/usp/models/usp_operation_result.dart' + show UspErrorDetail; + /// Unified service error hierarchy for all data sources. /// /// This sealed class serves as the contract between Service layer and Provider layer. @@ -28,7 +31,19 @@ /// } /// ``` sealed class ServiceError implements Exception { - const ServiceError(); + /// Diagnostic raw fault code (firmware 7xxx/9xxx, WASM 9999, codegen 9998…). + /// For logging/debugging only — `null` when there is no code. + final int? code; + + /// Raw technical message (firmware text / WASM string / error identifier). + /// + /// Primarily for logging/debugging. For most subtypes the UI derives a + /// localized message from the subtype itself and ignores [detail]. The + /// exception is fallback types like [UnexpectedError] that carry no + /// type-specific semantics — there the UI may surface [detail] directly. + final String? detail; + + const ServiceError({this.code, this.detail}); /// Human-readable label derived from the class name. /// @@ -63,27 +78,27 @@ sealed class ServiceError implements Exception { /// User not authenticated final class NotAuthenticatedError extends ServiceError { - const NotAuthenticatedError(); + const NotAuthenticatedError({super.code, super.detail}); } /// Session token is invalid or expired final class InvalidSessionTokenError extends ServiceError { - const InvalidSessionTokenError(); + const InvalidSessionTokenError({super.code, super.detail}); } /// Session token has expired and cannot be refreshed final class SessionTokenExpiredError extends ServiceError { - const SessionTokenExpiredError(); + const SessionTokenExpiredError({super.code, super.detail}); } /// Invalid credentials (username/password combination) final class InvalidCredentialsError extends ServiceError { - const InvalidCredentialsError(); + const InvalidCredentialsError({super.code, super.detail}); } /// Unauthorized access attempt final class UnauthorizedError extends ServiceError { - const UnauthorizedError(); + const UnauthorizedError({super.code, super.detail}); } // ============================================================================ @@ -92,7 +107,7 @@ final class UnauthorizedError extends ServiceError { /// Requested resource not found final class ResourceNotFoundError extends ServiceError { - const ResourceNotFoundError(); + const ResourceNotFoundError({super.code, super.detail}); } // ============================================================================ @@ -101,12 +116,12 @@ final class ResourceNotFoundError extends ServiceError { /// Invalid OTP code final class InvalidOtpError extends ServiceError { - const InvalidOtpError(); + const InvalidOtpError({super.code, super.detail}); } /// OTP code has expired final class ExpiredOtpError extends ServiceError { - const ExpiredOtpError(); + const ExpiredOtpError({super.code, super.detail}); } // ============================================================================ @@ -115,23 +130,24 @@ final class ExpiredOtpError extends ServiceError { /// Admin account is locked final class AdminAccountLockedError extends ServiceError { - const AdminAccountLockedError(); + const AdminAccountLockedError({super.code, super.detail}); } /// Invalid reset code provided final class InvalidResetCodeError extends ServiceError { final int? attemptsRemaining; - const InvalidResetCodeError({this.attemptsRemaining}); + const InvalidResetCodeError( + {this.attemptsRemaining, super.code, super.detail}); } /// Too many consecutive invalid reset code attempts final class ConsecutiveInvalidResetCodeError extends ServiceError { - const ConsecutiveInvalidResetCodeError(); + const ConsecutiveInvalidResetCodeError({super.code, super.detail}); } /// Invalid admin password final class InvalidAdminPasswordError extends ServiceError { - const InvalidAdminPasswordError(); + const InvalidAdminPasswordError({super.code, super.detail}); } // ============================================================================ @@ -144,55 +160,55 @@ final class InvalidAdminPasswordError extends ServiceError { /// initialized (e.g. non-Web platform or WASM not loaded). This is a setup /// error, not a network connectivity issue. final class ServiceNotInitializedError extends ServiceError { - final String? message; - const ServiceNotInitializedError({this.message}); + const ServiceNotInitializedError({super.code, super.detail}); @override String toString() => - message != null ? 'Service not initialized: $message' : super.toString(); + detail != null ? 'Service not initialized: $detail' : super.toString(); } /// Invalid input data final class InvalidInputError extends ServiceError { final String? field; - final String? message; - const InvalidInputError({this.field, this.message}); + const InvalidInputError({this.field, super.code, super.detail}); @override String toString() { - final detail = [ + final parts = [ if (field != null) field, - if (message != null) message, + if (detail != null) detail, ].join(': '); - return detail.isNotEmpty ? 'Invalid input: $detail' : super.toString(); + return parts.isNotEmpty ? 'Invalid input: $parts' : super.toString(); } } -/// Unexpected error (fallback for unmapped errors) +/// Unexpected error (fallback for unmapped errors). +/// +/// This is the one type whose semantics the UI cannot derive from the type +/// alone, so [detail] is meant to be surfaced to the user / used by callers +/// (e.g. the local-login flow reads [detail] as an error-code identifier). final class UnexpectedError extends ServiceError { final Object? originalError; - final String? message; - const UnexpectedError({this.originalError, this.message}); + const UnexpectedError({this.originalError, super.code, super.detail}); @override String toString() => - message != null ? 'Unexpected error: $message' : super.toString(); + detail != null ? 'Unexpected error: $detail' : super.toString(); } /// Network communication error final class NetworkError extends ServiceError { - final String? message; - const NetworkError({this.message}); + const NetworkError({super.code, super.detail}); @override String toString() => - message != null ? 'Network error: $message' : super.toString(); + detail != null ? 'Network error: $detail' : super.toString(); } /// Storage operation error final class StorageError extends ServiceError { final Object? originalError; - const StorageError({this.originalError}); + const StorageError({this.originalError, super.code, super.detail}); } // ============================================================================ @@ -202,13 +218,21 @@ final class StorageError extends ServiceError { /// USP operation failed completely (all parameters failed) final class UspCompleteFailureError extends ServiceError { final String summary; - final List failedPaths; + + /// Full per-path diagnostics (path + errorCode + errorMessage), retained so + /// the UI can later derive a localized message from each [UspErrorDetail]. + final List failures; const UspCompleteFailureError({ required this.summary, - required this.failedPaths, + required this.failures, + super.code, + super.detail, }); + /// Backward-compatible: the failed TR-181 paths. + List get failedPaths => failures.map((f) => f.requestedPath).toList(); + @override String toString() => summary; } @@ -217,14 +241,21 @@ final class UspCompleteFailureError extends ServiceError { final class UspPartialFailureError extends ServiceError { final String summary; final List successPaths; - final List failedPaths; + + /// Full per-path diagnostics for the failed entries. + final List failures; const UspPartialFailureError({ required this.summary, required this.successPaths, - required this.failedPaths, + required this.failures, + super.code, + super.detail, }); + /// Backward-compatible: the failed TR-181 paths. + List get failedPaths => failures.map((f) => f.requestedPath).toList(); + @override String toString() => '(Partial) $summary'; } @@ -238,17 +269,16 @@ final class SerialNumberMismatchError extends ServiceError { final String expected; final String actual; const SerialNumberMismatchError( - {required this.expected, required this.actual}); + {required this.expected, required this.actual, super.code, super.detail}); } /// Router connectivity error (cannot reach router) final class ConnectivityError extends ServiceError { - final String? message; - const ConnectivityError({this.message}); + const ConnectivityError({super.code, super.detail}); @override String toString() => - message != null ? 'Connectivity error: $message' : super.toString(); + detail != null ? 'Connectivity error: $detail' : super.toString(); } // ============================================================================ diff --git a/lib/core/session/services/session_service.dart b/lib/core/session/services/session_service.dart index 56680e94c..0d8c1ef0a 100644 --- a/lib/core/session/services/session_service.dart +++ b/lib/core/session/services/session_service.dart @@ -63,11 +63,11 @@ class SessionService { if (_usp == null) { logger.e('[SessionService]: USP not available'); throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } if (!_usp.isAuthenticated) { logger.d('[SessionService]: USP not authenticated'); - throw const ConnectivityError(message: 'USP not authenticated'); + throw const ConnectivityError(detail: 'USP not authenticated'); } try { final systemInfo = await SystemInfo.fetch(_usp); @@ -75,7 +75,7 @@ class SessionService { return NodeDeviceInfo.fromUsp(systemInfo); } catch (e) { logger.e('[SessionService]: USP device info fetch failed: $e'); - throw ConnectivityError(message: e.toString()); + throw ConnectivityError(detail: e.toString()); } } } diff --git a/lib/core/usp/errors/usp_error.dart b/lib/core/usp/errors/usp_error.dart index a30b77c67..1a51d506e 100644 --- a/lib/core/usp/errors/usp_error.dart +++ b/lib/core/usp/errors/usp_error.dart @@ -114,26 +114,57 @@ UspError? parseUspError(Object error) { /// Converts any caught error from a USP codegen call into a [ServiceError]. /// -/// ## WASM String Contract +/// ## Error Contract (the match points this function depends on) /// -/// This function matches error strings from `usp_framework/usp-client/src/error.rs`. -/// The following strings are part of the API contract: +/// Errors reach this function from THREE sources. Each match point below is a +/// brittle coupling — if the source string/code changes, the mapping silently +/// breaks. This table lists ONLY the values actually compared against (not the +/// full set of strings the sources can emit). Keep it in sync with the sources. /// -/// | Dart match string | WASM error type | -/// |-------------------------|----------------------------------| -/// | `'Invalid credentials'` | `AuthError::InvalidCredentials` | -/// | `'Session expired'` | `AuthError::SessionExpired` | -/// | `'Invalid token'` | `AuthError::InvalidToken` | -/// | `'Permission denied'` | `AuthError::PermissionDenied` | -/// | `'Authentication required'` | `AuthError::AuthenticationRequired` | -/// | `'Request timeout'` | `TransportError::Timeout` | -/// | `'Connection refused'` | `TransportError::ConnectionRefused` | -/// | `'Path not found'` | `OperationError::PathNotFound` | -/// | `'read-only'` | `OperationError::ReadOnly` | -/// | `'Invalid value'` | `OperationError::InvalidValue` | +/// ### Source 1 — Rust WASM client string `Display` (`usp-client/src/error.rs`) /// -/// **Warning**: If WASM error messages change, this mapping will break silently. -/// Update both sides together. +/// | Dart match string | Rust variant (error.rs) | → ServiceError | +/// |---------------------------|--------------------------------------|-----------------------------| +/// | `'Invalid credentials'` | `AuthError::InvalidCredentials` | InvalidCredentialsError | +/// | `'Session expired'` | `AuthError::SessionExpired` | SessionTokenExpiredError | +/// | `'Invalid token'` | `AuthError::InvalidToken` | InvalidSessionTokenError | +/// | `'Permission denied'` | `AuthError::PermissionDenied` | UnauthorizedError | +/// | `'Authentication required'`| `AuthError::AuthenticationRequired` | NotAuthenticatedError | +/// | `'Request timeout'` | `TransportError::Timeout` | NetworkError | +/// | `'Connection refused'` | `TransportError::ConnectionRefused` | ConnectivityError | +/// | HTTP status `401` | `HTTP error: HTTP 401` (regex) | NotAuthenticatedError | +/// +/// ### Source 2 — fault codes in `(code: XXXX)`, passed through from firmware +/// (7xxx = TR-369 standard; 9xxx = bbfdm vendor). Rust only relays these. +/// +/// | code | meaning | → ServiceError | +/// |------|----------------------------------|-------------------------| +/// | 7004 | parameter not writable | InvalidInputError | +/// | 7005 | invalid parameter name | InvalidInputError | +/// | 7006 | invalid parameter value | InvalidInputError | +/// | 7026 | parameter (path) not found | ResourceNotFoundError | +/// | 7027 | object not found | ResourceNotFoundError | +/// | 9001 | bbfdm: request denied | UnauthorizedError | +/// | 9005 | bbfdm: invalid/unimplemented param | ResourceNotFoundError | +/// | 9007 | bbfdm: (resource not found) | ResourceNotFoundError | +/// | 9008 | bbfdm: non-writable parameter | InvalidInputError | +/// +/// ### Source 3 — Dart codegen (`lib/generated/*.g.dart`), NOT from Rust +/// +/// | Dart match | emitted by | → ServiceError | +/// |-------------|-----------------------------------------------------|-------------------| +/// | code `9998` | codegen "Required fields missing from response" | InvalidInputError | +/// | | (category=validation → handled by the validation arm)| | +/// +/// ### Dead match points (kept for completeness / contract tests only) +/// `_mapOperationError` strings — `'Path not found'`, `'read-only'`, +/// `'Invalid value'` — map `OperationError::*`, which is constructed ONLY in the +/// Rust `ffi` module (native, `#[cfg(not(target_arch = "wasm32"))]`). They never +/// fire in the production WASM build. See [_mapOperationError]. +/// +/// **Warning**: anything not matched above falls through to NetworkError +/// (transport) or UnexpectedError (auth/protocol/unparseable). If source +/// strings/codes change, update this table AND the contract tests together. ServiceError mapUspErrorToServiceError(Object error) { final parsed = parseUspError(error); if (parsed == null) { @@ -147,7 +178,8 @@ ServiceError mapUspErrorToServiceError(Object error) { UspErrorCategory.transport => _mapTransportError(parsed), UspErrorCategory.protocol => _mapProtocolError(parsed), UspErrorCategory.operation => _mapOperationError(parsed), - UspErrorCategory.validation => InvalidInputError(message: parsed.message), + UspErrorCategory.validation => + InvalidInputError(code: parsed.faultCode, detail: parsed.message), }; logger.w('[USP][ServiceError]: "$error" → ${result.runtimeType}'); return result; @@ -155,35 +187,41 @@ ServiceError mapUspErrorToServiceError(Object error) { ServiceError _mapAuthError(UspError e) { final msg = e.message; + final code = e.faultCode; if (msg.contains('Invalid credentials')) { - return const InvalidCredentialsError(); + return InvalidCredentialsError(code: code, detail: msg); + } + if (msg.contains('Session expired')) { + return SessionTokenExpiredError(code: code, detail: msg); + } + if (msg.contains('Invalid token')) { + return InvalidSessionTokenError(code: code, detail: msg); + } + if (msg.contains('Permission denied')) { + return UnauthorizedError(code: code, detail: msg); } - if (msg.contains('Session expired')) return const SessionTokenExpiredError(); - if (msg.contains('Invalid token')) return const InvalidSessionTokenError(); - if (msg.contains('Permission denied')) return const UnauthorizedError(); if (msg.contains('Authentication required')) { - return const NotAuthenticatedError(); + return NotAuthenticatedError(code: code, detail: msg); } - return UnexpectedError(originalError: e.rawError, message: msg); + return UnexpectedError(originalError: e.rawError, detail: msg); } ServiceError _mapTransportError(UspError e) { final status = e.httpStatus; if (status != null) { return switch (status) { - 401 => const NotAuthenticatedError(), - 504 => NetworkError(message: e.message), - _ => NetworkError(message: e.message), + 401 => NotAuthenticatedError(code: status, detail: e.message), + _ => NetworkError(code: status, detail: e.message), }; } final msg = e.message; if (msg.contains('Request timeout')) { - return NetworkError(message: msg); + return NetworkError(detail: msg); } if (msg.contains('Connection refused')) { - return ConnectivityError(message: msg); + return ConnectivityError(detail: msg); } - return NetworkError(message: msg); + return NetworkError(detail: msg); } ServiceError _mapProtocolError(UspError e) { @@ -191,26 +229,45 @@ ServiceError _mapProtocolError(UspError e) { final code = e.faultCode; if (code != null) { return switch (code) { - 7004 => InvalidInputError(message: e.message), - 7026 => ResourceNotFoundError(), - 9001 => UnauthorizedError(), - 9005 => ResourceNotFoundError(), - 9007 => ResourceNotFoundError(), - 9008 => InvalidInputError(message: e.message), - _ => UnexpectedError(originalError: e.rawError, message: e.message), + 7004 => InvalidInputError(code: code, detail: e.message), // not writable + 7005 => + InvalidInputError(code: code, detail: e.message), // bad param name + 7006 => InvalidInputError(code: code, detail: e.message), // bad value + 7026 => ResourceNotFoundError(code: code, detail: e.message), // path 404 + 7027 => + ResourceNotFoundError(code: code, detail: e.message), // object 404 + 9001 => UnauthorizedError(code: code, detail: e.message), // bbfdm denied + 9005 => + ResourceNotFoundError(code: code, detail: e.message), // unimplemented + 9007 => ResourceNotFoundError(code: code, detail: e.message), + 9008 => InvalidInputError(code: code, detail: e.message), // non-writable + _ => UnexpectedError(originalError: e.rawError, detail: e.message), }; } - return UnexpectedError(originalError: e.rawError, message: e.message); + return UnexpectedError(originalError: e.rawError, detail: e.message); } +/// Maps `Operation error:` category strings. +/// +/// NOTE: In the production WASM build this path is effectively unreachable. +/// `UspError::OperationError` variants (`PathNotFound`/`ReadOnly`/`InvalidValue`) +/// are only constructed in the Rust `ffi` module, which is gated behind +/// `#[cfg(not(target_arch = "wasm32"))]` (lib.rs:22) — i.e. native FFI only, +/// stripped from the WASM binary. The only WASM-side `OperationError` is +/// `OperateFailed` from `subscribe`/`unsubscribe`, but those surface as a +/// thrown string via Promise reject and never reach codegen's catch, so they +/// don't pass through here. +/// +/// Kept for completeness, defensive coverage, and to satisfy the existing +/// contract tests (usp_error_test.dart). Do not rely on it firing in prod. ServiceError _mapOperationError(UspError e) { final msg = e.message; if (msg.contains('Path not found')) return const ResourceNotFoundError(); if (msg.contains('read-only')) { - return InvalidInputError(message: msg); + return InvalidInputError(detail: msg); } if (msg.contains('Invalid value')) { - return InvalidInputError(message: msg); + return InvalidInputError(detail: msg); } - return UnexpectedError(originalError: e.rawError, message: msg); + return UnexpectedError(originalError: e.rawError, detail: msg); } diff --git a/lib/page/admin/services/usp_admin_service.dart b/lib/page/admin/services/usp_admin_service.dart index d4f856377..6a8aba7bc 100644 --- a/lib/page/admin/services/usp_admin_service.dart +++ b/lib/page/admin/services/usp_admin_service.dart @@ -54,12 +54,12 @@ class UspAdminService { throw UspPartialFailureError( summary: 'Password update partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'Password update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { @@ -97,12 +97,12 @@ class UspAdminService { throw UspPartialFailureError( summary: 'Time settings partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'Time settings update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { @@ -138,12 +138,12 @@ class UspAdminService { throw UspPartialFailureError( summary: 'Timezone update partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'Timezone update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { diff --git a/lib/page/admin/services/usp_system_info_data_service.dart b/lib/page/admin/services/usp_system_info_data_service.dart index 347e37409..528762aca 100644 --- a/lib/page/admin/services/usp_system_info_data_service.dart +++ b/lib/page/admin/services/usp_system_info_data_service.dart @@ -19,7 +19,7 @@ final uspSystemInfoDataServiceProvider = Provider( final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspSystemInfoDataService(usp); }, diff --git a/lib/page/admin/services/usp_time_data_service.dart b/lib/page/admin/services/usp_time_data_service.dart index c6d9eb46d..3a0e65801 100644 --- a/lib/page/admin/services/usp_time_data_service.dart +++ b/lib/page/admin/services/usp_time_data_service.dart @@ -15,7 +15,7 @@ final uspTimeDataServiceProvider = Provider( final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspTimeDataService(usp); }, diff --git a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart index ed1236528..af782577e 100644 --- a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart +++ b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart @@ -124,7 +124,7 @@ class DashboardOrchestrator extends AsyncNotifier { final usp = ref.watch(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } // On page reload WASM state is lost — attempt session restore if (!usp.isAuthenticated) { diff --git a/lib/page/devices/services/usp_devices_data_service.dart b/lib/page/devices/services/usp_devices_data_service.dart index 322c061a3..41cd76df1 100644 --- a/lib/page/devices/services/usp_devices_data_service.dart +++ b/lib/page/devices/services/usp_devices_data_service.dart @@ -24,7 +24,7 @@ final uspDevicesDataServiceProvider = Provider( final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspDevicesDataService(usp); }, diff --git a/lib/page/dhcp/services/usp_dhcp_service.dart b/lib/page/dhcp/services/usp_dhcp_service.dart index 6aec65535..7f1a2189f 100644 --- a/lib/page/dhcp/services/usp_dhcp_service.dart +++ b/lib/page/dhcp/services/usp_dhcp_service.dart @@ -61,12 +61,12 @@ class UspDhcpService { throw UspPartialFailureError( summary: 'DHCP toggle partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'DHCP toggle failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { @@ -97,12 +97,12 @@ class UspDhcpService { throw UspPartialFailureError( summary: 'DHCP add partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'DHCP add failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { @@ -127,12 +127,12 @@ class UspDhcpService { throw UspPartialFailureError( summary: 'DHCP delete partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'DHCP delete failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { @@ -178,7 +178,7 @@ class UspDhcpService { case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'DHCP batch delete failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } @@ -203,7 +203,7 @@ class UspDhcpService { case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'DHCP batch add failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } @@ -242,7 +242,7 @@ class UspDhcpService { case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'DHCP batch update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } diff --git a/lib/page/dmz/services/usp_dmz_service.dart b/lib/page/dmz/services/usp_dmz_service.dart index 925a73764..30b5252ee 100644 --- a/lib/page/dmz/services/usp_dmz_service.dart +++ b/lib/page/dmz/services/usp_dmz_service.dart @@ -71,12 +71,12 @@ class UspDmzService { throw UspPartialFailureError( summary: 'DMZ add partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'DMZ add failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { @@ -117,12 +117,12 @@ class UspDmzService { throw UspPartialFailureError( summary: 'DMZ update partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'DMZ update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { diff --git a/lib/page/firewall/services/usp_firewall_data_service.dart b/lib/page/firewall/services/usp_firewall_data_service.dart index fd14f6560..fbdfff6ab 100644 --- a/lib/page/firewall/services/usp_firewall_data_service.dart +++ b/lib/page/firewall/services/usp_firewall_data_service.dart @@ -19,7 +19,7 @@ final uspFirewallDataServiceProvider = Provider( final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspFirewallDataService(usp); }, diff --git a/lib/page/firewall/services/usp_firewall_service.dart b/lib/page/firewall/services/usp_firewall_service.dart index ccc92eefd..f84fdae1f 100644 --- a/lib/page/firewall/services/usp_firewall_service.dart +++ b/lib/page/firewall/services/usp_firewall_service.dart @@ -81,12 +81,12 @@ class UspFirewallService { throw UspPartialFailureError( summary: 'Firewall update partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'Firewall update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } diff --git a/lib/page/firmware_update/services/firmware_local_upload_service.dart b/lib/page/firmware_update/services/firmware_local_upload_service.dart index b3d43beff..d77578018 100644 --- a/lib/page/firmware_update/services/firmware_local_upload_service.dart +++ b/lib/page/firmware_update/services/firmware_local_upload_service.dart @@ -113,7 +113,7 @@ class FirmwareLocalUploadService { final total = _chunker.totalFragments(bytes.length); if (total == 0) { throw const InvalidInputError( - message: 'Cannot upload an empty firmware image', + detail: 'Cannot upload an empty firmware image', ); } diff --git a/lib/page/firmware_update/services/firmware_ws_upload_strategy.dart b/lib/page/firmware_update/services/firmware_ws_upload_strategy.dart index 8f7e02b41..2292c9821 100644 --- a/lib/page/firmware_update/services/firmware_ws_upload_strategy.dart +++ b/lib/page/firmware_update/services/firmware_ws_upload_strategy.dart @@ -72,7 +72,7 @@ class FirmwareWsUploadStrategy implements FirmwareUploadStrategy { await _turboManager.start(); } catch (e) { logger.e('$_tag Failed to start turbo session: $e'); - throw NetworkError(message: 'Failed to acquire turbo channel: $e'); + throw NetworkError(detail: 'Failed to acquire turbo channel: $e'); } // 2. Connect WebSocket @@ -81,7 +81,7 @@ class FirmwareWsUploadStrategy implements FirmwareUploadStrategy { } catch (e) { logger.e('$_tag Failed to connect WebSocket: $e'); await _turboManager.release(); - throw NetworkError(message: 'WebSocket connection failed: $e'); + throw NetworkError(detail: 'WebSocket connection failed: $e'); } // 3. Setup message listener @@ -92,7 +92,7 @@ class FirmwareWsUploadStrategy implements FirmwareUploadStrategy { _responseCompleter!.completeError( UspCompleteFailureError( summary: msg.error?.message ?? 'Unknown error', - failedPaths: const [], + failures: const [], ), ); } else { @@ -122,7 +122,7 @@ class FirmwareWsUploadStrategy implements FirmwareUploadStrategy { _responseCompleter = null; logger.e('$_tag WebSocketConnect handshake failed: $e'); await finalize(); - throw NetworkError(message: 'WebSocket handshake failed: $e'); + throw NetworkError(detail: 'WebSocket handshake failed: $e'); } logger.i('$_tag WebSocket upload prepared'); @@ -170,7 +170,7 @@ class FirmwareWsUploadStrategy implements FirmwareUploadStrategy { ); } catch (e) { if (e is ServiceError) rethrow; - throw NetworkError(message: 'Chunk $sequenceNumber upload failed: $e'); + throw NetworkError(detail: 'Chunk $sequenceNumber upload failed: $e'); } finally { _responseCompleter = null; } diff --git a/lib/page/firmware_update/services/usp_firmware_update_service.dart b/lib/page/firmware_update/services/usp_firmware_update_service.dart index 6a505a72b..fcbf9153f 100644 --- a/lib/page/firmware_update/services/usp_firmware_update_service.dart +++ b/lib/page/firmware_update/services/usp_firmware_update_service.dart @@ -36,7 +36,7 @@ class UspFirmwareUpdateService { (b) => b.isActive, orElse: () => throw UspCompleteFailureError( summary: 'No active firmware bank found', - failedPaths: const [], + failures: const [], ), ); } @@ -47,7 +47,7 @@ class UspFirmwareUpdateService { (b) => b.available && !b.isActive, orElse: () => throw UspCompleteFailureError( summary: 'No available firmware bank found', - failedPaths: const [], + failures: const [], ), ); } @@ -97,7 +97,7 @@ class UspFirmwareUpdateService { (i) => _instanceFromPath(i.instancePath) == instance, orElse: () => throw UspCompleteFailureError( summary: 'Firmware bank instance $instance not found', - failedPaths: const [], + failures: const [], ), ); return match.status; @@ -134,7 +134,7 @@ class UspFirmwareUpdateService { throw UspCompleteFailureError( summary: 'Inconsistent firmware state: ${activeBanks.length} banks reported Active', - failedPaths: activeBanks.map((b) => b.instancePath).toList(), + failures: const [], ); } final match = images.items.firstWhere( @@ -142,14 +142,14 @@ class UspFirmwareUpdateService { orElse: () => throw UspCompleteFailureError( summary: 'Expected firmware bank instance $expectedActiveInstance ' 'not present after reboot', - failedPaths: const [], + failures: const [], ), ); if (match.status != 'Active') { throw UspCompleteFailureError( summary: 'Router restarted but did not boot the new image (instance ' '$expectedActiveInstance status=${match.status})', - failedPaths: [match.instancePath], + failures: const [], ); } return match.version == expectedVersion; diff --git a/lib/page/instant_privacy/services/instant_privacy_service.dart b/lib/page/instant_privacy/services/instant_privacy_service.dart index 0fddf0b36..9a998f068 100644 --- a/lib/page/instant_privacy/services/instant_privacy_service.dart +++ b/lib/page/instant_privacy/services/instant_privacy_service.dart @@ -219,12 +219,12 @@ class UspInstantPrivacyService { throw UspPartialFailureError( summary: 'MAC filter enable partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'MAC filter enable failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } @@ -252,12 +252,12 @@ class UspInstantPrivacyService { throw UspPartialFailureError( summary: 'MAC filter disable partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'MAC filter disable failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } @@ -286,12 +286,12 @@ class UspInstantPrivacyService { throw UspPartialFailureError( summary: 'MAC filter add partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'MAC filter add failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { diff --git a/lib/page/instant_safety/services/instant_safety_service.dart b/lib/page/instant_safety/services/instant_safety_service.dart index efc7c2349..069cae042 100644 --- a/lib/page/instant_safety/services/instant_safety_service.dart +++ b/lib/page/instant_safety/services/instant_safety_service.dart @@ -59,12 +59,12 @@ class UspInstantSafetyService { throw UspPartialFailureError( summary: 'Safe browsing update partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'Safe browsing update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { diff --git a/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart b/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart index 71a1c7343..ef626be46 100644 --- a/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart +++ b/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart @@ -36,8 +36,7 @@ final uspInternetSettingsServiceProvider = Provider.autoDispose((ref) { final usp = ref.watch(uspClientProvider); if (usp == null) { - throw const ServiceNotInitializedError( - message: 'USP service not available'); + throw const ServiceNotInitializedError(detail: 'USP service not available'); } return UspInternetSettingsService(usp); }); @@ -82,7 +81,7 @@ class UspInternetSettingsNotifier final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } // Session restore on page reload (WASM state may be lost) @@ -90,7 +89,7 @@ class UspInternetSettingsNotifier await ref.read(uspAuthCoordinatorProvider).restoreSession(); if (!usp.isAuthenticated) { throw const ConnectivityError( - message: 'USP not authenticated after restore attempt'); + detail: 'USP not authenticated after restore attempt'); } } diff --git a/lib/page/internet_settings/services/usp_internet_settings_service.dart b/lib/page/internet_settings/services/usp_internet_settings_service.dart index ead05bd16..634fcd40c 100644 --- a/lib/page/internet_settings/services/usp_internet_settings_service.dart +++ b/lib/page/internet_settings/services/usp_internet_settings_service.dart @@ -57,7 +57,6 @@ class UspInternetSettingsService { debugIpv6Enabled: ipv6.ipv6Enabled, ); } catch (e) { - if (e is ServiceError) rethrow; throw mapUspErrorToServiceError(e); } } @@ -454,12 +453,12 @@ class UspInternetSettingsService { throw UspPartialFailureError( summary: 'WAN update partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'WAN update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } @@ -478,12 +477,12 @@ class UspInternetSettingsService { throw UspPartialFailureError( summary: 'WAN delete partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'WAN delete failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } @@ -502,12 +501,12 @@ class UspInternetSettingsService { throw UspPartialFailureError( summary: 'WAN operation partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'WAN operation failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } diff --git a/lib/page/internet_settings/services/usp_wan_data_service.dart b/lib/page/internet_settings/services/usp_wan_data_service.dart index 34672bad3..e4cef3a7f 100644 --- a/lib/page/internet_settings/services/usp_wan_data_service.dart +++ b/lib/page/internet_settings/services/usp_wan_data_service.dart @@ -18,7 +18,7 @@ final uspWanDataServiceProvider = Provider( final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspWanDataService(usp); }, diff --git a/lib/page/ipv6_port_service/services/usp_ipv6_port_service_service.dart b/lib/page/ipv6_port_service/services/usp_ipv6_port_service_service.dart index 4d022fe67..b3ced6f2a 100644 --- a/lib/page/ipv6_port_service/services/usp_ipv6_port_service_service.dart +++ b/lib/page/ipv6_port_service/services/usp_ipv6_port_service_service.dart @@ -64,7 +64,7 @@ class UspIpv6PortServiceService { case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'IPv6 port service batch delete failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } @@ -98,7 +98,7 @@ class UspIpv6PortServiceService { case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'IPv6 port service batch add failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } @@ -141,7 +141,7 @@ class UspIpv6PortServiceService { case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'IPv6 port service batch update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } diff --git a/lib/page/local_network/services/usp_dhcp_data_service.dart b/lib/page/local_network/services/usp_dhcp_data_service.dart index e74858090..d084dbaec 100644 --- a/lib/page/local_network/services/usp_dhcp_data_service.dart +++ b/lib/page/local_network/services/usp_dhcp_data_service.dart @@ -17,7 +17,7 @@ final uspDhcpDataServiceProvider = Provider( final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspDhcpDataService(usp); }, diff --git a/lib/page/local_network/services/usp_ethernet_data_service.dart b/lib/page/local_network/services/usp_ethernet_data_service.dart index 13b20581d..cbf0787b3 100644 --- a/lib/page/local_network/services/usp_ethernet_data_service.dart +++ b/lib/page/local_network/services/usp_ethernet_data_service.dart @@ -17,7 +17,7 @@ final uspEthernetDataServiceProvider = Provider( final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspEthernetDataService(usp); }, diff --git a/lib/page/local_network/services/usp_lan_data_service.dart b/lib/page/local_network/services/usp_lan_data_service.dart index 0d8b61b45..11107d67a 100644 --- a/lib/page/local_network/services/usp_lan_data_service.dart +++ b/lib/page/local_network/services/usp_lan_data_service.dart @@ -16,7 +16,7 @@ final uspLanDataServiceProvider = Provider( final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspLanDataService(usp); }, diff --git a/lib/page/local_network/services/usp_local_network_service.dart b/lib/page/local_network/services/usp_local_network_service.dart index 18dd3e29d..88946d0cc 100644 --- a/lib/page/local_network/services/usp_local_network_service.dart +++ b/lib/page/local_network/services/usp_local_network_service.dart @@ -68,12 +68,12 @@ class UspLocalNetworkService { throw UspPartialFailureError( summary: 'Local network update partial failure: $errorSummary', successPaths: successes.map((s) => s.requestedPath).toList(), - failedPaths: failures.map((f) => f.requestedPath).toList(), + failures: failures, ); case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'Local network update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } catch (e) { diff --git a/lib/page/login/views/login_local_view.dart b/lib/page/login/views/login_local_view.dart index 06ee702a0..764d08a05 100644 --- a/lib/page/login/views/login_local_view.dart +++ b/lib/page/login/views/login_local_view.dart @@ -138,7 +138,7 @@ class _LoginViewState extends ConsumerState { void setErrorMessage(UnexpectedError? error) { if (error != null) { - final errorCode = error.message ?? ''; + final errorCode = error.detail ?? ''; // Check if it's the invalid admin password error from CheckAdminPassword3 if (errorCode == errorInvalidAdminPassword || errorCode == errorPasswordCheckDelayed) { @@ -323,7 +323,7 @@ class _LoginViewState extends ConsumerState { if (result != null) { // Create the error and the countdown has yet to be triggered final loginError = UnexpectedError( - message: errorPasswordCheckDelayed, + detail: errorPasswordCheckDelayed, originalError: jsonEncode(result), ); setErrorMessage(loginError); diff --git a/lib/page/port_forwarding/services/usp_port_forwarding_data_service.dart b/lib/page/port_forwarding/services/usp_port_forwarding_data_service.dart index 2e8b2e3f3..53225d568 100644 --- a/lib/page/port_forwarding/services/usp_port_forwarding_data_service.dart +++ b/lib/page/port_forwarding/services/usp_port_forwarding_data_service.dart @@ -17,7 +17,7 @@ final uspPortForwardingDataServiceProvider = final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspPortForwardingDataService(usp); }, diff --git a/lib/page/port_forwarding/services/usp_port_forwarding_service.dart b/lib/page/port_forwarding/services/usp_port_forwarding_service.dart index 5e56bb579..4f584fa17 100644 --- a/lib/page/port_forwarding/services/usp_port_forwarding_service.dart +++ b/lib/page/port_forwarding/services/usp_port_forwarding_service.dart @@ -15,7 +15,7 @@ final uspPortForwardingServiceProvider = Provider( final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspPortForwardingService(usp); }, @@ -74,12 +74,12 @@ class UspPortForwardingService { summary: 'Toggle forwarding partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'Toggle forwarding failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } catch (e) { @@ -121,12 +121,12 @@ class UspPortForwardingService { throw UspPartialFailureError( summary: 'Add forwarding partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'Add forwarding failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } catch (e) { @@ -156,12 +156,12 @@ class UspPortForwardingService { summary: 'Toggle triggering partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'Toggle triggering failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } catch (e) { @@ -286,7 +286,7 @@ class UspPortForwardingService { if (totalOps > 0 && failedOps == totalOps) { throw UspCompleteFailureError( summary: 'All forwarding batch operations failed', - failedPaths: [], + failures: const [], ); } @@ -440,7 +440,7 @@ class UspPortForwardingService { if (totalOps > 0 && failedOps == totalOps) { throw UspCompleteFailureError( summary: 'All triggering batch operations failed', - failedPaths: [], + failures: const [], ); } diff --git a/lib/page/port_forwarding/services/usp_port_triggering_data_service.dart b/lib/page/port_forwarding/services/usp_port_triggering_data_service.dart index 6cd04fc2f..6b4fd5e97 100644 --- a/lib/page/port_forwarding/services/usp_port_triggering_data_service.dart +++ b/lib/page/port_forwarding/services/usp_port_triggering_data_service.dart @@ -17,7 +17,7 @@ final uspPortTriggeringDataServiceProvider = final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspPortTriggeringDataService(usp); }, diff --git a/lib/page/static_routing/services/usp_static_routing_service.dart b/lib/page/static_routing/services/usp_static_routing_service.dart index 7aeb7b350..6b3d6f8b4 100644 --- a/lib/page/static_routing/services/usp_static_routing_service.dart +++ b/lib/page/static_routing/services/usp_static_routing_service.dart @@ -77,7 +77,7 @@ class UspStaticRoutingService { case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'Static routing batch delete failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } @@ -109,7 +109,7 @@ class UspStaticRoutingService { case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'Static routing batch add failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } @@ -151,7 +151,7 @@ class UspStaticRoutingService { case UspFailure(:final errorSummary, :final errors): throw UspCompleteFailureError( summary: 'Static routing batch update failed: $errorSummary', - failedPaths: errors.map((e) => e.requestedPath).toList(), + failures: errors, ); } } diff --git a/lib/page/unified_diagnostics/providers/manual_tools_notifier.dart b/lib/page/unified_diagnostics/providers/manual_tools_notifier.dart index 261512e2b..f74d8f06a 100644 --- a/lib/page/unified_diagnostics/providers/manual_tools_notifier.dart +++ b/lib/page/unified_diagnostics/providers/manual_tools_notifier.dart @@ -47,7 +47,7 @@ class ManualToolsNotifier final svc = ref.read(diagnosticsScopeServiceProvider); if (svc == null) { throw const ConnectivityError( - message: 'DiagnosticsScopeService not available'); + detail: 'DiagnosticsScopeService not available'); } return svc; } @@ -145,8 +145,8 @@ class ManualToolsNotifier String _pingErrorMessage(ServiceError e, String host) { return switch (e) { - InvalidInputError(:final message) => - message ?? 'Cannot ping $host — invalid host', + InvalidInputError(:final detail) => + detail ?? 'Cannot ping $host — invalid host', NetworkError() => 'Ping failed — router lost connection', ConnectivityError() => 'Ping unavailable — diagnostics scope not ready', _ => 'Ping failed — please try again', @@ -202,8 +202,8 @@ class ManualToolsNotifier String _tracerouteErrorMessage(ServiceError e, String host) { return switch (e) { - InvalidInputError(:final message) => - message ?? 'Cannot trace $host — invalid host', + InvalidInputError(:final detail) => + detail ?? 'Cannot trace $host — invalid host', NetworkError() => 'Traceroute failed — router lost connection', ConnectivityError() => 'Traceroute unavailable — diagnostics scope not ready', @@ -263,8 +263,8 @@ class ManualToolsNotifier String _nsLookupErrorMessage(ServiceError e, String host) { return switch (e) { - InvalidInputError(:final message) => - message ?? 'Cannot resolve $host — invalid host', + InvalidInputError(:final detail) => + detail ?? 'Cannot resolve $host — invalid host', NetworkError() => 'NS Lookup failed — router lost connection', ConnectivityError() => 'NS Lookup unavailable — diagnostics scope not ready', diff --git a/lib/page/unified_diagnostics/providers/speed_test_notifier.dart b/lib/page/unified_diagnostics/providers/speed_test_notifier.dart index fc2f55d6b..97b168adb 100644 --- a/lib/page/unified_diagnostics/providers/speed_test_notifier.dart +++ b/lib/page/unified_diagnostics/providers/speed_test_notifier.dart @@ -65,7 +65,7 @@ class SpeedTestNotifier extends AutoDisposeAsyncNotifier { final svc = ref.read(diagnosticsScopeServiceProvider); if (svc == null) { throw const ConnectivityError( - message: 'DiagnosticsScopeService not available'); + detail: 'DiagnosticsScopeService not available'); } return svc; } @@ -267,8 +267,8 @@ class SpeedTestNotifier extends AutoDisposeAsyncNotifier { NetworkError() => 'Speed test failed — router lost connection', ConnectivityError() => 'Speed test unavailable — diagnostics scope not ready', - InvalidInputError(:final message) => - message ?? 'Speed test failed — invalid configuration', + InvalidInputError(:final detail) => + detail ?? 'Speed test failed — invalid configuration', _ => 'Speed test failed — please try again', }; } diff --git a/lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart b/lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart index 871213457..f922b80c7 100644 --- a/lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart +++ b/lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart @@ -57,7 +57,7 @@ class UnifiedDiagnosticsNotifier final executor = ref.read(networkDiagnosticsExecutorProvider); if (executor == null) { throw const ConnectivityError( - message: 'NetworkDiagnosticsExecutor not available'); + detail: 'NetworkDiagnosticsExecutor not available'); } final scope = await executor.acquireScope(); _scope = scope; diff --git a/lib/page/unified_diagnostics/services/unified_diagnostics_service.dart b/lib/page/unified_diagnostics/services/unified_diagnostics_service.dart index 65408e7c5..77add21ee 100644 --- a/lib/page/unified_diagnostics/services/unified_diagnostics_service.dart +++ b/lib/page/unified_diagnostics/services/unified_diagnostics_service.dart @@ -119,7 +119,7 @@ class UnifiedDiagnosticsService { if (wan.ipAddress.isEmpty) { throw const InvalidInputError( field: 'wanIp', - message: 'No WAN IP address — cannot determine gateway'); + detail: 'No WAN IP address — cannot determine gateway'); } final gateway = _deriveGateway(wan.ipAddress, wan.subnetMask); return ping(gateway, repeatCount: repeatCount); diff --git a/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart b/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart index 6a00c05ee..9a4e7ba21 100644 --- a/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart +++ b/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart @@ -371,7 +371,7 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier if (count == 0) { logger.w('[USP][WiFi]: No SSIDs found matching the requested name'); throw const InvalidInputError( - message: 'No matching WiFi networks found'); + detail: 'No matching WiFi networks found'); } logger.d('[USP][WiFi]: Toggled $count SSIDs to $enable'); } on ServiceError catch (e) { diff --git a/lib/page/wifi_settings/services/usp_wifi_data_service.dart b/lib/page/wifi_settings/services/usp_wifi_data_service.dart index 34d5acd6f..6e9fdb4ac 100644 --- a/lib/page/wifi_settings/services/usp_wifi_data_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_data_service.dart @@ -22,7 +22,7 @@ final uspWifiDataServiceProvider = Provider( final usp = ref.read(uspClientProvider); if (usp == null) { throw const ServiceNotInitializedError( - message: 'USP service not available'); + detail: 'USP service not available'); } return UspWifiDataService(usp); }, diff --git a/lib/page/wifi_settings/services/usp_wifi_settings_service.dart b/lib/page/wifi_settings/services/usp_wifi_settings_service.dart index c066d4300..2f50e5eca 100644 --- a/lib/page/wifi_settings/services/usp_wifi_settings_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_settings_service.dart @@ -238,11 +238,11 @@ class UspWifiSettingsService { (ssidChanged || enabledChanged)) { if (ssidChanged) { if (pending.ssid.isEmpty) { - throw InvalidInputError(message: 'SSID name cannot be empty'); + throw InvalidInputError(detail: 'SSID name cannot be empty'); } if (pending.ssid.length > 32) { throw InvalidInputError( - message: 'SSID name cannot exceed 32 characters'); + detail: 'SSID name cannot exceed 32 characters'); } } for (final p in aggregate.ssidInstancePaths) { @@ -262,12 +262,12 @@ class UspWifiSettingsService { summary: 'WiFi SSID update partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'WiFi SSID update failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } @@ -316,12 +316,12 @@ class UspWifiSettingsService { summary: 'WiFi AP update partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'WiFi AP update failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } @@ -373,12 +373,12 @@ class UspWifiSettingsService { summary: 'WiFi SSID update partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'WiFi SSID update failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } @@ -413,12 +413,12 @@ class UspWifiSettingsService { summary: 'WiFi AP update partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'WiFi AP update failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } @@ -456,12 +456,12 @@ class UspWifiSettingsService { summary: 'WiFi Radio update partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'WiFi Radio update failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } @@ -491,12 +491,12 @@ class UspWifiSettingsService { throw UspPartialFailureError( summary: 'Toggle radio partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'Toggle radio failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } catch (e) { @@ -531,12 +531,12 @@ class UspWifiSettingsService { summary: 'Update radio channel partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'Update radio channel failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } catch (e) { @@ -574,12 +574,12 @@ class UspWifiSettingsService { throw UspPartialFailureError( summary: 'Toggle SSIDs partial failure: ${f.first.errorMessage}', successPaths: [], - failedPaths: f.map((e) => e.requestedPath).toList(), + failures: f, ); case UspFailure(errors: final e): throw UspCompleteFailureError( summary: 'Toggle SSIDs failed: ${e.first.errorMessage}', - failedPaths: e.map((e) => e.requestedPath).toList(), + failures: e, ); } } catch (e) { diff --git a/test/core/errors/service_error_test.dart b/test/core/errors/service_error_test.dart index a9fc26b6a..150ee4680 100644 --- a/test/core/errors/service_error_test.dart +++ b/test/core/errors/service_error_test.dart @@ -16,35 +16,35 @@ void main() { }); test('NetworkError appends message when present', () { - expect('${const NetworkError(message: 'Request timeout')}', + expect('${const NetworkError(detail: 'Request timeout')}', 'Network error: Request timeout'); expect('${const NetworkError()}', 'Network'); }); test('ConnectivityError appends message when present', () { - expect('${const ConnectivityError(message: 'Connection refused')}', + expect('${const ConnectivityError(detail: 'Connection refused')}', 'Connectivity error: Connection refused'); expect('${const ConnectivityError()}', 'Connectivity'); }); test('InvalidInputError shows field and message', () { expect( - '${const InvalidInputError(field: 'destIp', message: 'Invalid IP')}', + '${const InvalidInputError(field: 'destIp', detail: 'Invalid IP')}', 'Invalid input: destIp: Invalid IP'); - expect('${const InvalidInputError(message: 'bad value')}', + expect('${const InvalidInputError(detail: 'bad value')}', 'Invalid input: bad value'); expect('${const InvalidInputError()}', 'Invalid input'); }); test('UnexpectedError appends message when present', () { - expect('${const UnexpectedError(message: 'bad data')}', + expect('${const UnexpectedError(detail: 'bad data')}', 'Unexpected error: bad data'); expect('${const UnexpectedError()}', 'Unexpected'); }); test('ServiceNotInitializedError appends message when present', () { expect( - '${const ServiceNotInitializedError(message: 'USP service not available')}', + '${const ServiceNotInitializedError(detail: 'USP service not available')}', 'Service not initialized: USP service not available'); expect( '${const ServiceNotInitializedError()}', 'Service not initialized'); diff --git a/test/core/session/providers/session_notifier_test.dart b/test/core/session/providers/session_notifier_test.dart index 3fc584b13..6a184fb39 100644 --- a/test/core/session/providers/session_notifier_test.dart +++ b/test/core/session/providers/session_notifier_test.dart @@ -108,7 +108,7 @@ void main() { test('throws ConnectivityError when router is unreachable', () async { when(() => mockService.checkRouterIsBack(any())) - .thenThrow(const ConnectivityError(message: 'unreachable')); + .thenThrow(const ConnectivityError(detail: 'unreachable')); final container = createContainer(); final notifier = container.read(sessionProvider.notifier); @@ -180,7 +180,7 @@ void main() { test('throws on API failure', () async { when(() => mockService.forceFetchDeviceInfo()) - .thenThrow(const ConnectivityError(message: 'timeout')); + .thenThrow(const ConnectivityError(detail: 'timeout')); final container = createContainer(); final notifier = container.read(sessionProvider.notifier); @@ -237,7 +237,7 @@ void main() { test('throws on service failure', () async { when(() => mockService.fetchDeviceInfoAndInitializeServices()) - .thenThrow(const ConnectivityError(message: 'network down')); + .thenThrow(const ConnectivityError(detail: 'network down')); final container = createContainer(); final notifier = container.read(sessionProvider.notifier); diff --git a/test/core/usp/errors/usp_error_test.dart b/test/core/usp/errors/usp_error_test.dart index f3e302f9c..d8e307901 100644 --- a/test/core/usp/errors/usp_error_test.dart +++ b/test/core/usp/errors/usp_error_test.dart @@ -194,6 +194,27 @@ void main() { expect(mapUspErrorToServiceError(raw), isA()); }); + test('maps Protocol fault 7005 to InvalidInputError', () { + const raw = + 'Set failed: Protocol error: Decoding error: Received error response: ' + 'SetFailed: Invalid parameter name (code: 7005)'; + expect(mapUspErrorToServiceError(raw), isA()); + }); + + test('maps Protocol fault 7006 to InvalidInputError', () { + const raw = + 'Set failed: Protocol error: Decoding error: Received error response: ' + 'SetFailed: Invalid parameter value (code: 7006)'; + expect(mapUspErrorToServiceError(raw), isA()); + }); + + test('maps Protocol fault 7027 to ResourceNotFoundError', () { + const raw = + 'Delete failed: Protocol error: Decoding error: Received error response: ' + 'DeleteFailed: Object does not exist (code: 7027)'; + expect(mapUspErrorToServiceError(raw), isA()); + }); + test('maps Protocol fault 9001 to UnauthorizedError', () { const raw = 'Set failed: Protocol error: Decoding error: Received error response: ' diff --git a/test/page/admin/providers/usp_admin_notifier_test.dart b/test/page/admin/providers/usp_admin_notifier_test.dart index cf9b80c52..301c50776 100644 --- a/test/page/admin/providers/usp_admin_notifier_test.dart +++ b/test/page/admin/providers/usp_admin_notifier_test.dart @@ -81,7 +81,7 @@ void main() { test('build error sets AsyncError', () async { when(() => mockAdminService.fetchAdmin()) - .thenThrow(const NetworkError(message: 'admin fetch failed')); + .thenThrow(const NetworkError(detail: 'admin fetch failed')); final container = createContainer(); try { @@ -180,7 +180,7 @@ void main() { when(() => mockAdminService.updatePassword( instancePath: any(named: 'instancePath'), newPassword: any(named: 'newPassword'), - )).thenThrow(const NetworkError(message: 'timeout')); + )).thenThrow(const NetworkError(detail: 'timeout')); final container = createContainer(); await container.read(uspAdminProvider.future); @@ -198,7 +198,7 @@ void main() { when(() => mockAdminService.fetchAdmin()) .thenAnswer((_) async => testAdmin); when(() => mockAdminService.reboot()) - .thenThrow(const ConnectivityError(message: 'connection refused')); + .thenThrow(const ConnectivityError(detail: 'connection refused')); final container = createContainer(); await container.read(uspAdminProvider.future); diff --git a/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart b/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart index 5396482aa..b14d66606 100644 --- a/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart +++ b/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart @@ -73,7 +73,7 @@ void main() { test('fetch error sets error status', () async { when(() => mockService.fetchReservations()) - .thenThrow(const NetworkError(message: 'timeout')); + .thenThrow(const NetworkError(detail: 'timeout')); final container = createContainer(); await Future.delayed(Duration.zero); @@ -168,7 +168,7 @@ void main() { when(() => mockService.saveBatch( original: any(named: 'original'), current: any(named: 'current'), - )).thenThrow(const NetworkError(message: 'save failed')); + )).thenThrow(const NetworkError(detail: 'save failed')); final container = createContainer(); await Future.delayed(Duration.zero); @@ -304,7 +304,7 @@ void main() { test('immediateToggle rethrows ServiceError', () async { when(() => mockService.fetchReservations()).thenAnswer((_) async => [r1]); when(() => mockService.immediateToggle(any(), any())) - .thenThrow(const NetworkError(message: 'toggle failed')); + .thenThrow(const NetworkError(detail: 'toggle failed')); final container = createContainer(); await Future.delayed(Duration.zero); @@ -324,7 +324,7 @@ void main() { mac: any(named: 'mac'), ip: any(named: 'ip'), enable: any(named: 'enable'), - )).thenThrow(const NetworkError(message: 'add failed')); + )).thenThrow(const NetworkError(detail: 'add failed')); final container = createContainer(); await Future.delayed(Duration.zero); @@ -341,7 +341,7 @@ void main() { test('immediateDelete rethrows ServiceError', () async { when(() => mockService.fetchReservations()).thenAnswer((_) async => [r1]); when(() => mockService.immediateDelete(any())) - .thenThrow(const NetworkError(message: 'delete failed')); + .thenThrow(const NetworkError(detail: 'delete failed')); final container = createContainer(); await Future.delayed(Duration.zero); diff --git a/test/page/dmz/providers/usp_dmz_notifier_test.dart b/test/page/dmz/providers/usp_dmz_notifier_test.dart index ad9229a4a..248f40d53 100644 --- a/test/page/dmz/providers/usp_dmz_notifier_test.dart +++ b/test/page/dmz/providers/usp_dmz_notifier_test.dart @@ -81,7 +81,7 @@ void main() { test('fetch error sets error status, no settings change', () async { when(() => mockService.fetch()) - .thenThrow(const NetworkError(message: 'network error')); + .thenThrow(const NetworkError(detail: 'network error')); final container = createContainer(); await Future.delayed(Duration.zero); @@ -234,7 +234,7 @@ void main() { when(() => mockService.update( instancePath: any(named: 'instancePath'), model: any(named: 'model'), - )).thenThrow(const NetworkError(message: 'save failed')); + )).thenThrow(const NetworkError(detail: 'save failed')); when(() => mockService.validateForm(any())).thenReturn({}); final container = createContainer(); diff --git a/test/page/firewall/providers/usp_firewall_notifier_test.dart b/test/page/firewall/providers/usp_firewall_notifier_test.dart index 6895017cb..2de8afcd1 100644 --- a/test/page/firewall/providers/usp_firewall_notifier_test.dart +++ b/test/page/firewall/providers/usp_firewall_notifier_test.dart @@ -158,7 +158,7 @@ void main() { uspMutationLockProvider.overrideWithValue(UspMutationLock()), firewallDataProvider.overrideWith(() => _TestFirewallDataNotifier( testData, - errorToThrow: const NetworkError(message: 'timeout'), + errorToThrow: const NetworkError(detail: 'timeout'), )), ], ); @@ -175,7 +175,7 @@ void main() { original: any(named: 'original'), pending: any(named: 'pending'), context: any(named: 'context'), - )).thenThrow(const NetworkError(message: 'save failed')); + )).thenThrow(const NetworkError(detail: 'save failed')); final container = createContainer(); await Future.delayed(Duration.zero); diff --git a/test/page/firmware_update/providers/firmware_update_notifier_test.dart b/test/page/firmware_update/providers/firmware_update_notifier_test.dart index b3ee5df60..22a4d43bb 100644 --- a/test/page/firmware_update/providers/firmware_update_notifier_test.dart +++ b/test/page/firmware_update/providers/firmware_update_notifier_test.dart @@ -171,7 +171,7 @@ void main() { test('loadBanks failure transitions to failed phase', () async { final container = createContainer( banksData: AsyncError( - const NetworkError(message: 'timeout'), StackTrace.current), + const NetworkError(detail: 'timeout'), StackTrace.current), ); addTearDown(container.dispose); @@ -346,7 +346,7 @@ void main() { commandKey: any(named: 'commandKey'), isCancelled: any(named: 'isCancelled'), onProgress: any(named: 'onProgress'), - )).thenThrow(const NetworkError(message: 'chunk timeout')); + )).thenThrow(const NetworkError(detail: 'chunk timeout')); final container = createContainer( picker: _StubPickerService( FirmwarePickedFile(name: 'fw.img', size: bytes.length, bytes: bytes), @@ -702,7 +702,7 @@ void main() { when(() => mockService.triggerOtaDownload( targetInstance: any(named: 'targetInstance'), firmwareUrl: any(named: 'firmwareUrl'), - )).thenThrow(const NetworkError(message: 'Download failed')); + )).thenThrow(const NetworkError(detail: 'Download failed')); final container = createContainer(); addTearDown(container.dispose); diff --git a/test/page/firmware_update/services/firmware_http_upload_strategy_test.dart b/test/page/firmware_update/services/firmware_http_upload_strategy_test.dart index 62a746429..0ab44a76b 100644 --- a/test/page/firmware_update/services/firmware_http_upload_strategy_test.dart +++ b/test/page/firmware_update/services/firmware_http_upload_strategy_test.dart @@ -90,7 +90,7 @@ void main() { test('uploadChunk rethrows ServiceError', () async { when(() => mockUsp.operate(any(), args: any(named: 'args'))) - .thenThrow(const NetworkError(message: 'Connection lost')); + .thenThrow(const NetworkError(detail: 'Connection lost')); expect( () => strategy.uploadChunk( diff --git a/test/page/firmware_update/services/firmware_local_upload_service_test.dart b/test/page/firmware_update/services/firmware_local_upload_service_test.dart index e6addfc27..4d6dc2ec0 100644 --- a/test/page/firmware_update/services/firmware_local_upload_service_test.dart +++ b/test/page/firmware_update/services/firmware_local_upload_service_test.dart @@ -144,7 +144,7 @@ void main() { test('rethrows existing ServiceError without re-mapping', () async { when(() => mockUsp.operate(any(), args: any(named: 'args'))) - .thenThrow(const NetworkError(message: 'timeout')); + .thenThrow(const NetworkError(detail: 'timeout')); expect( () => service.uploadFile( diff --git a/test/page/instant_privacy/providers/instant_privacy_notifier_test.dart b/test/page/instant_privacy/providers/instant_privacy_notifier_test.dart index bd5e516f2..99ea48511 100644 --- a/test/page/instant_privacy/providers/instant_privacy_notifier_test.dart +++ b/test/page/instant_privacy/providers/instant_privacy_notifier_test.dart @@ -64,7 +64,7 @@ void main() { test('build error sets AsyncError', () async { when(() => mockService.fetchAll()) - .thenThrow(const NetworkError(message: 'fetch failed')); + .thenThrow(const NetworkError(detail: 'fetch failed')); final container = createContainer(); try { @@ -139,7 +139,7 @@ void main() { when(() => mockService.fetchAll()) .thenAnswer((_) async => disabledResult); when(() => mockService.enable(any(), any())) - .thenThrow(const NetworkError(message: 'enable failed')); + .thenThrow(const NetworkError(detail: 'enable failed')); final container = createContainer(); await container.read(uspInstantPrivacyProvider.future); diff --git a/test/page/instant_safety/providers/instant_safety_provider_test.dart b/test/page/instant_safety/providers/instant_safety_provider_test.dart index 0df50b639..d47c79ce8 100644 --- a/test/page/instant_safety/providers/instant_safety_provider_test.dart +++ b/test/page/instant_safety/providers/instant_safety_provider_test.dart @@ -188,7 +188,7 @@ void main() { when(() => mockService.fetch()).thenAnswer( (_) async => const SafeBrowsingUIModel(type: SafeBrowsingType.off)); when(() => mockService.save(any())) - .thenThrow(const NetworkError(message: 'save failed')); + .thenThrow(const NetworkError(detail: 'save failed')); final container = createContainer(); await Future.delayed(Duration.zero); diff --git a/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart b/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart index 747244770..85480d30b 100644 --- a/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart +++ b/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart @@ -88,7 +88,7 @@ void main() { test('fetch error sets error status', () async { when(() => mockService.fetchSettings()) - .thenThrow(const NetworkError(message: 'bridge unreachable')); + .thenThrow(const NetworkError(detail: 'bridge unreachable')); final container = createContainer(); await Future.delayed(Duration.zero); @@ -285,7 +285,7 @@ void main() { when(() => mockService.fetchSettings()) .thenAnswer((_) async => testFetchResult); when(() => mockService.saveAll(any(), any())) - .thenThrow(const NetworkError(message: 'save failed')); + .thenThrow(const NetworkError(detail: 'save failed')); final container = createContainer(); await Future.delayed(Duration.zero); diff --git a/test/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier_test.dart b/test/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier_test.dart index 85a01c991..f144507c4 100644 --- a/test/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier_test.dart +++ b/test/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier_test.dart @@ -77,7 +77,7 @@ void main() { test('fetch error sets error status', () async { when(() => mockService.fetch()) - .thenThrow(const NetworkError(message: 'fetch failed')); + .thenThrow(const NetworkError(detail: 'fetch failed')); final container = createContainer(); await Future.delayed(Duration.zero); @@ -169,7 +169,7 @@ void main() { when(() => mockService.saveBatch( original: any(named: 'original'), current: any(named: 'current'), - )).thenThrow(const NetworkError(message: 'save failed')); + )).thenThrow(const NetworkError(detail: 'save failed')); final container = createContainer(); await Future.delayed(Duration.zero); diff --git a/test/page/local_network/providers/usp_local_network_notifier_test.dart b/test/page/local_network/providers/usp_local_network_notifier_test.dart index 8de9e6d44..8a44dee77 100644 --- a/test/page/local_network/providers/usp_local_network_notifier_test.dart +++ b/test/page/local_network/providers/usp_local_network_notifier_test.dart @@ -183,7 +183,7 @@ void main() { uspMutationLockProvider.overrideWithValue(UspMutationLock()), lanDataProvider.overrideWith(() => _TestLanDataNotifier( testLanData, - errorToThrow: const NetworkError(message: 'timeout'), + errorToThrow: const NetworkError(detail: 'timeout'), )), ], ); @@ -218,7 +218,7 @@ void main() { when(() => mockService.save( original: any(named: 'original'), pending: any(named: 'pending'), - )).thenThrow(const NetworkError(message: 'save failed')); + )).thenThrow(const NetworkError(detail: 'save failed')); final container = createContainer(); await Future.delayed(Duration.zero); diff --git a/test/page/port_forwarding/providers/usp_port_forwarding_page_notifier_test.dart b/test/page/port_forwarding/providers/usp_port_forwarding_page_notifier_test.dart index d64d5c2ac..be9cf6ddc 100644 --- a/test/page/port_forwarding/providers/usp_port_forwarding_page_notifier_test.dart +++ b/test/page/port_forwarding/providers/usp_port_forwarding_page_notifier_test.dart @@ -98,7 +98,7 @@ void main() { test('fetch error sets error status', () async { when(() => mockService.fetchForwardingRules()) - .thenThrow(const NetworkError(message: 'timeout')); + .thenThrow(const NetworkError(detail: 'timeout')); when(() => mockService.fetchTriggeringRules()) .thenAnswer((_) async => [pt1]); final container = createContainer(); @@ -287,7 +287,7 @@ void main() { when(() => mockService.saveForwardingBatch( original: any(named: 'original'), current: any(named: 'current'), - )).thenThrow(const NetworkError(message: 'save failed')); + )).thenThrow(const NetworkError(detail: 'save failed')); when(() => mockService.saveTriggeringBatch( original: any(named: 'original'), current: any(named: 'current'), diff --git a/test/page/static_routing/providers/usp_static_routing_notifier_test.dart b/test/page/static_routing/providers/usp_static_routing_notifier_test.dart index a4da62b4b..31e6db3cd 100644 --- a/test/page/static_routing/providers/usp_static_routing_notifier_test.dart +++ b/test/page/static_routing/providers/usp_static_routing_notifier_test.dart @@ -81,7 +81,7 @@ void main() { test('fetch error sets error status', () async { when(() => mockService.fetch()) - .thenThrow(const NetworkError(message: 'connection lost')); + .thenThrow(const NetworkError(detail: 'connection lost')); final container = createContainer(); await Future.delayed(Duration.zero); @@ -173,7 +173,7 @@ void main() { when(() => mockService.saveBatch( original: any(named: 'original'), current: any(named: 'current'), - )).thenThrow(const NetworkError(message: 'save failed')); + )).thenThrow(const NetworkError(detail: 'save failed')); final container = createContainer(); await Future.delayed(Duration.zero); diff --git a/test/page/system_log/providers/usp_system_log_notifier_test.dart b/test/page/system_log/providers/usp_system_log_notifier_test.dart index f3d7ea446..cc2df66f6 100644 --- a/test/page/system_log/providers/usp_system_log_notifier_test.dart +++ b/test/page/system_log/providers/usp_system_log_notifier_test.dart @@ -82,7 +82,7 @@ void main() { test('build sets AsyncError with ServiceError when service throws', () async { when(() => mockService.fetch()) - .thenThrow(const NetworkError(message: 'timeout')); + .thenThrow(const NetworkError(detail: 'timeout')); final container = createContainer(); try { diff --git a/test/page/unified_diagnostics/services/diagnostics_scope_service_test.dart b/test/page/unified_diagnostics/services/diagnostics_scope_service_test.dart index 5504f5610..3b4e1f240 100644 --- a/test/page/unified_diagnostics/services/diagnostics_scope_service_test.dart +++ b/test/page/unified_diagnostics/services/diagnostics_scope_service_test.dart @@ -55,7 +55,7 @@ void main() { test('rethrows ServiceError unchanged', () async { when(() => mockExecutor.acquireScope()) - .thenThrow(const NetworkError(message: 'Connection lost')); + .thenThrow(const NetworkError(detail: 'Connection lost')); expect( () => service.acquireScope(), @@ -152,7 +152,7 @@ void main() { host: any(named: 'host'), numberOfRepetitions: any(named: 'numberOfRepetitions'), timeout: any(named: 'timeout'), - )).thenThrow(const InvalidInputError(message: 'Invalid host')); + )).thenThrow(const InvalidInputError(detail: 'Invalid host')); expect( () => service.ping(mockScope, host: 'invalid'), diff --git a/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart b/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart index d19d9d720..e175cb8f3 100644 --- a/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart +++ b/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart @@ -55,7 +55,7 @@ void main() { test('sets error status when service throws ServiceError', () async { when(() => mockService.fetchIeee80211h()) - .thenThrow(const NetworkError(message: 'timeout')); + .thenThrow(const NetworkError(detail: 'timeout')); final container = createContainer(); await Future.delayed(Duration.zero); @@ -243,7 +243,7 @@ void main() { when(() => mockService.setIeee80211hEnabled( radioPaths: any(named: 'radioPaths'), enabled: any(named: 'enabled'), - )).thenThrow(const InvalidInputError(message: 'read-only')); + )).thenThrow(const InvalidInputError(detail: 'read-only')); final container = createContainer(); await Future.delayed(Duration.zero); diff --git a/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart b/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart index 903aecc47..ced602957 100644 --- a/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart +++ b/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart @@ -552,7 +552,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspAuthCoordinatorProvider.overrideWithValue(mockAuthCoordinator), wifiDataProvider.overrideWith(() => - _ErrorWifiDataNotifier(const NetworkError(message: 'timeout'))), + _ErrorWifiDataNotifier(const NetworkError(detail: 'timeout'))), ], ); container.listen(uspWifiSettingsProvider, (_, __) {}); @@ -585,7 +585,7 @@ void main() { when(() => mockService.saveAdvanced( original: any(named: 'original'), current: any(named: 'current'), - )).thenThrow(const NetworkError(message: 'HTTP 504')); + )).thenThrow(const NetworkError(detail: 'HTTP 504')); final container = createContainer(); await Future.delayed(Duration.zero); @@ -616,7 +616,7 @@ void main() { when(() => mockService.saveAdvanced( original: any(named: 'original'), current: any(named: 'current'), - )).thenThrow(const NetworkError(message: 'HTTP 504')); + )).thenThrow(const NetworkError(detail: 'HTTP 504')); final container = createContainer(); await Future.delayed(Duration.zero); From 281169e019844f5bf9e0a4c4a9452be363c3677d Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Wed, 17 Jun 2026 17:27:05 +0800 Subject: [PATCH 2/7] feat(l10n): centralize error message localization for USP features All USP requests (Get/Set/Add/Operate) across feature pages now flow through a unified error handling pipeline: - Path 1 (fetch): errors stored in `state.error`, displayed via `ServiceErrorView` - Path 2 (save): errors rethrown to View, displayed via snackbar with `localizeServiceError` Key changes: - Add `ServiceError.code` and `ServiceError.detail` for diagnostic context - Add `TimeoutError` subtype for timeout handling - Remove unused OTP/admin-password ServiceError subtypes - Add `localizeServiceError()` central mapper with exhaustive switch - Add `ServiceErrorView` shared widget replacing per-feature `_buildError()` - Add 12 ARB keys for error messages - Change all feature state models: `String? errorMessage` -> `ServiceError? error` - Update providers to pass through ServiceError objects (not stringify) - Update views to use shared components Coverage: all USP feature pages except firmware_update (deferred). Note: SSE subscription errors are out of scope for this change. Note: UnexpectedError surfaces raw `detail` string as final fallback. --- .../service_error_localizations.dart | 72 ++++++++++++ lib/components/views/service_error_view.dart | 50 +++++++++ lib/core/errors/service_error.dart | 105 ++++-------------- lib/core/usp/errors/usp_error.dart | 28 +++-- .../usp/providers/usp_auth_coordinator.dart | 8 +- lib/l10n/app_en.arb | 16 ++- lib/page/admin/views/usp_admin_view.dart | 18 +-- .../dashboard/views/usp_dashboard_view.dart | 8 +- .../dhcp/models/dhcp_reservations_status.dart | 15 ++- .../usp_dhcp_reservations_notifier.dart | 2 +- lib/page/dhcp/views/usp_dhcp_detail_view.dart | 59 ++++------ lib/page/dmz/models/dmz_status.dart | 15 ++- lib/page/dmz/providers/usp_dmz_notifier.dart | 2 +- lib/page/dmz/views/usp_dmz_view.dart | 36 ++---- lib/page/firewall/models/firewall_status.dart | 15 ++- .../providers/usp_firewall_notifier.dart | 2 +- .../firewall/views/usp_firewall_view.dart | 36 ++---- .../views/instant_privacy_view.dart | 14 ++- .../models/instant_safety_status.dart | 15 ++- .../views/instant_safety_view.dart | 36 ++---- .../models/internet_settings_status.dart | 15 ++- .../usp_internet_settings_notifier.dart | 2 +- .../views/usp_internet_settings_view.dart | 30 ++--- .../models/ipv6_port_service_status.dart | 15 ++- .../usp_ipv6_port_service_notifier.dart | 2 +- .../views/usp_ipv6_port_service_view.dart | 38 ++----- .../models/local_network_status.dart | 15 ++- .../providers/usp_local_network_notifier.dart | 2 +- .../views/usp_local_network_view.dart | 38 ++----- .../models/port_forwarding_page_status.dart | 15 ++- .../usp_port_forwarding_page_notifier.dart | 2 +- .../usp_port_forwarding_detail_view.dart | 28 ++--- .../models/static_routing_status.dart | 15 ++- .../usp_static_routing_notifier.dart | 2 +- .../views/usp_static_routing_view.dart | 38 ++----- .../system_log/views/usp_system_log_view.dart | 8 +- .../cards/usp_speed_test_card.dart | 5 +- .../models/diagnostic_state.dart | 13 ++- .../models/manual_tools_state.dart | 11 +- .../models/speed_test_state.dart | 11 +- .../providers/manual_tools_notifier.dart | 45 +------- .../providers/speed_test_notifier.dart | 32 ++---- .../unified_diagnostics_notifier.dart | 26 +++-- .../services/diagnostic_report_service.dart | 4 +- .../views/speed_test_view.dart | 5 +- .../widgets/diagnostic_manual_tools_view.dart | 5 +- .../models/wifi_advanced_status.dart | 17 ++- .../models/wifi_settings_status.dart | 17 +-- .../providers/usp_wifi_advanced_provider.dart | 2 +- .../providers/usp_wifi_settings_provider.dart | 11 +- .../views/tabs/wifi_advanced_tab.dart | 30 +---- .../views/tabs/wifi_list_tab.dart | 10 +- .../views/usp_wifi_settings_view.dart | 3 +- lib/providers/auth/auth_provider.dart | 16 +-- test/core/errors/service_error_test.dart | 5 - .../page/dmz/fixtures/dmz_test_data.dart | 3 +- .../firewall/fixtures/firewall_test_data.dart | 3 +- .../fixtures/internet_settings_test_data.dart | 3 +- .../fixtures/local_network_test_data.dart | 3 +- .../fixtures/port_forwarding_test_data.dart | 3 +- .../fixtures/static_routing_test_data.dart | 3 +- .../unified_diagnostics_test_data.dart | 7 +- .../test_data/wifi_settings_test_data.dart | 5 +- .../usp_dhcp_reservations_notifier_test.dart | 2 +- .../dmz/providers/usp_dmz_notifier_test.dart | 2 +- .../providers/usp_firewall_notifier_test.dart | 2 +- .../models/internet_settings_status_test.dart | 22 +++- .../usp_internet_settings_notifier_test.dart | 4 +- .../usp_ipv6_port_service_notifier_test.dart | 2 +- .../usp_local_network_notifier_test.dart | 2 +- ...sp_port_forwarding_page_notifier_test.dart | 2 +- .../usp_static_routing_notifier_test.dart | 2 +- .../models/diagnostic_state_test.dart | 11 +- .../models/manual_tools_state_test.dart | 10 +- .../providers/manual_tools_notifier_test.dart | 11 +- .../providers/speed_test_notifier_test.dart | 5 +- .../diagnostic_report_service_test.dart | 7 +- .../wifi_advanced_feature_state_test.dart | 2 +- .../usp_wifi_advanced_notifier_test.dart | 4 +- .../usp_wifi_settings_notifier_test.dart | 5 +- 80 files changed, 588 insertions(+), 622 deletions(-) create mode 100644 lib/components/localizations/service_error_localizations.dart create mode 100644 lib/components/views/service_error_view.dart diff --git a/lib/components/localizations/service_error_localizations.dart b/lib/components/localizations/service_error_localizations.dart new file mode 100644 index 000000000..b39180fc5 --- /dev/null +++ b/lib/components/localizations/service_error_localizations.dart @@ -0,0 +1,72 @@ +import 'package:flutter/widgets.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/usp/models/usp_operation_result.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; + +/// Central mapper: turns a [ServiceError] into a localized, user-facing message. +/// +/// This is the ONLY place error types become display strings. The Service layer +/// produces typed [ServiceError]s (carrying diagnostic `code`/`detail`); the +/// Provider layer passes them through untouched; the View calls this with a +/// [BuildContext] to localize. +/// +/// Design: +/// - Most subtypes map purely by TYPE → one l10n string. `detail`/`code` are +/// diagnostic only and are NOT shown (they are firmware/WASM technical text). +/// - Batch errors ([UspPartialFailureError]/[UspCompleteFailureError]) are +/// containers; we localize the FIRST failure's code so the user sees a +/// concrete, actionable message (not a vague "N items failed"). +/// - [UnexpectedError] is the one fallback type with no type-specific meaning, +/// so its `detail` is surfaced when present. +/// +/// The `switch` is exhaustive over the sealed hierarchy — adding a new +/// [ServiceError] subtype will produce a compile-time warning here, forcing a +/// localization decision. +String localizeServiceError(BuildContext context, Object error) { + final l = loc(context); + // Non-ServiceError (shouldn't normally reach here, but be defensive). + if (error is! ServiceError) return l.errorUnexpected; + return switch (error) { + NotAuthenticatedError() => l.errorNotAuthenticated, + InvalidCredentialsError() => l.errorInvalidCredentials, + SessionTokenExpiredError() => l.errorSessionExpired, + InvalidSessionTokenError() => l.errorInvalidSessionToken, + UnauthorizedError() => l.errorUnauthorized, + ResourceNotFoundError() => l.errorResourceNotFound, + InvalidInputError() => l.errorInvalidInput, + NetworkError() => l.errorNetwork, + ConnectivityError() => l.errorConnectivity, + TimeoutError() => l.errorTimeout, + ServiceNotInitializedError() => l.errorServiceNotReady, + // Batch: show the first failure's concrete message (actionable). + UspPartialFailureError(:final failures) => + _localizeBatch(context, failures), + UspCompleteFailureError(:final failures) => + _localizeBatch(context, failures), + // Fallback: no type-specific semantics — surface detail if present. + UnexpectedError(:final detail) => detail ?? l.errorUnexpected, + // Infrastructure-level: caught at session/auth layer, never reaches UI. + StorageError() => l.errorUnexpected, + SerialNumberMismatchError() => l.errorUnexpected, + }; +} + +/// Localizes a batch failure by its FIRST entry's fault code. +/// +/// Rationale: "N settings failed" is unactionable. Showing the first concrete +/// error lets the user fix it; the next save surfaces the next failure. +String _localizeBatch(BuildContext context, List failures) { + final l = loc(context); + if (failures.isEmpty) return l.errorUnexpected; + final first = failures.first; + // Map the fault code to a typed l10n string using UspErrorDetail helpers. + if (first.isParameterNotFound || first.isObjectNotFound) { + return l.errorResourceNotFound; + } + if (first.isInvalidParameterName || + first.isInvalidParameterValue || + first.isParameterNotWritable) { + return l.errorInvalidInput; + } + return l.errorUnexpected; +} diff --git a/lib/components/views/service_error_view.dart b/lib/components/views/service_error_view.dart new file mode 100644 index 000000000..053bdba4b --- /dev/null +++ b/lib/components/views/service_error_view.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Shared fetch-failure empty state. +/// +/// Replaces the per-feature private `_buildError` methods. Shows a localized +/// title, the localized [ServiceError] detail (via [localizeServiceError]), +/// and a retry button. +class ServiceErrorView extends StatelessWidget { + /// The error to display. When null, only the generic title is shown. + final ServiceError? error; + + /// Called when the user taps retry (e.g. re-fetch with forceRemote). + final VoidCallback onRetry; + + const ServiceErrorView({ + super.key, + required this.error, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final message = + error != null ? localizeServiceError(context, error!) : null; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon.font(Icons.error_outline, + size: 48, color: Theme.of(context).colorScheme.error), + AppGap.xl(), + AppText.titleMedium(loc(context).failedToLoadSettings), + if (message != null) ...[ + AppGap.sm(), + AppText.bodyMedium(message), + ], + AppGap.md(), + AppButton( + label: loc(context).retry, + onTap: onRetry, + ), + ], + ), + ); + } +} diff --git a/lib/core/errors/service_error.dart b/lib/core/errors/service_error.dart index 1939d4d17..a8f530bf7 100644 --- a/lib/core/errors/service_error.dart +++ b/lib/core/errors/service_error.dart @@ -10,24 +10,21 @@ import 'package:privacy_gui/core/usp/models/usp_operation_result.dart' /// /// Example: /// ```dart -/// // In Service layer - map JNAP errors to ServiceError +/// // In Service layer - map USP/JNAP errors to ServiceError /// try { -/// await routerRepository.send(...); -/// } on JNAPError catch (e) { -/// throw switch (e.result) { -/// 'ErrorInvalidResetCode' => InvalidResetCodeError(attemptsRemaining: 3), -/// 'ErrorAdminAccountLocked' => const AdminAccountLockedError(), +/// await uspService.setParameters(...); +/// } catch (e) { +/// throw switch (e) { +/// UspError(:final code) when code == 7010 => const InvalidInputError(), /// _ => UnexpectedError(originalError: e), /// }; /// } /// /// // In Provider layer - catch ServiceError only /// try { -/// await service.verifyCode(code); -/// } on InvalidResetCodeError catch (e) { -/// state = state.copyWith(attemptsRemaining: e.attemptsRemaining); -/// } on AdminAccountLockedError { -/// // Handle locked account +/// await service.updateSettings(settings); +/// } on InvalidInputError { +/// state = state.copyWith(hasInputError: true); /// } /// ``` sealed class ServiceError implements Exception { @@ -110,46 +107,6 @@ final class ResourceNotFoundError extends ServiceError { const ResourceNotFoundError({super.code, super.detail}); } -// ============================================================================ -// OTP Errors -// ============================================================================ - -/// Invalid OTP code -final class InvalidOtpError extends ServiceError { - const InvalidOtpError({super.code, super.detail}); -} - -/// OTP code has expired -final class ExpiredOtpError extends ServiceError { - const ExpiredOtpError({super.code, super.detail}); -} - -// ============================================================================ -// Admin Password Errors -// ============================================================================ - -/// Admin account is locked -final class AdminAccountLockedError extends ServiceError { - const AdminAccountLockedError({super.code, super.detail}); -} - -/// Invalid reset code provided -final class InvalidResetCodeError extends ServiceError { - final int? attemptsRemaining; - const InvalidResetCodeError( - {this.attemptsRemaining, super.code, super.detail}); -} - -/// Too many consecutive invalid reset code attempts -final class ConsecutiveInvalidResetCodeError extends ServiceError { - const ConsecutiveInvalidResetCodeError({super.code, super.detail}); -} - -/// Invalid admin password -final class InvalidAdminPasswordError extends ServiceError { - const InvalidAdminPasswordError({super.code, super.detail}); -} - // ============================================================================ // General Errors // ============================================================================ @@ -205,6 +162,18 @@ final class NetworkError extends ServiceError { detail != null ? 'Network error: $detail' : super.toString(); } +/// Operation timed out before completing. +/// +/// A generic timeout (any operation may time out) — not bound to a specific +/// feature. Used e.g. by diagnostics to fold Dart's [TimeoutException] into the +/// [ServiceError] hierarchy so the UI can localize it by type. +final class TimeoutError extends ServiceError { + const TimeoutError({super.code, super.detail}); + + @override + String toString() => detail != null ? 'Timeout: $detail' : super.toString(); +} + /// Storage operation error final class StorageError extends ServiceError { final Object? originalError; @@ -280,37 +249,3 @@ final class ConnectivityError extends ServiceError { String toString() => detail != null ? 'Connectivity error: $detail' : super.toString(); } - -// ============================================================================ -// Side Effect Error (Operation succeeded but device recovery timed out) -// ============================================================================ - -/// Operation succeeded but triggered a side effect requiring device recovery. -/// -/// Unlike other [ServiceError] subtypes, this indicates the operation DID succeed. -/// The device is now recovering (restarting, reconnecting, etc.) and we timed out -/// waiting for it to come back online. -/// -/// - [originalResult]: The JNAP result from the operation that triggered the -/// side effect. Contains data like redirection URLs needed after device recovery. -/// - [lastPolledResult]: The last successful poll result before timeout. -/// Useful for diagnosing the device's final known state. -/// -/// UI should typically: -/// 1. Inform user the settings were saved -/// 2. Guide user to reconnect to the device -/// -/// Example: -/// ```dart -/// try { -/// await service.saveSettings(settings); -/// } on ServiceSideEffectError { -/// showRouterNotFoundAlert(context, ref); -/// } -/// ``` -final class ServiceSideEffectError extends ServiceError { - final Object? originalResult; - final Object? lastPolledResult; - - const ServiceSideEffectError([this.originalResult, this.lastPolledResult]); -} diff --git a/lib/core/usp/errors/usp_error.dart b/lib/core/usp/errors/usp_error.dart index 1a51d506e..0ee0a1e6f 100644 --- a/lib/core/usp/errors/usp_error.dart +++ b/lib/core/usp/errors/usp_error.dart @@ -247,19 +247,25 @@ ServiceError _mapProtocolError(UspError e) { return UnexpectedError(originalError: e.rawError, detail: e.message); } -/// Maps `Operation error:` category strings. +/// Maps `Operation error:` category strings (e.g. "Path not found: ...", +/// "Parameter is read-only: ...", "Invalid value '...' for '...': ..."). /// -/// NOTE: In the production WASM build this path is effectively unreachable. -/// `UspError::OperationError` variants (`PathNotFound`/`ReadOnly`/`InvalidValue`) -/// are only constructed in the Rust `ffi` module, which is gated behind -/// `#[cfg(not(target_arch = "wasm32"))]` (lib.rs:22) — i.e. native FFI only, -/// stripped from the WASM binary. The only WASM-side `OperationError` is -/// `OperateFailed` from `subscribe`/`unsubscribe`, but those surface as a -/// thrown string via Promise reject and never reach codegen's catch, so they -/// don't pass through here. +/// Two things to know about this mapper: /// -/// Kept for completeness, defensive coverage, and to satisfy the existing -/// contract tests (usp_error_test.dart). Do not rely on it firing in prod. +/// 1. **Effectively dead in production.** `UspError::OperationError` variants +/// are constructed ONLY in the Rust `ffi` module, gated behind +/// `#[cfg(not(target_arch = "wasm32"))]` — native FFI only, stripped from the +/// WASM binary the app actually runs. (The one WASM-side `OperationError`, +/// `OperateFailed` from subscribe/unsubscribe, surfaces as a thrown string +/// via Promise reject and never reaches codegen's catch, so it skips here.) +/// Kept only for completeness + the existing contract tests; don't rely on +/// it firing in prod. +/// +/// 2. **No `code` is passed — by design.** Unlike protocol errors, the Rust +/// `OperationError` Display strings carry NO `(code: XXXX)` suffix (they are +/// path/reason text only). So `parseUspError`'s regex never extracts a +/// faultCode here — `e.faultCode` is always null. Passing `code:` would just +/// forward null, so it's omitted. Only `detail` (the raw message) is kept. ServiceError _mapOperationError(UspError e) { final msg = e.message; if (msg.contains('Path not found')) return const ResourceNotFoundError(); diff --git a/lib/core/usp/providers/usp_auth_coordinator.dart b/lib/core/usp/providers/usp_auth_coordinator.dart index 545bd8dca..ebcc39eaa 100644 --- a/lib/core/usp/providers/usp_auth_coordinator.dart +++ b/lib/core/usp/providers/usp_auth_coordinator.dart @@ -147,7 +147,7 @@ class UspAuthCoordinator { if (_usp == null) { logger.w('[USP][Auth]: tryUspLogin skipped: UspClient is null'); throw const ServiceNotInitializedError( - message: 'USP client not available'); + detail: 'USP client not available'); } try { await _usp.login(password); @@ -162,15 +162,15 @@ class UspAuthCoordinator { // Map WASM errors to ServiceError final errorStr = e.toString(); if (_isAccountLockedError(e)) { - throw const AdminAccountLockedError(); + throw UnexpectedError(originalError: e, detail: 'Account locked'); } else if (_isAuthError(e)) { throw const InvalidCredentialsError(); } else if (errorStr.contains('HTTP 5') || errorStr.contains('network') || errorStr.contains('fetch')) { - throw NetworkError(message: errorStr); + throw NetworkError(detail: errorStr); } - throw UnexpectedError(originalError: e, message: errorStr); + throw UnexpectedError(originalError: e, detail: errorStr); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7214b5912..09f70b380 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1167,5 +1167,17 @@ "failedToLoadSettings": "Failed to load settings", "retry": "Retry", "pppStatus": "PPP Status", - "dhcpv6": "DHCPv6" -} \ No newline at end of file + "dhcpv6": "DHCPv6", + "errorNotAuthenticated": "You are not signed in. Please sign in and try again.", + "errorInvalidCredentials": "Incorrect username or password.", + "errorSessionExpired": "Your session has expired. Please sign in again.", + "errorInvalidSessionToken": "Your session is invalid. Please sign in again.", + "errorUnauthorized": "You do not have permission to perform this action.", + "errorResourceNotFound": "The requested setting could not be found on the router.", + "errorInvalidInput": "The value entered is not valid. Please check and try again.", + "errorNetwork": "Network error. Please check your connection and try again.", + "errorConnectivity": "Cannot reach the router. Please check your connection.", + "errorTimeout": "The operation timed out. Please try again.", + "errorServiceNotReady": "The service is not ready yet. Please try again in a moment.", + "errorUnexpected": "Something went wrong. Please try again." +} diff --git a/lib/page/admin/views/usp_admin_view.dart b/lib/page/admin/views/usp_admin_view.dart index 4c694b721..10bf5a6ed 100644 --- a/lib/page/admin/views/usp_admin_view.dart +++ b/lib/page/admin/views/usp_admin_view.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/helpers/recovery_dialog_helper.dart'; import 'package:privacy_gui/core/connection/models/app_connection_state.dart'; import 'package:privacy_gui/page/admin/providers/usp_admin_notifier.dart'; @@ -58,12 +60,12 @@ class UspAdminView extends ConsumerWidget { AppIcon.font(Icons.error_outline, size: 48, color: Theme.of(context).colorScheme.error), AppGap.xl(), - AppText.titleMedium('Unable to load admin data'), + AppText.titleMedium(loc(context).failedToLoadSettings), AppGap.md(), - AppText.bodyMedium(error.toString()), + AppText.bodyMedium(localizeServiceError(context, error)), AppGap.xxl(), AppButton( - label: 'Retry', + label: loc(context).retry, onTap: () => ref.invalidate(uspAdminProvider), ), ], @@ -176,7 +178,7 @@ class UspAdminView extends ConsumerWidget { if (context.mounted) showSuccessSnackBar(context, 'Timezone updated'); } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to update timezone: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } @@ -196,7 +198,7 @@ class UspAdminView extends ConsumerWidget { } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to update password: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } @@ -228,7 +230,9 @@ class UspAdminView extends ConsumerWidget { successMessage: 'Router reboot complete', ); } catch (e) { - if (context.mounted) showFailedSnackBar(context, 'Reboot failed: $e'); + if (context.mounted) { + showFailedSnackBar(context, localizeServiceError(context, e)); + } } } @@ -260,7 +264,7 @@ class UspAdminView extends ConsumerWidget { ); } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Factory reset failed: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/page/dashboard/views/usp_dashboard_view.dart b/lib/page/dashboard/views/usp_dashboard_view.dart index 8736f201c..7210d9526 100644 --- a/lib/page/dashboard/views/usp_dashboard_view.dart +++ b/lib/page/dashboard/views/usp_dashboard_view.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/providers/usp_bars_visible_provider.dart'; import 'package:privacy_gui/page/dashboard/orchestrator/dashboard_orchestrator.dart'; import 'package:privacy_gui/page/dashboard/views/usp_sliver_dashboard_view.dart'; @@ -68,12 +70,12 @@ class UspDashboardView extends ConsumerWidget { AppIcon.font(Icons.error_outline, size: 48, color: Theme.of(context).colorScheme.error), AppGap.xl(), - AppText.titleMedium('Unable to load USP data'), + AppText.titleMedium(loc(context).failedToLoadSettings), AppGap.md(), - AppText.bodyMedium(error.toString()), + AppText.bodyMedium(localizeServiceError(context, error)), AppGap.xxl(), AppButton( - label: 'Retry', + label: loc(context).retry, onTap: () => ref.read(dashboardOrchestratorProvider.notifier).refreshAll(), ), diff --git a/lib/page/dhcp/models/dhcp_reservations_status.dart b/lib/page/dhcp/models/dhcp_reservations_status.dart index c3051b707..fba189bad 100644 --- a/lib/page/dhcp/models/dhcp_reservations_status.dart +++ b/lib/page/dhcp/models/dhcp_reservations_status.dart @@ -1,29 +1,34 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; /// Transient status for the DHCP Reservations page notifier. class DhcpReservationsStatus extends Equatable { final bool isLoading; final bool isSaving; - final String? errorMessage; + + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; const DhcpReservationsStatus({ this.isLoading = false, this.isSaving = false, - this.errorMessage, + this.error, }); DhcpReservationsStatus copyWith({ bool? isLoading, bool? isSaving, - String? errorMessage, + ServiceError? error, + bool clearError = false, }) { return DhcpReservationsStatus( isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage, + error: clearError ? null : (error ?? this.error), ); } @override - List get props => [isLoading, isSaving, errorMessage]; + List get props => [isLoading, isSaving, error]; } diff --git a/lib/page/dhcp/providers/usp_dhcp_reservations_notifier.dart b/lib/page/dhcp/providers/usp_dhcp_reservations_notifier.dart index 3d43dd1b7..087b02099 100644 --- a/lib/page/dhcp/providers/usp_dhcp_reservations_notifier.dart +++ b/lib/page/dhcp/providers/usp_dhcp_reservations_notifier.dart @@ -75,7 +75,7 @@ class UspDhcpReservationsNotifier logger.e('[USP][DHCP][Reservations]: Fetch failed', error: e); return ( null, - DhcpReservationsStatus(errorMessage: '$e'), + DhcpReservationsStatus(error: e), ); } } diff --git a/lib/page/dhcp/views/usp_dhcp_detail_view.dart b/lib/page/dhcp/views/usp_dhcp_detail_view.dart index 9254c7f24..488982bc8 100644 --- a/lib/page/dhcp/views/usp_dhcp_detail_view.dart +++ b/lib/page/dhcp/views/usp_dhcp_detail_view.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; @@ -55,13 +58,29 @@ class UspDhcpDetailView extends ConsumerWidget { ); } - if (reservationStatus.errorMessage != null) { - return _buildError( - childContext, ref, reservationStatus.errorMessage!); + if (reservationStatus.error != null) { + return ServiceErrorView( + error: reservationStatus.error, + onRetry: () { + ref.invalidate(dhcpDataProvider); + ref + .read(uspDhcpReservationsProvider.notifier) + .fetch(forceRemote: true); + }, + ); } if (asyncDhcp.hasError && asyncDhcp.valueOrNull == null) { - return _buildError(childContext, ref, asyncDhcp.error.toString()); + final asyncError = asyncDhcp.error; + return ServiceErrorView( + error: asyncError is ServiceError ? asyncError : null, + onRetry: () { + ref.invalidate(dhcpDataProvider); + ref + .read(uspDhcpReservationsProvider.notifier) + .fetch(forceRemote: true); + }, + ); } return _buildContent(childContext, ref); @@ -88,36 +107,6 @@ class UspDhcpDetailView extends ConsumerWidget { ); } - // --------------------------------------------------------------------------- - // Error - // --------------------------------------------------------------------------- - - Widget _buildError(BuildContext context, WidgetRef ref, Object error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium('Unable to load DHCP data'), - AppGap.md(), - AppText.bodyMedium(error.toString()), - AppGap.xxl(), - AppButton( - label: 'Retry', - onTap: () { - ref.invalidate(dhcpDataProvider); - ref - .read(uspDhcpReservationsProvider.notifier) - .fetch(forceRemote: true); - }, - ), - ], - ), - ); - } - // --------------------------------------------------------------------------- // Content // --------------------------------------------------------------------------- @@ -216,7 +205,7 @@ class UspDhcpDetailView extends ConsumerWidget { } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to save: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/page/dmz/models/dmz_status.dart b/lib/page/dmz/models/dmz_status.dart index 1112b6213..e369f9108 100644 --- a/lib/page/dmz/models/dmz_status.dart +++ b/lib/page/dmz/models/dmz_status.dart @@ -1,33 +1,38 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; /// Transient (non-editable) status for the DMZ feature page. class DmzStatus extends Equatable { final bool isLoading; final bool isSaving; - final String? errorMessage; + + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; final Map fieldErrors; const DmzStatus({ this.isLoading = true, this.isSaving = false, - this.errorMessage, + this.error, this.fieldErrors = const {}, }); DmzStatus copyWith({ bool? isLoading, bool? isSaving, - String? errorMessage, + ServiceError? error, + bool clearError = false, Map? fieldErrors, }) { return DmzStatus( isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage, + error: clearError ? null : (error ?? this.error), fieldErrors: fieldErrors ?? this.fieldErrors, ); } @override - List get props => [isLoading, isSaving, errorMessage, fieldErrors]; + List get props => [isLoading, isSaving, error, fieldErrors]; } diff --git a/lib/page/dmz/providers/usp_dmz_notifier.dart b/lib/page/dmz/providers/usp_dmz_notifier.dart index c9da63dec..00eb8f82c 100644 --- a/lib/page/dmz/providers/usp_dmz_notifier.dart +++ b/lib/page/dmz/providers/usp_dmz_notifier.dart @@ -73,7 +73,7 @@ class UspDmzNotifier extends AutoDisposeNotifier logger.e('[USP][Firewall][DMZ]: Fetch failed', error: e); return ( null, - DmzStatus(isLoading: false, errorMessage: '$e'), + DmzStatus(isLoading: false, error: e), ); } } diff --git a/lib/page/dmz/views/usp_dmz_view.dart b/lib/page/dmz/views/usp_dmz_view.dart index 5efba8529..bc66592ea 100644 --- a/lib/page/dmz/views/usp_dmz_view.dart +++ b/lib/page/dmz/views/usp_dmz_view.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/dmz/models/dmz_feature_state.dart'; @@ -70,8 +72,12 @@ class _UspDmzViewState extends ConsumerState { if (status.isLoading) { return const Center(child: AppLoader()); } - if (status.errorMessage != null) { - return _buildError(context, ref); + if (status.error != null) { + return ServiceErrorView( + error: status.error, + onRetry: () => + ref.read(uspDmzProvider.notifier).fetch(forceRemote: true), + ); } _syncControllers(state); return _buildContent(context, ref, state); @@ -98,30 +104,6 @@ class _UspDmzViewState extends ConsumerState { ); } - // --------------------------------------------------------------------------- - // Error - // --------------------------------------------------------------------------- - - Widget _buildError(BuildContext context, WidgetRef ref) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium('Unable to load DMZ settings'), - AppGap.md(), - AppButton( - label: 'Retry', - onTap: () => - ref.read(uspDmzProvider.notifier).fetch(forceRemote: true), - ), - ], - ), - ); - } - // --------------------------------------------------------------------------- // Content // --------------------------------------------------------------------------- @@ -301,7 +283,7 @@ class _UspDmzViewState extends ConsumerState { } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to save: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/page/firewall/models/firewall_status.dart b/lib/page/firewall/models/firewall_status.dart index fab526d1c..70f5a9520 100644 --- a/lib/page/firewall/models/firewall_status.dart +++ b/lib/page/firewall/models/firewall_status.dart @@ -1,29 +1,34 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; /// Transient (non-editable) status for the firewall feature page. class FirewallStatus extends Equatable { final bool isLoading; final bool isSaving; - final String? errorMessage; + + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; const FirewallStatus({ this.isLoading = true, this.isSaving = false, - this.errorMessage, + this.error, }); FirewallStatus copyWith({ bool? isLoading, bool? isSaving, - String? errorMessage, + ServiceError? error, + bool clearError = false, }) { return FirewallStatus( isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage, + error: clearError ? null : (error ?? this.error), ); } @override - List get props => [isLoading, isSaving, errorMessage]; + List get props => [isLoading, isSaving, error]; } diff --git a/lib/page/firewall/providers/usp_firewall_notifier.dart b/lib/page/firewall/providers/usp_firewall_notifier.dart index d2cd24ca2..905f94973 100644 --- a/lib/page/firewall/providers/usp_firewall_notifier.dart +++ b/lib/page/firewall/providers/usp_firewall_notifier.dart @@ -78,7 +78,7 @@ class UspFirewallNotifier extends AutoDisposeNotifier logger.e('[USP][Firewall]: Fetch failed', error: e); return ( null, - FirewallStatus(isLoading: false, errorMessage: '$e'), + FirewallStatus(isLoading: false, error: e), ); } } diff --git a/lib/page/firewall/views/usp_firewall_view.dart b/lib/page/firewall/views/usp_firewall_view.dart index 2e9c4eee4..c0c6e7640 100644 --- a/lib/page/firewall/views/usp_firewall_view.dart +++ b/lib/page/firewall/views/usp_firewall_view.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/firewall/models/firewall_feature_state.dart'; @@ -37,8 +39,12 @@ class UspFirewallView extends ConsumerWidget { if (status.isLoading) { return const Center(child: AppLoader()); } - if (status.errorMessage != null) { - return _buildError(context, ref); + if (status.error != null) { + return ServiceErrorView( + error: status.error, + onRetry: () => + ref.read(uspFirewallProvider.notifier).fetch(forceRemote: true), + ); } return _buildContent(context, ref, state); }, @@ -63,30 +69,6 @@ class UspFirewallView extends ConsumerWidget { ); } - // --------------------------------------------------------------------------- - // Error - // --------------------------------------------------------------------------- - - Widget _buildError(BuildContext context, WidgetRef ref) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium('Unable to load firewall settings'), - AppGap.md(), - AppButton( - label: 'Retry', - onTap: () => - ref.read(uspFirewallProvider.notifier).fetch(forceRemote: true), - ), - ], - ), - ); - } - // --------------------------------------------------------------------------- // Content // --------------------------------------------------------------------------- @@ -286,7 +268,7 @@ class UspFirewallView extends ConsumerWidget { } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to save: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/page/instant_privacy/views/instant_privacy_view.dart b/lib/page/instant_privacy/views/instant_privacy_view.dart index ab497d8bb..5b5383e61 100644 --- a/lib/page/instant_privacy/views/instant_privacy_view.dart +++ b/lib/page/instant_privacy/views/instant_privacy_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; @@ -48,12 +50,12 @@ class InstantPrivacyView extends ConsumerWidget { AppIcon.font(Icons.error_outline, size: 48, color: Theme.of(context).colorScheme.error), AppGap.xl(), - AppText.titleMedium('Unable to load Instant Privacy settings'), + AppText.titleMedium(loc(context).failedToLoadSettings), AppGap.md(), - AppText.bodyMedium(error.toString()), + AppText.bodyMedium(localizeServiceError(context, error)), AppGap.xxl(), AppButton( - label: 'Retry', + label: loc(context).retry, onTap: () => ref.invalidate(uspInstantPrivacyProvider), ), ], @@ -262,7 +264,7 @@ class InstantPrivacyView extends ConsumerWidget { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to enable Instant Privacy: $e')), + SnackBar(content: Text(localizeServiceError(context, e))), ); } } @@ -294,7 +296,7 @@ class InstantPrivacyView extends ConsumerWidget { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to disable Instant Privacy: $e')), + SnackBar(content: Text(localizeServiceError(context, e))), ); } } @@ -320,7 +322,7 @@ class InstantPrivacyView extends ConsumerWidget { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to add device: $e')), + SnackBar(content: Text(localizeServiceError(context, e))), ); } } diff --git a/lib/page/instant_safety/models/instant_safety_status.dart b/lib/page/instant_safety/models/instant_safety_status.dart index 08f281e3a..284d89187 100644 --- a/lib/page/instant_safety/models/instant_safety_status.dart +++ b/lib/page/instant_safety/models/instant_safety_status.dart @@ -1,29 +1,34 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; /// Transient (non-editable) status for the Instant Safety feature page. class InstantSafetyStatus extends Equatable { final bool isLoading; final bool isSaving; - final String? errorMessage; + + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; const InstantSafetyStatus({ this.isLoading = true, this.isSaving = false, - this.errorMessage, + this.error, }); InstantSafetyStatus copyWith({ bool? isLoading, bool? isSaving, - String? errorMessage, + ServiceError? error, + bool clearError = false, }) { return InstantSafetyStatus( isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage, + error: clearError ? null : (error ?? this.error), ); } @override - List get props => [isLoading, isSaving, errorMessage]; + List get props => [isLoading, isSaving, error]; } diff --git a/lib/page/instant_safety/views/instant_safety_view.dart b/lib/page/instant_safety/views/instant_safety_view.dart index dcae45aec..95bac00d6 100644 --- a/lib/page/instant_safety/views/instant_safety_view.dart +++ b/lib/page/instant_safety/views/instant_safety_view.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/page/instant_safety/models/instant_safety_feature_state.dart'; @@ -34,8 +36,11 @@ class UspInstantSafetyView extends ConsumerWidget { if (state.status.isLoading) { return const Center(child: AppLoader()); } - if (state.status.errorMessage != null) { - return _buildError(context, ref, state.status.errorMessage!); + if (state.status.error != null) { + return ServiceErrorView( + error: state.status.error, + onRetry: () => ref.invalidate(uspInstantSafetyProvider), + ); } return _buildContent(context, ref, state); }, @@ -60,31 +65,6 @@ class UspInstantSafetyView extends ConsumerWidget { ); } - // --------------------------------------------------------------------------- - // Error - // --------------------------------------------------------------------------- - - Widget _buildError(BuildContext context, WidgetRef ref, String error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium('Unable to load safe browsing settings'), - AppGap.md(), - AppText.bodyMedium(error), - AppGap.xxl(), - AppButton( - label: 'Retry', - onTap: () => ref.invalidate(uspInstantSafetyProvider), - ), - ], - ), - ); - } - // --------------------------------------------------------------------------- // Content // --------------------------------------------------------------------------- @@ -183,7 +163,7 @@ class UspInstantSafetyView extends ConsumerWidget { } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to save: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/page/internet_settings/models/internet_settings_status.dart b/lib/page/internet_settings/models/internet_settings_status.dart index 754790eac..6fa3f54b9 100644 --- a/lib/page/internet_settings/models/internet_settings_status.dart +++ b/lib/page/internet_settings/models/internet_settings_status.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/page/internet_settings/models/internet_settings_read_only_info.dart'; /// Transient (non-editable) status for the internet settings feature page. @@ -8,7 +9,10 @@ class InternetSettingsStatus extends Equatable { final bool isLoading; final bool isSaving; final bool isEditing; - final String? errorMessage; + + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; /// Tracks active mutation: null (idle), 'save', 'renewIpv4', 'renewIpv6'. final String? activeMutation; @@ -28,7 +32,7 @@ class InternetSettingsStatus extends Equatable { this.isLoading = true, this.isSaving = false, this.isEditing = false, - this.errorMessage, + this.error, this.activeMutation, this.readOnlyInfo = const InternetSettingsReadOnlyInfo(), this.pppInstancePath, @@ -39,7 +43,8 @@ class InternetSettingsStatus extends Equatable { bool? isLoading, bool? isSaving, bool? isEditing, - String? errorMessage, + ServiceError? error, + bool clearError = false, String? activeMutation, bool clearActiveMutation = false, InternetSettingsReadOnlyInfo? readOnlyInfo, @@ -52,7 +57,7 @@ class InternetSettingsStatus extends Equatable { isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, isEditing: isEditing ?? this.isEditing, - errorMessage: errorMessage, + error: clearError ? null : (error ?? this.error), activeMutation: clearActiveMutation ? null : (activeMutation ?? this.activeMutation), readOnlyInfo: readOnlyInfo ?? this.readOnlyInfo, @@ -70,7 +75,7 @@ class InternetSettingsStatus extends Equatable { isLoading, isSaving, isEditing, - errorMessage, + error, activeMutation, readOnlyInfo, pppInstancePath, diff --git a/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart b/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart index ef626be46..933fc927a 100644 --- a/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart +++ b/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart @@ -128,7 +128,7 @@ class UspInternetSettingsNotifier logger.e('[USP][Network][WAN]: Fetch failed', error: e); return ( null, - InternetSettingsStatus(isLoading: false, errorMessage: '$e'), + InternetSettingsStatus(isLoading: false, error: e), ); } } diff --git a/lib/page/internet_settings/views/usp_internet_settings_view.dart b/lib/page/internet_settings/views/usp_internet_settings_view.dart index f46f01fbe..4c17ef6e5 100644 --- a/lib/page/internet_settings/views/usp_internet_settings_view.dart +++ b/lib/page/internet_settings/views/usp_internet_settings_view.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; import 'package:privacy_gui/page/internet_settings/models/internet_settings_feature_state.dart'; import 'package:privacy_gui/page/internet_settings/providers/usp_internet_settings_form_validator.dart'; import 'package:privacy_gui/page/internet_settings/providers/usp_internet_settings_notifier.dart'; @@ -42,32 +44,18 @@ class UspInternetSettingsView extends ConsumerWidget { if (state.status.isLoading) { return const Center(child: AppLoader()); } - if (state.status.errorMessage != null) { - return _buildError(childContext, ref, state.status.errorMessage!); + if (state.status.error != null) { + return ServiceErrorView( + error: state.status.error, + onRetry: () => + ref.read(uspInternetSettingsProvider.notifier).fetch(), + ); } return _buildContent(childContext, ref, state); }, ); } - Widget _buildError(BuildContext context, WidgetRef ref, String error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppText.bodyLarge(loc(context).failedToLoadSettings), - AppGap.md(), - AppText.bodyMedium(error), - AppGap.xl(), - AppButton.primary( - label: loc(context).retry, - onTap: () => ref.read(uspInternetSettingsProvider.notifier).fetch(), - ), - ], - ), - ); - } - Widget _buildContent( BuildContext context, WidgetRef ref, @@ -205,7 +193,7 @@ class UspInternetSettingsView extends ConsumerWidget { } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to save: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/page/ipv6_port_service/models/ipv6_port_service_status.dart b/lib/page/ipv6_port_service/models/ipv6_port_service_status.dart index 4a2dcd0db..b476c685a 100644 --- a/lib/page/ipv6_port_service/models/ipv6_port_service_status.dart +++ b/lib/page/ipv6_port_service/models/ipv6_port_service_status.dart @@ -1,29 +1,34 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; /// Transient status for the IPv6 port service page. class Ipv6PortServiceStatus extends Equatable { final bool isLoading; final bool isSaving; - final String? errorMessage; + + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; const Ipv6PortServiceStatus({ this.isLoading = false, this.isSaving = false, - this.errorMessage, + this.error, }); Ipv6PortServiceStatus copyWith({ bool? isLoading, bool? isSaving, - String? errorMessage, + ServiceError? error, + bool clearError = false, }) { return Ipv6PortServiceStatus( isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage, + error: clearError ? null : (error ?? this.error), ); } @override - List get props => [isLoading, isSaving, errorMessage]; + List get props => [isLoading, isSaving, error]; } diff --git a/lib/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier.dart b/lib/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier.dart index a608ab734..944787c56 100644 --- a/lib/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier.dart +++ b/lib/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier.dart @@ -68,7 +68,7 @@ class UspIpv6PortServiceNotifier logger.e('[USP][Firewall][IPv6Port]: Fetch failed', error: e); return ( null, - Ipv6PortServiceStatus(errorMessage: '$e'), + Ipv6PortServiceStatus(error: e), ); } } diff --git a/lib/page/ipv6_port_service/views/usp_ipv6_port_service_view.dart b/lib/page/ipv6_port_service/views/usp_ipv6_port_service_view.dart index ba7878fa8..18836a6e4 100644 --- a/lib/page/ipv6_port_service/views/usp_ipv6_port_service_view.dart +++ b/lib/page/ipv6_port_service/views/usp_ipv6_port_service_view.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/route/constants.dart'; @@ -40,8 +42,13 @@ class UspIpv6PortServiceView extends ConsumerWidget { if (status.isLoading) { return const Center(child: AppLoader()); } - if (status.errorMessage != null) { - return _buildError(context, ref); + if (status.error != null) { + return ServiceErrorView( + error: status.error, + onRetry: () => ref + .read(uspIpv6PortServiceProvider.notifier) + .fetch(forceRemote: true), + ); } return _buildContent(context, ref, state); }, @@ -67,31 +74,6 @@ class UspIpv6PortServiceView extends ConsumerWidget { ); } - // --------------------------------------------------------------------------- - // Error - // --------------------------------------------------------------------------- - - Widget _buildError(BuildContext context, WidgetRef ref) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium('Unable to load IPv6 port service'), - AppGap.md(), - AppButton( - label: 'Retry', - onTap: () => ref - .read(uspIpv6PortServiceProvider.notifier) - .fetch(forceRemote: true), - ), - ], - ), - ); - } - // --------------------------------------------------------------------------- // Content // --------------------------------------------------------------------------- @@ -275,7 +257,7 @@ class UspIpv6PortServiceView extends ConsumerWidget { } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to save: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/page/local_network/models/local_network_status.dart b/lib/page/local_network/models/local_network_status.dart index ac05ddd10..a28ee6468 100644 --- a/lib/page/local_network/models/local_network_status.dart +++ b/lib/page/local_network/models/local_network_status.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; /// Transient (non-editable) status for the local network feature page. /// @@ -6,7 +7,10 @@ import 'package:equatable/equatable.dart'; class LocalNetworkStatus extends Equatable { final bool isLoading; final bool isSaving; - final String? errorMessage; + + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; /// Per-field validation errors (field key → error message). /// null value = no error for that field. @@ -19,7 +23,7 @@ class LocalNetworkStatus extends Equatable { const LocalNetworkStatus({ this.isLoading = true, this.isSaving = false, - this.errorMessage, + this.error, this.validationErrors = const {}, this.lockedOctetCount = 0, }); @@ -29,14 +33,15 @@ class LocalNetworkStatus extends Equatable { LocalNetworkStatus copyWith({ bool? isLoading, bool? isSaving, - String? errorMessage, + ServiceError? error, + bool clearError = false, Map? validationErrors, int? lockedOctetCount, }) { return LocalNetworkStatus( isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage, + error: clearError ? null : (error ?? this.error), validationErrors: validationErrors ?? this.validationErrors, lockedOctetCount: lockedOctetCount ?? this.lockedOctetCount, ); @@ -44,5 +49,5 @@ class LocalNetworkStatus extends Equatable { @override List get props => - [isLoading, isSaving, errorMessage, validationErrors, lockedOctetCount]; + [isLoading, isSaving, error, validationErrors, lockedOctetCount]; } diff --git a/lib/page/local_network/providers/usp_local_network_notifier.dart b/lib/page/local_network/providers/usp_local_network_notifier.dart index 619cac578..a66cf5590 100644 --- a/lib/page/local_network/providers/usp_local_network_notifier.dart +++ b/lib/page/local_network/providers/usp_local_network_notifier.dart @@ -91,7 +91,7 @@ class UspLocalNetworkNotifier logger.e('[USP][Network][LAN]: Fetch failed', error: e); return ( null, - LocalNetworkStatus(isLoading: false, errorMessage: '$e'), + LocalNetworkStatus(isLoading: false, error: e), ); } } diff --git a/lib/page/local_network/views/usp_local_network_view.dart b/lib/page/local_network/views/usp_local_network_view.dart index 12a076f73..aa1f6938f 100644 --- a/lib/page/local_network/views/usp_local_network_view.dart +++ b/lib/page/local_network/views/usp_local_network_view.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/local_network/models/local_network_feature_state.dart'; @@ -102,8 +104,13 @@ class _UspLocalNetworkViewState extends ConsumerState { if (status.isLoading) { return const Center(child: AppLoader()); } - if (status.errorMessage != null) { - return _buildError(context, ref); + if (status.error != null) { + return ServiceErrorView( + error: status.error, + onRetry: () => ref + .read(uspLocalNetworkProvider.notifier) + .fetch(forceRemote: true), + ); } _syncControllers(state); return _buildContent(context, ref, state); @@ -130,31 +137,6 @@ class _UspLocalNetworkViewState extends ConsumerState { ); } - // --------------------------------------------------------------------------- - // Error - // --------------------------------------------------------------------------- - - Widget _buildError(BuildContext context, WidgetRef ref) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium('Unable to load local network settings'), - AppGap.md(), - AppButton( - label: 'Retry', - onTap: () => ref - .read(uspLocalNetworkProvider.notifier) - .fetch(forceRemote: true), - ), - ], - ), - ); - } - // --------------------------------------------------------------------------- // Content // --------------------------------------------------------------------------- @@ -399,7 +381,7 @@ class _UspLocalNetworkViewState extends ConsumerState { } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to save: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/page/port_forwarding/models/port_forwarding_page_status.dart b/lib/page/port_forwarding/models/port_forwarding_page_status.dart index 2cf0e2820..b2ede6d76 100644 --- a/lib/page/port_forwarding/models/port_forwarding_page_status.dart +++ b/lib/page/port_forwarding/models/port_forwarding_page_status.dart @@ -1,29 +1,34 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; /// Transient status for the Port Forwarding detail page. class PortForwardingPageStatus extends Equatable { final bool isLoading; final bool isSaving; - final String? errorMessage; + + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; const PortForwardingPageStatus({ this.isLoading = false, this.isSaving = false, - this.errorMessage, + this.error, }); PortForwardingPageStatus copyWith({ bool? isLoading, bool? isSaving, - String? errorMessage, + ServiceError? error, + bool clearError = false, }) { return PortForwardingPageStatus( isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage, + error: clearError ? null : (error ?? this.error), ); } @override - List get props => [isLoading, isSaving, errorMessage]; + List get props => [isLoading, isSaving, error]; } diff --git a/lib/page/port_forwarding/providers/usp_port_forwarding_page_notifier.dart b/lib/page/port_forwarding/providers/usp_port_forwarding_page_notifier.dart index bef6b343b..ac807aa97 100644 --- a/lib/page/port_forwarding/providers/usp_port_forwarding_page_notifier.dart +++ b/lib/page/port_forwarding/providers/usp_port_forwarding_page_notifier.dart @@ -89,7 +89,7 @@ class UspPortForwardingPageNotifier logger.e('[USP][Firewall][PortForwarding]: Fetch failed', error: e); return ( null, - PortForwardingPageStatus(errorMessage: '$e'), + PortForwardingPageStatus(error: e), ); } } diff --git a/lib/page/port_forwarding/views/usp_port_forwarding_detail_view.dart b/lib/page/port_forwarding/views/usp_port_forwarding_detail_view.dart index 34d910dd3..d75c2abb0 100644 --- a/lib/page/port_forwarding/views/usp_port_forwarding_detail_view.dart +++ b/lib/page/port_forwarding/views/usp_port_forwarding_detail_view.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/port_forwarding/models/port_forwarding_page_feature_state.dart'; import 'package:privacy_gui/page/port_forwarding/models/port_forwarding_page_status.dart'; @@ -123,24 +125,12 @@ class _UspPortForwardingDetailViewState if (status.isLoading) { return const Center(child: AppLoader()); } - if (status.errorMessage != null) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium('Unable to load data'), - AppGap.md(), - AppButton( - label: 'Retry', - onTap: () => ref - .read(uspPortForwardingPageProvider.notifier) - .fetch(forceRemote: true), - ), - ], - ), + if (status.error != null) { + return ServiceErrorView( + error: status.error, + onRetry: () => ref + .read(uspPortForwardingPageProvider.notifier) + .fetch(forceRemote: true), ); } return CustomScrollView( @@ -170,7 +160,7 @@ class _UspPortForwardingDetailViewState } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to save: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/page/static_routing/models/static_routing_status.dart b/lib/page/static_routing/models/static_routing_status.dart index 06e407f49..8abb8881c 100644 --- a/lib/page/static_routing/models/static_routing_status.dart +++ b/lib/page/static_routing/models/static_routing_status.dart @@ -1,29 +1,34 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; /// Transient status for the static routing page. class StaticRoutingStatus extends Equatable { final bool isLoading; final bool isSaving; - final String? errorMessage; + + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; const StaticRoutingStatus({ this.isLoading = false, this.isSaving = false, - this.errorMessage, + this.error, }); StaticRoutingStatus copyWith({ bool? isLoading, bool? isSaving, - String? errorMessage, + ServiceError? error, + bool clearError = false, }) { return StaticRoutingStatus( isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage, + error: clearError ? null : (error ?? this.error), ); } @override - List get props => [isLoading, isSaving, errorMessage]; + List get props => [isLoading, isSaving, error]; } diff --git a/lib/page/static_routing/providers/usp_static_routing_notifier.dart b/lib/page/static_routing/providers/usp_static_routing_notifier.dart index 04f9e6ea0..54ddf8f8d 100644 --- a/lib/page/static_routing/providers/usp_static_routing_notifier.dart +++ b/lib/page/static_routing/providers/usp_static_routing_notifier.dart @@ -76,7 +76,7 @@ class UspStaticRoutingNotifier logger.e('[USP][Network][Routing]: Fetch failed', error: e); return ( null, - StaticRoutingStatus(errorMessage: '$e'), + StaticRoutingStatus(error: e), ); } } diff --git a/lib/page/static_routing/views/usp_static_routing_view.dart b/lib/page/static_routing/views/usp_static_routing_view.dart index 9cd0666a1..6f154ec8b 100644 --- a/lib/page/static_routing/views/usp_static_routing_view.dart +++ b/lib/page/static_routing/views/usp_static_routing_view.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/route/constants.dart'; @@ -37,8 +39,13 @@ class UspStaticRoutingView extends ConsumerWidget { if (status.isLoading) { return const Center(child: AppLoader()); } - if (status.errorMessage != null) { - return _buildError(context, ref); + if (status.error != null) { + return ServiceErrorView( + error: status.error, + onRetry: () => ref + .read(uspStaticRoutingProvider.notifier) + .fetch(forceRemote: true), + ); } return _buildContent(context, ref, state); }, @@ -63,31 +70,6 @@ class UspStaticRoutingView extends ConsumerWidget { ); } - // --------------------------------------------------------------------------- - // Error - // --------------------------------------------------------------------------- - - Widget _buildError(BuildContext context, WidgetRef ref) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium('Unable to load static routing'), - AppGap.md(), - AppButton( - label: 'Retry', - onTap: () => ref - .read(uspStaticRoutingProvider.notifier) - .fetch(forceRemote: true), - ), - ], - ), - ); - } - // --------------------------------------------------------------------------- // Content // --------------------------------------------------------------------------- @@ -253,7 +235,7 @@ class UspStaticRoutingView extends ConsumerWidget { } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Failed to save: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/page/system_log/views/usp_system_log_view.dart b/lib/page/system_log/views/usp_system_log_view.dart index c5d34be6a..e2e0493f3 100644 --- a/lib/page/system_log/views/usp_system_log_view.dart +++ b/lib/page/system_log/views/usp_system_log_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/shell/usp_top_bar.dart'; import 'package:privacy_gui/page/system_log/models/log_file_ui_model.dart'; @@ -42,12 +44,12 @@ class UspSystemLogView extends ConsumerWidget { AppIcon.font(Icons.error_outline, size: 48, color: Theme.of(context).colorScheme.error), AppGap.xl(), - AppText.titleMedium('Unable to load log files'), + AppText.titleMedium(loc(context).failedToLoadSettings), AppGap.md(), - AppText.bodyMedium(error.toString()), + AppText.bodyMedium(localizeServiceError(context, error)), AppGap.xxl(), AppButton( - label: 'Retry', + label: loc(context).retry, onTap: () => ref.invalidate(uspSystemLogProvider), ), ], diff --git a/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart b/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart index 0f221844d..28383d3eb 100644 --- a/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart +++ b/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/speed_test_state.dart'; @@ -129,7 +130,9 @@ class UspSpeedTestCard extends ConsumerWidget { AppIcon.font(Icons.error_outline, size: 32, color: colorScheme.error), AppGap.sm(), AppText.bodySmall( - state.errorMessage ?? 'Test failed', + state.error != null + ? localizeServiceError(context, state.error!) + : 'Test failed', textAlign: TextAlign.center, color: colorScheme.onSurfaceVariant, maxLines: 2, diff --git a/lib/page/unified_diagnostics/models/diagnostic_state.dart b/lib/page/unified_diagnostics/models/diagnostic_state.dart index 2e3c6d369..0227e6a10 100644 --- a/lib/page/unified_diagnostics/models/diagnostic_state.dart +++ b/lib/page/unified_diagnostics/models/diagnostic_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/speed_test_state.dart'; import 'diagnostic_result.dart'; @@ -159,8 +160,8 @@ class UnifiedDiagnosticsState extends Equatable { /// Recommendations based on diagnostic results. final List recommendations; - /// Error message if a step failed. - final String? errorMessage; + /// Error if a step failed. + final ServiceError? error; /// Progress of current step (0.0–1.0). final double? progress; @@ -172,7 +173,7 @@ class UnifiedDiagnosticsState extends Equatable { this.results = const [], this.speedTest, this.recommendations = const [], - this.errorMessage, + this.error, this.progress, }); @@ -199,7 +200,7 @@ class UnifiedDiagnosticsState extends Equatable { List? results, SpeedTestResult? speedTest, List? recommendations, - String? errorMessage, + ServiceError? error, double? progress, bool clearError = false, bool clearSpeedTest = false, @@ -219,7 +220,7 @@ class UnifiedDiagnosticsState extends Equatable { recommendations: clearRecommendations ? const [] : (recommendations ?? this.recommendations), - errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + error: clearError ? null : (error ?? this.error), progress: clearProgress ? null : (progress ?? this.progress), ); } @@ -232,7 +233,7 @@ class UnifiedDiagnosticsState extends Equatable { results, speedTest, recommendations, - errorMessage, + error, progress, ]; } diff --git a/lib/page/unified_diagnostics/models/manual_tools_state.dart b/lib/page/unified_diagnostics/models/manual_tools_state.dart index ef134df90..95dab65c5 100644 --- a/lib/page/unified_diagnostics/models/manual_tools_state.dart +++ b/lib/page/unified_diagnostics/models/manual_tools_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/services/sse_operation_awaiter.dart'; /// Which diagnostic tool is active. @@ -21,7 +22,7 @@ class NetworkDiagnosticsState extends Equatable { final PingResult? pingResult; final TracerouteResult? tracerouteResult; final NsLookupResult? nsLookupResult; - final String? errorMessage; + final ServiceError? error; const NetworkDiagnosticsState({ this.activeTab = DiagnosticType.ping, @@ -33,7 +34,7 @@ class NetworkDiagnosticsState extends Equatable { this.pingResult, this.tracerouteResult, this.nsLookupResult, - this.errorMessage, + this.error, }); NetworkDiagnosticsState copyWith({ @@ -49,7 +50,7 @@ class NetworkDiagnosticsState extends Equatable { bool clearTracerouteResult = false, NsLookupResult? nsLookupResult, bool clearNsLookupResult = false, - String? errorMessage, + ServiceError? error, bool clearError = false, }) { return NetworkDiagnosticsState( @@ -65,7 +66,7 @@ class NetworkDiagnosticsState extends Equatable { : (tracerouteResult ?? this.tracerouteResult), nsLookupResult: clearNsLookupResult ? null : (nsLookupResult ?? this.nsLookupResult), - errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + error: clearError ? null : (error ?? this.error), ); } @@ -87,6 +88,6 @@ class NetworkDiagnosticsState extends Equatable { pingResult, tracerouteResult, nsLookupResult, - errorMessage, + error, ]; } diff --git a/lib/page/unified_diagnostics/models/speed_test_state.dart b/lib/page/unified_diagnostics/models/speed_test_state.dart index acbdb95fb..5acb19733 100644 --- a/lib/page/unified_diagnostics/models/speed_test_state.dart +++ b/lib/page/unified_diagnostics/models/speed_test_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; enum SpeedTestStep { idle, @@ -147,7 +148,7 @@ class SpeedTestState extends Equatable { final SpeedTestStep step; final SpeedTestServer selectedServer; final SpeedTestResult? result; - final String? errorMessage; + final ServiceError? error; final String? progressMessage; const SpeedTestState({ @@ -159,7 +160,7 @@ class SpeedTestState extends Equatable { sizeMb: 100, ), this.result, - this.errorMessage, + this.error, this.progressMessage, }); @@ -175,7 +176,7 @@ class SpeedTestState extends Equatable { SpeedTestServer? selectedServer, SpeedTestResult? result, bool clearResult = false, - String? errorMessage, + ServiceError? error, bool clearError = false, String? progressMessage, bool clearProgress = false, @@ -184,7 +185,7 @@ class SpeedTestState extends Equatable { step: step ?? this.step, selectedServer: selectedServer ?? this.selectedServer, result: clearResult ? null : (result ?? this.result), - errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + error: clearError ? null : (error ?? this.error), progressMessage: clearProgress ? null : (progressMessage ?? this.progressMessage), ); @@ -192,5 +193,5 @@ class SpeedTestState extends Equatable { @override List get props => - [step, selectedServer, result, errorMessage, progressMessage]; + [step, selectedServer, result, error, progressMessage]; } diff --git a/lib/page/unified_diagnostics/providers/manual_tools_notifier.dart b/lib/page/unified_diagnostics/providers/manual_tools_notifier.dart index f74d8f06a..194c4adfa 100644 --- a/lib/page/unified_diagnostics/providers/manual_tools_notifier.dart +++ b/lib/page/unified_diagnostics/providers/manual_tools_notifier.dart @@ -132,27 +132,17 @@ class ManualToolsNotifier logger.w('[USP][Diagnostics]: Ping timeout: $e'); state = AsyncData(state.requireValue.copyWith( status: DiagnosticStatus.error, - errorMessage: 'Ping timed out — no response from ${s.host}', + error: TimeoutError(detail: e.toString()), )); } on ServiceError catch (e) { logger.w('[USP][Diagnostics]: Ping failed: $e'); state = AsyncData(state.requireValue.copyWith( status: DiagnosticStatus.error, - errorMessage: _pingErrorMessage(e, s.host), + error: e, )); } } - String _pingErrorMessage(ServiceError e, String host) { - return switch (e) { - InvalidInputError(:final detail) => - detail ?? 'Cannot ping $host — invalid host', - NetworkError() => 'Ping failed — router lost connection', - ConnectivityError() => 'Ping unavailable — diagnostics scope not ready', - _ => 'Ping failed — please try again', - }; - } - // ------------------------------------------------------------------------- // Traceroute // ------------------------------------------------------------------------- @@ -189,28 +179,17 @@ class ManualToolsNotifier logger.w('[USP][Diagnostics]: Traceroute timeout: $e'); state = AsyncData(state.requireValue.copyWith( status: DiagnosticStatus.error, - errorMessage: 'Traceroute timed out — route to ${s.host} incomplete', + error: TimeoutError(detail: e.toString()), )); } on ServiceError catch (e) { logger.w('[USP][Diagnostics]: Traceroute failed: $e'); state = AsyncData(state.requireValue.copyWith( status: DiagnosticStatus.error, - errorMessage: _tracerouteErrorMessage(e, s.host), + error: e, )); } } - String _tracerouteErrorMessage(ServiceError e, String host) { - return switch (e) { - InvalidInputError(:final detail) => - detail ?? 'Cannot trace $host — invalid host', - NetworkError() => 'Traceroute failed — router lost connection', - ConnectivityError() => - 'Traceroute unavailable — diagnostics scope not ready', - _ => 'Traceroute failed — please try again', - }; - } - // ------------------------------------------------------------------------- // NS Lookup // ------------------------------------------------------------------------- @@ -249,26 +228,14 @@ class ManualToolsNotifier logger.w('[USP][Diagnostics]: NS Lookup timeout: $e'); state = AsyncData(state.requireValue.copyWith( status: DiagnosticStatus.error, - errorMessage: - 'NS Lookup timed out — no response while resolving ${s.host}', + error: TimeoutError(detail: e.toString()), )); } on ServiceError catch (e) { logger.w('[USP][Diagnostics]: NS Lookup failed: $e'); state = AsyncData(state.requireValue.copyWith( status: DiagnosticStatus.error, - errorMessage: _nsLookupErrorMessage(e, s.host), + error: e, )); } } - - String _nsLookupErrorMessage(ServiceError e, String host) { - return switch (e) { - InvalidInputError(:final detail) => - detail ?? 'Cannot resolve $host — invalid host', - NetworkError() => 'NS Lookup failed — router lost connection', - ConnectivityError() => - 'NS Lookup unavailable — diagnostics scope not ready', - _ => 'NS Lookup failed — please try again', - }; - } } diff --git a/lib/page/unified_diagnostics/providers/speed_test_notifier.dart b/lib/page/unified_diagnostics/providers/speed_test_notifier.dart index 97b168adb..e2492c67a 100644 --- a/lib/page/unified_diagnostics/providers/speed_test_notifier.dart +++ b/lib/page/unified_diagnostics/providers/speed_test_notifier.dart @@ -115,7 +115,7 @@ class SpeedTestNotifier extends AutoDisposeAsyncNotifier { state = AsyncData(state.requireValue.copyWith( step: SpeedTestStep.error, clearProgress: true, - errorMessage: _scopeErrorMessage(e), + error: e, )); return; } @@ -168,11 +168,15 @@ class SpeedTestNotifier extends AutoDisposeAsyncNotifier { if (downloadStatus != 'Complete') { logger.w( '[USP][SpeedTest]: Download failed with status: $downloadStatus'); + // Router-reported business status (not a ServiceError): the operate + // completed, but the firmware reports a download failure. Wrap the + // existing English message as UnexpectedError(detail) for now — speed + // test domain l10n is a later feature scope, out of this error-line pass. final errorMsg = _getDownloadErrorMessage(downloadStatus, downloadUrl); state = AsyncData(state.requireValue.copyWith( step: SpeedTestStep.error, clearProgress: true, - errorMessage: errorMsg, + error: UnexpectedError(detail: errorMsg), )); return; } @@ -214,14 +218,14 @@ class SpeedTestNotifier extends AutoDisposeAsyncNotifier { state = AsyncData(state.requireValue.copyWith( step: SpeedTestStep.error, clearProgress: true, - errorMessage: 'Speed test timed out', + error: TimeoutError(detail: e.toString()), )); } on ServiceError catch (e) { logger.w('[USP][SpeedTest]: Failed: $e'); state = AsyncData(state.requireValue.copyWith( step: SpeedTestStep.error, clearProgress: true, - errorMessage: _runErrorMessage(e), + error: e, )); } finally { if (identical(_activeScope, scope)) _activeScope = null; @@ -253,26 +257,6 @@ class SpeedTestNotifier extends AutoDisposeAsyncNotifier { // Error messages // ------------------------------------------------------------------------- - String _scopeErrorMessage(ServiceError e) { - return switch (e) { - ConnectivityError() => - 'Speed test unavailable — diagnostics scope not ready', - NetworkError() => 'Speed test unavailable — router lost connection', - _ => 'Speed test unavailable — please try again', - }; - } - - String _runErrorMessage(ServiceError e) { - return switch (e) { - NetworkError() => 'Speed test failed — router lost connection', - ConnectivityError() => - 'Speed test unavailable — diagnostics scope not ready', - InvalidInputError(:final detail) => - detail ?? 'Speed test failed — invalid configuration', - _ => 'Speed test failed — please try again', - }; - } - String _getDownloadErrorMessage(String status, String url) { // Extract hostname from URL for display final uri = Uri.tryParse(url); diff --git a/lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart b/lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart index f922b80c7..294e4f382 100644 --- a/lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart +++ b/lib/page/unified_diagnostics/providers/unified_diagnostics_notifier.dart @@ -296,7 +296,8 @@ class UnifiedDiagnosticsNotifier if (_cancelled) return; final svc = _svc; if (svc == null) { - _setError('Diagnostics service not available'); + _setError(const ServiceNotInitializedError( + detail: 'Diagnostics service not available')); return; } @@ -450,7 +451,8 @@ class UnifiedDiagnosticsNotifier if (_cancelled) return; final svc = _svc; if (svc == null) { - _setError('Diagnostics service not available'); + _setError(const ServiceNotInitializedError( + detail: 'Diagnostics service not available')); return; } @@ -576,7 +578,8 @@ class UnifiedDiagnosticsNotifier if (_cancelled) return; final svc = _svc; if (svc == null) { - _setError('Diagnostics service not available'); + _setError(const ServiceNotInitializedError( + detail: 'Diagnostics service not available')); return; } @@ -628,7 +631,8 @@ class UnifiedDiagnosticsNotifier if (_cancelled) return; final svc = _svc; if (svc == null) { - _setError('Diagnostics service not available'); + _setError(const ServiceNotInitializedError( + detail: 'Diagnostics service not available')); return; } @@ -674,7 +678,8 @@ class UnifiedDiagnosticsNotifier if (_cancelled) return; final svc = _svc; if (svc == null) { - _setError('Diagnostics service not available'); + _setError(const ServiceNotInitializedError( + detail: 'Diagnostics service not available')); return; } @@ -751,7 +756,8 @@ class UnifiedDiagnosticsNotifier if (_cancelled) return; final svc = _svc; if (svc == null) { - _setError('Diagnostics service not available'); + _setError(const ServiceNotInitializedError( + detail: 'Diagnostics service not available')); return; } @@ -836,7 +842,7 @@ class UnifiedDiagnosticsNotifier } } else if (speedState.step == SpeedTestStep.error) { if (!completer.isCompleted) { - logger.w('[Diagnostics] SpeedTest error: ${speedState.errorMessage}'); + logger.w('[Diagnostics] SpeedTest error: ${speedState.error}'); completer.complete(null); } } @@ -1357,11 +1363,11 @@ class UnifiedDiagnosticsNotifier ); } - void _setError(String message) { - logger.e('[Diagnostics] Error: $message'); + void _setError(ServiceError error) { + logger.e('[Diagnostics] Error: $error'); state = state.copyWith( step: DiagnosticStep.showingResults, - errorMessage: message, + error: error, ); } } diff --git a/lib/page/unified_diagnostics/services/diagnostic_report_service.dart b/lib/page/unified_diagnostics/services/diagnostic_report_service.dart index 9bb893fba..d4d35856b 100644 --- a/lib/page/unified_diagnostics/services/diagnostic_report_service.dart +++ b/lib/page/unified_diagnostics/services/diagnostic_report_service.dart @@ -27,8 +27,8 @@ class DiagnosticReportService { buffer.writeln('Flow: ${_flowName(state)}'); buffer.writeln(); - if (state.errorMessage != null) { - buffer.writeln('ERROR: ${state.errorMessage}'); + if (state.error != null) { + buffer.writeln('ERROR: ${state.error}'); buffer.writeln(); } diff --git a/lib/page/unified_diagnostics/views/speed_test_view.dart b/lib/page/unified_diagnostics/views/speed_test_view.dart index b8d54d9bd..0796b0aa3 100644 --- a/lib/page/unified_diagnostics/views/speed_test_view.dart +++ b/lib/page/unified_diagnostics/views/speed_test_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; @@ -396,9 +397,9 @@ class SpeedTestView extends ConsumerWidget { AppGap.xl(), AppText.titleMedium('Speed Test Failed'), AppGap.md(), - if (state.errorMessage != null) + if (state.error != null) AppText.bodyMedium( - state.errorMessage!, + localizeServiceError(context, state.error!), textAlign: TextAlign.center, color: colorScheme.onSurfaceVariant, ), diff --git a/lib/page/unified_diagnostics/views/widgets/diagnostic_manual_tools_view.dart b/lib/page/unified_diagnostics/views/widgets/diagnostic_manual_tools_view.dart index 6689fced4..5e983f8cb 100644 --- a/lib/page/unified_diagnostics/views/widgets/diagnostic_manual_tools_view.dart +++ b/lib/page/unified_diagnostics/views/widgets/diagnostic_manual_tools_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/core/usp/models/operate_result.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/manual_tools_state.dart'; @@ -129,7 +130,7 @@ class _DiagnosticManualToolsViewState ], // Error display - if (state.errorMessage != null) ...[ + if (state.error != null) ...[ LayoutBlock( padding: const EdgeInsets.all(AppSpacing.md), child: Row( @@ -139,7 +140,7 @@ class _DiagnosticManualToolsViewState AppGap.md(), Expanded( child: AppText.bodyMedium( - state.errorMessage!, + localizeServiceError(context, state.error!), color: colorScheme.error, ), ), diff --git a/lib/page/wifi_settings/models/wifi_advanced_status.dart b/lib/page/wifi_settings/models/wifi_advanced_status.dart index 264776d90..4d13f82cf 100644 --- a/lib/page/wifi_settings/models/wifi_advanced_status.dart +++ b/lib/page/wifi_settings/models/wifi_advanced_status.dart @@ -1,34 +1,39 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; /// Transient (non-editable) status for the WiFi Advanced feature page. class WifiAdvancedStatus extends Equatable { final bool isLoading; final bool isSaving; - final String? errorMessage; + + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; const WifiAdvancedStatus({ this.isLoading = false, this.isSaving = false, - this.errorMessage, + this.error, }); const WifiAdvancedStatus.loading() : isLoading = true, isSaving = false, - errorMessage = null; + error = null; WifiAdvancedStatus copyWith({ bool? isLoading, bool? isSaving, - String? errorMessage, + ServiceError? error, + bool clearError = false, }) { return WifiAdvancedStatus( isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage, + error: clearError ? null : (error ?? this.error), ); } @override - List get props => [isLoading, isSaving, errorMessage]; + List get props => [isLoading, isSaving, error]; } diff --git a/lib/page/wifi_settings/models/wifi_settings_status.dart b/lib/page/wifi_settings/models/wifi_settings_status.dart index 2d5d0c5dc..64ee70883 100644 --- a/lib/page/wifi_settings/models/wifi_settings_status.dart +++ b/lib/page/wifi_settings/models/wifi_settings_status.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/page/wifi_settings/models/wifi_quick_setup_network.dart'; /// Read-only system state for the WiFi Settings page. @@ -12,8 +13,9 @@ class WifiSettingsStatus extends Equatable { /// True while a save operation is in progress. final bool isSaving; - /// Non-null when the fetch failed. - final String? errorMessage; + /// Typed error from the last fetch. The View localizes it via + /// `localizeServiceError`; null means no error. + final ServiceError? error; /// Aggregated Quick Setup data for main (non-guest) networks. /// Contains [WifiQuickSetupNetwork.ssidInstancePaths] and @@ -26,7 +28,7 @@ class WifiSettingsStatus extends Equatable { const WifiSettingsStatus({ this.isLoading = false, this.isSaving = false, - this.errorMessage, + this.error, this.quickSetupMainAggregate, this.quickSetupGuestAggregate, }); @@ -34,21 +36,22 @@ class WifiSettingsStatus extends Equatable { const WifiSettingsStatus.loading() : isLoading = true, isSaving = false, - errorMessage = null, + error = null, quickSetupMainAggregate = null, quickSetupGuestAggregate = null; WifiSettingsStatus copyWith({ bool? isLoading, bool? isSaving, - String? errorMessage, + ServiceError? error, + bool clearError = false, WifiQuickSetupNetwork? quickSetupMainAggregate, WifiQuickSetupNetwork? quickSetupGuestAggregate, }) { return WifiSettingsStatus( isLoading: isLoading ?? this.isLoading, isSaving: isSaving ?? this.isSaving, - errorMessage: errorMessage ?? this.errorMessage, + error: clearError ? null : (error ?? this.error), quickSetupMainAggregate: quickSetupMainAggregate ?? this.quickSetupMainAggregate, quickSetupGuestAggregate: @@ -60,7 +63,7 @@ class WifiSettingsStatus extends Equatable { List get props => [ isLoading, isSaving, - errorMessage, + error, quickSetupMainAggregate, quickSetupGuestAggregate, ]; diff --git a/lib/page/wifi_settings/providers/usp_wifi_advanced_provider.dart b/lib/page/wifi_settings/providers/usp_wifi_advanced_provider.dart index e897ad0f9..16a3db638 100644 --- a/lib/page/wifi_settings/providers/usp_wifi_advanced_provider.dart +++ b/lib/page/wifi_settings/providers/usp_wifi_advanced_provider.dart @@ -70,7 +70,7 @@ class UspWifiAdvancedNotifier logger.e('[USP][WiFi][Advanced]: Fetch failed', error: e); return ( null, - WifiAdvancedStatus(errorMessage: '$e'), + WifiAdvancedStatus(error: e), ); } } diff --git a/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart b/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart index 9a4e7ba21..112bf60f3 100644 --- a/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart +++ b/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart @@ -69,7 +69,10 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier if (usp == null) { return ( null, - WifiSettingsStatus(errorMessage: 'USP service not available') + WifiSettingsStatus( + error: const ServiceNotInitializedError( + detail: 'USP service not available'), + ) ); } @@ -78,7 +81,9 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier if (!usp.isAuthenticated) { return ( null, - WifiSettingsStatus(errorMessage: 'USP not authenticated') + WifiSettingsStatus( + error: const NotAuthenticatedError(detail: 'USP not authenticated'), + ) ); } } @@ -96,7 +101,7 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier logger.w('[USP][WiFi]: WiFi data fetch failed: $e'); return ( null, - WifiSettingsStatus(errorMessage: '$e'), + WifiSettingsStatus(error: e), ); } final (:radios, :ssids, :accessPoints) = wifiData.codegenContext.raw; diff --git a/lib/page/wifi_settings/views/tabs/wifi_advanced_tab.dart b/lib/page/wifi_settings/views/tabs/wifi_advanced_tab.dart index 61d36e81a..fa695d887 100644 --- a/lib/page/wifi_settings/views/tabs/wifi_advanced_tab.dart +++ b/lib/page/wifi_settings/views/tabs/wifi_advanced_tab.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/page/wifi_settings/models/wifi_advanced_feature_state.dart'; import 'package:privacy_gui/page/wifi_settings/providers/usp_wifi_advanced_provider.dart'; @@ -29,30 +30,11 @@ class UspWifiAdvancedTab extends ConsumerWidget { ); } - if (status.errorMessage != null) { - return Center( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.xl), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font( - Icons.error_outline, - size: 48, - color: Theme.of(context).colorScheme.error, - ), - AppGap.xl(), - AppText.titleMedium('Unable to load advanced settings'), - AppGap.md(), - AppButton( - label: 'Retry', - onTap: () => ref - .read(uspWifiAdvancedProvider.notifier) - .fetch(forceRemote: true), - ), - ], - ), - ), + if (status.error != null) { + return ServiceErrorView( + error: status.error, + onRetry: () => + ref.read(uspWifiAdvancedProvider.notifier).fetch(forceRemote: true), ); } diff --git a/lib/page/wifi_settings/views/tabs/wifi_list_tab.dart b/lib/page/wifi_settings/views/tabs/wifi_list_tab.dart index ea3479c67..486e51dd2 100644 --- a/lib/page/wifi_settings/views/tabs/wifi_list_tab.dart +++ b/lib/page/wifi_settings/views/tabs/wifi_list_tab.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/page/wifi_settings/providers/usp_wifi_settings_provider.dart'; import 'package:privacy_gui/page/wifi_settings/providers/usp_wifi_settings_state.dart'; @@ -34,8 +35,8 @@ class UspWifiListTab extends ConsumerWidget { } // Error or empty state - if (state.status.errorMessage != null || - state.settings.current.networks.isEmpty) { + if (state.status.error != null || state.settings.current.networks.isEmpty) { + final error = state.status.error; return Center( child: Padding( padding: const EdgeInsets.all(AppSpacing.xl), @@ -46,8 +47,9 @@ class UspWifiListTab extends ConsumerWidget { color: Theme.of(context).colorScheme.error), AppGap.md(), AppText.bodyMedium( - state.status.errorMessage ?? - 'No WiFi networks found. Check router connection.', + error != null + ? localizeServiceError(context, error) + : 'No WiFi networks found. Check router connection.', color: Theme.of(context).colorScheme.onSurfaceVariant, ), ], diff --git a/lib/page/wifi_settings/views/usp_wifi_settings_view.dart b/lib/page/wifi_settings/views/usp_wifi_settings_view.dart index e2e51a39b..835f9f86a 100644 --- a/lib/page/wifi_settings/views/usp_wifi_settings_view.dart +++ b/lib/page/wifi_settings/views/usp_wifi_settings_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; @@ -188,7 +189,7 @@ class _UspWifiSettingsViewState extends ConsumerState } catch (e) { logger.d('[WiFi][Save] Error: $e'); if (context.mounted) { - showFailedSnackBar(context, 'Failed to save: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } } diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart index 8c73b9bdc..573338cd0 100644 --- a/lib/providers/auth/auth_provider.dart +++ b/lib/providers/auth/auth_provider.dart @@ -102,38 +102,32 @@ class AuthNotifier extends AsyncNotifier { /// Maps ServiceError to UnexpectedError with proper error code for View layer. ServiceError _mapToViewError(Object error) { - if (error is AdminAccountLockedError) { - return UnexpectedError( - message: errorAdminAccountLocked, - originalError: error, - ); - } if (error is InvalidCredentialsError) { return UnexpectedError( - message: errorInvalidAdminPassword, + detail: errorInvalidAdminPassword, originalError: error, ); } if (error is NetworkError) { return UnexpectedError( - message: errorUspNetworkError, + detail: errorUspNetworkError, originalError: error, ); } if (error is ServiceNotInitializedError) { return UnexpectedError( - message: errorUspServiceNotInitialized, + detail: errorUspServiceNotInitialized, originalError: error, ); } if (error is ServiceError) { return UnexpectedError( - message: errorUnexpected, + detail: errorUnexpected, originalError: error, ); } return UnexpectedError( - message: errorUnexpected, + detail: errorUnexpected, originalError: error, ); } diff --git a/test/core/errors/service_error_test.dart b/test/core/errors/service_error_test.dart index 150ee4680..551407faf 100644 --- a/test/core/errors/service_error_test.dart +++ b/test/core/errors/service_error_test.dart @@ -8,11 +8,6 @@ void main() { expect('${const SessionTokenExpiredError()}', 'Session token expired'); expect('${const NotAuthenticatedError()}', 'Not authenticated'); expect('${const ResourceNotFoundError()}', 'Resource not found'); - expect('${const AdminAccountLockedError()}', 'Admin account locked'); - }); - - test('preserves acronyms (OTP)', () { - expect('${const InvalidOtpError()}', 'Invalid otp'); }); test('NetworkError appends message when present', () { diff --git a/test/golden_test/page/dmz/fixtures/dmz_test_data.dart b/test/golden_test/page/dmz/fixtures/dmz_test_data.dart index 243d53157..9b19c5e39 100644 --- a/test/golden_test/page/dmz/fixtures/dmz_test_data.dart +++ b/test/golden_test/page/dmz/fixtures/dmz_test_data.dart @@ -1,3 +1,4 @@ +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/framework/preservable.dart'; import 'package:privacy_gui/page/dmz/models/dmz_feature_state.dart'; import 'package:privacy_gui/page/dmz/models/dmz_settings.dart'; @@ -68,6 +69,6 @@ DmzFeatureState get errorState => DmzFeatureState( ), status: const DmzStatus( isLoading: false, - errorMessage: 'Connection failed', + error: ConnectivityError(detail: 'Connection failed'), ), ); diff --git a/test/golden_test/page/firewall/fixtures/firewall_test_data.dart b/test/golden_test/page/firewall/fixtures/firewall_test_data.dart index 990acabca..d36a222e9 100644 --- a/test/golden_test/page/firewall/fixtures/firewall_test_data.dart +++ b/test/golden_test/page/firewall/fixtures/firewall_test_data.dart @@ -1,3 +1,4 @@ +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/framework/preservable.dart'; import 'package:privacy_gui/page/firewall/models/firewall_feature_state.dart'; import 'package:privacy_gui/page/firewall/models/firewall_settings.dart'; @@ -71,6 +72,6 @@ FirewallFeatureState get errorState => FirewallFeatureState( ), status: const FirewallStatus( isLoading: false, - errorMessage: 'Connection failed', + error: ConnectivityError(detail: 'Connection failed'), ), ); diff --git a/test/golden_test/page/internet_settings/fixtures/internet_settings_test_data.dart b/test/golden_test/page/internet_settings/fixtures/internet_settings_test_data.dart index 7abb4a014..d589adb11 100644 --- a/test/golden_test/page/internet_settings/fixtures/internet_settings_test_data.dart +++ b/test/golden_test/page/internet_settings/fixtures/internet_settings_test_data.dart @@ -1,3 +1,4 @@ +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/framework/preservable.dart'; import 'package:privacy_gui/page/internet_settings/models/internet_settings_feature_state.dart'; import 'package:privacy_gui/page/internet_settings/models/internet_settings_read_only_info.dart'; @@ -160,6 +161,6 @@ InternetSettingsFeatureState get errorState => InternetSettingsFeatureState( ), status: const InternetSettingsStatus( isLoading: false, - errorMessage: 'Connection failed', + error: ConnectivityError(detail: 'Connection failed'), ), ); diff --git a/test/golden_test/page/local_network/fixtures/local_network_test_data.dart b/test/golden_test/page/local_network/fixtures/local_network_test_data.dart index 8346ba033..726d547d8 100644 --- a/test/golden_test/page/local_network/fixtures/local_network_test_data.dart +++ b/test/golden_test/page/local_network/fixtures/local_network_test_data.dart @@ -1,3 +1,4 @@ +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/framework/preservable.dart'; import 'package:privacy_gui/page/local_network/models/local_network_feature_state.dart'; import 'package:privacy_gui/page/local_network/models/local_network_settings.dart'; @@ -71,7 +72,7 @@ LocalNetworkFeatureState get errorState => LocalNetworkFeatureState( ), status: const LocalNetworkStatus( isLoading: false, - errorMessage: 'Connection failed', + error: ConnectivityError(detail: 'Connection failed'), ), ); diff --git a/test/golden_test/page/port_forwarding/fixtures/port_forwarding_test_data.dart b/test/golden_test/page/port_forwarding/fixtures/port_forwarding_test_data.dart index 384f148a9..255adda62 100644 --- a/test/golden_test/page/port_forwarding/fixtures/port_forwarding_test_data.dart +++ b/test/golden_test/page/port_forwarding/fixtures/port_forwarding_test_data.dart @@ -1,3 +1,4 @@ +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/framework/preservable.dart'; import 'package:privacy_gui/page/_shared/models/port_forwarding_rule_ui_model.dart'; import 'package:privacy_gui/page/port_forwarding/models/port_forwarding_page_feature_state.dart'; @@ -163,6 +164,6 @@ PortForwardingPageFeatureState get errorState => PortForwardingPageFeatureState( current: const PortForwardingPageSettings(), ), status: const PortForwardingPageStatus( - errorMessage: 'Connection failed', + error: ConnectivityError(detail: 'Connection failed'), ), ); diff --git a/test/golden_test/page/static_routing/fixtures/static_routing_test_data.dart b/test/golden_test/page/static_routing/fixtures/static_routing_test_data.dart index be3600ad0..647c7ff8d 100644 --- a/test/golden_test/page/static_routing/fixtures/static_routing_test_data.dart +++ b/test/golden_test/page/static_routing/fixtures/static_routing_test_data.dart @@ -1,3 +1,4 @@ +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/framework/preservable.dart'; import 'package:privacy_gui/page/static_routing/models/static_route_list.dart'; import 'package:privacy_gui/page/static_routing/models/static_routing_feature_state.dart'; @@ -78,6 +79,6 @@ StaticRoutingFeatureState get errorState => StaticRoutingFeatureState( current: const StaticRouteList(), ), status: const StaticRoutingStatus( - errorMessage: 'Connection failed', + error: ConnectivityError(detail: 'Connection failed'), ), ); diff --git a/test/golden_test/page/unified_diagnostics/fixtures/unified_diagnostics_test_data.dart b/test/golden_test/page/unified_diagnostics/fixtures/unified_diagnostics_test_data.dart index 1b86a4afa..d64cf897b 100644 --- a/test/golden_test/page/unified_diagnostics/fixtures/unified_diagnostics_test_data.dart +++ b/test/golden_test/page/unified_diagnostics/fixtures/unified_diagnostics_test_data.dart @@ -1,3 +1,4 @@ +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/models/operate_result.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/device_score.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/diagnostic_result.dart'; @@ -735,7 +736,8 @@ const completedState = UnifiedDiagnosticsState( const errorState = UnifiedDiagnosticsState( step: DiagnosticStep.showingResults, flow: DiagnosticFlow.internet, - errorMessage: 'Connection timed out — unable to reach diagnostic service', + error: TimeoutError( + detail: 'Connection timed out — unable to reach diagnostic service'), ); // ============================================================================= @@ -885,5 +887,6 @@ const manualToolsErrorState = NetworkDiagnosticsState( status: DiagnosticStatus.error, host: '192.168.99.99', pingCount: 3, - errorMessage: 'Ping timed out — no response from 192.168.99.99', + error: + TimeoutError(detail: 'Ping timed out — no response from 192.168.99.99'), ); diff --git a/test/mocks/test_data/wifi_settings_test_data.dart b/test/mocks/test_data/wifi_settings_test_data.dart index 9f99f0350..f7ec84b48 100644 --- a/test/mocks/test_data/wifi_settings_test_data.dart +++ b/test/mocks/test_data/wifi_settings_test_data.dart @@ -1,3 +1,4 @@ +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/generated/wi_fi_access_points.g.dart'; import 'package:privacy_gui/generated/wi_fi_radios.g.dart'; import 'package:privacy_gui/generated/wi_fi_ssids.g.dart'; @@ -391,14 +392,14 @@ class WifiSettingsTestData { static WifiSettingsStatus createStatus({ bool isLoading = false, bool isSaving = false, - String? errorMessage, + ServiceError? error, WifiQuickSetupNetwork? quickSetupMainAggregate, WifiQuickSetupNetwork? quickSetupGuestAggregate, }) => WifiSettingsStatus( isLoading: isLoading, isSaving: isSaving, - errorMessage: errorMessage, + error: error, quickSetupMainAggregate: quickSetupMainAggregate, quickSetupGuestAggregate: quickSetupGuestAggregate, ); diff --git a/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart b/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart index b14d66606..4d52f50f2 100644 --- a/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart +++ b/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart @@ -78,7 +78,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspDhcpReservationsProvider); - expect(state.status.errorMessage, contains('timeout')); + expect(state.status.error, isA()); expect(state.settings.current.reservations, isEmpty); container.dispose(); }); diff --git a/test/page/dmz/providers/usp_dmz_notifier_test.dart b/test/page/dmz/providers/usp_dmz_notifier_test.dart index 248f40d53..9ff8d9a96 100644 --- a/test/page/dmz/providers/usp_dmz_notifier_test.dart +++ b/test/page/dmz/providers/usp_dmz_notifier_test.dart @@ -87,7 +87,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspDmzProvider); - expect(state.status.errorMessage, contains('Network error')); + expect(state.status.error, isA()); // Settings remain empty (initial) since performFetch returned null. expect(state.settings.current, const DmzSettings.empty()); container.dispose(); diff --git a/test/page/firewall/providers/usp_firewall_notifier_test.dart b/test/page/firewall/providers/usp_firewall_notifier_test.dart index 2de8afcd1..3e1ae0e60 100644 --- a/test/page/firewall/providers/usp_firewall_notifier_test.dart +++ b/test/page/firewall/providers/usp_firewall_notifier_test.dart @@ -166,7 +166,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspFirewallProvider); - expect(state.status.errorMessage, contains('Network error')); + expect(state.status.error, isA()); container.dispose(); }); diff --git a/test/page/internet_settings/models/internet_settings_status_test.dart b/test/page/internet_settings/models/internet_settings_status_test.dart index 489b47fba..424b5a064 100644 --- a/test/page/internet_settings/models/internet_settings_status_test.dart +++ b/test/page/internet_settings/models/internet_settings_status_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/page/internet_settings/models/internet_settings_read_only_info.dart'; import 'package:privacy_gui/page/internet_settings/models/internet_settings_status.dart'; @@ -9,7 +10,7 @@ void main() { expect(status.isLoading, true); expect(status.isSaving, false); expect(status.isEditing, false); - expect(status.errorMessage, isNull); + expect(status.error, isNull); expect(status.activeMutation, isNull); expect(status.pppInstancePath, isNull); expect(status.vlanInstancePath, isNull); @@ -22,7 +23,7 @@ void main() { isLoading: false, isSaving: true, isEditing: true, - errorMessage: 'error', + error: const NetworkError(detail: 'error'), activeMutation: 'save', pppInstancePath: 'Device.PPP.Interface.1.', vlanInstancePath: 'Device.Ethernet.VLANTermination.1.', @@ -31,7 +32,7 @@ void main() { expect(updated.isLoading, false); expect(updated.isSaving, true); expect(updated.isEditing, true); - expect(updated.errorMessage, 'error'); + expect(updated.error, isA()); expect(updated.activeMutation, 'save'); expect(updated.pppInstancePath, 'Device.PPP.Interface.1.'); expect(updated.vlanInstancePath, 'Device.Ethernet.VLANTermination.1.'); @@ -74,11 +75,20 @@ void main() { expect(updated.vlanInstancePath, isNull); }); - test('errorMessage resets to null when not provided', () { - const status = InternetSettingsStatus(errorMessage: 'error'); + test('error is preserved when not provided', () { + const status = + InternetSettingsStatus(error: NetworkError(detail: 'error')); final updated = status.copyWith(isLoading: false); - expect(updated.errorMessage, isNull); + expect(updated.error, isA()); + }); + + test('clearError sets error to null', () { + const status = + InternetSettingsStatus(error: NetworkError(detail: 'error')); + final updated = status.copyWith(clearError: true); + + expect(updated.error, isNull); }); }); diff --git a/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart b/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart index 85480d30b..03f38c4b7 100644 --- a/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart +++ b/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart @@ -93,7 +93,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspInternetSettingsProvider); - expect(state.status.errorMessage, contains('bridge unreachable')); + expect(state.status.error, isA()); container.dispose(); }); @@ -311,7 +311,7 @@ void main() { final state = container.read(uspInternetSettingsProvider); // Should hit the restore path which won't succeed with our mock. - expect(state.status.errorMessage, isNotNull); + expect(state.status.error, isNotNull); container.dispose(); }); }); diff --git a/test/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier_test.dart b/test/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier_test.dart index f144507c4..7df34d336 100644 --- a/test/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier_test.dart +++ b/test/page/ipv6_port_service/providers/usp_ipv6_port_service_notifier_test.dart @@ -82,7 +82,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspIpv6PortServiceProvider); - expect(state.status.errorMessage, contains('fetch failed')); + expect(state.status.error, isA()); expect(state.settings.current.rules, isEmpty); container.dispose(); }); diff --git a/test/page/local_network/providers/usp_local_network_notifier_test.dart b/test/page/local_network/providers/usp_local_network_notifier_test.dart index 8a44dee77..6c48caf2a 100644 --- a/test/page/local_network/providers/usp_local_network_notifier_test.dart +++ b/test/page/local_network/providers/usp_local_network_notifier_test.dart @@ -191,7 +191,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspLocalNetworkProvider); - expect(state.status.errorMessage, contains('Network error')); + expect(state.status.error, isA()); container.dispose(); }); diff --git a/test/page/port_forwarding/providers/usp_port_forwarding_page_notifier_test.dart b/test/page/port_forwarding/providers/usp_port_forwarding_page_notifier_test.dart index be9cf6ddc..51aeba08f 100644 --- a/test/page/port_forwarding/providers/usp_port_forwarding_page_notifier_test.dart +++ b/test/page/port_forwarding/providers/usp_port_forwarding_page_notifier_test.dart @@ -105,7 +105,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspPortForwardingPageProvider); - expect(state.status.errorMessage, contains('timeout')); + expect(state.status.error, isA()); container.dispose(); }); diff --git a/test/page/static_routing/providers/usp_static_routing_notifier_test.dart b/test/page/static_routing/providers/usp_static_routing_notifier_test.dart index 31e6db3cd..bf4e93fa3 100644 --- a/test/page/static_routing/providers/usp_static_routing_notifier_test.dart +++ b/test/page/static_routing/providers/usp_static_routing_notifier_test.dart @@ -86,7 +86,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspStaticRoutingProvider); - expect(state.status.errorMessage, contains('connection lost')); + expect(state.status.error, isA()); expect(state.settings.current.routes, isEmpty); container.dispose(); }); diff --git a/test/page/unified_diagnostics/models/diagnostic_state_test.dart b/test/page/unified_diagnostics/models/diagnostic_state_test.dart index 7987c44c2..00d619501 100644 --- a/test/page/unified_diagnostics/models/diagnostic_state_test.dart +++ b/test/page/unified_diagnostics/models/diagnostic_state_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/speed_test_state.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/diagnostic_state.dart'; @@ -73,7 +74,7 @@ void main() { expect(state.results, isEmpty); expect(state.speedTest, isNull); expect(state.recommendations, isEmpty); - expect(state.errorMessage, isNull); + expect(state.error, isNull); expect(state.progress, isNull); }); @@ -110,24 +111,24 @@ void main() { final initial = UnifiedDiagnosticsState( step: DiagnosticStep.checkingWanStatus, flow: DiagnosticFlow.internet, - errorMessage: 'test error', + error: const NetworkError(detail: 'test error'), ); final copied = initial.copyWith(step: DiagnosticStep.pingGateway); expect(copied.step, DiagnosticStep.pingGateway); expect(copied.flow, DiagnosticFlow.internet); - expect(copied.errorMessage, 'test error'); + expect(copied.error, isA()); }); test('copyWith clears error when requested', () { final initial = UnifiedDiagnosticsState( - errorMessage: 'test error', + error: const NetworkError(detail: 'test error'), ); final cleared = initial.copyWith(clearError: true); - expect(cleared.errorMessage, isNull); + expect(cleared.error, isNull); }); test('copyWith clears speedTest when requested', () { diff --git a/test/page/unified_diagnostics/models/manual_tools_state_test.dart b/test/page/unified_diagnostics/models/manual_tools_state_test.dart index 04bb75168..cae99eb15 100644 --- a/test/page/unified_diagnostics/models/manual_tools_state_test.dart +++ b/test/page/unified_diagnostics/models/manual_tools_state_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/models/operate_result.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/manual_tools_state.dart'; @@ -88,11 +89,12 @@ void main() { expect(next.dnsServer, baseline.dnsServer); }); - test('clearError resets errorMessage to null', () { - final withError = baseline.copyWith(errorMessage: 'oops'); - expect(withError.errorMessage, 'oops'); + test('clearError resets error to null', () { + final withError = + baseline.copyWith(error: const UnexpectedError(detail: 'oops')); + expect(withError.error, isA()); final cleared = withError.copyWith(clearError: true); - expect(cleared.errorMessage, isNull); + expect(cleared.error, isNull); }); test('clearPingResult resets pingResult to null', () { diff --git a/test/page/unified_diagnostics/providers/manual_tools_notifier_test.dart b/test/page/unified_diagnostics/providers/manual_tools_notifier_test.dart index 3d6f52dfc..eebcde656 100644 --- a/test/page/unified_diagnostics/providers/manual_tools_notifier_test.dart +++ b/test/page/unified_diagnostics/providers/manual_tools_notifier_test.dart @@ -3,6 +3,7 @@ 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/errors/service_error.dart'; import 'package:privacy_gui/core/usp/models/operate_result.dart'; import 'package:privacy_gui/core/usp/providers/sse_providers.dart'; import 'package:privacy_gui/core/usp/services/network_diagnostics_executor.dart'; @@ -226,7 +227,7 @@ void main() { final state = container.read(manualToolsProvider).valueOrNull; expect(state?.status, DiagnosticStatus.error); - expect(state?.errorMessage, contains('timed out')); + expect(state?.error, isA()); container.dispose(); }); @@ -235,7 +236,7 @@ void main() { host: any(named: 'host'), numberOfRepetitions: any(named: 'numberOfRepetitions'), timeout: any(named: 'timeout'), - )).thenThrow(Exception('network error')); + )).thenThrow(const NetworkError(detail: 'network error')); final container = createContainer(); await container.read(manualToolsProvider.future); @@ -245,7 +246,7 @@ void main() { final state = container.read(manualToolsProvider).valueOrNull; expect(state?.status, DiagnosticStatus.error); - expect(state?.errorMessage, contains('Ping failed')); + expect(state?.error, isA()); container.dispose(); }); @@ -291,7 +292,7 @@ void main() { final state = container.read(manualToolsProvider).valueOrNull; expect(state?.status, DiagnosticStatus.error); - expect(state?.errorMessage, contains('Traceroute timed out')); + expect(state?.error, isA()); container.dispose(); }); @@ -363,7 +364,7 @@ void main() { final state = container.read(manualToolsProvider).valueOrNull; expect(state?.status, DiagnosticStatus.error); - expect(state?.errorMessage, contains('NS Lookup timed out')); + expect(state?.error, isA()); container.dispose(); }); diff --git a/test/page/unified_diagnostics/providers/speed_test_notifier_test.dart b/test/page/unified_diagnostics/providers/speed_test_notifier_test.dart index 3c0b2a8c2..3afd2c627 100644 --- a/test/page/unified_diagnostics/providers/speed_test_notifier_test.dart +++ b/test/page/unified_diagnostics/providers/speed_test_notifier_test.dart @@ -3,6 +3,7 @@ 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/errors/service_error.dart'; import 'package:privacy_gui/core/usp/models/operate_result.dart'; import 'package:privacy_gui/core/usp/providers/sse_providers.dart'; import 'package:privacy_gui/core/usp/services/network_diagnostics_executor.dart'; @@ -177,7 +178,9 @@ void main() { final state = container.read(speedTestProvider).valueOrNull; expect(state?.step, SpeedTestStep.error); - expect(state?.errorMessage, contains('Could not connect')); + expect(state?.error, isA()); + expect((state?.error as UnexpectedError).detail, + contains('Could not connect')); container.dispose(); }); diff --git a/test/page/unified_diagnostics/services/diagnostic_report_service_test.dart b/test/page/unified_diagnostics/services/diagnostic_report_service_test.dart index 7bb2ab696..dc315a12b 100644 --- a/test/page/unified_diagnostics/services/diagnostic_report_service_test.dart +++ b/test/page/unified_diagnostics/services/diagnostic_report_service_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/diagnostic_result.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/diagnostic_state.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/speed_test_state.dart'; @@ -37,10 +38,10 @@ void main() { }); test('renders error message when present', () { - const state = - UnifiedDiagnosticsState(errorMessage: 'Network unreachable'); + const state = UnifiedDiagnosticsState( + error: NetworkError(detail: 'Network unreachable')); final report = service.buildTextReport(state); - expect(report, contains('ERROR: Network unreachable')); + expect(report, contains('Network unreachable')); }); test('renders ping result with severity icon and details', () { diff --git a/test/page/wifi_settings/models/wifi_advanced_feature_state_test.dart b/test/page/wifi_settings/models/wifi_advanced_feature_state_test.dart index 03888167c..74f3c5d7c 100644 --- a/test/page/wifi_settings/models/wifi_advanced_feature_state_test.dart +++ b/test/page/wifi_settings/models/wifi_advanced_feature_state_test.dart @@ -11,7 +11,7 @@ void main() { expect(state.status.isLoading, isTrue); expect(state.status.isSaving, isFalse); - expect(state.status.errorMessage, isNull); + expect(state.status.error, isNull); expect(state.settings.current.ieee80211hByRadio, isEmpty); expect(state.settings.original.ieee80211hByRadio, isEmpty); expect(state.isDirty, isFalse); diff --git a/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart b/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart index e175cb8f3..7a7c05a69 100644 --- a/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart +++ b/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart @@ -41,7 +41,7 @@ void main() { final state = container.read(uspWifiAdvancedProvider); expect(state.status.isLoading, isFalse); - expect(state.status.errorMessage, isNull); + expect(state.status.error, isNull); expect(state.settings.current.ieee80211hByRadio, { 'Device.WiFi.Radio.1.': true, 'Device.WiFi.Radio.2.': false, @@ -61,7 +61,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspWifiAdvancedProvider); - expect(state.status.errorMessage, isNotNull); + expect(state.status.error, isA()); expect(state.settings.current.ieee80211hByRadio, isEmpty); container.dispose(); }); diff --git a/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart b/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart index ced602957..9edbaf798 100644 --- a/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart +++ b/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart @@ -133,7 +133,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspWifiSettingsProvider); - expect(state.status.errorMessage, isNotNull); + expect(state.status.error, isA()); container.dispose(); }); @@ -560,8 +560,7 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspWifiSettingsProvider); - expect(state.status.errorMessage, isNotNull); - expect(state.status.errorMessage, contains('Network error')); + expect(state.status.error, isA()); expect(state.settings.current.networks, isEmpty); container.dispose(); }); From a11a11f9b23d5eb5ec2d3be5a3bff6b09e734842 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Thu, 18 Jun 2026 15:34:54 +0800 Subject: [PATCH 3/7] fix(l10n): align batch fault-code localization with _mapProtocolError The batch localizer (_localizeBatch) only recognized the five 7xxx codes exposed by UspErrorDetail's helpers, so write failures carrying bbfdm vendor codes (9001/9005/9007/9008) or the WASM transport code (9999) all fell through to the generic errorUnexpected message. The fetch path (_mapProtocolError) already mapped these, so the same firmware code localized differently depending on which path produced it. Extract _localizeFaultCode(code) and switch on the raw errorCode, mirroring _mapProtocolError's table: - 7004/7005/7006/9008 -> errorInvalidInput - 7026/7027/9005/9007 -> errorResourceNotFound - 9001 -> errorUnauthorized - 9999 -> errorNetwork (never reached the router) Most user-visible win: a 9999 (no connection to the router) now reads "Network error. Please check your connection." instead of the vague "Something went wrong." UspErrorDetail's helpers are left untouched (still used by the test console). Unknown vendor codes still fall back to the generic message and deliberately do not surface raw firmware text. --- .../service_error_localizations.dart | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/lib/components/localizations/service_error_localizations.dart b/lib/components/localizations/service_error_localizations.dart index b39180fc5..6a3c2ac99 100644 --- a/lib/components/localizations/service_error_localizations.dart +++ b/lib/components/localizations/service_error_localizations.dart @@ -58,15 +58,31 @@ String localizeServiceError(BuildContext context, Object error) { String _localizeBatch(BuildContext context, List failures) { final l = loc(context); if (failures.isEmpty) return l.errorUnexpected; - final first = failures.first; - // Map the fault code to a typed l10n string using UspErrorDetail helpers. - if (first.isParameterNotFound || first.isObjectNotFound) { - return l.errorResourceNotFound; - } - if (first.isInvalidParameterName || - first.isInvalidParameterValue || - first.isParameterNotWritable) { - return l.errorInvalidInput; - } - return l.errorUnexpected; + return _localizeFaultCode(context, failures.first.errorCode); +} + +/// Maps a single USP fault code to a localized message. +/// +/// Mirrors the fault-code arm of `_mapProtocolError` in +/// `lib/core/usp/errors/usp_error.dart` — the two MUST stay in sync. The fetch +/// path (string → `mapUspErrorToServiceError`) and the write path (envelope → +/// this batch localizer) otherwise disagree on the same firmware code (e.g. a +/// 9001 would localize differently depending on which path produced it). +/// +/// Codes: +/// - 7004/7005/7006 (TR-369) + 9008 (bbfdm non-writable) → invalid input +/// - 7026/7027 (TR-369 not found) + 9005/9007 (bbfdm) → resource not found +/// - 9001 (bbfdm request denied) → unauthorized +/// - 9999 (WASM client transport failure — never reached the router) → network +/// - anything else → generic fallback (firmware vendor codes are an open set; +/// we deliberately do NOT surface the raw firmware `errorMessage` here). +String _localizeFaultCode(BuildContext context, int code) { + final l = loc(context); + return switch (code) { + 7004 || 7005 || 7006 || 9008 => l.errorInvalidInput, + 7026 || 7027 || 9005 || 9007 => l.errorResourceNotFound, + 9001 => l.errorUnauthorized, + 9999 => l.errorNetwork, + _ => l.errorUnexpected, + }; } From a42ceb6b0f2a4a5a7af226e48430349ad303aa9c Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Thu, 18 Jun 2026 16:27:54 +0800 Subject: [PATCH 4/7] fix(auth): restore account-locked message after AdminAccountLockedError removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the AdminAccountLockedError subtype severed the lockout-message chain: the coordinator threw UnexpectedError(detail: 'Account locked') — a free-form string that does not equal the errorAdminAccountLocked constant ('ErrorAdminAccountLocked') — and _mapToViewError no longer had an account-locked branch, so it fell into the generic ServiceError arm and overwrote detail with errorUnexpected. The login view then resolved '_ErrorUnexpected' to unknownHandle, so a locked-out user saw "Something went wrong" instead of the too-many-attempts / account-locked message. A security-relevant lockout signal was swallowed. Fix: - Coordinator throws UnexpectedError(detail: errorAdminAccountLocked) — the actual error-code identifier the view's errorCodeHelper recognizes. - _mapToViewError passes such an UnexpectedError through unchanged instead of overwriting it to errorUnexpected. Tests: - usp_auth_coordinator_test: tryUspLogin maps account-locked WASM error to UnexpectedError(detail: errorAdminAccountLocked); plus invalid-credentials and authenticated=false cases. - auth_notifier_test: localLogin keeps errorAdminAccountLocked through _mapToViewError (regression guard). --- .../usp/providers/usp_auth_coordinator.dart | 6 ++- lib/providers/auth/auth_provider.dart | 7 ++++ .../providers/usp_auth_coordinator_test.dart | 41 +++++++++++++++++++ test/providers/auth/auth_notifier_test.dart | 27 ++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/lib/core/usp/providers/usp_auth_coordinator.dart b/lib/core/usp/providers/usp_auth_coordinator.dart index ebcc39eaa..a502180be 100644 --- a/lib/core/usp/providers/usp_auth_coordinator.dart +++ b/lib/core/usp/providers/usp_auth_coordinator.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:privacy_gui/constants/error_code.dart'; import 'package:privacy_gui/constants/pref_key.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; @@ -162,7 +163,10 @@ class UspAuthCoordinator { // Map WASM errors to ServiceError final errorStr = e.toString(); if (_isAccountLockedError(e)) { - throw UnexpectedError(originalError: e, detail: 'Account locked'); + // Carry the error-code identifier (not a free-form string) so the login + // view's errorCodeHelper resolves it to the "too many attempts / + // account locked" message. See _mapToViewError passthrough. + throw UnexpectedError(originalError: e, detail: errorAdminAccountLocked); } else if (_isAuthError(e)) { throw const InvalidCredentialsError(); } else if (errorStr.contains('HTTP 5') || diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart index 573338cd0..aaaa9f61b 100644 --- a/lib/providers/auth/auth_provider.dart +++ b/lib/providers/auth/auth_provider.dart @@ -102,6 +102,13 @@ class AuthNotifier extends AsyncNotifier { /// Maps ServiceError to UnexpectedError with proper error code for View layer. ServiceError _mapToViewError(Object error) { + // Passthrough: the coordinator may already have produced an UnexpectedError + // carrying an error-code identifier in `detail` (e.g. account-locked). Don't + // overwrite it with the generic errorUnexpected below — keep it so the login + // view's errorCodeHelper can resolve the specific message. + if (error is UnexpectedError && error.detail == errorAdminAccountLocked) { + return error; + } if (error is InvalidCredentialsError) { return UnexpectedError( detail: errorInvalidAdminPassword, diff --git a/test/core/usp/providers/usp_auth_coordinator_test.dart b/test/core/usp/providers/usp_auth_coordinator_test.dart index 34da677ae..aa8bdce49 100644 --- a/test/core/usp/providers/usp_auth_coordinator_test.dart +++ b/test/core/usp/providers/usp_auth_coordinator_test.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:privacy_gui/constants/error_code.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_auth_coordinator.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; @@ -208,6 +210,45 @@ void main() { }); }); + // --------------------------------------------------------------------------- + // tryUspLogin error mapping — WASM errors → typed ServiceError for the UI + // --------------------------------------------------------------------------- + group('tryUspLogin error mapping', () { + test( + 'account-locked WASM error → UnexpectedError carrying ' + 'errorAdminAccountLocked (so the login view shows the lockout message)', + () async { + when(() => mockUsp.login(any())) + .thenThrow(Exception('Account is locked')); + + await expectLater( + coordinator.tryUspLogin('password'), + throwsA(isA().having( + (e) => e.detail, 'detail', errorAdminAccountLocked)), + ); + }); + + test('invalid-credentials WASM error → InvalidCredentialsError', () async { + when(() => mockUsp.login(any())).thenThrow( + Exception('Login failed: Authentication error: Invalid credentials')); + + await expectLater( + coordinator.tryUspLogin('password'), + throwsA(isA()), + ); + }); + + test('authenticated=false after login → InvalidCredentialsError', () async { + when(() => mockUsp.login(any())).thenAnswer((_) async {}); + when(() => mockUsp.isAuthenticated).thenReturn(false); + + await expectLater( + coordinator.tryUspLogin('password'), + throwsA(isA()), + ); + }); + }); + // --------------------------------------------------------------------------- // syncAfterLogout resets _lastTokenRefresh // --------------------------------------------------------------------------- diff --git a/test/providers/auth/auth_notifier_test.dart b/test/providers/auth/auth_notifier_test.dart index 6eaabe8e0..344fee136 100644 --- a/test/providers/auth/auth_notifier_test.dart +++ b/test/providers/auth/auth_notifier_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:privacy_gui/constants/error_code.dart'; import 'package:privacy_gui/core/connection/services/router_fingerprint_service.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/models/device_info.dart'; @@ -250,6 +251,32 @@ void main() { container.dispose(); }); + test( + 'account-locked error is passed through to the view as ' + 'errorAdminAccountLocked (not overwritten to errorUnexpected)', + () async { + // The coordinator surfaces account-locked as an UnexpectedError carrying + // the error-code identifier in `detail`. _mapToViewError must keep it. + when(() => mockUspCoordinator.tryUspLogin('locked')).thenThrow( + UnexpectedError( + originalError: Exception('Account is locked'), + detail: errorAdminAccountLocked)); + + final container = createContainer(); + container.read(authProvider); + await Future.delayed(Duration.zero); + + final notifier = container.read(authProvider.notifier); + await notifier.localLogin('locked', guardError: true); + + final state = container.read(authProvider); + expect(state.hasError, isTrue); + final error = state.error; + expect(error, isA()); + expect((error as UnexpectedError).detail, errorAdminAccountLocked); + container.dispose(); + }); + test('login does not call fetchDeviceInfo if USP fails', () async { when(() => mockUspCoordinator.tryUspLogin('wrong')) .thenThrow(const InvalidCredentialsError()); From c40e92f6737307fc4556536c8c925ffb8c77ee8b Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Thu, 18 Jun 2026 16:46:29 +0800 Subject: [PATCH 5/7] test(l10n): cover service_error_localizations and ServiceErrorView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files shipped with zero test references. Add unit/widget coverage for the error-display logic, asserting the TYPE/CODE → l10n-key mapping (compared against loc(ctx).errorXxx, not hardcoded English) so the tests survive copy changes but break on mapping drift. service_error_localizations_test: - every sealed subtype → its l10n string (incl. infra types → errorUnexpected) - UnexpectedError surfaces detail when present, else fallback - non-ServiceError input → errorUnexpected - batch _localizeFaultCode per-code branches: 7004/7005/7006/9008 → invalidInput, 7026/7027/9005/9007 → resourceNotFound, 9001 → unauthorized, 9999 → network, unknown vendor code → unexpected (guards the no-raw-text-leak rule) - empty failures → unexpected; first-failure selection; partial-failure path service_error_view_test: - renders title + localized detail + retry when error is set - hides the detail line when error is null - invokes onRetry on tap --- .../service_error_localizations_test.dart | 164 ++++++++++++++++++ .../views/service_error_view_test.dart | 77 ++++++++ 2 files changed, 241 insertions(+) create mode 100644 test/components/localizations/service_error_localizations_test.dart create mode 100644 test/components/views/service_error_view_test.dart diff --git a/test/components/localizations/service_error_localizations_test.dart b/test/components/localizations/service_error_localizations_test.dart new file mode 100644 index 000000000..9e68d2403 --- /dev/null +++ b/test/components/localizations/service_error_localizations_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/usp/models/usp_operation_result.dart'; +import 'package:privacy_gui/l10n/gen/app_localizations.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; + +/// These tests assert the TYPE/CODE → l10n-key mapping rather than the literal +/// English text, so they stay green when copy changes but break the moment the +/// mapping drifts. The expected value is `loc(ctx).errorXxx`, captured from the +/// same context, never a hardcoded string. + +UspErrorDetail _detail(int code) => + UspErrorDetail(requestedPath: 'Device.X.1.', errorCode: code, errorMessage: 'raw'); + +UspCompleteFailureError _completeWith(int code) => + UspCompleteFailureError(summary: 's', failures: [_detail(code)]); + +void main() { + // Pumps a minimal localized widget tree and hands the BuildContext back so + // tests can call both localizeServiceError(ctx, ...) and loc(ctx). + Future pumpContext(WidgetTester tester) async { + late BuildContext captured; + await tester.pumpWidget(MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Builder(builder: (c) { + captured = c; + return const SizedBox(); + }), + )); + return captured; + } + + group('localizeServiceError — type → key', () { + testWidgets('maps each sealed subtype to its l10n string', (tester) async { + final ctx = await pumpContext(tester); + final l = loc(ctx); + + final cases = { + const NotAuthenticatedError(): l.errorNotAuthenticated, + const InvalidCredentialsError(): l.errorInvalidCredentials, + const SessionTokenExpiredError(): l.errorSessionExpired, + const InvalidSessionTokenError(): l.errorInvalidSessionToken, + const UnauthorizedError(): l.errorUnauthorized, + const ResourceNotFoundError(): l.errorResourceNotFound, + const InvalidInputError(): l.errorInvalidInput, + const NetworkError(): l.errorNetwork, + const ConnectivityError(): l.errorConnectivity, + const TimeoutError(): l.errorTimeout, + const ServiceNotInitializedError(): l.errorServiceNotReady, + // Infrastructure-level types fall back to the generic message. + const StorageError(): l.errorUnexpected, + const SerialNumberMismatchError(expected: 'a', actual: 'b'): + l.errorUnexpected, + }; + + cases.forEach((error, expected) { + expect(localizeServiceError(ctx, error), expected, + reason: '${error.runtimeType} should map to its l10n key'); + }); + }); + + testWidgets('UnexpectedError surfaces detail when present, else fallback', + (tester) async { + final ctx = await pumpContext(tester); + final l = loc(ctx); + + expect(localizeServiceError(ctx, const UnexpectedError(detail: 'boom')), + 'boom'); + expect(localizeServiceError(ctx, const UnexpectedError()), + l.errorUnexpected); + }); + + testWidgets('non-ServiceError falls back to errorUnexpected', + (tester) async { + final ctx = await pumpContext(tester); + final l = loc(ctx); + + expect(localizeServiceError(ctx, Exception('not a service error')), + l.errorUnexpected); + expect(localizeServiceError(ctx, 'plain string'), l.errorUnexpected); + }); + }); + + group('batch (_localizeBatch / _localizeFaultCode) — first failure code', () { + testWidgets('invalid-input codes → errorInvalidInput', (tester) async { + final ctx = await pumpContext(tester); + final l = loc(ctx); + for (final code in [7004, 7005, 7006, 9008]) { + expect(localizeServiceError(ctx, _completeWith(code)), l.errorInvalidInput, + reason: 'code $code should map to errorInvalidInput'); + } + }); + + testWidgets('not-found codes → errorResourceNotFound', (tester) async { + final ctx = await pumpContext(tester); + final l = loc(ctx); + for (final code in [7026, 7027, 9005, 9007]) { + expect(localizeServiceError(ctx, _completeWith(code)), + l.errorResourceNotFound, + reason: 'code $code should map to errorResourceNotFound'); + } + }); + + testWidgets('9001 → errorUnauthorized', (tester) async { + final ctx = await pumpContext(tester); + expect(localizeServiceError(ctx, _completeWith(9001)), + loc(ctx).errorUnauthorized); + }); + + testWidgets('9999 (client transport, never reached router) → errorNetwork', + (tester) async { + final ctx = await pumpContext(tester); + expect(localizeServiceError(ctx, _completeWith(9999)), + loc(ctx).errorNetwork); + }); + + testWidgets('unknown vendor code → errorUnexpected (no raw text leak)', + (tester) async { + final ctx = await pumpContext(tester); + expect(localizeServiceError(ctx, _completeWith(7099)), + loc(ctx).errorUnexpected); + }); + + testWidgets('empty failures list → errorUnexpected', (tester) async { + final ctx = await pumpContext(tester); + expect( + localizeServiceError( + ctx, const UspCompleteFailureError(summary: 's', failures: [])), + loc(ctx).errorUnexpected); + }); + + testWidgets('uses the FIRST failure when several are present', + (tester) async { + final ctx = await pumpContext(tester); + final error = UspCompleteFailureError( + summary: 's', + failures: [_detail(9001), _detail(7006)], + ); + // 9001 is first → unauthorized, not invalidInput. + expect(localizeServiceError(ctx, error), loc(ctx).errorUnauthorized); + }); + + testWidgets('UspPartialFailureError also localizes via first failure', + (tester) async { + final ctx = await pumpContext(tester); + final error = UspPartialFailureError( + summary: 's', + successPaths: const ['Device.Y.1.'], + failures: [_detail(7026)], + ); + expect(localizeServiceError(ctx, error), loc(ctx).errorResourceNotFound); + }); + }); +} diff --git a/test/components/views/service_error_view_test.dart b/test/components/views/service_error_view_test.dart new file mode 100644 index 000000000..eb3a4f896 --- /dev/null +++ b/test/components/views/service_error_view_test.dart @@ -0,0 +1,77 @@ +@Tags(['ui']) +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/l10n/gen/app_localizations.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +final _testTheme = AppTheme.create( + brightness: Brightness.light, + seedColor: Colors.blue, + designThemeBuilder: (c) => CustomDesignTheme.fromJson({'style': 'flat'}), +); + +Widget _wrap(Widget child) => MaterialApp( + theme: _testTheme, + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold(body: SizedBox(width: 800, child: child)), + ); + +void main() { + group('ServiceErrorView', () { + testWidgets('shows title, localized error detail, and retry when error set', + (tester) async { + await tester.pumpWidget(_wrap(ServiceErrorView( + error: const NetworkError(), + onRetry: () {}, + ))); + await tester.pumpAndSettle(); + + final en = lookupAppLocalizations(const Locale('en')); + expect(find.text(en.failedToLoadSettings), findsOneWidget); + // NetworkError → errorNetwork (the localized detail line). + expect(find.text(en.errorNetwork), findsOneWidget); + expect(find.text(en.retry), findsOneWidget); + }); + + testWidgets('hides the detail line when error is null', (tester) async { + await tester.pumpWidget(_wrap(ServiceErrorView( + error: null, + onRetry: () {}, + ))); + await tester.pumpAndSettle(); + + final en = lookupAppLocalizations(const Locale('en')); + // Title + retry still render; no error-detail strings present. + expect(find.text(en.failedToLoadSettings), findsOneWidget); + expect(find.text(en.retry), findsOneWidget); + expect(find.text(en.errorNetwork), findsNothing); + expect(find.text(en.errorUnexpected), findsNothing); + }); + + testWidgets('invokes onRetry when the retry button is tapped', + (tester) async { + var tapped = 0; + await tester.pumpWidget(_wrap(ServiceErrorView( + error: const ResourceNotFoundError(), + onRetry: () => tapped++, + ))); + await tester.pumpAndSettle(); + + final en = lookupAppLocalizations(const Locale('en')); + await tester.tap(find.text(en.retry)); + await tester.pumpAndSettle(); + + expect(tapped, 1); + }); + }); +} From 9545b1dee3f448ee70f29b0892197dfa9eeb9a4c Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Fri, 19 Jun 2026 00:46:26 +0800 Subject: [PATCH 6/7] style: dart format the three files flagged by CI Apply dart format to the files the CI format check reported (whitespace / line-wrapping only, no logic changes; affected tests still pass). Verified the whole repo is now format-clean: dart format --set-exit-if-changed passes (1037 files, 0 changed). --- lib/core/usp/providers/usp_auth_coordinator.dart | 3 ++- .../localizations/service_error_localizations_test.dart | 7 ++++--- test/core/usp/providers/usp_auth_coordinator_test.dart | 4 ++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/core/usp/providers/usp_auth_coordinator.dart b/lib/core/usp/providers/usp_auth_coordinator.dart index a502180be..04be9688d 100644 --- a/lib/core/usp/providers/usp_auth_coordinator.dart +++ b/lib/core/usp/providers/usp_auth_coordinator.dart @@ -166,7 +166,8 @@ class UspAuthCoordinator { // Carry the error-code identifier (not a free-form string) so the login // view's errorCodeHelper resolves it to the "too many attempts / // account locked" message. See _mapToViewError passthrough. - throw UnexpectedError(originalError: e, detail: errorAdminAccountLocked); + throw UnexpectedError( + originalError: e, detail: errorAdminAccountLocked); } else if (_isAuthError(e)) { throw const InvalidCredentialsError(); } else if (errorStr.contains('HTTP 5') || diff --git a/test/components/localizations/service_error_localizations_test.dart b/test/components/localizations/service_error_localizations_test.dart index 9e68d2403..40590a0f4 100644 --- a/test/components/localizations/service_error_localizations_test.dart +++ b/test/components/localizations/service_error_localizations_test.dart @@ -12,8 +12,8 @@ import 'package:privacy_gui/localization/localization_hook.dart'; /// mapping drifts. The expected value is `loc(ctx).errorXxx`, captured from the /// same context, never a hardcoded string. -UspErrorDetail _detail(int code) => - UspErrorDetail(requestedPath: 'Device.X.1.', errorCode: code, errorMessage: 'raw'); +UspErrorDetail _detail(int code) => UspErrorDetail( + requestedPath: 'Device.X.1.', errorCode: code, errorMessage: 'raw'); UspCompleteFailureError _completeWith(int code) => UspCompleteFailureError(summary: 's', failures: [_detail(code)]); @@ -96,7 +96,8 @@ void main() { final ctx = await pumpContext(tester); final l = loc(ctx); for (final code in [7004, 7005, 7006, 9008]) { - expect(localizeServiceError(ctx, _completeWith(code)), l.errorInvalidInput, + expect( + localizeServiceError(ctx, _completeWith(code)), l.errorInvalidInput, reason: 'code $code should map to errorInvalidInput'); } }); diff --git a/test/core/usp/providers/usp_auth_coordinator_test.dart b/test/core/usp/providers/usp_auth_coordinator_test.dart index aa8bdce49..1f4075769 100644 --- a/test/core/usp/providers/usp_auth_coordinator_test.dart +++ b/test/core/usp/providers/usp_auth_coordinator_test.dart @@ -223,8 +223,8 @@ void main() { await expectLater( coordinator.tryUspLogin('password'), - throwsA(isA().having( - (e) => e.detail, 'detail', errorAdminAccountLocked)), + throwsA(isA() + .having((e) => e.detail, 'detail', errorAdminAccountLocked)), ); }); From 8aee2b562becde1695aee30dd6f6df44df30864c Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Fri, 19 Jun 2026 01:06:30 +0800 Subject: [PATCH 7/7] docs: fix stale lifecycle comments in usp_internet_settings_service After #951 moved VLAN tagging from Add/Delete to SET on an existing instance, the class doc and InternetSettingsFetchResult field doc still described the old "PPP/VLAN multi-instance lifecycle (Add/Delete)". Update them to reflect the current behavior: PPP instance lifecycle still uses Add; VLAN enable/disable is a SET on the existing instance. Comment-only; no logic change. --- .../services/usp_internet_settings_service.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/page/internet_settings/services/usp_internet_settings_service.dart b/lib/page/internet_settings/services/usp_internet_settings_service.dart index 1a88fcc04..5d353fcd0 100644 --- a/lib/page/internet_settings/services/usp_internet_settings_service.dart +++ b/lib/page/internet_settings/services/usp_internet_settings_service.dart @@ -18,8 +18,8 @@ import 'package:privacy_gui/page/internet_settings/models/usp_wan_connection_typ /// Stateless service that wraps USP generated code for internet settings. /// /// Provides fetch, diff-based save, and DHCP renewal operations. -/// Handles PPP/VLAN multi-instance lifecycle (Add/Delete) and -/// DNS comma-separated conversion. +/// Handles PPP instance lifecycle (Add) and VLAN enable/disable via SET on an +/// existing instance, plus DNS comma-separated conversion. class UspInternetSettingsService { final UspClient _usp; @@ -444,7 +444,9 @@ class InternetSettingsFetchResult { final UspInternetSettingsForm form; final InternetSettingsReadOnlyInfo readOnlyInfo; - /// Instance paths for lifecycle management — tracked by state/notifier. + /// Existing instance paths tracked by state/notifier: [pppInstancePath] for + /// the PPP instance lifecycle, [vlanInstancePath] as the SET target for VLAN + /// enable/disable. final String? pppInstancePath; final String? vlanInstancePath;