diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart index e48992980b..3b65e43907 100644 --- a/mobile/lib/domain/models/user.model.dart +++ b/mobile/lib/domain/models/user.model.dart @@ -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 diff --git a/mobile/lib/domain/services/user.service.dart b/mobile/lib/domain/services/user.service.dart index d5c1a6c4f7..3ae6129142 100644 --- a/mobile/lib/domain/services/user.service.dart +++ b/mobile/lib/domain/services/user.service.dart @@ -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 watch(String id) => _userRepository.watch(id); } diff --git a/mobile/lib/infrastructure/repositories/user.repository.dart b/mobile/lib/infrastructure/repositories/user.repository.dart index f8c74222a8..242e0186f1 100644 --- a/mobile/lib/infrastructure/repositories/user.repository.dart +++ b/mobile/lib/infrastructure/repositories/user.repository.dart @@ -14,6 +14,9 @@ class UserRepository extends DatabaseAccessor with $UserRepositoryMixin { Drift get _db => attachedDatabase; Stream> getAll() => _db.select(_db.userEntity).map(mapToUser).watch(); + + Stream watch(String id) => + (_db.select(_db.userEntity)..where((u) => u.id.equals(id))).map(mapToUser).watchSingleOrNull(); } @DriftAccessor() diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart index 4b59e1ae60..e997c8c3fe 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details.widget.dart @@ -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), diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/asset_owner_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/asset_owner_details.widget.dart new file mode 100644 index 0000000000..60b8a8e24a --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/asset_owner_details.widget.dart @@ -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( + 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), + ), + ], + ), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart index 3b0984cfd7..ac45b8ac87 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/date_time_details.widget.dart @@ -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 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), - ), - ), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_details/description.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_details/description.widget.dart new file mode 100644 index 0000000000..d64b02becc --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_details/description.widget.dart @@ -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 createState() => _SheetAssetDescriptionState(); +} + +class _SheetAssetDescriptionState extends ConsumerState { + late TextEditingController _controller; + final _descriptionFocus = FocusNode(); + + @override + void initState() { + super.initState(); + + _controller = TextEditingController(text: widget.exifInfo?.description ?? ''); + } + + Future 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), + ), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/user.provider.dart b/mobile/lib/providers/infrastructure/user.provider.dart index a36082f31b..8e79791dd0 100644 --- a/mobile/lib/providers/infrastructure/user.provider.dart +++ b/mobile/lib/providers/infrastructure/user.provider.dart @@ -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), ), ); diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index 11257da77b..7bbe0b12a1 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -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); diff --git a/mobile/test/domain/services/user_service_test.dart b/mobile/test/domain/services/user_service_test.dart index 7b46a44339..49c2178c40 100644 --- a/mobile/test/domain/services/user_service_test.dart +++ b/mobile/test/domain/services/user_service_test.dart @@ -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); diff --git a/mobile/test/unit/mocks.dart b/mobile/test/unit/mocks.dart index e8ef01d3d2..ca4d5ed29b 100644 --- a/mobile/test/unit/mocks.dart +++ b/mobile/test/unit/mocks.dart @@ -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 Function() get createProfileImage => () => service.createProfileImage(any(), any()); + + Stream Function() get watch => + () => service.watch(any()); } extension type const AssetServiceStub(MockAssetService service) implements Stub { diff --git a/mobile/test/unit/presentation/asset_owner_details_test.dart b/mobile/test/unit/presentation/asset_owner_details_test.dart new file mode 100644 index 0000000000..c1111a66ae --- /dev/null +++ b/mobile/test/unit/presentation/asset_owner_details_test.dart @@ -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); + }); + }); +} diff --git a/mobile/test/unit/presentation/presentation_context.dart b/mobile/test/unit/presentation/presentation_context.dart index ba29cac8a8..8b576f3ac6 100644 --- a/mobile/test/unit/presentation/presentation_context.dart +++ b/mobile/test/unit/presentation/presentation_context.dart @@ -53,6 +53,7 @@ class PresentationContext { List 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),