feat: API auth with custom headers
This commit is contained in:
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
||||
/// Implements security requirements from OpenAPI specification
|
||||
class ApiAuthInterceptor extends Interceptor {
|
||||
String? _authToken;
|
||||
final Map<String, String> 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';
|
||||
|
||||
@@ -95,8 +95,10 @@ class AuthStateManager extends StateNotifier<AuthState> {
|
||||
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<AuthState> {
|
||||
_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<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform login with API key
|
||||
Future<bool> 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<bool> login(
|
||||
String username,
|
||||
@@ -272,8 +367,14 @@ class AuthStateManager extends StateNotifier<AuthState> {
|
||||
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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,7 @@ sealed class ServerConfig with _$ServerConfig {
|
||||
required String name,
|
||||
required String url,
|
||||
String? apiKey,
|
||||
@Default({}) Map<String, String> customHeaders,
|
||||
DateTime? lastConnected,
|
||||
@Default(false) bool isActive,
|
||||
}) = _ServerConfig;
|
||||
|
||||
@@ -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<String, String>.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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,22 +28,40 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
|
||||
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<ErrorBoundary> {
|
||||
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();
|
||||
}
|
||||
},
|
||||
|
||||
688
lib/features/auth/views/authentication_page.dart
Normal file
688
lib/features/auth/views/authentication_page.dart
Normal file
@@ -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<AuthenticationPage> createState() => _AuthenticationPageState();
|
||||
}
|
||||
|
||||
class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void> _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<void> _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<AuthState>(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<double> animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ConnectAndSignInPage> createState() =>
|
||||
_ConnectAndSignInPageState();
|
||||
}
|
||||
|
||||
class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// 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<void> _prefillFromState() async {
|
||||
final activeServer = await ref.read(activeServerProvider.future);
|
||||
if (activeServer != null) {
|
||||
_urlController.text = activeServer.url;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<bool> _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<void> _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<void> _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<void> _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<bool> _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
|
||||
}
|
||||
869
lib/features/auth/views/server_connection_page.dart
Normal file
869
lib/features/auth/views/server_connection_page.dart
Normal file
@@ -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<ServerConnectionPage> createState() =>
|
||||
_ServerConnectionPageState();
|
||||
}
|
||||
|
||||
class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _urlController = TextEditingController();
|
||||
final Map<String, String> _customHeaders = {};
|
||||
final TextEditingController _headerKeyController = TextEditingController();
|
||||
final TextEditingController _headerValueController = TextEditingController();
|
||||
|
||||
String? _connectionError;
|
||||
bool _isConnecting = false;
|
||||
bool _showAdvancedSettings = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_prefillFromState();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<String, String>.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<void> _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();
|
||||
}
|
||||
}
|
||||
@@ -1169,11 +1169,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
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<ChatPage> {
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user