From d2682f160e45114daf4161157b0b90527566d6e1 Mon Sep 17 00:00:00 2001 From: Thomas <9749173+uhthomas@users.noreply.github.com> Date: Mon, 16 Feb 2026 04:24:57 +0000 Subject: [PATCH] fix(mobile): inherit toolbar opacity (#25694) Some widgets, like Icon widgets, automatically inherit opacity from the icon theme in the context. Many other widgets however, do not. The Immich logo, profile picture, and backup badge are examples of widgets of this. All unsupported toolbar widgets have been updated to support inheriting the opacity from the icon theme. IconButtons internally animate properties like opacity, which is kind of nice, but means we have to do more work to replicate that behaviour for other widgets. In most cases, we can simply use an IconButton widget and forward the correct opacity. The Immich logo however is not a button, and therefore we need to use a custom TweenAnimationBuilder. All widgets are using efficient, native opacity rather than the heavy Opacity widget. --- .../lib/pages/album/album_options.page.dart | 2 +- .../pages/album/album_shared_user_icons.dart | 2 +- .../presentation/pages/drift_album.page.dart | 2 +- .../pages/drift_album_options.page.dart | 4 +- .../album/drift_activity_text_field.dart | 2 +- .../activities/activity_text_field.dart | 2 +- .../lib/widgets/activities/activity_tile.dart | 2 +- .../widgets/activities/comment_bubble.dart | 2 +- .../album/remote_album_shared_user_icons.dart | 2 +- .../app_bar_dialog/app_bar_profile_info.dart | 2 +- mobile/lib/widgets/common/immich_app_bar.dart | 2 +- .../widgets/common/immich_sliver_app_bar.dart | 185 +++++++++--------- .../widgets/common/user_circle_avatar.dart | 39 ++-- 13 files changed, 125 insertions(+), 123 deletions(-) diff --git a/mobile/lib/pages/album/album_options.page.dart b/mobile/lib/pages/album/album_options.page.dart index b0f682ffed..ca65a92a79 100644 --- a/mobile/lib/pages/album/album_options.page.dart +++ b/mobile/lib/pages/album/album_options.page.dart @@ -134,7 +134,7 @@ class AlbumOptionsPage extends HookConsumerWidget { itemBuilder: (context, index) { final user = sharedUsers.value[index]; return ListTile( - leading: UserCircleAvatar(user: user, radius: 22), + leading: UserCircleAvatar(user: user), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(), diff --git a/mobile/lib/pages/album/album_shared_user_icons.dart b/mobile/lib/pages/album/album_shared_user_icons.dart index fe1823ec61..7cf6f387ae 100644 --- a/mobile/lib/pages/album/album_shared_user_icons.dart +++ b/mobile/lib/pages/album/album_shared_user_icons.dart @@ -41,7 +41,7 @@ class AlbumSharedUserIcons extends HookConsumerWidget { itemBuilder: ((context, index) { return Padding( padding: const EdgeInsets.only(right: 8.0), - child: UserCircleAvatar(user: sharedUsers.value[index], radius: 18, size: 36), + child: UserCircleAvatar(user: sharedUsers.value[index], size: 36), ); }), itemCount: sharedUsers.value.length, diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index cde8c127db..c9fed636b4 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -44,8 +44,8 @@ class _DriftAlbumsPageState extends ConsumerState { pinned: true, actions: [ IconButton( - icon: const Icon(Icons.add_rounded, size: 28), onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()), + icon: const Icon(Icons.add_rounded), ), ], showUploadButton: false, diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart index 9db6e98613..061edbaf26 100644 --- a/mobile/lib/presentation/pages/drift_album_options.page.dart +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -149,7 +149,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { } return ListTile( - leading: UserCircleAvatar(user: user, radius: 22), + leading: UserCircleAvatar(user: user), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context), @@ -169,7 +169,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget { itemBuilder: (context, index) { final user = sharedUsers[index]; return ListTile( - leading: UserCircleAvatar(user: user, radius: 22), + leading: UserCircleAvatar(user: user), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(), diff --git a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart index fe5c763ec5..691b46f80d 100644 --- a/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart +++ b/mobile/lib/presentation/widgets/album/drift_activity_text_field.dart @@ -88,7 +88,7 @@ class _DriftActivityTextFieldState extends ConsumerState prefixIcon: user != null ? Padding( padding: const EdgeInsets.symmetric(horizontal: 15), - child: UserCircleAvatar(user: user, size: 30, radius: 15), + child: UserCircleAvatar(user: user, size: 30), ) : null, suffixIcon: IconButton( diff --git a/mobile/lib/widgets/activities/activity_text_field.dart b/mobile/lib/widgets/activities/activity_text_field.dart index a61a284844..d21cdfbc94 100644 --- a/mobile/lib/widgets/activities/activity_text_field.dart +++ b/mobile/lib/widgets/activities/activity_text_field.dart @@ -63,7 +63,7 @@ class ActivityTextField extends HookConsumerWidget { prefixIcon: user != null ? Padding( padding: const EdgeInsets.symmetric(horizontal: 15), - child: UserCircleAvatar(user: user, size: 30, radius: 15), + child: UserCircleAvatar(user: user, size: 30), ) : null, suffixIcon: Padding( diff --git a/mobile/lib/widgets/activities/activity_tile.dart b/mobile/lib/widgets/activities/activity_tile.dart index e0eccbff21..ac3b6c95a4 100644 --- a/mobile/lib/widgets/activities/activity_tile.dart +++ b/mobile/lib/widgets/activities/activity_tile.dart @@ -40,7 +40,7 @@ class ActivityTile extends HookConsumerWidget { child: Icon(Icons.thumb_up, color: context.primaryColor), ) : isBottomSheet - ? UserCircleAvatar(user: activity.user, size: 30, radius: 15) + ? UserCircleAvatar(user: activity.user, size: 30) : UserCircleAvatar(user: activity.user), title: _ActivityTitle( userName: activity.user.name, diff --git a/mobile/lib/widgets/activities/comment_bubble.dart b/mobile/lib/widgets/activities/comment_bubble.dart index 5f060833a7..401e4b8e99 100644 --- a/mobile/lib/widgets/activities/comment_bubble.dart +++ b/mobile/lib/widgets/activities/comment_bubble.dart @@ -41,7 +41,7 @@ class CommentBubble extends ConsumerWidget { // avatar (hidden for own messages) Widget avatar = const SizedBox.shrink(); if (!isOwn) { - avatar = UserCircleAvatar(user: activity.user, size: 28, radius: 14); + avatar = UserCircleAvatar(user: activity.user, size: 28); } // Thumbnail with tappable behavior and optional heart overlay diff --git a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart index 8913e94136..2025fa7583 100644 --- a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart +++ b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart @@ -33,7 +33,7 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget { itemBuilder: ((context, index) { return Padding( padding: const EdgeInsets.only(right: 4.0), - child: UserCircleAvatar(user: sharedUsers[index], radius: 18, size: 36, hasBorder: true), + child: UserCircleAvatar(user: sharedUsers[index], size: 36, hasBorder: true), ); }), itemCount: sharedUsers.length, diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart index b0c005424f..12273849f2 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_profile_info.dart @@ -34,7 +34,7 @@ class AppBarProfileInfoBox extends HookConsumerWidget { ); } - final userImage = UserCircleAvatar(radius: 22, size: 44, user: user); + final userImage = UserCircleAvatar(size: 44, user: user); if (uploadProfileImageStatus == UploadProfileStatus.loading) { return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20)); diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index b3dc04236c..ebd8ed8b36 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -51,7 +51,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { ? const Icon(Icons.face_outlined, size: widgetSize) : Semantics( label: "logged_in_as".tr(namedArgs: {"user": user.name}), - child: UserCircleAvatar(radius: 17, size: 31, user: user), + child: UserCircleAvatar(size: 32, user: user), ), ), ); diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 95622c1e5a..939e9e27aa 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/foundation.dart'; @@ -46,45 +48,42 @@ class ImmichSliverAppBar extends ConsumerWidget { final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); - return SliverAnimatedOpacity( - duration: Durations.medium1, - opacity: isMultiSelectEnabled ? 0 : 1, - sliver: SliverAppBar( - backgroundColor: context.colorScheme.surface, - surfaceTintColor: context.colorScheme.surfaceTint, - elevation: 0, - scrolledUnderElevation: 1.0, - floating: floating, - pinned: pinned, - snap: snap, - expandedHeight: expandedHeight, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), - automaticallyImplyLeading: false, - centerTitle: false, - title: title ?? const _ImmichLogoWithText(), - actions: [ - if (isCasting && !isReadonlyModeEnabled) - Padding( - padding: const EdgeInsets.only(right: 12), - child: IconButton( - onPressed: () { - showDialog(context: context, builder: (context) => const CastDialog()); - }, + return SliverIgnorePointer( + ignoring: isMultiSelectEnabled, + sliver: SliverAnimatedOpacity( + duration: Durations.medium1, + opacity: isMultiSelectEnabled ? 0 : 1, + sliver: SliverAppBar( + backgroundColor: context.colorScheme.surface, + surfaceTintColor: context.colorScheme.surfaceTint, + elevation: 0, + scrolledUnderElevation: 1.0, + floating: floating, + pinned: pinned, + snap: snap, + expandedHeight: expandedHeight, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))), + automaticallyImplyLeading: false, + centerTitle: false, + title: title ?? const _ImmichLogoWithText(), + actions: [ + const _SyncStatusIndicator(), + if (isCasting && !isReadonlyModeEnabled) + IconButton( + onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()), icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded), ), - ), - const _SyncStatusIndicator(), - if (actions != null) - ...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)), - if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) - IconButton( - icon: const Icon(Icons.palette_rounded), - onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), - ), - if (showUploadButton && !isReadonlyModeEnabled) - const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), - const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()), - ], + if (actions != null) ...actions!, + if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) + IconButton( + onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), + icon: const Icon(Icons.palette_rounded), + ), + if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(), + const _ProfileIndicator(), + const SizedBox(width: 8), + ], + ), ), ); } @@ -94,27 +93,14 @@ class _ImmichLogoWithText extends StatelessWidget { const _ImmichLogoWithText(); @override - Widget build(BuildContext context) { - return Builder( - builder: (BuildContext context) { - return Row( - children: [ - Builder( - builder: (context) { - return Padding( - padding: const EdgeInsets.only(top: 3.0), - child: SvgPicture.asset( - context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg', - height: 40, - ), - ); - }, - ), - ], - ); - }, - ); - } + Widget build(BuildContext context) => AnimatedOpacity( + opacity: IconTheme.of(context).opacity ?? 1, + duration: kThemeChangeDuration, + child: SvgPicture.asset( + context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg', + height: 40, + ), + ); } class _ProfileIndicator extends ConsumerWidget { @@ -126,7 +112,7 @@ class _ProfileIndicator extends ConsumerWidget { final bool versionWarningPresent = ref.watch(versionWarningPresentProvider(user)); final serverInfoState = ref.watch(serverInfoProvider); - const widgetSize = 30.0; + const widgetSize = 32.0; // TODO: remove this when update Flutter version newer than 3.35.7 final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile; @@ -146,27 +132,23 @@ class _ProfileIndicator extends ConsumerWidget { ); } - return InkWell( - onTap: () => showDialog( + return IconButton( + onPressed: () => showDialog( context: context, useRootNavigator: false, barrierDismissible: !isIpad, builder: (ctx) => const ImmichAppBarDialog(), ), onLongPress: () => toggleReadonlyMode(), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Badge( - label: Container( - decoration: BoxDecoration( - color: context.isDarkTheme ? Colors.black : Colors.white, - borderRadius: BorderRadius.circular(widgetSize / 2), - ), - child: Icon( + icon: Badge( + label: _BadgeLabel( + Icon( Icons.info, color: serverInfoState.versionStatus == VersionStatus.error ? context.colorScheme.error : context.primaryColor, size: widgetSize / 2, + semanticLabel: 'new_version_available'.tr(), ), ), backgroundColor: Colors.transparent, @@ -177,7 +159,16 @@ class _ProfileIndicator extends ConsumerWidget { ? const Icon(Icons.face_outlined, size: widgetSize) : Semantics( label: "logged_in_as".tr(namedArgs: {"user": user.name}), - child: AbsorbPointer(child: UserCircleAvatar(radius: 17, size: 31, user: user)), + child: AbsorbPointer( + child: Builder( + builder: (context) => UserCircleAvatar( + size: 32, + user: user, + opacity: IconTheme.of(context).opacity ?? 1, + hasBorder: true, + ), + ), + ), ), ), ); @@ -193,10 +184,9 @@ class _BackupIndicator extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final indicatorIcon = _getBackupBadgeIcon(context, ref); - return InkWell( - onTap: () => context.pushRoute(const DriftBackupRoute()), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Badge( + return IconButton( + onPressed: () => context.pushRoute(const DriftBackupRoute()), + icon: Badge( label: indicatorIcon, backgroundColor: Colors.transparent, alignment: Alignment.bottomRight, @@ -278,12 +268,14 @@ class _BadgeLabel extends StatelessWidget { @override Widget build(BuildContext context) { + final opacity = IconTheme.of(context).opacity ?? 1; + return Container( width: _kBadgeWidgetSize / 2, height: _kBadgeWidgetSize / 2, decoration: BoxDecoration( - color: backgroundColor ?? context.colorScheme.surfaceContainer, - border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)), + color: (backgroundColor ?? context.colorScheme.surfaceContainer).withValues(alpha: opacity), + border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3 * opacity)), borderRadius: BorderRadius.circular(_kBadgeWidgetSize / 2), ), child: indicator, @@ -346,23 +338,30 @@ class _SyncStatusIndicatorState extends ConsumerState<_SyncStatusIndicator> with return const SizedBox.shrink(); } - return AnimatedBuilder( - animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]), - builder: (context, child) { - return Padding( - padding: EdgeInsets.only(right: isSyncing ? 16 : 0), - child: Transform.scale( - scale: isSyncing ? 1.0 : _dismissalAnimation.value, - child: Opacity( - opacity: isSyncing ? 1.0 : _dismissalAnimation.value, - child: Transform.rotate( - angle: _rotationAnimation.value * 2 * 3.14159 * -1, // Rotate counter-clockwise - child: Icon(Icons.sync, size: 24, color: context.primaryColor), - ), - ), - ), - ); - }, + return Padding( + padding: const EdgeInsets.all(8), + child: TweenAnimationBuilder( + tween: Tween(end: IconTheme.of(context).opacity ?? 1), + duration: kThemeChangeDuration, + builder: (context, opacity, child) { + return AnimatedBuilder( + animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]), + builder: (context, child) { + final dismissalValue = isSyncing ? 1.0 : _dismissalAnimation.value; + return IconTheme( + data: IconTheme.of(context).copyWith(opacity: opacity * dismissalValue), + child: Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..scaleByDouble(dismissalValue, dismissalValue, dismissalValue, 1.0) + ..rotateZ(-_rotationAnimation.value * 2 * math.pi), + child: const Icon(Icons.sync), + ), + ); + }, + ); + }, + ), ); } } diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index 352d686e7c..fe39c5da3f 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -8,49 +8,52 @@ import 'package:immich_mobile/presentation/widgets/images/remote_image_provider. // ignore: must_be_immutable class UserCircleAvatar extends ConsumerWidget { final UserDto user; - double radius; double size; bool hasBorder; + double opacity; - UserCircleAvatar({super.key, this.radius = 22, this.size = 44, this.hasBorder = false, required this.user}); + UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user}); @override Widget build(BuildContext context, WidgetRef ref) { - final userAvatarColor = user.avatarColor.toColor(); + final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity); final profileImageUrl = '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}'; + final textColor = (user.avatarColor.toColor().computeLuminance() > 0.5 ? Colors.black : Colors.white).withValues( + alpha: opacity, + ); + final textIcon = DefaultTextStyle( - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12, - color: userAvatarColor.computeLuminance() > 0.5 ? Colors.black : Colors.white, - ), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: textColor), child: Text(user.name[0].toUpperCase()), ); return Tooltip( message: user.name, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - border: hasBorder ? Border.all(color: Colors.grey[500]!, width: 1) : null, - ), - child: CircleAvatar( - backgroundColor: userAvatarColor, - radius: radius, + child: UnconstrainedBox( + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: userAvatarColor, + shape: BoxShape.circle, + border: hasBorder ? Border.all(color: Colors.grey[500]!.withValues(alpha: opacity), width: 1) : null, + ), child: user.hasProfileImage ? ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(50)), + borderRadius: BorderRadius.all(Radius.circular(size / 2)), child: Image( fit: BoxFit.cover, width: size, height: size, image: RemoteImageProvider(url: profileImageUrl), errorBuilder: (context, error, stackTrace) => textIcon, + color: Colors.white.withValues(alpha: opacity), + colorBlendMode: BlendMode.modulate, ), ) - : textIcon, + : Center(child: textIcon), ), ), );