From da248414af94ebbf70a214fbb1b464031909e354 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:56:36 +0530 Subject: [PATCH 01/15] refactor(mobile): form & form field (#25042) * refactor: form & form field * chore: remove unused components --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- mobile/lib/main.dart | 8 + .../pages/dev/ui_showcase.page.dart | 57 +++- .../pages/editing/drift_crop.page.dart | 6 +- .../lib/widgets/forms/login/login_form.dart | 253 ++++++++---------- .../forms/login/o_auth_login_button.dart | 31 --- .../widgets/forms/login/password_input.dart | 37 --- .../forms/login/server_endpoint_input.dart | 46 ---- mobile/packages/ui/lib/immich_ui.dart | 11 +- .../{buttons => components}/close_button.dart | 9 +- .../packages/ui/lib/src/components/form.dart | 98 +++++++ .../{buttons => components}/icon_button.dart | 24 +- .../ui/lib/src/components/password_input.dart | 58 ++++ .../ui/lib/src/components/text_button.dart | 87 ++++++ .../ui/lib/src/components/text_input.dart | 88 ++++++ mobile/packages/ui/lib/src/constants.dart | 199 ++++++++++++++ mobile/packages/ui/lib/src/internal.dart | 6 + mobile/packages/ui/lib/src/theme.dart | 42 +++ mobile/packages/ui/lib/src/translation.dart | 31 +++ 18 files changed, 817 insertions(+), 274 deletions(-) delete mode 100644 mobile/lib/widgets/forms/login/o_auth_login_button.dart delete mode 100644 mobile/lib/widgets/forms/login/password_input.dart delete mode 100644 mobile/lib/widgets/forms/login/server_endpoint_input.dart rename mobile/packages/ui/lib/src/{buttons => components}/close_button.dart (75%) create mode 100644 mobile/packages/ui/lib/src/components/form.dart rename mobile/packages/ui/lib/src/{buttons => components}/icon_button.dart (60%) create mode 100644 mobile/packages/ui/lib/src/components/password_input.dart create mode 100644 mobile/packages/ui/lib/src/components/text_button.dart create mode 100644 mobile/packages/ui/lib/src/components/text_input.dart create mode 100644 mobile/packages/ui/lib/src/constants.dart create mode 100644 mobile/packages/ui/lib/src/internal.dart create mode 100644 mobile/packages/ui/lib/src/theme.dart create mode 100644 mobile/packages/ui/lib/src/translation.dart diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c3804d97f6..83bc840df1 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -42,6 +42,7 @@ import 'package:immich_mobile/utils/http_ssl_options.dart'; import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/wm_executor.dart'; +import 'package:immich_ui/immich_ui.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:logging/logging.dart'; import 'package:timezone/data/latest.dart'; @@ -252,6 +253,13 @@ class ImmichAppState extends ConsumerState with WidgetsBindingObserve themeMode: ref.watch(immichThemeModeProvider), darkTheme: getThemeData(colorScheme: immichTheme.dark, locale: context.locale), theme: getThemeData(colorScheme: immichTheme.light, locale: context.locale), + builder: (context, child) => ImmichTranslationProvider( + translations: ImmichTranslations( + submit: "submit".t(context: context), + password: "password".t(context: context), + ), + child: ImmichThemeProvider(colorScheme: context.colorScheme, child: child!), + ), routerConfig: router.config( deepLinkBuilder: _deepLinkBuilder, navigatorObservers: () => [AppNavigationObserver(ref: ref)], diff --git a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart index 01fe928478..37c412a0e9 100644 --- a/mobile/lib/presentation/pages/dev/ui_showcase.page.dart +++ b/mobile/lib/presentation/pages/dev/ui_showcase.page.dart @@ -19,6 +19,17 @@ List _showcaseBuilder(Function(ImmichVariant variant, ImmichColor color) return children; } +class _ComponentTitle extends StatelessWidget { + final String title; + + const _ComponentTitle(this.title); + + @override + Widget build(BuildContext context) { + return Text(title, style: context.textTheme.titleLarge); + } +} + @RoutePage() class ImmichUIShowcasePage extends StatelessWidget { const ImmichUIShowcasePage({super.key}); @@ -35,13 +46,51 @@ class ImmichUIShowcasePage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("IconButton", style: context.textTheme.titleLarge), + const _ComponentTitle("IconButton"), ..._showcaseBuilder( (variant, color) => - ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onTap: () {}), + ImmichIconButton(icon: Icons.favorite, color: color, variant: variant, onPressed: () {}), + ), + const _ComponentTitle("CloseButton"), + ..._showcaseBuilder( + (variant, color) => ImmichCloseButton(color: color, variant: variant, onPressed: () {}), + ), + const _ComponentTitle("TextButton"), + + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.filled, + color: ImmichColor.primary, + ), + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.filled, + color: ImmichColor.primary, + loading: true, + ), + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.ghost, + color: ImmichColor.primary, + ), + ImmichTextButton( + labelText: "Text Button", + onPressed: () {}, + variant: ImmichVariant.ghost, + color: ImmichColor.primary, + loading: true, + ), + const _ComponentTitle("Form"), + ImmichForm( + onSubmit: () {}, + child: const Column( + spacing: 10, + children: [ImmichTextInput(label: "Title", hintText: "Enter a title")], + ), ), - Text("CloseButton", style: context.textTheme.titleLarge), - ..._showcaseBuilder((variant, color) => ImmichCloseButton(color: color, variant: variant, onTap: () {})), ], ), ), diff --git a/mobile/lib/presentation/pages/editing/drift_crop.page.dart b/mobile/lib/presentation/pages/editing/drift_crop.page.dart index 1692140cd2..a213e4c640 100644 --- a/mobile/lib/presentation/pages/editing/drift_crop.page.dart +++ b/mobile/lib/presentation/pages/editing/drift_crop.page.dart @@ -37,7 +37,7 @@ class DriftCropImagePage extends HookWidget { icon: Icons.done_rounded, color: ImmichColor.primary, variant: ImmichVariant.ghost, - onTap: () async { + onPressed: () async { final croppedImage = await cropController.croppedImage(); unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true))); }, @@ -79,13 +79,13 @@ class DriftCropImagePage extends HookWidget { icon: Icons.rotate_left, variant: ImmichVariant.ghost, color: ImmichColor.secondary, - onTap: () => cropController.rotateLeft(), + onPressed: () => cropController.rotateLeft(), ), ImmichIconButton( icon: Icons.rotate_right, variant: ImmichVariant.ghost, color: ImmichColor.secondary, - onTap: () => cropController.rotateRight(), + onPressed: () => cropController.rotateRight(), ), ], ), diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index f810973298..71086fd803 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -14,6 +14,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; @@ -29,12 +30,7 @@ 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/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:immich_ui/immich_ui.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -45,16 +41,33 @@ class LoginForm extends HookConsumerWidget { final log = Logger('LoginForm'); + String? _validateUrl(String? url) { + if (url == null || url.isEmpty) return null; + + final parsedUrl = Uri.tryParse(url); + if (parsedUrl == null || !parsedUrl.isAbsolute || !parsedUrl.scheme.startsWith("http") || parsedUrl.host.isEmpty) { + return 'login_form_err_invalid_url'.tr(); + } + + return null; + } + + String? _validateEmail(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, WidgetRef ref) { final emailController = useTextEditingController.fromValue(TextEditingValue.empty); final passwordController = useTextEditingController.fromValue(TextEditingValue.empty); final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty); - final emailFocusNode = useFocusNode(); final passwordFocusNode = useFocusNode(); - final serverEndpointFocusNode = useFocusNode(); - final isLoading = useState(false); - final isLoadingServer = useState(false); final isOauthEnable = useState(false); final isPasswordLoginEnable = useState(false); final oAuthButtonLabel = useState('OAuth'); @@ -96,7 +109,6 @@ class LoginForm extends HookConsumerWidget { } try { - isLoadingServer.value = true; final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl); // Fetch and load server config and features @@ -120,7 +132,6 @@ class LoginForm extends HookConsumerWidget { ); isOauthEnable.value = false; isPasswordLoginEnable.value = true; - isLoadingServer.value = false; } on HandshakeException { ImmichToast.show( context: context, @@ -130,7 +141,6 @@ class LoginForm extends HookConsumerWidget { ); isOauthEnable.value = false; isPasswordLoginEnable.value = true; - isLoadingServer.value = false; } catch (e) { ImmichToast.show( context: context, @@ -140,10 +150,7 @@ class LoginForm extends HookConsumerWidget { ); isOauthEnable.value = false; isPasswordLoginEnable.value = true; - isLoadingServer.value = false; } - - isLoadingServer.value = false; } useEffect(() { @@ -230,8 +237,6 @@ class LoginForm extends HookConsumerWidget { login() async { TextInput.finishAutofillContext(); - isLoading.value = true; - // Invalidate all api repository provider instance to take into account new access token invalidateAllApiRepositoryProviders(ref); @@ -261,8 +266,6 @@ class LoginForm extends HookConsumerWidget { toastType: ToastType.error, gravity: ToastGravity.TOP, ); - } finally { - isLoading.value = false; } } @@ -306,8 +309,6 @@ class LoginForm extends HookConsumerWidget { codeChallenge, ); - isLoading.value = true; - // Invalidate all api repository provider instance to take into account new access token invalidateAllApiRepositoryProviders(ref); } catch (error, stack) { @@ -319,7 +320,6 @@ class LoginForm extends HookConsumerWidget { toastType: ToastType.error, gravity: ToastGravity.TOP, ); - isLoading.value = false; return; } @@ -338,7 +338,6 @@ class LoginForm extends HookConsumerWidget { .saveAuthInfo(accessToken: loginResponseDto.accessToken); if (isSuccess) { - isLoading.value = false; final permission = ref.watch(galleryPermissionNotifier); final isBeta = Store.isBetaTimelineEnabled; if (!isBeta && (permission.isGranted || permission.isLimited)) { @@ -364,9 +363,7 @@ class LoginForm extends HookConsumerWidget { toastType: ToastType.error, gravity: ToastGravity.TOP, ); - } finally { - isLoading.value = false; - } + } finally {} } else { ImmichToast.show( context: context, @@ -374,66 +371,10 @@ class LoginForm extends HookConsumerWidget { toastType: ToastType.info, gravity: ToastGravity.TOP, ); - isLoading.value = false; return; } } - buildSelectServer() { - const buttonRadius = 25.0; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ServerEndpointInput( - controller: serverEndpointController, - focusNode: serverEndpointFocusNode, - onSubmit: getServerAuthSettings, - ), - const SizedBox(height: 18), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(buttonRadius), - bottomLeft: Radius.circular(buttonRadius), - ), - ), - ), - onPressed: () => context.pushRoute(const SettingsRoute()), - icon: const Icon(Icons.settings_rounded), - label: const Text(""), - ), - ), - const SizedBox(width: 1), - Expanded( - flex: 3, - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.circular(buttonRadius), - bottomRight: Radius.circular(buttonRadius), - ), - ), - ), - onPressed: isLoadingServer.value ? null : getServerAuthSettings, - icon: const Icon(Icons.arrow_forward_rounded), - label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(), - ), - ), - ], - ), - const SizedBox(height: 18), - if (isLoadingServer.value) const LoadingIcon(), - ], - ); - } - buildVersionCompatWarning() { checkVersionMismatch(); @@ -455,66 +396,102 @@ class LoginForm extends HookConsumerWidget { ); } - buildLogin() { - return AutofillGroup( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildVersionCompatWarning(), - Text( - sanitizeUrl(serverEndpointController.text), - style: context.textTheme.displaySmall, - textAlign: TextAlign.center, + final serverSelectionOrLogin = serverEndpoint.value == null + ? Padding( + padding: const EdgeInsets.only(top: ImmichSpacing.md), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + ImmichForm( + submitText: 'next'.t(context: context), + submitIcon: Icons.arrow_forward_rounded, + onSubmit: getServerAuthSettings, + child: ImmichTextInput( + controller: serverEndpointController, + label: 'login_form_endpoint_url'.t(context: context), + hintText: 'login_form_endpoint_hint'.t(context: context), + validator: _validateUrl, + keyboardAction: TextInputAction.next, + keyboardType: TextInputType.url, + autofillHints: const [AutofillHints.url], + onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(), + ), + ), + ImmichTextButton( + labelText: 'settings'.t(context: context), + icon: Icons.settings, + variant: ImmichVariant.ghost, + onPressed: () => context.pushRoute(const SettingsRoute()), + ), + ], ), - if (isPasswordLoginEnable.value) ...[ - const SizedBox(height: 18), - EmailInput( - controller: emailController, - focusNode: emailFocusNode, - onSubmit: passwordFocusNode.requestFocus, - ), - const SizedBox(height: 8), - PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login), - ], - - // Note: This used to have an AnimatedSwitcher, but was removed - // because of https://github.com/flutter/flutter/issues/120874 - isLoading.value - ? const LoadingIcon() - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 18), - if (isPasswordLoginEnable.value) LoginButton(onPressed: login), - if (isOauthEnable.value) ...[ - if (isPasswordLoginEnable.value) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black), - ), - OAuthLoginButton( - serverEndpointController: serverEndpointController, - buttonLabel: oAuthButtonLabel.value, - isLoading: isLoading, - onPressed: oAuthLogin, + ) + : AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + children: [ + buildVersionCompatWarning(), + Padding( + padding: const EdgeInsets.only(bottom: ImmichSpacing.md), + child: Text( + sanitizeUrl(serverEndpointController.text), + style: context.textTheme.displaySmall, + textAlign: TextAlign.center, + ), + ), + if (isPasswordLoginEnable.value) + ImmichForm( + submitText: 'login'.t(context: context), + submitIcon: Icons.login_rounded, + onSubmit: login, + child: Column( + spacing: ImmichSpacing.md, + children: [ + ImmichTextInput( + controller: emailController, + label: 'email'.t(context: context), + hintText: 'login_form_email_hint'.t(context: context), + validator: _validateEmail, + keyboardAction: TextInputAction.next, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + onSubmit: (_, _) => passwordFocusNode.requestFocus(), + ), + ImmichPasswordInput( + controller: passwordController, + focusNode: passwordFocusNode, + label: 'password'.t(context: context), + hintText: 'login_form_password_hint'.t(context: context), + keyboardAction: TextInputAction.go, + onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(), ), ], - ], + ), ), - if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()), - const SizedBox(height: 12), - TextButton.icon( - icon: const Icon(Icons.arrow_back), - onPressed: () => serverEndpoint.value = null, - label: const Text('back').tr(), + if (isOauthEnable.value) + ImmichForm( + submitText: oAuthButtonLabel.value, + submitIcon: Icons.pin_outlined, + onSubmit: oAuthLogin, + child: isPasswordLoginEnable.value + ? Padding( + padding: const EdgeInsets.only(left: 18.0, right: 18.0, top: 12.0), + child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black, height: 5), + ) + : const SizedBox.shrink(), + ), + if (!isOauthEnable.value && !isPasswordLoginEnable.value) + Center(child: const Text('login_disabled').tr()), + ImmichTextButton( + labelText: 'back'.t(context: context), + icon: Icons.arrow_back, + variant: ImmichVariant.ghost, + onPressed: () => serverEndpoint.value = null, + ), + ], ), - ], - ), - ); - } - - final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin(); + ); return LayoutBuilder( builder: (context, constraints) { diff --git a/mobile/lib/widgets/forms/login/o_auth_login_button.dart b/mobile/lib/widgets/forms/login/o_auth_login_button.dart deleted file mode 100644 index 2d9b603b3c..0000000000 --- a/mobile/lib/widgets/forms/login/o_auth_login_button.dart +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 5cdfcc9567..0000000000 --- a/mobile/lib/widgets/forms/login/password_input.dart +++ /dev/null @@ -1,37 +0,0 @@ -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: '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 deleted file mode 100644 index f9bc1690af..0000000000 --- a/mobile/lib/widgets/forms/login/server_endpoint_input.dart +++ /dev/null @@ -1,46 +0,0 @@ -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/packages/ui/lib/immich_ui.dart b/mobile/packages/ui/lib/immich_ui.dart index 2417149f76..9f2a886ab3 100644 --- a/mobile/packages/ui/lib/immich_ui.dart +++ b/mobile/packages/ui/lib/immich_ui.dart @@ -1,3 +1,10 @@ -export 'src/buttons/close_button.dart'; -export 'src/buttons/icon_button.dart'; +export 'src/components/close_button.dart'; +export 'src/components/form.dart'; +export 'src/components/icon_button.dart'; +export 'src/components/password_input.dart'; +export 'src/components/text_button.dart'; +export 'src/components/text_input.dart'; +export 'src/constants.dart'; +export 'src/theme.dart'; +export 'src/translation.dart'; export 'src/types.dart'; diff --git a/mobile/packages/ui/lib/src/buttons/close_button.dart b/mobile/packages/ui/lib/src/components/close_button.dart similarity index 75% rename from mobile/packages/ui/lib/src/buttons/close_button.dart rename to mobile/packages/ui/lib/src/components/close_button.dart index c8c5d62a12..9308fdaadb 100644 --- a/mobile/packages/ui/lib/src/buttons/close_button.dart +++ b/mobile/packages/ui/lib/src/components/close_button.dart @@ -1,15 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:immich_ui/src/buttons/icon_button.dart'; import 'package:immich_ui/src/types.dart'; +import 'icon_button.dart'; + class ImmichCloseButton extends StatelessWidget { - final VoidCallback? onTap; + final VoidCallback? onPressed; final ImmichVariant variant; final ImmichColor color; const ImmichCloseButton({ super.key, - this.onTap, + this.onPressed, this.color = ImmichColor.primary, this.variant = ImmichVariant.ghost, }); @@ -20,6 +21,6 @@ class ImmichCloseButton extends StatelessWidget { icon: Icons.close, color: color, variant: variant, - onTap: onTap ?? () => Navigator.of(context).pop(), + onPressed: onPressed ?? () => Navigator.of(context).pop(), ); } diff --git a/mobile/packages/ui/lib/src/components/form.dart b/mobile/packages/ui/lib/src/components/form.dart new file mode 100644 index 0000000000..9e8c161806 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/form.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:immich_ui/immich_ui.dart'; +import 'package:immich_ui/src/internal.dart'; + +class ImmichForm extends StatefulWidget { + final String? submitText; + final IconData? submitIcon; + final FutureOr Function()? onSubmit; + final Widget child; + + const ImmichForm({ + super.key, + this.submitText, + this.submitIcon, + required this.onSubmit, + required this.child, + }); + + @override + State createState() => ImmichFormState(); + + static ImmichFormState of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_ImmichFormScope>(); + if (scope == null) { + throw FlutterError( + 'ImmichForm.of() called with a context that does not contain an ImmichForm.\n' + 'No ImmichForm ancestor could be found starting from the context that was passed to ' + 'ImmichForm.of(). This usually happens when the context provided is ' + 'from a widget above the ImmichForm.\n' + 'The context used was:\n' + '$context', + ); + } + return scope._formState; + } +} + +class ImmichFormState extends State { + final _formKey = GlobalKey(); + bool _isLoading = false; + + FutureOr submit() async { + final isValid = _formKey.currentState?.validate() ?? false; + if (!isValid) { + return; + } + + setState(() { + _isLoading = true; + }); + + try { + await widget.onSubmit?.call(); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final submitText = widget.submitText ?? context.translations.submit; + return _ImmichFormScope( + formState: this, + child: Form( + key: _formKey, + child: Column( + spacing: ImmichSpacing.md, + children: [ + widget.child, + ImmichTextButton( + labelText: submitText, + icon: widget.submitIcon, + variant: ImmichVariant.filled, + loading: _isLoading, + onPressed: submit, + disabled: widget.onSubmit == null, + ), + ], + ), + ), + ); + } +} + +class _ImmichFormScope extends InheritedWidget { + const _ImmichFormScope({required super.child, required ImmichFormState formState}) : _formState = formState; + + final ImmichFormState _formState; + + @override + bool updateShouldNotify(_ImmichFormScope oldWidget) => oldWidget._formState != _formState; +} diff --git a/mobile/packages/ui/lib/src/buttons/icon_button.dart b/mobile/packages/ui/lib/src/components/icon_button.dart similarity index 60% rename from mobile/packages/ui/lib/src/buttons/icon_button.dart rename to mobile/packages/ui/lib/src/components/icon_button.dart index 5c62ee8eda..dc140b71f9 100644 --- a/mobile/packages/ui/lib/src/buttons/icon_button.dart +++ b/mobile/packages/ui/lib/src/components/icon_button.dart @@ -3,42 +3,48 @@ import 'package:immich_ui/src/types.dart'; class ImmichIconButton extends StatelessWidget { final IconData icon; - final VoidCallback onTap; + final VoidCallback onPressed; final ImmichVariant variant; final ImmichColor color; + final bool disabled; const ImmichIconButton({ super.key, required this.icon, - required this.onTap, + required this.onPressed, this.color = ImmichColor.primary, this.variant = ImmichVariant.filled, + this.disabled = false, }); @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final background = switch (variant) { ImmichVariant.filled => switch (color) { - ImmichColor.primary => Theme.of(context).colorScheme.primary, - ImmichColor.secondary => Theme.of(context).colorScheme.secondary, + ImmichColor.primary => colorScheme.primary, + ImmichColor.secondary => colorScheme.secondary, }, ImmichVariant.ghost => Colors.transparent, }; final foreground = switch (variant) { ImmichVariant.filled => switch (color) { - ImmichColor.primary => Theme.of(context).colorScheme.onPrimary, - ImmichColor.secondary => Theme.of(context).colorScheme.onSecondary, + ImmichColor.primary => colorScheme.onPrimary, + ImmichColor.secondary => colorScheme.onSecondary, }, ImmichVariant.ghost => switch (color) { - ImmichColor.primary => Theme.of(context).colorScheme.primary, - ImmichColor.secondary => Theme.of(context).colorScheme.secondary, + ImmichColor.primary => colorScheme.primary, + ImmichColor.secondary => colorScheme.secondary, }, }; + final effectiveOnPressed = disabled ? null : onPressed; + return IconButton( icon: Icon(icon), - onPressed: onTap, + onPressed: effectiveOnPressed, style: IconButton.styleFrom( backgroundColor: background, foregroundColor: foreground, diff --git a/mobile/packages/ui/lib/src/components/password_input.dart b/mobile/packages/ui/lib/src/components/password_input.dart new file mode 100644 index 0000000000..bd5a149354 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/password_input.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/components/text_input.dart'; +import 'package:immich_ui/src/internal.dart'; + +class ImmichPasswordInput extends StatefulWidget { + final String? label; + final String? hintText; + final TextEditingController? controller; + final FocusNode? focusNode; + final String? Function(String?)? validator; + final void Function(BuildContext, String)? onSubmit; + final TextInputAction? keyboardAction; + + const ImmichPasswordInput({ + super.key, + this.controller, + this.focusNode, + this.label, + this.hintText, + this.validator, + this.onSubmit, + this.keyboardAction, + }); + + @override + State createState() => _ImmichPasswordInputState(); +} + +class _ImmichPasswordInputState extends State { + bool _visible = false; + + void _toggleVisibility() { + setState(() { + _visible = !_visible; + }); + } + + @override + Widget build(BuildContext context) { + return ImmichTextInput( + key: widget.key, + label: widget.label ?? context.translations.password, + hintText: widget.hintText, + controller: widget.controller, + focusNode: widget.focusNode, + validator: widget.validator, + onSubmit: widget.onSubmit, + keyboardAction: widget.keyboardAction, + obscureText: !_visible, + suffixIcon: IconButton( + onPressed: _toggleVisibility, + icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded), + ), + autofillHints: [AutofillHints.password], + keyboardType: TextInputType.text, + ); + } +} diff --git a/mobile/packages/ui/lib/src/components/text_button.dart b/mobile/packages/ui/lib/src/components/text_button.dart new file mode 100644 index 0000000000..6dc677aee2 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/text_button.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/constants.dart'; +import 'package:immich_ui/src/types.dart'; + +class ImmichTextButton extends StatelessWidget { + final String labelText; + final IconData? icon; + final FutureOr Function() onPressed; + final ImmichVariant variant; + final ImmichColor color; + final bool expanded; + final bool loading; + final bool disabled; + + const ImmichTextButton({ + super.key, + required this.labelText, + this.icon, + required this.onPressed, + this.variant = ImmichVariant.filled, + this.color = ImmichColor.primary, + this.expanded = true, + this.loading = false, + this.disabled = false, + }); + + Widget _buildButton(ImmichVariant variant) { + final Widget? effectiveIcon = loading + ? const SizedBox.square( + dimension: ImmichIconSize.md, + child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg), + ) + : icon != null + ? Icon(icon, fontWeight: FontWeight.w600) + : null; + final hasIcon = effectiveIcon != null; + + final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold)); + final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md)); + + final effectiveOnPressed = disabled || loading ? null : onPressed; + + switch (variant) { + case ImmichVariant.filled: + if (hasIcon) { + return ElevatedButton.icon( + style: style, + onPressed: effectiveOnPressed, + icon: effectiveIcon, + label: label, + ); + } + + return ElevatedButton( + style: style, + onPressed: effectiveOnPressed, + child: label, + ); + case ImmichVariant.ghost: + if (hasIcon) { + return TextButton.icon( + style: style, + onPressed: effectiveOnPressed, + icon: effectiveIcon, + label: label, + ); + } + + return TextButton( + style: style, + onPressed: effectiveOnPressed, + child: label, + ); + } + } + + @override + Widget build(BuildContext context) { + final button = _buildButton(variant); + if (expanded) { + return SizedBox(width: double.infinity, child: button); + } + return button; + } +} diff --git a/mobile/packages/ui/lib/src/components/text_input.dart b/mobile/packages/ui/lib/src/components/text_input.dart new file mode 100644 index 0000000000..f335df49f4 --- /dev/null +++ b/mobile/packages/ui/lib/src/components/text_input.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +class ImmichTextInput extends StatefulWidget { + final String label; + final String? hintText; + final TextEditingController? controller; + final FocusNode? focusNode; + final String? Function(String?)? validator; + final void Function(BuildContext, String)? onSubmit; + final TextInputType keyboardType; + final TextInputAction? keyboardAction; + final List? autofillHints; + final Widget? suffixIcon; + final bool obscureText; + + const ImmichTextInput({ + super.key, + this.controller, + this.focusNode, + required this.label, + this.hintText, + this.validator, + this.onSubmit, + this.keyboardType = TextInputType.text, + this.keyboardAction, + this.autofillHints, + this.suffixIcon, + this.obscureText = false, + }); + + @override + State createState() => _ImmichTextInputState(); +} + +class _ImmichTextInputState extends State { + late final FocusNode _focusNode; + String? _error; + + @override + void initState() { + super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + } + + @override + void dispose() { + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + String? _validateInput(String? value) { + setState(() { + _error = widget.validator?.call(value); + }); + return null; + } + + bool get _hasError => _error != null && _error!.isNotEmpty; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + + return TextFormField( + controller: widget.controller, + focusNode: _focusNode, + decoration: InputDecoration( + hintText: widget.hintText, + labelText: widget.label, + labelStyle: themeData.inputDecorationTheme.labelStyle?.copyWith( + color: _hasError ? themeData.colorScheme.error : null, + ), + errorText: _error, + suffixIcon: widget.suffixIcon, + ), + obscureText: widget.obscureText, + validator: _validateInput, + keyboardType: widget.keyboardType, + textInputAction: widget.keyboardAction, + autofillHints: widget.autofillHints, + onTap: () => setState(() => _error = null), + onTapOutside: (_) => _focusNode.unfocus(), + onFieldSubmitted: (value) => widget.onSubmit?.call(context, value), + ); + } +} diff --git a/mobile/packages/ui/lib/src/constants.dart b/mobile/packages/ui/lib/src/constants.dart new file mode 100644 index 0000000000..96122c9b36 --- /dev/null +++ b/mobile/packages/ui/lib/src/constants.dart @@ -0,0 +1,199 @@ +/// Spacing constants for gaps between widgets +abstract class ImmichSpacing { + const ImmichSpacing._(); + + /// Extra small spacing: 4.0 + static const double xs = 4.0; + + /// Small spacing: 8.0 + static const double sm = 8.0; + + /// Medium spacing (default): 12.0 + static const double md = 12.0; + + /// Large spacing: 16.0 + static const double lg = 16.0; + + /// Extra large spacing: 24.0 + static const double xl = 24.0; + + /// Extra extra large spacing: 32.0 + static const double xxl = 32.0; + + /// Extra extra extra large spacing: 48.0 + static const double xxxl = 48.0; +} + +/// Border radius constants for consistent rounded corners +abstract class ImmichRadius { + const ImmichRadius._(); + + /// No radius: 0.0 + static const double none = 0.0; + + /// Extra small radius: 4.0 + static const double xs = 4.0; + + /// Small radius: 8.0 + static const double sm = 8.0; + + /// Medium radius (default): 12.0 + static const double md = 12.0; + + /// Large radius: 16.0 + static const double lg = 16.0; + + /// Extra large radius: 20.0 + static const double xl = 20.0; + + /// Extra extra large radius: 24.0 + static const double xxl = 24.0; + + /// Full circular radius: infinity + static const double full = double.infinity; +} + +/// Icon size constants for consistent icon sizing +abstract class ImmichIconSize { + const ImmichIconSize._(); + + /// Extra small icon: 16.0 + static const double xs = 16.0; + + /// Small icon: 20.0 + static const double sm = 20.0; + + /// Medium icon (default): 24.0 + static const double md = 24.0; + + /// Large icon: 32.0 + static const double lg = 32.0; + + /// Extra large icon: 40.0 + static const double xl = 40.0; + + /// Extra extra large icon: 48.0 + static const double xxl = 48.0; +} + +/// Animation duration constants for consistent timing +abstract class ImmichDuration { + const ImmichDuration._(); + + /// Extra fast: 100ms + static const Duration extraFast = Duration(milliseconds: 100); + + /// Fast: 150ms + static const Duration fast = Duration(milliseconds: 150); + + /// Normal: 200ms + static const Duration normal = Duration(milliseconds: 200); + + /// Moderate: 300ms + static const Duration moderate = Duration(milliseconds: 300); + + /// Slow: 500ms + static const Duration slow = Duration(milliseconds: 500); + + /// Extra slow: 700ms + static const Duration extraSlow = Duration(milliseconds: 700); +} + +/// Elevation constants for consistent shadows and depth +abstract class ImmichElevation { + const ImmichElevation._(); + + /// No elevation: 0.0 + static const double none = 0.0; + + /// Extra small elevation: 1.0 + static const double xs = 1.0; + + /// Small elevation: 2.0 + static const double sm = 2.0; + + /// Medium elevation: 4.0 + static const double md = 4.0; + + /// Large elevation: 8.0 + static const double lg = 8.0; + + /// Extra large elevation: 12.0 + static const double xl = 12.0; + + /// Extra extra large elevation: 16.0 + static const double xxl = 16.0; +} + +/// Border width constants (similar to Tailwind's border-* scale) +abstract class ImmichBorderWidth { + const ImmichBorderWidth._(); + + /// No border: 0.0 + static const double none = 0.0; + + /// Hairline border: 0.5 + static const double hairline = 0.5; + + /// Default border: 1.0 (border) + static const double base = 1.0; + + /// Medium border: 2.0 (border-2) + static const double md = 2.0; + + /// Large border: 3.0 (border-4) + static const double lg = 3.0; + + /// Extra large border: 4.0 + static const double xl = 4.0; +} + +/// Text size constants with semantic HTML-like naming +/// These follow a type scale for harmonious text hierarchy +abstract class ImmichTextSize { + const ImmichTextSize._(); + + /// Caption text: 10.0 + /// Use for: Tiny labels, legal text, metadata, timestamps + static const double caption = 10.0; + + /// Label text: 12.0 + /// Use for: Form labels, secondary text, helper text + static const double label = 12.0; + + /// Body text: 14.0 (default) + /// Use for: Main body text, paragraphs, default UI text + static const double body = 14.0; + + /// Body emphasized: 16.0 + /// Use for: Emphasized body text, button labels, tabs + static const double bodyLarge = 16.0; + + /// Heading 6: 18.0 (smallest heading) + /// Use for: Subtitles, card titles, section headers + static const double h6 = 18.0; + + /// Heading 5: 20.0 + /// Use for: Small headings, prominent labels + static const double h5 = 20.0; + + /// Heading 4: 24.0 + /// Use for: Page titles, dialog titles + static const double h4 = 24.0; + + /// Heading 3: 30.0 + /// Use for: Section headings, large headings + static const double h3 = 30.0; + + /// Heading 2: 36.0 + /// Use for: Major section headings + static const double h2 = 36.0; + + /// Heading 1: 48.0 (largest heading) + /// Use for: Page hero headings, main titles + static const double h1 = 48.0; + + /// Display text: 60.0 + /// Use for: Hero numbers, splash screens, extra large display + static const double display = 60.0; +} diff --git a/mobile/packages/ui/lib/src/internal.dart b/mobile/packages/ui/lib/src/internal.dart new file mode 100644 index 0000000000..7f503927ff --- /dev/null +++ b/mobile/packages/ui/lib/src/internal.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/translation.dart'; + +extension TranslationHelper on BuildContext { + ImmichTranslations get translations => ImmichTranslationProvider.of(this); +} diff --git a/mobile/packages/ui/lib/src/theme.dart b/mobile/packages/ui/lib/src/theme.dart new file mode 100644 index 0000000000..387723b8ce --- /dev/null +++ b/mobile/packages/ui/lib/src/theme.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:immich_ui/src/constants.dart'; + +class ImmichThemeProvider extends StatelessWidget { + final ColorScheme colorScheme; + final Widget child; + + const ImmichThemeProvider({super.key, required this.colorScheme, required this.child}); + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: colorScheme, + brightness: colorScheme.brightness, + inputDecorationTheme: InputDecorationTheme( + floatingLabelBehavior: FloatingLabelBehavior.always, + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.primary), + borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.primary), + borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)), + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.error), + borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: colorScheme.error), + borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)), + ), + labelStyle: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.w600), + hintStyle: const TextStyle(fontSize: ImmichTextSize.body), + errorStyle: TextStyle(color: colorScheme.error, fontWeight: FontWeight.w600), + ), + ), + child: child, + ); + } +} diff --git a/mobile/packages/ui/lib/src/translation.dart b/mobile/packages/ui/lib/src/translation.dart new file mode 100644 index 0000000000..cd51f74422 --- /dev/null +++ b/mobile/packages/ui/lib/src/translation.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class ImmichTranslations { + late String submit; + late String password; + + ImmichTranslations({String? submit, String? password}) { + this.submit = submit ?? 'Submit'; + this.password = password ?? 'Password'; + } +} + +class ImmichTranslationProvider extends InheritedWidget { + final ImmichTranslations? translations; + + const ImmichTranslationProvider({ + super.key, + this.translations, + required super.child, + }); + + static ImmichTranslations of(BuildContext context) { + final provider = context.dependOnInheritedWidgetOfExactType(); + return provider?.translations ?? ImmichTranslations(); + } + + @override + bool updateShouldNotify(covariant ImmichTranslationProvider oldWidget) { + return oldWidget.translations != translations; + } +} From 702499b97dd57d938ff129a9e7ebee9f7f7bad85 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 9 Jan 2026 13:03:57 -0500 Subject: [PATCH 02/15] refactor: modals (#25162) --- .../PinCodeChangeForm.svelte | 11 +- .../user-settings-page/PinCodeSettings.svelte | 14 +-- web/src/lib/managers/event-manager.svelte.ts | 2 + web/src/lib/modals/ApiKeyCreateModal.svelte | 13 +- web/src/lib/modals/JobCreateModal.svelte | 46 ++------ .../lib/modals/ObtainiumConfigModal.svelte | 111 ++++++++---------- web/src/lib/modals/PinCodeResetModal.svelte | 87 +++++--------- web/src/lib/services/api-key.service.ts | 11 +- web/src/lib/services/job.service.ts | 16 +++ web/src/lib/services/queue.service.ts | 4 +- web/src/lib/services/user.service.ts | 18 +++ 11 files changed, 144 insertions(+), 189 deletions(-) create mode 100644 web/src/lib/services/job.service.ts create mode 100644 web/src/lib/services/user.service.ts diff --git a/web/src/lib/components/user-settings-page/PinCodeChangeForm.svelte b/web/src/lib/components/user-settings-page/PinCodeChangeForm.svelte index 1aadb7b783..2edb8fa606 100644 --- a/web/src/lib/components/user-settings-page/PinCodeChangeForm.svelte +++ b/web/src/lib/components/user-settings-page/PinCodeChangeForm.svelte @@ -1,8 +1,9 @@ + +
{#if hasPinCode}
- +
{:else}
diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 9fbe16b68a..7124fc4524 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -52,6 +52,8 @@ export type Events = { TagUpdate: [TagResponseDto]; TagDelete: [TreeNode]; + UserPinCodeReset: []; + UserAdminCreate: [UserAdminResponseDto]; UserAdminUpdate: [UserAdminResponseDto]; UserAdminRestore: [UserAdminResponseDto]; diff --git a/web/src/lib/modals/ApiKeyCreateModal.svelte b/web/src/lib/modals/ApiKeyCreateModal.svelte index c5078ca3dc..68216aa1fa 100644 --- a/web/src/lib/modals/ApiKeyCreateModal.svelte +++ b/web/src/lib/modals/ApiKeyCreateModal.svelte @@ -1,8 +1,9 @@ - (confirmed ? handleCreate() : onClose(false))} -> - {#snippet promptSnippet()} -
-
- -
-
- {/snippet} -
+ + + diff --git a/web/src/lib/modals/ObtainiumConfigModal.svelte b/web/src/lib/modals/ObtainiumConfigModal.svelte index bc74379ac8..8ffe3b7a50 100644 --- a/web/src/lib/modals/ObtainiumConfigModal.svelte +++ b/web/src/lib/modals/ObtainiumConfigModal.svelte @@ -1,10 +1,8 @@ -
- - {$t('obtainium_configurator_instructions')} - -
-
- -
+ {$t('obtainium_configurator_instructions')} -
- + + + -
- -
-
+ + + - - - - {#if inputUrl && inputApiKey && archVariant} -
-
-
- - Get it on Obtainium - -
-
- {/if} +
+
+ + + + {#if inputUrl && inputApiKey && archVariant} +
+
+
+ + Get it on Obtainium + +
+
+ {/if} diff --git a/web/src/lib/modals/PinCodeResetModal.svelte b/web/src/lib/modals/PinCodeResetModal.svelte index 1a8ce86e41..024f0c8528 100644 --- a/web/src/lib/modals/PinCodeResetModal.svelte +++ b/web/src/lib/modals/PinCodeResetModal.svelte @@ -1,20 +1,7 @@ - - -
- -
{$t('reset_pin_code_description')}
- {#if passwordLoginEnabled} -
-
- - - - {$t('reset_pin_code_with_password')} - - -
- {/if} -
-
-
- - - {#if passwordLoginEnabled} - - - - - {:else} - - {/if} - -
+{#if featureFlagsManager.value.passwordLogin === false} + + +
{$t('reset_pin_code_description')}
+
+
+ + + {$t('reset_pin_code_with_password')} + +
+
+
+{:else} + + +
{$t('reset_pin_code_description')}
+
+
+{/if} diff --git a/web/src/lib/services/api-key.service.ts b/web/src/lib/services/api-key.service.ts index 4833bafadc..dec333c0bc 100644 --- a/web/src/lib/services/api-key.service.ts +++ b/web/src/lib/services/api-key.service.ts @@ -1,6 +1,5 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte'; -import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte'; import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; @@ -56,14 +55,10 @@ export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => { return; } - const { apiKey, secret } = await createApiKey({ apiKeyCreateDto: dto }); + const response = await createApiKey({ apiKeyCreateDto: dto }); + eventManager.emit('ApiKeyCreate', response.apiKey); - eventManager.emit('ApiKeyCreate', apiKey); - - // no nested modal - void modalManager.show(ApiKeySecretModal, { secret }); - - return true; + return response; } catch (error) { handleError(error, $t('errors.unable_to_create_api_key')); } diff --git a/web/src/lib/services/job.service.ts b/web/src/lib/services/job.service.ts new file mode 100644 index 0000000000..3241afc5b0 --- /dev/null +++ b/web/src/lib/services/job.service.ts @@ -0,0 +1,16 @@ +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { createJob, type JobCreateDto } from '@immich/sdk'; +import { toastManager } from '@immich/ui'; + +export const handleCreateJob = async (dto: JobCreateDto) => { + const $t = await getFormatter(); + + try { + await createJob({ jobCreateDto: dto }); + toastManager.success($t('admin.job_created')); + return true; + } catch (error) { + handleError(error, $t('errors.unable_to_submit_job')); + } +}; diff --git a/web/src/lib/services/queue.service.ts b/web/src/lib/services/queue.service.ts index 8217e73634..d7063c29eb 100644 --- a/web/src/lib/services/queue.service.ts +++ b/web/src/lib/services/queue.service.ts @@ -64,9 +64,7 @@ export const getQueuesActions = ($t: MessageFormatter, queues: QueueResponseDto[ title: $t('admin.create_job'), type: $t('command'), shortcuts: { shift: true, key: 'n' }, - onAction: async () => { - await modalManager.show(JobCreateModal, {}); - }, + onAction: () => modalManager.show(JobCreateModal, {}), }; const ManageConcurrency: ActionItem = { diff --git a/web/src/lib/services/user.service.ts b/web/src/lib/services/user.service.ts new file mode 100644 index 0000000000..7d137449ae --- /dev/null +++ b/web/src/lib/services/user.service.ts @@ -0,0 +1,18 @@ +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { handleError } from '$lib/utils/handle-error'; +import { getFormatter } from '$lib/utils/i18n'; +import { resetPinCode, type PinCodeResetDto } from '@immich/sdk'; +import { toastManager } from '@immich/ui'; + +export const handleResetPinCode = async (dto: PinCodeResetDto) => { + const $t = await getFormatter(); + + try { + await resetPinCode({ pinCodeResetDto: dto }); + toastManager.success($t('pin_code_reset_successfully')); + eventManager.emit('UserPinCodeReset'); + return true; + } catch (error) { + handleError(error, $t('errors.failed_to_reset_pin_code')); + } +}; From 88327fb872f667b8d56f2aeaa28aaf478c24d0e9 Mon Sep 17 00:00:00 2001 From: Noel S Date: Fri, 9 Jan 2026 11:14:07 -0800 Subject: [PATCH 03/15] fix(mobile): remove weird zooming behaviour on videos and play/pause button delay (#24006) disable scale gestures --- .../lib/presentation/widgets/asset_viewer/asset_viewer.page.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 2a7ac9c7fe..279139a5b8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -611,6 +611,7 @@ class _AssetViewerState extends ConsumerState { filterQuality: FilterQuality.high, maxScale: 1.0, basePosition: Alignment.center, + disableScaleGestures: true, child: SizedBox( width: ctx.width, height: ctx.height, From 1e4af9731d40ee763f5701020e6d57b067c5342b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 9 Jan 2026 15:05:20 -0500 Subject: [PATCH 04/15] refactor: modals (#25163) --- e2e/src/web/specs/user-admin.e2e-spec.ts | 4 +- pnpm-lock.yaml | 10 +- web/package.json | 2 +- .../admin-settings/AuthSettings.svelte | 4 +- .../shared-components/change-location.svelte | 2 +- web/src/lib/modals/ApiKeySecretModal.svelte | 4 +- .../lib/modals/AssetDeleteConfirmModal.svelte | 2 +- .../AssetUpdateDescriptionConfirmModal.svelte | 20 +- .../AuthDisableLoginConfirmModal.svelte | 24 +-- .../GeolocationUpdateConfirmModal.svelte | 27 +-- .../modals/PasswordResetSuccessModal.svelte | 46 ++--- .../modals/PersonMergeSuggestionModal.svelte | 184 +++++++++--------- .../modals/ProfileImageCropperModal.svelte | 28 ++- .../lib/modals/UserDeleteConfirmModal.svelte | 2 +- .../lib/modals/UserRestoreConfirmModal.svelte | 2 +- .../modals/VersionAnnouncementModal.svelte | 58 +++--- .../admin/users/(list)/new/+page.svelte | 113 +++++------ .../routes/admin/users/[id]/edit/+page.svelte | 83 +++----- 18 files changed, 258 insertions(+), 357 deletions(-) diff --git a/e2e/src/web/specs/user-admin.e2e-spec.ts b/e2e/src/web/specs/user-admin.e2e-spec.ts index 7a2cd77177..67a537ba9d 100644 --- a/e2e/src/web/specs/user-admin.e2e-spec.ts +++ b/e2e/src/web/specs/user-admin.e2e-spec.ts @@ -56,7 +56,7 @@ test.describe('User Administration', () => { await expect(page.getByLabel('Admin User')).not.toBeChecked(); await page.getByLabel('Admin User').click(); await expect(page.getByLabel('Admin User')).toBeChecked(); - await page.getByRole('button', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); await expect .poll(async () => { @@ -85,7 +85,7 @@ test.describe('User Administration', () => { await expect(page.getByLabel('Admin User')).toBeChecked(); await page.getByLabel('Admin User').click(); await expect(page.getByLabel('Admin User')).not.toBeChecked(); - await page.getByRole('button', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Save' }).click(); await expect .poll(async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9980023d36..e3eb433e60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -735,8 +735,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.54.0 - version: 0.54.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) + specifier: ^0.56.1 + version: 0.56.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -3084,8 +3084,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.54.0': - resolution: {integrity: sha512-6jvkvKhgsZ7LvspaJkbht/f8W5IRm+vjYkcZecShFAPaxaowbm7io9sO15MpJdIQfPdXg7vwLI527PV3vlBc6A==} + '@immich/ui@0.56.1': + resolution: {integrity: sha512-W4uEQn9pxVKRvIV7sl9p6dU2r7xlVsMFxBeClxtXzSsiJEoE10uZwBIm0L9q17c4TQ/+lk9e/w1e4jNSvFqFwQ==} peerDependencies: svelte: ^5.0.0 @@ -15098,7 +15098,7 @@ snapshots: dependencies: svelte: 5.46.1 - '@immich/ui@0.54.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)': + '@immich/ui@0.56.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.1)': dependencies: '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1) '@internationalized/date': 3.10.0 diff --git a/web/package.json b/web/package.json index acd888783f..415bda0014 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.54.0", + "@immich/ui": "^0.56.1", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", diff --git a/web/src/lib/components/admin-settings/AuthSettings.svelte b/web/src/lib/components/admin-settings/AuthSettings.svelte index c53060706e..098ce23259 100644 --- a/web/src/lib/components/admin-settings/AuthSettings.svelte +++ b/web/src/lib/components/admin-settings/AuthSettings.svelte @@ -32,8 +32,8 @@ const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled; if (allMethodsDisabled) { - const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal); - if (!isConfirmed) { + const confirmed = await modalManager.show(AuthDisableLoginConfirmModal); + if (!confirmed) { return false; } } diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte index 57fcf9c98d..1abb2dacce 100644 --- a/web/src/lib/components/shared-components/change-location.svelte +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -146,7 +146,7 @@ size="medium" onClose={handleConfirm} > - {#snippet promptSnippet()} + {#snippet prompt()}
{#if suggestionContainer} diff --git a/web/src/lib/modals/ApiKeySecretModal.svelte b/web/src/lib/modals/ApiKeySecretModal.svelte index 98a303f814..fba988aa58 100644 --- a/web/src/lib/modals/ApiKeySecretModal.svelte +++ b/web/src/lib/modals/ApiKeySecretModal.svelte @@ -4,10 +4,10 @@ import { mdiKeyVariant } from '@mdi/js'; import { t } from 'svelte-i18n'; - interface Props { + type Props = { secret?: string; onClose: () => void; - } + }; let { secret = '', onClose }: Props = $props(); diff --git a/web/src/lib/modals/AssetDeleteConfirmModal.svelte b/web/src/lib/modals/AssetDeleteConfirmModal.svelte index 95c580d8f2..aba1d59e34 100644 --- a/web/src/lib/modals/AssetDeleteConfirmModal.svelte +++ b/web/src/lib/modals/AssetDeleteConfirmModal.svelte @@ -29,7 +29,7 @@ icon={mdiDeleteForeverOutline} {onClose} > - {#snippet promptSnippet()} + {#snippet prompt()}

{#snippet children({ message })} diff --git a/web/src/lib/modals/AssetUpdateDescriptionConfirmModal.svelte b/web/src/lib/modals/AssetUpdateDescriptionConfirmModal.svelte index 9bbfb71696..a8e163f29e 100644 --- a/web/src/lib/modals/AssetUpdateDescriptionConfirmModal.svelte +++ b/web/src/lib/modals/AssetUpdateDescriptionConfirmModal.svelte @@ -1,5 +1,5 @@ - (confirmed ? onClose(description) : onClose())} -> - {#snippet promptSnippet()} - -