feat(mobile): show asset owner in asset details (#29302)

* feat(mobile): show asset owner in asset details

* refactor(mobile): fetch asset owner from local DB instead of API

* cleanup

* review changes

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Lauritz Tieste 2026-08-25 22:40:05 +02:00 committed by GitHub
parent 290861e347
commit 4ff7148f85
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 266 additions and 94 deletions

View file

@ -118,7 +118,7 @@ class User {
final String email;
final DateTime profileChangedAt;
final bool hasProfileImage;
final AvatarColor? avatarColor;
final AvatarColor avatarColor;
const User({
required this.id,
@ -126,7 +126,7 @@ class User {
required this.email,
required this.profileChangedAt,
required this.hasProfileImage,
this.avatarColor = AvatarColor.primary,
this.avatarColor = .primary,
});
@override

View file

@ -4,15 +4,17 @@ import 'dart:typed_data';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:logging/logging.dart';
class UserService {
final Logger _log = Logger("UserService");
final UserApiRepository _userApiRepository;
final UserRepository _userRepository;
final StoreService _storeService;
UserService({required this._userApiRepository, required this._storeService});
UserService({required this._userApiRepository, required this._userRepository, required this._storeService});
UserDto getMyUser() {
return _storeService.get(StoreKey.currentUser);
@ -46,4 +48,6 @@ class UserService {
return null;
}
}
Stream<User?> watch(String id) => _userRepository.watch(id);
}

View file

@ -14,6 +14,9 @@ class UserRepository extends DatabaseAccessor<Drift> with $UserRepositoryMixin {
Drift get _db => attachedDatabase;
Stream<Iterable<User>> getAll() => _db.select(_db.userEntity).map(mapToUser).watch();
Stream<User?> watch(String id) =>
(_db.select(_db.userEntity)..where((u) => u.id.equals(id))).map(mapToUser).watchSingleOrNull();
}
@DriftAccessor()

View file

@ -3,7 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/appears_in_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/asset_owner_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/description.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/drag_handle.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/people_details.widget.dart';
@ -42,6 +44,8 @@ class AssetDetails extends ConsumerWidget {
children: [
const DragHandle(),
DateTimeDetails(asset: asset, exifInfo: exifInfo),
AssetOwnerDetails(asset: asset),
SheetAssetDescription(asset: asset, exifInfo: exifInfo),
PeopleDetails(asset: asset),
LocationDetails(asset: asset, exifInfo: exifInfo),
TechnicalDetails(asset: asset, exifInfo: exifInfo),

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
import 'package:immich_ui/immich_ui.dart';
class AssetOwnerDetails extends ConsumerWidget {
final BaseAsset asset;
const AssetOwnerDetails({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (asset is! RemoteAsset) {
return const SizedBox.shrink();
}
final remote = asset as RemoteAsset;
// Hide if asset belongs to the current user
final currentUserId = ref.watch(currentUserProvider)?.id;
if (remote.ownerId == currentUserId) {
return const SizedBox.shrink();
}
return _OwnerDetailsTile(ownerId: remote.ownerId);
}
}
class _OwnerDetailsTile extends ConsumerWidget {
final String ownerId;
const _OwnerDetailsTile({required this.ownerId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userServiceProvider).watch(ownerId);
return StreamBuilder<User?>(
stream: user,
builder: (context, snapshot) {
final ownerDto = snapshot.data;
if (ownerDto == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const .only(
left: ImmichSpacing.lg,
right: ImmichSpacing.lg,
top: ImmichSpacing.md,
bottom: ImmichSpacing.sm,
),
child: Row(
spacing: ImmichSpacing.md,
children: [
UserCircleAvatar.fromUser(user: ownerDto, size: 24, hasBorder: false, opacity: 0.8),
Text(
context.t.shared_by_user(user: ownerDto.name),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
),
);
},
);
}
}

View file

@ -1,21 +1,14 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/edit_datetime.action.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = '';
@ -29,7 +22,6 @@ class DateTimeDetails extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final asset = this.asset;
final exifInfo = this.exifInfo;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
final editDateTime = const EditDateTimeAction(source: .viewer).create(context, ref);
return Column(
@ -40,7 +32,6 @@ class DateTimeDetails extends ConsumerWidget {
trailing: editDateTime == null ? null : const Icon(Icons.edit, size: 18),
onTap: editDateTime?.onAction,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo, isEditable: isOwner),
],
);
}
@ -56,84 +47,3 @@ class DateTimeDetails extends ConsumerWidget {
return '$date$_kSeparator$time $timezone';
}
}
class _SheetAssetDescription extends ConsumerStatefulWidget {
final ExifInfo exif;
final bool isEditable;
const _SheetAssetDescription({required this.exif, this.isEditable = true});
@override
ConsumerState<_SheetAssetDescription> createState() => _SheetAssetDescriptionState();
}
class _SheetAssetDescriptionState extends ConsumerState<_SheetAssetDescription> {
late TextEditingController _controller;
final _descriptionFocus = FocusNode();
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.exif.description ?? '');
}
Future<void> saveDescription(String? previousDescription) async {
final newDescription = _controller.text.trim();
if (newDescription == previousDescription) {
_descriptionFocus.unfocus();
return;
}
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
if (!editAction.success) {
_controller.text = previousDescription ?? '';
if (!mounted) {
return;
}
ImmichToast.show(
context: context,
msg: context.t.exif_bottom_sheet_description_error,
toastType: ToastType.error,
);
}
_descriptionFocus.unfocus();
}
@override
Widget build(BuildContext context) {
final currentDescription = widget.exif.description ?? '';
final hintText = widget.isEditable
? context.t.exif_bottom_sheet_description
: context.t.exif_bottom_sheet_no_description;
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
_controller.text = currentDescription;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: IgnorePointer(
ignoring: !widget.isEditable,
child: TextField(
controller: _controller,
keyboardType: TextInputType.multiline,
maxLines: null,
focusNode: _descriptionFocus,
decoration: InputDecoration(
hintText: hintText,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
onTapOutside: (_) => saveDescription(widget.exif.description),
),
),
);
}
}

View file

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class SheetAssetDescription extends ConsumerStatefulWidget {
final BaseAsset asset;
final ExifInfo? exifInfo;
const SheetAssetDescription({super.key, required this.asset, this.exifInfo});
@override
ConsumerState<SheetAssetDescription> createState() => _SheetAssetDescriptionState();
}
class _SheetAssetDescriptionState extends ConsumerState<SheetAssetDescription> {
late TextEditingController _controller;
final _descriptionFocus = FocusNode();
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.exifInfo?.description ?? '');
}
Future<void> saveDescription(String? previousDescription) async {
final newDescription = _controller.text.trim();
if (newDescription == previousDescription) {
_descriptionFocus.unfocus();
return;
}
final editAction = await ref.read(actionProvider.notifier).updateDescription(ActionSource.viewer, newDescription);
if (!editAction.success) {
_controller.text = previousDescription ?? '';
if (!mounted) {
return;
}
ImmichToast.show(
context: context,
msg: context.t.exif_bottom_sheet_description_error,
toastType: ToastType.error,
);
}
_descriptionFocus.unfocus();
}
@override
Widget build(BuildContext context) {
final asset = widget.asset;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
final currentDescription = widget.exifInfo?.description ?? '';
final hintText = isOwner ? context.t.exif_bottom_sheet_description : context.t.exif_bottom_sheet_no_description;
if (_controller.text != currentDescription && !_descriptionFocus.hasFocus) {
_controller.text = currentDescription;
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: IgnorePointer(
ignoring: !isOwner,
child: TextField(
controller: _controller,
keyboardType: TextInputType.multiline,
maxLines: null,
focusNode: _descriptionFocus,
decoration: InputDecoration(
hintText: hintText,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
onTapOutside: (_) => saveDescription(widget.exifInfo?.description),
),
),
);
}
}

View file

@ -12,6 +12,7 @@ final userApiRepositoryProvider = Provider((ref) => UserApiRepository(ref.watch(
final userServiceProvider = Provider(
(ref) => UserService(
userApiRepository: ref.watch(userApiRepositoryProvider),
userRepository: ref.watch(driftProvider).userRepository,
storeService: ref.watch(storeServiceProvider),
),
);

View file

@ -12,6 +12,26 @@ class UserCircleAvatar extends StatelessWidget {
const UserCircleAvatar({super.key, this.size = 44, this.hasBorder = false, this.opacity = 1, required this.user});
// TODO(shenlong): Remove this factory when the UserDto is removed from the domain layer
factory UserCircleAvatar.fromUser({
required User user,
double size = 44,
bool hasBorder = false,
double opacity = 1,
}) => .new(
user: .new(
id: user.id,
email: user.email,
name: user.name,
profileChangedAt: user.profileChangedAt,
hasProfileImage: user.hasProfileImage,
avatarColor: user.avatarColor,
),
size: size,
hasBorder: hasBorder,
opacity: opacity,
);
@override
Widget build(BuildContext context) {
final userAvatarColor = user.avatarColor.toColor().withValues(alpha: opacity);

View file

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:mocktail/mocktail.dart';
@ -15,11 +16,17 @@ void main() {
late UserService sut;
late UserApiRepository mockUserApiRepo;
late StoreService mockStoreService;
late UserRepository mockUserRepository;
setUp(() {
mockUserApiRepo = MockUserApiRepository();
mockStoreService = MockStoreService();
sut = UserService(userApiRepository: mockUserApiRepo, storeService: mockStoreService);
mockUserRepository = MockUserRepository();
sut = UserService(
userApiRepository: mockUserApiRepo,
userRepository: mockUserRepository,
storeService: mockStoreService,
);
registerFallbackValue(UserStub.admin);
when(() => mockStoreService.get(StoreKey.currentUser)).thenReturn(UserStub.admin);

View file

@ -165,6 +165,7 @@ class ServiceMocks {
when(user.watchMyUser).thenAnswer((_) => const Stream.empty());
when(user.refreshMyUser).thenAnswer((_) async => null);
when(user.createProfileImage).thenAnswer((_) async => null);
when(user.watch).thenAnswer((_) => const Stream.empty());
}
void _stubPartnerService() {
@ -336,6 +337,9 @@ extension type const UserServiceStub(MockUserService service) implements Stub<Mo
Future<String?> Function() get createProfileImage =>
() => service.createProfileImage(any(), any());
Stream<User?> Function() get watch =>
() => service.watch(any());
}
extension type const AssetServiceStub(MockAssetService service) implements Stub<MockAssetService> {

View file

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_details/asset_owner_details.widget.dart';
import 'package:mocktail/mocktail.dart';
import '../factories/local_asset_factory.dart';
import '../factories/remote_asset_factory.dart';
import '../factories/user_factory.dart';
import 'presentation_context.dart';
void main() {
late PresentationContext context;
setUp(() async => context = await PresentationContext.create());
tearDown(() => context.dispose());
group('AssetOwnerDetails', () {
testWidgets('renders nothing for a local asset', (tester) async {
await tester.pumpTestWidget(context, AssetOwnerDetails(asset: LocalAssetFactory.create()));
expect(find.byType(Text), findsNothing);
verifyNever(context.service.user.watch);
});
testWidgets('renders nothing when the asset is owned by the current user', (tester) async {
final asset = RemoteAssetFactory.create(ownerId: context.currentUser.id);
await tester.pumpTestWidget(context, AssetOwnerDetails(asset: asset));
expect(find.byType(Text), findsNothing);
verifyNever(context.service.user.watch);
});
testWidgets('renders shared by for an asset owned by someone else', (tester) async {
final owner = UserFactory.create();
when(context.service.user.watch).thenAnswer((_) => Stream.value(owner));
await tester.pumpTestWidget(context, AssetOwnerDetails(asset: RemoteAssetFactory.create(ownerId: owner.id)));
expect(find.text('Shared by ${owner.name}'), findsOneWidget);
verify(() => context.service.user.service.watch(owner.id)).called(1);
});
testWidgets('renders nothing while the owner is unknown', (tester) async {
when(context.service.user.watch).thenAnswer((_) => Stream.value(null));
await tester.pumpTestWidget(context, AssetOwnerDetails(asset: RemoteAssetFactory.create()));
expect(find.byType(Text), findsNothing);
});
});
}

View file

@ -53,6 +53,7 @@ class PresentationContext {
List<Override> get overrides => [
driftProvider.overrideWithValue(_mockDrift()),
currentUserProvider.overrideWith((ref) => CurrentUserProvider(service.user.service)),
userServiceProvider.overrideWithValue(service.user.service),
assetServiceProvider.overrideWithValue(service.asset.service),
cleanupServiceProvider.overrideWithValue(service.cleanup.service),
remoteAlbumServiceProvider.overrideWithValue(service.album.service),