From b33069fdea3f0796cec546d7c6aa5632ff8c87a2 Mon Sep 17 00:00:00 2001
From: cogwheel <172976095+cogwheel0@users.noreply.github.com>
Date: Sat, 16 Aug 2025 15:51:27 +0530
Subject: [PATCH] feat: API auth with custom headers
---
.metadata | 15 +-
android/app/build.gradle.kts | 10 +-
devtools_options.yaml | 3 +
ios/Flutter/AppFrameworkInfo.plist | 2 +-
ios/Podfile | 2 +-
ios/Podfile.lock | 4 +-
ios/Runner.xcodeproj/project.pbxproj | 6 +-
lib/core/auth/api_auth_interceptor.dart | 17 +
lib/core/auth/auth_state_manager.dart | 105 ++-
lib/core/auth/token_validator.dart | 14 +-
lib/core/models/server_config.dart | 1 +
lib/core/services/api_service.dart | 10 +-
.../services/input_validation_service.dart | 49 +-
lib/core/widgets/error_boundary.dart | 34 +-
.../auth/views/authentication_page.dart | 688 ++++++++++++++
.../auth/views/connect_signin_page.dart | 675 +-------------
.../auth/views/server_connection_page.dart | 869 ++++++++++++++++++
lib/features/chat/views/chat_page.dart | 28 +-
lib/shared/services/brand_service.dart | 28 +-
lib/shared/widgets/conduit_components.dart | 10 +-
pubspec.lock | 20 +-
21 files changed, 1854 insertions(+), 736 deletions(-)
create mode 100644 devtools_options.yaml
create mode 100644 lib/features/auth/views/authentication_page.dart
create mode 100644 lib/features/auth/views/server_connection_page.dart
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: