fix(mobile): inherit toolbar opacity

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 Way
2026-01-30 15:22:43 +00:00
parent d5ad35ea52
commit 6ccd98c637
13 changed files with 92 additions and 87 deletions

View File

@@ -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(),

View File

@@ -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,

View File

@@ -44,8 +44,8 @@ class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
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,

View File

@@ -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(),

View File

@@ -88,7 +88,7 @@ class _DriftActivityTextFieldState extends ConsumerState<DriftActivityTextField>
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(

View File

@@ -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(

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

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) {
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)
: Semantics(
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:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
@@ -63,27 +65,20 @@ class ImmichSliverAppBar extends ConsumerWidget {
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());
},
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 (isCasting && !isReadonlyModeEnabled)
IconButton(
onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()),
icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded),
),
if (actions != null) ...actions!,
if ((kDebugMode || kProfileMode) && !isReadonlyModeEnabled)
IconButton(
icon: const Icon(Icons.palette_rounded),
onPressed: () => context.pushRoute(const ImmichUIShowcaseRoute()),
icon: const Icon(Icons.palette_rounded),
),
if (showUploadButton && !isReadonlyModeEnabled)
const Padding(padding: EdgeInsets.only(right: 20), child: _BackupIndicator()),
const Padding(padding: EdgeInsets.only(right: 20), child: _ProfileIndicator()),
if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(),
const _ProfileIndicator(),
],
),
);
@@ -95,22 +90,14 @@ class _ImmichLogoWithText extends StatelessWidget {
@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,
),
);
},
),
],
return TweenAnimationBuilder<double>(
tween: Tween(end: IconTheme.of(context).opacity ?? 1),
duration: kThemeChangeDuration,
builder: (context, opacity, child) {
return SvgPicture.asset(
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
height: 40,
colorFilter: ColorFilter.mode(Colors.white.withValues(alpha: opacity), BlendMode.modulate),
);
},
);
@@ -126,7 +113,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;
void toggleReadonlyMode() {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
@@ -143,11 +130,11 @@ class _ProfileIndicator extends ConsumerWidget {
);
}
return InkWell(
onTap: () => showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()),
return IconButton(
onPressed: () =>
showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()),
onLongPress: () => toggleReadonlyMode(),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
icon: Badge(
label: Container(
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.black : Colors.white,
@@ -169,7 +156,14 @@ 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) {
final opacity = IconTheme.of(context).opacity ?? 1;
return UserCircleAvatar(size: 32, user: user, opacity: opacity);
},
),
),
),
),
);
@@ -185,10 +179,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,
@@ -270,12 +263,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,
@@ -338,23 +333,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<double>(
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),
),
);
},
);
},
),
);
}
}

View File

@@ -12,24 +12,24 @@ import 'package:immich_mobile/widgets/common/transparent_image.dart';
// 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=${Random().nextInt(1024)}';
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()),
);
@@ -38,14 +38,15 @@ class UserCircleAvatar extends ConsumerWidget {
child: Container(
decoration: BoxDecoration(
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: Container(
width: size,
height: size,
decoration: BoxDecoration(color: userAvatarColor, shape: BoxShape.circle),
child: user.hasProfileImage
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(50)),
borderRadius: BorderRadius.all(Radius.circular(size / 2)),
child: CachedNetworkImage(
fit: BoxFit.cover,
cacheKey: '${user.id}-${user.profileChangedAt.toIso8601String()}',
@@ -56,9 +57,11 @@ class UserCircleAvatar extends ConsumerWidget {
httpHeaders: ApiService.getRequestHeaders(),
fadeInDuration: const Duration(milliseconds: 300),
errorWidget: (context, error, stackTrace) => textIcon,
color: Colors.white.withValues(alpha: opacity),
colorBlendMode: BlendMode.modulate,
),
)
: textIcon,
: Center(child: textIcon),
),
),
);