diff --git a/openspec/changes/graceful-401-session-dialog/design.md b/openspec/changes/graceful-401-session-dialog/design.md new file mode 100644 index 000000000..b4faf54a2 --- /dev/null +++ b/openspec/changes/graceful-401-session-dialog/design.md @@ -0,0 +1,68 @@ +## Context + +The app uses a global `AuthInterceptor` on Dio that catches 401 responses and immediately calls `onUnauthorized()` → `auth.logout()`. The error still propagates back to callers, causing visible error banners before the widget tree is rebuilt. + +`ApiException.fromDioException()` already extracts the human-readable message via `extractApiMessage()`. So `ApiException.message` for a 401 is already clean (e.g. `"Please Login and try again."`). + +`_AppShellBuilder` in `app_router.dart` already hosts a global bottom sheet overlay for logout confirmation — the same pattern applies for the session dialog. + +## Goals / Non-Goals + +**Goals:** +- On any 401: show one blocking dialog app-wide with the backend's message. +- User must explicitly tap "Sign In Again" to trigger logout. +- No error banners from sync providers on 401. +- Dialog fires only once even if multiple 401s arrive simultaneously. + +**Non-Goals:** +- Changing the logout state machine or GoRouter redirect logic. +- Token refresh / silent re-auth. +- Showing the dialog on 401s from auth-flow endpoints (login, OTP, etc.). + +## Decisions + +### Decision: `sessionExpiredProvider` holds `String?` not `bool` + +Holding the message string (not just a flag) lets the dialog render the backend message directly. `null` = no dialog shown. Non-null string = dialog is visible with that message. + +**Alternative**: Separate `bool` flag + `String` message as two providers. +**Rejected**: Unnecessary complexity; a nullable string is a clean sentinel. + +### Decision: Callback renamed from `onUnauthorized` to `onSessionExpired(String message)` + +The new callback signature passes the extracted message. `AuthInterceptor` extracts the message from `DioException` (or falls back to a default) before calling the callback. + +**Alternative**: Keep `onUnauthorized()` with no args, read message from somewhere else. +**Rejected**: Coupling concern — the interceptor already has the message; passing it is cleaner. + +### Decision: Suppress 401 errors in sync providers before they reach UI state + +In `course_list_provider._performSync()` and `_performSearch()`, if the caught exception is `ApiErrorType.unauthorized`, skip writing to `courseListSyncError` / `state.error`. The dialog handles UX globally. + +Detection: `e is ApiException && e.type == ApiErrorType.unauthorized`, with fallback for raw `DioException` with `statusCode == 401`. + +### Decision: Dialog is rendered as a Stack overlay in `_AppShellBuilder` + +Same pattern as the logout bottom sheet. The `sessionExpiredProvider` is watched inside `_AppShellBuilder`; when non-null, a full-screen semi-transparent overlay + centered dialog card is shown above all content. + +**Alternative**: Use `showDialog()` imperatively via a navigator listener. +**Rejected**: Declarative is safer — avoids context timing issues with GoRouter and avoids needing a BuildContext at interceptor time. + +### Decision: `SessionExpiredDialog` lives in `packages/core/lib/widgets/` + +It depends only on core design tokens and is reusable across any package. + +## Risks / Trade-offs + +- **Risk**: Multiple 401s fire before `sessionExpiredProvider` is set → multiple `setState` calls. The `_isLoggingOut` guard in `AuthInterceptor` already prevents multiple `onSessionExpired` calls. The provider itself is idempotent (same message written twice is fine). +- **Risk**: 401 from an auth-flow endpoint triggers the dialog. → Mitigated by the existing `_authFlowPaths` skip list in `AuthInterceptor`. + +## Migration Plan + +1. Add `sessionExpiredProvider` to `core`. +2. Update `AuthInterceptor` callback signature. +3. Update `dio_provider.dart` wiring. +4. Add `SessionExpiredDialog` widget in `core`. +5. Update `_AppShellBuilder` to watch provider and render overlay. +6. Suppress 401 errors in `course_list_provider.dart`. +7. Delete the old `suppress-401-error-display` change (it is superseded by this one). diff --git a/openspec/changes/graceful-401-session-dialog/proposal.md b/openspec/changes/graceful-401-session-dialog/proposal.md new file mode 100644 index 000000000..c270c1257 --- /dev/null +++ b/openspec/changes/graceful-401-session-dialog/proposal.md @@ -0,0 +1,33 @@ +## Why + +When the backend invalidates a session token, any in-flight API call returns a 401 with a human-readable message (e.g., `"Please Login and try again."`). Today the app: +1. Flashes the raw JSON error object in a red banner on screen +2. Immediately auto-logs out the user with no explanation + +This is confusing and feels like a crash. The fix is to intercept 401s globally, suppress all error UI, and show a single non-dismissible dialog that displays the backend message and gives the user one clear action: sign in again. + +## What Changes + +- `AuthInterceptor.onUnauthorized` callback is replaced with `onSessionExpired(String message)` — passes the extracted API message instead of calling logout directly. +- A new `sessionExpiredProvider` (`StateProvider`) is added in `core` to hold the expiry message. `null` = no dialog. Non-null = show dialog with that message. +- `dio_provider.dart` is updated to wire the new callback: set `sessionExpiredProvider.state = message` on 401. +- `_AppShellBuilder` in `app_router.dart` watches `sessionExpiredProvider` and renders a non-dismissible `SessionExpiredDialog` overlay when non-null. +- The dialog displays the API message and a "Sign In Again" button. Tapping calls `auth.logout()`. +- All sync providers (`course_list_provider.dart`) suppress 401 errors — they must NOT write to visible error state when a 401 fires (since the dialog handles it globally). + +## Capabilities + +### New Capabilities +- `session-expired-dialog`: A global, non-dismissible dialog overlay that intercepts 401 Unauthorized responses, displays the backend-provided message, and gives the user a single "Sign In Again" action to trigger logout. + +### Modified Capabilities +- None — the logout flow and auth state machine are unchanged. + +## Impact + +- `packages/core/lib/network/auth_interceptor.dart` — callback signature change +- `packages/core/lib/network/dio_provider.dart` — wiring the new callback +- `packages/core/lib/data/auth/` — new `sessionExpiredProvider` +- `packages/testpress/lib/navigation/app_router.dart` — dialog overlay in `_AppShellBuilder` +- `packages/courses/lib/providers/course_list_provider.dart` — suppress 401 errors from surfacing in state +- A new `SessionExpiredDialog` widget (in `core` or `testpress`) diff --git a/openspec/changes/graceful-401-session-dialog/specs/session-expired-dialog/spec.md b/openspec/changes/graceful-401-session-dialog/specs/session-expired-dialog/spec.md new file mode 100644 index 000000000..adf14af32 --- /dev/null +++ b/openspec/changes/graceful-401-session-dialog/specs/session-expired-dialog/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: sessionExpiredProvider holds the 401 message +The system SHALL expose a `StateProvider` named `sessionExpiredProvider` in `core`. A `null` value means no session expiry is in progress. A non-null value is the human-readable message extracted from the 401 API response. + +#### Scenario: No active session expiry +- **WHEN** no 401 has been received +- **THEN** `sessionExpiredProvider.state` is `null` + +#### Scenario: 401 received from a protected endpoint +- **WHEN** `AuthInterceptor.onError` processes a 401 from a non-auth-flow path +- **THEN** `sessionExpiredProvider.state` is set to the message from `ApiException.message` +- **THEN** the value is non-null and non-empty + +#### Scenario: Fallback message when backend sends no message +- **WHEN** the 401 response body contains no extractable message +- **THEN** `sessionExpiredProvider.state` is set to a default string: `"Your session has expired. Please sign in again."` + +### Requirement: AuthInterceptor uses onSessionExpired callback with message +The `AuthInterceptor` SHALL replace `onUnauthorized: void Function()` with `onSessionExpired: void Function(String message)`. On a 401 from a non-auth-flow path, it SHALL extract the message from the `DioException` and call `onSessionExpired(message)` exactly once per session. + +#### Scenario: First 401 triggers callback +- **WHEN** a 401 DioException arrives on a protected endpoint +- **THEN** `onSessionExpired` is called once with the extracted message +- **THEN** `_isLoggingOut` is set to `true` to prevent repeated calls + +#### Scenario: Subsequent 401s are ignored +- **WHEN** a second 401 arrives while `_isLoggingOut == true` +- **THEN** `onSessionExpired` is NOT called again +- **THEN** `sessionExpiredProvider.state` is unchanged + +#### Scenario: Auth-flow endpoints are excluded +- **WHEN** a 401 arrives on a path in `_authFlowPaths` (login, OTP, etc.) +- **THEN** `onSessionExpired` is NOT called +- **THEN** the error propagates normally to the caller + +### Requirement: SessionExpiredDialog is a non-dismissible blocking overlay +The system SHALL render a `SessionExpiredDialog` widget when `sessionExpiredProvider` is non-null. The dialog MUST NOT be dismissible by tapping outside or pressing back. + +#### Scenario: Dialog appears on session expiry +- **WHEN** `sessionExpiredProvider.state` becomes non-null +- **THEN** a full-screen semi-transparent overlay is shown above all app content +- **THEN** a centered card dialog is shown with the message from `sessionExpiredProvider.state` +- **THEN** a single "Sign In Again" button is visible + +#### Scenario: Dialog cannot be dismissed without action +- **WHEN** the user taps outside the dialog card +- **THEN** the dialog remains visible +- **WHEN** the user presses the device back button +- **THEN** the dialog remains visible + +#### Scenario: User taps "Sign In Again" +- **WHEN** the user taps the "Sign In Again" button +- **THEN** `auth.logout()` is called +- **THEN** `sessionExpiredProvider.state` is reset to `null` +- **THEN** the app navigates to the login screen (GoRouter redirect handles this automatically) + +### Requirement: Sync providers MUST NOT surface 401 errors in visible UI state +When a 401 is caught in any background sync provider, it SHALL be silently discarded and MUST NOT be written to any provider state that drives visible error UI. + +#### Scenario: Study screen sync catches a 401 +- **WHEN** `CourseList._performSync()` or `CourseSearch._performSearch()` catches an `ApiException` with `type == ApiErrorType.unauthorized` +- **THEN** `courseListSyncError` is NOT updated +- **THEN** `CourseSearchState.error` is NOT set +- **THEN** no red error banner appears in `StudyScreen` + +#### Scenario: Non-401 sync errors are still shown +- **WHEN** a sync provider catches an error that is NOT `ApiErrorType.unauthorized` +- **THEN** the error is written to state as before (existing behaviour preserved) diff --git a/openspec/changes/graceful-401-session-dialog/tasks.md b/openspec/changes/graceful-401-session-dialog/tasks.md new file mode 100644 index 000000000..d88ca6e78 --- /dev/null +++ b/openspec/changes/graceful-401-session-dialog/tasks.md @@ -0,0 +1,34 @@ +## 1. Core: Session Expired Provider + +- [x] 1.1 Add `sessionExpiredProvider` as `StateProvider` in `packages/core/lib/data/auth/auth_provider.dart` (initial state: `null`) + +## 2. Core: AuthInterceptor Callback Update + +- [x] 2.1 Rename `onUnauthorized: void Function()?` to `onSessionExpired: void Function(String message)?` in `AuthInterceptor` +- [x] 2.2 In `AuthInterceptor.onError()`, extract the message from the `DioException` using `ApiException.fromDioException(err).message` before calling `onSessionExpired` +- [x] 2.3 Apply a fallback message `"Your session has expired. Please sign in again."` when the extracted message is empty + +## 3. Core: Dio Provider Wiring + +- [x] 3.1 Update `dio_provider.dart` — replace `onUnauthorized: () => ref.read(authProvider.notifier).logout()` with `onSessionExpired: (msg) => ref.read(sessionExpiredProvider.notifier).state = msg` + +## 4. Core: SessionExpiredDialog Widget + +- [x] 4.1 Create `packages/core/lib/widgets/session_expired_dialog.dart` — a `ConsumerWidget` that accepts `message` and `onSignIn` callback +- [x] 4.2 Style: semi-transparent full-screen barrier + centered card with lock icon, title "Session Ended", message text, and "Sign In Again" primary button +- [x] 4.3 Make it non-dismissible: `WillPopScope` (or `PopScope`) returning false, no barrier tap dismissal + +## 5. App Shell: Dialog Overlay + +- [x] 5.1 In `_AppShellBuilder` (`app_router.dart`), watch `sessionExpiredProvider` +- [x] 5.2 When `sessionExpiredProvider` is non-null, render `SessionExpiredDialog` as a `Stack` overlay above the `AppShell` (same pattern as the logout bottom sheet) +- [x] 5.3 Wire the `onSignIn` callback: call `auth.logout()` then reset `sessionExpiredProvider.state = null` + +## 6. Courses: Suppress 401 Errors from UI State + +- [x] 6.1 In `course_list_provider._performSync()` catch block — if `e is ApiException && e.type == ApiErrorType.unauthorized`, return early without setting `courseListSyncError` +- [x] 6.2 In `CourseSearch._performSearch()` catch block — same guard, skip `state.copyWith(error: e)` on unauthorized + +## 7. Cleanup + +- [x] 7.1 Export `sessionExpiredProvider` and `SessionExpiredDialog` from `core.dart` diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index 33051dfc3..afa847ea1 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -42,6 +42,7 @@ export 'widgets/dashboard_header.dart'; export 'widgets/lesson_detail_shell.dart'; export 'widgets/bookmark_folders_sheet.dart'; export 'widgets/app_toast.dart'; +export 'widgets/session_expired_dialog.dart'; // Shell export 'shell/app_shell.dart'; diff --git a/packages/core/lib/data/auth/auth_provider.dart b/packages/core/lib/data/auth/auth_provider.dart index 4255d5ea6..546890486 100644 --- a/packages/core/lib/data/auth/auth_provider.dart +++ b/packages/core/lib/data/auth/auth_provider.dart @@ -1,4 +1,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:google_sign_in/google_sign_in.dart'; import '../config/app_config.dart'; @@ -42,6 +43,16 @@ final authRepositoryProvider = Provider((ref) { ); }); +/// Holds the session-expired message from a 401 response. +/// `null` = no session expiry in progress. +/// Non-null = show the SessionExpiredDialog with this message. +final sessionExpiredProvider = StateProvider((ref) => null); + +/// Tracks whether the onboarding screen has already been shown this app session. +/// Stored in Riverpod so it resets between widget tests and stays consistent +/// with how the rest of UI state is managed. +final hasShownOnboardingProvider = StateProvider((ref) => false); + @Riverpod(keepAlive: true) class Auth extends _$Auth { AuthRepository get _repository => ref.read(authRepositoryProvider); diff --git a/packages/core/lib/data/exceptions/api_exception.dart b/packages/core/lib/data/exceptions/api_exception.dart index 67a2ac1f3..7e0f2c0af 100644 --- a/packages/core/lib/data/exceptions/api_exception.dart +++ b/packages/core/lib/data/exceptions/api_exception.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:dio/dio.dart'; enum ApiErrorType { @@ -84,7 +85,10 @@ class ApiException implements Exception { if (statusCode == 401) { return ApiException( - backendMessage ?? 'You are not authorized to perform this action.', + // Pass the backend message as-is, or empty string if none. + // An empty message signals SessionExpiredDialog to show the + // localized sessionExpiredFallbackMessage in the user's locale. + backendMessage ?? '', type: ApiErrorType.unauthorized, statusCode: statusCode, data: data, @@ -152,7 +156,20 @@ class ApiException implements Exception { static String? extractApiMessage(dynamic responseData) { if (responseData == null) return null; if (responseData is String && responseData.trim().isNotEmpty) { - return responseData.trim(); + final trimmed = responseData.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { + final decoded = jsonDecode(trimmed); + final nestedMsg = extractApiMessage(decoded); + if (nestedMsg != null && nestedMsg.isNotEmpty) { + return nestedMsg; + } + } catch (_) { + // Fall through to returning raw string if JSON parsing fails + } + } + return trimmed; } if (responseData is List) { final messages = []; diff --git a/packages/core/lib/generated/l10n/app_localizations.dart b/packages/core/lib/generated/l10n/app_localizations.dart index 6ee382e42..be9a61c5b 100644 --- a/packages/core/lib/generated/l10n/app_localizations.dart +++ b/packages/core/lib/generated/l10n/app_localizations.dart @@ -5038,6 +5038,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'View'** String get viewAction; + + /// No description provided for @sessionExpiredTitle. + /// + /// In en, this message translates to: + /// **'Session Ended'** + String get sessionExpiredTitle; + + /// No description provided for @sessionExpiredIconSemantics. + /// + /// In en, this message translates to: + /// **'Session ended icon'** + String get sessionExpiredIconSemantics; + + /// No description provided for @sessionExpiredLoginButton. + /// + /// In en, this message translates to: + /// **'Login Again'** + String get sessionExpiredLoginButton; + + /// No description provided for @sessionExpiredFallbackMessage. + /// + /// In en, this message translates to: + /// **'Your session has expired. Please sign in again.'** + String get sessionExpiredFallbackMessage; } class _AppLocalizationsDelegate diff --git a/packages/core/lib/generated/l10n/app_localizations_ar.dart b/packages/core/lib/generated/l10n/app_localizations_ar.dart index 9a40c789e..82f82f9db 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ar.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ar.dart @@ -2792,4 +2792,17 @@ class AppLocalizationsAr extends AppLocalizations { @override String get viewAction => 'عرض'; + + @override + String get sessionExpiredTitle => 'انتهت الجلسة'; + + @override + String get sessionExpiredIconSemantics => 'أيقونة انتهاء الجلسة'; + + @override + String get sessionExpiredLoginButton => 'تسجيل الدخول مرة أخرى'; + + @override + String get sessionExpiredFallbackMessage => + 'انتهت صلاحية جلستك. يرجى تسجيل الدخول مرة أخرى.'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_en.dart b/packages/core/lib/generated/l10n/app_localizations_en.dart index 096c26d4c..2d2183fdd 100644 --- a/packages/core/lib/generated/l10n/app_localizations_en.dart +++ b/packages/core/lib/generated/l10n/app_localizations_en.dart @@ -2790,4 +2790,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get viewAction => 'View'; + + @override + String get sessionExpiredTitle => 'Session Ended'; + + @override + String get sessionExpiredIconSemantics => 'Session ended icon'; + + @override + String get sessionExpiredLoginButton => 'Login Again'; + + @override + String get sessionExpiredFallbackMessage => + 'Your session has expired. Please sign in again.'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_ml.dart b/packages/core/lib/generated/l10n/app_localizations_ml.dart index 392fb895e..f7f45d1cf 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ml.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ml.dart @@ -2842,4 +2842,17 @@ class AppLocalizationsMl extends AppLocalizations { @override String get viewAction => 'കാണുക'; + + @override + String get sessionExpiredTitle => 'സെഷൻ അവസാനിച്ചു'; + + @override + String get sessionExpiredIconSemantics => 'സെഷൻ അവസാനിച്ചതിന്റെ ഐക്കൺ'; + + @override + String get sessionExpiredLoginButton => 'വീണ്ടും ലോഗിൻ ചെയ്യുക'; + + @override + String get sessionExpiredFallbackMessage => + 'നിങ്ങളുടെ സെഷൻ കാലഹരണപ്പെട്ടു. ദയവായി വീണ്ടും ലോഗിൻ ചെയ്യുക.'; } diff --git a/packages/core/lib/generated/l10n/app_localizations_ta.dart b/packages/core/lib/generated/l10n/app_localizations_ta.dart index f223f75ed..c782a708c 100644 --- a/packages/core/lib/generated/l10n/app_localizations_ta.dart +++ b/packages/core/lib/generated/l10n/app_localizations_ta.dart @@ -2835,4 +2835,17 @@ class AppLocalizationsTa extends AppLocalizations { @override String get viewAction => 'காண்க'; + + @override + String get sessionExpiredTitle => 'அமர்வு முடிந்தது'; + + @override + String get sessionExpiredIconSemantics => 'அமர்வு முடிந்ததற்கான ஐகான்'; + + @override + String get sessionExpiredLoginButton => 'மீண்டும் உள்நுழைக'; + + @override + String get sessionExpiredFallbackMessage => + 'உங்கள் அமர்வு காலாவதியாகிவிட்டது. தயவுசெய்து மீண்டும் உள்நுழையவும்.'; } diff --git a/packages/core/lib/l10n/app_ar.arb b/packages/core/lib/l10n/app_ar.arb index 1f45b4673..92bb66341 100644 --- a/packages/core/lib/l10n/app_ar.arb +++ b/packages/core/lib/l10n/app_ar.arb @@ -1074,4 +1074,9 @@ "downloadCompleted": "اكتمل التنزيل", "downloadStarted": "بدأ التنزيل", "downloadingFile": "جاري التنزيل...", - "viewAction": "عرض"} + "viewAction": "عرض", + "sessionExpiredTitle": "انتهت الجلسة", + "sessionExpiredIconSemantics": "أيقونة انتهاء الجلسة", + "sessionExpiredLoginButton": "تسجيل الدخول مرة أخرى", + "sessionExpiredFallbackMessage": "انتهت صلاحية جلستك. يرجى تسجيل الدخول مرة أخرى." +} diff --git a/packages/core/lib/l10n/app_en.arb b/packages/core/lib/l10n/app_en.arb index c9a1fb261..02e83582a 100644 --- a/packages/core/lib/l10n/app_en.arb +++ b/packages/core/lib/l10n/app_en.arb @@ -1390,4 +1390,9 @@ "downloadCompleted": "Download completed", "downloadStarted": "Download started", "downloadingFile": "Downloading...", - "viewAction": "View"} + "viewAction": "View", + "sessionExpiredTitle": "Session Ended", + "sessionExpiredIconSemantics": "Session ended icon", + "sessionExpiredLoginButton": "Login Again", + "sessionExpiredFallbackMessage": "Your session has expired. Please sign in again." +} diff --git a/packages/core/lib/l10n/app_ml.arb b/packages/core/lib/l10n/app_ml.arb index 535c01551..e2f2e6fa0 100644 --- a/packages/core/lib/l10n/app_ml.arb +++ b/packages/core/lib/l10n/app_ml.arb @@ -1074,4 +1074,9 @@ "downloadCompleted": "ഡൗൺലോഡ് പൂർത്തിയായി", "downloadStarted": "ഡൗൺലോഡ് ആരംഭിച്ചു", "downloadingFile": "ഡൗൺലോഡ് ചെയ്യുന്നു...", - "viewAction": "കാണുക"} + "viewAction": "കാണുക", + "sessionExpiredTitle": "സെഷൻ അവസാനിച്ചു", + "sessionExpiredIconSemantics": "സെഷൻ അവസാനിച്ചതിന്റെ ഐക്കൺ", + "sessionExpiredLoginButton": "വീണ്ടും ലോഗിൻ ചെയ്യുക", + "sessionExpiredFallbackMessage": "നിങ്ങളുടെ സെഷൻ കാലഹരണപ്പെട്ടു. ദയവായി വീണ്ടും ലോഗിൻ ചെയ്യുക." +} diff --git a/packages/core/lib/l10n/app_ta.arb b/packages/core/lib/l10n/app_ta.arb index f1210f261..51f216474 100644 --- a/packages/core/lib/l10n/app_ta.arb +++ b/packages/core/lib/l10n/app_ta.arb @@ -1308,4 +1308,9 @@ "downloadCompleted": "பதிவிறக்கம் முடிந்தது", "downloadStarted": "பதிவிறக்கம் தொடங்கியது", "downloadingFile": "பதிவிறக்கப்படுகிறது...", - "viewAction": "காண்க"} + "viewAction": "காண்க", + "sessionExpiredTitle": "அமர்வு முடிந்தது", + "sessionExpiredIconSemantics": "அமர்வு முடிந்ததற்கான ஐகான்", + "sessionExpiredLoginButton": "மீண்டும் உள்நுழைக", + "sessionExpiredFallbackMessage": "உங்கள் அமர்வு காலாவதியாகிவிட்டது. தயவுசெய்து மீண்டும் உள்நுழையவும்." +} diff --git a/packages/core/lib/network/auth_interceptor.dart b/packages/core/lib/network/auth_interceptor.dart index 8b2d7f0c6..50ad025c0 100644 --- a/packages/core/lib/network/auth_interceptor.dart +++ b/packages/core/lib/network/auth_interceptor.dart @@ -1,12 +1,13 @@ import 'package:dio/dio.dart'; import 'api_endpoints.dart'; +import '../data/exceptions/api_exception.dart'; /// Attaches the JWT authentication token to the Authorization header. /// Fetches the token asynchronously from storage to ensure it's always fresh. -/// Also handles global 401 Unauthorized responses to trigger session invalidation. +/// Also handles global 401 Unauthorized responses to trigger session expiry dialog. class AuthInterceptor extends Interceptor { final Future Function() getToken; - final void Function()? onUnauthorized; + final void Function(String message)? onSessionExpired; bool _isLoggingOut = false; /// Paths that should not have an Authorization header attached. @@ -17,7 +18,7 @@ class AuthInterceptor extends Interceptor { ApiEndpoints.resetPassword, ]; - AuthInterceptor({required this.getToken, this.onUnauthorized}); + AuthInterceptor({required this.getToken, this.onSessionExpired}); @override void onRequest( @@ -55,7 +56,10 @@ class AuthInterceptor extends Interceptor { if (!isAuthFlowPath && !isLogoutRequest) { if (!_isLoggingOut) { _isLoggingOut = true; - onUnauthorized?.call(); + final apiException = ApiException.fromDioException(err); + // Pass the backend message, or empty string if none — the dialog + // resolves an empty message to a localized fallback at render time. + onSessionExpired?.call(apiException.message); } } } diff --git a/packages/core/lib/network/dio_provider.dart b/packages/core/lib/network/dio_provider.dart index 70f6646d8..a0b2ae133 100644 --- a/packages/core/lib/network/dio_provider.dart +++ b/packages/core/lib/network/dio_provider.dart @@ -12,7 +12,7 @@ import 'auth_interceptor.dart'; class DioFactory { static Dio createBackgroundDio({ required Future Function() getToken, - void Function()? onUnauthorized, + void Function(String message)? onSessionExpired, }) { final dio = Dio( BaseOptions( @@ -35,7 +35,7 @@ class DioFactory { dio.interceptors.add(UserAgentInterceptor()); dio.interceptors.add( - AuthInterceptor(getToken: getToken, onUnauthorized: onUnauthorized), + AuthInterceptor(getToken: getToken, onSessionExpired: onSessionExpired), ); if (kDebugMode) { @@ -55,6 +55,7 @@ class DioFactory { final Provider dioProvider = Provider((ref) { return DioFactory.createBackgroundDio( getToken: () => ref.read(authLocalDataSourceProvider).getToken(), - onUnauthorized: () => ref.read(authProvider.notifier).logout(), + onSessionExpired: (msg) => + ref.read(sessionExpiredProvider.notifier).state = msg, ); }); diff --git a/packages/core/lib/widgets/session_expired_dialog.dart b/packages/core/lib/widgets/session_expired_dialog.dart new file mode 100644 index 000000000..daab60a3a --- /dev/null +++ b/packages/core/lib/widgets/session_expired_dialog.dart @@ -0,0 +1,118 @@ +import 'package:flutter/widgets.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../design/design_provider.dart'; +import '../accessibility/app_semantics.dart'; +import '../localization/l10n_helper.dart'; +import 'app_text.dart'; +import 'app_button.dart'; + +/// A non-dismissible blocking overlay shown when the user's session has expired. +/// +/// Displays the backend-provided [message] and a single "Login Again" action. +/// Cannot be dismissed by tapping outside or pressing back. +class SessionExpiredDialog extends StatelessWidget { + const SessionExpiredDialog({ + super.key, + required this.message, + required this.onSignIn, + }); + + final String message; + final VoidCallback onSignIn; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + + return PopScope( + // Prevent back button from dismissing the dialog + canPop: false, + child: ColoredBox( + color: design.colors.overlay, + child: Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: design.spacing.xl), + child: _DialogCard(message: message, onSignIn: onSignIn), + ), + ), + ), + ); + } +} + +class _DialogCard extends StatelessWidget { + const _DialogCard({required this.message, required this.onSignIn}); + + final String message; + final VoidCallback onSignIn; + + @override + Widget build(BuildContext context) { + final design = Design.of(context); + final l10n = L10n.of(context); + + // Icon container size: 2 × xl icon + padding on all sides + final iconContainerSize = design.iconSize.xl * 2; + + return Container( + decoration: BoxDecoration( + color: design.colors.card, + borderRadius: design.radius.card, + boxShadow: design.shadows.floating, + ), + padding: EdgeInsets.all(design.spacing.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Lock icon container + Semantics( + image: true, + label: l10n.sessionExpiredIconSemantics, + child: Container( + width: iconContainerSize, + height: iconContainerSize, + decoration: BoxDecoration( + color: design.colors.error.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.lock, + size: design.iconSize.xl, + color: design.colors.error, + ), + ), + ), + + SizedBox(height: design.spacing.lg), + + // Title + AppSemantics.header( + label: l10n.sessionExpiredTitle, + child: AppText.title( + l10n.sessionExpiredTitle, + textAlign: TextAlign.center, + ), + ), + + SizedBox(height: design.spacing.sm), + + // Backend API message — fall back to localized string if empty + AppText.body( + message.isNotEmpty ? message : l10n.sessionExpiredFallbackMessage, + color: design.colors.textSecondary, + textAlign: TextAlign.center, + ), + + SizedBox(height: design.spacing.xl), + + // Login Again button + AppButton.primary( + label: l10n.sessionExpiredLoginButton, + onPressed: onSignIn, + fullWidth: true, + ), + ], + ), + ); + } +} diff --git a/packages/core/lib/workers/offline_exam_sync_worker.dart b/packages/core/lib/workers/offline_exam_sync_worker.dart index 9a06ac036..2aa1765fc 100644 --- a/packages/core/lib/workers/offline_exam_sync_worker.dart +++ b/packages/core/lib/workers/offline_exam_sync_worker.dart @@ -26,7 +26,7 @@ void callbackDispatcher() { final dio = DioFactory.createBackgroundDio( getToken: () => authLocalDataSource.getToken(), - onUnauthorized: () {}, + onSessionExpired: (_) {}, ); final db = AppDatabase(); diff --git a/packages/courses/lib/providers/course_list_provider.dart b/packages/courses/lib/providers/course_list_provider.dart index dfcf39f96..bc1af3fbb 100644 --- a/packages/courses/lib/providers/course_list_provider.dart +++ b/packages/courses/lib/providers/course_list_provider.dart @@ -175,6 +175,8 @@ class CourseList extends _$CourseList { } } catch (e, st) { sentryService.captureException(e, stackTrace: st); + // Suppress 401 errors — the SessionExpiredDialog handles UX globally + if (e is ApiException && e.type == ApiErrorType.unauthorized) return; // Capture the error but don't rethrow (so stream from DB is still visible) ref.read(courseListSyncError.notifier).state = e; } finally { @@ -268,6 +270,12 @@ class CourseSearch extends _$CourseSearch { ); } catch (e, st) { sentryService.captureException(e, stackTrace: st); + // Suppress 401 errors — the SessionExpiredDialog handles UX globally. + // Still reset isLoading so the search UI doesn't get stuck in a spinner. + if (e is ApiException && e.type == ApiErrorType.unauthorized) { + state = state.copyWith(isLoading: false); + return; + } state = state.copyWith(error: e, isLoading: false); } finally { _pendingRequest = null; diff --git a/packages/testpress/lib/navigation/app_router.dart b/packages/testpress/lib/navigation/app_router.dart index 10ffdcb37..b9ee8d354 100644 --- a/packages/testpress/lib/navigation/app_router.dart +++ b/packages/testpress/lib/navigation/app_router.dart @@ -107,6 +107,7 @@ class _AppShellBuilder extends ConsumerWidget { final items = activeTabs.map((tab) => tab.toTabItem(settings)).toList(); final isLogoutSheetOpen = ref.watch(isLogoutSheetOpenProvider); final activeTabId = allTabs[navigationShell.currentIndex].id; + final sessionExpiredMessage = ref.watch(sessionExpiredProvider); void closeSheet() => ref.read(isLogoutSheetOpenProvider.notifier).state = false; @@ -115,32 +116,47 @@ class _AppShellBuilder extends ConsumerWidget { builder: (context, constraints) { final isLandscape = constraints.maxWidth > constraints.maxHeight; - return AppShell( - bottomNavigationBar: AppTabBar( - items: items, - activeItemId: activeTabId, - onTabChange: (id) => - _onTabItemTapped(navigationShell, id, allTabs: allTabs), - ), - navigationRail: AppNavigationRail( - items: items, - activeItemId: activeTabId, - onTabChange: (id) => - _onTabItemTapped(navigationShell, id, allTabs: allTabs), - ), - drawer: DashboardDrawer(isLandscape: isLandscape), - bottomSheet: AppBottomSheet( - isOpen: isLogoutSheetOpen, - onClose: closeSheet, - child: LogoutConfirmationSheet( - onConfirm: () { - closeSheet(); - ref.read(authProvider.notifier).logout(); - }, - onCancel: closeSheet, + return Stack( + children: [ + AppShell( + bottomNavigationBar: AppTabBar( + items: items, + activeItemId: activeTabId, + onTabChange: (id) => + _onTabItemTapped(navigationShell, id, allTabs: allTabs), + ), + navigationRail: AppNavigationRail( + items: items, + activeItemId: activeTabId, + onTabChange: (id) => + _onTabItemTapped(navigationShell, id, allTabs: allTabs), + ), + drawer: DashboardDrawer(isLandscape: isLandscape), + bottomSheet: AppBottomSheet( + isOpen: isLogoutSheetOpen, + onClose: closeSheet, + child: LogoutConfirmationSheet( + onConfirm: () { + closeSheet(); + ref.read(authProvider.notifier).logout(); + }, + onCancel: closeSheet, + ), + ), + child: navigationShell, ), - ), - child: navigationShell, + // Session expired overlay — shown above all content when a 401 fires + if (sessionExpiredMessage != null) + Positioned.fill( + child: SessionExpiredDialog( + message: sessionExpiredMessage, + onSignIn: () async { + await ref.read(authProvider.notifier).logout(); + ref.read(sessionExpiredProvider.notifier).state = null; + }, + ), + ), + ], ); }, ); diff --git a/packages/testpress/lib/navigation/routes/auth_routes.dart b/packages/testpress/lib/navigation/routes/auth_routes.dart index 02d62f28a..827eb1092 100644 --- a/packages/testpress/lib/navigation/routes/auth_routes.dart +++ b/packages/testpress/lib/navigation/routes/auth_routes.dart @@ -1,5 +1,7 @@ import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:core/core.dart'; +import 'package:core/data/data.dart'; import 'package:profile/profile.dart'; class AuthRoutes { @@ -20,9 +22,27 @@ class AuthRoutes { ) { final path = state.uri.path; final isAuthRoute = _authPaths.contains(path); + final container = ProviderScope.containerOf(context, listen: false); - if (!isLoggedIn && !isAuthRoute) return '/onboarding'; - if (isLoggedIn && isAuthRoute) return '/home'; + if (!isLoggedIn && !isAuthRoute) { + container.read(hasShownOnboardingProvider.notifier).state = true; + return '/login'; + } + if (isLoggedIn && isAuthRoute) { + container.read(hasShownOnboardingProvider.notifier).state = true; + return '/home'; + } + + if (!isLoggedIn && path == '/onboarding') { + final hasShown = container.read(hasShownOnboardingProvider); + if (!hasShown) { + container.read(hasShownOnboardingProvider.notifier).state = true; + return null; + } + return '/login'; + } + + container.read(hasShownOnboardingProvider.notifier).state = true; return null; }