From 7ac7c5bbc3fdff043e5289aab2286e627c8dc8de Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Thu, 28 May 2026 20:05:21 +0000 Subject: [PATCH 1/9] refactor: improve startup performance by enabling parallel background hydration and explicit sync state handling across UI components, make profile sync non-blocking chore: bump app ver to 4.4.6 --- .example.env | 4 +- mobile/lib/config/app_config.dart | 2 +- mobile/lib/providers/auth_provider.dart | 58 ++++++++++++------- mobile/lib/providers/dashboard_provider.dart | 15 ----- mobile/lib/providers/leave_provider.dart | 6 -- .../lib/providers/notification_provider.dart | 3 - mobile/lib/providers/score_provider.dart | 6 -- mobile/lib/providers/tracking_provider.dart | 6 -- mobile/lib/screens/leaves_screen.dart | 9 +++ mobile/lib/screens/notifications_screen.dart | 11 ++++ mobile/lib/screens/scores_screen.dart | 4 +- mobile/lib/screens/splash_screen.dart | 27 ++++++--- mobile/lib/screens/tracking_screen.dart | 4 +- mobile/lib/services/secure_storage.dart | 14 +++++ mobile/pubspec.yaml | 2 +- .../auth_provider_analytics_test.dart | 3 +- .../auth_profile_settings_coverage_test.dart | 5 +- .../stealth_headers_service_test.dart | 5 +- package-lock.json | 4 +- package.json | 2 +- public/openapi/openapi.yaml | 2 +- 21 files changed, 114 insertions(+), 78 deletions(-) diff --git a/.example.env b/.example.env index 5c3ac5ee..94f0ec38 100644 --- a/.example.env +++ b/.example.env @@ -44,7 +44,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass # âš ī¸ App version displayed in footer and health checks # 🔨 Build-time (Infisical `/build-time` folder) -NEXT_PUBLIC_APP_VERSION=4.4.5 +NEXT_PUBLIC_APP_VERSION=4.4.6 # âš ī¸ Your production domain WITHOUT https:// # All URL-based variables are derived from this. @@ -359,7 +359,7 @@ JWE_PRIVATE_KEY= # âš ī¸ Minimum supported app version required to bypass forced update # 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) -MIN_APP_VERSION=4.4.5 +MIN_APP_VERSION=4.4.6 # â„šī¸ Enforce Firebase App Check for all mobile clients in production # Valid: "true", "false" (default: false in dev, true recommended in prod) diff --git a/mobile/lib/config/app_config.dart b/mobile/lib/config/app_config.dart index a4ea7bbe..ca792f8c 100644 --- a/mobile/lib/config/app_config.dart +++ b/mobile/lib/config/app_config.dart @@ -75,7 +75,7 @@ class AppConfig { /// Current application version (derived from Infisical compilation injection). static String get appVersion => - const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.5'); + const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.6'); /// Commit SHA injected by CI for release builds. static String get appCommitSha => diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 95826493..773542c7 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -562,11 +562,13 @@ class AuthNotifier extends AsyncNotifier ezygoToken: ezygoToken ?? '', ); - // Synchronously await critical profile sync to block authProvider initialization - // until server profile sync returns 200 successfully. - await _runBackgroundStartupHydration(user); + // Trigger profile sync in parallel without blocking startup/splash screen + AppLogger.safeUnawait( + _runBackgroundStartupHydration(user), + 'AuthNotifier: background startup hydration', + ); - return state.value ?? user; + return user.copyWith(isSyncing: true); } Future _runBackgroundStartupHydration( @@ -1486,26 +1488,42 @@ class AuthNotifier extends AsyncNotifier ]; await Future.wait(saves); + final newSem = profile.currentSemester; + final newYear = profile.currentYear; + final newClassLabel = profile.classField?.name; + + final oldSem = currentUser.profile?.currentSemester; + final oldYear = currentUser.profile?.currentYear; + final oldClassLabel = currentUser.profile?.classField?.name; + + final classChanged = + oldClassLabel != null && oldClassLabel != newClassLabel; final academicChanged = - nextAcademic != null && - (currentUser.profile?.currentSemester != nextAcademic.semester || - currentUser.profile?.currentYear != nextAcademic.year); + (oldSem != null && oldSem != newSem) || + (oldYear != null && oldYear != newYear); - if (academicChanged) { + if (academicChanged || classChanged) { + AppLogger.i( + 'AuthNotifier: Academic context or class changed (sem: $oldSem->$newSem, year: $oldYear->$newYear, class: $oldClassLabel->$newClassLabel). ' + 'Purging caches and invalidating page providers.', + ); ref.read(apiServiceProvider).clearCaches(); - } + await storage.clearAllCachedData(); - if (nextAcademic != null) { - AppLogger.safeUnawait( - Future.delayed(Duration.zero, () { - ref.read(academicProvider.notifier).state = AsyncValue.data( - nextAcademic, - ); - }).catchError((Object e, StackTrace st) { - AppLogger.e('AuthNotifier: Deferred academic set failed', e, st); - }), - 'AuthNotifier: deferred academic set', - ); + ref.invalidate(academicProvider); + } else { + if (nextAcademic != null) { + AppLogger.safeUnawait( + Future.delayed(Duration.zero, () { + ref.read(academicProvider.notifier).state = AsyncValue.data( + nextAcademic, + ); + }).catchError((Object e, StackTrace st) { + AppLogger.e('AuthNotifier: Deferred academic set failed', e, st); + }), + 'AuthNotifier: deferred academic set', + ); + } } if (updateState) state = AsyncValue.data(mergedUser); diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index 1d0e0b52..b71b0dbc 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -145,21 +145,6 @@ class DashboardNotifier extends AsyncNotifier { } } - // BLOCKER: Do not fire server queries until Cron Sync is finished - if (user.isSyncing) { - if (_cachedCourses != null && _cachedAttendance != null) { - return _processData( - _cachedCourses!, - _cachedAttendance!, - [], - academic, - _cachedInstructors ?? [], - ); - } - // Return a future that will be replaced once isSyncing changes - return Completer().future; - } - // 2. Wait for tracking data final tracking = await ref.watch(trackingProvider.future); diff --git a/mobile/lib/providers/leave_provider.dart b/mobile/lib/providers/leave_provider.dart index f0c23b20..cd774bfd 100644 --- a/mobile/lib/providers/leave_provider.dart +++ b/mobile/lib/providers/leave_provider.dart @@ -34,12 +34,6 @@ class LeaveNotifier extends AsyncNotifier { if (authState.value == null || academic == null) return LeaveState.empty(); - // BLOCKER: Do not fire queries until Cron Sync is finished - if (authState.value?.isSyncing == true) { - // Return a future that will be replaced once isSyncing changes - return Completer().future; - } - final api = ref.read(apiServiceProvider); final storage = ref.read(secureStorageProvider); diff --git a/mobile/lib/providers/notification_provider.dart b/mobile/lib/providers/notification_provider.dart index 7575be48..48320247 100644 --- a/mobile/lib/providers/notification_provider.dart +++ b/mobile/lib/providers/notification_provider.dart @@ -69,9 +69,6 @@ class NotificationsNotifier extends AsyncNotifier { final user = ref.watch(authProvider).value; if (user == null) return NotificationsState.empty(); - // BLOCKER: Do not fire queries until Cron Sync is finished - if (user.isSyncing) return NotificationsState.empty(); - return _fetchInitialData(user.supabaseUserId); } diff --git a/mobile/lib/providers/score_provider.dart b/mobile/lib/providers/score_provider.dart index 6bd08a52..08b13892 100644 --- a/mobile/lib/providers/score_provider.dart +++ b/mobile/lib/providers/score_provider.dart @@ -78,12 +78,6 @@ class ScoreNotifier extends AsyncNotifier { final academic = academicAsync.value; - // BLOCKER: Do not fire queries until Cron Sync is finished - if (authState.value?.isSyncing == true) { - // Return a future that will be replaced once isSyncing changes - return Completer().future; - } - return _initialFetch(academic: academic); } diff --git a/mobile/lib/providers/tracking_provider.dart b/mobile/lib/providers/tracking_provider.dart index 6a4ea2d1..d3a713ad 100644 --- a/mobile/lib/providers/tracking_provider.dart +++ b/mobile/lib/providers/tracking_provider.dart @@ -79,12 +79,6 @@ class TrackingNotifier extends AsyncNotifier { ); } - // BLOCKER: Do not fire queries until Cron Sync is finished - if (authState.value?.isSyncing == true) { - // Return a future that will be replaced once isSyncing changes - return Completer().future; - } - // 2. Initial Load return _fetchAndProcess(academic: academic, isInitial: true); } diff --git a/mobile/lib/screens/leaves_screen.dart b/mobile/lib/screens/leaves_screen.dart index a5f47610..445d6b07 100644 --- a/mobile/lib/screens/leaves_screen.dart +++ b/mobile/lib/screens/leaves_screen.dart @@ -36,6 +36,15 @@ class LeavesScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final leaveState = ref.watch(leaveProvider); + final user = ref.watch(authProvider).value; + final isSyncing = user?.isSyncing ?? false; + + if (isSyncing) { + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: const LoadingOverlay(isFullScreen: false, showLogo: false), + ); + } return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, diff --git a/mobile/lib/screens/notifications_screen.dart b/mobile/lib/screens/notifications_screen.dart index b7719b61..4ba267f6 100644 --- a/mobile/lib/screens/notifications_screen.dart +++ b/mobile/lib/screens/notifications_screen.dart @@ -7,6 +7,7 @@ import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; import 'package:ghostclass/services/refresh_coordinator.dart'; +import 'package:ghostclass/widgets/loading_overlay.dart'; import 'package:ghostclass/widgets/service_refresh_indicator.dart'; import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -84,6 +85,16 @@ class _NotificationsScreenState extends ConsumerState Widget build(BuildContext context) { final notificationsAsync = ref.watch(notificationsProvider); final permissionAsync = ref.watch(notificationPermissionProvider); + final user = ref.watch(authProvider).value; + final isSyncing = user?.isSyncing ?? false; + + if (isSyncing) { + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: const LoadingOverlay(isFullScreen: false, showLogo: false), + ); + } + final isDenied = permissionAsync.when( data: (v) => v, loading: () => false, diff --git a/mobile/lib/screens/scores_screen.dart b/mobile/lib/screens/scores_screen.dart index 86928b65..6dad000f 100644 --- a/mobile/lib/screens/scores_screen.dart +++ b/mobile/lib/screens/scores_screen.dart @@ -30,8 +30,10 @@ class _ScoresScreenState extends ConsumerState { @override Widget build(BuildContext context) { final scoreState = ref.watch(scoreProvider); + final user = ref.watch(authProvider).value; + final isSyncing = user?.isSyncing ?? false; - if (scoreState.isLoading) { + if (scoreState.isLoading || isSyncing) { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: const LoadingOverlay(isFullScreen: false, showLogo: false), diff --git a/mobile/lib/screens/splash_screen.dart b/mobile/lib/screens/splash_screen.dart index e8a1dfe3..0e186998 100644 --- a/mobile/lib/screens/splash_screen.dart +++ b/mobile/lib/screens/splash_screen.dart @@ -130,11 +130,26 @@ class _SplashScreenState extends ConsumerState { authStack = st; }); + Object? jweError; + StackTrace? jweStack; + + final jweTask = JweService.instance + .preWarm() + .then((_) { + AppLogger.i('SplashScreen: jweTask completed'); + }) + .catchError((Object e, StackTrace st) { + AppLogger.e('SplashScreen: jweTask failed', e, st); + jweError = e; + jweStack = st; + }); + api.clearCaches(); AppLogger.i('SplashScreen: Awaiting Future.wait...'); await Future.wait([ integrityTask, authTask, + jweTask, ]); AppLogger.i('SplashScreen: Future.wait completed'); @@ -147,6 +162,9 @@ class _SplashScreenState extends ConsumerState { if (authError != null) { Error.throwWithStackTrace(authError!, authStack ?? StackTrace.current); } + if (jweError != null) { + Error.throwWithStackTrace(jweError!, jweStack ?? StackTrace.current); + } return _StartupSnapshot(user: user, versionResult: versionResult); }(); @@ -260,15 +278,6 @@ class _SplashScreenState extends ConsumerState { } Future _initializeApp() async { - // Start JWE key warm-up early so attestation requests can reuse prepared - // key material, but do not block startup on this. - AppLogger.safeUnawait( - JweService.instance.preWarm().catchError((Object e, StackTrace st) { - AppLogger.e('SplashScreen: early JWE pre-warm failed', e, st); - }), - 'SplashScreen: early JWE pre-warm', - ); - // Keep the splash visible for 1.5s to improve perceived startup time final splashHold = Future.delayed( const Duration(milliseconds: 1500), diff --git a/mobile/lib/screens/tracking_screen.dart b/mobile/lib/screens/tracking_screen.dart index 37459ebe..53849a65 100644 --- a/mobile/lib/screens/tracking_screen.dart +++ b/mobile/lib/screens/tracking_screen.dart @@ -43,8 +43,10 @@ class _TrackingScreenState extends ConsumerState final dashboardAsync = ref.watch(dashboardProvider); final data = trackingState.value; final dashboard = dashboardAsync.value; + final user = ref.watch(authProvider).value; + final isSyncing = user?.isSyncing ?? false; - if (trackingState.isLoading) { + if (trackingState.isLoading || isSyncing) { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, body: const LoadingOverlay(isFullScreen: false, showLogo: false), diff --git a/mobile/lib/services/secure_storage.dart b/mobile/lib/services/secure_storage.dart index 171e61d2..dad66ec0 100644 --- a/mobile/lib/services/secure_storage.dart +++ b/mobile/lib/services/secure_storage.dart @@ -288,6 +288,20 @@ class SecureStorageService { /// Deletes every key managed by this service. Should be called on logout. Future clearAll() => _safeDeleteAll(); + + /// Deletes all cached keys starting with "cache_" from secure storage. + Future clearAllCachedData() async { + try { + final all = await _storage.readAll(); + for (final key in all.keys) { + if (key.startsWith('cache_')) { + await _safeDelete(key: key); + } + } + } on Object catch (e) { + AppLogger.e('SecureStorage: Error clearing cached data', e); + } + } } final secureStorageProvider = Provider( diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 5ef7a080..3cb31268 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 4.4.5+1 +version: 4.4.6+1 environment: sdk: ^3.11.4 diff --git a/mobile/test/providers/auth_provider_analytics_test.dart b/mobile/test/providers/auth_provider_analytics_test.dart index 15512267..a24c0b94 100644 --- a/mobile/test/providers/auth_provider_analytics_test.dart +++ b/mobile/test/providers/auth_provider_analytics_test.dart @@ -160,6 +160,7 @@ void main() { when(() => mockStorage.saveEzygoUserId(any())).thenAnswer((_) async {}); when(() => mockStorage.saveTermsVersion(any())).thenAnswer((_) async {}); when(() => mockStorage.clearAll()).thenAnswer((_) async {}); + when(() => mockStorage.clearAllCachedData()).thenAnswer((_) async {}); when( () => mockProfileService.hasRenderableLocalProfile(any()), @@ -533,7 +534,7 @@ void main() { await notifier.updateAcademicContext('Even', '2025-2026'); - verify(() => mockApi.clearCaches()).called(1); + verify(() => mockApi.clearCaches()).called(2); expect(refreshCount, equals(1)); final user = container.read(authProvider).value; diff --git a/mobile/test/services/auth_profile_settings_coverage_test.dart b/mobile/test/services/auth_profile_settings_coverage_test.dart index 1b603706..c232adf5 100644 --- a/mobile/test/services/auth_profile_settings_coverage_test.dart +++ b/mobile/test/services/auth_profile_settings_coverage_test.dart @@ -16,7 +16,10 @@ class MockDio extends Mock implements Dio {} class MockDioService extends Mock implements DioService {} -class MockSecureStorageService extends Mock implements SecureStorageService {} +class MockSecureStorageService extends Mock implements SecureStorageService { + @override + Future clearAllCachedData() async {} +} void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/mobile/test/services/stealth_headers_service_test.dart b/mobile/test/services/stealth_headers_service_test.dart index 77b387c4..3ce2dc6d 100644 --- a/mobile/test/services/stealth_headers_service_test.dart +++ b/mobile/test/services/stealth_headers_service_test.dart @@ -4,7 +4,10 @@ import 'package:ghostclass/services/secure_storage.dart'; import 'package:ghostclass/services/stealth_headers_service.dart'; import 'package:mocktail/mocktail.dart'; -class MockSecureStorageService extends Mock implements SecureStorageService {} +class MockSecureStorageService extends Mock implements SecureStorageService { + @override + Future clearAllCachedData() async {} +} void main() { late MockSecureStorageService mockStorage; diff --git a/package-lock.json b/package-lock.json index 3c8ff002..5eac50f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostclass", - "version": "4.4.5", + "version": "4.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostclass", - "version": "4.4.5", + "version": "4.4.6", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/package.json b/package.json index 9e3c7b59..e936ed5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclass", - "version": "4.4.5", + "version": "4.4.6", "private": true, "engines": { "node": ">=22.12.0", diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index ad364ac0..8303b738 100644 --- a/public/openapi/openapi.yaml +++ b/public/openapi/openapi.yaml @@ -6,7 +6,7 @@ openapi: 3.1.0 info: title: GhostClass API - version: 4.4.5 + version: 4.4.6 description: | **GhostClass API** provides endpoints for authentication, profile synchronization, attendance integrations with EzyGo, telemetry, and build provenance. From fa3d0ca0016bbbc6736efa054a6ed4f7d4ec37ef Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Fri, 29 May 2026 18:45:00 +0000 Subject: [PATCH 2/9] refactor: optimize pull-to-refresh logic refactor: optimize leave data parsing, improve image upload reliability. feat: implement double-back-to-exit functionality on the dashboard using PopScope and improve navigation logic --- .../android/app/src/main/AndroidManifest.xml | 1 + mobile/lib/models/leave.dart | 3 + mobile/lib/providers/leave_provider.dart | 15 +- mobile/lib/screens/navigation_shell.dart | 558 ++++++++++-------- mobile/lib/screens/notifications_screen.dart | 31 +- mobile/lib/screens/profile_dump_screen.dart | 23 +- mobile/lib/screens/profile_screen.dart | 25 +- mobile/lib/screens/scores_screen.dart | 22 +- mobile/lib/screens/tracking_screen.dart | 20 +- mobile/lib/services/logger.dart | 13 +- .../widgets/aesthetic_refresh_indicator.dart | 32 +- .../navigation_shell_interaction_test.dart | 67 +++ 12 files changed, 485 insertions(+), 325 deletions(-) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index d60258b4..0d13629d 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -13,6 +13,7 @@ android:icon="@mipmap/ic_launcher" android:allowBackup="false" android:debuggable="false" + android:enableOnBackInvokedCallback="false" tools:ignore="HardcodedDebugMode"> json) { return LeaveSession( id: int.parse(json['id'].toString()), + leaveId: int.parse(json['student_leave_id'].toString()), date: json['date'] as String, session: json['session'] != null ? Session.fromJson(json['session'] as Map) @@ -167,6 +169,7 @@ class LeaveSession { ); } final int id; + final int leaveId; final String date; final Session? session; final Course? course; diff --git a/mobile/lib/providers/leave_provider.dart b/mobile/lib/providers/leave_provider.dart index cd774bfd..d97e75eb 100644 --- a/mobile/lib/providers/leave_provider.dart +++ b/mobile/lib/providers/leave_provider.dart @@ -42,7 +42,7 @@ class LeaveNotifier extends AsyncNotifier { final studentLeaves = data['studentLeaves'] as Map? ?? {}; final rawLeaves = studentLeaves['student_leaves'] as List? ?? []; final rawSessions = - studentLeaves['student_leave_sessions'] as Map? ?? {}; + studentLeaves['student_leave_sessions'] as List? ?? []; final leaves = rawLeaves .whereType>() @@ -55,15 +55,10 @@ class LeaveNotifier extends AsyncNotifier { .toList(); final sessions = >{}; - rawSessions.forEach((key, value) { - final parsedKey = int.tryParse(key.toString()); - if (parsedKey != null && value is List) { - sessions[parsedKey] = value - .whereType>() - .map((s) => LeaveSession.fromJson(s.cast())) - .toList(); - } - }); + for (final raw in rawSessions.whereType>()) { + final session = LeaveSession.fromJson(raw.cast()); + sessions.putIfAbsent(session.leaveId, () => []).add(session); + } return LeaveState(leaves: leaves, sessions: sessions); } diff --git a/mobile/lib/screens/navigation_shell.dart b/mobile/lib/screens/navigation_shell.dart index ccf1ca61..8e66134d 100644 --- a/mobile/lib/screens/navigation_shell.dart +++ b/mobile/lib/screens/navigation_shell.dart @@ -48,6 +48,10 @@ class _NavigationShellState extends ConsumerState { bool _criticalSecurityLogoutStarted = false; final List> _subscriptions = []; + // Tracks the timestamp of the first back-press on the dashboard tab, + // used to implement double-back-to-exit behaviour. + DateTime? _lastBackPressOnDashboard; + AcademicState? _asyncValueOrNull(AsyncValue value) { return value.hasValue ? value.value : null; } @@ -186,6 +190,56 @@ class _NavigationShellState extends ConsumerState { super.dispose(); } + /// Handles back-button presses intercepted by [BackButtonListener]. + /// + /// [BackButtonListener] registers a [ChildBackButtonDispatcher] that takes + /// PRIORITY over go_router's [RootBackButtonDispatcher], so this callback + /// fires before go_router attempts to pop any navigator — reliably on all + /// Android versions including predictive back (Android 13+). + /// + /// Returns `true` → back consumed (app stays open, we navigate ourselves). + /// Returns `false` → delegate to go_router's default back handling. + Future _handleBackPress(int selectedIndex) async { + AppLogger.i( + 'NavigationShell: _handleBackPress called with selectedIndex=$selectedIndex', + ); + if (selectedIndex != 0) { + AppLogger.i( + 'NavigationShell: selectedIndex != 0, navigating to /dashboard', + ); + context.go('/dashboard'); + return true; + } + + // Dashboard → double-back-to-exit. + final now = DateTime.now(); + final last = _lastBackPressOnDashboard; + if (last != null && now.difference(last) < const Duration(seconds: 2)) { + // Second back within 2 s → exit. + await SystemNavigator.pop(); + return true; + } + _lastBackPressOnDashboard = now; + + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar( + SnackBar( + content: Text( + 'Press back again to exit', + style: GoogleFonts.manrope(fontWeight: FontWeight.w600), + ), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + margin: const EdgeInsets.fromLTRB(16, 0, 16, 90), + ), + ); + return true; + } + @override Widget build(BuildContext context) { final ref = this.ref; @@ -579,182 +633,252 @@ class _NavigationShellState extends ConsumerState { ), ); - return Stack( - alignment: Alignment.bottomCenter, - children: [ - ExcludeSemantics( - excluding: showSecurityBarrier, - child: mainScaffold, - ), - // The Semicircle Button (Positioned at bottom center, above everything) - Positioned( - bottom: 75 + bottomPadding, // Account for bottom safe area/notch - child: ExcludeSemantics( - excluding: - isModalOpen || - (selectedIndex > 1) || - showOutageBarrier || - showSecurityBarrier, - child: IgnorePointer( - ignoring: + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + AppLogger.i( + 'NavigationShell: onPopInvokedWithResult called with didPop=$didPop', + ); + if (didPop) return; + unawaited(_handleBackPress(selectedIndex)); + }, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + ExcludeSemantics( + excluding: showSecurityBarrier, + child: mainScaffold, + ), + // The Semicircle Button (Positioned at bottom center, above everything) + Positioned( + bottom: 75 + bottomPadding, // Account for bottom safe area/notch + child: ExcludeSemantics( + excluding: isModalOpen || (selectedIndex > 1) || showOutageBarrier || showSecurityBarrier, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: - (isModalOpen || - (selectedIndex > 1) || - showOutageBarrier || - showSecurityBarrier) - ? 0 - : 1, - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: showAddAttendanceDialog, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(72), - ), - child: Container( - width: 72, - height: 34, - decoration: BoxDecoration( - color: primary, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(72), - ), + child: IgnorePointer( + ignoring: + isModalOpen || + (selectedIndex > 1) || + showOutageBarrier || + showSecurityBarrier, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: + (isModalOpen || + (selectedIndex > 1) || + showOutageBarrier || + showSecurityBarrier) + ? 0 + : 1, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: showAddAttendanceDialog, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(72), ), - child: Icon( - LucideIcons.plus, - color: Theme.of(context).colorScheme.onPrimary, - size: 24, + child: Container( + width: 72, + height: 34, + decoration: BoxDecoration( + color: primary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(72), + ), + ), + child: Icon( + LucideIcons.plus, + color: Theme.of(context).colorScheme.onPrimary, + size: 24, + ), ), ), ), ), ), ), - ), - ).animate().slideY(begin: 3.5, end: 0, curve: Curves.easeOutBack), - - // --- GLOBAL OUTAGE OVERLAY --- - if (showOutageBarrier) - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: ColoredBox( - color: bg.withValues(alpha: 0.9), // More opaque background - child: ServiceErrorView( - error: dashboardAsync.error ?? trackingAsync.error, - onRetry: () async { - ref.read(apiServiceProvider).clearCaches(); - ref - ..invalidate(dashboardProvider) - ..invalidate(trackingProvider) - ..invalidate(academicProvider); - - // Wait for critical providers to finish (success or new error) - // We add a 10s timeout so the UI doesn't feel 'stuck' if network hangs - AppLogger.d( - 'NavigationShell: Starting outage recovery retry...', - ); - try { - await Future.wait([ - ref.read(dashboardProvider.future), - ref.read(trackingProvider.future), - ]).timeout(const Duration(seconds: 30)); - AppLogger.i( - 'NavigationShell: Outage recovery wait completed (or partial success).', - ); - } on Object catch (e) { - AppLogger.e( - 'NavigationShell: Outage recovery wait timed out or failed ($e). Re-enabling UI.', + ).animate().slideY(begin: 3.5, end: 0, curve: Curves.easeOutBack), + + // --- GLOBAL OUTAGE OVERLAY --- + if (showOutageBarrier) + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), + child: ColoredBox( + color: bg.withValues(alpha: 0.9), // More opaque background + child: ServiceErrorView( + error: dashboardAsync.error ?? trackingAsync.error, + onRetry: () async { + ref.read(apiServiceProvider).clearCaches(); + ref + ..invalidate(dashboardProvider) + ..invalidate(trackingProvider) + ..invalidate(academicProvider); + + // Wait for critical providers to finish (success or new error) + // We add a 10s timeout so the UI doesn't feel 'stuck' if network hangs + AppLogger.d( + 'NavigationShell: Starting outage recovery retry...', ); - } - }, + try { + await Future.wait([ + ref.read(dashboardProvider.future), + ref.read(trackingProvider.future), + ]).timeout(const Duration(seconds: 30)); + AppLogger.i( + 'NavigationShell: Outage recovery wait completed (or partial success).', + ); + } on Object catch (e) { + AppLogger.e( + 'NavigationShell: Outage recovery wait timed out or failed ($e). Re-enabling UI.', + ); + } + }, + ), ), ), - ), - ).animate().fadeIn(duration: 300.ms), - - // --- GLOBAL SECURITY BARRIER --- - if (showSecurityBarrier) - Positioned.fill( - child: Semantics( - container: true, - liveRegion: true, - label: securityMessage.isEmpty - ? 'Security verification failed.' - : 'Security verification failed. $securityMessage', - child: Stack( - children: [ - const ModalBarrier( - dismissible: false, - color: Colors.black, - semanticsLabel: 'Security verification failed', - ), - BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - color: Colors.black.withValues(alpha: 0.5), - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - shape: BoxShape.circle, - border: Border.all( - color: Colors.red.withValues(alpha: 0.3), + ).animate().fadeIn(duration: 300.ms), + + // --- GLOBAL SECURITY BARRIER --- + if (showSecurityBarrier) + Positioned.fill( + child: Semantics( + container: true, + liveRegion: true, + label: securityMessage.isEmpty + ? 'Security verification failed.' + : 'Security verification failed. $securityMessage', + child: Stack( + children: [ + const ModalBarrier( + dismissible: false, + color: Colors.black, + semanticsLabel: 'Security verification failed', + ), + BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + color: Colors.black.withValues(alpha: 0.5), + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + shape: BoxShape.circle, + border: Border.all( + color: Colors.red.withValues(alpha: 0.3), + ), ), + child: const Icon( + LucideIcons.shieldAlert, + size: 48, + color: Colors.red, + ), + ) + .animate( + onPlay: (controller) => + controller.repeat(reverse: true), + ) + .scale( + begin: const Offset(1, 1), + end: const Offset(1.1, 1.1), + duration: 1000.ms, ), - child: const Icon( - LucideIcons.shieldAlert, - size: 48, - color: Colors.red, - ), - ) - .animate( - onPlay: (controller) => - controller.repeat(reverse: true), - ) - .scale( - begin: const Offset(1, 1), - end: const Offset(1.1, 1.1), - duration: 1000.ms, + const SizedBox(height: 32), + Text( + 'Security Verification Failed', + style: GoogleFonts.manrope( + fontSize: 24, + fontWeight: FontWeight.w800, + color: Colors.white, ), - const SizedBox(height: 32), - Text( - 'Security Verification Failed', - style: GoogleFonts.manrope( - fontSize: 24, - fontWeight: FontWeight.w800, - color: Colors.white, + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - securityMessage, - style: GoogleFonts.manrope( - fontSize: 15, - color: Colors.white.withValues(alpha: 0.7), - height: 1.5, + const SizedBox(height: 16), + Text( + securityMessage, + style: GoogleFonts.manrope( + fontSize: 15, + color: Colors.white.withValues(alpha: 0.7), + height: 1.5, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 48), - if (!Platform.isIOS || !isCriticalSecurityFailure) + const SizedBox(height: 48), + if (!Platform.isIOS || !isCriticalSecurityFailure) + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + onPressed: () async { + if (isCriticalSecurityFailure) { + if (Platform.isAndroid) { + await SystemNavigator.pop(); + } else { + exit(0); + } + return; + } + + // Clear lock and retry + ref.read(apiServiceProvider).clearCaches(); + ref + .read(securityFailureProvider.notifier) + .clearFailure(); + try { + await ref + .read(authProvider.notifier) + .refreshProfile(force: true); + } on Object { + // The 401 interceptor will catch it again if it still fails + } + }, + child: Text( + isCriticalSecurityFailure + ? 'Close App' + : 'Restart App', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + ), + ) + else + Text( + 'Please close the app manually.', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), SizedBox( width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide( + color: Colors.white.withValues(alpha: 0.3), + ), padding: const EdgeInsets.symmetric( vertical: 16, ), @@ -762,110 +886,50 @@ class _NavigationShellState extends ConsumerState { borderRadius: BorderRadius.circular(16), ), ), - onPressed: () async { - if (isCriticalSecurityFailure) { - if (Platform.isAndroid) { - await SystemNavigator.pop(); - } else { - exit(0); - } - return; - } - - // Clear lock and retry - ref.read(apiServiceProvider).clearCaches(); - ref - .read(securityFailureProvider.notifier) - .clearFailure(); - try { - await ref - .read(authProvider.notifier) - .refreshProfile(force: true); - } on Object { - // The 401 interceptor will catch it again if it still fails - } - }, - child: Text( - isCriticalSecurityFailure - ? 'Close App' - : 'Restart App', + onPressed: () => SupportHelper.contactViaEmail( + subject: + 'Security Failure Report [v${AppConfig.appVersion}]', + customBody: + 'Hi Support,\n\nI encountered a security failure while using the app.\n\n' + '-- SUMMARY --\n' + 'Message: $securityMessage\n\n' + '-- PERSISTENCE --\n' + '${SupportHelper.persistenceMessage}\n', + ), + icon: const Icon(LucideIcons.mail, size: 18), + label: Text( + 'Contact Support', style: GoogleFonts.manrope( - fontSize: 16, fontWeight: FontWeight.w700, - color: Colors.black, ), ), ), - ) - else - Text( - 'Please close the app manually.', - style: GoogleFonts.manrope( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white.withValues(alpha: 0.6), - ), - textAlign: TextAlign.center, ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide( - color: Colors.white.withValues(alpha: 0.3), - ), - padding: const EdgeInsets.symmetric( - vertical: 16, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - onPressed: () => SupportHelper.contactViaEmail( - subject: - 'Security Failure Report [v${AppConfig.appVersion}]', - customBody: - 'Hi Support,\n\nI encountered a security failure while using the app.\n\n' - '-- SUMMARY --\n' - 'Message: $securityMessage\n\n' - '-- PERSISTENCE --\n' - '${SupportHelper.persistenceMessage}\n', - ), - icon: const Icon(LucideIcons.mail, size: 18), - label: Text( - 'Contact Support', - style: GoogleFonts.manrope( - fontWeight: FontWeight.w700, - ), - ), - ), - ), - if (!isCriticalSecurityFailure) ...[ - const SizedBox(height: 16), - TextButton( - onPressed: () => - ref.read(authProvider.notifier).logout(), - child: Text( - 'Logout of GhostClass', - style: GoogleFonts.manrope( - color: Colors.red, - fontWeight: FontWeight.w600, + if (!isCriticalSecurityFailure) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: () => + ref.read(authProvider.notifier).logout(), + child: Text( + 'Logout of GhostClass', + style: GoogleFonts.manrope( + color: Colors.red, + fontWeight: FontWeight.w600, + ), ), ), - ), + ], ], - ], + ), ), ), - ), - ], + ], + ), ), - ), - ).animate().fadeIn(duration: 400.ms), - ], - ); + ).animate().fadeIn(duration: 400.ms), + ], + ), // Stack + ); // PopScope } } diff --git a/mobile/lib/screens/notifications_screen.dart b/mobile/lib/screens/notifications_screen.dart index 4ba267f6..d63bfafd 100644 --- a/mobile/lib/screens/notifications_screen.dart +++ b/mobile/lib/screens/notifications_screen.dart @@ -179,27 +179,30 @@ class _NotificationsScreenState extends ConsumerState child: notificationsAsync.when( data: (data) => ServiceRefreshIndicator( onRefresh: () async { + final authNotifier = ref.read(authProvider.notifier); + final supabaseClient = ref.read(supabaseClientProvider); + final apiService = ref.read(apiServiceProvider); + // ref.invalidate is synchronous — safe to call before awaits. + // Capture it as a closure so it's invoked at the right time. + void invalidateNotifications() => + ref.invalidate(notificationsProvider); try { await runUnifiedPullToRefresh( logLabel: 'NotificationsScreen', - refreshProfile: () => ref - .read(authProvider.notifier) - .refreshProfile(force: true), + refreshProfile: () => + authNotifier.refreshProfile(force: true), syncCron: () async { - final supabaseToken = ref - .read(supabaseClientProvider) - .auth - .currentSession - ?.accessToken; + final supabaseToken = + supabaseClient.auth.currentSession?.accessToken; if (supabaseToken == null) return; - await ref - .read(apiServiceProvider) - .triggerSync(supabaseToken, force: true); + await apiService.triggerSync( + supabaseToken, + force: true, + ); }, refreshData: () async { - final _ = await ref.refresh( - notificationsProvider.future, - ); + invalidateNotifications(); + await ref.read(notificationsProvider.future); }, ); } on Object { diff --git a/mobile/lib/screens/profile_dump_screen.dart b/mobile/lib/screens/profile_dump_screen.dart index 22923587..12040db6 100644 --- a/mobile/lib/screens/profile_dump_screen.dart +++ b/mobile/lib/screens/profile_dump_screen.dart @@ -96,19 +96,16 @@ class _ProfileDumpContent extends ConsumerWidget { if (user.ezygoId == null) { return insts.isNotEmpty ? insts.first.name : '—'; } - try { - final searchId = user.ezygoId!.trim(); - return insts - .firstWhere((i) => i.id.toString().trim() == searchId) - .name; - } on Object catch (e) { - AppLogger.e( - 'ProfileDumpScreen: Failed to resolve institution by id', - e, - ); - // Fallback to first available for UI consistency, similar to GhostClassScreen - return insts.isNotEmpty ? insts.first.name : 'Unknown'; - } + final searchId = user.ezygoId!.trim(); + final matched = insts + .where((i) => i.id.toString().trim() == searchId) + .firstOrNull; + if (matched != null) return matched.name; + // ezygoId present but not in the institutions list — fall back gracefully + AppLogger.w( + 'ProfileDumpScreen: No institution found for ezygoId $searchId, falling back to first', + ); + return insts.isNotEmpty ? insts.first.name : 'Unknown'; }, loading: () => '...', error: (err, stack) => 'Error', diff --git a/mobile/lib/screens/profile_screen.dart b/mobile/lib/screens/profile_screen.dart index e54f9bd8..ce5185ec 100644 --- a/mobile/lib/screens/profile_screen.dart +++ b/mobile/lib/screens/profile_screen.dart @@ -37,6 +37,7 @@ class _ProfileScreenState extends ConsumerState String? _selectedGender; DateTime? _selectedBirthDate; bool _isUploadingAvatar = false; + bool _isPickingImage = false; @override void initState() { @@ -116,13 +117,23 @@ class _ProfileScreenState extends ConsumerState } Future _pickAndUploadAvatar() async { - final picker = ImagePicker(); - final image = await picker.pickImage( - source: ImageSource.gallery, - maxWidth: 512, - maxHeight: 512, - imageQuality: 85, - ); + if (_isPickingImage || _isUploadingAvatar) return; + if (mounted) setState(() => _isPickingImage = true); + final XFile? image; + try { + final picker = ImagePicker(); + image = await picker.pickImage( + source: ImageSource.gallery, + maxWidth: 512, + maxHeight: 512, + imageQuality: 85, + ); + } on Object catch (e, st) { + AppLogger.e('ProfileScreen: Image picker failed', e, st); + return; + } finally { + if (mounted) setState(() => _isPickingImage = false); + } if (image == null) return; setState(() => _isUploadingAvatar = true); try { diff --git a/mobile/lib/screens/scores_screen.dart b/mobile/lib/screens/scores_screen.dart index 6dad000f..7377d32a 100644 --- a/mobile/lib/screens/scores_screen.dart +++ b/mobile/lib/screens/scores_screen.dart @@ -77,24 +77,22 @@ class _ScoresScreenState extends ConsumerState { ServiceRefreshIndicator( useOverlay: false, onRefresh: () async { + final authNotifier = ref.read(authProvider.notifier); + final supabaseClient = ref.read(supabaseClientProvider); + final apiService = ref.read(apiServiceProvider); + final scoreNotifier = ref.read(scoreProvider.notifier); try { await runUnifiedPullToRefresh( logLabel: 'ScoresScreen', - refreshProfile: () => ref - .read(authProvider.notifier) - .refreshProfile(force: true), + refreshProfile: () => + authNotifier.refreshProfile(force: true), syncCron: () async { - final supabaseToken = ref - .read(supabaseClientProvider) - .auth - .currentSession - ?.accessToken; + final supabaseToken = + supabaseClient.auth.currentSession?.accessToken; if (supabaseToken == null) return; - await ref - .read(apiServiceProvider) - .triggerSync(supabaseToken, force: true); + await apiService.triggerSync(supabaseToken, force: true); }, - refreshData: () => ref.read(scoreProvider.notifier).refresh(), + refreshData: scoreNotifier.refresh, ); } on Object { if (!context.mounted) rethrow; diff --git a/mobile/lib/screens/tracking_screen.dart b/mobile/lib/screens/tracking_screen.dart index 53849a65..a63e0e0d 100644 --- a/mobile/lib/screens/tracking_screen.dart +++ b/mobile/lib/screens/tracking_screen.dart @@ -117,23 +117,21 @@ class _TrackingScreenState extends ConsumerState return ServiceRefreshIndicator( onRefresh: () async { + final authNotifier = ref.read(authProvider.notifier); + final supabaseClient = ref.read(supabaseClientProvider); + final apiService = ref.read(apiServiceProvider); + final trackingNotifier = ref.read(trackingProvider.notifier); try { await runUnifiedPullToRefresh( logLabel: 'TrackingScreen', - refreshProfile: () => - ref.read(authProvider.notifier).refreshProfile(force: true), + refreshProfile: () => authNotifier.refreshProfile(force: true), syncCron: () async { - final supabaseToken = ref - .read(supabaseClientProvider) - .auth - .currentSession - ?.accessToken; + final supabaseToken = + supabaseClient.auth.currentSession?.accessToken; if (supabaseToken == null) return; - await ref - .read(apiServiceProvider) - .triggerSync(supabaseToken, force: true); + await apiService.triggerSync(supabaseToken, force: true); }, - refreshData: () => ref.read(trackingProvider.notifier).refresh(), + refreshData: trackingNotifier.refresh, ); } on Object catch (e, st) { AppLogger.e('TrackingScreen: Pull-to-refresh failed', e, st); diff --git a/mobile/lib/services/logger.dart b/mobile/lib/services/logger.dart index d4afcffd..42321e43 100644 --- a/mobile/lib/services/logger.dart +++ b/mobile/lib/services/logger.dart @@ -100,11 +100,14 @@ class AppLogger { static void safeUnawait(Future future, [String? context]) { unawaited( future.catchError( - (Object e, StackTrace st) => AppLogger.e( - 'SafeUnawait${context != null ? ': $context' : ''}', - e, - st, - ), + (Object e, StackTrace st) { + AppLogger.e( + 'SafeUnawait${context != null ? ': $context' : ''}', + e, + st, + ); + return null; + }, ), ); } diff --git a/mobile/lib/widgets/aesthetic_refresh_indicator.dart b/mobile/lib/widgets/aesthetic_refresh_indicator.dart index 69793826..80128ef9 100644 --- a/mobile/lib/widgets/aesthetic_refresh_indicator.dart +++ b/mobile/lib/widgets/aesthetic_refresh_indicator.dart @@ -32,6 +32,12 @@ class _AestheticRefreshIndicatorState extends State { double _pullDistance = 0; bool _isRefreshing = false; + /// True only when the current drag gesture started while the list was + /// already at (or above) the very top. This prevents fast upward flings + /// from the middle/bottom of the list accidentally triggering a refresh + /// when they overshoot past pixels = 0. + bool _dragStartedAtTop = false; + void _safeSetState(VoidCallback fn) { if (mounted) { setState(fn); @@ -101,14 +107,25 @@ class _AestheticRefreshIndicatorState extends State { final metrics = notification.metrics; + // Track whether each new drag gesture started from the very top of the + // scroll view. Only gestures that begin at the top are eligible to + // trigger pull-to-refresh. A fast upward fling from the middle/bottom + // may overshoot past pixels=0, but _dragStartedAtTop will be false, so + // the overscroll is ignored. + if (notification is ScrollStartNotification) { + _dragStartedAtTop = metrics.pixels <= 0; + } + if (notification is ScrollUpdateNotification && !_isRefreshing) { - if (metrics.pixels < 0) { - final distance = (metrics.pixels.abs() / 100).clamp(0.0, 1.0); + if (_dragStartedAtTop && metrics.pixels < 0) { + // Divisor 160 → full progress at 160 px; threshold 0.85 → triggers at ~136 px. + final distance = (metrics.pixels.abs() / 160).clamp(0.0, 1.0); _safeSetState(() => _pullDistance = distance); // Trigger refresh exactly when user releases (dragDetails becomes null) // and we are past the threshold. - if (notification.dragDetails == null && distance >= 0.8) { + if (notification.dragDetails == null && distance >= 0.85) { + _dragStartedAtTop = false; final _ = _handleRefresh(); } } else if (_pullDistance > 0) { @@ -117,11 +134,12 @@ class _AestheticRefreshIndicatorState extends State { } if (notification is OverscrollNotification && !_isRefreshing) { - if (metrics.pixels < 0) { - final distance = (metrics.pixels.abs() / 100).clamp(0.0, 1.0); + if (_dragStartedAtTop && metrics.pixels < 0) { + final distance = (metrics.pixels.abs() / 160).clamp(0.0, 1.0); _safeSetState(() => _pullDistance = distance); - if (notification.dragDetails == null && distance >= 0.8) { + if (notification.dragDetails == null && distance >= 0.85) { + _dragStartedAtTop = false; final _ = _handleRefresh(); } } @@ -130,12 +148,14 @@ class _AestheticRefreshIndicatorState extends State { if (notification is UserScrollNotification && notification.direction == ScrollDirection.idle) { if (!_isRefreshing) { + _dragStartedAtTop = false; _safeSetState(() => _pullDistance = 0.0); } } if (notification is ScrollEndNotification) { if (!_isRefreshing) { + _dragStartedAtTop = false; _safeSetState(() => _pullDistance = 0.0); } } diff --git a/mobile/test/navigation_shell_interaction_test.dart b/mobile/test/navigation_shell_interaction_test.dart index 15015659..a5371cd9 100644 --- a/mobile/test/navigation_shell_interaction_test.dart +++ b/mobile/test/navigation_shell_interaction_test.dart @@ -113,4 +113,71 @@ void main() { await tester.pumpAndSettle(); expect(find.text('dashboard'), findsOneWidget); }); + + testWidgets('NavigationShell back button intercept on calendar tab', ( + tester, + ) async { + final overrides = [ + dashboardProvider.overrideWith( + () => MockDashboardNotifier(mockDashboard), + ), + authProvider.overrideWith(() => MockAuthNotifier(mockUser)), + trackingProvider.overrideWith(() => MockTrackingNotifier(mockTracking)), + academicProvider.overrideWith(() => MockAcademicNotifier(mockAcademic)), + notificationsProvider.overrideWith( + () => MockNotificationNotifier(mockNotifications), + ), + outageProvider.overrideWith(() => MockOutageNotifier(data: false)), + securityFailureProvider.overrideWith( + () => MockSecurityFailureNotifier(null), + ), + ]; + + final router = GoRouter( + initialLocation: '/dashboard', + routes: [ + ShellRoute( + builder: (context, state, child) => NavigationShell(child: child), + routes: [ + GoRoute( + path: '/dashboard', + pageBuilder: (c, s) => + const MaterialPage(child: Center(child: Text('dashboard'))), + ), + GoRoute( + path: '/calendar', + pageBuilder: (c, s) => + const MaterialPage(child: Center(child: Text('calendar'))), + ), + ], + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: MaterialApp.router( + theme: AppTheme.darkTheme, + routerConfig: router, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Go to calendar + await tester.tap(find.byIcon(LucideIcons.calendar)); + await tester.pumpAndSettle(); + expect(find.text('calendar'), findsOneWidget); + + // Simulate system back button + final handled = await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + // We expect the back press to be handled by our back press listener, + // which should navigate to dashboard. + expect(handled, isTrue); + expect(find.text('dashboard'), findsOneWidget); + }); } From 38302b9281b39a21fe6c1883cc8f59628a4d1c65 Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Fri, 29 May 2026 19:21:14 +0000 Subject: [PATCH 3/9] fix/perf(mobile): core architecture hardening, state management fixes, and lifecycle optimizations This patch addresses critical deadlock issues, secures storage data persistence, enforces proper Riverpod contracts, and introduces several performance optimizations across features. Critical Fixes: - fix(secure-storage): implement self-healing mechanism to only purge on unrecoverable Keystore/Keychain corruption, rethrowing transient errors. - fix(provider): eliminate provider deadlocks by replacing the `Completer().future` anti-pattern with `Future.wait` on upstream dependencies. - fix(riverpod): correct lifecycle contract violations by migrating `ref.watch` from async contexts to `ref.read` and ensuring watches execute at the start of build methods. - fix(notification): add an in-flight concurrency guard to `toggleRead` to eliminate optimistic update race conditions. - fix(splash): isolate startup cache fields to `StartupFlowService` instance and invalidate on logout to prevent cross-session leakage. High Priority Fixes & Optimizations: - refactor(security): dry up duplicate transient error keyword matches into a static helper. - perf(dashboard): prevent redundant attendance fetches during background revalidation by reusing cached official reports. - fix(dashboard): shield disk cache from deletion when refresh operations fail due to transient connectivity errors. - chore(tracking): remove dead code, unused fields, and redundant blocks associated with `forceSync`. - fix(notification): implement `isFetchingNextPage` pagination guards to prevent redundant API queries on scroll events. - fix(leave): remove redundant loading state updates and expose fully awaitable futures on refresh. - fix(ezygo): switch from unstable `String.hashCode` to `Uri.encodeFull` for secure storage cache keys to prevent key collisions. - fix(score): validate cache payloads are explicitly instances of `List` before mutation to block malformed/error map payloads. Medium Priority Optimizations: - refactor(navigation): extract build-context closures into private instance methods to reduce allocation and GC pressure. - perf(security): cache the `Dio` instance in a private field to bypass container reads on every getter access. - refactor(dashboard): isolate IIFE metadata evaluations into a dedicated private mapper function. - perf(notification): bypass database queries on auth state updates if the underlying user ID has not changed. - perf(splash): conditionally restrict authenticated provider prewarming to logged-in sessions to prevent initialization errors. --- mobile/lib/providers/auth_provider.dart | 3 +- mobile/lib/providers/dashboard_provider.dart | 148 +++++++------ mobile/lib/providers/leave_provider.dart | 7 +- .../lib/providers/notification_provider.dart | 208 +++++++++++------- mobile/lib/providers/score_provider.dart | 11 +- mobile/lib/providers/tracking_provider.dart | 24 +- mobile/lib/screens/navigation_shell.dart | 162 +++++++------- mobile/lib/screens/notifications_screen.dart | 1 + mobile/lib/screens/splash_screen.dart | 101 +++------ mobile/lib/services/ezygo_service.dart | 2 +- mobile/lib/services/secure_storage.dart | 20 +- mobile/lib/services/security_service.dart | 35 +-- mobile/lib/services/startup_flow_service.dart | 6 + .../services/secure_storage_service_test.dart | 62 ++++++ 14 files changed, 440 insertions(+), 350 deletions(-) diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 773542c7..552c4032 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -894,7 +894,8 @@ class AuthNotifier extends AsyncNotifier await ref.read(cacheManagerProvider).clearAllCaches(); ref ..invalidate(institutionsProvider) - ..invalidate(academicProvider); + ..invalidate(academicProvider) + ..invalidate(startupFlowServiceProvider); state = const AsyncValue.data(null); // Stop periodic refreshes and prevent in-flight refresh continuations diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index b71b0dbc..7755f9f1 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -57,11 +57,15 @@ class DashboardNotifier extends AsyncNotifier { // 1. Reactive Dependency: Rebuild when auth user ID or academic status changes final userAsync = ref.watch(authProvider); final academicAsync = ref.watch(academicProvider); + final trackingFuture = ref.watch(trackingProvider.future); // If either core dependency is actively reloading (e.g. changing semester), // suspend the dashboard build to prevent showing a split-second stale UI. if (userAsync.isLoading || academicAsync.isLoading) { - return Completer().future; + await Future.wait([ + if (userAsync.isLoading) ref.watch(authProvider.future), + if (academicAsync.isLoading) ref.watch(academicProvider.future), + ]); } final user = userAsync.value; @@ -146,7 +150,7 @@ class DashboardNotifier extends AsyncNotifier { } // 2. Wait for tracking data - final tracking = await ref.watch(trackingProvider.future); + final tracking = await trackingFuture; // Proactively update official report cache if tracking has a fresher one (e.g. from a sync) if (tracking.officialReport != null) { @@ -417,53 +421,15 @@ class DashboardNotifier extends AsyncNotifier { // Pre-calculate sorting criteria to avoid redundant math during sort final target = (auth?.settings.targetPercentage ?? 75).toDouble(); - int getTier(CourseStat? s, {required bool disabled}) { - if (disabled) return 2; // Absolute bottom - if (s == null || s.finalTotal == 0) return 1; - return 0; - } - - final metaMap = - < - String, - ({int tier, int canBunk, int safeCanBunk, int requiredToAttend}) - >{ - for (final c in courses) - c.safeId: (() { - final s = stats.courseStats[c.safeId]; - final disabled = disabledCodes.contains( - utils.standardizeCourseCode(c.code ?? ''), - ); - final tier = getTier(s, disabled: disabled); - - if (s == null) { - return ( - tier: tier, - canBunk: 0, - safeCanBunk: 0, - requiredToAttend: 0, - ); - } - - final bunkRes = utils.calculateAttendance( - s.finalPresent, - s.finalTotal, - targetPercentage: target, - ); - final safeRes = utils.calculateAttendance( - s.officialPresent, - s.officialTotal, - targetPercentage: target, - ); - - return ( - tier: tier, - canBunk: bunkRes.canBunk, - safeCanBunk: safeRes.canBunk, - requiredToAttend: bunkRes.requiredToAttend, - ); - })(), - }; + final metaMap = { + for (final c in courses) + c.safeId: _computeCourseMeta( + course: c, + stats: stats, + disabledCodes: disabledCodes, + targetPercentage: target, + ), + }; final sortedCourses = List.from(courses) ..sort((a, b) { @@ -503,12 +469,66 @@ class DashboardNotifier extends AsyncNotifier { ); } + ({int tier, int canBunk, int safeCanBunk, int requiredToAttend}) + _computeCourseMeta({ + required CourseDetails course, + required AttendanceReportStats stats, + required Set disabledCodes, + required double targetPercentage, + }) { + final s = stats.courseStats[course.safeId]; + final disabled = disabledCodes.contains( + utils.standardizeCourseCode(course.code ?? ''), + ); + + final int tier; + if (disabled) { + tier = 2; // Absolute bottom + } else if (s == null || s.finalTotal == 0) { + tier = 1; + } else { + tier = 0; + } + + if (s == null) { + return ( + tier: tier, + canBunk: 0, + safeCanBunk: 0, + requiredToAttend: 0, + ); + } + + final bunkRes = utils.calculateAttendance( + s.finalPresent, + s.finalTotal, + targetPercentage: targetPercentage, + ); + final safeRes = utils.calculateAttendance( + s.officialPresent, + s.officialTotal, + targetPercentage: targetPercentage, + ); + + return ( + tier: tier, + canBunk: bunkRes.canBunk, + safeCanBunk: safeRes.canBunk, + requiredToAttend: bunkRes.requiredToAttend, + ); + } + Future _silentRevalidate( List tracking, AcademicState academic, ) async { try { - final freshData = await _fetchAndProcess(tracking, academic, null); + final trackingState = ref.read(trackingProvider).value; + final freshData = await _fetchAndProcess( + tracking, + academic, + trackingState?.officialReport, + ); final currentAcademic = ref.read(academicProvider).value; if (academic == currentAcademic) { state = AsyncValue.data(freshData); @@ -560,19 +580,21 @@ class DashboardNotifier extends AsyncNotifier { refreshError = e; refreshStackTrace = st; } finally { - // Clear disk cache even when refresh fails so the next launch or retry - // cannot silently reuse stale dashboard data. - final storage = ref.read(secureStorageProvider); - final academicAsync = ref.read(academicProvider); - final academic = academicAsync.value; - if (academic != null) { - final suffix = - '${user.supabaseUserId}_${academic.semester}_${academic.year}'; - await Future.wait([ - storage.deleteCachedData('dashboard_courses_$suffix'), - storage.deleteCachedData('dashboard_attendance_$suffix'), - storage.deleteCachedData('dashboard_instructors_$suffix'), - ]); + // Clear disk cache only when refresh succeeds to avoid leaving the app + // with no fallback data during connectivity issues. + if (refreshError == null) { + final storage = ref.read(secureStorageProvider); + final academicAsync = ref.read(academicProvider); + final academic = academicAsync.value; + if (academic != null) { + final suffix = + '${user.supabaseUserId}_${academic.semester}_${academic.year}'; + await Future.wait([ + storage.deleteCachedData('dashboard_courses_$suffix'), + storage.deleteCachedData('dashboard_attendance_$suffix'), + storage.deleteCachedData('dashboard_instructors_$suffix'), + ]); + } } } } diff --git a/mobile/lib/providers/leave_provider.dart b/mobile/lib/providers/leave_provider.dart index d97e75eb..6413716b 100644 --- a/mobile/lib/providers/leave_provider.dart +++ b/mobile/lib/providers/leave_provider.dart @@ -27,7 +27,10 @@ class LeaveNotifier extends AsyncNotifier { final academicAsync = ref.watch(academicProvider); if (authState.isLoading || academicAsync.isLoading) { - return Completer().future; + await Future.wait([ + if (authState.isLoading) ref.watch(authProvider.future), + if (academicAsync.isLoading) ref.watch(academicProvider.future), + ]); } final academic = academicAsync.value; @@ -65,7 +68,7 @@ class LeaveNotifier extends AsyncNotifier { Future refresh() async { ref.invalidate(notificationsProvider); - state = const AsyncValue.loading(); ref.invalidateSelf(); + await future; } } diff --git a/mobile/lib/providers/notification_provider.dart b/mobile/lib/providers/notification_provider.dart index 48320247..1ee88deb 100644 --- a/mobile/lib/providers/notification_provider.dart +++ b/mobile/lib/providers/notification_provider.dart @@ -37,22 +37,42 @@ class NotificationsState { required this.regularNotifications, required this.unreadCount, this.hasNextPage = false, + this.isFetchingNextPage = false, }); factory NotificationsState.empty() => const NotificationsState( actionNotifications: [], regularNotifications: [], unreadCount: 0, + hasNextPage: false, + isFetchingNextPage: false, ); final List actionNotifications; final List regularNotifications; final int unreadCount; final bool hasNextPage; + final bool isFetchingNextPage; List get allNotifications => [ ...actionNotifications, ...regularNotifications, ]; + + NotificationsState copyWith({ + List? actionNotifications, + List? regularNotifications, + int? unreadCount, + bool? hasNextPage, + bool? isFetchingNextPage, + }) { + return NotificationsState( + actionNotifications: actionNotifications ?? this.actionNotifications, + regularNotifications: regularNotifications ?? this.regularNotifications, + unreadCount: unreadCount ?? this.unreadCount, + hasNextPage: hasNextPage ?? this.hasNextPage, + isFetchingNextPage: isFetchingNextPage ?? this.isFetchingNextPage, + ); + } } final notificationsProvider = @@ -63,12 +83,22 @@ final notificationsProvider = class NotificationsNotifier extends AsyncNotifier { int _currentPage = 0; static const _pageSize = 20; + final _toggleReadInFlight = {}; + String? _lastUserId; @override Future build() async { final user = ref.watch(authProvider).value; - if (user == null) return NotificationsState.empty(); + if (user == null) { + _lastUserId = null; + return NotificationsState.empty(); + } + + if (_lastUserId == user.supabaseUserId && state.hasValue) { + return state.value!; + } + _lastUserId = user.supabaseUserId; return _fetchInitialData(user.supabaseUserId); } @@ -182,113 +212,121 @@ class NotificationsNotifier extends AsyncNotifier { ); } - bool _isFetchingNextPage = false; Future fetchNextPage() async { final current = state.value; - if (current == null || !current.hasNextPage || _isFetchingNextPage) return; + if (current == null || !current.hasNextPage || current.isFetchingNextPage) return; - _isFetchingNextPage = true; + state = AsyncValue.data(current.copyWith(isFetchingNextPage: true)); final page = _currentPage + 1; try { final nextState = await _fetchNextPage(page: page); - // Only advance the current page after a successful fetch to avoid - // skipping pages when a network call fails. + if (!ref.mounted) return; _currentPage = page; - state = AsyncValue.data(nextState); + state = AsyncValue.data(nextState.copyWith(isFetchingNextPage: false)); } finally { - _isFetchingNextPage = false; + if (ref.mounted && (state.value?.isFetchingNextPage ?? false)) { + final latest = state.value ?? current; + state = AsyncValue.data(latest.copyWith(isFetchingNextPage: false)); + } } } Future toggleRead(int id, {required bool wasRead}) async { - final previousState = state.value; - if (previousState == null) return; + if (_toggleReadInFlight.contains(id)) return; + _toggleReadInFlight.add(id); + + try { + final previousState = state.value; + if (previousState == null) return; - final newIsRead = !wasRead; - - // 1. Update in actionNotifications - final updatedActions = []; - AppNotification? movedToRegular; - - for (final n in previousState.actionNotifications) { - if (n.id == id) { - final updated = AppNotification( - id: n.id, - title: n.title, - description: n.description, - createdAt: n.createdAt, - topic: n.topic, - isRead: newIsRead, - ); - if (newIsRead) { - movedToRegular = updated; + final newIsRead = !wasRead; + + // 1. Update in actionNotifications + final updatedActions = []; + AppNotification? movedToRegular; + + for (final n in previousState.actionNotifications) { + if (n.id == id) { + final updated = AppNotification( + id: n.id, + title: n.title, + description: n.description, + createdAt: n.createdAt, + topic: n.topic, + isRead: newIsRead, + ); + if (newIsRead) { + movedToRegular = updated; + } else { + updatedActions.add(updated); + } } else { - updatedActions.add(updated); + updatedActions.add(n); } - } else { - updatedActions.add(n); } - } - // 2. Update in regularNotifications - final updatedRegular = []; - AppNotification? movedToAction; - - for (final n in previousState.regularNotifications) { - if (n.id == id) { - final updated = AppNotification( - id: n.id, - title: n.title, - description: n.description, - createdAt: n.createdAt, - topic: n.topic, - isRead: newIsRead, - ); - - // If a conflict is marked as UNREAD, it must move back to actionNotifications - if (!newIsRead && - (n.topic?.toLowerCase().contains('conflict') ?? false)) { - movedToAction = updated; + // 2. Update in regularNotifications + final updatedRegular = []; + AppNotification? movedToAction; + + for (final n in previousState.regularNotifications) { + if (n.id == id) { + final updated = AppNotification( + id: n.id, + title: n.title, + description: n.description, + createdAt: n.createdAt, + topic: n.topic, + isRead: newIsRead, + ); + + // If a conflict is marked as UNREAD, it must move back to actionNotifications + if (!newIsRead && + (n.topic?.toLowerCase().contains('conflict') ?? false)) { + movedToAction = updated; + } else { + updatedRegular.add(updated); + } } else { - updatedRegular.add(updated); + updatedRegular.add(n); } - } else { - updatedRegular.add(n); } - } - - if (movedToAction != null) { - updatedActions - ..insert(0, movedToAction) - ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); - } - if (movedToRegular != null) { - updatedRegular - ..insert(0, movedToRegular) - // Re-sort regular by date if needed, but inserting at 0 is fine for now - ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); - } + if (movedToAction != null) { + updatedActions + ..insert(0, movedToAction) + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } - final unreadChange = wasRead ? 1 : -1; - state = AsyncValue.data( - NotificationsState( - actionNotifications: updatedActions, - regularNotifications: updatedRegular, - unreadCount: previousState.unreadCount + unreadChange, - hasNextPage: previousState.hasNextPage, - ), - ); + if (movedToRegular != null) { + updatedRegular + ..insert(0, movedToRegular) + // Re-sort regular by date if needed, but inserting at 0 is fine for now + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } - try { - final supabase = Supabase.instance.client; - await supabase - .from('notification') - .update({'is_read': newIsRead}) - .eq('id', id); - } on Object catch (_) { - state = AsyncValue.data(previousState); - rethrow; + final unreadChange = wasRead ? 1 : -1; + state = AsyncValue.data( + NotificationsState( + actionNotifications: updatedActions, + regularNotifications: updatedRegular, + unreadCount: previousState.unreadCount + unreadChange, + hasNextPage: previousState.hasNextPage, + ), + ); + + try { + final supabase = Supabase.instance.client; + await supabase + .from('notification') + .update({'is_read': newIsRead}) + .eq('id', id); + } on Object catch (_) { + state = AsyncValue.data(previousState); + rethrow; + } + } finally { + _toggleReadInFlight.remove(id); } } diff --git a/mobile/lib/providers/score_provider.dart b/mobile/lib/providers/score_provider.dart index 08b13892..1063e7f2 100644 --- a/mobile/lib/providers/score_provider.dart +++ b/mobile/lib/providers/score_provider.dart @@ -73,7 +73,10 @@ class ScoreNotifier extends AsyncNotifier { final academicAsync = ref.watch(academicProvider); if (authState.isLoading || academicAsync.isLoading) { - return Completer().future; + await Future.wait([ + if (authState.isLoading) ref.watch(authProvider.future), + if (academicAsync.isLoading) ref.watch(academicProvider.future), + ]); } final academic = academicAsync.value; @@ -85,7 +88,7 @@ class ScoreNotifier extends AsyncNotifier { AcademicState? academic, bool bypassCache = false, }) async { - final authState = ref.watch(authProvider); + final authState = ref.read(authProvider); final user = authState.value; if (user == null) throw Exception('Unauthorized'); @@ -263,10 +266,10 @@ class ScoreNotifier extends AsyncNotifier { qsData = results[0].data; ansData = results[1].data; - if (results[0].statusCode == 200 && qsData != null) { + if (results[0].statusCode == 200 && qsData is List) { await storage.saveCachedData(cacheKeyQs, qsData); } - if (results[1].statusCode == 200 && ansData != null) { + if (results[1].statusCode == 200 && ansData is List) { await storage.saveCachedData(cacheKeyAns, ansData); } } diff --git a/mobile/lib/providers/tracking_provider.dart b/mobile/lib/providers/tracking_provider.dart index d3a713ad..84a7e835 100644 --- a/mobile/lib/providers/tracking_provider.dart +++ b/mobile/lib/providers/tracking_provider.dart @@ -52,7 +52,6 @@ final trackingProvider = AsyncNotifierProvider( ); class TrackingNotifier extends AsyncNotifier { - static bool _isSyncingExternal = false; String _canonicalTrackerCourseCode(String courseId) { return utils.standardizeCourseCode(courseId); @@ -65,7 +64,10 @@ class TrackingNotifier extends AsyncNotifier { final academicAsync = ref.watch(academicProvider); if (authState.isLoading || academicAsync.isLoading) { - return Completer().future; + await Future.wait([ + if (authState.isLoading) ref.watch(authProvider.future), + if (academicAsync.isLoading) ref.watch(academicProvider.future), + ]); } final academic = academicAsync.value; @@ -101,23 +103,7 @@ class TrackingNotifier extends AsyncNotifier { ); } - var syncCompleted = false; - if (forceSync) { - if (_isSyncingExternal) { - syncCompleted = true; - } else { - _isSyncingExternal = true; - try { - // Sync is now primarily triggered by DashboardNotifier.refresh - // or NavigationShell. Individual triggers here are redundant. - } finally { - _isSyncingExternal = false; - syncCompleted = true; - } - } - } else { - syncCompleted = true; - } + const syncCompleted = true; late final AttendanceReportDetailed officialReport; final records = []; diff --git a/mobile/lib/screens/navigation_shell.dart b/mobile/lib/screens/navigation_shell.dart index 8e66134d..65dc66cb 100644 --- a/mobile/lib/screens/navigation_shell.dart +++ b/mobile/lib/screens/navigation_shell.dart @@ -240,6 +240,78 @@ class _NavigationShellState extends ConsumerState { return true; } + int _calculateSelectedIndex(String location) { + if (location.startsWith('/dashboard')) return 0; + if (location.startsWith('/calendar')) return 1; + if (location.startsWith('/scores')) return 2; + if (location.startsWith('/leaves')) return 3; + if (location.startsWith('/ghostclass') || + location.startsWith('/profile-dump')) { + return 4; + } + return 0; + } + + void _onTabTapped(int index) { + switch (index) { + case 0: + context.go('/dashboard'); + case 1: + context.go('/calendar'); + case 2: + context.go('/scores'); + case 3: + context.go('/leaves'); + case 4: + context.go('/ghostclass'); + } + } + + Future _showTrackingOverlay() async { + ref.read(uiModalOpenProvider.notifier).setOpen(true); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + ), + builder: (context) => const TrackingScreen(), + ); + if (mounted) { + ref.read(uiModalOpenProvider.notifier).setOpen(false); + } + } + + Future _showNotificationsOverlay() async { + ref.read(uiModalOpenProvider.notifier).setOpen(true); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(32)), + ), + builder: (context) => const NotificationsScreen(), + ); + if (mounted) { + ref.read(uiModalOpenProvider.notifier).setOpen(false); + } + } + + Future _showAddAttendanceDialog() async { + ref.read(uiModalOpenProvider.notifier).setOpen(true); + await showDialog( + context: context, + builder: (context) => const AddAttendanceDialog(), + ); + if (mounted) { + ref.read(uiModalOpenProvider.notifier).setOpen(false); + } + } + @override Widget build(BuildContext context) { final ref = this.ref; @@ -257,68 +329,7 @@ class _NavigationShellState extends ConsumerState { final location = GoRouterState.of(context).uri.path; - int calculateSelectedIndex(String location) { - if (location.startsWith('/dashboard')) return 0; - if (location.startsWith('/calendar')) return 1; - if (location.startsWith('/scores')) return 2; - if (location.startsWith('/leaves')) return 3; - if (location.startsWith('/ghostclass') || - location.startsWith('/profile-dump')) { - return 4; - } - return 0; - } - - final selectedIndex = calculateSelectedIndex(location); - - void onTabTapped(int index) { - switch (index) { - case 0: - context.go('/dashboard'); - case 1: - context.go('/calendar'); - case 2: - context.go('/scores'); - case 3: - context.go('/leaves'); - case 4: - context.go('/ghostclass'); - } - } - - Future showTrackingOverlay() async { - ref.read(uiModalOpenProvider.notifier).setOpen(true); - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(32)), - ), - builder: (context) => const TrackingScreen(), - ); - if (mounted) { - ref.read(uiModalOpenProvider.notifier).setOpen(false); - } - } - - Future showNotificationsOverlay() async { - ref.read(uiModalOpenProvider.notifier).setOpen(true); - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(32)), - ), - builder: (context) => const NotificationsScreen(), - ); - if (mounted) { - ref.read(uiModalOpenProvider.notifier).setOpen(false); - } - } + final selectedIndex = _calculateSelectedIndex(location); final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -335,17 +346,6 @@ class _NavigationShellState extends ConsumerState { final securityMessage = securityFailure?.message ?? ''; final isCriticalSecurityFailure = securityFailure?.criticalRisk ?? false; - Future showAddAttendanceDialog() async { - ref.read(uiModalOpenProvider.notifier).setOpen(true); - await showDialog( - context: context, - builder: (context) => const AddAttendanceDialog(), - ); - if (mounted) { - ref.read(uiModalOpenProvider.notifier).setOpen(false); - } - } - final mainScaffold = Scaffold( backgroundColor: bg, appBar: PreferredSize( @@ -377,7 +377,7 @@ class _NavigationShellState extends ConsumerState { button: true, label: 'Notifications', child: InkWell( - onTap: showNotificationsOverlay, + onTap: _showNotificationsOverlay, splashColor: Colors.transparent, highlightColor: Colors.transparent, child: Stack( @@ -449,7 +449,7 @@ class _NavigationShellState extends ConsumerState { button: true, label: 'Tracking', child: InkWell( - onTap: showTrackingOverlay, + onTap: _showTrackingOverlay, splashColor: Colors.transparent, highlightColor: Colors.transparent, child: Container( @@ -592,7 +592,7 @@ class _NavigationShellState extends ConsumerState { icon: LucideIcons.layoutDashboard, label: 'Dashboard', isSelected: selectedIndex == 0, - onTap: () => onTabTapped(0), + onTap: () => _onTabTapped(0), ), ), Expanded( @@ -600,7 +600,7 @@ class _NavigationShellState extends ConsumerState { icon: LucideIcons.calendar, label: 'Calendar', isSelected: selectedIndex == 1, - onTap: () => onTabTapped(1), + onTap: () => _onTabTapped(1), ), ), Expanded( @@ -608,7 +608,7 @@ class _NavigationShellState extends ConsumerState { icon: LucideIcons.graduationCap, label: 'Scores', isSelected: selectedIndex == 2, - onTap: () => onTabTapped(2), + onTap: () => _onTabTapped(2), ), ), Expanded( @@ -616,7 +616,7 @@ class _NavigationShellState extends ConsumerState { icon: LucideIcons.clipboardList, label: 'Leaves', isSelected: selectedIndex == 3, - onTap: () => onTabTapped(3), + onTap: () => _onTabTapped(3), ), ), Expanded( @@ -624,7 +624,7 @@ class _NavigationShellState extends ConsumerState { icon: LucideIcons.ghost, label: 'GhostClass', isSelected: selectedIndex == 4, - onTap: () => onTabTapped(4), + onTap: () => _onTabTapped(4), ), ), ], @@ -676,7 +676,7 @@ class _NavigationShellState extends ConsumerState { child: Material( type: MaterialType.transparency, child: InkWell( - onTap: showAddAttendanceDialog, + onTap: _showAddAttendanceDialog, borderRadius: const BorderRadius.vertical( top: Radius.circular(72), ), diff --git a/mobile/lib/screens/notifications_screen.dart b/mobile/lib/screens/notifications_screen.dart index d63bfafd..5f1b545a 100644 --- a/mobile/lib/screens/notifications_screen.dart +++ b/mobile/lib/screens/notifications_screen.dart @@ -70,6 +70,7 @@ class _NotificationsScreenState extends ConsumerState final state = ref.read(notificationsProvider).value; if (state != null && state.hasNextPage && + !state.isFetchingNextPage && !ref.read(notificationsProvider).isLoading) { AppLogger.safeUnawait( notifier.fetchNextPage().catchError((Object e, StackTrace st) { diff --git a/mobile/lib/screens/splash_screen.dart b/mobile/lib/screens/splash_screen.dart index 0e186998..fb1794d3 100644 --- a/mobile/lib/screens/splash_screen.dart +++ b/mobile/lib/screens/splash_screen.dart @@ -49,10 +49,6 @@ class SplashScreen extends ConsumerStatefulWidget { } class _SplashScreenState extends ConsumerState { - static Future<_StartupSnapshot>? _startupInFlight; - static _StartupSnapshot? _startupCache; - static DateTime? _startupCacheAt; - static String? _startupCacheSessionKey; static const Duration _startupCacheTtl = Duration(seconds: 20); bool _pushInitTriggered = false; @@ -63,22 +59,24 @@ class _SplashScreenState extends ConsumerState { return session?.user.id ?? 'anon'; } - bool _canUseStartupCache(String sessionKey) { - final cachedAt = _startupCacheAt; - if (_startupCache == null || cachedAt == null) return false; - if (_startupCacheSessionKey != sessionKey) return false; + bool _canUseStartupCache(String sessionKey, StartupFlowService startupFlow) { + final cachedAt = startupFlow.startupCacheAt; + if (startupFlow.startupCache == null || cachedAt == null) return false; + if (startupFlow.startupCacheSessionKey != sessionKey) return false; return DateTime.now().difference(cachedAt) <= _startupCacheTtl; } Future<_StartupSnapshot> _runStartupChecksSingleFlight() { final sessionKey = _currentSessionKey(); - if (_canUseStartupCache(sessionKey)) { - return Future<_StartupSnapshot>.value(_startupCache); + final startupFlow = ref.read(startupFlowServiceProvider); + + if (_canUseStartupCache(sessionKey, startupFlow)) { + return Future<_StartupSnapshot>.value(startupFlow.startupCache as _StartupSnapshot); } - final inFlight = _startupInFlight; - if (inFlight != null && _startupCacheSessionKey == sessionKey) { - return inFlight; + final inFlight = startupFlow.startupInFlight; + if (inFlight != null && startupFlow.startupCacheSessionKey == sessionKey) { + return inFlight.then((val) => val as _StartupSnapshot); } final api = ref.read(apiServiceProvider); @@ -169,18 +167,18 @@ class _SplashScreenState extends ConsumerState { return _StartupSnapshot(user: user, versionResult: versionResult); }(); - _startupCacheSessionKey = sessionKey; - _startupInFlight = future; + startupFlow.startupCacheSessionKey = sessionKey; + startupFlow.startupInFlight = future; return future .then((snapshot) { - _startupCache = snapshot; - _startupCacheAt = DateTime.now(); + startupFlow.startupCache = snapshot; + startupFlow.startupCacheAt = DateTime.now(); return snapshot; }) .whenComplete(() { - if (identical(_startupInFlight, future)) { - _startupInFlight = null; + if (identical(startupFlow.startupInFlight, future)) { + startupFlow.startupInFlight = null; } }); } @@ -245,11 +243,11 @@ class _SplashScreenState extends ConsumerState { void _startPostNavigationPreloads({ required bool isDashboard, required ApiService apiService, - required Future dashboardFuture, - required Future trackingFuture, - required Future leaveFuture, - required Future scoreFuture, - required Future notificationsFuture, + Future? dashboardFuture, + Future? trackingFuture, + Future? leaveFuture, + Future? scoreFuture, + Future? notificationsFuture, }) { AppLogger.safeUnawait( JweService.instance.preWarm().catchError( @@ -268,11 +266,11 @@ class _SplashScreenState extends ConsumerState { if (isDashboard) { _prewarmAppData( - dashboardFuture: dashboardFuture, - trackingFuture: trackingFuture, - leaveFuture: leaveFuture, - scoreFuture: scoreFuture, - notificationsFuture: notificationsFuture, + dashboardFuture: dashboardFuture!, + trackingFuture: trackingFuture!, + leaveFuture: leaveFuture!, + scoreFuture: scoreFuture!, + notificationsFuture: notificationsFuture!, ); } } @@ -380,27 +378,8 @@ class _SplashScreenState extends ConsumerState { ); final isConnectionOrQuota = - (appCheckError != null && - (appCheckError.toLowerCase().contains('quota') || - appCheckError.toLowerCase().contains('connection') || - appCheckError.toLowerCase().contains('timeout') || - appCheckError.toLowerCase().contains('too_many_attempts') || - appCheckError.toLowerCase().contains('network') || - appCheckError.toLowerCase().contains('rate limit') || - appCheckError.toLowerCase().contains('server') || - appCheckError.toLowerCase().contains('internal error') || - appCheckError.toLowerCase().contains('-12') || - appCheckError.toLowerCase().contains('unavailable'))) || - (reason.toLowerCase().contains('quota') || - reason.toLowerCase().contains('connection') || - reason.toLowerCase().contains('timeout') || - reason.toLowerCase().contains('too_many_attempts') || - reason.toLowerCase().contains('network') || - reason.toLowerCase().contains('rate limit') || - reason.toLowerCase().contains('server') || - reason.toLowerCase().contains('internal error') || - reason.toLowerCase().contains('-12') || - reason.toLowerCase().contains('unavailable')); + SecurityService.isTransientAppCheckFailureText(appCheckError) || + SecurityService.isTransientAppCheckFailureText(reason); final isGenuineSecurityFailure = !isConnectionOrQuota; @@ -486,14 +465,14 @@ class _SplashScreenState extends ConsumerState { final supabaseClient = ref.read(supabaseClientProvider); final token = supabaseClient.auth.currentSession?.accessToken; - final dashboardFuture = ref.read(dashboardProvider.future); - final trackingFuture = ref.read(trackingProvider.future); - final leaveFuture = ref.read(leaveProvider.future); - final scoreFuture = ref.read(scoreProvider.future); - final notificationsFuture = ref.read(notificationsProvider.future); - if (finalUser != null) { if (finalUser.termsAccepted) { + final dashboardFuture = ref.read(dashboardProvider.future); + final trackingFuture = ref.read(trackingProvider.future); + final leaveFuture = ref.read(leaveProvider.future); + final scoreFuture = ref.read(scoreProvider.future); + final notificationsFuture = ref.read(notificationsProvider.future); + _startPushInitInBackgroundAfterSplash(pushService); context.go('/dashboard'); AppLogger.safeUnawait( @@ -530,11 +509,6 @@ class _SplashScreenState extends ConsumerState { _startPostNavigationPreloads( isDashboard: false, apiService: apiService, - dashboardFuture: dashboardFuture, - trackingFuture: trackingFuture, - leaveFuture: leaveFuture, - scoreFuture: scoreFuture, - notificationsFuture: notificationsFuture, ); }).catchError((Object e, StackTrace st) { AppLogger.e( @@ -554,11 +528,6 @@ class _SplashScreenState extends ConsumerState { _startPostNavigationPreloads( isDashboard: false, apiService: apiService, - dashboardFuture: dashboardFuture, - trackingFuture: trackingFuture, - leaveFuture: leaveFuture, - scoreFuture: scoreFuture, - notificationsFuture: notificationsFuture, ); }).catchError((Object e, StackTrace st) { AppLogger.e('SplashScreen: post-login preloads failed', e, st); diff --git a/mobile/lib/services/ezygo_service.dart b/mobile/lib/services/ezygo_service.dart index 451b6310..a0348883 100644 --- a/mobile/lib/services/ezygo_service.dart +++ b/mobile/lib/services/ezygo_service.dart @@ -141,7 +141,7 @@ class EzygoService { SecureStorageService storage, { Duration ttl = const Duration(days: 7), }) async { - final cacheKey = 'ezygo_static_${path.hashCode}'; + final cacheKey = 'ezygo_static_${Uri.encodeFull(path)}'; try { final cached = await storage.getCachedData(cacheKey); if (cached != null) { diff --git a/mobile/lib/services/secure_storage.dart b/mobile/lib/services/secure_storage.dart index dad66ec0..b3cb169a 100644 --- a/mobile/lib/services/secure_storage.dart +++ b/mobile/lib/services/secure_storage.dart @@ -59,7 +59,10 @@ class SecureStorageService { } on Object catch (e, st) { AppLogger.e('SecureStorage: Error during read of key: $key', e, st); await _selfHeal(e); - return null; + if (_isUnrecoverableError(e)) { + return null; + } + rethrow; } } @@ -82,9 +85,22 @@ class SecureStorageService { } } + bool _isUnrecoverableError(Object error) { + final errStr = error.toString(); + return errStr.contains('storage_key_error') || + errStr.contains('KeyStoreException') || + errStr.contains('javax.crypto.AEADBadTagException'); + } + Future _selfHeal(Object error) async { + if (!_isUnrecoverableError(error)) { + AppLogger.e( + 'SecureStorage: Transient error detected ($error), skipping self-heal.', + ); + return; + } AppLogger.e( - 'SecureStorage: Executing self-healing routine due to exception: $error', + 'SecureStorage: Executing self-healing routine due to unrecoverable exception: $error', ); try { await _storage.deleteAll(); diff --git a/mobile/lib/services/security_service.dart b/mobile/lib/services/security_service.dart index 7fd63c84..428ace8e 100644 --- a/mobile/lib/services/security_service.dart +++ b/mobile/lib/services/security_service.dart @@ -41,13 +41,15 @@ class SecurityService { static const Duration _cachedAttestationMaxAge = Duration(hours: 6); static const Duration _blockingAttestationMaxAge = Duration(days: 7); + late final Dio _cachedDio = _ref.read(dioServiceProvider).dio; + Dio get _dio { if (_disposed) { throw StateError( 'Cannot use SecurityService after it has been disposed.', ); } - return _ref.read(dioServiceProvider).dio; + return _cachedDio; } bool _isVersionOlder(String current, String target) { @@ -70,7 +72,8 @@ class SecurityService { return false; } - bool _isTransientAppCheckFailureText(String text) { + static bool isTransientAppCheckFailureText(String? text) { + if (text == null) return false; final msg = text.toLowerCase(); return msg.contains('quota') || msg.contains('connection') || @@ -106,9 +109,8 @@ class SecurityService { final reason = (e.details?['reason'] as String?) ?? e.message; final appCheckError = e.details?['appCheckError'] as String?; final isConnectionOrQuota = - (appCheckError != null && - _isTransientAppCheckFailureText(appCheckError)) || - _isTransientAppCheckFailureText(reason); + isTransientAppCheckFailureText(appCheckError) || + isTransientAppCheckFailureText(reason); if (isConnectionOrQuota) { return true; } @@ -272,27 +274,8 @@ class SecurityService { final appCheckError = e.details?['appCheckError'] as String?; final isConnectionOrQuota = - (appCheckError != null && - (appCheckError.toLowerCase().contains('quota') || - appCheckError.toLowerCase().contains('connection') || - appCheckError.toLowerCase().contains('timeout') || - appCheckError.toLowerCase().contains('too_many_attempts') || - appCheckError.toLowerCase().contains('network') || - appCheckError.toLowerCase().contains('rate limit') || - appCheckError.toLowerCase().contains('server') || - appCheckError.toLowerCase().contains('internal error') || - appCheckError.toLowerCase().contains('-12') || - appCheckError.toLowerCase().contains('unavailable'))) || - (reason.toLowerCase().contains('quota') || - reason.toLowerCase().contains('connection') || - reason.toLowerCase().contains('timeout') || - reason.toLowerCase().contains('too_many_attempts') || - reason.toLowerCase().contains('network') || - reason.toLowerCase().contains('rate limit') || - reason.toLowerCase().contains('server') || - reason.toLowerCase().contains('internal error') || - reason.toLowerCase().contains('-12') || - reason.toLowerCase().contains('unavailable')); + isTransientAppCheckFailureText(appCheckError) || + isTransientAppCheckFailureText(reason); final isGenuineSecurityFailure = !isConnectionOrQuota; diff --git a/mobile/lib/services/startup_flow_service.dart b/mobile/lib/services/startup_flow_service.dart index 2c0e6414..7bd28345 100644 --- a/mobile/lib/services/startup_flow_service.dart +++ b/mobile/lib/services/startup_flow_service.dart @@ -7,6 +7,12 @@ class StartupFlowService { DateTime? _postLoginMarkedAt; static const Duration _postLoginFastPathTtl = Duration(seconds: 45); + // SplashScreen startup checks cache + Object? startupCache; + DateTime? startupCacheAt; + String? startupCacheSessionKey; + Future? startupInFlight; + void markPostLoginFastPath(String sessionKey) { _postLoginSessionKey = sessionKey; _postLoginMarkedAt = DateTime.now(); diff --git a/mobile/test/services/secure_storage_service_test.dart b/mobile/test/services/secure_storage_service_test.dart index c308ce43..bb03aeb3 100644 --- a/mobile/test/services/secure_storage_service_test.dart +++ b/mobile/test/services/secure_storage_service_test.dart @@ -347,6 +347,68 @@ void main() { verify(() => mockStorage.deleteAll()).called(1); }); + group('Self-Healing & Error Handling', () { + test('Transient error on read (e.g., interaction not allowed) does not purge and rethrows', () async { + final transientError = Exception('Keychain error: -25308 (interaction not allowed)'); + when(() => mockStorage.read(key: any(named: 'key'))).thenThrow(transientError); + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + expect( + () => service.getEzygoToken(), + throwsA(isA().having((e) => e.toString(), 'message', contains('-25308'))), + ); + + verifyNever(() => mockStorage.deleteAll()); + }); + + test('Unrecoverable error on read (e.g., storage_key_error) purges and returns null', () async { + final unrecoverableError = Exception('PlatformException(storage_key_error, Keystore corrupted, null, null)'); + when(() => mockStorage.read(key: any(named: 'key'))).thenThrow(unrecoverableError); + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + final result = await service.getEzygoToken(); + + expect(result, isNull); + verify(() => mockStorage.deleteAll()).called(1); + }); + + test('Transient error on write does not purge and rethrows', () async { + final transientError = Exception('Keychain error: -25308 (interaction not allowed)'); + when( + () => mockStorage.write( + key: any(named: 'key'), + value: any(named: 'value'), + ), + ).thenThrow(transientError); + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + expect( + () => service.saveEzygoToken('test'), + throwsA(isA().having((e) => e.toString(), 'message', contains('-25308'))), + ); + + verifyNever(() => mockStorage.deleteAll()); + }); + + test('Unrecoverable error on write purges and rethrows', () async { + final unrecoverableError = Exception('PlatformException(storage_key_error, Keystore corrupted, null, null)'); + when( + () => mockStorage.write( + key: any(named: 'key'), + value: any(named: 'value'), + ), + ).thenThrow(unrecoverableError); + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + expect( + () => service.saveEzygoToken('test'), + throwsA(isA().having((e) => e.toString(), 'message', contains('storage_key_error'))), + ); + + verify(() => mockStorage.deleteAll()).called(1); + }); + }); + test('Provider returns SecureStorageService', () { final container = ProviderContainer(); final fetchedService = container.read(secureStorageProvider); From 3fd250405bf2d7b26f2b59039ea89ec3d76ae8bc Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Fri, 29 May 2026 19:54:36 +0000 Subject: [PATCH 4/9] fix(mobile): reduce refresh redundancy and align UI behavior - centralize notification invalidation in runUnifiedPullToRefresh - suppress forced syncs shortly after the startup sync - reuse the tracking official attendance report during dashboard refresh to avoid duplicate attendance fetches - make EzyGo auth/token handling fail fast on missing credentials - remove the redundant tracking course-code wrapper - make dashboard cache TTL explicit and parallelize cached-key cleanup - improve accessibility and scroll behavior in notifications - fix leave card clipping and theme usage for success colors - smooth splash shimmer animation - normalize error styling to theme-aware colors where applicable --- mobile/lib/logic/attendance_utils.dart | 112 +++--- mobile/lib/providers/dashboard_provider.dart | 39 ++- mobile/lib/providers/leave_provider.dart | 2 - .../lib/providers/notification_provider.dart | 6 +- mobile/lib/providers/score_provider.dart | 30 +- mobile/lib/providers/tracking_provider.dart | 9 +- mobile/lib/screens/leaves_screen.dart | 247 +++++++------ mobile/lib/screens/navigation_shell.dart | 10 +- mobile/lib/screens/notifications_screen.dart | 330 ++++++++++-------- mobile/lib/screens/scores_screen.dart | 15 +- mobile/lib/screens/splash_screen.dart | 21 +- mobile/lib/screens/tracking_screen.dart | 3 + mobile/lib/services/api_service.dart | 22 +- mobile/lib/services/auth_service.dart | 7 +- mobile/lib/services/ezygo_service.dart | 43 +-- mobile/lib/services/refresh_coordinator.dart | 3 + mobile/lib/services/secure_storage.dart | 10 +- mobile/lib/widgets/add_attendance_dialog.dart | 6 +- .../services/secure_storage_service_test.dart | 88 +++-- 19 files changed, 555 insertions(+), 448 deletions(-) diff --git a/mobile/lib/logic/attendance_utils.dart b/mobile/lib/logic/attendance_utils.dart index f73172f6..70e42805 100644 --- a/mobile/lib/logic/attendance_utils.dart +++ b/mobile/lib/logic/attendance_utils.dart @@ -93,74 +93,14 @@ String normalizeDate(dynamic date) { // 2. Dash-separated (YYYY-MM-DD or DD-MM-YYYY) if (base.contains('-')) { - final parts = base.split('-'); - if (parts.length == 3) { - final a = parts[0].trim(); - final b = parts[1].trim(); - final c = parts[2].trim(); - - if (RegExp(r'^\d+$').hasMatch(a) && - RegExp(r'^\d+$').hasMatch(b) && - RegExp(r'^\d+$').hasMatch(c)) { - var year = (a.length == 4) ? int.parse(a) : int.parse(c); - if (year < 100) year += 2000; - final month = int.parse(b); - final day = (a.length == 4) ? int.parse(c) : int.parse(a); - - if (month < 1 || month > 12 || day < 1 || day > 31) return ''; - - final parsed = DateTime.tryParse( - "${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}", - ); - if (parsed == null || - parsed.year != year || - parsed.month != month || - parsed.day != day) { - return ''; - } - - final yearStr = year.toString().padLeft(4, '0'); - final monthStr = month.toString().padLeft(2, '0'); - final dayStr = day.toString().padLeft(2, '0'); - return '$yearStr$monthStr$dayStr'; - } - } + final parsedDate = _parseSeparatedDate(base, '-'); + if (parsedDate != null) return parsedDate; } // 3. Slash-separated (DD/MM/YYYY or YYYY/MM/DD) if (base.contains('/')) { - final parts = base.split('/'); - if (parts.length == 3) { - final a = parts[0].trim(); - final b = parts[1].trim(); - final c = parts[2].trim(); - - if (RegExp(r'^\d+$').hasMatch(a) && - RegExp(r'^\d+$').hasMatch(b) && - RegExp(r'^\d+$').hasMatch(c)) { - var year = (a.length == 4) ? int.parse(a) : int.parse(c); - if (year < 100) year += 2000; - final month = int.parse(b); - final day = (a.length == 4) ? int.parse(c) : int.parse(a); - - if (month < 1 || month > 12 || day < 1 || day > 31) return ''; - - final parsed = DateTime.tryParse( - "${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}", - ); - if (parsed == null || - parsed.year != year || - parsed.month != month || - parsed.day != day) { - return ''; - } - - final yearStr = year.toString().padLeft(4, '0'); - final monthStr = month.toString().padLeft(2, '0'); - final dayStr = day.toString().padLeft(2, '0'); - return '$yearStr$monthStr$dayStr'; - } - } + final parsedDate = _parseSeparatedDate(base, '/'); + if (parsedDate != null) return parsedDate; } AppLogger.e( @@ -170,6 +110,43 @@ String normalizeDate(dynamic date) { return ''; } +String? _parseSeparatedDate(String base, String sep) { + final parts = base.split(sep); + if (parts.length != 3) return null; + + final a = parts[0].trim(); + final b = parts[1].trim(); + final c = parts[2].trim(); + + if (!RegExp(r'^\d+$').hasMatch(a) || + !RegExp(r'^\d+$').hasMatch(b) || + !RegExp(r'^\d+$').hasMatch(c)) { + return null; + } + + var year = (a.length == 4) ? int.parse(a) : int.parse(c); + if (year < 100) year += 2000; + final month = int.parse(b); + final day = (a.length == 4) ? int.parse(c) : int.parse(a); + + if (month < 1 || month > 12 || day < 1 || day > 31) return ''; + + final parsed = DateTime.tryParse( + "${year.toString().padLeft(4, '0')}-${month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}", + ); + if (parsed == null || + parsed.year != year || + parsed.month != month || + parsed.day != day) { + return ''; + } + + final yearStr = year.toString().padLeft(4, '0'); + final monthStr = month.toString().padLeft(2, '0'); + final dayStr = day.toString().padLeft(2, '0'); + return '$yearStr$monthStr$dayStr'; +} + /// Normalizes session identifiers (e.g., "1st Hour", "Session I") to a numeric string. String normalizeSession(dynamic session) { if (session == null) return ''; @@ -386,7 +363,12 @@ bool isValidCourseName(String text) { } String standardizeCourseCode(String input) { - return input.trim().toUpperCase().replaceAll(RegExp(r'[\s\u00A0-]'), ''); + return input + .trim() + .toUpperCase() + .replaceAll(RegExp(r'\s'), '') + .replaceAll('\u00A0', '') + .replaceAll('-', ''); } const Set remarkPlaceholders = { diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index 7755f9f1..e806e3af 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -49,6 +49,7 @@ class DashboardNotifier extends AsyncNotifier { AttendanceReportDetailed? _cachedAttendance; List? _cachedInstructors; AcademicState? _lastAcademic; + AttendanceReportDetailed? _pendingTrackedAttendance; bool _needsRevalidate = true; @@ -206,6 +207,8 @@ class DashboardNotifier extends AsyncNotifier { final storage = ref.read(secureStorageProvider); final classId = ref.read(authProvider).value?.profile?.classField?.id; + final attendanceToUse = trackedAttendance ?? _pendingTrackedAttendance; + _pendingTrackedAttendance = null; late final Response coursesResponse; late final AttendanceReportDetailed attendance; @@ -214,8 +217,8 @@ class DashboardNotifier extends AsyncNotifier { await Future.wait([ api.fetchCourses(storage).then((res) => coursesResponse = res), - (trackedAttendance != null - ? Future.value(trackedAttendance) + (attendanceToUse != null + ? Future.value(attendanceToUse) : _fetchAttendanceOnce(api: api, storage: storage)) .then((res) { if (res == null) throw Exception('No attendance data'); @@ -421,15 +424,19 @@ class DashboardNotifier extends AsyncNotifier { // Pre-calculate sorting criteria to avoid redundant math during sort final target = (auth?.settings.targetPercentage ?? 75).toDouble(); - final metaMap = { - for (final c in courses) - c.safeId: _computeCourseMeta( - course: c, - stats: stats, - disabledCodes: disabledCodes, - targetPercentage: target, - ), - }; + final metaMap = + < + String, + ({int tier, int canBunk, int safeCanBunk, int requiredToAttend}) + >{ + for (final c in courses) + c.safeId: _computeCourseMeta( + course: c, + stats: stats, + disabledCodes: disabledCodes, + targetPercentage: target, + ), + }; final sortedCourses = List.from(courses) ..sort((a, b) { @@ -470,9 +477,9 @@ class DashboardNotifier extends AsyncNotifier { } ({int tier, int canBunk, int safeCanBunk, int requiredToAttend}) - _computeCourseMeta({ + _computeCourseMeta({ required CourseDetails course, - required AttendanceReportStats stats, + required DashboardStats stats, required Set disabledCodes, required double targetPercentage, }) { @@ -547,7 +554,6 @@ class DashboardNotifier extends AsyncNotifier { } Future refresh() async { - ref.invalidate(notificationsProvider); final user = ref.read(authProvider).value; // 0. Set local loading state @@ -559,6 +565,7 @@ class DashboardNotifier extends AsyncNotifier { if (user != null) { try { await runUnifiedPullToRefresh( + invalidateNotifications: () => ref.invalidate(notificationsProvider), logLabel: 'DashboardNotifier', refreshProfile: () => ref.read(authProvider.notifier).refreshProfile(force: true), @@ -575,6 +582,10 @@ class DashboardNotifier extends AsyncNotifier { }, refreshData: () => ref.read(trackingProvider.notifier).refresh(), ); + _pendingTrackedAttendance = ref + .read(trackingProvider) + .value + ?.officialReport; } on Object catch (e, st) { AppLogger.e('DashboardNotifier: refresh coordinator failed', e, st); refreshError = e; diff --git a/mobile/lib/providers/leave_provider.dart b/mobile/lib/providers/leave_provider.dart index 6413716b..01c3802a 100644 --- a/mobile/lib/providers/leave_provider.dart +++ b/mobile/lib/providers/leave_provider.dart @@ -4,7 +4,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/models/leave.dart'; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; -import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/secure_storage.dart'; @@ -67,7 +66,6 @@ class LeaveNotifier extends AsyncNotifier { } Future refresh() async { - ref.invalidate(notificationsProvider); ref.invalidateSelf(); await future; } diff --git a/mobile/lib/providers/notification_provider.dart b/mobile/lib/providers/notification_provider.dart index 1ee88deb..f1a41a91 100644 --- a/mobile/lib/providers/notification_provider.dart +++ b/mobile/lib/providers/notification_provider.dart @@ -44,8 +44,6 @@ class NotificationsState { actionNotifications: [], regularNotifications: [], unreadCount: 0, - hasNextPage: false, - isFetchingNextPage: false, ); final List actionNotifications; final List regularNotifications; @@ -214,7 +212,9 @@ class NotificationsNotifier extends AsyncNotifier { Future fetchNextPage() async { final current = state.value; - if (current == null || !current.hasNextPage || current.isFetchingNextPage) return; + if (current == null || !current.hasNextPage || current.isFetchingNextPage) { + return; + } state = AsyncValue.data(current.copyWith(isFetchingNextPage: true)); final page = _currentPage + 1; diff --git a/mobile/lib/providers/score_provider.dart b/mobile/lib/providers/score_provider.dart index 1063e7f2..4cd9e022 100644 --- a/mobile/lib/providers/score_provider.dart +++ b/mobile/lib/providers/score_provider.dart @@ -5,7 +5,6 @@ import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/models/score.dart'; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; -import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/secure_storage.dart'; @@ -167,13 +166,25 @@ class ScoreNotifier extends AsyncNotifier { pendingCount: pending, ); - return _applyFilter(state, 'all'); + return _applyFilter( + state, + 'all', + totalExams: visibleExams.length, + scoredCount: scored, + pendingCount: pending, + ); } on Object catch (e) { throw Exception('Failed to load internal marks: $e'); } } - ScoreState _applyFilter(ScoreState baseState, String type) { + ScoreState _applyFilter( + ScoreState baseState, + String type, { + int? totalExams, + int? scoredCount, + int? pendingCount, + }) { var filtered = baseState.rawExams; if (type != 'all') { filtered = baseState.rawExams @@ -190,11 +201,13 @@ class ScoreNotifier extends AsyncNotifier { .map((entry) => CourseGroup(label: entry.key, exams: entry.value)) .toList(); - final total = filtered.length; - final scored = filtered - .where((e) => baseState.resolvedScores.containsKey(e.id)) - .length; - final pending = total - scored; + final total = totalExams ?? filtered.length; + final scored = + scoredCount ?? + filtered + .where((e) => baseState.resolvedScores.containsKey(e.id)) + .length; + final pending = pendingCount ?? (total - scored); return baseState.copyWith( filterType: type, @@ -212,7 +225,6 @@ class ScoreNotifier extends AsyncNotifier { } Future refresh() async { - ref.invalidate(notificationsProvider); final academicAsync = ref.read(academicProvider); final academic = academicAsync.value; state = const AsyncValue.loading(); diff --git a/mobile/lib/providers/tracking_provider.dart b/mobile/lib/providers/tracking_provider.dart index 84a7e835..fab15894 100644 --- a/mobile/lib/providers/tracking_provider.dart +++ b/mobile/lib/providers/tracking_provider.dart @@ -6,7 +6,6 @@ import 'package:ghostclass/logic/error_utils.dart'; import 'package:ghostclass/models/attendance.dart'; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; -import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/services/analytics_service.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/logger.dart'; @@ -52,11 +51,6 @@ final trackingProvider = AsyncNotifierProvider( ); class TrackingNotifier extends AsyncNotifier { - - String _canonicalTrackerCourseCode(String courseId) { - return utils.standardizeCourseCode(courseId); - } - @override FutureOr build() async { // 1. Reactive Dependency: Clear data immediately on logout OR Semester Change @@ -200,7 +194,6 @@ class TrackingNotifier extends AsyncNotifier { /// DashboardNotifier.refresh() already handles the primary sync, so pass /// forceSync: false when calling from that context to avoid redundant requests. Future refresh({bool forceSync = false}) async { - ref.invalidate(notificationsProvider); final academicAsync = ref.read(academicProvider); final user = ref.read(authProvider).value; final supabaseToken = ref @@ -240,7 +233,7 @@ class TrackingNotifier extends AsyncNotifier { final academic = academicAsync.value; if (auth == null || academic == null) return; - final canonicalCourseId = _canonicalTrackerCourseCode(courseId); + final canonicalCourseId = utils.standardizeCourseCode(courseId); try { final response = await ref diff --git a/mobile/lib/screens/leaves_screen.dart b/mobile/lib/screens/leaves_screen.dart index 445d6b07..81c894d0 100644 --- a/mobile/lib/screens/leaves_screen.dart +++ b/mobile/lib/screens/leaves_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/models/leave.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/leave_provider.dart'; +import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/refresh_coordinator.dart'; import 'package:ghostclass/theme/app_theme.dart'; @@ -33,6 +34,119 @@ class LeavesScreen extends ConsumerWidget { return '${(bytes / 1048576).toStringAsFixed(1)} MB'; } + List _buildWorkflowHistory(BuildContext context, Leave leave) { + final filteredApprovers = {}; + + for (final approver in leave.approvers) { + if (approver.actionByUser == null) continue; + + final actionByUser = approver.actionByUser!; + final dedupeKey = [ + actionByUser.firstName, + actionByUser.lastName, + approver.actionType, + approver.actionAt, + ].join('|'); + + filteredApprovers.putIfAbsent(dedupeKey, () => approver); + } + + final sortedApprovers = filteredApprovers.values.toList() + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + final successGreen = + Theme.of(context).extension()?.successGreen ?? + const Color(0xFF10B981); + + return sortedApprovers.map((approver) { + final isApproved = approver.actionType == 'approve'; + final isRejected = approver.actionType == 'reject'; + final isForwarded = approver.actionType == 'forward'; + final isRecommended = approver.actionType == 'recommend'; + final color = isApproved + ? successGreen + : isRejected + ? Colors.red + : isForwarded + ? Colors.indigo + : isRecommended + ? Colors.blue + : Colors.grey; + final label = isApproved + ? 'Approved' + : isForwarded + ? 'Forwarded' + : isRecommended + ? 'Recommended' + : isRejected + ? 'Rejected' + : (approver.actionType ?? 'Action'); + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + LucideIcons.user, + size: 12, + color: color, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '${approver.actionByUser!.firstName} ${approver.actionByUser!.lastName}', + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label.toUpperCase(), + style: GoogleFonts.manrope( + fontSize: 9, + fontWeight: FontWeight.w900, + color: color, + ), + ), + ), + const SizedBox(width: 8), + Text( + _formatDate( + approver.actionAt ?? approver.updatedAt, + ), + style: GoogleFonts.manrope( + fontSize: 11, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ], + ), + ); + }).toList(); + } + @override Widget build(BuildContext context, WidgetRef ref) { final leaveState = ref.watch(leaveProvider); @@ -85,6 +199,8 @@ class LeavesScreen extends ConsumerWidget { onRefresh: () async { try { await runUnifiedPullToRefresh( + invalidateNotifications: () => + ref.invalidate(notificationsProvider), logLabel: 'LeavesScreen', refreshProfile: () => ref .read(authProvider.notifier) @@ -136,6 +252,8 @@ class LeavesScreen extends ConsumerWidget { child: ServiceErrorView( error: err, onRetry: () => runUnifiedPullToRefresh( + invalidateNotifications: () => + ref.invalidate(notificationsProvider), logLabel: 'LeavesScreen', refreshProfile: () => ref .read(authProvider.notifier) @@ -216,8 +334,11 @@ class LeavesScreen extends ConsumerWidget { } final approvedCount = data.leaves - .where((l) => _getLeaveStatus(l.approvers).label == 'Approved') + .where((l) => _getLeaveStatus(context, l.approvers).label == 'Approved') .length; + final successGreen = + Theme.of(context).extension()?.successGreen ?? + const Color(0xFF10B981); return SliverPadding( padding: const EdgeInsets.all(24), @@ -238,7 +359,7 @@ class LeavesScreen extends ConsumerWidget { 'Approved', approvedCount.toString(), LucideIcons.checkCircle2, - const Color(0xFF10B981), + successGreen, ), ], ), @@ -338,7 +459,7 @@ class LeavesScreen extends ConsumerWidget { Leave leave, List sessions, ) { - final status = _getLeaveStatus(leave.approvers); + final status = _getLeaveStatus(context, leave.approvers); // Unique dates final uniqueDates = sessions.map((s) => s.date).toSet().toList()..sort(); @@ -375,6 +496,7 @@ class LeavesScreen extends ConsumerWidget { ), clipBehavior: Clip.antiAlias, child: Stack( + clipBehavior: Clip.antiAlias, children: [ // Content Padding( @@ -626,113 +748,7 @@ class LeavesScreen extends ConsumerWidget { ), ), const SizedBox(height: 12), - ...() { - final filteredApprovers = []; - for (final a in leave.approvers) { - if (a.actionByUser == null) continue; - final isDuplicate = filteredApprovers.any( - (item) => - item.actionByUser?.firstName == - a.actionByUser?.firstName && - item.actionByUser?.lastName == - a.actionByUser?.lastName && - item.actionType == a.actionType && - item.actionAt == a.actionAt, - ); - if (!isDuplicate) filteredApprovers.add(a); - } - filteredApprovers.sort( - (a, b) => b.updatedAt.compareTo(a.updatedAt), - ); - - return filteredApprovers.map((approver) { - final isApproved = approver.actionType == 'approve'; - final isRejected = approver.actionType == 'reject'; - final isForwarded = approver.actionType == 'forward'; - final isRecommended = approver.actionType == 'recommend'; - final color = isApproved - ? const Color(0xFF10B981) - : isRejected - ? Colors.red - : isForwarded - ? Colors.indigo - : isRecommended - ? Colors.blue - : Colors.grey; - final label = isApproved - ? 'Approved' - : isForwarded - ? 'Forwarded' - : isRecommended - ? 'Recommended' - : isRejected - ? 'Rejected' - : (approver.actionType ?? 'Action'); - - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon( - LucideIcons.user, - size: 12, - color: color, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - '${approver.actionByUser!.firstName} ${approver.actionByUser!.lastName}', - style: GoogleFonts.manrope( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurface, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - label.toUpperCase(), - style: GoogleFonts.manrope( - fontSize: 9, - fontWeight: FontWeight.w900, - color: color, - ), - ), - ), - const SizedBox(width: 8), - Text( - _formatDate( - approver.actionAt ?? approver.updatedAt, - ), - style: GoogleFonts.manrope( - fontSize: 11, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), - ), - ), - ], - ), - ); - }).toList(); - }(), + ..._buildWorkflowHistory(context, leave), ], ], ), @@ -803,7 +819,14 @@ class LeavesScreen extends ConsumerWidget { ); } - _LeaveStatus _getLeaveStatus(List approvers) { + _LeaveStatus _getLeaveStatus( + BuildContext context, + List approvers, + ) { + final successGreen = + Theme.of(context).extension()?.successGreen ?? + const Color(0xFF10B981); + if (approvers.isEmpty) { return _LeaveStatus('Pending', Colors.amber, LucideIcons.clock); } @@ -848,7 +871,7 @@ class LeavesScreen extends ConsumerWidget { if (lastAction == 'approve') { return _LeaveStatus( 'Approved', - const Color(0xFF10B981), + successGreen, LucideIcons.checkCircle2, ); } diff --git a/mobile/lib/screens/navigation_shell.dart b/mobile/lib/screens/navigation_shell.dart index 65dc66cb..995f1dbb 100644 --- a/mobile/lib/screens/navigation_shell.dart +++ b/mobile/lib/screens/navigation_shell.dart @@ -334,8 +334,12 @@ class _NavigationShellState extends ConsumerState { final bottomPadding = MediaQuery.of(context).padding.bottom; // --- OUTAGE BARRIER --- - final dashboardAsync = ref.watch(dashboardProvider); - final trackingAsync = ref.watch(trackingProvider); + final dashboardError = ref.watch( + dashboardProvider.select((state) => state.error), + ); + final trackingError = ref.watch( + trackingProvider.select((state) => state.error), + ); // Reactive provider confirms an outage (Global Barrier) final showOutageBarrier = ref.watch(outageProvider); @@ -710,7 +714,7 @@ class _NavigationShellState extends ConsumerState { child: ColoredBox( color: bg.withValues(alpha: 0.9), // More opaque background child: ServiceErrorView( - error: dashboardAsync.error ?? trackingAsync.error, + error: dashboardError ?? trackingError, onRetry: () async { ref.read(apiServiceProvider).clearCaches(); ref diff --git a/mobile/lib/screens/notifications_screen.dart b/mobile/lib/screens/notifications_screen.dart index 5f1b545a..8e193277 100644 --- a/mobile/lib/screens/notifications_screen.dart +++ b/mobile/lib/screens/notifications_screen.dart @@ -183,12 +183,10 @@ class _NotificationsScreenState extends ConsumerState final authNotifier = ref.read(authProvider.notifier); final supabaseClient = ref.read(supabaseClientProvider); final apiService = ref.read(apiServiceProvider); - // ref.invalidate is synchronous — safe to call before awaits. - // Capture it as a closure so it's invoked at the right time. - void invalidateNotifications() => - ref.invalidate(notificationsProvider); try { await runUnifiedPullToRefresh( + invalidateNotifications: () => + ref.invalidate(notificationsProvider), logLabel: 'NotificationsScreen', refreshProfile: () => authNotifier.refreshProfile(force: true), @@ -202,7 +200,6 @@ class _NotificationsScreenState extends ConsumerState ); }, refreshData: () async { - invalidateNotifications(); await ref.read(notificationsProvider.future); }, ); @@ -229,32 +226,37 @@ class _NotificationsScreenState extends ConsumerState NotificationsState data, ) { if (data.allNotifications.isEmpty) { - return ListView( + return CustomScrollView( controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox(height: MediaQuery.of(context).size.height * 0.2), - Center( - child: Column( - children: [ - Icon( - LucideIcons.bellOff, - size: 64, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.2), - ), - const SizedBox(height: 16), - const Text('All caught up!'), - Text( - 'You have no new notifications.', - style: TextStyle( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.bellOff, + size: 64, color: Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.5), + ).colorScheme.onSurface.withValues(alpha: 0.2), ), - ), - ], + const SizedBox(height: 16), + const Text('All caught up!'), + Text( + 'You have no new notifications.', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), ), ), ], @@ -280,50 +282,64 @@ class _NotificationsScreenState extends ConsumerState unreadRegular.sort(compareNotifications); readNotifications.sort(compareNotifications); - return ListView( - controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), - children: [ - if (unreadConflicts.isNotEmpty) ...[ - _buildSectionHeader(context, 'ACTION REQUIRED', Colors.amber), - const SizedBox(height: 12), - ...unreadConflicts.map( - (n) => _NotificationCard( - notification: n, - key: ValueKey('notif_${n.id}_${n.isRead}'), - ), - ), - const SizedBox(height: 24), - ], - if (unreadRegular.isNotEmpty) ...[ - _buildSectionHeader(context, 'UNREAD', Colors.blue), - const SizedBox(height: 12), - ...unreadRegular.map( - (n) => _NotificationCard( - notification: n, - key: ValueKey('notif_${n.id}_${n.isRead}'), + final slivers = []; + + void addSection(String title, Color color, List items) { + if (items.isEmpty) return; + slivers + ..add( + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.only( + top: slivers.isEmpty ? 16 : 0, + ), + child: _buildSectionHeader(context, title, color), ), ), - const SizedBox(height: 24), - ], - if (readNotifications.isNotEmpty) ...[ - _buildSectionHeader(context, 'EARLIER', Colors.grey), - const SizedBox(height: 12), - ...readNotifications.map( - (n) => _NotificationCard( - notification: n, - key: ValueKey('notif_${n.id}_${n.isRead}'), + ) + ..add(const SliverToBoxAdapter(child: SizedBox(height: 12))) + ..add( + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final notification = items[index]; + return _NotificationCard( + notification: notification, + key: ValueKey( + 'notif_${notification.id}_${notification.isRead}', + ), + ); + }, + childCount: items.length, ), ), - ], - if (data.hasNextPage) - const Padding( + ) + ..add(const SliverToBoxAdapter(child: SizedBox(height: 24))); + } + + addSection('ACTION REQUIRED', Colors.amber, unreadConflicts); + addSection('UNREAD', Colors.blue, unreadRegular); + addSection('EARLIER', Colors.grey, readNotifications); + + if (data.hasNextPage) { + slivers.add( + const SliverToBoxAdapter( + child: Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center( child: CircularProgressIndicator(strokeWidth: 2), ), ), - ], + ), + ); + } + + return CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: slivers, ); } @@ -461,112 +477,118 @@ class _NotificationCard extends ConsumerWidget { iconColor = Colors.blue; } - return InkWell( - onTap: () { - final _ = ref - .read(notificationsProvider.notifier) - .toggleRead(notification.id, wasRead: isRead); - ServiceToast.show( - context, - isRead ? 'Marked as unread' : 'Marked as read', - duration: const Duration(seconds: 2), - ); - }, - borderRadius: BorderRadius.circular(20), - child: Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: isRead - ? Colors.transparent - : Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(20), - border: Border.all( + return Semantics( + button: true, + label: + 'Notification: ${notification.title}, ${isRead ? 'read' : 'unread'}. Tap to toggle.', + child: InkWell( + onTap: () { + final _ = ref + .read(notificationsProvider.notifier) + .toggleRead(notification.id, wasRead: isRead); + ServiceToast.show( + context, + isRead ? 'Marked as unread' : 'Marked as read', + duration: const Duration(seconds: 2), + ); + }, + borderRadius: BorderRadius.circular(20), + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( color: isRead ? Colors.transparent - : Theme.of( - context, - ).colorScheme.outlineVariant.withValues(alpha: 0.1), + : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isRead + ? Colors.transparent + : Theme.of( + context, + ).colorScheme.outlineVariant.withValues(alpha: 0.1), + ), + boxShadow: isRead + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.02), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), - boxShadow: isRead - ? null - : [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.02), - blurRadius: 10, - offset: const Offset(0, 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isRead) + Container( + margin: const EdgeInsets.only(right: 12, top: 12), + width: 6, + height: 6, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, ), - ], - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isRead) + ), Container( - margin: const EdgeInsets.only(right: 12, top: 12), - width: 6, - height: 6, + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - shape: BoxShape.circle, + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), ), + child: Icon(icon, size: 20, color: iconColor), ), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, size: 20, color: iconColor), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - notification.title, - style: GoogleFonts.manrope( - fontSize: 14, - fontWeight: isRead - ? FontWeight.w600 - : FontWeight.w800, - color: Theme.of(context).colorScheme.onSurface - .withValues(alpha: isRead ? 0.6 : 1.0), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + notification.title, + style: GoogleFonts.manrope( + fontSize: 14, + fontWeight: isRead + ? FontWeight.w600 + : FontWeight.w800, + color: Theme.of(context).colorScheme.onSurface + .withValues(alpha: isRead ? 0.6 : 1.0), + ), ), ), - ), - Text( - _formatRelativeTime(notification.createdAt), - style: GoogleFonts.manrope( - fontSize: 10, - fontWeight: FontWeight.w600, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.4), + Text( + _formatRelativeTime(notification.createdAt), + style: GoogleFonts.manrope( + fontSize: 10, + fontWeight: FontWeight.w600, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), ), + ], + ), + const SizedBox(height: 4), + Text( + notification.description, + style: GoogleFonts.manrope( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurface + .withValues( + alpha: isRead ? 0.4 : 0.6, + ), + height: 1.4, ), - ], - ), - const SizedBox(height: 4), - Text( - notification.description, - style: GoogleFonts.manrope( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurface.withValues( - alpha: isRead ? 0.4 : 0.6, - ), - height: 1.4, ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ).animate().fadeIn(duration: 300.ms).slideY(begin: 0.05); diff --git a/mobile/lib/screens/scores_screen.dart b/mobile/lib/screens/scores_screen.dart index 7377d32a..24b4420f 100644 --- a/mobile/lib/screens/scores_screen.dart +++ b/mobile/lib/screens/scores_screen.dart @@ -3,12 +3,14 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:ghostclass/models/score.dart'; import 'package:ghostclass/providers/auth_provider.dart'; +import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/providers/score_provider.dart'; import 'package:ghostclass/providers/ui_state_provider.dart'; import 'package:ghostclass/services/api_service.dart'; import 'package:ghostclass/services/refresh_coordinator.dart'; import 'package:ghostclass/theme/app_theme.dart'; import 'package:ghostclass/widgets/loading_overlay.dart'; +import 'package:ghostclass/widgets/service_error_view.dart'; import 'package:ghostclass/widgets/service_refresh_indicator.dart'; import 'package:ghostclass/widgets/service_toast.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -83,6 +85,8 @@ class _ScoresScreenState extends ConsumerState { final scoreNotifier = ref.read(scoreProvider.notifier); try { await runUnifiedPullToRefresh( + invalidateNotifications: () => + ref.invalidate(notificationsProvider), logLabel: 'ScoresScreen', refreshProfile: () => authNotifier.refreshProfile(force: true), @@ -202,13 +206,10 @@ class _ScoresScreenState extends ConsumerState { ), loading: () => const SliverFillRemaining(child: SizedBox.shrink()), - error: (err, _) => const SliverFillRemaining( - child: Center( - child: Text( - 'We encountered an error while loading your scores. Please try again later. If the issue persists, please contact us.', - style: TextStyle(color: Colors.redAccent), - textAlign: TextAlign.center, - ), + error: (err, _) => SliverFillRemaining( + child: ServiceErrorView( + error: err, + onRetry: () => ref.invalidate(scoreProvider), ), ), ), diff --git a/mobile/lib/screens/splash_screen.dart b/mobile/lib/screens/splash_screen.dart index fb1794d3..f8cf9fc7 100644 --- a/mobile/lib/screens/splash_screen.dart +++ b/mobile/lib/screens/splash_screen.dart @@ -71,7 +71,9 @@ class _SplashScreenState extends ConsumerState { final startupFlow = ref.read(startupFlowServiceProvider); if (_canUseStartupCache(sessionKey, startupFlow)) { - return Future<_StartupSnapshot>.value(startupFlow.startupCache as _StartupSnapshot); + return Future<_StartupSnapshot>.value( + startupFlow.startupCache! as _StartupSnapshot, + ); } final inFlight = startupFlow.startupInFlight; @@ -167,13 +169,15 @@ class _SplashScreenState extends ConsumerState { return _StartupSnapshot(user: user, versionResult: versionResult); }(); - startupFlow.startupCacheSessionKey = sessionKey; - startupFlow.startupInFlight = future; + startupFlow + ..startupCacheSessionKey = sessionKey + ..startupInFlight = future; return future .then((snapshot) { - startupFlow.startupCache = snapshot; - startupFlow.startupCacheAt = DateTime.now(); + startupFlow + ..startupCache = snapshot + ..startupCacheAt = DateTime.now(); return snapshot; }) .whenComplete(() { @@ -589,7 +593,12 @@ class _SplashScreenState extends ConsumerState { duration: 500.ms, curve: Curves.easeInOut, ) - .animate(onPlay: (controller) => controller.repeat()) + .animate( + onPlay: (controller) => controller.repeat( + reverse: true, + period: 1200.ms, + ), + ) .shimmer( duration: 600.ms, color: Colors.white.withValues(alpha: 0.3), diff --git a/mobile/lib/screens/tracking_screen.dart b/mobile/lib/screens/tracking_screen.dart index a63e0e0d..21e22b54 100644 --- a/mobile/lib/screens/tracking_screen.dart +++ b/mobile/lib/screens/tracking_screen.dart @@ -8,6 +8,7 @@ import 'package:ghostclass/models/course_details.dart'; import 'package:ghostclass/providers/academic_provider.dart'; import 'package:ghostclass/providers/auth_provider.dart'; import 'package:ghostclass/providers/dashboard_provider.dart'; +import 'package:ghostclass/providers/notification_provider.dart'; import 'package:ghostclass/providers/tracking_provider.dart'; import 'package:ghostclass/providers/tracking_ui_provider.dart'; import 'package:ghostclass/services/api_service.dart'; @@ -123,6 +124,8 @@ class _TrackingScreenState extends ConsumerState final trackingNotifier = ref.read(trackingProvider.notifier); try { await runUnifiedPullToRefresh( + invalidateNotifications: () => + ref.invalidate(notificationsProvider), logLabel: 'TrackingScreen', refreshProfile: () => authNotifier.refreshProfile(force: true), syncCron: () async { diff --git a/mobile/lib/services/api_service.dart b/mobile/lib/services/api_service.dart index 7ab3d099..e37d311f 100644 --- a/mobile/lib/services/api_service.dart +++ b/mobile/lib/services/api_service.dart @@ -36,8 +36,9 @@ class ApiService { // --- GhostClass Sync State --- Future>? _syncInFlight; - DateTime? _lastSyncTime; + DateTime? _lastSyncAt; static const _syncCooldown = Duration(minutes: 5); + static const _forcedSyncGracePeriod = Duration(seconds: 15); void clearCaches() => _ezygo.clearCaches(); @@ -159,9 +160,22 @@ class ApiService { // 3. Throttling final now = DateTime.now(); + if (force && + _lastSyncAt != null && + now.difference(_lastSyncAt!) < _forcedSyncGracePeriod) { + AppLogger.d( + 'ApiService: Skipping forced sync due to recent startup sync.', + ); + return Response( + requestOptions: RequestOptions(path: 'sync'), + statusCode: 304, + data: {'message': 'Recently synced'}, + ); + } + if (!force && - _lastSyncTime != null && - now.difference(_lastSyncTime!) < _syncCooldown) { + _lastSyncAt != null && + now.difference(_lastSyncAt!) < _syncCooldown) { AppLogger.d('ApiService: Sync throttled.'); return Response( requestOptions: RequestOptions(path: 'sync'), @@ -180,7 +194,7 @@ class ApiService { receiveTimeout: const Duration(seconds: 30), ), ); - _lastSyncTime = DateTime.now(); + _lastSyncAt = DateTime.now(); return response; } on Object catch (e) { AppLogger.e('ApiService: Background sync failed', e); diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 7d0642e9..6f19c67b 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -25,15 +25,16 @@ class AuthService { if (ezygoResponse.statusCode != 200) return ezygoResponse; final data = ezygoResponse.data as Map?; - final ezygoToken = data?['token'] ?? data?['access_token']; - if (ezygoToken?.toString().isEmpty ?? true) { + final rawToken = data?['token'] ?? data?['access_token']; + final ezygoToken = rawToken is String ? rawToken : rawToken?.toString(); + if (ezygoToken == null || ezygoToken.trim().isEmpty) { throw const AppException( message: 'Portal returned no token.', type: AppExceptionType.unauthorized, ); } - return provisionGhostClassSession(ezygoToken.toString()); + return provisionGhostClassSession(ezygoToken); } Future> loginEzygo(String username, String password) async { diff --git a/mobile/lib/services/ezygo_service.dart b/mobile/lib/services/ezygo_service.dart index a0348883..6af4e917 100644 --- a/mobile/lib/services/ezygo_service.dart +++ b/mobile/lib/services/ezygo_service.dart @@ -25,13 +25,22 @@ class EzygoService { late final EzygoBatchFetcher _fetcher; static final String _ezygoApiRoot = AppConfig.ezygoApiRoot; + String _requireEzygoToken(String? token) { + if (token == null) { + throw const AppException( + message: 'No EzyGo credentials found. Please log in.', + type: AppExceptionType.unauthorized, + ); + } + return token; + } + void clearCaches() => _fetcher.clearAll(); Future> fetchCourses(SecureStorageService storage) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/institutionuser/courses/withusers'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future> fetchAttendanceReportDetailed( @@ -39,18 +48,10 @@ class EzygoService { ) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/attendancereports/student/detailed'; - if (token == null) { - return _ref - .read(dioServiceProvider) - .dio - .post( - path, - data: {}, - ); - } + _requireEzygoToken(token); return _fetcher.fetch( path: path, - token: token, + token: token!, method: 'POST', data: {}, ); @@ -61,8 +62,7 @@ class EzygoService { ) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/institutionusers/myinstitutions'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future> updateDefaultInstitution( @@ -122,8 +122,7 @@ class EzygoService { Future> fetchSemester(SecureStorageService storage) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/user/setting/default_semester'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future> fetchAcademicYear( @@ -131,8 +130,7 @@ class EzygoService { ) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/user/setting/default_academic_year'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future _fetchWithCache( @@ -225,8 +223,7 @@ class EzygoService { Future> fetchExams(SecureStorageService storage) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/exams'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future> fetchExamQuestions( @@ -236,8 +233,7 @@ class EzygoService { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/exams/$examId/examquestions?from_view_score=true'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } Future> fetchExamAnswers( @@ -246,8 +242,7 @@ class EzygoService { ) async { final token = await storage.getNormalizedEzygoToken(); final path = '$_ezygoApiRoot/exams/$examId/institutionuser/examanswers'; - if (token == null) return _ref.read(dioServiceProvider).dio.get(path); - return _fetcher.fetch(path: path, token: token); + return _fetcher.fetch(path: path, token: _requireEzygoToken(token)); } } diff --git a/mobile/lib/services/refresh_coordinator.dart b/mobile/lib/services/refresh_coordinator.dart index 601d6f51..d76d305c 100644 --- a/mobile/lib/services/refresh_coordinator.dart +++ b/mobile/lib/services/refresh_coordinator.dart @@ -1,11 +1,14 @@ +import 'package:flutter/foundation.dart'; import 'package:ghostclass/services/logger.dart'; Future runUnifiedPullToRefresh({ + required VoidCallback invalidateNotifications, required Future Function() refreshProfile, required Future Function() refreshData, Future Function()? syncCron, String logLabel = 'PullToRefresh', }) async { + invalidateNotifications(); await refreshProfile(); final syncFuture = syncCron == null diff --git a/mobile/lib/services/secure_storage.dart b/mobile/lib/services/secure_storage.dart index b3cb169a..5472f20d 100644 --- a/mobile/lib/services/secure_storage.dart +++ b/mobile/lib/services/secure_storage.dart @@ -309,11 +309,11 @@ class SecureStorageService { Future clearAllCachedData() async { try { final all = await _storage.readAll(); - for (final key in all.keys) { - if (key.startsWith('cache_')) { - await _safeDelete(key: key); - } - } + await Future.wait( + all.keys + .where((key) => key.startsWith('cache_')) + .map((key) => _safeDelete(key: key)), + ); } on Object catch (e) { AppLogger.e('SecureStorage: Error clearing cached data', e); } diff --git a/mobile/lib/widgets/add_attendance_dialog.dart b/mobile/lib/widgets/add_attendance_dialog.dart index 6a72670a..ccd80904 100644 --- a/mobile/lib/widgets/add_attendance_dialog.dart +++ b/mobile/lib/widgets/add_attendance_dialog.dart @@ -284,12 +284,12 @@ class _AddAttendanceDialogState extends ConsumerState { const AttendanceDialogLabel(text: 'Session'), _buildSessionSelector(primary), if (_isBlocked) - const Padding( - padding: EdgeInsets.only(top: 6, left: 4), + Padding( + padding: const EdgeInsets.only(top: 6, left: 4), child: Text( 'Session occupied', style: TextStyle( - color: Colors.redAccent, + color: Theme.of(context).colorScheme.error, fontSize: 11, fontWeight: FontWeight.w600, ), diff --git a/mobile/test/services/secure_storage_service_test.dart b/mobile/test/services/secure_storage_service_test.dart index bb03aeb3..f3c1e815 100644 --- a/mobile/test/services/secure_storage_service_test.dart +++ b/mobile/test/services/secure_storage_service_test.dart @@ -348,32 +348,54 @@ void main() { }); group('Self-Healing & Error Handling', () { - test('Transient error on read (e.g., interaction not allowed) does not purge and rethrows', () async { - final transientError = Exception('Keychain error: -25308 (interaction not allowed)'); - when(() => mockStorage.read(key: any(named: 'key'))).thenThrow(transientError); - when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); - - expect( - () => service.getEzygoToken(), - throwsA(isA().having((e) => e.toString(), 'message', contains('-25308'))), - ); - - verifyNever(() => mockStorage.deleteAll()); - }); - - test('Unrecoverable error on read (e.g., storage_key_error) purges and returns null', () async { - final unrecoverableError = Exception('PlatformException(storage_key_error, Keystore corrupted, null, null)'); - when(() => mockStorage.read(key: any(named: 'key'))).thenThrow(unrecoverableError); - when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); - - final result = await service.getEzygoToken(); + test( + 'Transient error on read (e.g., interaction not allowed) does not purge and rethrows', + () async { + final transientError = Exception( + 'Keychain error: -25308 (interaction not allowed)', + ); + when( + () => mockStorage.read(key: any(named: 'key')), + ).thenThrow(transientError); + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + expect( + () => service.getEzygoToken(), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('-25308'), + ), + ), + ); + + verifyNever(() => mockStorage.deleteAll()); + }, + ); - expect(result, isNull); - verify(() => mockStorage.deleteAll()).called(1); - }); + test( + 'Unrecoverable error on read (e.g., storage_key_error) purges and returns null', + () async { + final unrecoverableError = Exception( + 'PlatformException(storage_key_error, Keystore corrupted, null, null)', + ); + when( + () => mockStorage.read(key: any(named: 'key')), + ).thenThrow(unrecoverableError); + when(() => mockStorage.deleteAll()).thenAnswer((_) async {}); + + final result = await service.getEzygoToken(); + + expect(result, isNull); + verify(() => mockStorage.deleteAll()).called(1); + }, + ); test('Transient error on write does not purge and rethrows', () async { - final transientError = Exception('Keychain error: -25308 (interaction not allowed)'); + final transientError = Exception( + 'Keychain error: -25308 (interaction not allowed)', + ); when( () => mockStorage.write( key: any(named: 'key'), @@ -384,14 +406,22 @@ void main() { expect( () => service.saveEzygoToken('test'), - throwsA(isA().having((e) => e.toString(), 'message', contains('-25308'))), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('-25308'), + ), + ), ); verifyNever(() => mockStorage.deleteAll()); }); test('Unrecoverable error on write purges and rethrows', () async { - final unrecoverableError = Exception('PlatformException(storage_key_error, Keystore corrupted, null, null)'); + final unrecoverableError = Exception( + 'PlatformException(storage_key_error, Keystore corrupted, null, null)', + ); when( () => mockStorage.write( key: any(named: 'key'), @@ -402,7 +432,13 @@ void main() { expect( () => service.saveEzygoToken('test'), - throwsA(isA().having((e) => e.toString(), 'message', contains('storage_key_error'))), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('storage_key_error'), + ), + ), ); verify(() => mockStorage.deleteAll()).called(1); From 851bc363c35d6b02a8041fcea4e6943f970c0e4e Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Sat, 30 May 2026 17:33:37 +0000 Subject: [PATCH 5/9] fix(web): harden auth, settings, and dashboard flows - remove raw token exposure from save-token responses and align origin validation with shared host resolution - scope localStorage cleanup to GhostClass keys and keep passwords untrimmed during login - validate stored user settings before use in all code paths - make circuit-breaker status reflect Redis-backed state, not stale local memory - replace blank protected-layout gating with a visible loading state - tighten dashboard and scores loading, navigation, and accessibility behavior - invalidate class-dependent caches after profile class_id updates - clean up sync lifecycle naming and reduce redundant or high-cost requests - fix smaller UX, accessibility, and config inconsistencies surfaced by the audit --- mobile/lib/services/auth_service.dart | 11 +- .../(protected)/dashboard/DashboardClient.tsx | 231 ++++++++++++------ .../__tests__/DashboardClient.basic.test.tsx | 2 +- .../DashboardClient.coverage.test.tsx | 2 +- .../__tests__/DashboardClient.test.tsx | 4 +- .../dashboard/components/CourseGrid.tsx | 34 ++- .../components/__tests__/CourseGrid.test.tsx | 4 +- src/app/(protected)/layout.tsx | 10 +- .../notifications/NotificationsClient.tsx | 8 +- .../NotificationsClient.minimal.test.tsx | 9 +- .../__tests__/NotificationsClient.test.tsx | 7 +- src/app/(protected)/scores/ScoresClient.tsx | 63 ++++- .../(protected)/tracking/TrackingClient.tsx | 6 +- .../TrackingClient.coverage.test.tsx | 3 +- .../__tests__/TrackingClient.test.tsx | 3 +- .../tracking/__tests__/simple.test.tsx | 2 +- src/app/actions/courses.ts | 3 +- src/app/actions/instructors.ts | 3 +- src/app/actions/user.ts | 7 +- .../auth/save-token/__tests__/route.test.ts | 41 ++++ src/app/api/auth/save-token/route.ts | 36 ++- .../api/health/ezygo/__tests__/route.test.ts | 16 +- src/app/api/health/ezygo/route.ts | 2 +- src/app/api/logout/route.ts | 5 +- .../attendance/AddAttendanceDialog.tsx | 10 +- .../__tests__/attendance-chart.test.tsx | 1 + .../attendance/attendance-calendar.tsx | 20 +- .../attendance/attendance-chart.tsx | 4 +- src/components/attendance/course-card.tsx | 3 +- src/components/user/login-form.tsx | 38 +-- .../__tests__/use-sync-on-mount.test.tsx | 14 +- src/hooks/courses/attendance.ts | 7 +- src/hooks/courses/exams.ts | 2 +- src/hooks/courses/useCourseLookup.ts | 4 +- src/hooks/use-dashboard-stats.ts | 51 ++-- src/hooks/use-sync-on-mount.ts | 86 +++++-- src/hooks/users/profile.ts | 16 +- src/lib/__tests__/circuit-breaker.test.ts | 36 +-- src/lib/__tests__/redis.test.ts | 29 ++- src/lib/circuit-breaker.ts | 39 ++- src/lib/logger.ts | 63 +++-- src/lib/redis.ts | 33 ++- src/lib/security/auth-cookie.ts | 3 +- src/lib/security/cookie-utils.ts | 5 + src/lib/security/csrf.ts | 3 +- src/lib/user/sync.ts | 5 +- src/lib/utils.ts | 9 + src/lib/validate-env.ts | 8 +- src/lib/validation/text.ts | 4 +- src/providers/user-settings.tsx | 9 +- src/types/react-intrinsic.d.ts | 9 + vitest.setup.ts | 2 + 52 files changed, 693 insertions(+), 332 deletions(-) create mode 100644 src/lib/security/cookie-utils.ts create mode 100644 src/types/react-intrinsic.d.ts diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index 6f19c67b..95ce4429 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -34,7 +34,16 @@ class AuthService { ); } - return provisionGhostClassSession(ezygoToken); + final bridgeResponse = await provisionGhostClassSession(ezygoToken); + final bridgeData = bridgeResponse.data; + if (bridgeData is Map) { + bridgeResponse.data = { + ...bridgeData, + 'ezygo_token': ezygoToken.trim(), + }; + } + + return bridgeResponse; } Future> loginEzygo(String username, String password) async { diff --git a/src/app/(protected)/dashboard/DashboardClient.tsx b/src/app/(protected)/dashboard/DashboardClient.tsx index 9aeffd1c..5fc63f59 100644 --- a/src/app/(protected)/dashboard/DashboardClient.tsx +++ b/src/app/(protected)/dashboard/DashboardClient.tsx @@ -56,11 +56,12 @@ import { useSyncOnMount } from "@/hooks/use-sync-on-mount"; import { PWAInstallBanner } from "@/components/pwa-install-banner"; import { useDisabledCourses } from "@/hooks/courses/useDisabledCourses"; import { useCourseLookup } from "@/hooks/courses/useCourseLookup"; +import { normalizeCourseCode } from "@/lib/utils"; import { AttendanceReport, Course, TrackAttendance, UserProfile } from "@/types"; import { StatsPanel } from "./components/StatsPanel"; import { DashboardCharts } from "./components/DashboardCharts"; -import { CourseGrid } from "./components/CourseGrid"; +import { CourseGrid, DashboardCourse } from "./components/CourseGrid"; const ChartSkeleton = () => (
@@ -138,6 +139,36 @@ const shiftAcademicPeriod = ( const formatAcademicPeriod = (period: AcademicPeriod) => `${period.semester.toUpperCase()} ${period.year}`; +function computeInitialDataValidity( + initialData: InitialDashboardData | undefined, + selectedSemester: AcademicSemester | null, + selectedYear: string | null, + ezygoSemester: AcademicSemester | undefined, + ezygoYear: string | undefined, + effectiveSemester: AcademicSemester | undefined, + effectiveYear: string | undefined +): { isInitialDataValid: boolean; isAttendanceStale: boolean } { + const isInitial = (() => { + if (selectedSemester !== null || selectedYear !== null) return false; + if (ezygoSemester && ezygoSemester !== effectiveSemester) return false; + if (ezygoYear && ezygoYear !== effectiveYear) return false; + return true; + })(); + + const attendance = initialData?.attendance; + const isAttendanceStale = !!( + attendance && + typeof attendance === "object" && + "studentAttendanceData" in attendance && + attendance.studentAttendanceData && + typeof attendance.studentAttendanceData === "object" && + "stale" in (attendance.studentAttendanceData as { stale?: unknown }) && + (attendance.studentAttendanceData as { stale?: unknown }).stale === true + ); + + return { isInitialDataValid: isInitial, isAttendanceStale }; +} + interface DashboardClientProps { initialData?: InitialDashboardData; serverError?: string | null; @@ -180,12 +211,12 @@ const getActiveCourseStats = ( const catalogCodes = new Set(); if (coursesData?.courses) { Object.values(coursesData.courses).forEach((c) => { - catalogCodes.add(c.code ? c.code.toUpperCase().replace(/[\s\u00A0-]/g, "") : String(c.id).toUpperCase()); + catalogCodes.add(normalizeCourseCode(c.code ?? String(c.id))); }); } if (classCourses) { classCourses.forEach((cc) => { - catalogCodes.add(cc.course_code.toUpperCase().replace(/[\s\u00A0-]/g, "")); + catalogCodes.add(normalizeCourseCode(cc.course_code ?? "")); }); } @@ -193,7 +224,7 @@ const getActiveCourseStats = ( const disabledWithDataCodes = new Set(); activeIds.forEach((id) => { - const code = String(getCourseCode(id) || id).toUpperCase().replace(/[\s\u00A0-]/g, ""); + const code = normalizeCourseCode(String(getCourseCode(id) || id)); if (code !== "" && catalogCodes.has(code)) { if (disabledCodes.has(code)) disabledWithDataCodes.add(code); else activeCodes.add(code); @@ -236,16 +267,17 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const currentSem = effectiveSemester || undefined; const currentYear = effectiveYear || undefined; + const isRateLimitError = serverError ? (serverError.toLowerCase().includes("rate limit") || serverError.includes("429")) : false; + useEffect(() => { if (serverError) { - const isRateLimit = serverError.toLowerCase().includes("rate limit") || serverError.includes("429"); - toast.error(isRateLimit ? "EzyGo Rate Limit Reached" : "Dashboard Pre-fetch Failed", { - description: isRateLimit ? "Too many requests. Please wait." : "Failed to pre-load your data.", + toast.error(isRateLimitError ? "EzyGo Rate Limit Reached" : "Dashboard Pre-fetch Failed", { + description: isRateLimitError ? "Too many requests. Please wait." : "Failed to pre-load your data.", duration: 8000, action: { label: "Retry", onClick: () => window.location.reload() }, }); } - }, [serverError]); + }, [serverError, isRateLimitError]); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [isAddCourseOpen, setIsAddCourseOpen] = useState(false); @@ -257,7 +289,7 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const [isSelectClassOpen, setIsSelectClassOpen] = useState(false); const academicShiftLockRef = useRef(false); - const { syncCompleted } = useSyncOnMount({ + const { syncSettled, syncFailed } = useSyncOnMount({ username: profile?.username, userId: profile?.id ? String(profile.id) : undefined, enabled: !!profile?.username, @@ -265,32 +297,31 @@ export default function DashboardClient({ initialData, serverError }: DashboardC sentryTag: "background_sync" }); + const syncSuccess = !!(syncSettled && !syncFailed); + useEffect(() => { - const shouldOpen = !!(syncCompleted && profile && !profile.class?.id); + const shouldOpen = !!(syncSuccess && profile && !profile.class?.id); const timer = setTimeout(() => { setIsSelectClassOpen(shouldOpen); }, 0); return () => clearTimeout(timer); - }, [syncCompleted, profile]); - - const isInitialDataValid = useMemo(() => { - if (selectedSemester !== null || selectedYear !== null) return false; - if (ezygoSemester && ezygoSemester !== effectiveSemester) return false; - if (ezygoYear && ezygoYear !== effectiveYear) return false; - return true; - }, [selectedSemester, selectedYear, ezygoSemester, ezygoYear, effectiveSemester, effectiveYear]); - - const isAttendanceStale = - initialData?.attendance && - typeof initialData.attendance === "object" && - "studentAttendanceData" in initialData.attendance && - initialData.attendance.studentAttendanceData && - typeof initialData.attendance.studentAttendanceData === "object" && - "stale" in initialData.attendance.studentAttendanceData && - (initialData.attendance.studentAttendanceData as { stale?: unknown }).stale === true; + }, [syncSuccess, profile]); + + const { isInitialDataValid, isAttendanceStale } = useMemo(() => + computeInitialDataValidity( + initialData, + selectedSemester, + selectedYear, + (ezygoSemester === "even" || ezygoSemester === "odd") ? ezygoSemester : undefined, + ezygoYear ?? undefined, + (effectiveSemester === "even" || effectiveSemester === "odd") ? effectiveSemester : undefined, + effectiveYear, + ), + [initialData, selectedSemester, selectedYear, ezygoSemester, ezygoYear, effectiveSemester, effectiveYear] + ); const { data: rawAttendanceData, isLoading: isLoadingAttendance, refetch: refetchAttendance } = useAttendanceReport(currentSem, currentYear, { - enabled: syncCompleted, + enabled: syncSuccess, initialData: (isInitialDataValid && !isAttendanceStale) ? (initialData?.attendance as AttendanceReport ?? undefined) : undefined, }); const attendanceData = rawAttendanceData as AttendanceReport | undefined; @@ -321,7 +352,7 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const { data: rawCoursesData, isLoading: isLoadingCourses } = useFetchCourses({ semester: currentSem, year: currentYear, - enabled: syncCompleted && !!currentSem && !!currentYear, + enabled: syncSuccess && !!currentSem && !!currentYear, initialData: isInitialDataValid ? formattedInitialCourses : undefined, }); const coursesData = rawCoursesData as { courses: Record } | undefined; @@ -329,12 +360,12 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const { data: rawTrackingData, refetch: refetchTracking } = useTrackingData(profile, { semester: currentSem, year: currentYear, - enabled: syncCompleted, + enabled: syncSuccess, }); const trackingData = rawTrackingData as TrackAttendance[] | undefined; - const { data: customInstructors } = useFetchCourseInstructors({ semester: currentSem, year: currentYear, enabled: syncCompleted }); - const { data: rawClassCourses } = useFetchClassCourses({ semester: currentSem, year: currentYear, enabled: syncCompleted && !!profile?.class?.id }); + const { data: customInstructors } = useFetchCourseInstructors({ semester: currentSem, year: currentYear, enabled: syncSuccess }); + const { data: rawClassCourses } = useFetchClassCourses({ semester: currentSem, year: currentYear, enabled: syncSuccess && !!profile?.class?.id }); const classCourses = rawClassCourses as ClassCourse[] | undefined; const { getCourseCodeById: getCourseCode } = useCourseLookup({ @@ -344,22 +375,43 @@ export default function DashboardClient({ initialData, serverError }: DashboardC attendanceData: attendanceData as any }) as { getCourseCodeById: (id: string | number) => string }; - const courseList = useMemo(() => { - const registry = new Map(); + const { courseList, courseRegistry } = useMemo(() => { + const listMap = new Map(); + const registry = new Map(); + if (coursesData?.courses) { - Object.entries(coursesData.courses).forEach(([code, c]) => { - const actualCode = c.code ?? code; - const key = actualCode.toUpperCase().replace(/[\s\u00A0-]/g, ""); - registry.set(key, { code: actualCode, id: Number(c.id), name: c.name || "" }); + Object.entries(coursesData.courses).forEach(([id, course]) => { + const courseCode = course.code ?? String(id); + const item = { code: courseCode, id: Number(course.id), name: course.name || "" }; + + // Deduplicate list by normalized code + const norm = normalizeCourseCode(courseCode || ""); + if (norm && !listMap.has(norm)) listMap.set(norm, item); + + // Populate registry keyed by id and by normalized code key + const basic: MergedCourse = { id: course.id || id, code: course.code, name: course.name, key: id }; + registry.set(String(id), basic); + const codeKey = normalizeCourseCode(course.code || ""); + if (codeKey && !registry.has(codeKey)) registry.set(codeKey, basic); }); } + if (classCourses) { classCourses.forEach((cc) => { - const key = cc.course_code.toUpperCase().replace(/[\s\u00A0-]/g, ""); - if (!registry.has(key)) registry.set(key, { code: cc.course_code, id: 0, name: cc.course_name || cc.course_code }); + const norm = normalizeCourseCode(cc.course_code); + if (!listMap.has(norm)) listMap.set(norm, { code: cc.course_code, id: 0, name: cc.course_name || cc.course_code }); + + const codeKey = normalizeCourseCode(cc.course_code); + if (!registry.has(codeKey)) { + registry.set(codeKey, { id: 0, code: cc.course_code, name: cc.course_name || cc.course_code, key: codeKey }); + } }); } - return Array.from(registry.values()); + + return { + courseList: Array.from(listMap.values()), + courseRegistry: Object.fromEntries(registry), + }; }, [coursesData, classCourses]); const { data: allCourseSummaries, isLoading: isLoadingAllCourseSummaries } = useAllCourseDetails(courseList); @@ -370,15 +422,25 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const requestAcademicShift = (direction: "previous" | "next") => { if (!effectiveSemester || !effectiveYear || isUpdating || academicShiftLockRef.current) return; - const nextPeriod = shiftAcademicPeriod(effectiveSemester, effectiveYear, direction); if (!nextPeriod) { toast.error("Could not compute the next academic period."); return; } + // Clamp forward navigation to at most one academic year ahead of current + const currentStart = parseAcademicYearStart(defaultAcademicInfo.current_year) ?? new Date().getFullYear(); + const maxAllowedStart = currentStart + 1; + const nextStart = parseAcademicYearStart(nextPeriod.year); + let clampedNext = nextPeriod; + if (direction === "next" && nextStart !== null && nextStart > maxAllowedStart) { + // clamp to the maximum allowed academic year + clampedNext = { semester: nextPeriod.semester, year: formatAcademicYear(maxAllowedStart) }; + toast.info("Clamped to the nearest future academic period"); + } + academicShiftLockRef.current = true; - setPendingChange(nextPeriod); + setPendingChange(clampedNext); setShowConfirmDialog(true); }; @@ -454,31 +516,12 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const stats = useDashboardStats({ coursesData, attendanceData, trackingData, classCourses, disabledCodes, selectedSemester: effectiveSemester, selectedYear: effectiveYear }); - const courseRegistry = useMemo(() => { - const registry = new Map(); - if (coursesData?.courses) { - Object.entries(coursesData.courses).forEach(([id, course]) => { - const basic: MergedCourse = { id: course.id || id, code: course.code, name: course.name, key: id }; - registry.set(id, basic); - const codeKey = (course.code || "").toUpperCase().replace(/[\s\u00A0-]/g, ""); - if (codeKey && !registry.has(codeKey)) registry.set(codeKey, basic); - }); - } - if (classCourses) { - classCourses.forEach((cc) => { - const codeKey = cc.course_code.toUpperCase().replace(/[\s\u00A0-]/g, ""); - if (!registry.has(codeKey)) { - registry.set(codeKey, { id: 0, code: cc.course_code, name: cc.course_name || cc.course_code, key: codeKey }); - } - }); - } - return Object.fromEntries(registry); - }, [coursesData, classCourses]); + - const sortedCourses = useMemo(() => { + const sortedCourses = useMemo(() => { const seen = new Set(); const unique = Object.values(courseRegistry).filter((c) => { - const key = (c.code || c.key).toUpperCase().replace(/[\s\u00A0-]/g, ""); + const key = normalizeCourseCode(String(c.code || c.key)); if (seen.has(key)) return false; seen.add(key); return true; @@ -488,7 +531,7 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const summariesMap = new Map(allCourseSummaries ? Object.entries(allCourseSummaries) : []); return unique.map((course) => { - const codeKey = (course.code || course.key).toUpperCase().replace(/[\s\u00A0-]/g, ""); + const codeKey = normalizeCourseCode(String(course.code || course.key)); const courseCode = String(course.code || ""); const activeDetails = summariesMap.get(courseCode); const stat = statsMap.get(codeKey) || statsMap.get(course.key) || { present: 0, total: 0, officialPresent: 0, officialTotal: 0 }; @@ -498,15 +541,24 @@ export default function DashboardClient({ initialData, serverError }: DashboardC const safeRes = isNew ? { canBunk: 0 } : calculateAttendance(stat.officialPresent, stat.officialTotal, targetPercentage); return { - ...course, currentPercentage: stat.total > 0 ? Math.round((stat.present / stat.total) * 100) : 0, bunkable: res.canBunk, safeBunkable: safeRes.canBunk, required: res.requiredToAttend, isNew, isDisabled: disabledCodes.has(codeKey), ...stat, activeCourseDetails: activeDetails, name: String(course.name || "") - }; + ...course, + currentPercentage: stat.total > 0 ? Math.round((stat.present / stat.total) * 100) : 0, + bunkable: res.canBunk, + safeBunkable: safeRes.canBunk, + required: res.requiredToAttend, + isNew, + isDisabled: disabledCodes.has(codeKey), + ...stat, + activeCourseDetails: activeDetails, + name: String(course.name || "") + } as DashboardCourse; }).sort((a, b) => { const tA = getSortPriority(a); const tB = getSortPriority(b); if (tA !== tB) return tA - tB; if (tA === 0) { - if (b.bunkable !== a.bunkable) return b.bunkable - a.bunkable; - if (a.required !== b.required) return a.required - b.required; + if ((b.bunkable ?? 0) !== (a.bunkable ?? 0)) return (b.bunkable ?? 0) - (a.bunkable ?? 0); + if ((a.required ?? 0) !== (b.required ?? 0)) return (a.required ?? 0) - (b.required ?? 0); } return a.name.localeCompare(b.name); }); @@ -530,15 +582,30 @@ export default function DashboardClient({ initialData, serverError }: DashboardC return
No data
; }; - if ((!profile && !isLoadingProfile) || !currentSem || !currentYear || !syncCompleted) return ; + if (!profile || isLoadingProfile || !currentSem || !currentYear || !syncSuccess) return ; - const isGlobalLoading = isLoadingProfile || isUpdating || isSettingsLoading || setSemesterMutation.isPending || setAcademicYearMutation.isPending || !syncCompleted; + const isGlobalLoading = isLoadingProfile || isUpdating || isSettingsLoading || setSemesterMutation.isPending || setAcademicYearMutation.isPending || !syncSuccess; return (
{isGlobalLoading && (

Syncing...

)} + {isGlobalLoading && (

Syncing...

)} +
+ {serverError && ( +
+
+
+ {isRateLimitError ? "EzyGo Rate Limit" : "Data prefetch failed"} +
{isRateLimitError ? "Too many requests. Some data may be unavailable." : "Failed to preload dashboard data. You can retry or continue."}
+
+
+ +
+
+
+ )}

Welcome back, {profile?.first_name} {profile?.last_name}!

@@ -563,8 +630,20 @@ export default function DashboardClient({ initialData, serverError }: DashboardC
Academic Term
-
- {effectiveSemester?.toUpperCase()} {effectiveYear} +
+
+ {effectiveSemester?.toUpperCase()} {effectiveYear} +
+ {(() => { + const currentStart = parseAcademicYearStart(defaultAcademicInfo.current_year) ?? new Date().getFullYear(); + const viewedStart = parseAcademicYearStart(String(effectiveYear)) ?? currentStart; + if (viewedStart > currentStart) { + return ( + Future + ); + } + return null; + })()}
@@ -728,7 +881,7 @@ export default function DashboardClient({ initialData, serverError }: DashboardC >
- + {!isDataLoading && ( + + )}
- - } profile={profile ?? null} onEditInstructor={(course: DashboardCourse, _name: string, hasCustomName: boolean, customInstructor?: { instructor_name?: string | null } | undefined | null) => { - const customInst = customInstructor as CustomInstructor | undefined; - setSelectedInstructorCourse({ code: normalizeCourseCode(String(course.code || course.id)), name: String(course.name || ""), initialName: hasCustomName ? (customInst?.instructor_name ?? "") : "" }); - setIsEditInstructorOpen(true); - }} onAddCourse={() => { - if (!profile?.class?.id) { - toast.error("You have not assigned a class yet."); - } else { - setIsAddCourseOpen(true); - } - }} /> - -
- - Attendance Calendar - - {renderAttendanceCalendarContent()} - - -
+ {isDataLoading ? ( +
+ +
+ ) : ( + <> + + } profile={profile ?? null} onEditInstructor={(course: DashboardCourse, _name: string, hasCustomName: boolean, customInstructor?: { instructor_name?: string | null } | undefined | null) => { + const customInst = customInstructor as CustomInstructor | undefined; + setSelectedInstructorCourse({ code: normalizeCourseCode(String(course.code || course.id)), name: String(course.name || ""), initialName: hasCustomName ? (customInst?.instructor_name ?? "") : "" }); + setIsEditInstructorOpen(true); + }} onAddCourse={() => { + if (!profile?.class?.id) { + toast.error("You have not assigned a class yet."); + } else { + setIsAddCourseOpen(true); + } + }} /> + +
+ + Attendance Calendar + + {renderAttendanceCalendarContent()} + + +
+ + )} { expect(screen.getByTestId('edit-instructor-dialog')).toBeInTheDocument(); }); + + it('shows loading message when attendance is loading but profile has loaded', async () => { + vi.mocked(attendanceHooks.useAttendanceReport).mockReturnValue({ + data: null, + isLoading: true, + isFetching: true, + isError: false, + refetch: vi.fn(), + } as any); + + render( + + + + ); + + // Welcome message with name is visible + expect(await screen.findByText(/Welcome back,/i)).toBeInTheDocument(); + expect(screen.getByText(/Test User!/i)).toBeInTheDocument(); + + // Data panels and charts are NOT visible + expect(screen.queryByTestId('stats-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-charts')).not.toBeInTheDocument(); + + // Loader is visible + expect(screen.getByTestId('loading')).toBeInTheDocument(); + }); }); diff --git a/src/app/(protected)/notifications/NotificationsClient.tsx b/src/app/(protected)/notifications/NotificationsClient.tsx index f2d197a7..9ffbfa9d 100644 --- a/src/app/(protected)/notifications/NotificationsClient.tsx +++ b/src/app/(protected)/notifications/NotificationsClient.tsx @@ -115,8 +115,6 @@ export default function NotificationsPage() { }, }); - const syncSuccess = !!(syncSettled && !syncFailed); - const { actionNotifications, regularNotifications, @@ -127,7 +125,7 @@ export default function NotificationsPage() { fetchNextPage, hasNextPage, isFetchingNextPage - } = useNotifications(syncSuccess); + } = useNotifications(true); const [readingId, setReadingId] = useState(null); @@ -136,28 +134,28 @@ export default function NotificationsPage() { const items: VirtualItem[] = []; // 1. ACTION REQUIRED (Unread Conflicts) - const unreadActions = actionNotifications.filter(n => !n.is_read); + const unreadActions = actionNotifications.filter((n: Notification) => !n.is_read); if (unreadActions.length > 0) { items.push({ type: 'header', id: 'action-header', label: 'ACTION REQUIRED' }); - unreadActions.forEach(n => { + unreadActions.forEach((n: Notification) => { items.push({ type: 'notification', id: n.id, data: n }); }); } // 2. UNREAD (Unread Regular) - const unreadRegular = regularNotifications.filter(n => !n.is_read); + const unreadRegular = regularNotifications.filter((n: Notification) => !n.is_read); if (unreadRegular.length > 0) { items.push({ type: 'header', id: 'unread-header', label: 'UNREAD' }); - unreadRegular.forEach(n => { + unreadRegular.forEach((n: Notification) => { items.push({ type: 'notification', id: n.id, data: n }); }); } // 3. EARLIER (All Read Notifications) - const readNotifications = regularNotifications.filter(n => n.is_read); + const readNotifications = regularNotifications.filter((n: Notification) => n.is_read); if (readNotifications.length > 0) { items.push({ type: 'header', id: 'earlier-header', label: 'EARLIER' }); - readNotifications.forEach(n => { + readNotifications.forEach((n: Notification) => { items.push({ type: 'notification', id: n.id, data: n }); }); } @@ -233,8 +231,8 @@ export default function NotificationsPage() { } }, [toggleRead, readingId, rowVirtualizer, user?.id]); - // Block rendering until user is available, data has loaded, and initial sync has completed. - if (!user?.id || isLoading || isSyncing || !syncSuccess) return ; + // Block rendering only on auth/data readiness; sync runs in the background. + if (!user?.id || isLoading) return ; const isEmpty = virtualItems.length === 0; @@ -256,6 +254,12 @@ export default function NotificationsPage() { Syncing
)} + {syncSettled && syncFailed && ( +
+ + Showing cached data +
+ )}
{unreadCount > 0 && ( diff --git a/src/app/(protected)/scores/ScoresClient.tsx b/src/app/(protected)/scores/ScoresClient.tsx index cd26957f..7f9ec060 100644 --- a/src/app/(protected)/scores/ScoresClient.tsx +++ b/src/app/(protected)/scores/ScoresClient.tsx @@ -27,6 +27,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { useQueryClient } from "@tanstack/react-query"; import { Loading } from "@/components/loading"; import { useExams, useExamAnswers, useExamQuestions, useBatchExamDetails } from "@/hooks/courses/exams"; import { useFetchSemester, useFetchAcademicYear } from "@/hooks/users/settings"; @@ -245,7 +246,7 @@ function ScoreCard({ {/* Course */}
- + {getCourseName(exam)}
@@ -466,11 +467,13 @@ function ExamDetailDrawer({ const computedTotal = useMemo(() => { if (!answers || answers.length === 0) return null; // Deduplicate by unique answer ID to prevent inflation from API duplicates - const uniqueAnswers = Array.from(new Map(answers.map((a) => [a.id, a])).values()); - const hasAnyScore = uniqueAnswers.some((a) => a.score != null); + const uniqueAnswers = Array.from( + new Map(answers.map((a: ExamAnswer) => [a.id, a])).values() + ); + const hasAnyScore = uniqueAnswers.some((a: ExamAnswer) => a.score != null); if (!hasAnyScore) return null; - return uniqueAnswers.reduce( - (sum, a) => sum + (a.score != null ? safeParseFloat(a.score) : 0), + return uniqueAnswers.reduce( + (sum: number, a: ExamAnswer) => sum + (a.score != null ? safeParseFloat(a.score) : 0), 0 ); }, [answers]); @@ -496,23 +499,25 @@ function ExamDetailDrawer({ if (!questions || questions.length === 0) return null; // Deduplicate by unique question ID - const uniqueQuestions = Array.from(new Map(questions.map((q) => [q.id, q])).values()); + const uniqueQuestions = Array.from( + new Map(questions.map((q: ExamQuestion) => [q.id, q])).values() + ); // Identify parents to find leaves const parentIds = new Set( uniqueQuestions - .map((q) => q.subquestion_parent_id) + .map((q: ExamQuestion) => q.subquestion_parent_id) .filter((id): id is number => id !== null) ); - const leaves = uniqueQuestions.filter((q) => !parentIds.has(q.id)); + const leaves = uniqueQuestions.filter((q: ExamQuestion) => !parentIds.has(q.id)); // Priority 3: Only sum leaves that have been graded (have a non-null score). // This is the most reliable way to handle flexible papers in EzyGo, // where unattempted optional questions are returned but shouldn't count. const gradedQuestionIds = new Set( - answers?.filter((a) => a.score !== null).map((a) => a.examquestion_id) || [] + answers?.filter((a: ExamAnswer) => a.score !== null).map((a: ExamAnswer) => a.examquestion_id) || [] ); - const gradedLeaves = leaves.filter((q) => gradedQuestionIds.has(q.id)); + const gradedLeaves = leaves.filter((q: ExamQuestion) => gradedQuestionIds.has(q.id)); const targetSet = gradedLeaves.length > 0 ? gradedLeaves : leaves; @@ -747,7 +752,7 @@ function ExamDetailDrawer({ {/* Footer — computed total (only for marked exams) */} - {computedTotal !== null && totalPossible !== null && ( + {computedTotal != null && totalPossible != null && (
@@ -852,7 +857,7 @@ function CourseGroupsSection({ className="h-4 w-4 text-primary shrink-0" aria-hidden="true" /> - + {group.label} {isCourseDisabled( @@ -894,6 +899,7 @@ function CourseGroupsSection({ export default function ScoresClient() { const [filter, setFilter] = useState("all"); const router = useRouter(); + const queryClient = useQueryClient(); const searchParams = useSearchParams(); const pathname = usePathname(); const { data: exams, isLoading: examsLoading, isError, refetch, isFetching } = useExams(); @@ -902,7 +908,7 @@ export default function ScoresClient() { const panel = searchParams.get("panel"); const selectedExam = useMemo(() => { if (!panel) return null; - return exams?.find((e) => e.id.toString() === panel) ?? null; + return exams?.find((e: Exam) => e.id.toString() === panel) ?? null; }, [panel, exams]); const { data: semesterData } = useFetchSemester(); const { data: academicYearData } = useFetchAcademicYear(); @@ -918,13 +924,32 @@ export default function ScoresClient() { const examIds = useMemo( () => exams - ?.filter((e) => e.participants && e.participants.length > 0) - .map((e) => e.id) ?? [], + ?.filter((e: Exam) => e.participants && e.participants.length > 0) + .map((e: Exam) => e.id) ?? [], [exams] ); // Pre-fetch all exam answers in parallel on load via a single batch request. const batchQuery = useBatchExamDetails(examIds); + useEffect(() => { + if (!batchQuery.data) return; + + for (const [idStr, detailsVal] of Object.entries(batchQuery.data)) { + const details = detailsVal as { questions: ExamQuestion[]; answers: ExamAnswer[] }; + const examId = Number.parseInt(idStr, 10); + if (!Number.isFinite(examId)) continue; + + queryClient.setQueryData( + ["exam-answers", examId, semesterData, academicYearData], + details.answers, + ); + queryClient.setQueryData( + ["exam-questions", examId, semesterData, academicYearData], + details.questions, + ); + } + }, [batchQuery.data, queryClient, semesterData, academicYearData]); + // Block render until exams list + batch details have settled. const isLoading = examsLoading || batchQuery.isPending; @@ -936,19 +961,20 @@ export default function ScoresClient() { const resMap = new Map(); if (!batchQuery.data) return resMap; - Object.entries(batchQuery.data).forEach(([idStr, details]) => { + Object.entries(batchQuery.data).forEach(([idStr, detailsVal]) => { + const details = detailsVal as { questions: ExamQuestion[]; answers: ExamAnswer[] }; const id = parseInt(idStr, 10); const answers = details.answers; if (answers && answers.length > 0) { const uniqueAnswers = Array.from( - new Map(answers.map((a) => [a.id, a])).values(), + new Map(answers.map((a: ExamAnswer) => [a.id, a])).values(), ); - const hasAnyScore = uniqueAnswers.some((a) => a.score != null); + const hasAnyScore = uniqueAnswers.some((a: ExamAnswer) => a.score != null); if (hasAnyScore) { resMap.set( id, - uniqueAnswers.reduce( - (sum, a) => sum + (a.score != null ? safeParseFloat(a.score) : 0), + uniqueAnswers.reduce( + (sum: number, a: ExamAnswer) => sum + (a.score != null ? safeParseFloat(a.score) : 0), 0, ), ); @@ -967,9 +993,10 @@ export default function ScoresClient() { const resMap = new Map(); if (!batchQuery.data) return resMap; - Object.entries(batchQuery.data).forEach(([idStr, details]) => { + Object.entries(batchQuery.data).forEach(([idStr, detailsVal]) => { + const details = detailsVal as { questions: ExamQuestion[]; answers: ExamAnswer[] }; const id = parseInt(idStr, 10); - const exam = exams?.find((e) => e.id === id); + const exam = exams?.find((e: Exam) => e.id === id); const qData = details.questions; if (id !== undefined) { @@ -1020,7 +1047,7 @@ export default function ScoresClient() { */ const participatedExams = useMemo(() => { if (!exams) return []; - return exams.filter((e) => { + return exams.filter((e: Exam) => { if (!e.participants || e.participants.length === 0) return false; if (e.activity_type === "assignment") { const details = batchQuery.data?.[e.id]; @@ -1036,7 +1063,7 @@ export default function ScoresClient() { const base = filter === "all" ? participatedExams - : participatedExams.filter((e) => e.activity_type === filter); + : participatedExams.filter((e: Exam) => e.activity_type === filter); // Marked (has resolved score) first, then pending return [...base].sort((a, b) => { const aScored = resolvedScores.has(a.id) || getScore(a) !== null ? 1 : 0; @@ -1048,8 +1075,8 @@ export default function ScoresClient() { const counts: Record = useMemo(() => { return { all: participatedExams.length, - assessment: participatedExams.filter((e) => e.activity_type === "assessment").length, - assignment: participatedExams.filter((e) => e.activity_type === "assignment").length, + assessment: participatedExams.filter((e: Exam) => e.activity_type === "assessment").length, + assignment: participatedExams.filter((e: Exam) => e.activity_type === "assignment").length, }; }, [participatedExams]); diff --git a/src/app/(protected)/tracking/TrackingClient.tsx b/src/app/(protected)/tracking/TrackingClient.tsx index 298ca128..c6fb5310 100644 --- a/src/app/(protected)/tracking/TrackingClient.tsx +++ b/src/app/(protected)/tracking/TrackingClient.tsx @@ -1372,8 +1372,6 @@ export default function TrackingClient() { }, }); - const syncSuccess = !!(syncSettled && !syncFailed); - // --- 1. GROUP AND SORT DATA --- const groupedAllData = useMemo( () => groupAndSortTrackingData(trackingData, semesterData ?? undefined, academicYearData ?? undefined), @@ -1465,9 +1463,8 @@ export default function TrackingClient() { // --- 2. OFFICIAL SESSION LOOKUP MAP --- const officialSessionsMap = useMemo(() => buildOfficialSessionsMap(attendanceData), [attendanceData]); - // Block rendering until tracking is enabled, base data has loaded, and initial sync has completed. - const isInitialLoading = !enabled || isDataLoading || isSyncing || - !syncSuccess; + // Block rendering only on base data readiness; sync runs in the background. + const isInitialLoading = !enabled || isDataLoading; const hasBaseErrors = isTrackingError || isCountError || isCoursesError || isAttendanceError || isSemesterError || isAcademicYearError; @@ -1560,6 +1557,17 @@ export default function TrackingClient() { Syncing with EzyGo... )} + {syncSettled && syncFailed && ( + + + Showing cached data until sync recovers. + + )} {hasCount && ( { expect(res.status).toBe(500); expect(await res.json()).toEqual({ error: "Failed to update profile" }); }); + + it("backgrounds the profile sync when class sem/year match expected academic settings (no conflict)", async () => { + const { calculateCurrentAcademicInfo } = await import("@/lib/logic/academic"); + const expected = calculateCurrentAcademicInfo(); + + mockAdminSelect.mockImplementation(() => ({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { + id: 1, + first_name: "Alice", + auth_id: MOCK_USER.id, + class: { + id: "class-id", + name: "Test Class", + sem: expected.current_semester, + year: expected.current_year + } + }, + error: null, + }), + }), + })); + + const { GET } = await import("../route"); + const req = new NextRequest("http://localhost/api/profile?sync=true"); + const res = await GET(req, { params: {} }); + + expect(res.status).toBe(200); + expect(mockPerformProfileSync).not.toHaveBeenCalled(); }); + + it("performs blocking profile sync synchronously when class sem/year do not match expected settings (conflict)", async () => { + const { calculateCurrentAcademicInfo } = await import("@/lib/logic/academic"); + const expected = calculateCurrentAcademicInfo(); + const wrongSem = expected.current_semester === "even" ? "odd" : "even"; + + mockAdminSelect.mockImplementation(() => ({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { + id: 1, + first_name: "Alice", + auth_id: MOCK_USER.id, + class: { + id: "class-id", + name: "Test Class", + sem: wrongSem, + year: expected.current_year + } + }, + error: null, + }), + }), + })); + + const { GET } = await import("../route"); + const req = new NextRequest("http://localhost/api/profile?sync=true"); + const res = await GET(req, { params: {} }); + + expect(res.status).toBe(200); + expect(mockPerformProfileSync).toHaveBeenCalledOnce(); + }); + + it("performs blocking profile sync synchronously when force=true even when class sem/year match expected settings (no conflict)", async () => { + const { calculateCurrentAcademicInfo } = await import("@/lib/logic/academic"); + const expected = calculateCurrentAcademicInfo(); + + mockAdminSelect.mockImplementation(() => ({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ + data: { + id: 1, + first_name: "Alice", + auth_id: MOCK_USER.id, + class: { + id: "class-id", + name: "Test Class", + sem: expected.current_semester, + year: expected.current_year + } + }, + error: null, + }), + }), + })); + + const { GET } = await import("../route"); + const req = new NextRequest("http://localhost/api/profile?sync=true&force=true"); + const res = await GET(req, { params: {} }); + + expect(res.status).toBe(200); + expect(mockPerformProfileSync).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index b98cf854..c8d3344a 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -13,6 +13,7 @@ import { withSecurity } from "@/lib/security/app-check"; import { performProfileSync } from "@/lib/user/sync"; import { getProfileBundle } from "@/lib/user/profile-bundle"; import { birthDateSchema, genderSchema, optionalPersonNameSchema, personNameSchema } from "@/lib/validation/text"; +import { calculateCurrentAcademicInfo } from "@/lib/logic/academic"; export const dynamic = "force-dynamic"; @@ -141,7 +142,7 @@ async function performSyncAndFetchUser( }; } - const { data: updatedUser } = await supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", userId).single(); + const { data: updatedUser } = await supabaseAdmin.from("users").select("*, class:classes(id, name, sem, year)").eq("auth_id", userId).single(); return { updatedUser: updatedUser ?? existingUser, syncResult, @@ -233,9 +234,9 @@ const getHandler = async (req: NextRequest) => { const user = await authenticateUser(req, supabaseAdmin); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401, headers: { "Cache-Control": "no-store" } }); - const { data: existingUserRaw } = await supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", user.id).maybeSingle(); + const { data: existingUserRaw } = await supabaseAdmin.from("users").select("*, class:classes(id, name, sem, year)").eq("auth_id", user.id).maybeSingle(); const searchParams = req.nextUrl.searchParams; - const shouldSync = searchParams.get("sync") === "true"; + let shouldSync = searchParams.get("sync") === "true"; const force = searchParams.get("force") === "true"; if (existingUserRaw && existingUserRaw.first_name) { const lastSyncedAtStr = existingUserRaw.last_synced_at as string | null | undefined; @@ -243,6 +244,18 @@ const getHandler = async (req: NextRequest) => { const minutesSinceSync = (Date.now() - lastSyncedAt.getTime()) / 60000; const isDebounced = !force && minutesSinceSync < 5; + // Check if semester/year has changed since last sync + const userClass = existingUserRaw.class as { sem?: string; year?: string } | null | undefined; + const expectedAcademic = calculateCurrentAcademicInfo(); + const hasAcademicConflict = !userClass || + userClass.sem !== expectedAcademic.current_semester || + userClass.year !== expectedAcademic.current_year; + + // If there is no conflict/change, we can optimize and do the profile sync in the background (non-blocking) + if (shouldSync && !hasAcademicConflict && !force) { + shouldSync = false; + } + return loadExistingUserBundle(existingUserRaw, user.id, shouldSync, isDebounced, supabaseAdmin); } diff --git a/src/components/attendance/AddRecordTrigger.tsx b/src/components/attendance/AddRecordTrigger.tsx index 9ea3fdac..1c38c2a1 100644 --- a/src/components/attendance/AddRecordTrigger.tsx +++ b/src/components/attendance/AddRecordTrigger.tsx @@ -40,13 +40,13 @@ export function AddRecordTrigger({ user, onSuccess }: AddRecordTriggerProps) { const { data: selectedSemester } = useFetchSemester(); const { data: selectedYear } = useFetchAcademicYear(); - const { data: attendanceData, refetch: refetchAttendance } = useAttendanceReport( + const { data: attendanceData } = useAttendanceReport( selectedSemester ?? undefined, selectedYear ?? undefined, { enabled: isOpen }, ); - const { data: trackingData, refetch: refetchTracking } = useTrackingData(user, { + const { data: trackingData } = useTrackingData(user, { enabled: isOpen }); @@ -62,8 +62,7 @@ export function AddRecordTrigger({ user, onSuccess }: AddRecordTriggerProps) { queryClient.invalidateQueries({ queryKey: ["attendance-report"] }); queryClient.invalidateQueries({ queryKey: ["attendance-report-all"] }); queryClient.invalidateQueries({ queryKey: ["track_data"] }); - - await Promise.all([refetchAttendance(), refetchTracking()]); + await onSuccess(); }; diff --git a/src/hooks/__tests__/use-sync-on-mount.test.tsx b/src/hooks/__tests__/use-sync-on-mount.test.tsx index c5de3fd5..a4495dc1 100644 --- a/src/hooks/__tests__/use-sync-on-mount.test.tsx +++ b/src/hooks/__tests__/use-sync-on-mount.test.tsx @@ -182,6 +182,10 @@ describe("useSyncOnMount", () => { const { unmount } = renderHook(() => useSyncOnMount(defaultOptions)); + await waitFor(() => { + expect(axios.get).toHaveBeenCalled(); + }); + unmount(); // Complete axios after unmount @@ -204,6 +208,10 @@ describe("useSyncOnMount", () => { const { unmount } = renderHook(() => useSyncOnMount(defaultOptions)); + await waitFor(() => { + expect(axios.get).toHaveBeenCalled(); + }); + unmount(); // Fail axios after unmount @@ -214,4 +222,37 @@ describe("useSyncOnMount", () => { // Verify no log error was recorded since the component had unmounted expect(logger.error).not.toHaveBeenCalled(); }); + + it("should defer sync until the load event if document is not fully loaded", async () => { + vi.mocked(axios.get).mockResolvedValue({ + status: 200, + data: { success: true }, + }); + + const originalReadyState = document.readyState; + Object.defineProperty(document, "readyState", { + get() { + return "loading"; + }, + configurable: true, + }); + + const { result } = renderHook(() => useSyncOnMount(defaultOptions)); + + expect(result.current.isSyncing).toBe(false); + expect(axios.get).not.toHaveBeenCalled(); + + window.dispatchEvent(new Event("load")); + + await waitFor(() => { + expect(axios.get).toHaveBeenCalledTimes(1); + }, { timeout: 10000 }); + + Object.defineProperty(document, "readyState", { + get() { + return originalReadyState; + }, + configurable: true, + }); + }); }); diff --git a/src/hooks/courses/courses.ts b/src/hooks/courses/courses.ts index 7e05ed8f..2e12edb6 100644 --- a/src/hooks/courses/courses.ts +++ b/src/hooks/courses/courses.ts @@ -53,7 +53,8 @@ export const useFetchCourses = (options?: { return formattedData; }, - enabled: options?.enabled, + // Default to enabled unless caller explicitly disables. + enabled: options?.enabled !== false, initialData: options?.initialData ?? undefined, staleTime: 10 * 60 * 1000, gcTime: 15 * 60 * 1000, diff --git a/src/hooks/use-sync-on-mount.ts b/src/hooks/use-sync-on-mount.ts index 596221a7..df20553a 100644 --- a/src/hooks/use-sync-on-mount.ts +++ b/src/hooks/use-sync-on-mount.ts @@ -223,9 +223,64 @@ export function useSyncOnMount({ } }; - runSync(); + let deferHandle: number | null = null; + let idleHandle: number | null = null; + + const scheduleSync = () => { + if (typeof window !== "undefined") { + const win = window as unknown as Window & { + requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number; + cancelIdleCallback?: (id: number) => void; + }; + if (win.requestIdleCallback) { + idleHandle = win.requestIdleCallback(() => { + if (!isCleanedUp) runSync(); + }, { timeout: 1000 }); + } else { + deferHandle = setTimeout(() => { + if (!isCleanedUp) runSync(); + }, 200) as unknown as number; + } + } + }; + + if (typeof document !== "undefined") { + if (document.readyState === "complete") { + scheduleSync(); + } else { + const handleLoad = () => { + scheduleSync(); + }; + const win = window as unknown as Window & { + cancelIdleCallback?: (id: number) => void; + }; + win.addEventListener("load", handleLoad); + return () => { + isCleanedUp = true; + win.removeEventListener("load", handleLoad); + if (idleHandle !== null && win.cancelIdleCallback) { + win.cancelIdleCallback(idleHandle); + } + if (deferHandle !== null) { + clearTimeout(deferHandle); + } + }; + } + } + return () => { isCleanedUp = true; + if (typeof window !== "undefined") { + const win = window as unknown as Window & { + cancelIdleCallback?: (id: number) => void; + }; + if (idleHandle !== null && win.cancelIdleCallback) { + win.cancelIdleCallback(idleHandle); + } + if (deferHandle !== null) { + clearTimeout(deferHandle); + } + } }; }, [enabled, username, userId, sentryLocation, sentryTag]); diff --git a/src/hooks/users/settings.ts b/src/hooks/users/settings.ts index bf0674ce..9ef46891 100644 --- a/src/hooks/users/settings.ts +++ b/src/hooks/users/settings.ts @@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import * as Sentry from "@sentry/nextjs"; import { logger } from "@/lib/logger"; import { makeRetryFn } from "@/lib/query-utils"; +import { UserProfile } from "@/types"; type SemesterData = { default_semester: "even" | "odd"; @@ -24,17 +25,90 @@ export type UserSettings = { // Shared retry logic for settings queries — skip all 4xx, retry twice for 5xx/network const settingsRetryFn = makeRetryFn(2); +function extractClassField( + uClass: { sem?: string; year?: string } | null | undefined, + field: "sem" | "year" +): T | null { + if (!uClass) return null; + if (field === "sem" && uClass.sem) return uClass.sem as T; + if (field === "year" && uClass.year) return uClass.year as T; + return null; +} + +async function resolveSettingFromProfileQuery( + queryClient: ReturnType, + field: "sem" | "year", + fallbackApiCall: () => Promise +): Promise { + const cachedProfile = queryClient.getQueryData(["profile"]) || queryClient.getQueryData(["profile", "synced"]); + const userClass = cachedProfile?.class as { sem?: string; year?: string } | null | undefined; + const cachedValue = extractClassField(userClass, field); + if (cachedValue) { + return cachedValue; + } + + const syncedState = queryClient.getQueryState(["profile", "synced"]); + const normalState = queryClient.getQueryState(["profile"]); + const isSyncedPending = syncedState && syncedState.status === "pending"; + const isNormalPending = normalState && normalState.status === "pending"; + + if (isSyncedPending || isNormalPending) { + const targetKey = isSyncedPending ? "synced" : "normal"; + return new Promise((resolve) => { + let isSettled = false; + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + const key = event.query.queryKey; + const matchesKey = targetKey === "synced" + ? (key[0] === "profile" && key[1] === "synced") + : (key[0] === "profile" && key.length === 1); + + if (matchesKey) { + if (event.query.state.status === "success") { + unsubscribe(); + isSettled = true; + const profile = event.query.state.data as UserProfile | null; + const uClass = profile?.class as { sem?: string; year?: string } | null | undefined; + const value = extractClassField(uClass, field); + if (value) { + resolve(value); + } else { + fallbackApiCall().then(resolve).catch(() => resolve(null)); + } + } else if (event.query.state.status === "error") { + unsubscribe(); + isSettled = true; + fallbackApiCall().then(resolve).catch(() => resolve(null)); + } + } + }); + // Safety timeout to prevent hanging if the profile sync fails/hangs + setTimeout(() => { + if (!isSettled) { + unsubscribe(); + fallbackApiCall().then(resolve).catch(() => resolve(null)); + } + }, 30000); + }); + } + + return fallbackApiCall(); +} + export const useFetchSemester = () => { + const queryClient = useQueryClient(); + return useQuery<"even" | "odd" | null>({ queryKey: ["semester"], queryFn: async () => { - try { - const res = await axios.get("/user/setting/default_semester"); - return res.data; - } catch (error: unknown) { - if (isAxiosError(error) && error.response?.status === 404) return null; - throw error; - } + return resolveSettingFromProfileQuery(queryClient, "sem", async () => { + try { + const res = await axios.get("/user/setting/default_semester"); + return res.data; + } catch (error: unknown) { + if (isAxiosError(error) && error.response?.status === 404) return null; + throw error; + } + }); }, retry: settingsRetryFn, staleTime: 1000 * 60 * 5, @@ -43,16 +117,20 @@ export const useFetchSemester = () => { }; export const useFetchAcademicYear = () => { + const queryClient = useQueryClient(); + return useQuery({ queryKey: ["academic-year"], queryFn: async () => { - try { - const res = await axios.get("/user/setting/default_academic_year"); - return res.data; - } catch (error: unknown) { - if (isAxiosError(error) && error.response?.status === 404) return null; - throw error; - } + return resolveSettingFromProfileQuery(queryClient, "year", async () => { + try { + const res = await axios.get("/user/setting/default_academic_year"); + return res.data; + } catch (error: unknown) { + if (isAxiosError(error) && error.response?.status === 404) return null; + throw error; + } + }); }, retry: settingsRetryFn, staleTime: 1000 * 60 * 5, diff --git a/src/lib/user/profile-bundle.ts b/src/lib/user/profile-bundle.ts index 1c7b405b..a938856a 100644 --- a/src/lib/user/profile-bundle.ts +++ b/src/lib/user/profile-bundle.ts @@ -24,7 +24,7 @@ export async function getProfileBundle( // 1. Fetch user and settings in parallel const [userRes, settingsRes] = await Promise.all([ - preFetchedUser ? Promise.resolve({ data: preFetchedUser }) : supabaseAdmin.from("users").select("*, class:classes(id, name)").eq("auth_id", authId).maybeSingle(), + preFetchedUser ? Promise.resolve({ data: preFetchedUser }) : supabaseAdmin.from("users").select("*, class:classes(id, name, sem, year)").eq("auth_id", authId).maybeSingle(), supabaseAdmin.from("user_settings").select("*").eq("user_id", authId).maybeSingle() ]); From b298a630fda349d7df1f842a2e3192bcdc1a2b35 Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Mon, 1 Jun 2026 10:00:56 +0000 Subject: [PATCH 8/9] fix: flutter test --- .../lib/providers/notification_provider.dart | 11 +- package-lock.json | 601 +++++++++--------- 2 files changed, 319 insertions(+), 293 deletions(-) diff --git a/mobile/lib/providers/notification_provider.dart b/mobile/lib/providers/notification_provider.dart index b52873fc..26d52182 100644 --- a/mobile/lib/providers/notification_provider.dart +++ b/mobile/lib/providers/notification_provider.dart @@ -375,8 +375,15 @@ class NotificationsNotifier extends AsyncNotifier { try { final supabase = Supabase.instance.client; - final userId = supabase.auth.currentUser?.id; - if (userId == null) return; + final userId = + ref.read(authProvider).value?.supabaseUserId ?? + supabase.auth.currentUser?.id; + if (userId == null) { + // If there's no authenticated user, treat this as an error so callers/tests + // observing failure semantics can react accordingly instead of silently + // returning and leaving optimistic state applied. + throw Exception('No authenticated user for markAllAsRead'); + } await supabase .from('notification') diff --git a/package-lock.json b/package-lock.json index e472be49..f60ac61a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2532,9 +2532,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -3927,9 +3927,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -3944,9 +3944,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -3961,9 +3961,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -3978,9 +3978,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -3995,9 +3995,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -4012,9 +4012,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -4032,9 +4032,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -4052,9 +4052,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -4072,9 +4072,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -4092,9 +4092,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -4112,9 +4112,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -4132,9 +4132,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -4149,9 +4149,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -4168,9 +4168,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -4185,9 +4185,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -4257,9 +4257,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz", + "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==", "cpu": [ "arm" ], @@ -4270,9 +4270,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz", + "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==", "cpu": [ "arm64" ], @@ -4283,9 +4283,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz", + "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==", "cpu": [ "arm64" ], @@ -4296,9 +4296,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz", + "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==", "cpu": [ "x64" ], @@ -4309,9 +4309,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz", + "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==", "cpu": [ "arm64" ], @@ -4322,9 +4322,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz", + "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==", "cpu": [ "x64" ], @@ -4335,9 +4335,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz", + "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==", "cpu": [ "arm" ], @@ -4351,9 +4351,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz", + "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==", "cpu": [ "arm" ], @@ -4367,9 +4367,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz", + "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==", "cpu": [ "arm64" ], @@ -4383,9 +4383,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz", + "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==", "cpu": [ "arm64" ], @@ -4399,9 +4399,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz", + "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==", "cpu": [ "loong64" ], @@ -4415,9 +4415,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz", + "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==", "cpu": [ "loong64" ], @@ -4431,9 +4431,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz", + "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==", "cpu": [ "ppc64" ], @@ -4447,9 +4447,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz", + "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==", "cpu": [ "ppc64" ], @@ -4463,9 +4463,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz", + "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==", "cpu": [ "riscv64" ], @@ -4479,9 +4479,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz", + "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==", "cpu": [ "riscv64" ], @@ -4495,9 +4495,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz", + "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==", "cpu": [ "s390x" ], @@ -4511,9 +4511,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz", + "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==", "cpu": [ "x64" ], @@ -4527,9 +4527,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz", + "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==", "cpu": [ "x64" ], @@ -4543,9 +4543,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz", + "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==", "cpu": [ "x64" ], @@ -4556,9 +4556,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz", + "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==", "cpu": [ "arm64" ], @@ -4569,9 +4569,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz", + "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==", "cpu": [ "arm64" ], @@ -4582,9 +4582,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz", + "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==", "cpu": [ "ia32" ], @@ -4595,9 +4595,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz", + "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==", "cpu": [ "x64" ], @@ -4608,9 +4608,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz", + "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==", "cpu": [ "x64" ], @@ -5161,6 +5161,21 @@ } } }, + "node_modules/@serwist/build/node_modules/type-fest": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@serwist/build/node_modules/zod": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", @@ -5331,9 +5346,9 @@ } }, "node_modules/@supabase/cli-darwin-arm64": { - "version": "2.102.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.102.0.tgz", - "integrity": "sha512-DzQetP1iOnxAmbarZJLoFr3yMtyDrfOC/T2aNpYEASykFGnvCb87CbWMQhjp9zDdA0pFfDYgzM5yESg7cC/anw==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.103.0.tgz", + "integrity": "sha512-BoOaHyoLuyOtmhxtRti7za3YYH9T05JenEla/zDXikqTiY5yDd63/1RxSF9mXt+ICFMkqHq3oFkFcDP5snumBQ==", "cpu": [ "arm64" ], @@ -5345,9 +5360,9 @@ ] }, "node_modules/@supabase/cli-darwin-x64": { - "version": "2.102.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.102.0.tgz", - "integrity": "sha512-jJxyULNvgBuWxDrjVySwIB76IUlWOf5lYd/2Zz/Lrop8r1sr7aKJFULz7l1A1vk09ImbvnbkM9IBtH8rNdmBUg==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.103.0.tgz", + "integrity": "sha512-SLBb5lfmIF9G+FanWZQvKG6HQeUKFmMv8ls8ZVm6OnO87pLm4aRKLfFYjeXXtiBiRYMZRwxE/RwrRy0uXc0wqw==", "cpu": [ "x64" ], @@ -5359,9 +5374,9 @@ ] }, "node_modules/@supabase/cli-linux-arm64": { - "version": "2.102.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.102.0.tgz", - "integrity": "sha512-vOEdz4mWoqSpygXliGysYsDobtTlZLMhC2KpDsoYio9vQuQPRgaK2kW4fIGPKfagR4Odv3PycgjoNfdsg7TYUQ==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.103.0.tgz", + "integrity": "sha512-nk0fxTbQho+9OoUmIEBlgvEG69GmooMO1D8Ypt7SzgczNcLfYoCoKV9NMkQRUGi5jFXS5xFrRMPeSa/i3tNgwQ==", "cpu": [ "arm64" ], @@ -5376,9 +5391,9 @@ ] }, "node_modules/@supabase/cli-linux-arm64-musl": { - "version": "2.102.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.102.0.tgz", - "integrity": "sha512-VCEJiXK4JhDRXPR32UcIijKdsVT0zg81EoIt6ydmNDJ9vfPI+eFRIzJpeEST1XobTMAVzbJEBw/b/zq5BywR5w==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.103.0.tgz", + "integrity": "sha512-zn2N7t/Mx1LUE/knZvJfJwNHKKvEl5KCqob1fetfPXoPN/GcI0UnfJ+V/bkPjhpPA1b8htVhq4brHMcqpV80mA==", "cpu": [ "arm64" ], @@ -5393,9 +5408,9 @@ ] }, "node_modules/@supabase/cli-linux-x64": { - "version": "2.102.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.102.0.tgz", - "integrity": "sha512-Jcekp6NOWlLuj8vs/ToR11bbqGWhlijs0rmMRyQvDRocWFgeDWX5IBWZwCXZX2xxr7pICJaSdRZx6V8uW2XVEQ==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.103.0.tgz", + "integrity": "sha512-xk9ADh4D+luctRgscBzNvYLZa1qAw067IwateSE1AxhO3GOhdN6fiLZszl5LodO7CHmp54204dj+UcMct3KWFQ==", "cpu": [ "x64" ], @@ -5410,9 +5425,9 @@ ] }, "node_modules/@supabase/cli-linux-x64-musl": { - "version": "2.102.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.102.0.tgz", - "integrity": "sha512-KFzdivlbk8rnMHebxFa//AlQE3uxCWF6ZNIWkYUjE+1EBVXJr/j2Mr1SxkJJyt9h/8jztBUUPJgzuRzIJW0GyA==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.103.0.tgz", + "integrity": "sha512-/fpuslo5ERFWJxbkRcpUFLV8T7087vOVU+clN2OeYTL8lgxVl04FPL3nW0qDgYeehb9GUeje5adgGvHYS006KA==", "cpu": [ "x64" ], @@ -5427,9 +5442,9 @@ ] }, "node_modules/@supabase/cli-windows-arm64": { - "version": "2.102.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.102.0.tgz", - "integrity": "sha512-yY3qbHhMtQFI2TBXbU611UI7b0TaShxGwbunMUwmTUyaCLDTSsxOjxwbpCyfvgY7eDEeYI0WnkbBKHKxknKVDQ==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.103.0.tgz", + "integrity": "sha512-oz1xMJ2bu/lfVV1WaNegGmzoxoEEaiBMg6g2Jm4aB6qCUgoIIkiXClghEnPotd0DWklpqFCOIp1lUb15mnjK6Q==", "cpu": [ "arm64" ], @@ -5441,9 +5456,9 @@ ] }, "node_modules/@supabase/cli-windows-x64": { - "version": "2.102.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.102.0.tgz", - "integrity": "sha512-PpIBXxGRi3bk0Zj2JeDCsr2edDt2qd5j31aaixuxHSsPJh0A052kDEDw4jSOWFHBL1UEai6rGF7zWuX/F3gNiw==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.103.0.tgz", + "integrity": "sha512-jueFJW/QmzWuVis2vGzeSDsrZ3gy5KIsq5vlpmhQF0bWpemgqP7buojPUiHCGSW6+9tTh8za8MraEy7HJGX/wQ==", "cpu": [ "x64" ], @@ -6946,14 +6961,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", - "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -6967,8 +6982,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.7", - "vitest": "4.1.7" + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -6977,16 +6992,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -6995,13 +7010,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -7032,9 +7047,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { @@ -7045,13 +7060,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "pathe": "^2.0.3" }, "funding": { @@ -7059,14 +7074,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -7075,9 +7090,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", "funding": { @@ -7085,13 +7100,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.7.tgz", - "integrity": "sha512-TP6utB2yX6rsJNVRo2qAlsi48i1YwFTrLV2tnTtWqJaYX7m4lRCCLirZBjU6xC5m0RsPHr+L2+N+eIPhgEzFfw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.8.tgz", + "integrity": "sha512-RUS2ZU2TsduVrI+9c12uTNaKrNUTsm6yFt3fueEUB9iKvyC2UP83F+sqIz00HQIah4UOL1TMoDAki8K0NjGvsA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -7103,17 +7118,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.7" + "vitest": "4.1.8" } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -7505,9 +7520,9 @@ "license": "MIT" }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", - "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", + "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", "dev": true, "license": "MIT", "dependencies": { @@ -11893,10 +11908,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -12484,16 +12509,16 @@ "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" }, "node_modules/lint-staged": { - "version": "17.0.6", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.6.tgz", - "integrity": "sha512-xTowloQX5tfs9TC6SUHnKuBSx/TUx+9w39zRTbVrB70DxUJZh3OZWnOa0LbejxVX9adYuioGJoP4dpQ04QHehg==", + "version": "17.0.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", + "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", "dev": true, "license": "MIT", "dependencies": { "listr2": "^10.2.1", "picomatch": "^4.0.4", "string-argv": "^0.3.2", - "tinyexec": "1.2.2" + "tinyexec": "^1.2.4" }, "bin": { "lint-staged": "bin/lint-staged.js" @@ -14796,9 +14821,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.76.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.1.tgz", - "integrity": "sha512-rYM7tPiWlu3nZchkR/ex7piyzui2vFPyaLnXnI/RnblB/L4qfMmyses8llJVtF1NpE9WBBsJlGtcSZzPCXW1qQ==", + "version": "7.77.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.77.0.tgz", + "integrity": "sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -15331,13 +15356,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -15347,30 +15372,30 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "version": "4.61.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz", + "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -15380,40 +15405,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", + "@rollup/rollup-android-arm-eabi": "4.61.0", + "@rollup/rollup-android-arm64": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-darwin-x64": "4.61.0", + "@rollup/rollup-freebsd-arm64": "4.61.0", + "@rollup/rollup-freebsd-x64": "4.61.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.0", + "@rollup/rollup-linux-arm-musleabihf": "4.61.0", + "@rollup/rollup-linux-arm64-gnu": "4.61.0", + "@rollup/rollup-linux-arm64-musl": "4.61.0", + "@rollup/rollup-linux-loong64-gnu": "4.61.0", + "@rollup/rollup-linux-loong64-musl": "4.61.0", + "@rollup/rollup-linux-ppc64-gnu": "4.61.0", + "@rollup/rollup-linux-ppc64-musl": "4.61.0", + "@rollup/rollup-linux-riscv64-gnu": "4.61.0", + "@rollup/rollup-linux-riscv64-musl": "4.61.0", + "@rollup/rollup-linux-s390x-gnu": "4.61.0", + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-linux-x64-musl": "4.61.0", + "@rollup/rollup-openbsd-x64": "4.61.0", + "@rollup/rollup-openharmony-arm64": "4.61.0", + "@rollup/rollup-win32-arm64-msvc": "4.61.0", + "@rollup/rollup-win32-ia32-msvc": "4.61.0", + "@rollup/rollup-win32-x64-gnu": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", "fsevents": "~2.3.2" } }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -16378,23 +16397,23 @@ } }, "node_modules/supabase": { - "version": "2.102.0", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.102.0.tgz", - "integrity": "sha512-PMoWgoQOb1/mZ3Y+e/HIIvWmEfui20cWELXYURrb6s66Swvo1jnkBmRvtCtSA8lan+aHtkgVQAyekocugle2eg==", + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.103.0.tgz", + "integrity": "sha512-LAdBSEzbmJIAf3UojILFL1DF2npwUpqGZ5FNtuqu9pzUdPMHvICyFeQeQCpUpLNrqmee78LqYwiFBpHtBP/TQQ==", "dev": true, "license": "MIT", "bin": { "supabase": "dist/supabase.js" }, "optionalDependencies": { - "@supabase/cli-darwin-arm64": "2.102.0", - "@supabase/cli-darwin-x64": "2.102.0", - "@supabase/cli-linux-arm64": "2.102.0", - "@supabase/cli-linux-arm64-musl": "2.102.0", - "@supabase/cli-linux-x64": "2.102.0", - "@supabase/cli-linux-x64-musl": "2.102.0", - "@supabase/cli-windows-arm64": "2.102.0", - "@supabase/cli-windows-x64": "2.102.0" + "@supabase/cli-darwin-arm64": "2.103.0", + "@supabase/cli-darwin-x64": "2.103.0", + "@supabase/cli-linux-arm64": "2.103.0", + "@supabase/cli-linux-arm64-musl": "2.103.0", + "@supabase/cli-linux-x64": "2.103.0", + "@supabase/cli-linux-x64-musl": "2.103.0", + "@supabase/cli-windows-arm64": "2.103.0", + "@supabase/cli-windows-x64": "2.103.0" } }, "node_modules/supports-color": { @@ -16503,9 +16522,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz", - "integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "license": "MIT", "engines": { "node": ">=18" @@ -16695,9 +16714,9 @@ } }, "node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -17181,17 +17200,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -17274,19 +17293,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -17314,12 +17333,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" From 69f34784f530fb50b1616c9b61d1f46ab2696b1b Mon Sep 17 00:00:00 2001 From: Devanarayanan Date: Mon, 1 Jun 2026 10:04:30 +0000 Subject: [PATCH 9/9] fix(logger): improve JSON serialization for logging payloads fix(circuit-breaker): refine local failure check logic fix(pill): disable tap action for disabled selectable pill --- mobile/lib/widgets/common/pill.dart | 2 +- src/lib/circuit-breaker.ts | 2 +- src/lib/logger.ts | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/mobile/lib/widgets/common/pill.dart b/mobile/lib/widgets/common/pill.dart index bea0ce67..a8126c0f 100644 --- a/mobile/lib/widgets/common/pill.dart +++ b/mobile/lib/widgets/common/pill.dart @@ -62,7 +62,7 @@ class SelectablePill extends StatelessWidget { Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; return InkWell( - onTap: onTap, + onTap: isDisabled ? null : onTap, borderRadius: BorderRadius.circular(16), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), diff --git a/src/lib/circuit-breaker.ts b/src/lib/circuit-breaker.ts index 9b3207ad..b389427d 100644 --- a/src/lib/circuit-breaker.ts +++ b/src/lib/circuit-breaker.ts @@ -308,7 +308,7 @@ class CircuitBreaker { // Optimize: avoid a Redis read on every successful request by checking // the local mirror first. Successful requests are the common case when // the circuit is CLOSED and localFailures will typically be 0. - if (this.localFailures === 0) return; + if (this.localFailures === 0 && this.localLastFailTime === 0) return; // If local mirror indicates failures > 0, read authoritative value // from Redis and reset it if needed. diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 85390053..2a6b9837 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -21,6 +21,14 @@ const isDevelopment = process.env.NODE_ENV === 'development'; // Detect test environment via the VITEST env var (set automatically by Vitest runner). const isTest = process.env.VITEST === "true"; +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, (_key, v) => (typeof v === 'bigint' ? v.toString() : v)); + } catch { + return '"[unserializable]"'; + } +} + function buildStructuredPayload(level: string, args: unknown[]) { const timestamp = new Date().toISOString(); @@ -44,7 +52,7 @@ function buildStructuredPayload(level: string, args: unknown[]) { }; if (message) payload.msg = message; if (meta !== null) payload.meta = meta; - return JSON.stringify(payload); + return safeStringify(payload); } export const logger = {