diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart index 47e609e..fb07352 100644 --- a/lib/core/auth/auth_state_manager.dart +++ b/lib/core/auth/auth_state_manager.dart @@ -1,5 +1,7 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; // Types are used through app_providers.dart import '../providers/app_providers.dart'; import '../models/user.dart'; @@ -7,6 +9,8 @@ import 'token_validator.dart'; import 'auth_cache_manager.dart'; import '../utils/debug_logger.dart'; +part 'auth_state_manager.g.dart'; + /// Comprehensive auth state representation @immutable class AuthState { @@ -78,25 +82,41 @@ enum AuthStatus { } /// Unified auth state manager - single source of truth for all auth operations -class AuthStateManager extends Notifier { +@Riverpod(keepAlive: true) +class AuthStateManager extends _$AuthStateManager { final AuthCacheManager _cacheManager = AuthCacheManager(); - // Prevent overlapping silent-login attempts from multiple triggers Future? _silentLoginFuture; - bool _initialized = false; + + AuthState get _current => + state.asData?.value ?? const AuthState(status: AuthStatus.initial); + + void _set(AuthState next, {bool cache = false}) { + state = AsyncValue.data(next); + if (cache) { + _cacheManager.cacheAuthState(next); + } + } + + void _update( + AuthState Function(AuthState current) transform, { + bool cache = false, + }) { + final next = transform(_current); + _set(next, cache: cache); + } @override - AuthState build() { - if (!_initialized) { - _initialized = true; - Future.microtask(_initialize); - } - - return const AuthState(status: AuthStatus.initial); + Future build() async { + await _initialize(); + return _current; } /// Initialize auth state from storage Future _initialize() async { - state = state.copyWith(status: AuthStatus.loading, isLoading: true); + _update( + (current) => + current.copyWith(status: AuthStatus.loading, isLoading: true), + ); try { final storage = ref.read(optimizedStorageServiceProvider); @@ -107,11 +127,14 @@ class AuthStateManager extends Notifier { // Fast path: trust token format to avoid blocking startup on network final formatOk = _isValidTokenFormat(token); if (formatOk) { - state = state.copyWith( - status: AuthStatus.authenticated, - token: token, - isLoading: false, - clearError: true, + _update( + (current) => current.copyWith( + status: AuthStatus.authenticated, + token: token, + isLoading: false, + clearError: true, + ), + cache: true, ); // Update API service with token and load user data in background @@ -132,27 +155,33 @@ class AuthStateManager extends Notifier { // Token format invalid; clear and require login DebugLogger.auth('Token format invalid, deleting token'); await storage.deleteAuthToken(); - state = state.copyWith( + _update( + (current) => current.copyWith( + status: AuthStatus.unauthenticated, + isLoading: false, + clearToken: true, + clearError: true, + ), + ); + } + } else { + _update( + (current) => current.copyWith( status: AuthStatus.unauthenticated, isLoading: false, clearToken: true, clearError: true, - ); - } - } else { - state = state.copyWith( - status: AuthStatus.unauthenticated, - isLoading: false, - clearToken: true, - clearError: true, + ), ); } } catch (e) { DebugLogger.error('auth-init-failed', scope: 'auth/state', error: e); - state = state.copyWith( - status: AuthStatus.error, - error: 'Failed to initialize auth: $e', - isLoading: false, + _update( + (current) => current.copyWith( + status: AuthStatus.error, + error: 'Failed to initialize auth: $e', + isLoading: false, + ), ); } } @@ -162,10 +191,12 @@ class AuthStateManager extends Notifier { String apiKey, { bool rememberCredentials = false, }) async { - state = state.copyWith( - status: AuthStatus.loading, - isLoading: true, - clearError: true, + _update( + (current) => current.copyWith( + status: AuthStatus.loading, + isLoading: true, + clearError: true, + ), ); try { @@ -216,19 +247,19 @@ class AuthStateManager extends Notifier { } // Update state (without user data initially) - state = state.copyWith( - status: AuthStatus.authenticated, - token: tokenStr, - isLoading: false, - clearError: true, + _update( + (current) => current.copyWith( + status: AuthStatus.authenticated, + token: tokenStr, + isLoading: false, + clearError: true, + ), + cache: true, ); // Update API service with token _updateApiServiceToken(tokenStr); - // Cache the successful auth state - _cacheManager.cacheAuthState(state); - // Load user data in background (consistent with credentials method) _loadUserData(); @@ -240,11 +271,13 @@ class AuthStateManager extends Notifier { } } catch (e) { DebugLogger.error('api-key-login-failed', scope: 'auth/state', error: e); - state = state.copyWith( - status: AuthStatus.error, - error: e.toString(), - isLoading: false, - clearToken: true, + _update( + (current) => current.copyWith( + status: AuthStatus.error, + error: e.toString(), + isLoading: false, + clearToken: true, + ), ); return false; } @@ -256,10 +289,12 @@ class AuthStateManager extends Notifier { String password, { bool rememberCredentials = false, }) async { - state = state.copyWith( - status: AuthStatus.loading, - isLoading: true, - clearError: true, + _update( + (current) => current.copyWith( + status: AuthStatus.loading, + isLoading: true, + clearError: true, + ), ); try { @@ -302,18 +337,18 @@ class AuthStateManager extends Notifier { } // Update state and API service - state = state.copyWith( - status: AuthStatus.authenticated, - token: tokenStr, - isLoading: false, - clearError: true, + _update( + (current) => current.copyWith( + status: AuthStatus.authenticated, + token: tokenStr, + isLoading: false, + clearError: true, + ), + cache: true, ); _updateApiServiceToken(tokenStr); - // Cache the successful auth state - _cacheManager.cacheAuthState(state); - // Load user data in background _loadUserData(); @@ -321,11 +356,13 @@ class AuthStateManager extends Notifier { return true; } catch (e) { DebugLogger.error('login-failed', scope: 'auth/state', error: e); - state = state.copyWith( - status: AuthStatus.error, - error: e.toString(), - isLoading: false, - clearToken: true, + _update( + (current) => current.copyWith( + status: AuthStatus.error, + error: e.toString(), + isLoading: false, + clearToken: true, + ), ); return false; } @@ -361,10 +398,12 @@ class AuthStateManager extends Notifier { } Future _performSilentLogin() async { - state = state.copyWith( - status: AuthStatus.loading, - isLoading: true, - clearError: true, + _update( + (current) => current.copyWith( + status: AuthStatus.loading, + isLoading: true, + clearError: true, + ), ); try { @@ -372,10 +411,12 @@ class AuthStateManager extends Notifier { final savedCredentials = await storage.getSavedCredentials(); if (savedCredentials == null) { - state = state.copyWith( - status: AuthStatus.unauthenticated, - isLoading: false, - clearError: true, + _update( + (current) => current.copyWith( + status: AuthStatus.unauthenticated, + isLoading: false, + clearError: true, + ), ); return false; } @@ -394,11 +435,13 @@ class AuthStateManager extends Notifier { ref.invalidate(serverConfigsProvider); ref.invalidate(activeServerProvider); - state = state.copyWith( - status: AuthStatus.error, - error: - 'Saved server configuration is no longer available. Please reconnect.', - isLoading: false, + _update( + (current) => current.copyWith( + status: AuthStatus.error, + error: + 'Saved server configuration is no longer available. Please reconnect.', + isLoading: false, + ), ); return false; } @@ -411,10 +454,12 @@ class AuthStateManager extends Notifier { final activeServer = await ref.read(activeServerProvider.future); if (activeServer == null) { await storage.setActiveServerId(null); - state = state.copyWith( - status: AuthStatus.error, - error: 'Server configuration not found', - isLoading: false, + _update( + (current) => current.copyWith( + status: AuthStatus.error, + error: 'Server configuration not found', + isLoading: false, + ), ); return false; } @@ -439,11 +484,13 @@ class AuthStateManager extends Notifier { await storage.deleteSavedCredentials(); } - state = state.copyWith( - status: AuthStatus.unauthenticated, - error: e.toString(), - isLoading: false, - clearToken: true, + _update( + (current) => current.copyWith( + status: AuthStatus.unauthenticated, + error: e.toString(), + isLoading: false, + clearToken: true, + ), ); return false; } @@ -463,11 +510,13 @@ class AuthStateManager extends Notifier { _updateApiServiceToken(null); // Update state - state = state.copyWith( - status: AuthStatus.tokenExpired, - clearToken: true, - clearUser: true, - clearError: true, + _update( + (current) => current.copyWith( + status: AuthStatus.tokenExpired, + clearToken: true, + clearUser: true, + clearError: true, + ), ); // Attempt silent re-login if credentials are available @@ -482,7 +531,10 @@ class AuthStateManager extends Notifier { /// Logout user Future logout() async { - state = state.copyWith(status: AuthStatus.loading, isLoading: true); + _update( + (current) => + current.copyWith(status: AuthStatus.loading, isLoading: true), + ); try { // Call server logout if possible @@ -505,24 +557,28 @@ class AuthStateManager extends Notifier { _updateApiServiceToken(null); // Update state - state = state.copyWith( - status: AuthStatus.unauthenticated, - isLoading: false, - clearToken: true, - clearUser: true, - clearError: true, + _update( + (current) => current.copyWith( + status: AuthStatus.unauthenticated, + isLoading: false, + clearToken: true, + clearUser: true, + clearError: true, + ), ); DebugLogger.auth('Logout complete'); } catch (e) { DebugLogger.error('logout-failed', scope: 'auth/state', error: e); // Even if logout fails, clear local state - state = state.copyWith( - status: AuthStatus.unauthenticated, - isLoading: false, - clearToken: true, - clearUser: true, - error: 'Logout error: $e', + _update( + (current) => current.copyWith( + status: AuthStatus.unauthenticated, + isLoading: false, + clearToken: true, + clearUser: true, + error: 'Logout error: $e', + ), ); _updateApiServiceToken(null); } @@ -532,13 +588,14 @@ class AuthStateManager extends Notifier { Future _loadUserData() async { try { // First try to extract user info from JWT token if available - if (state.token != null) { - final jwtUserInfo = TokenValidator.extractUserInfo(state.token!); + final current = _current; + if (current.token != null) { + final jwtUserInfo = TokenValidator.extractUserInfo(current.token!); if (jwtUserInfo != null) { final userFromJwt = _userFromJwtClaims(jwtUserInfo); if (userFromJwt != null) { DebugLogger.auth('Extracted user info from JWT token'); - state = state.copyWith(user: userFromJwt); + _update((current) => current.copyWith(user: userFromJwt)); } // Still try to load from server in background for complete data @@ -563,15 +620,16 @@ class AuthStateManager extends Notifier { Future _loadServerUserData() async { try { final api = ref.read(apiServiceProvider); - if (api != null && state.isAuthenticated) { + final current = _current; + if (api != null && current.isAuthenticated) { // Check if we already have user data from token validation - if (state.user != null) { + if (current.user != null) { DebugLogger.auth('user-data-present-from-token', scope: 'auth/state'); return; } final user = await api.getCurrentUser(); - state = state.copyWith(user: user); + _update((current) => current.copyWith(user: user)); DebugLogger.auth('Loaded complete user data from server'); } } catch (e) { @@ -647,8 +705,8 @@ class AuthStateManager extends Notifier { // Store the user data if validation was successful if (serverResult.isValid && validationUser != null && - state.isAuthenticated) { - state = state.copyWith(user: validationUser); + _current.isAuthenticated) { + _update((current) => current.copyWith(user: validationUser)); DebugLogger.auth('Cached user data from token validation'); } @@ -754,31 +812,3 @@ class AuthStateManager extends Notifier { extension _StringFallbackExtension on String { String ifEmptyReturn(String fallback) => isEmpty ? fallback : this; } - -/// Provider for the unified auth state manager -final authStateManagerProvider = NotifierProvider( - AuthStateManager.new, -); - -/// Computed providers for common auth state queries -final isAuthenticatedProvider = Provider((ref) { - return ref.watch( - authStateManagerProvider.select((state) => state.isAuthenticated), - ); -}); - -final authTokenProvider2 = Provider((ref) { - return ref.watch(authStateManagerProvider.select((state) => state.token)); -}); - -final authUserProvider = Provider((ref) { - return ref.watch(authStateManagerProvider.select((state) => state.user)); -}); - -final authErrorProvider2 = Provider((ref) { - return ref.watch(authStateManagerProvider.select((state) => state.error)); -}); - -final isAuthLoadingProvider = Provider((ref) { - return ref.watch(authStateManagerProvider.select((state) => state.isLoading)); -}); diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index d0fbf65..356b77a 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../services/storage_service.dart'; // (removed duplicate) import '../services/optimized_storage_service.dart'; @@ -22,6 +25,8 @@ import '../services/optimized_storage_service.dart'; import '../services/socket_service.dart'; import '../utils/debug_logger.dart'; +part 'app_providers.g.dart'; + // Storage providers final sharedPreferencesProvider = Provider((ref) { throw UnimplementedError(); @@ -189,45 +194,171 @@ final apiServiceProvider = Provider((ref) { }); // Socket.IO service provider -final socketServiceProvider = Provider((ref) { - final reviewerMode = ref.watch(reviewerModeProvider); - if (reviewerMode) return null; +@Riverpod(keepAlive: true) +class SocketServiceManager extends _$SocketServiceManager { + SocketService? _service; + ProviderSubscription? _tokenSubscription; - final activeServer = ref.watch(activeServerProvider); - final token = ref.watch(authTokenProvider3.select((t) => t)); - final transportMode = ref.watch( - appSettingsProvider.select((s) => s.socketTransportMode), - ); + @override + FutureOr build() async { + final reviewerMode = ref.watch(reviewerModeProvider); + if (reviewerMode) { + _disposeService(); + return null; + } - return activeServer.maybeWhen( - data: (server) { - if (server == null) return null; - final s = SocketService( + final server = await ref.watch(activeServerProvider.future); + if (server == null) { + _disposeService(); + return null; + } + + final transportMode = ref.watch( + appSettingsProvider.select((settings) => settings.socketTransportMode), + ); + final websocketOnly = transportMode == 'ws'; + final token = ref.watch(authTokenProvider3); + + final requiresNewService = + _service == null || + _service!.serverConfig.id != server.id || + _service!.websocketOnly != websocketOnly; + if (requiresNewService) { + _disposeService(); + _service = SocketService( serverConfig: server, authToken: token, - websocketOnly: transportMode == 'ws', + websocketOnly: websocketOnly, ); - // best-effort connect, but defer to post-frame with a small delay - WidgetsBinding.instance.addPostFrameCallback((_) async { - await Future.delayed(const Duration(milliseconds: 150)); - // ignore: discarded_futures - s.connect(); - }); - // Keep socket token up-to-date without reconstructing the service - ref.listen(authTokenProvider3, (prev, next) { - s.updateAuthToken(next); - }); - ref.onDispose(() { - try { - s.dispose(); - } catch (_) {} - }); - return s; - }, - orElse: () => null, - ); + _scheduleConnect(_service!); + } else { + _service!.updateAuthToken(token); + } + + _tokenSubscription ??= ref.listen(authTokenProvider3, ( + previous, + next, + ) { + _service?.updateAuthToken(next); + }); + + ref.onDispose(() { + _tokenSubscription?.close(); + _tokenSubscription = null; + _disposeService(); + }); + + return _service; + } + + void _scheduleConnect(SocketService service) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.delayed(const Duration(milliseconds: 150)); + if (!ref.mounted) return; + try { + unawaited(service.connect()); + } catch (_) {} + }); + } + + void _disposeService() { + if (_service == null) return; + try { + _service!.dispose(); + } catch (_) {} + _service = null; + } +} + +final socketServiceProvider = Provider((ref) { + final asyncService = ref.watch(socketServiceManagerProvider); + return asyncService.maybeWhen(data: (service) => service, orElse: () => null); }); +enum SocketConnectionState { disconnected, connecting, connected } + +@Riverpod(keepAlive: true) +class SocketConnectionStream extends _$SocketConnectionStream { + StreamController? _controller; + ProviderSubscription>? _serviceSubscription; + void Function()? _cancelConnectListener; + void Function()? _cancelDisconnectListener; + + @override + Stream build() { + final controller = StreamController.broadcast(); + _controller = controller; + + void emitState(SocketService? service) { + if (service == null) { + controller.add(SocketConnectionState.disconnected); + _unbindSocket(); + return; + } + controller.add( + service.isConnected + ? SocketConnectionState.connected + : SocketConnectionState.connecting, + ); + _bindSocket(service); + } + + emitState( + ref + .watch(socketServiceManagerProvider) + .maybeWhen(data: (service) => service, orElse: () => null), + ); + + _serviceSubscription = ref.listen>( + socketServiceManagerProvider, + (previous, next) { + emitState( + next.maybeWhen(data: (service) => service, orElse: () => null), + ); + }, + ); + + ref.onDispose(() { + _serviceSubscription?.close(); + _serviceSubscription = null; + _unbindSocket(); + _controller?.close(); + _controller = null; + }); + + return controller.stream; + } + + void _bindSocket(SocketService service) { + _unbindSocket(); + + void handleConnect(dynamic _) { + _controller?.add(SocketConnectionState.connected); + } + + void handleDisconnect(dynamic _) { + _controller?.add(SocketConnectionState.disconnected); + } + + service.socket?.on('connect', handleConnect); + service.socket?.on('disconnect', handleDisconnect); + + _cancelConnectListener = () { + service.socket?.off('connect', handleConnect); + }; + _cancelDisconnectListener = () { + service.socket?.off('disconnect', handleDisconnect); + }; + } + + void _unbindSocket() { + _cancelConnectListener?.call(); + _cancelDisconnectListener?.call(); + _cancelConnectListener = null; + _cancelDisconnectListener = null; + } +} + // Attachment upload queue provider final attachmentUploadQueueProvider = Provider((ref) { final api = ref.watch(apiServiceProvider); diff --git a/lib/core/providers/app_startup_providers.dart b/lib/core/providers/app_startup_providers.dart index e1949a3..16a15cb 100644 --- a/lib/core/providers/app_startup_providers.dart +++ b/lib/core/providers/app_startup_providers.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../providers/app_providers.dart'; import '../../features/auth/providers/unified_auth_providers.dart'; @@ -16,6 +17,8 @@ import '../services/connectivity_service.dart'; import '../utils/debug_logger.dart'; import '../models/server_config.dart'; +part 'app_startup_providers.g.dart'; + enum _ConversationWarmupStatus { idle, warming, complete } final _conversationWarmupStatusProvider = @@ -116,149 +119,174 @@ void _scheduleConversationWarmup(Ref ref, {bool force = false}) { /// App-level startup/background task flow orchestrator. /// -/// Moves background initialization out of widgets and into a Riverpod provider, -/// keeping UI lean and business logic centralized. -final appStartupFlowProvider = Provider((ref) { - // Ensure token integration listeners are active - ref.watch(authApiIntegrationProvider); - ref.watch(apiTokenUpdaterProvider); - ref.watch(silentLoginCoordinatorProvider); +/// Moves background initialization out of widgets and into a Riverpod controller, +/// keeping UI lean and business logic centralized while avoiding side effects +/// during provider build. +@Riverpod(keepAlive: true) +class AppStartupFlow extends _$AppStartupFlow { + bool _started = false; - // Kick background model loading flow (non-blocking) - ref.watch(backgroundModelLoadProvider); + @override + FutureOr build() {} - // If authenticated, keep socket service alive and connected - final navState = ref.watch(authNavigationStateProvider); - if (navState == AuthNavigationState.authenticated) { - ref.watch(socketServiceProvider); + void start() { + if (_started) return; + _started = true; + state = const AsyncValue.data(null); + _activate(); } - // Ensure resume-triggered foreground refresh is active - ref.watch(foregroundRefreshProvider); + void _activate() { + final ref = this.ref; - // Keep Socket.IO connection alive in background within platform limits - ref.watch(socketPersistenceProvider); + // Ensure token integration listeners are active + ref.watch(authApiIntegrationProvider); + ref.watch(apiTokenUpdaterProvider); + ref.watch(silentLoginCoordinatorProvider); - // Ensure persistent streaming uses the shared connectivity service - final connectivityService = ref.watch(connectivityServiceProvider); - PersistentStreamingService().attachConnectivityService(connectivityService); + // Kick background model loading flow (non-blocking) + ref.watch(backgroundModelLoadProvider); - // Warm the conversations list in the background as soon as possible, - // but avoid doing so on poor connectivity to reduce startup load. - // Apply a small randomized delay to smooth load spikes across app wakes. - Future.microtask(() async { - final online = ref.read(isOnlineProvider); - if (!online) return; - // Slightly increase jitter to reduce contention on startup - final jitter = Duration( - milliseconds: 150 + (DateTime.now().millisecond % 200), - ); - // Defer until after first frame to keep first paint smooth - WidgetsBinding.instance.addPostFrameCallback((_) async { - await Future.delayed(jitter); - _scheduleConversationWarmup(ref); - }); - }); + // If authenticated, keep socket service alive and connected + final navState = ref.watch(authNavigationStateProvider); + if (navState == AuthNavigationState.authenticated) { + ref.watch(socketServiceProvider); + } - // One-time, post-frame system UI polish: set status bar icon brightness to - // match theme after the first frame. Avoids flicker at startup. - WidgetsBinding.instance.addPostFrameCallback((_) { - try { - final context = NavigationService.context; - final view = context != null ? View.maybeOf(context) : null; - final dispatcher = WidgetsBinding.instance.platformDispatcher; - final platformBrightness = - view?.platformDispatcher.platformBrightness ?? - dispatcher.platformBrightness; - final isDark = platformBrightness == Brightness.dark; - SystemChrome.setSystemUIOverlayStyle( - SystemUiOverlayStyle( - statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark, - systemNavigationBarIconBrightness: isDark - ? Brightness.light - : Brightness.dark, - ), + // Ensure resume-triggered foreground refresh is active + ref.watch(foregroundRefreshProvider); + + // Keep Socket.IO connection alive in background within platform limits + ref.watch(socketPersistenceProvider); + ref.watch(socketConnectionStreamProvider); + + // Ensure persistent streaming uses the shared connectivity service + final connectivityService = ref.watch(connectivityServiceProvider); + PersistentStreamingService().attachConnectivityService(connectivityService); + + // Warm the conversations list in the background as soon as possible, + // but avoid doing so on poor connectivity to reduce startup load. + // Apply a small randomized delay to smooth load spikes across app wakes. + Future.microtask(() async { + final online = ref.read(isOnlineProvider); + if (!online) return; + // Slightly increase jitter to reduce contention on startup + final jitter = Duration( + milliseconds: 150 + (DateTime.now().millisecond % 200), ); - } catch (_) {} - }); - - // Watch for auth transitions to trigger warmup and other background work - ref.listen(authNavigationStateProvider, (prev, next) { - if (next == AuthNavigationState.authenticated) { - // Schedule microtask so we don't perform side-effects inside build - Future.microtask(() async { - try { - final api = ref.read(apiServiceProvider); - if (api == null) { - DebugLogger.warning('API service not available for startup flow'); - return; - } - - // Ensure API has the latest token immediately - final authToken = ref.read(authTokenProvider3); - if (authToken != null && authToken.isNotEmpty) { - api.updateAuthToken(authToken); - DebugLogger.auth('StartupFlow: Applied auth token to API'); - } - - // Preload default model in background (best-effort) with an adaptive - // delay based on network latency to avoid hammering poor networks. - final latency = ref.read(connectivityServiceProvider).lastLatencyMs; - final delayMs = latency < 0 - ? 300 - : latency > 800 - ? 600 - : 200 + (latency ~/ 2); - Future.delayed(Duration(milliseconds: delayMs), () async { - try { - await ref.read(defaultModelProvider.future); - } catch (e) { - DebugLogger.warning( - 'model-preload-failed', - scope: 'startup', - data: {'error': e}, - ); - } - }); - - // Kick background chat warmup now that we're authenticated - _scheduleConversationWarmup(ref, force: true); - - // Show onboarding once when user reaches chat and hasn't seen it yet - await _maybeShowOnboarding(ref); - } catch (e) { - DebugLogger.error('startup-flow-failed', scope: 'startup', error: e); - } + // Defer until after first frame to keep first paint smooth + WidgetsBinding.instance.addPostFrameCallback((_) async { + await Future.delayed(jitter); + _scheduleConversationWarmup(ref); }); - } else { - // Reset warmup state when leaving authenticated flow - ref - .read(_conversationWarmupStatusProvider.notifier) - .set(_ConversationWarmupStatus.idle); - } - }); + }); - // Retry warmup when connectivity is restored - ref.listen(isOnlineProvider, (prev, next) { - if (next == true) { - _scheduleConversationWarmup(ref); - } - }); + // One-time, post-frame system UI polish: set status bar icon brightness to + // match theme after the first frame. Avoids flicker at startup. + WidgetsBinding.instance.addPostFrameCallback((_) { + try { + final context = NavigationService.context; + final view = context != null ? View.maybeOf(context) : null; + final dispatcher = WidgetsBinding.instance.platformDispatcher; + final platformBrightness = + view?.platformDispatcher.platformBrightness ?? + dispatcher.platformBrightness; + final isDark = platformBrightness == Brightness.dark; + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + statusBarIconBrightness: isDark + ? Brightness.light + : Brightness.dark, + systemNavigationBarIconBrightness: isDark + ? Brightness.light + : Brightness.dark, + ), + ); + } catch (_) {} + }); - // When conversations reload (e.g., manual refresh), ensure warmup runs again - ref.listen>>(conversationsProvider, ( - previous, - next, - ) { - final wasReady = previous?.hasValue == true || previous?.hasError == true; - if (wasReady && next.isLoading) { - ref - .read(_conversationWarmupStatusProvider.notifier) - .set(_ConversationWarmupStatus.idle); - Future.microtask(() => _scheduleConversationWarmup(ref, force: true)); - } - }); -}); + // Watch for auth transitions to trigger warmup and other background work + ref.listen(authNavigationStateProvider, (prev, next) { + if (next == AuthNavigationState.authenticated) { + // Schedule microtask so we don't perform side-effects inside build + Future.microtask(() async { + try { + final api = ref.read(apiServiceProvider); + if (api == null) { + DebugLogger.warning('API service not available for startup flow'); + return; + } + + // Ensure API has the latest token immediately + final authToken = ref.read(authTokenProvider3); + if (authToken != null && authToken.isNotEmpty) { + api.updateAuthToken(authToken); + DebugLogger.auth('StartupFlow: Applied auth token to API'); + } + + // Preload default model in background (best-effort) with an adaptive + // delay based on network latency to avoid hammering poor networks. + final latency = ref.read(connectivityServiceProvider).lastLatencyMs; + final delayMs = latency < 0 + ? 300 + : latency > 800 + ? 600 + : 200 + (latency ~/ 2); + Future.delayed(Duration(milliseconds: delayMs), () async { + try { + await ref.read(defaultModelProvider.future); + } catch (e) { + DebugLogger.warning( + 'model-preload-failed', + scope: 'startup', + data: {'error': e}, + ); + } + }); + + // Kick background chat warmup now that we're authenticated + _scheduleConversationWarmup(ref, force: true); + + // Show onboarding once when user reaches chat and hasn't seen it yet + await _maybeShowOnboarding(ref); + } catch (e) { + DebugLogger.error( + 'startup-flow-failed', + scope: 'startup', + error: e, + ); + } + }); + } else { + // Reset warmup state when leaving authenticated flow + ref + .read(_conversationWarmupStatusProvider.notifier) + .set(_ConversationWarmupStatus.idle); + } + }); + + // Retry warmup when connectivity is restored + ref.listen(isOnlineProvider, (prev, next) { + if (next == true) { + _scheduleConversationWarmup(ref); + } + }); + + // When conversations reload (e.g., manual refresh), ensure warmup runs again + ref.listen>>(conversationsProvider, ( + previous, + next, + ) { + final wasReady = previous?.hasValue == true || previous?.hasError == true; + if (wasReady && next.isLoading) { + ref + .read(_conversationWarmupStatusProvider.notifier) + .set(_ConversationWarmupStatus.idle); + Future.microtask(() => _scheduleConversationWarmup(ref, force: true)); + } + }); + } +} // Tracks whether we've already attempted a silent login for the current app session. final _silentLoginAttemptedProvider = diff --git a/lib/features/auth/providers/unified_auth_providers.dart b/lib/features/auth/providers/unified_auth_providers.dart index 432274f..ce8c501 100644 --- a/lib/features/auth/providers/unified_auth_providers.dart +++ b/lib/features/auth/providers/unified_auth_providers.dart @@ -18,13 +18,10 @@ class AuthActions { String password, { bool rememberCredentials = false, }) { - // Defer mutation to a microtask to avoid provider-build side-effects - return Future( - () => _auth.login( - username, - password, - rememberCredentials: rememberCredentials, - ), + return _auth.login( + username, + password, + rememberCredentials: rememberCredentials, ); } @@ -32,24 +29,22 @@ class AuthActions { String apiKey, { bool rememberCredentials = false, }) { - return Future( - () => _auth.loginWithApiKey( - apiKey, - rememberCredentials: rememberCredentials, - ), + return _auth.loginWithApiKey( + apiKey, + rememberCredentials: rememberCredentials, ); } Future silentLogin() { - return Future(() => _auth.silentLogin()); + return _auth.silentLogin(); } Future logout() { - return Future(() => _auth.logout()); + return _auth.logout(); } Future refresh() { - return Future(() => _auth.refresh()); + return _auth.refresh(); } } @@ -67,29 +62,43 @@ final hasSavedCredentialsProvider2 = FutureProvider((ref) async { /// These automatically update when auth state changes final isAuthenticatedProvider2 = Provider((ref) { - return ref.watch( - authStateManagerProvider.select((state) => state.isAuthenticated), + final authState = ref.watch(authStateManagerProvider); + return authState.maybeWhen( + data: (state) => state.isAuthenticated, + orElse: () => false, ); }); final authTokenProvider3 = Provider((ref) { - return ref.watch(authStateManagerProvider.select((state) => state.token)); + final authState = ref.watch(authStateManagerProvider); + return authState.maybeWhen(data: (state) => state.token, orElse: () => null); }); final currentUserProvider2 = Provider((ref) { - return ref.watch(authStateManagerProvider.select((state) => state.user)); + final authState = ref.watch(authStateManagerProvider); + return authState.maybeWhen(data: (state) => state.user, orElse: () => null); }); final authErrorProvider3 = Provider((ref) { - return ref.watch(authStateManagerProvider.select((state) => state.error)); + final authState = ref.watch(authStateManagerProvider); + return authState.maybeWhen(data: (state) => state.error, orElse: () => null); }); final isAuthLoadingProvider2 = Provider((ref) { - return ref.watch(authStateManagerProvider.select((state) => state.isLoading)); + final authState = ref.watch(authStateManagerProvider); + if (authState.isLoading) return true; + return authState.maybeWhen( + data: (state) => state.isLoading, + orElse: () => false, + ); }); final authStatusProvider = Provider((ref) { - return ref.watch(authStateManagerProvider.select((state) => state.status)); + final authState = ref.watch(authStateManagerProvider); + return authState.maybeWhen( + data: (state) => state.status, + orElse: () => AuthStatus.loading, + ); }); // Use `ref.read(authActionsProvider).refresh()` instead of refresh providers @@ -107,19 +116,24 @@ final authApiIntegrationProvider = Provider((ref) { /// Navigation helper provider - determines where user should go final authNavigationStateProvider = Provider((ref) { final authState = ref.watch(authStateManagerProvider); - - switch (authState.status) { - case AuthStatus.initial: - case AuthStatus.loading: - return AuthNavigationState.loading; - case AuthStatus.authenticated: - return AuthNavigationState.authenticated; - case AuthStatus.unauthenticated: - case AuthStatus.tokenExpired: - return AuthNavigationState.needsLogin; - case AuthStatus.error: - return AuthNavigationState.error; - } + return authState.when( + data: (state) { + switch (state.status) { + case AuthStatus.initial: + case AuthStatus.loading: + return AuthNavigationState.loading; + case AuthStatus.authenticated: + return AuthNavigationState.authenticated; + case AuthStatus.unauthenticated: + case AuthStatus.tokenExpired: + return AuthNavigationState.needsLogin; + case AuthStatus.error: + return AuthNavigationState.error; + } + }, + loading: () => AuthNavigationState.loading, + error: (_, stack) => AuthNavigationState.error, + ); }); enum AuthNavigationState { loading, authenticated, needsLogin, error } diff --git a/lib/features/auth/views/authentication_page.dart b/lib/features/auth/views/authentication_page.dart index ccf968a..0b85cc3 100644 --- a/lib/features/auth/views/authentication_page.dart +++ b/lib/features/auth/views/authentication_page.dart @@ -124,10 +124,15 @@ class _AuthenticationPageState extends ConsumerState { @override Widget build(BuildContext context) { // Listen for auth state changes to navigate on successful login - ref.listen(authStateManagerProvider, (previous, next) { + ref.listen>(authStateManagerProvider, ( + previous, + next, + ) { + final nextState = next.asData?.value; + final prevState = previous?.asData?.value; if (mounted && - next.isAuthenticated && - previous?.isAuthenticated != true) { + nextState?.isAuthenticated == true && + prevState?.isAuthenticated != true) { DebugLogger.auth( 'Authentication successful, initializing background resources', ); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 752de0b..61dae92 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -10,11 +10,11 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'dart:io' show Platform; import 'dart:async'; import '../../../core/providers/app_providers.dart'; -import '../../../core/auth/auth_state_manager.dart'; import '../providers/chat_providers.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/user_display_name.dart'; import '../../../core/utils/model_icon_utils.dart'; +import '../../auth/providers/unified_auth_providers.dart'; import '../widgets/modern_chat_input.dart'; import '../widgets/user_message_bubble.dart'; @@ -901,7 +901,7 @@ class _ChatPageState extends ConsumerState { data: (user) => user, orElse: () => null, ); - final authUser = ref.watch(authUserProvider); + final authUser = ref.watch(currentUserProvider2); final user = userFromProfile ?? authUser; final greetingName = deriveUserDisplayName(user); return LayoutBuilder( diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index cd605fe..1ab25bf 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -14,10 +14,10 @@ import '../../chat/providers/chat_providers.dart' as chat; import '../../../shared/utils/ui_utils.dart'; import '../../../core/services/navigation_service.dart'; import '../../../shared/widgets/themed_dialogs.dart'; -import '../../../core/auth/auth_state_manager.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/utils/user_display_name.dart'; import '../../../core/utils/model_icon_utils.dart'; +import '../../auth/providers/unified_auth_providers.dart'; import '../../../core/utils/user_avatar_utils.dart'; import '../../../shared/utils/conversation_context_menu.dart'; import '../../../shared/widgets/user_avatar.dart'; @@ -1218,7 +1218,7 @@ class _ChatsDrawerState extends ConsumerState { data: (u) => u, orElse: () => null, ); - final authUser = ref.watch(authUserProvider); + final authUser = ref.watch(currentUserProvider2); final user = userFromProfile ?? authUser; final api = ref.watch(apiServiceProvider); diff --git a/lib/features/onboarding/views/onboarding_sheet.dart b/lib/features/onboarding/views/onboarding_sheet.dart index bbd6f6a..b25d091 100644 --- a/lib/features/onboarding/views/onboarding_sheet.dart +++ b/lib/features/onboarding/views/onboarding_sheet.dart @@ -4,11 +4,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:conduit/l10n/app_localizations.dart'; -import '../../../core/auth/auth_state_manager.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/utils/user_display_name.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/widgets/sheet_handle.dart'; +import '../../auth/providers/unified_auth_providers.dart'; class OnboardingSheet extends ConsumerStatefulWidget { const OnboardingSheet({super.key}); @@ -73,7 +73,7 @@ class _OnboardingSheetState extends ConsumerState { data: (user) => user, orElse: () => null, ); - final authUser = ref.watch(authUserProvider); + final authUser = ref.watch(currentUserProvider2); final user = userFromProfile ?? authUser; final greetingName = deriveUserDisplayName(user); final pages = _buildPages(l10n, greetingName); diff --git a/lib/main.dart b/lib/main.dart index 5126cca..abd13ea 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -116,19 +116,12 @@ class ConduitApp extends ConsumerStatefulWidget { } class _ConduitAppState extends ConsumerState { - ProviderSubscription? _startupFlowSubscription; Brightness? _lastAppliedOverlayBrightness; @override void initState() { super.initState(); // Defer heavy provider initialization to after first frame to render UI sooner WidgetsBinding.instance.addPostFrameCallback((_) => _initializeAppState()); - - // Activate app startup flow without tying it to root widget rebuilds - _startupFlowSubscription = ref.listenManual( - appStartupFlowProvider, - (previous, next) {}, - ); } void _initializeAppState() { @@ -138,11 +131,11 @@ class _ConduitAppState extends ConsumerState { ref.read(authApiIntegrationProvider); ref.read(defaultModelAutoSelectionProvider); ref.read(shareReceiverInitializerProvider); + ref.read(appStartupFlowProvider.notifier).start(); } @override void dispose() { - _startupFlowSubscription?.close(); super.dispose(); } diff --git a/lib/shared/widgets/offline_indicator.dart b/lib/shared/widgets/offline_indicator.dart index c1cbda5..14d8037 100644 --- a/lib/shared/widgets/offline_indicator.dart +++ b/lib/shared/widgets/offline_indicator.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'dart:io' show Platform; import '../../core/services/connectivity_service.dart'; +import '../../core/providers/app_providers.dart'; import '../theme/theme_extensions.dart'; import 'package:conduit/l10n/app_localizations.dart'; @@ -20,7 +21,12 @@ class OfflineIndicator extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final connectivityStatus = ref.watch(connectivityStatusProvider); + final socketConnection = ref.watch(socketConnectionStreamProvider); final wasOffline = ref.watch(_wasOfflineProvider); + final socketOffline = socketConnection.maybeWhen( + data: (state) => state == SocketConnectionState.disconnected, + orElse: () => false, + ); return Stack( children: [ @@ -28,7 +34,7 @@ class OfflineIndicator extends ConsumerWidget { if (showBanner) connectivityStatus.when( data: (status) { - if (status == ConnectivityStatus.offline) { + if (status == ConnectivityStatus.offline || socketOffline) { return _OfflineBanner(); } // Announce back-online briefly if we were previously offline diff --git a/pubspec.lock b/pubspec.lock index 0221aa9..6a4a695 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,26 @@ packages: dependency: transitive description: name: analyzer - sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c url: "https://pub.dev" source: hosted - version: "7.7.1" + version: "7.6.0" + analyzer_buffer: + dependency: transitive + description: + name: analyzer_buffer + sha256: f7833bee67c03c37241c67f8741b17cc501b69d9758df7a5a4a13ed6c947be43 + url: "https://pub.dev" + source: hosted + version: "0.1.10" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" ansicolor: dependency: transitive description: @@ -241,6 +257,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: cc4684d22ca05bf0a4a51127e19a8aea576b42079ed2bc9e956f11aaebe35dd1 + url: "https://pub.dev" + source: hosted + version: "0.8.0" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.7.0" dart_style: dependency: transitive description: @@ -773,6 +805,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mockito: + dependency: transitive + description: + name: mockito + sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99" + url: "https://pub.dev" + source: hosted + version: "5.5.0" node_preamble: dependency: transitive description: @@ -997,6 +1037,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: c971e50f678d55c87d9e3b5015df7fcaadb2302a84ea101247cf99387b4554fe + url: "https://pub.dev" + source: hosted + version: "1.0.0-dev.6" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: "1f9c1bc10b13f68ebc83ab391e77a865ff796880461d2a60c5ce301e1fb65f51" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "178d6bfa6de66d7db97db2466e5fad2da91a678ab376a7309235eec731755046" + url: "https://pub.dev" + source: hosted + version: "3.0.0" rxdart: dependency: transitive description: @@ -1174,10 +1238,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "800f12fb87434defa13432ab37e33051b43b290a174e15259563b043cda40c46" + sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "3.1.0" source_helper: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 062c7c3..34a76a3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,7 @@ dependencies: wakelock_plus: ^1.4.0 share_plus: ^12.0.0 share_handler: ^0.0.19 + riverpod_annotation: ^3.0.0 # Clipboard functionality is available through flutter/services (part of Flutter SDK) @@ -73,6 +74,7 @@ dev_dependencies: freezed: ^3.2.0 json_serializable: ^6.11.1 flutter_native_splash: ^2.4.6 + riverpod_generator: ^3.0.0 dependency_overrides: