feat: API auth with custom headers
This commit is contained in:
15
.metadata
15
.metadata
@@ -4,7 +4,7 @@
|
|||||||
# This file should be version controlled and should not be manually edited.
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
version:
|
version:
|
||||||
revision: "edada7c56edf4a183c1735310e123c7f923584f1"
|
revision: "20f82749394e68bcfbbeee96bad384abaae09c13"
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
|
|
||||||
project_type: app
|
project_type: app
|
||||||
@@ -13,14 +13,11 @@ project_type: app
|
|||||||
migration:
|
migration:
|
||||||
platforms:
|
platforms:
|
||||||
- platform: root
|
- platform: root
|
||||||
create_revision: edada7c56edf4a183c1735310e123c7f923584f1
|
create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
|
||||||
base_revision: edada7c56edf4a183c1735310e123c7f923584f1
|
base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
|
||||||
- platform: android
|
- platform: macos
|
||||||
create_revision: edada7c56edf4a183c1735310e123c7f923584f1
|
create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
|
||||||
base_revision: edada7c56edf4a183c1735310e123c7f923584f1
|
base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13
|
||||||
- platform: ios
|
|
||||||
create_revision: edada7c56edf4a183c1735310e123c7f923584f1
|
|
||||||
base_revision: edada7c56edf4a183c1735310e123c7f923584f1
|
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ android {
|
|||||||
ndkVersion = "27.0.12077973"
|
ndkVersion = "27.0.12077973"
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "app.cogwheel.conduit"
|
applicationId = "app.cogwheel.conduit"
|
||||||
minSdk = 23
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@@ -21,6 +21,6 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>12.0</string>
|
<string>13.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# Uncomment this line to define a global platform for your project
|
||||||
# platform :ios, '12.0'
|
# platform :ios, '13.0'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ SPEC CHECKSUMS:
|
|||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||||
@@ -121,6 +121,6 @@ SPEC CHECKSUMS:
|
|||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||||
|
|
||||||
PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -454,7 +454,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -586,7 +586,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -637,7 +637,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
/// Implements security requirements from OpenAPI specification
|
/// Implements security requirements from OpenAPI specification
|
||||||
class ApiAuthInterceptor extends Interceptor {
|
class ApiAuthInterceptor extends Interceptor {
|
||||||
String? _authToken;
|
String? _authToken;
|
||||||
|
final Map<String, String> customHeaders;
|
||||||
|
|
||||||
// Callbacks for auth events
|
// Callbacks for auth events
|
||||||
void Function()? onAuthTokenInvalid;
|
void Function()? onAuthTokenInvalid;
|
||||||
@@ -35,6 +36,7 @@ class ApiAuthInterceptor extends Interceptor {
|
|||||||
String? authToken,
|
String? authToken,
|
||||||
this.onAuthTokenInvalid,
|
this.onAuthTokenInvalid,
|
||||||
this.onTokenInvalidated,
|
this.onTokenInvalidated,
|
||||||
|
this.customHeaders = const {},
|
||||||
}) : _authToken = authToken;
|
}) : _authToken = authToken;
|
||||||
|
|
||||||
void updateAuthToken(String? token) {
|
void updateAuthToken(String? token) {
|
||||||
@@ -102,6 +104,21 @@ class ApiAuthInterceptor extends Interceptor {
|
|||||||
options.headers['Authorization'] = 'Bearer $_authToken';
|
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
|
// Add other common headers for API consistency
|
||||||
options.headers['Content-Type'] ??= 'application/json';
|
options.headers['Content-Type'] ??= 'application/json';
|
||||||
options.headers['Accept'] ??= 'application/json';
|
options.headers['Accept'] ??= 'application/json';
|
||||||
|
|||||||
@@ -95,8 +95,10 @@ class AuthStateManager extends StateNotifier<AuthState> {
|
|||||||
final token = await storage.getAuthToken();
|
final token = await storage.getAuthToken();
|
||||||
|
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
|
debugPrint('DEBUG: Found stored token during initialization: ${token.substring(0, 10)}...');
|
||||||
// Validate token before setting authenticated state
|
// Validate token before setting authenticated state
|
||||||
final isValid = await _validateToken(token);
|
final isValid = await _validateToken(token);
|
||||||
|
debugPrint('DEBUG: Token validation result: $isValid');
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: AuthStatus.authenticated,
|
status: AuthStatus.authenticated,
|
||||||
@@ -112,6 +114,7 @@ class AuthStateManager extends StateNotifier<AuthState> {
|
|||||||
_loadUserData();
|
_loadUserData();
|
||||||
} else {
|
} else {
|
||||||
// Token is invalid, clear it
|
// Token is invalid, clear it
|
||||||
|
debugPrint('DEBUG: Token validation failed, deleting token');
|
||||||
await storage.deleteAuthToken();
|
await storage.deleteAuthToken();
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: AuthStatus.unauthenticated,
|
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
|
/// Perform login with credentials
|
||||||
Future<bool> login(
|
Future<bool> login(
|
||||||
String username,
|
String username,
|
||||||
@@ -272,8 +367,14 @@ class AuthStateManager extends StateNotifier<AuthState> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt login
|
// Attempt login (detect API key vs normal credentials)
|
||||||
return await login(username, password, rememberCredentials: false);
|
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) {
|
} catch (e) {
|
||||||
debugPrint('ERROR: Silent login failed: $e');
|
debugPrint('ERROR: Silent login failed: $e');
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'package:crypto/crypto.dart';
|
|||||||
class TokenValidator {
|
class TokenValidator {
|
||||||
static const Duration _validationTimeout = Duration(seconds: 5);
|
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) {
|
static TokenValidationResult validateTokenFormat(String token) {
|
||||||
try {
|
try {
|
||||||
// Basic format check
|
// Basic format check
|
||||||
@@ -14,10 +14,20 @@ class TokenValidator {
|
|||||||
return TokenValidationResult.invalid('Token too short');
|
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)
|
// Check if it looks like a JWT (has at least 2 dots)
|
||||||
final parts = token.split('.');
|
final parts = token.split('.');
|
||||||
if (parts.length < 3) {
|
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
|
// Try to decode the payload to check expiry
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ sealed class ServerConfig with _$ServerConfig {
|
|||||||
required String name,
|
required String name,
|
||||||
required String url,
|
required String url,
|
||||||
String? apiKey,
|
String? apiKey,
|
||||||
|
@Default({}) Map<String, String> customHeaders,
|
||||||
DateTime? lastConnected,
|
DateTime? lastConnected,
|
||||||
@Default(false) bool isActive,
|
@Default(false) bool isActive,
|
||||||
}) = _ServerConfig;
|
}) = _ServerConfig;
|
||||||
|
|||||||
@@ -38,13 +38,21 @@ class ApiService {
|
|||||||
followRedirects: true,
|
followRedirects: true,
|
||||||
maxRedirects: 5,
|
maxRedirects: 5,
|
||||||
validateStatus: (status) => status != null && status < 400,
|
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
|
// Initialize the consistent auth interceptor
|
||||||
_authInterceptor = ApiAuthInterceptor(
|
_authInterceptor = ApiAuthInterceptor(
|
||||||
authToken: authToken,
|
authToken: effectiveAuthToken,
|
||||||
onAuthTokenInvalid: onAuthTokenInvalid,
|
onAuthTokenInvalid: onAuthTokenInvalid,
|
||||||
onTokenInvalidated: onTokenInvalidated,
|
onTokenInvalidated: onTokenInvalidated,
|
||||||
|
customHeaders: serverConfig.customHeaders,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add interceptors in order of priority:
|
// Add interceptors in order of priority:
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ class InputValidationService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate URL
|
/// Validate URL (enhanced version for server addresses)
|
||||||
static String? validateUrl(String? value, {bool required = true}) {
|
static String? validateUrl(String? value, {bool required = true}) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return required ? 'URL is required' : null;
|
return required ? 'Server address is required' : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final trimmed = value.trim();
|
final trimmed = value.trim();
|
||||||
@@ -38,21 +38,58 @@ class InputValidationService {
|
|||||||
// Add protocol if missing
|
// Add protocol if missing
|
||||||
String urlToValidate = trimmed;
|
String urlToValidate = trimmed;
|
||||||
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
||||||
urlToValidate = 'https://$trimmed';
|
urlToValidate = 'http://$trimmed';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse(urlToValidate);
|
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) {
|
} catch (e) {
|
||||||
return 'Please enter a valid URL';
|
return 'Invalid server address format';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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
|
/// Validate password strength
|
||||||
static String? validatePassword(String? value, {bool checkStrength = true}) {
|
static String? validatePassword(String? value, {bool checkStrength = true}) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
|
|||||||
@@ -28,22 +28,40 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
|
|||||||
Object? _error;
|
Object? _error;
|
||||||
StackTrace? _stackTrace;
|
StackTrace? _stackTrace;
|
||||||
bool _hasError = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// Set up Flutter error handling for this widget
|
// Set up Flutter error handling for this widget
|
||||||
final previousOnError = FlutterError.onError;
|
_previousOnError = FlutterError.onError;
|
||||||
FlutterError.onError = (FlutterErrorDetails details) {
|
FlutterError.onError = (FlutterErrorDetails details) {
|
||||||
// Forward to any previously registered handler to avoid interfering
|
// Forward to any previously registered handler to avoid interfering
|
||||||
if (previousOnError != null) {
|
_previousOnError?.call(details);
|
||||||
previousOnError(details);
|
// Defer handling to avoid setState during build
|
||||||
}
|
_scheduleHandleError(details.exception, details.stack);
|
||||||
_handleError(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) {
|
void _handleError(Object error, StackTrace? stack) {
|
||||||
// Log error
|
// Log error
|
||||||
enhancedErrorService.logError(
|
enhancedErrorService.logError(
|
||||||
@@ -134,14 +152,16 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
|
|||||||
return Builder(
|
return Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
ErrorWidget.builder = (FlutterErrorDetails details) {
|
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();
|
return const SizedBox.shrink();
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return widget.child;
|
return widget.child;
|
||||||
} catch (error, stack) {
|
} catch (error, stack) {
|
||||||
_handleError(error, stack);
|
// Defer handling to avoid setState during build
|
||||||
|
_scheduleHandleError(error, stack);
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
688
lib/features/auth/views/authentication_page.dart
Normal file
688
lib/features/auth/views/authentication_page.dart
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
|
||||||
|
import '../../../core/models/server_config.dart';
|
||||||
|
import '../../../core/providers/app_providers.dart';
|
||||||
|
import '../../../core/services/input_validation_service.dart';
|
||||||
|
import '../../../core/services/navigation_service.dart';
|
||||||
|
import '../../../core/widgets/error_boundary.dart';
|
||||||
|
import '../../../shared/services/brand_service.dart';
|
||||||
|
import '../../../shared/theme/theme_extensions.dart';
|
||||||
|
import '../../../shared/widgets/conduit_components.dart';
|
||||||
|
import '../../../core/auth/auth_state_manager.dart';
|
||||||
|
import '../../onboarding/views/onboarding_sheet.dart';
|
||||||
|
import '../providers/unified_auth_providers.dart';
|
||||||
|
|
||||||
|
class AuthenticationPage extends ConsumerStatefulWidget {
|
||||||
|
final ServerConfig serverConfig;
|
||||||
|
|
||||||
|
const AuthenticationPage({
|
||||||
|
super.key,
|
||||||
|
required this.serverConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AuthenticationPage> createState() => _AuthenticationPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final TextEditingController _usernameController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _apiKeyController = TextEditingController();
|
||||||
|
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _useApiKey = false;
|
||||||
|
String? _loginError;
|
||||||
|
bool _isSigningIn = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadSavedCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSavedCredentials() async {
|
||||||
|
final storage = ref.read(optimizedStorageServiceProvider);
|
||||||
|
final savedCredentials = await storage.getSavedCredentials();
|
||||||
|
if (savedCredentials != null) {
|
||||||
|
setState(() {
|
||||||
|
_usernameController.text = savedCredentials['username'] ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_usernameController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_apiKeyController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _signIn() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSigningIn = true;
|
||||||
|
_loginError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final authManager = ref.read(authStateManagerProvider.notifier);
|
||||||
|
bool success;
|
||||||
|
|
||||||
|
if (_useApiKey) {
|
||||||
|
success = await authManager.loginWithApiKey(
|
||||||
|
_apiKeyController.text.trim(),
|
||||||
|
rememberCredentials: true, // Consistent with credentials method
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
success = await authManager.login(
|
||||||
|
_usernameController.text.trim(),
|
||||||
|
_passwordController.text,
|
||||||
|
rememberCredentials: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
final authState = ref.read(authStateManagerProvider);
|
||||||
|
throw Exception(authState.error ?? 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success - navigation will be handled by auth state change
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_loginError = _formatLoginError(e.toString());
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSigningIn = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initializeBackgroundResources(WidgetRef ref) {
|
||||||
|
// Initialize resources in the background without blocking UI
|
||||||
|
Future.microtask(() async {
|
||||||
|
try {
|
||||||
|
// Get the API service
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
if (api == null) {
|
||||||
|
debugPrint(
|
||||||
|
'DEBUG: API service not available for background initialization',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly get the current auth token and set it on the API service
|
||||||
|
final authToken = ref.read(authTokenProvider3);
|
||||||
|
if (authToken != null && authToken.isNotEmpty) {
|
||||||
|
api.updateAuthToken(authToken);
|
||||||
|
debugPrint('DEBUG: Background - Set auth token on API service');
|
||||||
|
} else {
|
||||||
|
debugPrint('DEBUG: Background - No auth token available yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the token updater for future updates
|
||||||
|
ref.read(apiTokenUpdaterProvider);
|
||||||
|
|
||||||
|
// Load models and set default in background
|
||||||
|
await ref.read(defaultModelProvider.future);
|
||||||
|
debugPrint('DEBUG: Background initialization completed');
|
||||||
|
|
||||||
|
// Onboarding: show once if not seen
|
||||||
|
final storage = ref.read(optimizedStorageServiceProvider);
|
||||||
|
final seen = await storage.getOnboardingSeen();
|
||||||
|
if (!seen && mounted) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
if (!mounted) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
final navContext = NavigationService.navigatorKey.currentContext;
|
||||||
|
if (!mounted || navContext == null) return;
|
||||||
|
_showOnboarding(navContext);
|
||||||
|
await storage.setOnboardingSeen(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('DEBUG: Background initialization failed: $e');
|
||||||
|
// Don't throw - this is background initialization
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showOnboarding(BuildContext context) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.surfaceBackground,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(AppBorderRadius.modal),
|
||||||
|
),
|
||||||
|
boxShadow: ConduitShadows.modal,
|
||||||
|
),
|
||||||
|
child: const OnboardingSheet(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatLoginError(String error) {
|
||||||
|
if (error.contains('401') || error.contains('Unauthorized')) {
|
||||||
|
return 'Invalid username or password. Please try again.';
|
||||||
|
} else if (error.contains('redirect')) {
|
||||||
|
return 'The server is redirecting requests. Check your server\'s HTTPS configuration.';
|
||||||
|
} else if (error.contains('SocketException')) {
|
||||||
|
return 'Unable to connect to server. Please check your connection.';
|
||||||
|
} else if (error.contains('timeout')) {
|
||||||
|
return 'The request timed out. Please try again.';
|
||||||
|
}
|
||||||
|
return 'We couldn\'t sign you in. Check your credentials and server settings.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Listen for auth state changes to navigate on successful login
|
||||||
|
ref.listen<AuthState>(authStateManagerProvider, (previous, next) {
|
||||||
|
if (mounted && next.isAuthenticated && previous?.isAuthenticated != true) {
|
||||||
|
debugPrint('DEBUG: Authentication successful, initializing background resources');
|
||||||
|
|
||||||
|
// Initialize background resources
|
||||||
|
_initializeBackgroundResources(ref);
|
||||||
|
|
||||||
|
debugPrint('DEBUG: Navigating to chat page');
|
||||||
|
// Navigate directly to chat page on successful authentication
|
||||||
|
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||||
|
Routes.chat,
|
||||||
|
(route) => false, // Remove all previous routes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ErrorBoundary(
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.pagePadding,
|
||||||
|
vertical: Spacing.lg,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header with progress indicator
|
||||||
|
_buildHeader(),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.extraLarge),
|
||||||
|
|
||||||
|
// Main content
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 500),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Server connection status
|
||||||
|
_buildServerStatus(),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.sectionGap),
|
||||||
|
|
||||||
|
// Welcome section
|
||||||
|
_buildWelcomeSection(),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.sectionGap),
|
||||||
|
|
||||||
|
// Authentication form
|
||||||
|
_buildAuthForm(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom action button
|
||||||
|
_buildSignInButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
ConduitIconButton(
|
||||||
|
icon: Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back,
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
tooltip: 'Back to server setup',
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
// Progress indicator (step 2 of 2)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.buttonPrimary,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.buttonPrimary,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
const SizedBox(width: TouchTarget.minimum), // Balance the back button
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildServerStatus() {
|
||||||
|
return ConduitCard(
|
||||||
|
isElevated: false,
|
||||||
|
padding: const EdgeInsets.all(Spacing.lg),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.successBackground,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.conduitTheme.success.withValues(alpha: 0.3),
|
||||||
|
width: BorderWidth.standard,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.checkmark_circle_fill : Icons.check_circle,
|
||||||
|
color: context.conduitTheme.success,
|
||||||
|
size: IconSize.medium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Connected to Server',
|
||||||
|
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.conduitTheme.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.xs),
|
||||||
|
Text(
|
||||||
|
Uri.parse(widget.serverConfig.url).host,
|
||||||
|
style: context.conduitTheme.bodySmall?.copyWith(
|
||||||
|
color: context.conduitTheme.textSecondary,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).animate().slideX(
|
||||||
|
begin: -0.05,
|
||||||
|
duration: AnimationDuration.messageSlide,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWelcomeSection() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
BrandService.createBrandIcon(
|
||||||
|
size: 48,
|
||||||
|
useGradient: true,
|
||||||
|
addShadow: true,
|
||||||
|
).animate().scale(
|
||||||
|
duration: AnimationDuration.pageTransition,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
Text(
|
||||||
|
'Sign In',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: context.conduitTheme.headingLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
).animate().fadeIn(
|
||||||
|
duration: AnimationDuration.pageTransition,
|
||||||
|
delay: AnimationDuration.microInteraction,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.sm),
|
||||||
|
Text(
|
||||||
|
'Enter your credentials to access your AI conversations',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: context.conduitTheme.bodyLarge?.copyWith(
|
||||||
|
color: context.conduitTheme.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
).animate().fadeIn(
|
||||||
|
duration: AnimationDuration.pageTransition,
|
||||||
|
delay: AnimationDuration.fast,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthForm() {
|
||||||
|
return ConduitCard(
|
||||||
|
isElevated: true,
|
||||||
|
padding: const EdgeInsets.all(Spacing.xl),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Authentication mode toggle
|
||||||
|
_buildAuthModeToggle(),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
|
||||||
|
// Authentication form fields
|
||||||
|
_buildAuthFields(),
|
||||||
|
|
||||||
|
if (_loginError != null) ...[
|
||||||
|
const SizedBox(height: Spacing.md),
|
||||||
|
_buildErrorMessage(_loginError!),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthModeToggle() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.surfaceContainer,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.conduitTheme.dividerColor,
|
||||||
|
width: BorderWidth.standard,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildAuthToggleOption(
|
||||||
|
icon: Platform.isIOS ? CupertinoIcons.person_circle : Icons.account_circle_outlined,
|
||||||
|
label: 'Credentials',
|
||||||
|
isSelected: !_useApiKey,
|
||||||
|
onTap: () => setState(() => _useApiKey = false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _buildAuthToggleOption(
|
||||||
|
icon: Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key_outlined,
|
||||||
|
label: 'API Key',
|
||||||
|
isSelected: _useApiKey,
|
||||||
|
onTap: () => setState(() => _useApiKey = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).animate().fadeIn(
|
||||||
|
duration: AnimationDuration.pageTransition,
|
||||||
|
delay: AnimationDuration.microInteraction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthToggleOption({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required bool isSelected,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: AnimationDuration.microInteraction,
|
||||||
|
curve: Curves.easeInOutCubic,
|
||||||
|
child: Material(
|
||||||
|
color: isSelected
|
||||||
|
? context.conduitTheme.buttonPrimary
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.button - 2),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.button - 2),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: Spacing.md,
|
||||||
|
horizontal: Spacing.sm,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: IconSize.small,
|
||||||
|
color: isSelected
|
||||||
|
? context.conduitTheme.buttonPrimaryText
|
||||||
|
: context.conduitTheme.iconSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.sm),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||||
|
color: isSelected
|
||||||
|
? context.conduitTheme.buttonPrimaryText
|
||||||
|
: context.conduitTheme.textSecondary,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthFields() {
|
||||||
|
return AnimatedSwitcher(
|
||||||
|
duration: AnimationDuration.pageTransition,
|
||||||
|
switchInCurve: Curves.easeInOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInOutCubic,
|
||||||
|
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(animation),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _useApiKey ? _buildApiKeyForm() : _buildCredentialsForm(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildApiKeyForm() {
|
||||||
|
return Column(
|
||||||
|
key: const ValueKey('api_key_form'),
|
||||||
|
children: [
|
||||||
|
AccessibleFormField(
|
||||||
|
label: 'API Key',
|
||||||
|
hint: 'sk-...',
|
||||||
|
controller: _apiKeyController,
|
||||||
|
validator: InputValidationService.combine([
|
||||||
|
InputValidationService.validateRequired,
|
||||||
|
(value) => InputValidationService.validateMinLength(
|
||||||
|
value,
|
||||||
|
10,
|
||||||
|
fieldName: 'API Key',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
semanticLabel: 'Enter your API key',
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.lock_shield : Icons.vpn_key_outlined,
|
||||||
|
color: context.conduitTheme.iconSecondary,
|
||||||
|
),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword
|
||||||
|
? (Platform.isIOS ? CupertinoIcons.eye_slash : Icons.visibility_off)
|
||||||
|
: (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility),
|
||||||
|
color: context.conduitTheme.iconSecondary,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _signIn(),
|
||||||
|
isRequired: true,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCredentialsForm() {
|
||||||
|
return Column(
|
||||||
|
key: const ValueKey('credentials_form'),
|
||||||
|
children: [
|
||||||
|
AccessibleFormField(
|
||||||
|
label: 'Username or Email',
|
||||||
|
hint: 'Enter your username or email',
|
||||||
|
controller: _usernameController,
|
||||||
|
validator: InputValidationService.combine([
|
||||||
|
InputValidationService.validateRequired,
|
||||||
|
(value) => InputValidationService.validateEmailOrUsername(value),
|
||||||
|
]),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
semanticLabel: 'Enter your username or email',
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.person : Icons.person_outline,
|
||||||
|
color: context.conduitTheme.iconSecondary,
|
||||||
|
),
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.username,
|
||||||
|
AutofillHints.email,
|
||||||
|
],
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
AccessibleFormField(
|
||||||
|
label: 'Password',
|
||||||
|
hint: 'Enter your password',
|
||||||
|
controller: _passwordController,
|
||||||
|
validator: InputValidationService.combine([
|
||||||
|
InputValidationService.validateRequired,
|
||||||
|
(value) => InputValidationService.validateMinLength(
|
||||||
|
value,
|
||||||
|
1,
|
||||||
|
fieldName: 'Password',
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
semanticLabel: 'Enter your password',
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.lock : Icons.lock_outline,
|
||||||
|
color: context.conduitTheme.iconSecondary,
|
||||||
|
),
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword
|
||||||
|
? (Platform.isIOS ? CupertinoIcons.eye_slash : Icons.visibility_off)
|
||||||
|
: (Platform.isIOS ? CupertinoIcons.eye : Icons.visibility),
|
||||||
|
color: context.conduitTheme.iconSecondary,
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _signIn(),
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
isRequired: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSignInButton() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: Spacing.lg),
|
||||||
|
child: ConduitButton(
|
||||||
|
text: _isSigningIn
|
||||||
|
? 'Signing in...'
|
||||||
|
: _useApiKey
|
||||||
|
? 'Sign in with API Key'
|
||||||
|
: 'Sign In',
|
||||||
|
icon: _isSigningIn
|
||||||
|
? null
|
||||||
|
: (Platform.isIOS ? CupertinoIcons.arrow_right : Icons.arrow_forward),
|
||||||
|
onPressed: _isSigningIn ? null : _signIn,
|
||||||
|
isLoading: _isSigningIn,
|
||||||
|
isFullWidth: true,
|
||||||
|
).animate().fadeIn(
|
||||||
|
duration: AnimationDuration.pageTransition,
|
||||||
|
delay: AnimationDuration.fast,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorMessage(String message) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(Spacing.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.errorBackground,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.conduitTheme.error.withValues(alpha: 0.3),
|
||||||
|
width: BorderWidth.standard,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.exclamationmark_circle_fill
|
||||||
|
: Icons.error_outline,
|
||||||
|
color: context.conduitTheme.error,
|
||||||
|
size: IconSize.medium,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||||
|
color: context.conduitTheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).animate().slideX(
|
||||||
|
begin: 0.05,
|
||||||
|
duration: AnimationDuration.messageSlide,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,680 +1,37 @@
|
|||||||
import 'dart:io' show Platform;
|
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../../core/widgets/error_boundary.dart';
|
||||||
import '../../../shared/services/brand_service.dart';
|
|
||||||
import '../../../shared/theme/theme_extensions.dart';
|
import '../../../shared/theme/theme_extensions.dart';
|
||||||
import '../../../shared/widgets/conduit_components.dart';
|
import '../../../shared/widgets/conduit_components.dart';
|
||||||
import '../../../core/auth/auth_state_manager.dart';
|
import 'server_connection_page.dart';
|
||||||
import '../../chat/views/chat_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});
|
const ConnectAndSignInPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<ConnectAndSignInPage> createState() =>
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
_ConnectAndSignInPageState();
|
// Directly navigate to the new mobile-first server connection page
|
||||||
}
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
|
MaterialPageRoute(
|
||||||
final _formKey = GlobalKey<FormState>();
|
builder: (_) => const ServerConnectionPage(),
|
||||||
|
),
|
||||||
// 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 {
|
// Show a simple loading state while transitioning
|
||||||
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(
|
return ErrorBoundary(
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||||
body: SafeArea(
|
body: const Center(
|
||||||
child: Center(
|
child: ConduitLoadingIndicator(
|
||||||
child: SingleChildScrollView(
|
message: 'Loading...',
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionHeader extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String? subtitle;
|
|
||||||
|
|
||||||
const _SectionHeader({
|
|
||||||
required this.icon,
|
|
||||||
required this.title,
|
|
||||||
this.subtitle,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: context.conduitTheme.iconPrimary),
|
|
||||||
const SizedBox(width: Spacing.sm),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: context.conduitTheme.headingSmall?.copyWith(
|
|
||||||
color: context.conduitTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (subtitle != null)
|
|
||||||
Text(
|
|
||||||
subtitle!,
|
|
||||||
style: context.conduitTheme.bodySmall?.copyWith(
|
|
||||||
color: context.conduitTheme.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InlineMessage extends StatelessWidget {
|
|
||||||
final String message;
|
|
||||||
final bool isError;
|
|
||||||
|
|
||||||
const _InlineMessage({required this.message, this.isError = false});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final isIOS = Platform.isIOS;
|
|
||||||
final color = isError
|
|
||||||
? context.conduitTheme.error
|
|
||||||
: context.conduitTheme.success;
|
|
||||||
final bg = isError
|
|
||||||
? context.conduitTheme.errorBackground
|
|
||||||
: context.conduitTheme.successBackground;
|
|
||||||
final icon = isError
|
|
||||||
? (isIOS
|
|
||||||
? CupertinoIcons.exclamationmark_circle_fill
|
|
||||||
: Icons.error_outline)
|
|
||||||
: (isIOS ? CupertinoIcons.check_mark_circled : Icons.check_circle);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(Spacing.cardPadding),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: bg,
|
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.card),
|
|
||||||
border: Border.all(
|
|
||||||
color: color.withValues(alpha: 0.3),
|
|
||||||
width: BorderWidth.regular,
|
|
||||||
),
|
|
||||||
boxShadow: ConduitShadows.low,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: color, size: IconSize.medium),
|
|
||||||
const SizedBox(width: Spacing.comfortable),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
message,
|
|
||||||
style: context.conduitTheme.bodyMedium?.copyWith(color: color),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// removed unused _ButtonProgress; ConduitButton provides built-in loading state
|
|
||||||
869
lib/features/auth/views/server_connection_page.dart
Normal file
869
lib/features/auth/views/server_connection_page.dart
Normal file
@@ -0,0 +1,869 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../../../core/models/server_config.dart';
|
||||||
|
import '../../../core/providers/app_providers.dart';
|
||||||
|
import '../../../core/services/api_service.dart';
|
||||||
|
import '../../../core/services/input_validation_service.dart';
|
||||||
|
import '../../../core/widgets/error_boundary.dart';
|
||||||
|
import '../../../shared/services/brand_service.dart';
|
||||||
|
import '../../../shared/theme/theme_extensions.dart';
|
||||||
|
import '../../../shared/widgets/conduit_components.dart';
|
||||||
|
import '../../chat/views/chat_page.dart';
|
||||||
|
import 'authentication_page.dart';
|
||||||
|
|
||||||
|
class ServerConnectionPage extends ConsumerStatefulWidget {
|
||||||
|
const ServerConnectionPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ServerConnectionPage> createState() =>
|
||||||
|
_ServerConnectionPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServerConnectionPageState extends ConsumerState<ServerConnectionPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final TextEditingController _urlController = TextEditingController();
|
||||||
|
final Map<String, String> _customHeaders = {};
|
||||||
|
final TextEditingController _headerKeyController = TextEditingController();
|
||||||
|
final TextEditingController _headerValueController = TextEditingController();
|
||||||
|
|
||||||
|
String? _connectionError;
|
||||||
|
bool _isConnecting = false;
|
||||||
|
bool _showAdvancedSettings = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_prefillFromState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _prefillFromState() async {
|
||||||
|
final activeServer = await ref.read(activeServerProvider.future);
|
||||||
|
if (activeServer != null) {
|
||||||
|
_urlController.text = activeServer.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_urlController.dispose();
|
||||||
|
_headerKeyController.dispose();
|
||||||
|
_headerValueController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _connectToServer() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isConnecting = true;
|
||||||
|
_connectionError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
String url = _validateAndFormatUrl(_urlController.text.trim());
|
||||||
|
|
||||||
|
final tempConfig = ServerConfig(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
name: _deriveServerNameFromUrl(url),
|
||||||
|
url: url,
|
||||||
|
customHeaders: Map<String, String>.from(_customHeaders),
|
||||||
|
isActive: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
final api = ApiService(serverConfig: tempConfig);
|
||||||
|
final isHealthy = await api.checkHealth();
|
||||||
|
if (!isHealthy) {
|
||||||
|
throw Exception('This does not appear to be an Open-WebUI server.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveServerConfig(tempConfig);
|
||||||
|
|
||||||
|
// Navigate to authentication page
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => AuthenticationPage(serverConfig: tempConfig),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_connectionError = _formatConnectionError(e.toString());
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isConnecting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveServerConfig(ServerConfig config) async {
|
||||||
|
final storage = ref.read(optimizedStorageServiceProvider);
|
||||||
|
await storage.saveServerConfigs([config]);
|
||||||
|
await storage.setActiveServerId(config.id);
|
||||||
|
ref.invalidate(serverConfigsProvider);
|
||||||
|
ref.invalidate(activeServerProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _validateAndFormatUrl(String input) {
|
||||||
|
if (input.isEmpty) {
|
||||||
|
throw Exception('Server URL cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the input
|
||||||
|
String url = input.trim();
|
||||||
|
|
||||||
|
// Add protocol if missing
|
||||||
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||||
|
url = 'http://$url';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash
|
||||||
|
if (url.endsWith('/')) {
|
||||||
|
url = url.substring(0, url.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate the URI
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null) {
|
||||||
|
throw Exception('Invalid URL format. Please check your input.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate scheme
|
||||||
|
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||||
|
throw Exception('Only HTTP and HTTPS protocols are supported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate host
|
||||||
|
if (uri.host.isEmpty) {
|
||||||
|
throw Exception('Server address is required (e.g., 192.168.1.10 or example.com).');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate port if specified
|
||||||
|
if (uri.hasPort) {
|
||||||
|
if (uri.port < 1 || uri.port > 65535) {
|
||||||
|
throw Exception('Port must be between 1 and 65535.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IP address format if it looks like an IP
|
||||||
|
if (_isIPAddress(uri.host) && !_isValidIPAddress(uri.host)) {
|
||||||
|
throw Exception('Invalid IP address format. Use format like 192.168.1.10.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isIPAddress(String host) {
|
||||||
|
return RegExp(r'^\d+\.\d+\.\d+\.\d+$').hasMatch(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isValidIPAddress(String ip) {
|
||||||
|
final parts = ip.split('.');
|
||||||
|
if (parts.length != 4) return false;
|
||||||
|
|
||||||
|
for (final part in parts) {
|
||||||
|
final num = int.tryParse(part);
|
||||||
|
if (num == null || num < 0 || num > 255) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _deriveServerNameFromUrl(String url) {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
if (uri.host.isNotEmpty) return uri.host;
|
||||||
|
} catch (_) {}
|
||||||
|
return 'Server';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatConnectionError(String error) {
|
||||||
|
// Clean up the error message
|
||||||
|
String cleanError = error.replaceFirst('Exception: ', '');
|
||||||
|
|
||||||
|
// Handle specific error types
|
||||||
|
if (error.contains('SocketException')) {
|
||||||
|
return 'We couldn\'t reach the server. Check your connection and that the server is running.';
|
||||||
|
} else if (error.contains('timeout')) {
|
||||||
|
return 'Connection timed out. The server might be busy or blocked by a firewall.';
|
||||||
|
} else if (error.contains('Server URL cannot be empty')) {
|
||||||
|
return 'Please enter a server address.';
|
||||||
|
} else if (error.contains('Invalid URL format')) {
|
||||||
|
return 'Invalid server address format. Examples:\n• 192.168.1.10:3000\n• example.com\n• https://myserver.com';
|
||||||
|
} else if (error.contains('Only HTTP and HTTPS')) {
|
||||||
|
return 'Use http:// or https:// only.';
|
||||||
|
} else if (error.contains('Server address is required')) {
|
||||||
|
return cleanError;
|
||||||
|
} else if (error.contains('Port must be between')) {
|
||||||
|
return cleanError;
|
||||||
|
} else if (error.contains('Invalid IP address format')) {
|
||||||
|
return cleanError;
|
||||||
|
} else if (error.contains('This does not appear to be an Open-WebUI server')) {
|
||||||
|
return 'This server doesn\'t appear to be running Open-WebUI. Please check the address.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Couldn\'t connect. Double-check the address and try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||||
|
|
||||||
|
return ErrorBoundary(
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.pagePadding,
|
||||||
|
vertical: Spacing.lg,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header with progress indicator
|
||||||
|
_buildHeader(),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.extraLarge),
|
||||||
|
|
||||||
|
// Main content
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 500),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Brand header
|
||||||
|
_buildBrandHeader(reviewerMode),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.sectionGap),
|
||||||
|
|
||||||
|
// Welcome section
|
||||||
|
_buildWelcomeSection(),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.sectionGap),
|
||||||
|
|
||||||
|
// Reviewer mode demo (if enabled)
|
||||||
|
if (reviewerMode) ...[
|
||||||
|
_buildReviewerModeSection(),
|
||||||
|
const SizedBox(height: Spacing.sectionGap),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Server connection form
|
||||||
|
_buildServerForm(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom action button
|
||||||
|
_buildConnectButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Progress indicator (step 1 of 2)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.buttonPrimary,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.dividerColor,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBrandHeader(bool reviewerMode) {
|
||||||
|
return GestureDetector(
|
||||||
|
onLongPress: () async {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
await ref.read(reviewerModeProvider.notifier).toggle();
|
||||||
|
if (!mounted) return;
|
||||||
|
final enabled = ref.read(reviewerModeProvider);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
enabled
|
||||||
|
? 'Reviewer Mode enabled: Demo without server'
|
||||||
|
: 'Reviewer Mode disabled',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
// Glow effect
|
||||||
|
Container(
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: [
|
||||||
|
context.conduitTheme.buttonPrimary.withValues(alpha: 0.12),
|
||||||
|
context.conduitTheme.buttonPrimary.withValues(alpha: 0.06),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.7, 1.0],
|
||||||
|
radius: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Brand logo
|
||||||
|
BrandService.createBrandIcon(
|
||||||
|
size: 64,
|
||||||
|
useGradient: true,
|
||||||
|
addShadow: true,
|
||||||
|
),
|
||||||
|
// Reviewer mode badge
|
||||||
|
if (reviewerMode)
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
child: ConduitBadge(
|
||||||
|
text: 'Demo',
|
||||||
|
backgroundColor: context.conduitTheme.warning.withValues(alpha: 0.15),
|
||||||
|
textColor: context.conduitTheme.warning,
|
||||||
|
isCompact: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).animate().scale(
|
||||||
|
duration: AnimationDuration.pageTransition,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWelcomeSection() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Connect to Server',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: context.conduitTheme.headingLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
).animate().fadeIn(
|
||||||
|
duration: AnimationDuration.pageTransition,
|
||||||
|
delay: AnimationDuration.microInteraction,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.sm),
|
||||||
|
Text(
|
||||||
|
'Enter your Open-WebUI server address to get started',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: context.conduitTheme.bodyLarge?.copyWith(
|
||||||
|
color: context.conduitTheme.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
).animate().fadeIn(
|
||||||
|
duration: AnimationDuration.pageTransition,
|
||||||
|
delay: AnimationDuration.fast,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReviewerModeSection() {
|
||||||
|
return ConduitCard(
|
||||||
|
isElevated: false,
|
||||||
|
padding: const EdgeInsets.all(Spacing.lg),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.wand_stars : Icons.auto_awesome,
|
||||||
|
color: context.conduitTheme.warning,
|
||||||
|
size: IconSize.medium,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Demo Mode Active',
|
||||||
|
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: context.conduitTheme.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.xs),
|
||||||
|
Text(
|
||||||
|
'Skip server setup and try the demo',
|
||||||
|
style: context.conduitTheme.bodySmall?.copyWith(
|
||||||
|
color: context.conduitTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
ConduitButton(
|
||||||
|
text: 'Enter Demo',
|
||||||
|
icon: Platform.isIOS ? CupertinoIcons.play_fill : Icons.play_arrow,
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const ChatPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
isSecondary: true,
|
||||||
|
isFullWidth: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildServerForm() {
|
||||||
|
return ConduitCard(
|
||||||
|
isElevated: true,
|
||||||
|
padding: const EdgeInsets.all(Spacing.xl),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AccessibleFormField(
|
||||||
|
label: 'Server URL',
|
||||||
|
hint: 'https://your-server.com',
|
||||||
|
controller: _urlController,
|
||||||
|
validator: InputValidationService.combine([
|
||||||
|
InputValidationService.validateRequired,
|
||||||
|
(value) => InputValidationService.validateUrl(value, required: true),
|
||||||
|
]),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
semanticLabel: 'Enter your server URL or IP address',
|
||||||
|
onSubmitted: (_) => _connectToServer(),
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.globe : Icons.public,
|
||||||
|
color: context.conduitTheme.iconSecondary,
|
||||||
|
),
|
||||||
|
autofillHints: const [AutofillHints.url],
|
||||||
|
isRequired: true,
|
||||||
|
).animate().slideX(
|
||||||
|
begin: -0.05,
|
||||||
|
duration: AnimationDuration.messageSlide,
|
||||||
|
delay: AnimationDuration.microInteraction,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_connectionError != null) ...[
|
||||||
|
const SizedBox(height: Spacing.md),
|
||||||
|
_buildErrorMessage(_connectionError!),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
|
||||||
|
// Advanced settings
|
||||||
|
_buildAdvancedSettings(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAdvancedSettings() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
ConduitCard(
|
||||||
|
isElevated: false,
|
||||||
|
padding: const EdgeInsets.all(Spacing.lg),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () => setState(() => _showAdvancedSettings = !_showAdvancedSettings),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: Spacing.sm),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.gear : Icons.tune,
|
||||||
|
color: context.conduitTheme.iconSecondary,
|
||||||
|
size: IconSize.medium,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Advanced Settings',
|
||||||
|
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_customHeaders.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'${_customHeaders.length} custom header${_customHeaders.length != 1 ? 's' : ''}',
|
||||||
|
style: context.conduitTheme.bodySmall?.copyWith(
|
||||||
|
color: context.conduitTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedRotation(
|
||||||
|
duration: AnimationDuration.microInteraction,
|
||||||
|
turns: _showAdvancedSettings ? 0.5 : 0,
|
||||||
|
child: Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.chevron_down : Icons.expand_more,
|
||||||
|
color: context.conduitTheme.iconSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: AnimationDuration.microInteraction,
|
||||||
|
curve: Curves.easeInOutCubic,
|
||||||
|
child: _showAdvancedSettings ? _buildAdvancedSettingsContent() : const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAdvancedSettingsContent() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
ConduitDivider(),
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Custom Headers',
|
||||||
|
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_customHeaders.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'${_customHeaders.length}/10',
|
||||||
|
style: context.conduitTheme.bodySmall?.copyWith(
|
||||||
|
color: _customHeaders.length >= 10
|
||||||
|
? context.conduitTheme.error
|
||||||
|
: context.conduitTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.xs),
|
||||||
|
Text(
|
||||||
|
'Add custom HTTP headers for authentication, API keys, or special server requirements.',
|
||||||
|
style: context.conduitTheme.bodySmall?.copyWith(
|
||||||
|
color: context.conduitTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.md),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: AccessibleFormField(
|
||||||
|
label: 'Header Name',
|
||||||
|
hint: 'X-Custom-Header',
|
||||||
|
controller: _headerKeyController,
|
||||||
|
validator: (value) => _validateHeaderKey(value ?? ''),
|
||||||
|
semanticLabel: 'Enter header name',
|
||||||
|
isCompact: true,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.md),
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: AccessibleFormField(
|
||||||
|
label: 'Header Value',
|
||||||
|
hint: 'api-key-123 or Bearer token',
|
||||||
|
controller: _headerValueController,
|
||||||
|
validator: (value) => _validateHeaderValue(value ?? ''),
|
||||||
|
semanticLabel: 'Enter header value',
|
||||||
|
isCompact: true,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.md),
|
||||||
|
ConduitIconButton(
|
||||||
|
icon: Platform.isIOS ? CupertinoIcons.plus : Icons.add,
|
||||||
|
onPressed: _customHeaders.length >= 10 ? null : _addCustomHeader,
|
||||||
|
tooltip: _customHeaders.length >= 10
|
||||||
|
? 'Maximum headers reached'
|
||||||
|
: 'Add header',
|
||||||
|
backgroundColor: _customHeaders.length >= 10
|
||||||
|
? context.conduitTheme.surfaceContainer
|
||||||
|
: context.conduitTheme.buttonPrimary,
|
||||||
|
iconColor: _customHeaders.length >= 10
|
||||||
|
? context.conduitTheme.textDisabled
|
||||||
|
: context.conduitTheme.buttonPrimaryText,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_customHeaders.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
_buildCustomHeadersList(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCustomHeadersList() {
|
||||||
|
return Column(
|
||||||
|
children: _customHeaders.entries.map((entry) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: Spacing.sm),
|
||||||
|
padding: const EdgeInsets.all(Spacing.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.conduitTheme.dividerColor,
|
||||||
|
width: BorderWidth.standard,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ConduitBadge(
|
||||||
|
text: entry.key,
|
||||||
|
backgroundColor: context.conduitTheme.buttonPrimary.withValues(alpha: 0.1),
|
||||||
|
textColor: context.conduitTheme.buttonPrimary,
|
||||||
|
isCompact: true,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
entry.value,
|
||||||
|
style: context.conduitTheme.bodySmall?.copyWith(
|
||||||
|
color: context.conduitTheme.textSecondary,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.md),
|
||||||
|
ConduitIconButton(
|
||||||
|
icon: Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
||||||
|
onPressed: () => _removeCustomHeader(entry.key),
|
||||||
|
tooltip: 'Remove header',
|
||||||
|
backgroundColor: context.conduitTheme.error.withValues(alpha: 0.1),
|
||||||
|
iconColor: context.conduitTheme.error,
|
||||||
|
isCompact: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).animate().fadeIn(duration: AnimationDuration.microInteraction);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildConnectButton() {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: Spacing.lg),
|
||||||
|
child: ConduitButton(
|
||||||
|
text: _isConnecting ? 'Connecting...' : 'Connect to Server',
|
||||||
|
icon: _isConnecting
|
||||||
|
? null
|
||||||
|
: (Platform.isIOS ? CupertinoIcons.arrow_right : Icons.arrow_forward),
|
||||||
|
onPressed: _isConnecting ? null : _connectToServer,
|
||||||
|
isLoading: _isConnecting,
|
||||||
|
isFullWidth: true,
|
||||||
|
).animate().fadeIn(
|
||||||
|
duration: AnimationDuration.pageTransition,
|
||||||
|
delay: AnimationDuration.fast,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildErrorMessage(String message) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(Spacing.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.conduitTheme.errorBackground,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.button),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.conduitTheme.error.withValues(alpha: 0.3),
|
||||||
|
width: BorderWidth.standard,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.exclamationmark_circle_fill
|
||||||
|
: Icons.error_outline,
|
||||||
|
color: context.conduitTheme.error,
|
||||||
|
size: IconSize.medium,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||||
|
color: context.conduitTheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).animate().slideX(
|
||||||
|
begin: 0.05,
|
||||||
|
duration: AnimationDuration.messageSlide,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addCustomHeader() {
|
||||||
|
final key = _headerKeyController.text.trim();
|
||||||
|
final value = _headerValueController.text.trim();
|
||||||
|
|
||||||
|
if (key.isEmpty || value.isEmpty) return;
|
||||||
|
|
||||||
|
// Validate header name
|
||||||
|
final keyValidation = _validateHeaderKey(key);
|
||||||
|
if (keyValidation != null) {
|
||||||
|
_showHeaderError(keyValidation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate header value
|
||||||
|
final valueValidation = _validateHeaderValue(value);
|
||||||
|
if (valueValidation != null) {
|
||||||
|
_showHeaderError(valueValidation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
if (_customHeaders.containsKey(key)) {
|
||||||
|
_showHeaderError('Header "$key" already exists. Remove it first to update.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check header count limit
|
||||||
|
if (_customHeaders.length >= 10) {
|
||||||
|
_showHeaderError('Maximum of 10 custom headers allowed. Remove some to add more.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_customHeaders[key] = value;
|
||||||
|
_headerKeyController.clear();
|
||||||
|
_headerValueController.clear();
|
||||||
|
});
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateHeaderKey(String key) {
|
||||||
|
// RFC 7230 compliant header name validation
|
||||||
|
if (key.isEmpty) return 'Header name cannot be empty';
|
||||||
|
if (key.length > 64) return 'Header name too long (max 64 characters)';
|
||||||
|
|
||||||
|
// Check for valid characters (RFC 7230: token characters)
|
||||||
|
if (!RegExp(r'^[a-zA-Z0-9!#$&\-^_`|~]+$').hasMatch(key)) {
|
||||||
|
return 'Invalid header name. Use only letters, numbers, and these symbols: !#\$&-^_`|~';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reserved headers that should not be overridden
|
||||||
|
final lowerKey = key.toLowerCase();
|
||||||
|
final reservedHeaders = {
|
||||||
|
'authorization', 'content-type', 'content-length', 'host',
|
||||||
|
'user-agent', 'accept', 'accept-encoding', 'connection',
|
||||||
|
'transfer-encoding', 'upgrade', 'via', 'warning'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reservedHeaders.contains(lowerKey)) {
|
||||||
|
return 'Cannot override reserved header "$key"';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _validateHeaderValue(String value) {
|
||||||
|
if (value.isEmpty) return 'Header value cannot be empty';
|
||||||
|
if (value.length > 1024) return 'Header value too long (max 1024 characters)';
|
||||||
|
|
||||||
|
// Check for valid characters (no control characters except tab)
|
||||||
|
for (int i = 0; i < value.length; i++) {
|
||||||
|
final char = value.codeUnitAt(i);
|
||||||
|
// Allow printable ASCII (32-126) and tab (9)
|
||||||
|
if (char != 9 && (char < 32 || char > 126)) {
|
||||||
|
return 'Header value contains invalid characters. Use only printable ASCII.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for security-sensitive patterns
|
||||||
|
if (value.toLowerCase().contains('script') ||
|
||||||
|
value.contains('<') ||
|
||||||
|
value.contains('>')) {
|
||||||
|
return 'Header value appears to contain potentially unsafe content';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showHeaderError(String message) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor: context.conduitTheme.error,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeCustomHeader(String key) {
|
||||||
|
setState(() {
|
||||||
|
_customHeaders.remove(key);
|
||||||
|
});
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1169,11 +1169,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Flexible(
|
||||||
_formatModelDisplayName(selectedModel.name),
|
child: Text(
|
||||||
style: AppTypography.headlineSmallStyle.copyWith(
|
_formatModelDisplayName(selectedModel.name),
|
||||||
color: context.conduitTheme.textPrimary,
|
style: AppTypography.headlineSmallStyle.copyWith(
|
||||||
fontWeight: FontWeight.w400,
|
color: context.conduitTheme.textPrimary,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: Spacing.xs),
|
const SizedBox(width: Spacing.xs),
|
||||||
@@ -1214,11 +1218,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Flexible(
|
||||||
'Choose Model',
|
child: Text(
|
||||||
style: AppTypography.headlineSmallStyle.copyWith(
|
'Choose Model',
|
||||||
color: context.conduitTheme.textPrimary,
|
style: AppTypography.headlineSmallStyle.copyWith(
|
||||||
fontWeight: FontWeight.w400,
|
color: context.conduitTheme.textPrimary,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: Spacing.xs),
|
const SizedBox(width: Spacing.xs),
|
||||||
|
|||||||
@@ -9,17 +9,15 @@ import '../theme/app_theme.dart';
|
|||||||
class BrandService {
|
class BrandService {
|
||||||
BrandService._();
|
BrandService._();
|
||||||
|
|
||||||
/// Primary brand icon - the hub icon
|
/// Primary brand icon - the hub icon (consistent across platforms)
|
||||||
static IconData get primaryIcon =>
|
static IconData get primaryIcon => Icons.hub;
|
||||||
Platform.isIOS ? CupertinoIcons.link_circle_fill : Icons.hub;
|
|
||||||
|
|
||||||
/// Alternative brand icons for different contexts
|
/// Alternative brand icons for different contexts
|
||||||
static IconData get primaryIconOutlined =>
|
static IconData get primaryIconOutlined => Icons.hub_outlined;
|
||||||
Platform.isIOS ? CupertinoIcons.link_circle : Icons.hub_outlined;
|
|
||||||
static IconData get connectivityIcon =>
|
static IconData get connectivityIcon =>
|
||||||
Platform.isIOS ? CupertinoIcons.wifi : Icons.hub;
|
Platform.isIOS ? CupertinoIcons.wifi : Icons.wifi;
|
||||||
static IconData get networkIcon =>
|
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
|
/// Brand colors - these should be accessed through context.conduitTheme in UI components
|
||||||
static Color get primaryBrandColor => AppTheme.brandPrimary;
|
static Color get primaryBrandColor => AppTheme.brandPrimary;
|
||||||
@@ -231,13 +229,15 @@ class BrandService {
|
|||||||
return AppBar(
|
return AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
title,
|
title,
|
||||||
style: (context != null ? context.conduitTheme.headingSmall : null)
|
style: context != null
|
||||||
?.copyWith(
|
? context.conduitTheme.headingSmall?.copyWith(
|
||||||
color: (context != null
|
color: context.conduitTheme.textPrimary,
|
||||||
? context.conduitTheme.textPrimary
|
fontWeight: FontWeight.w600,
|
||||||
: null),
|
)
|
||||||
fontWeight: FontWeight.w600,
|
: TextStyle(
|
||||||
),
|
fontSize: AppTypography.headlineSmall,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
centerTitle: centerTitle,
|
centerTitle: centerTitle,
|
||||||
elevation: elevation,
|
elevation: elevation,
|
||||||
|
|||||||
@@ -741,17 +741,20 @@ class AccessibleFormField extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (label != null) ...[
|
if (label != null) ...[
|
||||||
Row(
|
Wrap(
|
||||||
|
spacing: Spacing.textSpacing,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label!,
|
label!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: AppTypography.standard.copyWith(
|
style: AppTypography.standard.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: context.conduitTheme.textPrimary,
|
color: context.conduitTheme.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isRequired) ...[
|
if (isRequired)
|
||||||
SizedBox(width: Spacing.textSpacing),
|
|
||||||
Text(
|
Text(
|
||||||
'*',
|
'*',
|
||||||
style: AppTypography.standard.copyWith(
|
style: AppTypography.standard.copyWith(
|
||||||
@@ -759,7 +762,6 @@ class AccessibleFormField extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
|
SizedBox(height: isCompact ? Spacing.xs : Spacing.sm),
|
||||||
|
|||||||
20
pubspec.lock
20
pubspec.lock
@@ -652,26 +652,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.9"
|
version: "11.0.1"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.9"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1201,10 +1201,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.6"
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1337,10 +1337,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
Reference in New Issue
Block a user