Files
plezy/lib/profiles/profile_avatar.dart
edde746 860ce1e11a feat(profiles): show the first linked connection's user picture
A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.

Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.

The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.

close #1667
2026-08-04 02:22:44 +02:00

97 lines
3.2 KiB
Dart

import 'package:cached_network_image_ce/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../services/image_cache_service.dart';
import '../utils/initials_palette.dart';
import '../widgets/app_icon.dart';
import 'profile.dart';
class ProfileAvatar extends StatelessWidget {
final Profile? profile;
final double size;
final bool showLockBadge;
/// The derived picture for this profile (see [resolveProfileAvatarUrls]).
///
/// Falls back to [Profile.avatarThumbUrl] when null.
final String? avatarUrl;
const ProfileAvatar({super.key, required this.profile, this.size = 40, this.showLockBadge = true, this.avatarUrl});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final p = profile;
final lockBadgeSize = size * 0.34;
final memCacheSize = (size * MediaQuery.devicePixelRatioOf(context)).round();
return SizedBox(
width: size,
height: size,
child: Stack(
clipBehavior: Clip.none,
children: [
ClipOval(
child: SizedBox(width: size, height: size, child: _buildContent(theme, p, memCacheSize)),
),
if (showLockBadge && p != null && p.isPinProtected)
Positioned(
right: -2,
bottom: -2,
child: Container(
width: lockBadgeSize,
height: lockBadgeSize,
alignment: .center,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(color: theme.colorScheme.surface, width: 1),
),
child: AppIcon(
Symbols.lock_rounded,
fill: 1,
size: lockBadgeSize * 0.7,
color: theme.colorScheme.onSurface,
),
),
),
],
),
);
}
Widget _buildContent(ThemeData theme, Profile? p, int memCacheSize) {
if (p == null) {
return Container(color: theme.colorScheme.surfaceContainerHighest);
}
// An empty override means "nothing derived", not "suppress the profile's
// own picture" — same absent-or-blank rule the thumb itself uses below.
final override = avatarUrl;
final thumb = override != null && override.isNotEmpty ? override : p.avatarThumbUrl;
if (thumb != null && thumb.isNotEmpty) {
return CachedNetworkImage(
imageUrl: thumb,
cacheManager: PlexImageCacheManager.instance,
fit: BoxFit.cover,
memCacheWidth: memCacheSize,
memCacheHeight: memCacheSize,
placeholder: (_, _) => _initialFallback(theme, p),
errorBuilder: (_, _, _) => _initialFallback(theme, p),
);
}
return _initialFallback(theme, p);
}
Widget _initialFallback(ThemeData theme, Profile p) {
return Container(
color: colorForName(p.displayName, theme),
alignment: .center,
child: Text(
initialOf(p.displayName),
style: TextStyle(color: Colors.white, fontSize: size * 0.42, fontWeight: .w600, height: 1.0),
),
);
}
}