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
133 lines
4.6 KiB
Dart
133 lines
4.6 KiB
Dart
import 'package:cached_network_image_ce/cached_network_image.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:plezy/profiles/profile.dart';
|
|
import 'package:plezy/profiles/profile_avatar.dart';
|
|
import 'package:plezy/utils/initials_palette.dart';
|
|
|
|
void main() {
|
|
Future<void> pumpAvatar(
|
|
WidgetTester tester, {
|
|
required Profile profile,
|
|
String? avatarUrl,
|
|
double size = 40,
|
|
double devicePixelRatio = 1,
|
|
}) {
|
|
return tester.pumpWidget(
|
|
MaterialApp(
|
|
theme: ThemeData(),
|
|
home: MediaQuery(
|
|
data: MediaQueryData(devicePixelRatio: devicePixelRatio),
|
|
child: Center(
|
|
child: ProfileAvatar(profile: profile, avatarUrl: avatarUrl, size: size),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Profile localProfile({String? avatarThumbUrl, String? pinHash}) {
|
|
return Profile.local(
|
|
id: 'local-owner',
|
|
displayName: 'Owner',
|
|
avatarThumbUrl: avatarThumbUrl,
|
|
pinHash: pinHash,
|
|
createdAt: DateTime(2026, 1, 1),
|
|
);
|
|
}
|
|
|
|
testWidgets('avatarUrl renders the derived network image', (tester) async {
|
|
const avatarUrl = 'https://jellyfin.example/Users/user-1/Images/Primary?tag=derived';
|
|
|
|
await pumpAvatar(tester, profile: localProfile(), avatarUrl: avatarUrl);
|
|
|
|
final image = tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage));
|
|
expect(image.imageUrl, avatarUrl);
|
|
});
|
|
|
|
testWidgets('avatarUrl takes precedence over the profile thumbnail', (tester) async {
|
|
const derivedUrl = 'https://jellyfin.example/Users/user-1/Images/Primary?tag=derived';
|
|
const profileThumbUrl = 'https://plex.example/profile-thumb.jpg';
|
|
|
|
await pumpAvatar(
|
|
tester,
|
|
profile: localProfile(avatarThumbUrl: profileThumbUrl),
|
|
avatarUrl: derivedUrl,
|
|
);
|
|
|
|
final image = tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage));
|
|
expect(image.imageUrl, derivedUrl);
|
|
});
|
|
|
|
testWidgets('a null avatarUrl preserves the profile thumbnail fallback', (tester) async {
|
|
const profileThumbUrl = 'https://plex.example/profile-thumb.jpg';
|
|
|
|
await pumpAvatar(tester, profile: localProfile(avatarThumbUrl: profileThumbUrl));
|
|
|
|
final image = tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage));
|
|
expect(image.imageUrl, profileThumbUrl);
|
|
});
|
|
|
|
testWidgets('a profile without a picture renders its display-name initial', (tester) async {
|
|
final profile = localProfile();
|
|
|
|
await pumpAvatar(tester, profile: profile);
|
|
|
|
expect(find.byType(CachedNetworkImage), findsNothing);
|
|
expect(find.text(initialOf(profile.displayName)), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('an empty avatarUrl falls through to the profile picture rather than suppressing it', (tester) async {
|
|
const plexThumb = 'https://plex.tv/users/abc/avatar';
|
|
|
|
// An empty override means "nothing derived". Treating it as a value would
|
|
// blank out a Plex Home profile that owns a perfectly good thumb.
|
|
await pumpAvatar(
|
|
tester,
|
|
profile: localProfile(avatarThumbUrl: plexThumb),
|
|
avatarUrl: '',
|
|
);
|
|
|
|
expect(tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage)).imageUrl, plexThumb);
|
|
});
|
|
|
|
testWidgets('an empty avatarUrl renders initials instead of requesting an empty URL', (tester) async {
|
|
final profile = localProfile();
|
|
|
|
await pumpAvatar(tester, profile: profile, avatarUrl: '');
|
|
|
|
expect(find.byType(CachedNetworkImage), findsNothing);
|
|
expect(find.text(initialOf(profile.displayName)), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('network image decoding is bounded by the physical avatar size', (tester) async {
|
|
const size = 44.0;
|
|
const devicePixelRatio = 2.5;
|
|
|
|
await pumpAvatar(
|
|
tester,
|
|
profile: localProfile(),
|
|
avatarUrl: 'https://jellyfin.example/Users/user-1/Images/Primary?tag=large-original',
|
|
size: size,
|
|
devicePixelRatio: devicePixelRatio,
|
|
);
|
|
|
|
final image = tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage));
|
|
final expectedDecodeSize = (size * devicePixelRatio).round();
|
|
expect(image.memCacheWidth, expectedDecodeSize);
|
|
expect(image.memCacheHeight, expectedDecodeSize);
|
|
});
|
|
|
|
testWidgets('a derived avatar keeps the PIN lock badge visible', (tester) async {
|
|
await pumpAvatar(
|
|
tester,
|
|
profile: localProfile(pinHash: 'stored-pin-hash'),
|
|
avatarUrl: 'https://jellyfin.example/Users/user-1/Images/Primary?tag=derived',
|
|
);
|
|
|
|
expect(find.byType(CachedNetworkImage), findsOneWidget);
|
|
expect(find.byIcon(Symbols.lock_rounded), findsOneWidget);
|
|
});
|
|
}
|