chore: initial release

This commit is contained in:
cogwheel0
2025-08-10 01:20:45 +05:30
commit 758615813f
218 changed files with 67743 additions and 0 deletions

View File

@@ -0,0 +1,659 @@
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';
class ConnectAndSignInPage extends ConsumerStatefulWidget {
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,
);
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);
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),
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,
),
).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,
),
),
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(),
),
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,
),
],
),
),
],
),
),
),
),
),
),
),
);
}
}
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