mirror of
https://github.com/immich-app/immich.git
synced 2026-02-28 01:29:04 +03:00
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:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,7 +48,9 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
|
||||
|
||||
return SliverAnimatedOpacity(
|
||||
return SliverIgnorePointer(
|
||||
ignoring: isMultiSelectEnabled,
|
||||
sliver: SliverAnimatedOpacity(
|
||||
duration: Durations.medium1,
|
||||
opacity: isMultiSelectEnabled ? 0 : 1,
|
||||
sliver: SliverAppBar(
|
||||
@@ -63,29 +67,24 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||
centerTitle: false,
|
||||
title: title ?? const _ImmichLogoWithText(),
|
||||
actions: [
|
||||
const _SyncStatusIndicator(),
|
||||
if (isCasting && !isReadonlyModeEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: IconButton(
|
||||
onPressed: () {
|
||||
showDialog(context: context, builder: (context) => const CastDialog());
|
||||
},
|
||||
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 (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(),
|
||||
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),
|
||||
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 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) {
|
||||
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),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: UnconstrainedBox(
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: userAvatarColor,
|
||||
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
|
||||
? 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user