diff --git a/lib/components/localizations/service_error_localizations.dart b/lib/components/localizations/service_error_localizations.dart new file mode 100644 index 000000000..6a3c2ac99 --- /dev/null +++ b/lib/components/localizations/service_error_localizations.dart @@ -0,0 +1,88 @@ +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; + 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, + }; +} 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 d6715616d..a8f530bf7 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. @@ -7,28 +10,37 @@ /// /// 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 { - 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 +75,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,46 +104,7 @@ final class UnauthorizedError extends ServiceError { /// Requested resource not found final class ResourceNotFoundError extends ServiceError { - const ResourceNotFoundError(); -} - -// ============================================================================ -// OTP Errors -// ============================================================================ - -/// Invalid OTP code -final class InvalidOtpError extends ServiceError { - const InvalidOtpError(); -} - -/// OTP code has expired -final class ExpiredOtpError extends ServiceError { - const ExpiredOtpError(); -} - -// ============================================================================ -// Admin Password Errors -// ============================================================================ - -/// Admin account is locked -final class AdminAccountLockedError extends ServiceError { - const AdminAccountLockedError(); -} - -/// Invalid reset code provided -final class InvalidResetCodeError extends ServiceError { - final int? attemptsRemaining; - const InvalidResetCodeError({this.attemptsRemaining}); -} - -/// Too many consecutive invalid reset code attempts -final class ConsecutiveInvalidResetCodeError extends ServiceError { - const ConsecutiveInvalidResetCodeError(); -} - -/// Invalid admin password -final class InvalidAdminPasswordError extends ServiceError { - const InvalidAdminPasswordError(); + const ResourceNotFoundError({super.code, super.detail}); } // ============================================================================ @@ -144,55 +117,67 @@ 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(); +} + +/// 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; - const StorageError({this.originalError}); + const StorageError({this.originalError, super.code, super.detail}); } // ============================================================================ @@ -202,13 +187,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 +210,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,49 +238,14 @@ 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(); -} - -// ============================================================================ -// 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]); + 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..0ee0a1e6f 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 -/// -/// This function matches error strings from `usp_framework/usp-client/src/error.rs`. -/// The following strings are part of the API contract: -/// -/// | 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` | -/// -/// **Warning**: If WASM error messages change, this mapping will break silently. -/// Update both sides together. +/// ## Error Contract (the match points this function depends on) +/// +/// 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. +/// +/// ### Source 1 — Rust WASM client string `Display` (`usp-client/src/error.rs`) +/// +/// | 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,51 @@ 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 (e.g. "Path not found: ...", +/// "Parameter is read-only: ...", "Invalid value '...' for '...': ..."). +/// +/// Two things to know about this mapper: +/// +/// 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(); 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/core/usp/providers/usp_auth_coordinator.dart b/lib/core/usp/providers/usp_auth_coordinator.dart index 545bd8dca..04be9688d 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'; @@ -147,7 +148,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 +163,19 @@ class UspAuthCoordinator { // Map WASM errors to ServiceError final errorStr = e.toString(); if (_isAccountLockedError(e)) { - throw const AdminAccountLockedError(); + // 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') || 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/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/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/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/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/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/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/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/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/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/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/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/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/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_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/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/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 71a1c7343..933fc927a 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'); } } @@ -129,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/services/usp_internet_settings_service.dart b/lib/page/internet_settings/services/usp_internet_settings_service.dart index 5ad5a1c4d..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; @@ -57,7 +57,6 @@ class UspInternetSettingsService { debugIpv6Enabled: ipv6.ipv6Enabled, ); } catch (e) { - if (e is ServiceError) rethrow; throw mapUspErrorToServiceError(e); } } @@ -405,12 +404,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, ); } } @@ -429,12 +428,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, ); } } @@ -445,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; 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/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/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/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/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/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/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/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/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/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/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/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 261512e2b..194c4adfa 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; } @@ -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 message) => - message ?? '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 message) => - message ?? '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 message) => - message ?? '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 fc2f55d6b..e2492c67a 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; } @@ -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 message) => - message ?? '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 871213457..294e4f382 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; @@ -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/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/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 6a00c05ee..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; @@ -371,7 +376,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/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..aaaa9f61b 100644 --- a/lib/providers/auth/auth_provider.dart +++ b/lib/providers/auth/auth_provider.dart @@ -102,38 +102,39 @@ 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, - ); + // 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( - 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/components/localizations/service_error_localizations_test.dart b/test/components/localizations/service_error_localizations_test.dart new file mode 100644 index 000000000..40590a0f4 --- /dev/null +++ b/test/components/localizations/service_error_localizations_test.dart @@ -0,0 +1,165 @@ +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); + }); + }); +} diff --git a/test/core/errors/service_error_test.dart b/test/core/errors/service_error_test.dart index a9fc26b6a..551407faf 100644 --- a/test/core/errors/service_error_test.dart +++ b/test/core/errors/service_error_test.dart @@ -8,43 +8,38 @@ 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', () { - 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/core/usp/providers/usp_auth_coordinator_test.dart b/test/core/usp/providers/usp_auth_coordinator_test.dart index 34da677ae..1f4075769 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/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/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..4d52f50f2 100644 --- a/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart +++ b/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart @@ -73,12 +73,12 @@ 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); final state = container.read(uspDhcpReservationsProvider); - expect(state.status.errorMessage, contains('timeout')); + expect(state.status.error, isA()); expect(state.settings.current.reservations, isEmpty); container.dispose(); }); @@ -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..9ff8d9a96 100644 --- a/test/page/dmz/providers/usp_dmz_notifier_test.dart +++ b/test/page/dmz/providers/usp_dmz_notifier_test.dart @@ -81,13 +81,13 @@ 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); 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(); @@ -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..3e1ae0e60 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'), )), ], ); @@ -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(); }); @@ -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/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 747244770..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 @@ -88,12 +88,12 @@ 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); final state = container.read(uspInternetSettingsProvider); - expect(state.status.errorMessage, contains('bridge unreachable')); + expect(state.status.error, isA()); container.dispose(); }); @@ -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); @@ -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 85a01c991..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 @@ -77,12 +77,12 @@ 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); 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(); }); @@ -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..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 @@ -183,7 +183,7 @@ void main() { uspMutationLockProvider.overrideWithValue(UspMutationLock()), lanDataProvider.overrideWith(() => _TestLanDataNotifier( testLanData, - errorToThrow: const NetworkError(message: 'timeout'), + errorToThrow: const NetworkError(detail: 'timeout'), )), ], ); @@ -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(); }); @@ -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..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 @@ -98,14 +98,14 @@ 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(); await Future.delayed(Duration.zero); final state = container.read(uspPortForwardingPageProvider); - expect(state.status.errorMessage, contains('timeout')); + expect(state.status.error, isA()); container.dispose(); }); @@ -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..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 @@ -81,12 +81,12 @@ 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); 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(); }); @@ -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/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/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/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 d19d9d720..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, @@ -55,13 +55,13 @@ 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); final state = container.read(uspWifiAdvancedProvider); - expect(state.status.errorMessage, isNotNull); + expect(state.status.error, isA()); expect(state.settings.current.ieee80211hByRadio, isEmpty); container.dispose(); }); @@ -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..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(); }); @@ -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, (_, __) {}); @@ -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(); }); @@ -585,7 +584,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 +615,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); 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());