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.
This commit is contained in:
Thomas
2026-02-16 04:24:57 +00:00
committed by GitHub
parent c9dd8e0a79
commit d2682f160e
13 changed files with 125 additions and 123 deletions

View File

@@ -134,7 +134,7 @@ class AlbumOptionsPage extends HookConsumerWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = sharedUsers.value[index]; final user = sharedUsers.value[index];
return ListTile( return ListTile(
leading: UserCircleAvatar(user: user, radius: 22), leading: UserCircleAvatar(user: user),
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(), trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),

View File

@@ -41,7 +41,7 @@ class AlbumSharedUserIcons extends HookConsumerWidget {
itemBuilder: ((context, index) { itemBuilder: ((context, index) {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8.0), 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, itemCount: sharedUsers.value.length,

View File

@@ -44,8 +44,8 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
pinned: true, pinned: true,
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.add_rounded, size: 28),
onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()), onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()),
icon: const Icon(Icons.add_rounded),
), ),
], ],
showUploadButton: false, showUploadButton: false,

View File

@@ -149,7 +149,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
} }
return ListTile( return ListTile(
leading: UserCircleAvatar(user: user, radius: 22), leading: UserCircleAvatar(user: user),
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context), trailing: Text("owner", style: context.textTheme.labelLarge).t(context: context),
@@ -169,7 +169,7 @@ class DriftAlbumOptionsPage extends HookConsumerWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = sharedUsers[index]; final user = sharedUsers[index];
return ListTile( return ListTile(
leading: UserCircleAvatar(user: user, radius: 22), leading: UserCircleAvatar(user: user),
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(), trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),

View File

@@ -88,7 +88,7 @@ class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField>
prefixIcon: user != null prefixIcon: user != null
? Padding( ? Padding(
padding: const EdgeInsets.symmetric(horizontal: 15), padding: const EdgeInsets.symmetric(horizontal: 15),
child: UserCircleAvatar(user: user, size: 30, radius: 15), child: UserCircleAvatar(user: user, size: 30),
) )
: null, : null,
suffixIcon: IconButton( suffixIcon: IconButton(

View File

@@ -63,7 +63,7 @@ class ActivityTextField extends HookConsumerWidget {
prefixIcon: user != null prefixIcon: user != null
? Padding( ? Padding(
padding: const EdgeInsets.symmetric(horizontal: 15), padding: const EdgeInsets.symmetric(horizontal: 15),
child: UserCircleAvatar(user: user, size: 30, radius: 15), child: UserCircleAvatar(user: user, size: 30),
) )
: null, : null,
suffixIcon: Padding( suffixIcon: Padding(

View File

@@ -40,7 +40,7 @@ class ActivityTile extends HookConsumerWidget {
child: Icon(Icons.thumb_up, color: context.primaryColor), child: Icon(Icons.thumb_up, color: context.primaryColor),
) )
: isBottomSheet : isBottomSheet
? UserCircleAvatar(user: activity.user, size: 30, radius: 15) ? UserCircleAvatar(user: activity.user, size: 30)
: UserCircleAvatar(user: activity.user), : UserCircleAvatar(user: activity.user),
title: _ActivityTitle( title: _ActivityTitle(
userName: activity.user.name, userName: activity.user.name,

View File

@@ -41,7 +41,7 @@ class CommentBubble extends ConsumerWidget {
// avatar (hidden for own messages) // avatar (hidden for own messages)
Widget avatar = const SizedBox.shrink(); Widget avatar = const SizedBox.shrink();
if (!isOwn) { 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 // Thumbnail with tappable behavior and optional heart overlay

View File

@@ -33,7 +33,7 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget {
itemBuilder: ((context, index) { itemBuilder: ((context, index) {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 4.0), 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, itemCount: sharedUsers.length,

View File

@@ -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) { if (uploadProfileImageStatus == UploadProfileStatus.loading) {
return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20)); return const SizedBox(height: 40, width: 40, child: ImmichLoadingIndicator(borderRadius: 20));

View File

@@ -51,7 +51,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
? const Icon(Icons.face_outlined, size: widgetSize) ? const Icon(Icons.face_outlined, size: widgetSize)
: Semantics( : Semantics(
label: "logged_in_as".tr(namedArgs: {"user": user.name}), label: "logged_in_as".tr(namedArgs: {"user": user.name}),
child: UserCircleAvatar(radius: 17, size: 31, user: user), child: UserCircleAvatar(size: 32, user: user),
), ),
), ),
); );

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -46,7 +48,9 @@ class ImmichSliverAppBar extends ConsumerWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return SliverAnimatedOpacity( return SliverIgnorePointer(
ignoring: isMultiSelectEnabled,
sliver: SliverAnimatedOpacity(
duration: Durations.medium1, duration: Durations.medium1,
opacity: isMultiSelectEnabled ? 0 : 1, opacity: isMultiSelectEnabled ? 0 : 1,
sliver: SliverAppBar( sliver: SliverAppBar(
@@ -63,29 +67,24 @@ class ImmichSliverAppBar extends ConsumerWidget {
centerTitle: false, centerTitle: false,
title: title ?? const _ImmichLogoWithText(), title: title ?? const _ImmichLogoWithText(),
actions: [ actions: [
const _SyncStatusIndicator(),
if (isCasting && !isReadonlyModeEnabled) if (isCasting && !isReadonlyModeEnabled)
Padding( IconButton(
padding: const EdgeInsets.only(right: 12), onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()),
child: IconButton(
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded), icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded),
), ),
), if (actions != null) ...actions!,
const _SyncStatusIndicator(),
if (actions != null)
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled) if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
IconButton( IconButton(
icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()), onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
icon: const Icon(Icons.palette_rounded),
), ),
if (showUploadButton && !isReadonlyModeEnabled) if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(),
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()), const _ProfileIndicator(),
const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()), const SizedBox(width: 8),
], ],
), ),
),
); );
} }
} }
@@ -94,27 +93,14 @@ class _ImmichLogoWithText extends StatelessWidget {
const _ImmichLogoWithText(); const _ImmichLogoWithText();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => AnimatedOpacity(
return Builder( opacity: IconTheme.of(context).opacity ?? 1,
builder: (BuildContext context) { duration: kThemeChangeDuration,
return Row(
children: [
Builder(
builder: (context) {
return Padding(
padding: const EdgeInsets.only(top: 3.0),
child: SvgPicture.asset( child: SvgPicture.asset(
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg', context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
height: 40, height: 40,
), ),
); );
},
),
],
);
},
);
}
} }
class _ProfileIndicator extends ConsumerWidget { class _ProfileIndicator extends ConsumerWidget {
@@ -126,7 +112,7 @@ class _ProfileIndicator extends ConsumerWidget {
final bool versionWarningPresent = ref.watch(versionWarningPresentProvider(user)); final bool versionWarningPresent = ref.watch(versionWarningPresentProvider(user));
final serverInfoState = ref.watch(serverInfoProvider); 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 // TODO: remove this when update Flutter version newer than 3.35.7
final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile; final isIpad = defaultTargetPlatform == TargetPlatform.iOS && !context.isMobile;
@@ -146,27 +132,23 @@ class _ProfileIndicator extends ConsumerWidget {
); );
} }
return InkWell( return IconButton(
onTap: () => showDialog( onPressed: () => showDialog(
context: context, context: context,
useRootNavigator: false, useRootNavigator: false,
barrierDismissible: !isIpad, barrierDismissible: !isIpad,
builder: (ctx) => const ImmichAppBarDialog(), builder: (ctx) => const ImmichAppBarDialog(),
), ),
onLongPress: () => toggleReadonlyMode(), onLongPress: () => toggleReadonlyMode(),
borderRadius: const BorderRadius.all(Radius.circular(12)), icon: Badge(
child: Badge( label: _BadgeLabel(
label: Container( Icon(
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.black : Colors.white,
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: Icon(
Icons.info, Icons.info,
color: serverInfoState.versionStatus == VersionStatus.error color: serverInfoState.versionStatus == VersionStatus.error
? context.colorScheme.error ? context.colorScheme.error
: context.primaryColor, : context.primaryColor,
size: widgetSize / 2, size: widgetSize / 2,
semanticLabel: 'new_version_available'.tr(),
), ),
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@@ -177,7 +159,16 @@ class _ProfileIndicator extends ConsumerWidget {
? const Icon(Icons.face_outlined, size: widgetSize) ? const Icon(Icons.face_outlined, size: widgetSize)
: Semantics( : Semantics(
label: "logged_in_as".tr(namedArgs: {"user": user.name}), 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) { Widget build(BuildContext context, WidgetRef ref) {
final indicatorIcon = _getBackupBadgeIcon(context, ref); final indicatorIcon = _getBackupBadgeIcon(context, ref);
return InkWell( return IconButton(
onTap: () => context.pushRoute(const DriftBackupRoute()), onPressed: () => context.pushRoute(const DriftBackupRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)), icon: Badge(
child: Badge(
label: indicatorIcon, label: indicatorIcon,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight, alignment: Alignment.bottomRight,
@@ -278,12 +268,14 @@ class _BadgeLabel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final opacity = IconTheme.of(context).opacity ?? 1;
return Container( return Container(
width: _kBadgeWidgetSize / 2, width: _kBadgeWidgetSize / 2,
height: _kBadgeWidgetSize / 2, height: _kBadgeWidgetSize / 2,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor ?? context.colorScheme.surfaceContainer, color: (backgroundColor ?? context.colorScheme.surfaceContainer).withValues(alpha: opacity),
border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)), border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3 * opacity)),
borderRadius: BorderRadius.circular(_kBadgeWidgetSize / 2), borderRadius: BorderRadius.circular(_kBadgeWidgetSize / 2),
), ),
child: indicator, child: indicator,
@@ -346,23 +338,30 @@ class _SyncStatusIndicatorState extends ConsumerState<_SyncStatusIndicator> with
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
return Padding(
padding: const EdgeInsets.all(8),
child: TweenAnimationBuilder<double>(
tween: Tween(end: IconTheme.of(context).opacity ?? 1),
duration: kThemeChangeDuration,
builder: (context, opacity, child) {
return AnimatedBuilder( return AnimatedBuilder(
animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]), animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]),
builder: (context, child) { builder: (context, child) {
return Padding( final dismissalValue = isSyncing ? 1.0 : _dismissalAnimation.value;
padding: EdgeInsets.only(right: isSyncing ? 16 : 0), return IconTheme(
child: Transform.scale( data: IconTheme.of(context).copyWith(opacity: opacity * dismissalValue),
scale: isSyncing ? 1.0 : _dismissalAnimation.value, child: Transform(
child: Opacity( alignment: Alignment.center,
opacity: isSyncing ? 1.0 : _dismissalAnimation.value, transform: Matrix4.identity()
child: Transform.rotate( ..scaleByDouble(dismissalValue, dismissalValue, dismissalValue, 1.0)
angle: _rotationAnimation.value * 2 * 3.14159 * -1, // Rotate counter-clockwise ..rotateZ(-_rotationAnimation.value * 2 * math.pi),
child: Icon(Icons.sync, size: 24, color: context.primaryColor), child: const Icon(Icons.sync),
),
),
), ),
); );
}, },
); );
},
),
);
} }
} }

View File

@@ -8,49 +8,52 @@ import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.
// ignore: must_be_immutable // ignore: must_be_immutable
class UserCircleAvatar extends ConsumerWidget { class UserCircleAvatar extends ConsumerWidget {
final UserDto user; final UserDto user;
double radius;
double size; double size;
bool hasBorder; 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final userAvatarColor = user.avatarColor.toColor(); final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity);
final profileImageUrl = final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}'; '${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( final textIcon = DefaultTextStyle(
style: TextStyle( style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: textColor),
fontWeight: FontWeight.bold,
fontSize: 12,
color: userAvatarColor.computeLuminance() > 0.5 ? Colors.black : Colors.white,
),
child: Text(user.name[0].toUpperCase()), child: Text(user.name[0].toUpperCase()),
); );
return Tooltip( return Tooltip(
message: user.name, message: user.name,
child: UnconstrainedBox(
child: Container( child: Container(
width: size,
height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: userAvatarColor,
shape: BoxShape.circle, shape: BoxShape.circle,
border: hasBorder ? Border.all(color: Colors.grey[500]!, width: 1) : null, border: hasBorder ? Border.all(color: Colors.grey[500]!.withValues(alpha: opacity), width: 1) : null,
), ),
child: CircleAvatar(
backgroundColor: userAvatarColor,
radius: radius,
child: user.hasProfileImage child: user.hasProfileImage
? ClipRRect( ? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(50)), borderRadius: BorderRadius.all(Radius.circular(size / 2)),
child: Image( child: Image(
fit: BoxFit.cover, fit: BoxFit.cover,
width: size, width: size,
height: size, height: size,
image: RemoteImageProvider(url: profileImageUrl), image: RemoteImageProvider(url: profileImageUrl),
errorBuilder: (context, error, stackTrace) => textIcon, errorBuilder: (context, error, stackTrace) => textIcon,
color: Colors.white.withValues(alpha: opacity),
colorBlendMode: BlendMode.modulate,
), ),
) )
: textIcon, : Center(child: textIcon),
), ),
), ),
); );