feat: API auth with custom headers
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user