feat: API auth with custom headers

This commit is contained in:
cogwheel
2025-08-16 15:51:27 +05:30
parent 37dece4263
commit b33069fdea
21 changed files with 1854 additions and 736 deletions

View File

@@ -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';

View File

@@ -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');

View File

@@ -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

View File

@@ -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;

View File

@@ -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:

View File

@@ -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) {

View File

@@ -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();
}
},

View 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,
);
}
}

View File

@@ -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
}

View 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();
}
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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),