diff --git a/lib/core/auth/api_auth_interceptor.dart b/lib/core/auth/api_auth_interceptor.dart index e935ee6..7772232 100644 --- a/lib/core/auth/api_auth_interceptor.dart +++ b/lib/core/auth/api_auth_interceptor.dart @@ -30,6 +30,7 @@ class ApiAuthInterceptor extends Interceptor { // Endpoints that have optional authentication (work without but better with) static const Set _optionalAuthEndpoints = { + '/api/config', '/api/models', '/api/v1/configs/models', }; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index dcef7d4..7446559 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -81,8 +81,14 @@ class RouterNotifier extends ChangeNotifier { final activeServer = activeServerAsync.asData?.value; final hasActiveServer = activeServer != null; if (!hasActiveServer) { - // Allow auth-related routes while no server configured - if (_isAuthLocation(location)) return null; + // No server configured - redirect to server connection + // Exception: allow staying on server connection or authentication pages + // But always redirect away from connection issue page (user logged out) + if (location == Routes.serverConnection || + location == Routes.authentication || + location == Routes.login) { + return null; + } return Routes.serverConnection; } diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 4070543..aa4320e 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -219,7 +219,7 @@ class ApiService { return parsed; } - // Health check + /// Basic health check - just verifies the server is reachable. Future checkHealth() async { try { final response = await _dio.get('/health'); @@ -229,6 +229,35 @@ class ApiService { } } + /// Verifies this is actually an OpenWebUI server by checking the /api/config + /// endpoint for OpenWebUI-specific fields (version, status, features). + /// + /// Returns `true` if the server appears to be a valid OpenWebUI instance. + Future verifyIsOpenWebUIServer() async { + try { + final response = await _dio.get('/api/config'); + if (response.statusCode != 200) { + return false; + } + + final data = response.data; + if (data is! Map) { + return false; + } + + // Check for OpenWebUI-specific fields + // The /api/config endpoint always returns these fields on OpenWebUI + final hasStatus = data['status'] == true; + final hasVersion = + data['version'] is String && (data['version'] as String).isNotEmpty; + final hasFeatures = data['features'] is Map; + + return hasStatus && hasVersion && hasFeatures; + } catch (e) { + return false; + } + } + // Enhanced health check with model availability Future> checkServerStatus() async { final result = { diff --git a/lib/core/widgets/error_boundary.dart b/lib/core/widgets/error_boundary.dart index 08693b6..48405b4 100644 --- a/lib/core/widgets/error_boundary.dart +++ b/lib/core/widgets/error_boundary.dart @@ -32,10 +32,18 @@ class _ErrorBoundaryState extends ConsumerState { void Function(FlutterErrorDetails details)? _previousOnError; bool _shouldIgnoreError(Object error) { - // Ignore RenderFlex overflow errors (layout issues) final errorString = error.toString(); - return errorString.contains('RenderFlex') || - errorString.contains('overflow') && errorString.contains('pixels'); + // Ignore RenderFlex overflow errors (layout issues) + if (errorString.contains('RenderFlex') || + errorString.contains('overflow') && errorString.contains('pixels')) { + return true; + } + // Ignore "Build scheduled during frame" errors - these are harmless + // framework warnings from animations during layout + if (errorString.contains('Build scheduled during frame')) { + return true; + } + return false; } void _scheduleHandleError(Object error, StackTrace? stack) { @@ -59,6 +67,10 @@ class _ErrorBoundaryState extends ConsumerState { // Set up Flutter error handling for this widget _previousOnError = FlutterError.onError; FlutterError.onError = (FlutterErrorDetails details) { + // Check if this is a harmless error we should completely ignore + if (_shouldIgnoreError(details.exception)) { + return; // Don't forward or handle + } // Forward to any previously registered handler to avoid interfering _previousOnError?.call(details); // Defer handling to avoid setState during build diff --git a/lib/features/auth/views/authentication_page.dart b/lib/features/auth/views/authentication_page.dart index 5ece084..00752e4 100644 --- a/lib/features/auth/views/authentication_page.dart +++ b/lib/features/auth/views/authentication_page.dart @@ -37,6 +37,7 @@ class _AuthenticationPageState extends ConsumerState { bool _useApiKey = false; String? _loginError; bool _isSigningIn = false; + bool _serverConfigSaved = false; @override void initState() { @@ -72,13 +73,20 @@ class _AuthenticationPageState extends ConsumerState { }); try { + // Save server config on first sign-in attempt if it's a new config + // This persists the server so user can retry with different credentials + if (widget.serverConfig != null && !_serverConfigSaved) { + await _saveServerConfig(widget.serverConfig!); + _serverConfigSaved = true; + } + final actions = ref.read(authActionsProvider); bool success; if (_useApiKey) { success = await actions.loginWithApiKey( _apiKeyController.text.trim(), - rememberCredentials: true, // Consistent with credentials method + rememberCredentials: true, ); } else { success = await actions.login( @@ -95,6 +103,9 @@ class _AuthenticationPageState extends ConsumerState { // Success - navigation will be handled by auth state change } catch (e) { + // Don't clear server config on auth failure - user should be able to retry + // The server config is valid (passed OpenWebUI verification), only the + // credentials were wrong or there was a network issue setState(() { _loginError = _formatLoginError(e.toString()); }); @@ -107,6 +118,14 @@ class _AuthenticationPageState extends ConsumerState { } } + Future _saveServerConfig(ServerConfig config) async { + final storage = ref.read(optimizedStorageServiceProvider); + await storage.saveServerConfigs([config]); + await storage.setActiveServerId(config.id); + ref.invalidate(serverConfigsProvider); + ref.invalidate(activeServerProvider); + } + String _formatLoginError(String error) { if (error.contains('401') || error.contains('Unauthorized')) { return AppLocalizations.of(context)!.invalidCredentials; @@ -164,6 +183,8 @@ class _AuthenticationPageState extends ConsumerState { // Main content Expanded( child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Form( diff --git a/lib/features/auth/views/connection_issue_page.dart b/lib/features/auth/views/connection_issue_page.dart index 8c017de..242b758 100644 --- a/lib/features/auth/views/connection_issue_page.dart +++ b/lib/features/auth/views/connection_issue_page.dart @@ -215,19 +215,31 @@ class _ConnectionIssuePageState extends ConsumerState { } Future _retryConnection() async { + final l10n = AppLocalizations.of(context)!; + setState(() { _isRetrying = true; _statusMessage = null; }); try { - // Clear the error state and attempt to re-establish connection final authManager = ref.read(authStateManagerProvider.notifier); + final authState = ref.read(authStateManagerProvider); + final hasValidToken = authState.maybeWhen( + data: (state) => state.hasValidToken, + orElse: () => false, + ); // Reset retry counter for manual retry attempts authManager.resetRetryCounter(); - await authManager.silentLogin(); + if (hasValidToken) { + // User has a valid token - just refresh to verify connection + await authManager.refresh(); + } else { + // No valid token - attempt silent login with saved credentials + await authManager.silentLogin(); + } // If successful, router will automatically navigate to chat if (!mounted) return; @@ -237,7 +249,7 @@ class _ConnectionIssuePageState extends ConsumerState { } catch (_) { if (!mounted) return; setState(() { - _statusMessage = 'Connection failed. Please try again.'; + _statusMessage = l10n.couldNotConnectGeneric; }); } finally { if (mounted) { diff --git a/lib/features/auth/views/server_connection_page.dart b/lib/features/auth/views/server_connection_page.dart index 4dabc16..2f36f7e 100644 --- a/lib/features/auth/views/server_connection_page.dart +++ b/lib/features/auth/views/server_connection_page.dart @@ -14,6 +14,7 @@ import '../../../core/services/api_service.dart'; import '../../../core/services/worker_manager.dart'; import '../../../core/services/input_validation_service.dart'; import '../../../core/services/navigation_service.dart'; +import '../../../core/utils/debug_logger.dart'; import '../../../core/widgets/error_boundary.dart'; import '../../../shared/services/brand_service.dart'; import '../../../shared/theme/theme_extensions.dart'; @@ -63,7 +64,22 @@ class _ServerConnectionPageState extends ConsumerState { } Future _connectToServer() async { - if (!_formKey.currentState!.validate()) return; + DebugLogger.log('Connect button pressed', scope: 'auth/connection'); + + final urlValue = _urlController.text.trim(); + DebugLogger.log('URL value: "$urlValue"', scope: 'auth/connection'); + + // Check what validation would return + final validationResult = InputValidationService.validateUrl(urlValue); + DebugLogger.log( + 'URL validation result: ${validationResult ?? "valid"}', + scope: 'auth/connection', + ); + + if (!_formKey.currentState!.validate()) { + DebugLogger.log('Form validation failed', scope: 'auth/connection'); + return; + } setState(() { _isConnecting = true; @@ -87,21 +103,56 @@ class _ServerConnectionPageState extends ConsumerState { serverConfig: tempConfig, workerManager: workerManager, ); - final isHealthy = await api.checkHealth(); - if (!isHealthy) { + + // First check basic connectivity + DebugLogger.log('Checking server health...', scope: 'auth/connection'); + final isReachable = await api.checkHealth(); + DebugLogger.log( + 'Health check result: $isReachable', + scope: 'auth/connection', + ); + if (!isReachable) { + throw Exception( + 'Could not reach the server. Please check the address.', + ); + } + + // Then verify it's actually an OpenWebUI server + DebugLogger.log( + 'Verifying OpenWebUI server...', + scope: 'auth/connection', + ); + final isOpenWebUI = await api.verifyIsOpenWebUIServer(); + DebugLogger.log( + 'OpenWebUI verification result: $isOpenWebUI', + scope: 'auth/connection', + ); + if (!isOpenWebUI) { throw Exception('This does not appear to be an Open-WebUI server.'); } - await _saveServerConfig(tempConfig); + DebugLogger.log( + 'Server validation passed, navigating to auth page', + scope: 'auth/connection', + ); - // Navigate to authentication page + // Don't save server config yet - wait until authentication succeeds + // The config is passed to the authentication page if (mounted) { context.pushNamed(RouteNames.authentication, extra: tempConfig); } - } catch (e) { - setState(() { - _connectionError = _formatConnectionError(e.toString()); - }); + } catch (e, stack) { + DebugLogger.error( + 'server-connection-error', + scope: 'auth/connection', + error: e, + stackTrace: stack, + ); + if (mounted) { + setState(() { + _connectionError = _formatConnectionError(e.toString()); + }); + } } finally { if (mounted) { setState(() { @@ -111,14 +162,6 @@ class _ServerConnectionPageState extends ConsumerState { } } - Future _saveServerConfig(ServerConfig config) async { - final storage = ref.read(optimizedStorageServiceProvider); - await storage.saveServerConfigs([config]); - await storage.setActiveServerId(config.id); - ref.invalidate(serverConfigsProvider); - ref.invalidate(activeServerProvider); - } - String _validateAndFormatUrl(String input) { if (input.isEmpty) { throw Exception(AppLocalizations.of(context)!.serverUrlEmpty); @@ -244,6 +287,8 @@ class _ServerConnectionPageState extends ConsumerState { // Main content Expanded( child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 480), child: Form( @@ -528,12 +573,16 @@ class _ServerConnectionPageState extends ConsumerState { ), ), ), - AnimatedSize( + // Use AnimatedCrossFade instead of AnimatedSize to avoid + // "Build scheduled during frame" errors + AnimatedCrossFade( duration: AnimationDuration.microInteraction, - curve: Curves.easeInOutCubic, - child: _showAdvancedSettings - ? _buildAdvancedSettingsContent() - : const SizedBox.shrink(), + sizeCurve: Curves.easeInOutCubic, + crossFadeState: _showAdvancedSettings + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SizedBox.shrink(), + secondChild: _buildAdvancedSettingsContent(), ), ], ); @@ -845,8 +894,8 @@ class _ServerConnectionPageState extends ConsumerState { } String? _validateHeaderKey(String key) { - // RFC 7230 compliant header name validation - if (key.isEmpty) return AppLocalizations.of(context)!.headerNameEmpty; + // Allow empty - header fields are optional + if (key.isEmpty) return null; if (key.length > 64) return AppLocalizations.of(context)!.headerNameTooLong; // Check for valid characters (RFC 7230: token characters) @@ -879,7 +928,8 @@ class _ServerConnectionPageState extends ConsumerState { } String? _validateHeaderValue(String value) { - if (value.isEmpty) return AppLocalizations.of(context)!.headerValueEmpty; + // Allow empty - header fields are optional + if (value.isEmpty) return null; if (value.length > 1024) { return AppLocalizations.of(context)!.headerValueTooLong; } diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 0a39f9b..1f2ce43 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -12,6 +12,7 @@ import '../../../shared/widgets/responsive_drawer_layout.dart'; import '../../navigation/widgets/chats_drawer.dart'; import 'dart:async'; import '../../../core/providers/app_providers.dart'; +import '../../auth/providers/unified_auth_providers.dart'; import '../providers/chat_providers.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/user_display_name.dart'; @@ -1122,12 +1123,12 @@ class _ChatPageState extends ConsumerState { Widget _buildEmptyState(ThemeData theme) { final l10n = AppLocalizations.of(context)!; - final currentUserAsync = ref.watch(currentUserProvider); - final userFromProfile = currentUserAsync.maybeWhen( - data: (user) => user, - orElse: () => null, + final authUser = ref.watch(currentUserProvider2); + final asyncUser = ref.watch(currentUserProvider); + final user = asyncUser.maybeWhen( + data: (value) => value ?? authUser, + orElse: () => authUser, ); - final user = userFromProfile; String? greetingName; if (user != null) { final derived = deriveUserDisplayName(user, fallback: '').trim(); diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 4a911e2..cf1fa0a 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/providers/app_providers.dart'; +import '../../auth/providers/unified_auth_providers.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../chat/providers/chat_providers.dart' as chat; // import '../../files/views/files_page.dart'; @@ -1477,12 +1478,7 @@ class _ChatsDrawerState extends ConsumerState { Widget _buildBottomSection(BuildContext context) { final theme = context.conduitTheme; final sidebarTheme = context.sidebarTheme; - final currentUserAsync = ref.watch(currentUserProvider); - final userFromProfile = currentUserAsync.maybeWhen( - data: (u) => u, - orElse: () => null, - ); - final user = userFromProfile; + final user = ref.watch(currentUserProvider2); final api = ref.watch(apiServiceProvider); String initialFor(String name) { diff --git a/lib/features/onboarding/views/onboarding_sheet.dart b/lib/features/onboarding/views/onboarding_sheet.dart index 64559f1..e5a5663 100644 --- a/lib/features/onboarding/views/onboarding_sheet.dart +++ b/lib/features/onboarding/views/onboarding_sheet.dart @@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:conduit/l10n/app_localizations.dart'; -import '../../../core/providers/app_providers.dart'; +import '../../auth/providers/unified_auth_providers.dart'; import '../../../core/utils/user_display_name.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/widgets/sheet_handle.dart'; @@ -67,12 +67,7 @@ class _OnboardingSheetState extends ConsumerState { Widget build(BuildContext context) { final height = MediaQuery.of(context).size.height; final l10n = AppLocalizations.of(context)!; - final currentUserAsync = ref.watch(currentUserProvider); - final userFromProfile = currentUserAsync.maybeWhen( - data: (user) => user, - orElse: () => null, - ); - final user = userFromProfile; + final user = ref.watch(currentUserProvider2); final greetingName = deriveUserDisplayName(user); final pages = _buildPages(l10n, greetingName); final pageCount = pages.length; diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart index 57aa576..87aa4f3 100644 --- a/lib/features/profile/views/profile_page.dart +++ b/lib/features/profile/views/profile_page.dart @@ -41,40 +41,23 @@ class ProfilePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final user = ref.watch(currentUserProvider); + final user = ref.watch(currentUserProvider2); + final isAuthLoading = ref.watch(isAuthLoadingProvider2); final api = ref.watch(apiServiceProvider); - return ErrorBoundary( - child: user.when( - data: (userData) => _buildScaffold( - context, - body: _buildProfileBody(context, ref, userData, api), + Widget body; + if (isAuthLoading && user == null) { + body = _buildCenteredState( + context, + ImprovedLoadingState( + message: AppLocalizations.of(context)!.loadingProfile, ), - loading: () => _buildScaffold( - context, - body: _buildCenteredState( - context, - ImprovedLoadingState( - message: AppLocalizations.of(context)!.loadingProfile, - ), - ), - ), - error: (error, stack) => _buildScaffold( - context, - body: _buildCenteredState( - context, - ImprovedEmptyState( - title: AppLocalizations.of(context)!.unableToLoadProfile, - subtitle: AppLocalizations.of(context)!.pleaseCheckConnection, - icon: UiUtils.platformIcon( - ios: CupertinoIcons.exclamationmark_triangle, - android: Icons.error_outline, - ), - ), - ), - ), - ), - ); + ); + } else { + body = _buildProfileBody(context, ref, user, api); + } + + return ErrorBoundary(child: _buildScaffold(context, body: body)); } Scaffold _buildScaffold(BuildContext context, {required Widget body}) {