diff --git a/mobile/lib/pages/login/login.page.dart b/mobile/lib/pages/login/login.page.dart index c84f692822..212145ed5a 100644 --- a/mobile/lib/pages/login/login.page.dart +++ b/mobile/lib/pages/login/login.page.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/widgets/forms/login_form.dart'; +import 'package:immich_mobile/widgets/forms/login/login_form.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:package_info_plus/package_info_plus.dart'; diff --git a/mobile/lib/utils/version_compatibility.dart b/mobile/lib/utils/version_compatibility.dart new file mode 100644 index 0000000000..19d9aa38d4 --- /dev/null +++ b/mobile/lib/utils/version_compatibility.dart @@ -0,0 +1,17 @@ +String? getVersionCompatibilityMessage( + int appMajor, + int appMinor, + int serverMajor, + int serverMinor, +) { + if (serverMajor != appMajor) { + return 'Your app major version is not compatible with the server!'; + } + + // Add latest compat info up top + if (serverMinor < 106 && appMinor >= 106) { + return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login'; + } + + return null; +} diff --git a/mobile/lib/widgets/forms/login/email_input.dart b/mobile/lib/widgets/forms/login/email_input.dart new file mode 100644 index 0000000000..bbb8f8b882 --- /dev/null +++ b/mobile/lib/widgets/forms/login/email_input.dart @@ -0,0 +1,49 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class EmailInput extends StatelessWidget { + final TextEditingController controller; + final FocusNode? focusNode; + final Function()? onSubmit; + + const EmailInput({ + super.key, + required this.controller, + this.focusNode, + this.onSubmit, + }); + + 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(), + hintStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + ), + validator: _validateInput, + autovalidateMode: AutovalidateMode.always, + autofillHints: const [AutofillHints.email], + keyboardType: TextInputType.emailAddress, + onFieldSubmitted: (_) => onSubmit?.call(), + focusNode: focusNode, + textInputAction: TextInputAction.next, + ); + } +} diff --git a/mobile/lib/widgets/forms/login/loading_icon.dart b/mobile/lib/widgets/forms/login/loading_icon.dart new file mode 100644 index 0000000000..9d3f5eab64 --- /dev/null +++ b/mobile/lib/widgets/forms/login/loading_icon.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class LoadingIcon extends StatelessWidget { + const LoadingIcon({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(top: 18.0), + child: SizedBox( + width: 24, + height: 24, + child: FittedBox( + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/forms/login/login_button.dart b/mobile/lib/widgets/forms/login/login_button.dart new file mode 100644 index 0000000000..291debb4ca --- /dev/null +++ b/mobile/lib/widgets/forms/login/login_button.dart @@ -0,0 +1,27 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class LoginButton extends ConsumerWidget { + final Function() onPressed; + + const LoginButton({ + super.key, + required this.onPressed, + }); + + @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(), + ); + } +} diff --git a/mobile/lib/widgets/forms/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart similarity index 73% rename from mobile/lib/widgets/forms/login_form.dart rename to mobile/lib/widgets/forms/login/login_form.dart index 854121a9c2..4504860d44 100644 --- a/mobile/lib/widgets/forms/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -15,11 +15,19 @@ import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/authentication.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/utils/version_compatibility.dart'; import 'package:immich_mobile/widgets/common/immich_logo.dart'; import 'package:immich_mobile/widgets/common/immich_title_text.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/utils/url_helper.dart'; +import 'package:immich_mobile/widgets/forms/login/email_input.dart'; +import 'package:immich_mobile/widgets/forms/login/loading_icon.dart'; +import 'package:immich_mobile/widgets/forms/login/login_button.dart'; +import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart'; +import 'package:immich_mobile/widgets/forms/login/password_input.dart'; +import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart'; import 'package:openapi/api.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:permission_handler/permission_handler.dart'; class LoginForm extends HookConsumerWidget { @@ -45,9 +53,35 @@ class LoginForm extends HookConsumerWidget { final logoAnimationController = useAnimationController( duration: const Duration(seconds: 60), )..repeat(); + final serverInfo = ref.watch(serverInfoProvider); + final warningMessage = useState(''); final ValueNotifier serverEndpoint = useState(null); + checkVersionMismatch() async { + try { + final packageInfo = await PackageInfo.fromPlatform(); + final appVersion = packageInfo.version; + final appMajorVersion = int.parse(appVersion.split('.')[0]); + final appMinorVersion = int.parse(appVersion.split('.')[1]); + final serverMajorVersion = serverInfo.serverVersion.major; + final serverMinorVersion = serverInfo.serverVersion.minor; + + final message = getVersionCompatibilityMessage( + appMajorVersion, + appMinorVersion, + serverMajorVersion, + serverMinorVersion, + ); + + if (message != null) { + warningMessage.value = message; + } + } catch (error) { + warningMessage.value = 'Error checking version compatibility'; + } + } + /// Fetch the server login credential and enables oAuth login if necessary /// Returns true if successful, false otherwise Future getServerLoginCredential() async { @@ -308,11 +342,40 @@ class LoginForm extends HookConsumerWidget { ); } + buildVersionCompatWarning() { + checkVersionMismatch(); + + if (warningMessage.value.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: + context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: + context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!, + ), + ), + child: Text( + warningMessage.value, + textAlign: TextAlign.center, + ), + ), + ); + } + buildLogin() { return AutofillGroup( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + buildVersionCompatWarning(), Text( sanitizeUrl(serverEndpointController.text), style: context.textTheme.displaySmall, @@ -416,7 +479,6 @@ class LoginForm extends HookConsumerWidget { ), ], ), - const SizedBox(height: 18), // Note: This used to have an AnimatedSwitcher, but was removed // because of https://github.com/flutter/flutter/issues/120874 @@ -430,218 +492,3 @@ class LoginForm extends HookConsumerWidget { ); } } - -class ServerEndpointInput extends StatelessWidget { - final TextEditingController controller; - final FocusNode focusNode; - final Function()? onSubmit; - - const ServerEndpointInput({ - super.key, - required this.controller, - required this.focusNode, - this.onSubmit, - }); - - 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({ - super.key, - required this.controller, - this.focusNode, - this.onSubmit, - }); - - 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(), - hintStyle: const TextStyle( - fontWeight: FontWeight.normal, - fontSize: 14, - ), - ), - validator: _validateInput, - autovalidateMode: AutovalidateMode.always, - autofillHints: const [AutofillHints.email], - keyboardType: TextInputType.emailAddress, - onFieldSubmitted: (_) => onSubmit?.call(), - focusNode: focusNode, - textInputAction: TextInputAction.next, - ); - } -} - -class PasswordInput extends HookConsumerWidget { - final TextEditingController controller; - final FocusNode? focusNode; - final Function()? onSubmit; - - const PasswordInput({ - super.key, - required this.controller, - this.focusNode, - this.onSubmit, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isPasswordVisible = useState(false); - - return TextFormField( - obscureText: !isPasswordVisible.value, - controller: controller, - decoration: InputDecoration( - labelText: 'login_form_label_password'.tr(), - border: const OutlineInputBorder(), - hintText: 'login_form_password_hint'.tr(), - hintStyle: const TextStyle( - fontWeight: FontWeight.normal, - fontSize: 14, - ), - suffixIcon: IconButton( - onPressed: () => isPasswordVisible.value = !isPasswordVisible.value, - icon: Icon( - isPasswordVisible.value - ? Icons.visibility_off_sharp - : Icons.visibility_sharp, - ), - ), - ), - autofillHints: const [AutofillHints.password], - keyboardType: TextInputType.text, - onFieldSubmitted: (_) => onSubmit?.call(), - focusNode: focusNode, - textInputAction: TextInputAction.go, - ); - } -} - -class LoginButton extends ConsumerWidget { - final Function() onPressed; - - const LoginButton({ - super.key, - required this.onPressed, - }); - - @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 isLoading; - final String buttonLabel; - final Function() onPressed; - - const OAuthLoginButton({ - super.key, - required this.serverEndpointController, - required this.isLoading, - required this.buttonLabel, - required this.onPressed, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: context.primaryColor.withAlpha(230), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - onPressed: onPressed, - icon: const Icon(Icons.pin_rounded), - label: Text( - buttonLabel, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - ), - ); - } -} - -class LoadingIcon extends StatelessWidget { - const LoadingIcon({super.key}); - - @override - Widget build(BuildContext context) { - return const Padding( - padding: EdgeInsets.only(top: 18.0), - child: SizedBox( - width: 24, - height: 24, - child: FittedBox( - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - ), - ); - } -} diff --git a/mobile/lib/widgets/forms/login/o_auth_login_button.dart b/mobile/lib/widgets/forms/login/o_auth_login_button.dart new file mode 100644 index 0000000000..465d88a4d2 --- /dev/null +++ b/mobile/lib/widgets/forms/login/o_auth_login_button.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class OAuthLoginButton extends ConsumerWidget { + final TextEditingController serverEndpointController; + final ValueNotifier isLoading; + final String buttonLabel; + final Function() onPressed; + + const OAuthLoginButton({ + super.key, + required this.serverEndpointController, + required this.isLoading, + required this.buttonLabel, + required this.onPressed, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: context.primaryColor.withAlpha(230), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: onPressed, + icon: const Icon(Icons.pin_rounded), + label: Text( + buttonLabel, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ); + } +} diff --git a/mobile/lib/widgets/forms/login/password_input.dart b/mobile/lib/widgets/forms/login/password_input.dart new file mode 100644 index 0000000000..f3eaed5f2e --- /dev/null +++ b/mobile/lib/widgets/forms/login/password_input.dart @@ -0,0 +1,49 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class PasswordInput extends HookConsumerWidget { + final TextEditingController controller; + final FocusNode? focusNode; + final Function()? onSubmit; + + const PasswordInput({ + super.key, + required this.controller, + this.focusNode, + this.onSubmit, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isPasswordVisible = useState(false); + + return TextFormField( + obscureText: !isPasswordVisible.value, + controller: controller, + decoration: InputDecoration( + labelText: 'login_form_label_password'.tr(), + border: const OutlineInputBorder(), + hintText: 'login_form_password_hint'.tr(), + hintStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + suffixIcon: IconButton( + onPressed: () => isPasswordVisible.value = !isPasswordVisible.value, + icon: Icon( + isPasswordVisible.value + ? Icons.visibility_off_sharp + : Icons.visibility_sharp, + ), + ), + ), + autofillHints: const [AutofillHints.password], + keyboardType: TextInputType.text, + onFieldSubmitted: (_) => onSubmit?.call(), + focusNode: focusNode, + textInputAction: TextInputAction.go, + ); + } +} diff --git a/mobile/lib/widgets/forms/login/server_endpoint_input.dart b/mobile/lib/widgets/forms/login/server_endpoint_input.dart new file mode 100644 index 0000000000..37bcad9d82 --- /dev/null +++ b/mobile/lib/widgets/forms/login/server_endpoint_input.dart @@ -0,0 +1,54 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; + +class ServerEndpointInput extends StatelessWidget { + final TextEditingController controller; + final FocusNode focusNode; + final Function()? onSubmit; + + const ServerEndpointInput({ + super.key, + required this.controller, + required this.focusNode, + this.onSubmit, + }); + + 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 Padding( + padding: const EdgeInsets.only(top: 16.0), + child: 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, + ), + ); + } +} diff --git a/mobile/test/modules/utils/version_compatibility_test.dart b/mobile/test/modules/utils/version_compatibility_test.dart new file mode 100644 index 0000000000..bdcc0c8fce --- /dev/null +++ b/mobile/test/modules/utils/version_compatibility_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/version_compatibility.dart'; + +void main() { + test('getVersionCompatibilityMessage', () { + String? result; + + result = getVersionCompatibilityMessage(1, 0, 2, 0); + expect( + result, + 'Your app major version is not compatible with the server!', + ); + + result = getVersionCompatibilityMessage(1, 106, 1, 105); + expect( + result, + 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login', + ); + + result = getVersionCompatibilityMessage(1, 107, 1, 105); + expect( + result, + 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login', + ); + + result = getVersionCompatibilityMessage(1, 106, 1, 106); + expect(result, null); + + result = getVersionCompatibilityMessage(1, 107, 1, 106); + expect(result, null); + + result = getVersionCompatibilityMessage(1, 107, 1, 108); + expect(result, null); + }); +}