mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 18:35:00 -04:00 
			
		
		
		
	feat(mobile): Enter server first for login (#1952)
* improves login form * login form improvements * correctly trim server endpoint controller text when logging in * don't show loading while fetching server info * fixes get server login credentials * fixes up sign in form * error handling * fixed layout * removed placeholder text
This commit is contained in:
		
							parent
							
								
									2ca560ebf8
								
							
						
					
					
						commit
						a4c215751e
					
				| @ -157,8 +157,11 @@ | |||||||
|   "login_form_failed_login": "Error logging you in, check server URL, email and password", |   "login_form_failed_login": "Error logging you in, check server URL, email and password", | ||||||
|   "login_form_label_email": "Email", |   "login_form_label_email": "Email", | ||||||
|   "login_form_label_password": "Password", |   "login_form_label_password": "Password", | ||||||
|   "login_form_password_hint": "password", |   "login_form_password_hint": "Password", | ||||||
|   "login_form_save_login": "Stay logged in", |   "login_form_save_login": "Stay logged in", | ||||||
|  |   "login_form_server_empty": "Enter a server URL.", | ||||||
|  |   "login_form_server_error": "Could not connect to server.", | ||||||
|  |   "login_form_api_exception": "API exception. Please check the server URL and try again.", | ||||||
|   "monthly_title_text_date_format": "MMMM y", |   "monthly_title_text_date_format": "MMMM y", | ||||||
|   "notification_permission_dialog_cancel": "Cancel", |   "notification_permission_dialog_cancel": "Cancel", | ||||||
|   "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", |   "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", | ||||||
|  | |||||||
| @ -32,48 +32,78 @@ class LoginForm extends HookConsumerWidget { | |||||||
|     final serverEndpointController = |     final serverEndpointController = | ||||||
|         useTextEditingController.fromValue(TextEditingValue.empty); |         useTextEditingController.fromValue(TextEditingValue.empty); | ||||||
|     final apiService = ref.watch(apiServiceProvider); |     final apiService = ref.watch(apiServiceProvider); | ||||||
|  |     final emailFocusNode = useFocusNode(); | ||||||
|  |     final passwordFocusNode = useFocusNode(); | ||||||
|     final serverEndpointFocusNode = useFocusNode(); |     final serverEndpointFocusNode = useFocusNode(); | ||||||
|     final isLoading = useState<bool>(false); |     final isLoading = useState<bool>(false); | ||||||
|  |     final isLoadingServer = useState<bool>(false); | ||||||
|     final isOauthEnable = useState<bool>(false); |     final isOauthEnable = useState<bool>(false); | ||||||
|     final oAuthButtonLabel = useState<String>('OAuth'); |     final oAuthButtonLabel = useState<String>('OAuth'); | ||||||
|     final logoAnimationController = useAnimationController( |     final logoAnimationController = useAnimationController( | ||||||
|       duration: const Duration(seconds: 60), |       duration: const Duration(seconds: 60), | ||||||
|     )..repeat(); |     )..repeat(); | ||||||
| 
 | 
 | ||||||
|     getServeLoginConfig() async { |     final ValueNotifier<String?> serverEndpoint = useState<String?>(null); | ||||||
|       if (!serverEndpointFocusNode.hasFocus) { |  | ||||||
|         var serverUrl = serverEndpointController.text.trim(); |  | ||||||
| 
 | 
 | ||||||
|         try { |     /// Fetch the server login credential and enables oAuth login if necessary | ||||||
|           if (serverUrl.isNotEmpty) { |     /// Returns true if successful, false otherwise | ||||||
|             isLoading.value = true; |     Future<bool> getServerLoginCredential() async { | ||||||
|             final serverEndpoint = |       final serverUrl = serverEndpointController.text.trim(); | ||||||
|                 await apiService.resolveAndSetEndpoint(serverUrl.toString()); |  | ||||||
| 
 | 
 | ||||||
|             var loginConfig = await apiService.oAuthApi.generateConfig( |       // Guard empty URL | ||||||
|               OAuthConfigDto(redirectUri: serverEndpoint), |       if (serverUrl.isEmpty) { | ||||||
|             ); |         ImmichToast.show( | ||||||
|  |           context: context, | ||||||
|  |           msg: "login_form_server_empty".tr(), | ||||||
|  |           toastType: ToastType.error, | ||||||
|  |         ); | ||||||
|  |   | ||||||
|  |         return false; | ||||||
|  |       } | ||||||
| 
 | 
 | ||||||
|             if (loginConfig != null) { |       try { | ||||||
|               isOauthEnable.value = loginConfig.enabled; |         isLoadingServer.value = true; | ||||||
|               oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth'; |         final endpoint =  | ||||||
|             } else { |             await apiService.resolveAndSetEndpoint(serverUrl); | ||||||
|               isOauthEnable.value = false; |  | ||||||
|             } |  | ||||||
| 
 | 
 | ||||||
|             isLoading.value = false; |         final loginConfig = await apiService.oAuthApi.generateConfig( | ||||||
|           } |           OAuthConfigDto(redirectUri: serverUrl), | ||||||
|         } catch (_) { |         ); | ||||||
|           isLoading.value = false; | 
 | ||||||
|  |         if (loginConfig != null) { | ||||||
|  |           isOauthEnable.value = loginConfig.enabled; | ||||||
|  |           oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth'; | ||||||
|  |         } else { | ||||||
|           isOauthEnable.value = false; |           isOauthEnable.value = false; | ||||||
|         } |         } | ||||||
|       } | 
 | ||||||
|  |         serverEndpoint.value = endpoint; | ||||||
|  |       } on ApiException catch (e) { | ||||||
|  |         ImmichToast.show( | ||||||
|  |           context: context, | ||||||
|  |           msg: e.message ?? 'login_form_api_exception'.tr(), | ||||||
|  |           toastType: ToastType.error, | ||||||
|  |         ); | ||||||
|  |         isOauthEnable.value = false; | ||||||
|  |         isLoadingServer.value = false; | ||||||
|  |         return false; | ||||||
|  |       } catch (e) { | ||||||
|  |         ImmichToast.show( | ||||||
|  |           context: context, | ||||||
|  |           msg: 'login_form_server_error'.tr(), | ||||||
|  |           toastType: ToastType.error, | ||||||
|  |         ); | ||||||
|  |         isOauthEnable.value = false; | ||||||
|  |         isLoadingServer.value = false; | ||||||
|  |         return false; | ||||||
|  |       }  | ||||||
|  | 
 | ||||||
|  |       isLoadingServer.value = false; | ||||||
|  |       return true; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     useEffect( |     useEffect( | ||||||
|       () { |       () { | ||||||
|         serverEndpointFocusNode.addListener(getServeLoginConfig); |  | ||||||
| 
 |  | ||||||
|         var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox) |         var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox) | ||||||
|             .get(savedLoginInfoKey); |             .get(savedLoginInfoKey); | ||||||
| 
 | 
 | ||||||
| @ -83,7 +113,6 @@ class LoginForm extends HookConsumerWidget { | |||||||
|           serverEndpointController.text = loginInfo.serverUrl; |           serverEndpointController.text = loginInfo.serverUrl; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         getServeLoginConfig(); |  | ||||||
|         return null; |         return null; | ||||||
|       }, |       }, | ||||||
|       [], |       [], | ||||||
| @ -95,215 +124,20 @@ class LoginForm extends HookConsumerWidget { | |||||||
|       serverEndpointController.text = 'http://10.1.15.216:2283/api'; |       serverEndpointController.text = 'http://10.1.15.216:2283/api'; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return Center( |     login() async { | ||||||
|       child: ConstrainedBox( |       // Start loading | ||||||
|         constraints: const BoxConstraints(maxWidth: 300), |       isLoading.value = true; | ||||||
|         child: SingleChildScrollView( |  | ||||||
|           child: AutofillGroup( |  | ||||||
|             child: Wrap( |  | ||||||
|               spacing: 16, |  | ||||||
|               runSpacing: 16, |  | ||||||
|               alignment: WrapAlignment.center, |  | ||||||
|               children: [ |  | ||||||
|                 GestureDetector( |  | ||||||
|                   onDoubleTap: () => populateTestLoginInfo(), |  | ||||||
|                   child: RotationTransition( |  | ||||||
|                     turns: logoAnimationController, |  | ||||||
|                     child: const ImmichLogo( |  | ||||||
|                       heroTag: 'logo', |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                 ), |  | ||||||
|                 const ImmichTitleText(), |  | ||||||
|                 EmailInput(controller: usernameController), |  | ||||||
|                 PasswordInput(controller: passwordController), |  | ||||||
|                 ServerEndpointInput( |  | ||||||
|                   controller: serverEndpointController, |  | ||||||
|                   focusNode: serverEndpointFocusNode, |  | ||||||
|                 ), |  | ||||||
|                 if (isLoading.value) |  | ||||||
|                   const SizedBox( |  | ||||||
|                     width: 24, |  | ||||||
|                     height: 24, |  | ||||||
|                     child: CircularProgressIndicator( |  | ||||||
|                       strokeWidth: 2, |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                 if (!isLoading.value) |  | ||||||
|                   Column( |  | ||||||
|                     crossAxisAlignment: CrossAxisAlignment.stretch, |  | ||||||
|                     mainAxisAlignment: MainAxisAlignment.center, |  | ||||||
|                     children: [ |  | ||||||
|                       const SizedBox(height: 18), |  | ||||||
|                       LoginButton( |  | ||||||
|                         emailController: usernameController, |  | ||||||
|                         passwordController: passwordController, |  | ||||||
|                         serverEndpointController: serverEndpointController, |  | ||||||
|                       ), |  | ||||||
|                       if (isOauthEnable.value) ...[ |  | ||||||
|                         Padding( |  | ||||||
|                           padding: const EdgeInsets.symmetric( |  | ||||||
|                             horizontal: 16.0, |  | ||||||
|                           ), |  | ||||||
|                           child: Divider( |  | ||||||
|                             color: |  | ||||||
|                                 Brightness.dark == Theme.of(context).brightness |  | ||||||
|                                     ? Colors.white |  | ||||||
|                                     : Colors.black, |  | ||||||
|                           ), |  | ||||||
|                         ), |  | ||||||
|                         OAuthLoginButton( |  | ||||||
|                           serverEndpointController: serverEndpointController, |  | ||||||
|                           buttonLabel: oAuthButtonLabel.value, |  | ||||||
|                           isLoading: isLoading, |  | ||||||
|                           onLoginSuccess: () { |  | ||||||
|                             isLoading.value = false; |  | ||||||
|                             final permission = ref.watch(galleryPermissionNotifier); |  | ||||||
|                             if (permission.isGranted || permission.isLimited) { |  | ||||||
|                               ref.watch(backupProvider.notifier).resumeBackup(); |  | ||||||
|                             } |  | ||||||
|                             AutoRouter.of(context).replace( |  | ||||||
|                               const TabControllerRoute(), |  | ||||||
|                             ); |  | ||||||
|                           }, |  | ||||||
|                         ), |  | ||||||
|                       ], |  | ||||||
|                     ], |  | ||||||
|                   ) |  | ||||||
|               ], |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|         ), |  | ||||||
|       ), |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| class ServerEndpointInput extends StatelessWidget { |       // This will remove current cache asset state of previous user login. | ||||||
|   final TextEditingController controller; |       ref.read(assetProvider.notifier).clearAllAsset(); | ||||||
|   final FocusNode focusNode; |  | ||||||
|   const ServerEndpointInput({ |  | ||||||
|     Key? key, |  | ||||||
|     required this.controller, |  | ||||||
|     required this.focusNode, |  | ||||||
|   }) : super(key: key); |  | ||||||
| 
 |  | ||||||
|   String? _validateInput(String? url) { |  | ||||||
|     if (url == null || url.isEmpty) return null; |  | ||||||
| 
 |  | ||||||
|     final parsedUrl = Uri.tryParse(sanitizeUrl(url)); |  | ||||||
|     if (parsedUrl == null || |  | ||||||
|         !parsedUrl.isAbsolute || |  | ||||||
|         !parsedUrl.scheme.startsWith("http") || |  | ||||||
|         parsedUrl.host.isEmpty) { |  | ||||||
|       return 'login_form_err_invalid_url'.tr(); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     return null; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) { |  | ||||||
|     return TextFormField( |  | ||||||
|       controller: controller, |  | ||||||
|       decoration: InputDecoration( |  | ||||||
|         labelText: 'login_form_endpoint_url'.tr(), |  | ||||||
|         border: const OutlineInputBorder(), |  | ||||||
|         hintText: 'login_form_endpoint_hint'.tr(), |  | ||||||
|         errorMaxLines: 4, |  | ||||||
|       ), |  | ||||||
|       validator: _validateInput, |  | ||||||
|       autovalidateMode: AutovalidateMode.always, |  | ||||||
|       focusNode: focusNode, |  | ||||||
|       autofillHints: const [AutofillHints.url], |  | ||||||
|       keyboardType: TextInputType.url, |  | ||||||
|       autocorrect: false, |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| class EmailInput extends StatelessWidget { |  | ||||||
|   final TextEditingController controller; |  | ||||||
| 
 |  | ||||||
|   const EmailInput({Key? key, required this.controller}) : super(key: key); |  | ||||||
| 
 |  | ||||||
|   String? _validateInput(String? email) { |  | ||||||
|     if (email == null || email == '') return null; |  | ||||||
|     if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr(); |  | ||||||
|     if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr(); |  | ||||||
|     if (email.contains(' ') || !email.contains('@')) { |  | ||||||
|       return 'login_form_err_invalid_email'.tr(); |  | ||||||
|     } |  | ||||||
|     return null; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) { |  | ||||||
|     return TextFormField( |  | ||||||
|       controller: controller, |  | ||||||
|       decoration: InputDecoration( |  | ||||||
|         labelText: 'login_form_label_email'.tr(), |  | ||||||
|         border: const OutlineInputBorder(), |  | ||||||
|         hintText: 'login_form_email_hint'.tr(), |  | ||||||
|       ), |  | ||||||
|       validator: _validateInput, |  | ||||||
|       autovalidateMode: AutovalidateMode.always, |  | ||||||
|       autofillHints: const [AutofillHints.email], |  | ||||||
|       keyboardType: TextInputType.emailAddress, |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| class PasswordInput extends StatelessWidget { |  | ||||||
|   final TextEditingController controller; |  | ||||||
| 
 |  | ||||||
|   const PasswordInput({Key? key, required this.controller}) : super(key: key); |  | ||||||
| 
 |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) { |  | ||||||
|     return TextFormField( |  | ||||||
|       obscureText: true, |  | ||||||
|       controller: controller, |  | ||||||
|       decoration: InputDecoration( |  | ||||||
|         labelText: 'login_form_label_password'.tr(), |  | ||||||
|         border: const OutlineInputBorder(), |  | ||||||
|         hintText: 'login_form_password_hint'.tr(), |  | ||||||
|       ), |  | ||||||
|       autofillHints: const [AutofillHints.password], |  | ||||||
|       keyboardType: TextInputType.text, |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| class LoginButton extends ConsumerWidget { |  | ||||||
|   final TextEditingController emailController; |  | ||||||
|   final TextEditingController passwordController; |  | ||||||
|   final TextEditingController serverEndpointController; |  | ||||||
| 
 |  | ||||||
|   const LoginButton({ |  | ||||||
|     Key? key, |  | ||||||
|     required this.emailController, |  | ||||||
|     required this.passwordController, |  | ||||||
|     required this.serverEndpointController, |  | ||||||
|   }) : super(key: key); |  | ||||||
| 
 |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |  | ||||||
|     return ElevatedButton.icon( |  | ||||||
|       style: ElevatedButton.styleFrom( |  | ||||||
|         padding: const EdgeInsets.symmetric(vertical: 12), |  | ||||||
|       ), |  | ||||||
|       onPressed: () async { |  | ||||||
|         // This will remove current cache asset state of previous user login. |  | ||||||
|         ref.read(assetProvider.notifier).clearAllAsset(); |  | ||||||
| 
 |  | ||||||
|         var isAuthenticated = |  | ||||||
|             await ref.read(authenticationProvider.notifier).login( |  | ||||||
|                   emailController.text, |  | ||||||
|                   passwordController.text, |  | ||||||
|                   serverEndpointController.text, |  | ||||||
|                 ); |  | ||||||
| 
 | 
 | ||||||
|  |       try { | ||||||
|  |         final isAuthenticated = | ||||||
|  |           await ref.read(authenticationProvider.notifier).login( | ||||||
|  |             usernameController.text, | ||||||
|  |             passwordController.text, | ||||||
|  |             serverEndpointController.text.trim(), | ||||||
|  |           ); | ||||||
|         if (isAuthenticated) { |         if (isAuthenticated) { | ||||||
|           // Resume backup (if enable) then navigate |           // Resume backup (if enable) then navigate | ||||||
|           if (ref.read(authenticationProvider).shouldChangePassword && |           if (ref.read(authenticationProvider).shouldChangePassword && | ||||||
| @ -326,35 +160,15 @@ class LoginButton extends ConsumerWidget { | |||||||
|             toastType: ToastType.error, |             toastType: ToastType.error, | ||||||
|           ); |           ); | ||||||
|         } |         } | ||||||
|       }, |       } finally { | ||||||
|       icon: const Icon(Icons.login_rounded), |         // Make sure we stop loading | ||||||
|       label: const Text( |         isLoading.value = false; | ||||||
|         "login_form_button_text", |       } | ||||||
|         style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), |     } | ||||||
|       ).tr(), |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| class OAuthLoginButton extends ConsumerWidget { |  | ||||||
|   final TextEditingController serverEndpointController; |  | ||||||
|   final ValueNotifier<bool> isLoading; |  | ||||||
|   final VoidCallback onLoginSuccess; |  | ||||||
|   final String buttonLabel; |  | ||||||
| 
 | 
 | ||||||
|   const OAuthLoginButton({ |     oAuthLogin() async { | ||||||
|     Key? key, |       var oAuthService = ref.watch(oAuthServiceProvider); | ||||||
|     required this.serverEndpointController, |  | ||||||
|     required this.isLoading, |  | ||||||
|     required this.onLoginSuccess, |  | ||||||
|     required this.buttonLabel, |  | ||||||
|   }) : super(key: key); |  | ||||||
| 
 |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |  | ||||||
|     var oAuthService = ref.watch(oAuthServiceProvider); |  | ||||||
| 
 |  | ||||||
|     void performOAuthLogin() async { |  | ||||||
|       ref.watch(assetProvider.notifier).clearAllAsset(); |       ref.watch(assetProvider.notifier).clearAllAsset(); | ||||||
|       OAuthConfigResponseDto? oAuthServerConfig; |       OAuthConfigResponseDto? oAuthServerConfig; | ||||||
| 
 | 
 | ||||||
| @ -387,7 +201,13 @@ class OAuthLoginButton extends ConsumerWidget { | |||||||
| 
 | 
 | ||||||
|           if (isSuccess) { |           if (isSuccess) { | ||||||
|             isLoading.value = false; |             isLoading.value = false; | ||||||
|             onLoginSuccess(); |             final permission = ref.watch(galleryPermissionNotifier); | ||||||
|  |             if (permission.isGranted || permission.isLimited) { | ||||||
|  |               ref.watch(backupProvider.notifier).resumeBackup(); | ||||||
|  |             } | ||||||
|  |             AutoRouter.of(context).replace( | ||||||
|  |               const TabControllerRoute(), | ||||||
|  |             ); | ||||||
|           } else { |           } else { | ||||||
|             ImmichToast.show( |             ImmichToast.show( | ||||||
|               context: context, |               context: context, | ||||||
| @ -409,12 +229,328 @@ class OAuthLoginButton extends ConsumerWidget { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     buildSelectServer() { | ||||||
|  |       return ConstrainedBox( | ||||||
|  |         key: const ValueKey('server'), | ||||||
|  |         constraints: const BoxConstraints(maxWidth: 300), | ||||||
|  |         child: Column( | ||||||
|  |           crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |           children: [ | ||||||
|  |             ServerEndpointInput( | ||||||
|  |               controller: serverEndpointController, | ||||||
|  |               focusNode: serverEndpointFocusNode, | ||||||
|  |               onSubmit: getServerLoginCredential, | ||||||
|  |             ), | ||||||
|  |             const SizedBox(height: 18), | ||||||
|  |             ElevatedButton.icon( | ||||||
|  |               style: ElevatedButton.styleFrom( | ||||||
|  |                 padding: const EdgeInsets.symmetric(vertical: 12), | ||||||
|  |               ), | ||||||
|  |               onPressed: isLoadingServer.value ? null : getServerLoginCredential, | ||||||
|  |               icon: const Icon(Icons.arrow_forward_rounded), | ||||||
|  |               label: const Text( | ||||||
|  |                 'Next', | ||||||
|  |                 style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), | ||||||
|  |               ).tr(), | ||||||
|  |             ), | ||||||
|  |             if (isLoadingServer.value) | ||||||
|  |               const Padding( | ||||||
|  |                 padding: EdgeInsets.only(top: 18.0), | ||||||
|  |                 child: Center( | ||||||
|  |                   child: CircularProgressIndicator(), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     buildLogin() { | ||||||
|  |       return ConstrainedBox( | ||||||
|  |         key: const ValueKey('login'), | ||||||
|  |         constraints: const BoxConstraints(maxWidth: 300), | ||||||
|  |         child: AutofillGroup( | ||||||
|  |           child: Column( | ||||||
|  |             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |             children: [ | ||||||
|  |               Text( | ||||||
|  |                 serverEndpointController.text, | ||||||
|  |                 style: Theme.of(context).textTheme.displaySmall, | ||||||
|  |                 textAlign: TextAlign.center, | ||||||
|  |               ), | ||||||
|  |               const SizedBox(height: 18), | ||||||
|  |               EmailInput( | ||||||
|  |                 controller: usernameController, | ||||||
|  |                 focusNode: emailFocusNode, | ||||||
|  |                 onSubmit: passwordFocusNode.requestFocus, | ||||||
|  |               ), | ||||||
|  |               const SizedBox(height: 8), | ||||||
|  |               PasswordInput( | ||||||
|  |                 controller: passwordController, | ||||||
|  |                 focusNode: passwordFocusNode, | ||||||
|  |                 onSubmit: login, | ||||||
|  |               ), | ||||||
|  |               AnimatedSwitcher( | ||||||
|  |                 duration: const Duration(milliseconds: 500), | ||||||
|  |                 child: isLoading.value  | ||||||
|  |                   ? const SizedBox( | ||||||
|  |                       width: 24, | ||||||
|  |                       height: 24, | ||||||
|  |                       child: CircularProgressIndicator( | ||||||
|  |                         strokeWidth: 2, | ||||||
|  |                       ), | ||||||
|  |                     ) | ||||||
|  |                   : Column( | ||||||
|  |                       crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |                       mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |                       children: [ | ||||||
|  |                         const SizedBox(height: 18), | ||||||
|  |                         LoginButton(onPressed: login), | ||||||
|  |                         if (isOauthEnable.value) ...[ | ||||||
|  |                           Padding( | ||||||
|  |                             padding: const EdgeInsets.symmetric( | ||||||
|  |                               horizontal: 16.0, | ||||||
|  |                             ), | ||||||
|  |                             child: Divider( | ||||||
|  |                               color: | ||||||
|  |                                   Brightness.dark == Theme.of(context).brightness | ||||||
|  |                                       ? Colors.white | ||||||
|  |                                       : Colors.black, | ||||||
|  |                             ), | ||||||
|  |                           ), | ||||||
|  |                           OAuthLoginButton( | ||||||
|  |                             serverEndpointController: serverEndpointController, | ||||||
|  |                             buttonLabel: oAuthButtonLabel.value, | ||||||
|  |                             isLoading: isLoading, | ||||||
|  |                             onPressed: oAuthLogin, | ||||||
|  |                           ), | ||||||
|  |                         ], | ||||||
|  |                       ], | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 const SizedBox(height: 12), | ||||||
|  |                 TextButton.icon( | ||||||
|  |                   icon: const Icon(Icons.arrow_back), | ||||||
|  |                   onPressed: () => serverEndpoint.value = null, | ||||||
|  |                   label: const Text('Back'), | ||||||
|  |                 ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     final child = serverEndpoint.value == null  | ||||||
|  |       ? buildSelectServer() | ||||||
|  |       : buildLogin(); | ||||||
|  | 
 | ||||||
|  |     return LayoutBuilder( | ||||||
|  |       builder: (context, constraints) { | ||||||
|  |         return SingleChildScrollView( | ||||||
|  |           child: Column( | ||||||
|  |             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |             mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |             children: [ | ||||||
|  |               SizedBox( | ||||||
|  |                 height: constraints.maxHeight / 5, | ||||||
|  |               ), | ||||||
|  |               Column( | ||||||
|  |                 crossAxisAlignment: CrossAxisAlignment.center, | ||||||
|  |                 mainAxisAlignment: MainAxisAlignment.end, | ||||||
|  |                 children: [ | ||||||
|  |                   GestureDetector( | ||||||
|  |                     onDoubleTap: () => populateTestLoginInfo(), | ||||||
|  |                     child: RotationTransition( | ||||||
|  |                       turns: logoAnimationController, | ||||||
|  |                       child: const ImmichLogo( | ||||||
|  |                         heroTag: 'logo', | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                   const ImmichTitleText(), | ||||||
|  |                 ], | ||||||
|  |               ), | ||||||
|  |               const SizedBox(height: 18), | ||||||
|  |               AnimatedSwitcher( | ||||||
|  |                 duration: const Duration(milliseconds: 500), | ||||||
|  |                 child: child,  | ||||||
|  |               ), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class ServerEndpointInput extends StatelessWidget { | ||||||
|  |   final TextEditingController controller; | ||||||
|  |   final FocusNode focusNode; | ||||||
|  |   final Function()? onSubmit; | ||||||
|  |    | ||||||
|  |   const ServerEndpointInput({ | ||||||
|  |     Key? key, | ||||||
|  |     required this.controller, | ||||||
|  |     required this.focusNode, | ||||||
|  |     this.onSubmit, | ||||||
|  |   }) : super(key: key); | ||||||
|  | 
 | ||||||
|  |   String? _validateInput(String? url) { | ||||||
|  |     if (url == null || url.isEmpty) return null; | ||||||
|  | 
 | ||||||
|  |     final parsedUrl = Uri.tryParse(sanitizeUrl(url)); | ||||||
|  |     if (parsedUrl == null || | ||||||
|  |         !parsedUrl.isAbsolute || | ||||||
|  |         !parsedUrl.scheme.startsWith("http") || | ||||||
|  |         parsedUrl.host.isEmpty) { | ||||||
|  |       return 'login_form_err_invalid_url'.tr(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return TextFormField( | ||||||
|  |       controller: controller, | ||||||
|  |       decoration: InputDecoration( | ||||||
|  |         labelText: 'login_form_endpoint_url'.tr(), | ||||||
|  |         border: const OutlineInputBorder(), | ||||||
|  |         hintText: 'login_form_endpoint_hint'.tr(), | ||||||
|  |         errorMaxLines: 4, | ||||||
|  |       ), | ||||||
|  |       validator: _validateInput, | ||||||
|  |       autovalidateMode: AutovalidateMode.always, | ||||||
|  |       focusNode: focusNode, | ||||||
|  |       autofillHints: const [AutofillHints.url], | ||||||
|  |       keyboardType: TextInputType.url, | ||||||
|  |       autocorrect: false, | ||||||
|  |       onFieldSubmitted: (_) => onSubmit?.call(), | ||||||
|  |       textInputAction: TextInputAction.go, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class EmailInput extends StatelessWidget { | ||||||
|  |   final TextEditingController controller; | ||||||
|  |   final FocusNode? focusNode; | ||||||
|  |   final Function()? onSubmit; | ||||||
|  | 
 | ||||||
|  |   const EmailInput({ | ||||||
|  |     Key? key,  | ||||||
|  |     required this.controller, | ||||||
|  |     this.focusNode, | ||||||
|  |     this.onSubmit, | ||||||
|  |   }) : super(key: key); | ||||||
|  | 
 | ||||||
|  |   String? _validateInput(String? email) { | ||||||
|  |     if (email == null || email == '') return null; | ||||||
|  |     if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr(); | ||||||
|  |     if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr(); | ||||||
|  |     if (email.contains(' ') || !email.contains('@')) { | ||||||
|  |       return 'login_form_err_invalid_email'.tr(); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return TextFormField( | ||||||
|  |       autofocus: true, | ||||||
|  |       controller: controller, | ||||||
|  |       decoration: InputDecoration( | ||||||
|  |         labelText: 'login_form_label_email'.tr(), | ||||||
|  |         border: const OutlineInputBorder(), | ||||||
|  |         hintText: 'login_form_email_hint'.tr(), | ||||||
|  |       ), | ||||||
|  |       validator: _validateInput, | ||||||
|  |       autovalidateMode: AutovalidateMode.always, | ||||||
|  |       autofillHints: const [AutofillHints.email], | ||||||
|  |       keyboardType: TextInputType.emailAddress, | ||||||
|  |       onFieldSubmitted: (_) => onSubmit?.call(), | ||||||
|  |       focusNode: focusNode, | ||||||
|  |       textInputAction: TextInputAction.next, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class PasswordInput extends StatelessWidget { | ||||||
|  |   final TextEditingController controller; | ||||||
|  |   final FocusNode? focusNode; | ||||||
|  |   final Function()? onSubmit; | ||||||
|  | 
 | ||||||
|  |   const PasswordInput({ | ||||||
|  |     Key? key, | ||||||
|  |     required this.controller,  | ||||||
|  |     this.focusNode,  | ||||||
|  |     this.onSubmit, | ||||||
|  |   }) : super(key: key); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     return TextFormField( | ||||||
|  |       obscureText: true, | ||||||
|  |       controller: controller, | ||||||
|  |       decoration: InputDecoration( | ||||||
|  |         labelText: 'login_form_label_password'.tr(), | ||||||
|  |         border: const OutlineInputBorder(), | ||||||
|  |         hintText: 'login_form_password_hint'.tr(), | ||||||
|  |       ), | ||||||
|  |       autofillHints: const [AutofillHints.password], | ||||||
|  |       keyboardType: TextInputType.text, | ||||||
|  |       onFieldSubmitted: (_) => onSubmit?.call(), | ||||||
|  |       focusNode: focusNode, | ||||||
|  |       textInputAction: TextInputAction.go, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class LoginButton extends ConsumerWidget { | ||||||
|  |   final Function() onPressed; | ||||||
|  | 
 | ||||||
|  |   const LoginButton({ | ||||||
|  |     Key? key, | ||||||
|  |     required this.onPressed, | ||||||
|  |   }) : super(key: key); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     return ElevatedButton.icon( | ||||||
|  |       style: ElevatedButton.styleFrom( | ||||||
|  |         padding: const EdgeInsets.symmetric(vertical: 12), | ||||||
|  |       ), | ||||||
|  |       onPressed: onPressed, | ||||||
|  |       icon: const Icon(Icons.login_rounded), | ||||||
|  |       label: const Text( | ||||||
|  |         "login_form_button_text", | ||||||
|  |         style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), | ||||||
|  |       ).tr(), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | class OAuthLoginButton extends ConsumerWidget { | ||||||
|  |   final TextEditingController serverEndpointController; | ||||||
|  |   final ValueNotifier<bool> isLoading; | ||||||
|  |   final String buttonLabel; | ||||||
|  |   final Function() onPressed; | ||||||
|  | 
 | ||||||
|  |   const OAuthLoginButton({ | ||||||
|  |     Key? key, | ||||||
|  |     required this.serverEndpointController, | ||||||
|  |     required this.isLoading, | ||||||
|  |     required this.buttonLabel, | ||||||
|  |     required this.onPressed, | ||||||
|  |   }) : super(key: key); | ||||||
|  | 
 | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  | 
 | ||||||
|     return ElevatedButton.icon( |     return ElevatedButton.icon( | ||||||
|       style: ElevatedButton.styleFrom( |       style: ElevatedButton.styleFrom( | ||||||
|         backgroundColor: Theme.of(context).primaryColor.withAlpha(230), |         backgroundColor: Theme.of(context).primaryColor.withAlpha(230), | ||||||
|         padding: const EdgeInsets.symmetric(vertical: 12), |         padding: const EdgeInsets.symmetric(vertical: 12), | ||||||
|       ), |       ), | ||||||
|       onPressed: performOAuthLogin, |       onPressed: onPressed, | ||||||
|       icon: const Icon(Icons.pin_rounded), |       icon: const Icon(Icons.pin_rounded), | ||||||
|       label: Text( |       label: Text( | ||||||
|         buttonLabel, |         buttonLabel, | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user