diff --git a/.metadata b/.metadata index bbb2321..cf1a589 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "edada7c56edf4a183c1735310e123c7f923584f1" + revision: "20f82749394e68bcfbbeee96bad384abaae09c13" channel: "stable" project_type: app @@ -13,14 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: edada7c56edf4a183c1735310e123c7f923584f1 - base_revision: edada7c56edf4a183c1735310e123c7f923584f1 - - platform: android - create_revision: edada7c56edf4a183c1735310e123c7f923584f1 - base_revision: edada7c56edf4a183c1735310e123c7f923584f1 - - platform: ios - create_revision: edada7c56edf4a183c1735310e123c7f923584f1 - base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + - platform: macos + create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 # User provided section diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c746461..3e0b5a2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,11 +20,11 @@ android { ndkVersion = "27.0.12077973" defaultConfig { - applicationId = "app.cogwheel.conduit" - minSdk = 23 - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName + applicationId = "app.cogwheel.conduit" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName } compileOptions { diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 7c56964..1dc6cf7 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/ios/Podfile b/ios/Podfile index e549ee2..620e46e 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6331445..1eeab38 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -108,7 +108,7 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a @@ -121,6 +121,6 @@ SPEC CHECKSUMS: SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d -PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 7383a0e..9042221 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -454,7 +454,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -586,7 +586,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -637,7 +637,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/lib/core/auth/api_auth_interceptor.dart b/lib/core/auth/api_auth_interceptor.dart index 6765eb1..bfb0b51 100644 --- a/lib/core/auth/api_auth_interceptor.dart +++ b/lib/core/auth/api_auth_interceptor.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; /// Implements security requirements from OpenAPI specification class ApiAuthInterceptor extends Interceptor { String? _authToken; + final Map customHeaders; // Callbacks for auth events void Function()? onAuthTokenInvalid; @@ -35,6 +36,7 @@ class ApiAuthInterceptor extends Interceptor { String? authToken, this.onAuthTokenInvalid, this.onTokenInvalidated, + this.customHeaders = const {}, }) : _authToken = authToken; void updateAuthToken(String? token) { @@ -102,6 +104,21 @@ class ApiAuthInterceptor extends Interceptor { options.headers['Authorization'] = 'Bearer $_authToken'; } + // Add custom headers from server config (with safety checks) + if (customHeaders.isNotEmpty) { + customHeaders.forEach((key, value) { + // Don't override critical headers that we manage + final lowerKey = key.toLowerCase(); + if (lowerKey != 'authorization' && + lowerKey != 'content-type' && + lowerKey != 'accept') { + options.headers[key] = value; + } else { + debugPrint('WARNING: Skipping reserved header override attempt: $key'); + } + }); + } + // Add other common headers for API consistency options.headers['Content-Type'] ??= 'application/json'; options.headers['Accept'] ??= 'application/json'; diff --git a/lib/core/auth/auth_state_manager.dart b/lib/core/auth/auth_state_manager.dart index 03be868..66b5fb0 100644 --- a/lib/core/auth/auth_state_manager.dart +++ b/lib/core/auth/auth_state_manager.dart @@ -95,8 +95,10 @@ class AuthStateManager extends StateNotifier { final token = await storage.getAuthToken(); if (token != null && token.isNotEmpty) { + debugPrint('DEBUG: Found stored token during initialization: ${token.substring(0, 10)}...'); // Validate token before setting authenticated state final isValid = await _validateToken(token); + debugPrint('DEBUG: Token validation result: $isValid'); if (isValid) { state = state.copyWith( status: AuthStatus.authenticated, @@ -112,6 +114,7 @@ class AuthStateManager extends StateNotifier { _loadUserData(); } else { // Token is invalid, clear it + debugPrint('DEBUG: Token validation failed, deleting token'); await storage.deleteAuthToken(); state = state.copyWith( status: AuthStatus.unauthenticated, @@ -138,6 +141,98 @@ class AuthStateManager extends StateNotifier { } } + /// Perform login with API key + Future loginWithApiKey( + String apiKey, { + bool rememberCredentials = false, + }) async { + state = state.copyWith( + status: AuthStatus.loading, + isLoading: true, + clearError: true, + ); + + try { + // Validate API key format + if (apiKey.trim().isEmpty) { + throw Exception('API key cannot be empty'); + } + + // Ensure API service is available + await _ensureApiServiceAvailable(); + final api = _ref.read(apiServiceProvider); + if (api == null) { + throw Exception('No server connection available'); + } + + // Use API key directly as Bearer token + final tokenStr = apiKey.trim(); + + // Validate token format (consistent with credentials method) + if (!_isValidTokenFormat(tokenStr)) { + throw Exception('Invalid API key format'); + } + + // Update API service with the API key + _updateApiServiceToken(tokenStr); + + // Validate by attempting to fetch user info + try { + await api.getCurrentUser(); // Just validate, don't store user data yet + + // Save token to storage + final storage = _ref.read(optimizedStorageServiceProvider); + await storage.saveAuthToken(tokenStr); + + // Save API key if requested (for convenience, though less secure than credentials) + if (rememberCredentials) { + final activeServer = await _ref.read(activeServerProvider.future); + if (activeServer != null) { + // Store API key as a special credential type + await storage.saveCredentials( + serverId: activeServer.id, + username: 'api_key_user', // Special username to indicate API key auth + password: tokenStr, // Store API key in password field + ); + await storage.setRememberCredentials(true); + } + } + + // Update state (without user data initially) + state = state.copyWith( + status: AuthStatus.authenticated, + token: tokenStr, + isLoading: false, + clearError: 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(); + + debugPrint('DEBUG: API key login successful'); + return true; + } catch (e) { + // If user fetch fails, the API key might be invalid + throw Exception('Invalid API key or insufficient permissions'); + } + } catch (e) { + debugPrint('ERROR: API key login failed: $e'); + state = state.copyWith( + status: AuthStatus.error, + error: e.toString(), + isLoading: false, + clearToken: true, + ); + return false; + } + } + /// Perform login with credentials Future login( String username, @@ -272,8 +367,14 @@ class AuthStateManager extends StateNotifier { return false; } - // Attempt login - return await login(username, password, rememberCredentials: false); + // Attempt login (detect API key vs normal credentials) + if (username == 'api_key_user') { + // This is a saved API key + return await loginWithApiKey(password, rememberCredentials: false); + } else { + // Normal username/password credentials + return await login(username, password, rememberCredentials: false); + } } catch (e) { debugPrint('ERROR: Silent login failed: $e'); diff --git a/lib/core/auth/token_validator.dart b/lib/core/auth/token_validator.dart index e9ad101..0d3ce38 100644 --- a/lib/core/auth/token_validator.dart +++ b/lib/core/auth/token_validator.dart @@ -6,7 +6,7 @@ import 'package:crypto/crypto.dart'; class TokenValidator { static const Duration _validationTimeout = Duration(seconds: 5); - /// Validate JWT token format and expiry without network call + /// Validate token format (supports both JWT and API key formats) static TokenValidationResult validateTokenFormat(String token) { try { // Basic format check @@ -14,10 +14,20 @@ class TokenValidator { return TokenValidationResult.invalid('Token too short'); } + // Check if it's an API key format (starts with sk- or similar) + if (token.startsWith('sk-') || token.startsWith('api-') || token.startsWith('key-')) { + // API key format - validate differently + if (token.length < 20) { + return TokenValidationResult.invalid('API key too short'); + } + return TokenValidationResult.valid('API key format valid'); + } + // Check if it looks like a JWT (has at least 2 dots) final parts = token.split('.'); if (parts.length < 3) { - return TokenValidationResult.invalid('Invalid JWT format'); + // Not JWT format, treat as opaque token + return TokenValidationResult.valid('Opaque token format valid'); } // Try to decode the payload to check expiry diff --git a/lib/core/models/server_config.dart b/lib/core/models/server_config.dart index bfa58d4..65b31e5 100644 --- a/lib/core/models/server_config.dart +++ b/lib/core/models/server_config.dart @@ -10,6 +10,7 @@ sealed class ServerConfig with _$ServerConfig { required String name, required String url, String? apiKey, + @Default({}) Map customHeaders, DateTime? lastConnected, @Default(false) bool isActive, }) = _ServerConfig; diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 2787044..fd89d6b 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -38,13 +38,21 @@ class ApiService { followRedirects: true, maxRedirects: 5, validateStatus: (status) => status != null && status < 400, + // Add custom headers from server config + headers: serverConfig.customHeaders.isNotEmpty + ? Map.from(serverConfig.customHeaders) + : null, ), ) { + // Use API key from server config if provided and no explicit auth token + final effectiveAuthToken = authToken ?? serverConfig.apiKey; + // Initialize the consistent auth interceptor _authInterceptor = ApiAuthInterceptor( - authToken: authToken, + authToken: effectiveAuthToken, onAuthTokenInvalid: onAuthTokenInvalid, onTokenInvalidated: onTokenInvalidated, + customHeaders: serverConfig.customHeaders, ); // Add interceptors in order of priority: diff --git a/lib/core/services/input_validation_service.dart b/lib/core/services/input_validation_service.dart index dd51481..4444fe1 100644 --- a/lib/core/services/input_validation_service.dart +++ b/lib/core/services/input_validation_service.dart @@ -27,10 +27,10 @@ class InputValidationService { return null; } - /// Validate URL + /// Validate URL (enhanced version for server addresses) static String? validateUrl(String? value, {bool required = true}) { if (value == null || value.isEmpty) { - return required ? 'URL is required' : null; + return required ? 'Server address is required' : null; } final trimmed = value.trim(); @@ -38,21 +38,58 @@ class InputValidationService { // Add protocol if missing String urlToValidate = trimmed; if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) { - urlToValidate = 'https://$trimmed'; + urlToValidate = 'http://$trimmed'; } try { final uri = Uri.parse(urlToValidate); - if (!uri.hasScheme || !uri.hasAuthority) { - return 'Please enter a valid URL'; + + // Validate scheme + if (!uri.hasScheme || (uri.scheme != 'http' && uri.scheme != 'https')) { + return 'Use http:// or https:// only'; } + + // Validate host + if (!uri.hasAuthority || uri.host.isEmpty) { + return 'Please enter a server address (e.g., 192.168.1.10:3000)'; + } + + // Validate port if specified + if (uri.hasPort) { + if (uri.port < 1 || uri.port > 65535) { + return 'Port must be between 1 and 65535'; + } + } + + // Validate IP address format if it looks like an IP + if (_isIPAddress(uri.host) && !_isValidIPAddress(uri.host)) { + return 'Invalid IP address format (use 192.168.1.10)'; + } + } catch (e) { - return 'Please enter a valid URL'; + return 'Invalid server address format'; } return null; } + /// Check if a string looks like an IP address + static bool _isIPAddress(String host) { + return RegExp(r'^\d+\.\d+\.\d+\.\d+$').hasMatch(host); + } + + /// Validate IP address format + static bool _isValidIPAddress(String ip) { + final parts = ip.split('.'); + if (parts.length != 4) return false; + + for (final part in parts) { + final num = int.tryParse(part); + if (num == null || num < 0 || num > 255) return false; + } + return true; + } + /// Validate password strength static String? validatePassword(String? value, {bool checkStrength = true}) { if (value == null || value.isEmpty) { diff --git a/lib/core/widgets/error_boundary.dart b/lib/core/widgets/error_boundary.dart index b73b0db..e3f0613 100644 --- a/lib/core/widgets/error_boundary.dart +++ b/lib/core/widgets/error_boundary.dart @@ -28,22 +28,40 @@ class _ErrorBoundaryState extends ConsumerState { Object? _error; StackTrace? _stackTrace; bool _hasError = false; + void Function(FlutterErrorDetails details)? _previousOnError; + + void _scheduleHandleError(Object error, StackTrace? stack) { + // Defer to next frame to avoid setState during build exceptions + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _handleError(error, stack); + } + }); + } @override void initState() { super.initState(); // Set up Flutter error handling for this widget - final previousOnError = FlutterError.onError; + _previousOnError = FlutterError.onError; FlutterError.onError = (FlutterErrorDetails details) { // Forward to any previously registered handler to avoid interfering - if (previousOnError != null) { - previousOnError(details); - } - _handleError(details.exception, details.stack); + _previousOnError?.call(details); + // Defer handling to avoid setState during build + _scheduleHandleError(details.exception, details.stack); }; } + @override + void dispose() { + // Restore previous error handler to avoid leaking global state + if (FlutterError.onError != _previousOnError) { + FlutterError.onError = _previousOnError; + } + super.dispose(); + } + void _handleError(Object error, StackTrace? stack) { // Log error enhancedErrorService.logError( @@ -134,14 +152,16 @@ class _ErrorBoundaryState extends ConsumerState { return Builder( builder: (context) { ErrorWidget.builder = (FlutterErrorDetails details) { - _handleError(details.exception, details.stack); + // Defer handling to avoid setState during build of error widgets + _scheduleHandleError(details.exception, details.stack); return const SizedBox.shrink(); }; try { return widget.child; } catch (error, stack) { - _handleError(error, stack); + // Defer handling to avoid setState during build + _scheduleHandleError(error, stack); return const SizedBox.shrink(); } }, diff --git a/lib/features/auth/views/authentication_page.dart b/lib/features/auth/views/authentication_page.dart new file mode 100644 index 0000000..a5b77db --- /dev/null +++ b/lib/features/auth/views/authentication_page.dart @@ -0,0 +1,688 @@ +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + + +import '../../../core/models/server_config.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../core/services/input_validation_service.dart'; +import '../../../core/services/navigation_service.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../shared/services/brand_service.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import '../../../core/auth/auth_state_manager.dart'; +import '../../onboarding/views/onboarding_sheet.dart'; +import '../providers/unified_auth_providers.dart'; + +class AuthenticationPage extends ConsumerStatefulWidget { + final ServerConfig serverConfig; + + const AuthenticationPage({ + super.key, + required this.serverConfig, + }); + + @override + ConsumerState createState() => _AuthenticationPageState(); +} + +class _AuthenticationPageState extends ConsumerState { + final _formKey = GlobalKey(); + final TextEditingController _usernameController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _apiKeyController = TextEditingController(); + + bool _obscurePassword = true; + bool _useApiKey = false; + String? _loginError; + bool _isSigningIn = false; + + @override + void initState() { + super.initState(); + _loadSavedCredentials(); + } + + Future _loadSavedCredentials() async { + final storage = ref.read(optimizedStorageServiceProvider); + final savedCredentials = await storage.getSavedCredentials(); + if (savedCredentials != null) { + setState(() { + _usernameController.text = savedCredentials['username'] ?? ''; + }); + } + } + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + _apiKeyController.dispose(); + super.dispose(); + } + + Future _signIn() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isSigningIn = true; + _loginError = null; + }); + + try { + final authManager = ref.read(authStateManagerProvider.notifier); + bool success; + + if (_useApiKey) { + success = await authManager.loginWithApiKey( + _apiKeyController.text.trim(), + rememberCredentials: true, // Consistent with credentials method + ); + } else { + success = await authManager.login( + _usernameController.text.trim(), + _passwordController.text, + rememberCredentials: true, + ); + } + + if (!success) { + final authState = ref.read(authStateManagerProvider); + throw Exception(authState.error ?? 'Login failed'); + } + + // Success - navigation will be handled by auth state change + } catch (e) { + setState(() { + _loginError = _formatLoginError(e.toString()); + }); + } finally { + if (mounted) { + setState(() { + _isSigningIn = false; + }); + } + } + } + + void _initializeBackgroundResources(WidgetRef ref) { + // Initialize resources in the background without blocking UI + Future.microtask(() async { + try { + // Get the API service + final api = ref.read(apiServiceProvider); + if (api == null) { + debugPrint( + 'DEBUG: API service not available for background initialization', + ); + return; + } + + // Explicitly get the current auth token and set it on the API service + final authToken = ref.read(authTokenProvider3); + if (authToken != null && authToken.isNotEmpty) { + api.updateAuthToken(authToken); + debugPrint('DEBUG: Background - Set auth token on API service'); + } else { + debugPrint('DEBUG: Background - No auth token available yet'); + return; + } + + // Initialize the token updater for future updates + ref.read(apiTokenUpdaterProvider); + + // Load models and set default in background + await ref.read(defaultModelProvider.future); + debugPrint('DEBUG: Background initialization completed'); + + // Onboarding: show once if not seen + final storage = ref.read(optimizedStorageServiceProvider); + final seen = await storage.getOnboardingSeen(); + if (!seen && mounted) { + await Future.delayed(const Duration(milliseconds: 300)); + if (!mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) async { + final navContext = NavigationService.navigatorKey.currentContext; + if (!mounted || navContext == null) return; + _showOnboarding(navContext); + await storage.setOnboardingSeen(true); + }); + } + } catch (e) { + debugPrint('DEBUG: Background initialization failed: $e'); + // Don't throw - this is background initialization + } + }); + } + + void _showOnboarding(BuildContext context) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (context) => Container( + decoration: BoxDecoration( + color: context.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.modal), + ), + boxShadow: ConduitShadows.modal, + ), + child: const OnboardingSheet(), + ), + ); + } + + String _formatLoginError(String error) { + if (error.contains('401') || error.contains('Unauthorized')) { + return 'Invalid username or password. Please try again.'; + } else if (error.contains('redirect')) { + return 'The server is redirecting requests. Check your server\'s HTTPS configuration.'; + } else if (error.contains('SocketException')) { + return 'Unable to connect to server. Please check your connection.'; + } else if (error.contains('timeout')) { + return 'The request timed out. Please try again.'; + } + return 'We couldn\'t sign you in. Check your credentials and server settings.'; + } + + @override + Widget build(BuildContext context) { + // Listen for auth state changes to navigate on successful login + ref.listen(authStateManagerProvider, (previous, next) { + if (mounted && next.isAuthenticated && previous?.isAuthenticated != true) { + debugPrint('DEBUG: Authentication successful, initializing background resources'); + + // Initialize background resources + _initializeBackgroundResources(ref); + + debugPrint('DEBUG: Navigating to chat page'); + // Navigate directly to chat page on successful authentication + Navigator.of(context).pushNamedAndRemoveUntil( + Routes.chat, + (route) => false, // Remove all previous routes + ); + } + }); + + return ErrorBoundary( + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.pagePadding, + vertical: Spacing.lg, + ), + child: Column( + children: [ + // Header with progress indicator + _buildHeader(), + + const SizedBox(height: Spacing.extraLarge), + + // Main content + Expanded( + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Server connection status + _buildServerStatus(), + + const SizedBox(height: Spacing.sectionGap), + + // Welcome section + _buildWelcomeSection(), + + const SizedBox(height: Spacing.sectionGap), + + // Authentication form + _buildAuthForm(), + ], + ), + ), + ), + ), + ), + + // Bottom action button + _buildSignInButton(), + ], + ), + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Row( + children: [ + ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back, + onPressed: () => Navigator.pop(context), + tooltip: 'Back to server setup', + ), + const Spacer(), + // Progress indicator (step 2 of 2) + Row( + children: [ + Container( + width: 32, + height: 6, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + borderRadius: BorderRadius.circular(AppBorderRadius.round), + ), + ), + const SizedBox(width: Spacing.xs), + Container( + width: 32, + height: 6, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + borderRadius: BorderRadius.circular(AppBorderRadius.round), + ), + ), + ], + ), + const Spacer(), + const SizedBox(width: TouchTarget.minimum), // Balance the back button + ], + ); + } + + Widget _buildServerStatus() { + return ConduitCard( + isElevated: false, + padding: const EdgeInsets.all(Spacing.lg), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.conduitTheme.successBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.round), + border: Border.all( + color: context.conduitTheme.success.withValues(alpha: 0.3), + width: BorderWidth.standard, + ), + ), + child: Icon( + Platform.isIOS ? CupertinoIcons.checkmark_circle_fill : Icons.check_circle, + color: context.conduitTheme.success, + size: IconSize.medium, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Connected to Server', + style: context.conduitTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.conduitTheme.success, + ), + ), + const SizedBox(height: Spacing.xs), + Text( + Uri.parse(widget.serverConfig.url).host, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ], + ), + ).animate().slideX( + begin: -0.05, + duration: AnimationDuration.messageSlide, + curve: Curves.easeOutCubic, + ); + } + + Widget _buildWelcomeSection() { + return Column( + children: [ + BrandService.createBrandIcon( + size: 48, + useGradient: true, + addShadow: true, + ).animate().scale( + duration: AnimationDuration.pageTransition, + curve: Curves.easeOutBack, + ), + const SizedBox(height: Spacing.lg), + Text( + 'Sign In', + textAlign: TextAlign.center, + style: context.conduitTheme.headingLarge?.copyWith( + fontWeight: FontWeight.w700, + height: 1.2, + ), + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + delay: AnimationDuration.microInteraction, + ), + const SizedBox(height: Spacing.sm), + Text( + 'Enter your credentials to access your AI conversations', + textAlign: TextAlign.center, + style: context.conduitTheme.bodyLarge?.copyWith( + color: context.conduitTheme.textSecondary, + height: 1.5, + ), + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + delay: AnimationDuration.fast, + ), + ], + ); + } + + Widget _buildAuthForm() { + return ConduitCard( + isElevated: true, + padding: const EdgeInsets.all(Spacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Authentication mode toggle + _buildAuthModeToggle(), + + const SizedBox(height: Spacing.lg), + + // Authentication form fields + _buildAuthFields(), + + if (_loginError != null) ...[ + const SizedBox(height: Spacing.md), + _buildErrorMessage(_loginError!), + ], + ], + ), + ); + } + + Widget _buildAuthModeToggle() { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer, + borderRadius: BorderRadius.circular(AppBorderRadius.button), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.standard, + ), + ), + child: Row( + children: [ + Expanded( + child: _buildAuthToggleOption( + icon: Platform.isIOS ? CupertinoIcons.person_circle : Icons.account_circle_outlined, + label: 'Credentials', + isSelected: !_useApiKey, + onTap: () => setState(() => _useApiKey = false), + ), + ), + Expanded( + child: _buildAuthToggleOption( + icon: Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key_outlined, + label: 'API Key', + isSelected: _useApiKey, + onTap: () => setState(() => _useApiKey = true), + ), + ), + ], + ), + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + delay: AnimationDuration.microInteraction, + ); + } + + Widget _buildAuthToggleOption({ + required IconData icon, + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + return AnimatedContainer( + duration: AnimationDuration.microInteraction, + curve: Curves.easeInOutCubic, + child: Material( + color: isSelected + ? context.conduitTheme.buttonPrimary + : Colors.transparent, + borderRadius: BorderRadius.circular(AppBorderRadius.button - 2), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppBorderRadius.button - 2), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: Spacing.md, + horizontal: Spacing.sm, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: IconSize.small, + color: isSelected + ? context.conduitTheme.buttonPrimaryText + : context.conduitTheme.iconSecondary, + ), + const SizedBox(width: Spacing.sm), + Text( + label, + style: context.conduitTheme.bodyMedium?.copyWith( + color: isSelected + ? context.conduitTheme.buttonPrimaryText + : context.conduitTheme.textSecondary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildAuthFields() { + return AnimatedSwitcher( + duration: AnimationDuration.pageTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (Widget child, Animation animation) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: _useApiKey ? _buildApiKeyForm() : _buildCredentialsForm(), + ); + } + + Widget _buildApiKeyForm() { + return Column( + key: const ValueKey('api_key_form'), + children: [ + AccessibleFormField( + label: 'API Key', + hint: 'sk-...', + controller: _apiKeyController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => InputValidationService.validateMinLength( + value, + 10, + fieldName: 'API Key', + ), + ]), + obscureText: _obscurePassword, + semanticLabel: 'Enter your API key', + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key_outlined, + color: context.conduitTheme.iconSecondary, + ), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? (Platform.isIOS ? CupertinoIcons.eye_slash : Icons.visibility_off) + : (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility), + color: context.conduitTheme.iconSecondary, + ), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + onSubmitted: (_) => _signIn(), + isRequired: true, + autofillHints: const [AutofillHints.password], + ), + ], + ); + } + + Widget _buildCredentialsForm() { + return Column( + key: const ValueKey('credentials_form'), + children: [ + AccessibleFormField( + label: 'Username or Email', + hint: 'Enter your username or email', + controller: _usernameController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => InputValidationService.validateEmailOrUsername(value), + ]), + keyboardType: TextInputType.emailAddress, + semanticLabel: 'Enter your username or email', + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.person : Icons.person_outline, + color: context.conduitTheme.iconSecondary, + ), + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + isRequired: true, + ), + const SizedBox(height: Spacing.lg), + AccessibleFormField( + label: 'Password', + hint: 'Enter your password', + controller: _passwordController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => InputValidationService.validateMinLength( + value, + 1, + fieldName: 'Password', + ), + ]), + obscureText: _obscurePassword, + semanticLabel: 'Enter your password', + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.lock : Icons.lock_outline, + color: context.conduitTheme.iconSecondary, + ), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? (Platform.isIOS ? CupertinoIcons.eye_slash : Icons.visibility_off) + : (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility), + color: context.conduitTheme.iconSecondary, + ), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + onSubmitted: (_) => _signIn(), + autofillHints: const [AutofillHints.password], + isRequired: true, + ), + ], + ); + } + + Widget _buildSignInButton() { + return Padding( + padding: const EdgeInsets.only(top: Spacing.lg), + child: ConduitButton( + text: _isSigningIn + ? 'Signing in...' + : _useApiKey + ? 'Sign in with API Key' + : 'Sign In', + icon: _isSigningIn + ? null + : (Platform.isIOS ? CupertinoIcons.arrow_right : Icons.arrow_forward), + onPressed: _isSigningIn ? null : _signIn, + isLoading: _isSigningIn, + isFullWidth: true, + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + delay: AnimationDuration.fast, + ), + ); + } + + Widget _buildErrorMessage(String message) { + return Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.errorBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.button), + border: Border.all( + color: context.conduitTheme.error.withValues(alpha: 0.3), + width: BorderWidth.standard, + ), + ), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle_fill + : Icons.error_outline, + color: context.conduitTheme.error, + size: IconSize.medium, + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Text( + message, + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.error, + ), + ), + ), + ], + ), + ).animate().slideX( + begin: 0.05, + duration: AnimationDuration.messageSlide, + curve: Curves.easeOutCubic, + ); + } +} \ No newline at end of file diff --git a/lib/features/auth/views/connect_signin_page.dart b/lib/features/auth/views/connect_signin_page.dart index 498e8e8..612f1d5 100644 --- a/lib/features/auth/views/connect_signin_page.dart +++ b/lib/features/auth/views/connect_signin_page.dart @@ -1,680 +1,37 @@ -import 'dart:io' show Platform; - -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter/services.dart'; -import 'package:uuid/uuid.dart'; -import '../../../core/models/server_config.dart'; -import '../../../core/providers/app_providers.dart'; -import '../../../core/services/api_service.dart'; -import '../../../core/services/input_validation_service.dart'; import '../../../core/widgets/error_boundary.dart'; -import '../../../shared/services/brand_service.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/widgets/conduit_components.dart'; -import '../../../core/auth/auth_state_manager.dart'; -import '../../chat/views/chat_page.dart'; +import 'server_connection_page.dart'; -class ConnectAndSignInPage extends ConsumerStatefulWidget { +/// Entry point for the connection and sign-in flow +/// Redirects to the mobile-first two-step process +class ConnectAndSignInPage extends ConsumerWidget { const ConnectAndSignInPage({super.key}); @override - ConsumerState createState() => - _ConnectAndSignInPageState(); -} - -class _ConnectAndSignInPageState extends ConsumerState { - final _formKey = GlobalKey(); - - // Server controls - final TextEditingController _urlController = TextEditingController(); - String? _connectionError; - - // Auth controls - final TextEditingController _usernameController = TextEditingController(); - final TextEditingController _passwordController = TextEditingController(); - bool _obscurePassword = true; - String? _loginError; - bool _isSubmitting = false; - - @override - void initState() { - super.initState(); - _prefillFromState(); - _loadSavedCredentials(); - } - - Future _prefillFromState() async { - final activeServer = await ref.read(activeServerProvider.future); - if (activeServer != null) { - _urlController.text = activeServer.url; - } - } - - Future _loadSavedCredentials() async { - final storage = ref.read(optimizedStorageServiceProvider); - final savedCredentials = await storage.getSavedCredentials(); - if (savedCredentials != null) { - setState(() { - _usernameController.text = savedCredentials['username'] ?? ''; - }); - } - } - - @override - void dispose() { - _urlController.dispose(); - _usernameController.dispose(); - _passwordController.dispose(); - super.dispose(); - } - - Future _connectToServer() async { - if (!_formKey.currentState!.validate()) return false; - - setState(() { - _connectionError = null; - }); - - try { - String url = _urlController.text.trim(); - if (url.isEmpty) throw Exception('URL cannot be empty'); - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'http://$url'; - } - if (url.endsWith('/')) { - url = url.substring(0, url.length - 1); - } - - final uri = Uri.tryParse(url); - if (uri == null || !uri.hasScheme || uri.host.isEmpty) { - throw Exception('Invalid URL format. Please check your input.'); - } - if (uri.scheme != 'http' && uri.scheme != 'https') { - throw Exception('Only HTTP and HTTPS protocols are supported.'); - } - - final tempConfig = ServerConfig( - id: const Uuid().v4(), - name: _deriveServerNameFromUrl(url), - url: url, - isActive: true, + Widget build(BuildContext context, WidgetRef ref) { + // Directly navigate to the new mobile-first server connection page + WidgetsBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => const ServerConnectionPage(), + ), ); - - final api = ApiService(serverConfig: tempConfig); - final isHealthy = await api.checkHealth(); - if (!isHealthy) { - throw Exception('This does not appear to be an Open-WebUI server.'); - } - - await _saveServerConfig(tempConfig); - // Success - return true; - } catch (e) { - setState(() { - _connectionError = _formatConnectionError(e.toString()); - }); - return false; - } finally { - // no-op - } - } - - 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 _deriveServerNameFromUrl(String url) { - try { - final uri = Uri.parse(url); - if (uri.host.isNotEmpty) return uri.host; - } catch (_) {} - return 'Server'; - } - - Future _signIn() async { - if (!_formKey.currentState!.validate()) return; - setState(() { - _loginError = null; }); - try { - final authManager = ref.read(authStateManagerProvider.notifier); - final success = await authManager.login( - _usernameController.text.trim(), - _passwordController.text, - rememberCredentials: true, - ); - if (!success) { - final authState = ref.read(authStateManagerProvider); - throw Exception(authState.error ?? 'Login failed'); - } - } catch (e) { - setState(() { - _loginError = _formatLoginError(e.toString()); - }); - } finally { - // no-op - } - } - - Future _connectAndSignIn() async { - if (!_formKey.currentState!.validate()) return; - setState(() { - _isSubmitting = true; - _connectionError = null; - _loginError = null; - }); - - try { - final connected = await _connectToServer(); - if (!connected) return; - // Wait for providers to reflect the new active server and API service - await ref.read(activeServerProvider.future); - final apiReady = await _waitForApiService(); - if (!apiReady) { - setState(() { - _connectionError = 'Setting up the connection... Please try again.'; - }); - return; - } - await _signIn(); - } finally { - if (mounted) { - setState(() { - _isSubmitting = false; - }); - } - } - } - - Future _waitForApiService({ - Duration timeout = const Duration(seconds: 2), - }) async { - final end = DateTime.now().add(timeout); - while (DateTime.now().isBefore(end)) { - final api = ref.read(apiServiceProvider); - if (api != null) return true; - await Future.delayed(const Duration(milliseconds: 50)); - } - return ref.read(apiServiceProvider) != null; - } - - String _formatConnectionError(String error) { - if (error.contains('SocketException')) { - return 'We couldn\'t reach the server. Check your connection and that the server is running.'; - } else if (error.contains('timeout')) { - return 'Connection timed out. The server might be busy or blocked by a firewall.'; - } else if (error.contains('Invalid URL format')) { - return error.replaceFirst('Exception: ', ''); - } else if (error.contains('Missing protocol')) { - return 'Include http:// or https:// (e.g., http://192.168.1.10:3000).'; - } else if (error.contains('Only HTTP and HTTPS')) { - return 'Use http:// or https:// only.'; - } - return 'Couldn\'t connect. Double-check the address and try again.'; - } - - String _formatLoginError(String error) { - if (error.contains('401') || error.contains('Unauthorized')) { - return 'Invalid username or password. Please try again.'; - } else if (error.contains('redirect')) { - return 'The server is redirecting requests. Check your server\'s HTTPS configuration.'; - } else if (error.contains('SocketException')) { - return 'Unable to connect to server. Please check your connection.'; - } else if (error.contains('timeout')) { - return 'The request timed out. Please try again.'; - } - return 'We couldn\'t sign you in. Check your credentials and server settings.'; - } - - @override - Widget build(BuildContext context) { - final isIOS = Platform.isIOS; - final activeServerAsync = ref.watch(activeServerProvider); - final reviewerMode = ref.watch(reviewerModeProvider); - + // Show a simple loading state while transitioning return ErrorBoundary( child: Scaffold( backgroundColor: context.conduitTheme.surfaceBackground, - body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.pagePadding), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - GestureDetector( - onLongPress: () async { - HapticFeedback.mediumImpact(); - await ref - .read(reviewerModeProvider.notifier) - .toggle(); - if (!mounted) return; - final enabled = ref.read(reviewerModeProvider); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - enabled - ? 'Reviewer Mode enabled: Demo without server' - : 'Reviewer Mode disabled', - ), - ), - ); - }, - child: Stack( - alignment: Alignment.center, - children: [ - BrandService.createBrandIcon( - size: 100, - useGradient: true, - addShadow: true, - ), - if (reviewerMode) - Positioned( - bottom: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: context.conduitTheme.warning - .withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: context.conduitTheme.warning, - width: 1, - ), - ), - child: Text( - 'Reviewer Mode', - style: TextStyle( - color: context.conduitTheme.warning, - fontSize: AppTypography.labelSmall, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ], - ), - ) - .animate() - .scale( - duration: AnimationDuration.pageTransition, - curve: Curves.easeOutBack, - ) - .then() - .shimmer(duration: AnimationDuration.typingIndicator), - - const SizedBox(height: Spacing.sectionGap), - - Text( - 'Connect and sign in', - textAlign: TextAlign.center, - style: context.conduitTheme.headingLarge?.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ).animate().fadeIn( - duration: AnimationDuration.pageTransition, - delay: AnimationDuration.microInteraction, - ), - - const SizedBox(height: Spacing.comfortable), - - if (reviewerMode) ...[ - ConduitButton( - text: 'Enter Reviewer Demo', - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ChatPage(), - ), - ); - }, - isSecondary: true, - isFullWidth: true, - ), - const SizedBox(height: Spacing.xs), - Text( - 'Demo mode: explore the app without a server. Some features are simulated.', - textAlign: TextAlign.center, - style: TextStyle( - color: context.conduitTheme.textSecondary, - fontSize: AppTypography.bodySmall, - ), - ), - - const SizedBox(height: Spacing.sectionGap), - ], - - // Card container for form content - ConduitCard( - isElevated: true, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Step 1: Server - _SectionHeader( - icon: isIOS - ? CupertinoIcons.globe - : Icons.language, - title: 'Server', - subtitle: null, - ), - - const SizedBox(height: Spacing.sm), - - AutofillGroup( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - AccessibleFormField( - label: 'Server address', - hint: 'https://server', - controller: _urlController, - validator: InputValidationService.combine([ - InputValidationService.validateRequired, - (value) => - InputValidationService.validateUrl( - value, - required: true, - ), - ]), - keyboardType: TextInputType.url, - semanticLabel: - 'Enter your server URL or IP address', - onSubmitted: (_) => _connectAndSignIn(), - prefixIcon: Icon( - isIOS - ? CupertinoIcons.globe - : Icons.public, - color: context.conduitTheme.iconSecondary, - ), - autofillHints: const [AutofillHints.url], - ).animate().slideX( - begin: -0.08, - duration: AnimationDuration.messageSlide, - delay: AnimationDuration.microInteraction, - curve: Curves.easeOutCubic, - ), - - if (_connectionError != null) ...[ - const SizedBox(height: Spacing.sm), - _InlineMessage( - message: _connectionError!, - isError: true, - ).animate().slideX( - begin: 0.08, - duration: AnimationDuration.messageSlide, - curve: Curves.easeOutCubic, - ), - ], - - const SizedBox(height: Spacing.sectionGap), - - // Step 2: Sign in - _SectionHeader( - icon: isIOS - ? CupertinoIcons.lock - : Icons.lock_outline, - title: 'Sign in', - subtitle: null, - ), - - const SizedBox(height: Spacing.sm), - - activeServerAsync.maybeWhen( - data: (server) => server != null - ? Row( - children: [ - Icon( - isIOS - ? CupertinoIcons.link - : Icons.link_outlined, - size: IconSize.small, - color: context - .conduitTheme - .iconSecondary, - ), - const SizedBox(width: Spacing.xs), - Expanded( - child: Text( - server.url, - textAlign: TextAlign.left, - overflow: - TextOverflow.ellipsis, - style: context - .conduitTheme - .bodySmall - ?.copyWith( - color: context - .conduitTheme - .textSecondary, - ), - ), - ), - ], - ) - : const SizedBox.shrink(), - orElse: () => const SizedBox.shrink(), - ), - - const SizedBox(height: Spacing.sm), - - AccessibleFormField( - label: 'Username or email', - hint: null, - controller: _usernameController, - validator: InputValidationService.combine([ - InputValidationService.validateRequired, - (value) => - InputValidationService.validateEmailOrUsername( - value, - ), - ]), - keyboardType: TextInputType.emailAddress, - semanticLabel: - 'Enter your username or email', - prefixIcon: Icon( - isIOS - ? CupertinoIcons.person - : Icons.person_outline, - color: context.conduitTheme.iconSecondary, - ), - autofillHints: const [ - AutofillHints.username, - AutofillHints.email, - ], - ), - - const SizedBox(height: Spacing.comfortable), - - AccessibleFormField( - label: 'Password', - hint: null, - controller: _passwordController, - validator: InputValidationService.combine([ - InputValidationService.validateRequired, - (value) => - InputValidationService.validateMinLength( - value, - 1, - fieldName: 'Password', - ), - ]), - obscureText: _obscurePassword, - semanticLabel: 'Enter your password', - prefixIcon: Icon( - isIOS - ? CupertinoIcons.lock - : Icons.lock_outline, - color: context.conduitTheme.iconSecondary, - ), - suffixIcon: IconButton( - icon: Icon( - _obscurePassword - ? (isIOS - ? CupertinoIcons.eye_slash - : Icons.visibility_off) - : (isIOS - ? CupertinoIcons.eye - : Icons.visibility), - color: - context.conduitTheme.iconSecondary, - ), - onPressed: () => setState(() { - _obscurePassword = !_obscurePassword; - }), - ), - onSubmitted: (_) => _connectAndSignIn(), - autofillHints: const [ - AutofillHints.password, - ], - ), - - if (_loginError != null) ...[ - const SizedBox(height: Spacing.sm), - _InlineMessage( - message: _loginError!, - isError: true, - ), - ], - - const SizedBox(height: Spacing.md), - - ConduitButton( - text: 'Continue', - onPressed: _isSubmitting - ? null - : _connectAndSignIn, - isLoading: _isSubmitting, - isFullWidth: true, - ).animate().scale( - duration: AnimationDuration.buttonPress, - curve: Curves.easeOutCubic, - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), + body: const Center( + child: ConduitLoadingIndicator( + message: 'Loading...', ), ), ), ); } -} - -class _SectionHeader extends StatelessWidget { - final IconData icon; - final String title; - final String? subtitle; - - const _SectionHeader({ - required this.icon, - required this.title, - this.subtitle, - }); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(icon, color: context.conduitTheme.iconPrimary), - const SizedBox(width: Spacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: context.conduitTheme.headingSmall?.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - if (subtitle != null) - Text( - subtitle!, - style: context.conduitTheme.bodySmall?.copyWith( - color: context.conduitTheme.textSecondary, - ), - ), - ], - ), - ), - ], - ); - } -} - -class _InlineMessage extends StatelessWidget { - final String message; - final bool isError; - - const _InlineMessage({required this.message, this.isError = false}); - - @override - Widget build(BuildContext context) { - final isIOS = Platform.isIOS; - final color = isError - ? context.conduitTheme.error - : context.conduitTheme.success; - final bg = isError - ? context.conduitTheme.errorBackground - : context.conduitTheme.successBackground; - final icon = isError - ? (isIOS - ? CupertinoIcons.exclamationmark_circle_fill - : Icons.error_outline) - : (isIOS ? CupertinoIcons.check_mark_circled : Icons.check_circle); - - return Container( - padding: const EdgeInsets.all(Spacing.cardPadding), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(AppBorderRadius.card), - border: Border.all( - color: color.withValues(alpha: 0.3), - width: BorderWidth.regular, - ), - boxShadow: ConduitShadows.low, - ), - child: Row( - children: [ - Icon(icon, color: color, size: IconSize.medium), - const SizedBox(width: Spacing.comfortable), - Expanded( - child: Text( - message, - style: context.conduitTheme.bodyMedium?.copyWith(color: color), - ), - ), - ], - ), - ); - } -} - -// removed unused _ButtonProgress; ConduitButton provides built-in loading state +} \ No newline at end of file diff --git a/lib/features/auth/views/server_connection_page.dart b/lib/features/auth/views/server_connection_page.dart new file mode 100644 index 0000000..130b45c --- /dev/null +++ b/lib/features/auth/views/server_connection_page.dart @@ -0,0 +1,869 @@ +import 'dart:io' show Platform; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../core/models/server_config.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../core/services/api_service.dart'; +import '../../../core/services/input_validation_service.dart'; +import '../../../core/widgets/error_boundary.dart'; +import '../../../shared/services/brand_service.dart'; +import '../../../shared/theme/theme_extensions.dart'; +import '../../../shared/widgets/conduit_components.dart'; +import '../../chat/views/chat_page.dart'; +import 'authentication_page.dart'; + +class ServerConnectionPage extends ConsumerStatefulWidget { + const ServerConnectionPage({super.key}); + + @override + ConsumerState createState() => + _ServerConnectionPageState(); +} + +class _ServerConnectionPageState extends ConsumerState { + final _formKey = GlobalKey(); + final TextEditingController _urlController = TextEditingController(); + final Map _customHeaders = {}; + final TextEditingController _headerKeyController = TextEditingController(); + final TextEditingController _headerValueController = TextEditingController(); + + String? _connectionError; + bool _isConnecting = false; + bool _showAdvancedSettings = false; + + @override + void initState() { + super.initState(); + _prefillFromState(); + } + + Future _prefillFromState() async { + final activeServer = await ref.read(activeServerProvider.future); + if (activeServer != null) { + _urlController.text = activeServer.url; + } + } + + @override + void dispose() { + _urlController.dispose(); + _headerKeyController.dispose(); + _headerValueController.dispose(); + super.dispose(); + } + + Future _connectToServer() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isConnecting = true; + _connectionError = null; + }); + + try { + String url = _validateAndFormatUrl(_urlController.text.trim()); + + final tempConfig = ServerConfig( + id: const Uuid().v4(), + name: _deriveServerNameFromUrl(url), + url: url, + customHeaders: Map.from(_customHeaders), + isActive: true, + ); + + final api = ApiService(serverConfig: tempConfig); + final isHealthy = await api.checkHealth(); + if (!isHealthy) { + throw Exception('This does not appear to be an Open-WebUI server.'); + } + + await _saveServerConfig(tempConfig); + + // Navigate to authentication page + if (mounted) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AuthenticationPage(serverConfig: tempConfig), + ), + ); + } + } catch (e) { + setState(() { + _connectionError = _formatConnectionError(e.toString()); + }); + } finally { + if (mounted) { + setState(() { + _isConnecting = false; + }); + } + } + } + + 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('Server URL cannot be empty'); + } + + // Clean up the input + String url = input.trim(); + + // Add protocol if missing + if (!url.startsWith('http://') && !url.startsWith('https://')) { + url = 'http://$url'; + } + + // Remove trailing slash + if (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + + // Parse and validate the URI + final uri = Uri.tryParse(url); + if (uri == null) { + throw Exception('Invalid URL format. Please check your input.'); + } + + // Validate scheme + if (uri.scheme != 'http' && uri.scheme != 'https') { + throw Exception('Only HTTP and HTTPS protocols are supported.'); + } + + // Validate host + if (uri.host.isEmpty) { + throw Exception('Server address is required (e.g., 192.168.1.10 or example.com).'); + } + + // Validate port if specified + if (uri.hasPort) { + if (uri.port < 1 || uri.port > 65535) { + throw Exception('Port must be between 1 and 65535.'); + } + } + + // Validate IP address format if it looks like an IP + if (_isIPAddress(uri.host) && !_isValidIPAddress(uri.host)) { + throw Exception('Invalid IP address format. Use format like 192.168.1.10.'); + } + + return url; + } + + bool _isIPAddress(String host) { + return RegExp(r'^\d+\.\d+\.\d+\.\d+$').hasMatch(host); + } + + bool _isValidIPAddress(String ip) { + final parts = ip.split('.'); + if (parts.length != 4) return false; + + for (final part in parts) { + final num = int.tryParse(part); + if (num == null || num < 0 || num > 255) return false; + } + return true; + } + + String _deriveServerNameFromUrl(String url) { + try { + final uri = Uri.parse(url); + if (uri.host.isNotEmpty) return uri.host; + } catch (_) {} + return 'Server'; + } + + String _formatConnectionError(String error) { + // Clean up the error message + String cleanError = error.replaceFirst('Exception: ', ''); + + // Handle specific error types + if (error.contains('SocketException')) { + return 'We couldn\'t reach the server. Check your connection and that the server is running.'; + } else if (error.contains('timeout')) { + return 'Connection timed out. The server might be busy or blocked by a firewall.'; + } else if (error.contains('Server URL cannot be empty')) { + return 'Please enter a server address.'; + } else if (error.contains('Invalid URL format')) { + return 'Invalid server address format. Examples:\n• 192.168.1.10:3000\n• example.com\n• https://myserver.com'; + } else if (error.contains('Only HTTP and HTTPS')) { + return 'Use http:// or https:// only.'; + } else if (error.contains('Server address is required')) { + return cleanError; + } else if (error.contains('Port must be between')) { + return cleanError; + } else if (error.contains('Invalid IP address format')) { + return cleanError; + } else if (error.contains('This does not appear to be an Open-WebUI server')) { + return 'This server doesn\'t appear to be running Open-WebUI. Please check the address.'; + } + + return 'Couldn\'t connect. Double-check the address and try again.'; + } + + @override + Widget build(BuildContext context) { + final reviewerMode = ref.watch(reviewerModeProvider); + + return ErrorBoundary( + child: Scaffold( + backgroundColor: context.conduitTheme.surfaceBackground, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.pagePadding, + vertical: Spacing.lg, + ), + child: Column( + children: [ + // Header with progress indicator + _buildHeader(), + + const SizedBox(height: Spacing.extraLarge), + + // Main content + Expanded( + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Brand header + _buildBrandHeader(reviewerMode), + + const SizedBox(height: Spacing.sectionGap), + + // Welcome section + _buildWelcomeSection(), + + const SizedBox(height: Spacing.sectionGap), + + // Reviewer mode demo (if enabled) + if (reviewerMode) ...[ + _buildReviewerModeSection(), + const SizedBox(height: Spacing.sectionGap), + ], + + // Server connection form + _buildServerForm(), + ], + ), + ), + ), + ), + ), + + // Bottom action button + _buildConnectButton(), + ], + ), + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Progress indicator (step 1 of 2) + Row( + children: [ + Container( + width: 32, + height: 6, + decoration: BoxDecoration( + color: context.conduitTheme.buttonPrimary, + borderRadius: BorderRadius.circular(AppBorderRadius.round), + ), + ), + const SizedBox(width: Spacing.xs), + Container( + width: 32, + height: 6, + decoration: BoxDecoration( + color: context.conduitTheme.dividerColor, + borderRadius: BorderRadius.circular(AppBorderRadius.round), + ), + ), + ], + ), + ], + ); + } + + Widget _buildBrandHeader(bool reviewerMode) { + return GestureDetector( + onLongPress: () async { + HapticFeedback.mediumImpact(); + await ref.read(reviewerModeProvider.notifier).toggle(); + if (!mounted) return; + final enabled = ref.read(reviewerModeProvider); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + enabled + ? 'Reviewer Mode enabled: Demo without server' + : 'Reviewer Mode disabled', + ), + ), + ); + }, + child: Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + // Glow effect + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + context.conduitTheme.buttonPrimary.withValues(alpha: 0.12), + context.conduitTheme.buttonPrimary.withValues(alpha: 0.06), + Colors.transparent, + ], + stops: const [0.0, 0.7, 1.0], + radius: 0.8, + ), + ), + ), + // Brand logo + BrandService.createBrandIcon( + size: 64, + useGradient: true, + addShadow: true, + ), + // Reviewer mode badge + if (reviewerMode) + Positioned( + bottom: 0, + child: ConduitBadge( + text: 'Demo', + backgroundColor: context.conduitTheme.warning.withValues(alpha: 0.15), + textColor: context.conduitTheme.warning, + isCompact: true, + ), + ), + ], + ), + ], + ), + ).animate().scale( + duration: AnimationDuration.pageTransition, + curve: Curves.easeOutBack, + ); + } + + Widget _buildWelcomeSection() { + return Column( + children: [ + Text( + 'Connect to Server', + textAlign: TextAlign.center, + style: context.conduitTheme.headingLarge?.copyWith( + fontWeight: FontWeight.w700, + height: 1.2, + ), + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + delay: AnimationDuration.microInteraction, + ), + const SizedBox(height: Spacing.sm), + Text( + 'Enter your Open-WebUI server address to get started', + textAlign: TextAlign.center, + style: context.conduitTheme.bodyLarge?.copyWith( + color: context.conduitTheme.textSecondary, + height: 1.5, + ), + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + delay: AnimationDuration.fast, + ), + ], + ); + } + + Widget _buildReviewerModeSection() { + return ConduitCard( + isElevated: false, + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + children: [ + Row( + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.wand_stars : Icons.auto_awesome, + color: context.conduitTheme.warning, + size: IconSize.medium, + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Demo Mode Active', + style: context.conduitTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.conduitTheme.warning, + ), + ), + const SizedBox(height: Spacing.xs), + Text( + 'Skip server setup and try the demo', + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: Spacing.lg), + ConduitButton( + text: 'Enter Demo', + icon: Platform.isIOS ? CupertinoIcons.play_fill : Icons.play_arrow, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ChatPage(), + ), + ); + }, + isSecondary: true, + isFullWidth: true, + ), + ], + ), + ); + } + + Widget _buildServerForm() { + return ConduitCard( + isElevated: true, + padding: const EdgeInsets.all(Spacing.xl), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AccessibleFormField( + label: 'Server URL', + hint: 'https://your-server.com', + controller: _urlController, + validator: InputValidationService.combine([ + InputValidationService.validateRequired, + (value) => InputValidationService.validateUrl(value, required: true), + ]), + keyboardType: TextInputType.url, + semanticLabel: 'Enter your server URL or IP address', + onSubmitted: (_) => _connectToServer(), + prefixIcon: Icon( + Platform.isIOS ? CupertinoIcons.globe : Icons.public, + color: context.conduitTheme.iconSecondary, + ), + autofillHints: const [AutofillHints.url], + isRequired: true, + ).animate().slideX( + begin: -0.05, + duration: AnimationDuration.messageSlide, + delay: AnimationDuration.microInteraction, + curve: Curves.easeOutCubic, + ), + + if (_connectionError != null) ...[ + const SizedBox(height: Spacing.md), + _buildErrorMessage(_connectionError!), + ], + + const SizedBox(height: Spacing.lg), + + // Advanced settings + _buildAdvancedSettings(), + ], + ), + ); + } + + Widget _buildAdvancedSettings() { + return Column( + children: [ + ConduitCard( + isElevated: false, + padding: const EdgeInsets.all(Spacing.lg), + child: Column( + children: [ + InkWell( + onTap: () => setState(() => _showAdvancedSettings = !_showAdvancedSettings), + borderRadius: BorderRadius.circular(AppBorderRadius.button), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), + child: Row( + children: [ + Icon( + Platform.isIOS ? CupertinoIcons.gear : Icons.tune, + color: context.conduitTheme.iconSecondary, + size: IconSize.medium, + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Advanced Settings', + style: context.conduitTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (_customHeaders.isNotEmpty) + Text( + '${_customHeaders.length} custom header${_customHeaders.length != 1 ? 's' : ''}', + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + AnimatedRotation( + duration: AnimationDuration.microInteraction, + turns: _showAdvancedSettings ? 0.5 : 0, + child: Icon( + Platform.isIOS ? CupertinoIcons.chevron_down : Icons.expand_more, + color: context.conduitTheme.iconSecondary, + ), + ), + ], + ), + ), + ), + AnimatedSize( + duration: AnimationDuration.microInteraction, + curve: Curves.easeInOutCubic, + child: _showAdvancedSettings ? _buildAdvancedSettingsContent() : const SizedBox.shrink(), + ), + ], + ), + ), + ], + ); + } + + Widget _buildAdvancedSettingsContent() { + return Column( + children: [ + const SizedBox(height: Spacing.lg), + ConduitDivider(), + const SizedBox(height: Spacing.lg), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Custom Headers', + style: context.conduitTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (_customHeaders.isNotEmpty) + Text( + '${_customHeaders.length}/10', + style: context.conduitTheme.bodySmall?.copyWith( + color: _customHeaders.length >= 10 + ? context.conduitTheme.error + : context.conduitTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(height: Spacing.xs), + Text( + 'Add custom HTTP headers for authentication, API keys, or special server requirements.', + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + const SizedBox(height: Spacing.md), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + flex: 2, + child: AccessibleFormField( + label: 'Header Name', + hint: 'X-Custom-Header', + controller: _headerKeyController, + validator: (value) => _validateHeaderKey(value ?? ''), + semanticLabel: 'Enter header name', + isCompact: true, + keyboardType: TextInputType.text, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + flex: 3, + child: AccessibleFormField( + label: 'Header Value', + hint: 'api-key-123 or Bearer token', + controller: _headerValueController, + validator: (value) => _validateHeaderValue(value ?? ''), + semanticLabel: 'Enter header value', + isCompact: true, + keyboardType: TextInputType.text, + ), + ), + const SizedBox(width: Spacing.md), + ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.plus : Icons.add, + onPressed: _customHeaders.length >= 10 ? null : _addCustomHeader, + tooltip: _customHeaders.length >= 10 + ? 'Maximum headers reached' + : 'Add header', + backgroundColor: _customHeaders.length >= 10 + ? context.conduitTheme.surfaceContainer + : context.conduitTheme.buttonPrimary, + iconColor: _customHeaders.length >= 10 + ? context.conduitTheme.textDisabled + : context.conduitTheme.buttonPrimaryText, + ), + ], + ), + if (_customHeaders.isNotEmpty) ...[ + const SizedBox(height: Spacing.lg), + _buildCustomHeadersList(), + ], + ], + ); + } + + Widget _buildCustomHeadersList() { + return Column( + children: _customHeaders.entries.map((entry) { + return Container( + margin: const EdgeInsets.only(bottom: Spacing.sm), + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.button), + border: Border.all( + color: context.conduitTheme.dividerColor, + width: BorderWidth.standard, + ), + ), + child: Row( + children: [ + ConduitBadge( + text: entry.key, + backgroundColor: context.conduitTheme.buttonPrimary.withValues(alpha: 0.1), + textColor: context.conduitTheme.buttonPrimary, + isCompact: true, + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Text( + entry.value, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: Spacing.md), + ConduitIconButton( + icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close, + onPressed: () => _removeCustomHeader(entry.key), + tooltip: 'Remove header', + backgroundColor: context.conduitTheme.error.withValues(alpha: 0.1), + iconColor: context.conduitTheme.error, + isCompact: true, + ), + ], + ), + ).animate().fadeIn(duration: AnimationDuration.microInteraction); + }).toList(), + ); + } + + Widget _buildConnectButton() { + return Padding( + padding: const EdgeInsets.only(top: Spacing.lg), + child: ConduitButton( + text: _isConnecting ? 'Connecting...' : 'Connect to Server', + icon: _isConnecting + ? null + : (Platform.isIOS ? CupertinoIcons.arrow_right : Icons.arrow_forward), + onPressed: _isConnecting ? null : _connectToServer, + isLoading: _isConnecting, + isFullWidth: true, + ).animate().fadeIn( + duration: AnimationDuration.pageTransition, + delay: AnimationDuration.fast, + ), + ); + } + + Widget _buildErrorMessage(String message) { + return Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.errorBackground, + borderRadius: BorderRadius.circular(AppBorderRadius.button), + border: Border.all( + color: context.conduitTheme.error.withValues(alpha: 0.3), + width: BorderWidth.standard, + ), + ), + child: Row( + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.exclamationmark_circle_fill + : Icons.error_outline, + color: context.conduitTheme.error, + size: IconSize.medium, + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Text( + message, + style: context.conduitTheme.bodyMedium?.copyWith( + color: context.conduitTheme.error, + ), + ), + ), + ], + ), + ).animate().slideX( + begin: 0.05, + duration: AnimationDuration.messageSlide, + curve: Curves.easeOutCubic, + ); + } + + void _addCustomHeader() { + final key = _headerKeyController.text.trim(); + final value = _headerValueController.text.trim(); + + if (key.isEmpty || value.isEmpty) return; + + // Validate header name + final keyValidation = _validateHeaderKey(key); + if (keyValidation != null) { + _showHeaderError(keyValidation); + return; + } + + // Validate header value + final valueValidation = _validateHeaderValue(value); + if (valueValidation != null) { + _showHeaderError(valueValidation); + return; + } + + // Check for duplicates + if (_customHeaders.containsKey(key)) { + _showHeaderError('Header "$key" already exists. Remove it first to update.'); + return; + } + + // Check header count limit + if (_customHeaders.length >= 10) { + _showHeaderError('Maximum of 10 custom headers allowed. Remove some to add more.'); + return; + } + + setState(() { + _customHeaders[key] = value; + _headerKeyController.clear(); + _headerValueController.clear(); + }); + HapticFeedback.lightImpact(); + } + + String? _validateHeaderKey(String key) { + // RFC 7230 compliant header name validation + if (key.isEmpty) return 'Header name cannot be empty'; + if (key.length > 64) return 'Header name too long (max 64 characters)'; + + // Check for valid characters (RFC 7230: token characters) + if (!RegExp(r'^[a-zA-Z0-9!#$&\-^_`|~]+$').hasMatch(key)) { + return 'Invalid header name. Use only letters, numbers, and these symbols: !#\$&-^_`|~'; + } + + // Check for reserved headers that should not be overridden + final lowerKey = key.toLowerCase(); + final reservedHeaders = { + 'authorization', 'content-type', 'content-length', 'host', + 'user-agent', 'accept', 'accept-encoding', 'connection', + 'transfer-encoding', 'upgrade', 'via', 'warning' + }; + + if (reservedHeaders.contains(lowerKey)) { + return 'Cannot override reserved header "$key"'; + } + + return null; + } + + String? _validateHeaderValue(String value) { + if (value.isEmpty) return 'Header value cannot be empty'; + if (value.length > 1024) return 'Header value too long (max 1024 characters)'; + + // Check for valid characters (no control characters except tab) + for (int i = 0; i < value.length; i++) { + final char = value.codeUnitAt(i); + // Allow printable ASCII (32-126) and tab (9) + if (char != 9 && (char < 32 || char > 126)) { + return 'Header value contains invalid characters. Use only printable ASCII.'; + } + } + + // Check for security-sensitive patterns + if (value.toLowerCase().contains('script') || + value.contains('<') || + value.contains('>')) { + return 'Header value appears to contain potentially unsafe content'; + } + + return null; + } + + void _showHeaderError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: context.conduitTheme.error, + duration: const Duration(seconds: 3), + ), + ); + } + + void _removeCustomHeader(String key) { + setState(() { + _customHeaders.remove(key); + }); + HapticFeedback.lightImpact(); + } +} \ No newline at end of file diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index c02fe53..93b6f80 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -1169,11 +1169,15 @@ class _ChatPageState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - _formatModelDisplayName(selectedModel.name), - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w400, + Flexible( + child: Text( + _formatModelDisplayName(selectedModel.name), + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: Spacing.xs), @@ -1214,11 +1218,15 @@ class _ChatPageState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - 'Choose Model', - style: AppTypography.headlineSmallStyle.copyWith( - color: context.conduitTheme.textPrimary, - fontWeight: FontWeight.w400, + Flexible( + child: Text( + 'Choose Model', + style: AppTypography.headlineSmallStyle.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w400, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: Spacing.xs), diff --git a/lib/shared/services/brand_service.dart b/lib/shared/services/brand_service.dart index bde4236..7baadf8 100644 --- a/lib/shared/services/brand_service.dart +++ b/lib/shared/services/brand_service.dart @@ -9,17 +9,15 @@ import '../theme/app_theme.dart'; class BrandService { BrandService._(); - /// Primary brand icon - the hub icon - static IconData get primaryIcon => - Platform.isIOS ? CupertinoIcons.link_circle_fill : Icons.hub; + /// Primary brand icon - the hub icon (consistent across platforms) + static IconData get primaryIcon => Icons.hub; /// Alternative brand icons for different contexts - static IconData get primaryIconOutlined => - Platform.isIOS ? CupertinoIcons.link_circle : Icons.hub_outlined; + static IconData get primaryIconOutlined => Icons.hub_outlined; static IconData get connectivityIcon => - Platform.isIOS ? CupertinoIcons.wifi : Icons.hub; + Platform.isIOS ? CupertinoIcons.wifi : Icons.wifi; static IconData get networkIcon => - Platform.isIOS ? CupertinoIcons.globe : Icons.hub; + Platform.isIOS ? CupertinoIcons.globe : Icons.public; /// Brand colors - these should be accessed through context.conduitTheme in UI components static Color get primaryBrandColor => AppTheme.brandPrimary; @@ -231,13 +229,15 @@ class BrandService { return AppBar( title: Text( title, - style: (context != null ? context.conduitTheme.headingSmall : null) - ?.copyWith( - color: (context != null - ? context.conduitTheme.textPrimary - : null), - fontWeight: FontWeight.w600, - ), + style: context != null + ? context.conduitTheme.headingSmall?.copyWith( + color: context.conduitTheme.textPrimary, + fontWeight: FontWeight.w600, + ) + : TextStyle( + fontSize: AppTypography.headlineSmall, + fontWeight: FontWeight.w600, + ), ), centerTitle: centerTitle, elevation: elevation, diff --git a/lib/shared/widgets/conduit_components.dart b/lib/shared/widgets/conduit_components.dart index 41bc7b2..9205dc6 100644 --- a/lib/shared/widgets/conduit_components.dart +++ b/lib/shared/widgets/conduit_components.dart @@ -741,17 +741,20 @@ class AccessibleFormField extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (label != null) ...[ - Row( + Wrap( + spacing: Spacing.textSpacing, + crossAxisAlignment: WrapCrossAlignment.center, children: [ Text( label!, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: AppTypography.standard.copyWith( fontWeight: FontWeight.w500, color: context.conduitTheme.textPrimary, ), ), - if (isRequired) ...[ - SizedBox(width: Spacing.textSpacing), + if (isRequired) Text( '*', style: AppTypography.standard.copyWith( @@ -759,7 +762,6 @@ class AccessibleFormField extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - ], ], ), SizedBox(height: isCompact ? Spacing.xs : Spacing.sm), diff --git a/pubspec.lock b/pubspec.lock index 3088429..4c405dd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -652,26 +652,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.1" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -1201,10 +1201,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.6" timing: dependency: transitive description: @@ -1337,10 +1337,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: