mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
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:
parent
290861e347
commit
4ff7148f85
13 changed files with 266 additions and 94 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
52
mobile/test/unit/presentation/asset_owner_details_test.dart
Normal file
52
mobile/test/unit/presentation/asset_owner_details_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue