From b59abdff3d2ec83cd28a903ec855dff7ae91f701 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Thu, 10 Oct 2024 10:42:59 +0200 Subject: [PATCH 01/13] chore(e2e): dont check for immich folder (#13298) chore: dont check immich folder --- e2e/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 298ff16c7671e..40e800f054b98 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -22,6 +22,7 @@ services: - IMMICH_METRICS=true - IMMICH_ENV=testing - IMMICH_PORT=2285 + - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true volumes: - ./test-assets:/test-assets extra_hosts: From e9813315e7224d2f21f32785d785039cbc50e61c Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 10 Oct 2024 15:44:14 +0700 Subject: [PATCH 02/13] feat(mobile): new mobile UI (#12582) --- mobile/assets/i18n/en-US.json | 22 +- mobile/ios/Podfile.lock | 13 +- mobile/lib/constants/immich_colors.dart | 5 +- mobile/lib/entities/asset.entity.g.dart | 141 ++-- mobile/lib/interfaces/album.interface.dart | 3 + .../lib/models/albums/album_search.model.dart | 5 + mobile/lib/pages/albums/albums.page.dart | 469 +++++++++++++ .../backup/backup_album_selection.page.dart | 2 +- .../pages/backup/backup_controller.page.dart | 2 +- .../lib/pages/common/album_options.page.dart | 14 +- .../album_shared_user_selection.page.dart | 17 +- .../lib/pages/common/album_viewer.page.dart | 66 +- .../lib/pages/common/create_album.page.dart | 59 +- .../lib/pages/common/large_leading_tile.dart | 50 ++ .../lib/pages/common/tab_controller.page.dart | 55 +- mobile/lib/pages/editing/edit.page.dart | 2 +- mobile/lib/pages/library/library.page.dart | 615 ++++++++++-------- .../lib/pages/library/local_albums.page.dart | 55 ++ .../partner/partner.page.dart | 15 +- .../partner/partner_detail.page.dart | 55 +- .../people/people_collection.page.dart | 104 +++ .../places/places_collection.part.dart | 125 ++++ .../shared_link/shared_link.page.dart | 0 .../shared_link/shared_link_edit.page.dart | 0 mobile/lib/pages/photos/photos.page.dart | 4 +- .../lib/pages/search/person_result.page.dart | 9 +- mobile/lib/pages/search/search.page.dart | 121 +--- .../lib/pages/search/search_input.page.dart | 101 ++- mobile/lib/pages/sharing/sharing.page.dart | 283 -------- .../lib/providers/album/album.provider.dart | 103 ++- .../album/album_viewer.provider.dart | 2 - .../album/shared_album.provider.dart | 90 --- .../providers/app_life_cycle.provider.dart | 8 +- .../providers/authentication.provider.dart | 2 - .../backup_verification.provider.g.dart | 2 +- mobile/lib/providers/tab.provider.dart | 7 +- mobile/lib/repositories/album.repository.dart | 31 + .../repositories/partner_api.repository.dart | 2 +- mobile/lib/routing/router.dart | 69 +- mobile/lib/routing/router.gr.dart | 116 +++- .../lib/routing/tab_navigation_observer.dart | 19 - mobile/lib/services/album.service.dart | 95 ++- mobile/lib/services/entity.service.dart | 1 + mobile/lib/services/sync.service.dart | 14 +- mobile/lib/utils/immich_app_theme.dart | 5 +- .../album/add_to_album_bottom_sheet.dart | 14 +- .../widgets/album/album_thumbnail_card.dart | 30 +- .../widgets/album/album_viewer_appbar.dart | 12 +- .../asset_grid/control_bottom_app_bar.dart | 4 +- .../widgets/asset_grid/multiselect_grid.dart | 13 +- .../asset_viewer/bottom_gallery_bar.dart | 6 +- mobile/lib/widgets/common/immich_app_bar.dart | 23 +- .../lib/widgets/forms/login/login_form.dart | 2 +- mobile/lib/widgets/partner/partner_list.dart | 48 -- .../widgets/search/search_map_thumbnail.dart | 1 + mobile/test/services/album.service_test.dart | 49 +- 56 files changed, 1933 insertions(+), 1247 deletions(-) create mode 100644 mobile/lib/models/albums/album_search.model.dart create mode 100644 mobile/lib/pages/albums/albums.page.dart create mode 100644 mobile/lib/pages/common/large_leading_tile.dart create mode 100644 mobile/lib/pages/library/local_albums.page.dart rename mobile/lib/pages/{sharing => library}/partner/partner.page.dart (93%) rename mobile/lib/pages/{sharing => library}/partner/partner_detail.page.dart (59%) create mode 100644 mobile/lib/pages/library/people/people_collection.page.dart create mode 100644 mobile/lib/pages/library/places/places_collection.part.dart rename mobile/lib/pages/{sharing => library}/shared_link/shared_link.page.dart (100%) rename mobile/lib/pages/{sharing => library}/shared_link/shared_link_edit.page.dart (100%) delete mode 100644 mobile/lib/pages/sharing/sharing.page.dart delete mode 100644 mobile/lib/providers/album/shared_album.provider.dart delete mode 100644 mobile/lib/widgets/partner/partner_list.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 477dfbd39ff13..5938bc6ff17d1 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,4 +1,24 @@ { + "all": "All", + "shared_with_me": "Shared with me", + "my_albums": "My albums", + "create_new": "CREATE NEW", + "create_album": "Create album", + "videos": "Videos", + "recently_added": "Recently added", + "partners": "Partners", + "partner_page_title": "Partners", + "library": "Library", + "on_this_device": "On this device", + "add_a_name": "Add a name", + "places": "Places", + "albums": "Albums", + "people": "People", + "shared_links": "Shared links", + "trash": "Trash", + "archived": "Archived", + "favorites": "Favorites", + "search_albums": "Search albums", "action_common_back": "Back", "action_common_cancel": "Cancel", "action_common_clear": "Clear", @@ -353,7 +373,6 @@ "notification_permission_list_tile_enable_button": "Enable Notifications", "notification_permission_list_tile_title": "Notification Permission", "partner_list_user_photos": "{user}'s photos", - "partner_list_view_all": "View all", "partner_page_add_partner": "Add partner", "partner_page_empty_message": "Your photos are not yet shared with any partner.", "partner_page_no_more_users": "No more users to add", @@ -362,7 +381,6 @@ "partner_page_shared_to_title": "Shared to", "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_title": "Stop sharing your photos?", - "partner_page_title": "Partner", "permission_onboarding_back": "Back", "permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_get_started": "Get started", diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 6a9d34ab83bfe..567406aef0df2 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -3,7 +3,7 @@ PODS: - Flutter - connectivity_plus (0.0.1): - Flutter - - ReachabilitySwift + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.9): @@ -77,7 +77,6 @@ PODS: - photo_manager (2.0.0): - Flutter - FlutterMacOS - - ReachabilitySwift (5.0.0) - SAMKeychain (1.5.3) - SDWebImage (5.19.4): - SDWebImage/Core (= 5.19.4) @@ -102,7 +101,7 @@ PODS: DEPENDENCIES: - background_downloader (from `.symlinks/plugins/background_downloader/ios`) - - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) @@ -133,7 +132,6 @@ SPEC REPOS: - DKImagePickerController - DKPhotoGallery - MapLibre - - ReachabilitySwift - SAMKeychain - SDWebImage - SwiftyGif @@ -143,7 +141,7 @@ EXTERNAL SOURCES: background_downloader: :path: ".symlinks/plugins/background_downloader/ios" connectivity_plus: - :path: ".symlinks/plugins/connectivity_plus/ios" + :path: ".symlinks/plugins/connectivity_plus/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: @@ -195,8 +193,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf - connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d - device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 + connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 @@ -217,7 +215,6 @@ SPEC CHECKSUMS: path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a - ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad diff --git a/mobile/lib/constants/immich_colors.dart b/mobile/lib/constants/immich_colors.dart index 38deac3f0ec61..6f6d1a6a31e88 100644 --- a/mobile/lib/constants/immich_colors.dart +++ b/mobile/lib/constants/immich_colors.dart @@ -24,7 +24,10 @@ final Map _themePresetsMap = { ImmichColorPreset.indigo: ImmichTheme( light: ColorScheme.fromSeed( seedColor: immichBrandColorLight, - ).copyWith(primary: immichBrandColorLight), + ).copyWith( + primary: immichBrandColorLight, + onSurface: const Color.fromARGB(255, 34, 31, 32), + ), dark: ColorScheme.fromSeed( seedColor: immichBrandColorDark, brightness: Brightness.dark, diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 8be636efb659b..23bf23604635d 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -57,64 +57,69 @@ const AssetSchema = CollectionSchema( name: r'isFavorite', type: IsarType.bool, ), - r'isTrashed': PropertySchema( + r'isOffline': PropertySchema( id: 8, + name: r'isOffline', + type: IsarType.bool, + ), + r'isTrashed': PropertySchema( + id: 9, name: r'isTrashed', type: IsarType.bool, ), r'livePhotoVideoId': PropertySchema( - id: 9, + id: 10, name: r'livePhotoVideoId', type: IsarType.string, ), r'localId': PropertySchema( - id: 10, + id: 11, name: r'localId', type: IsarType.string, ), r'ownerId': PropertySchema( - id: 11, + id: 12, name: r'ownerId', type: IsarType.long, ), r'remoteId': PropertySchema( - id: 12, + id: 13, name: r'remoteId', type: IsarType.string, ), r'stackCount': PropertySchema( - id: 13, + id: 14, name: r'stackCount', type: IsarType.long, ), r'stackId': PropertySchema( - id: 14, + id: 15, name: r'stackId', type: IsarType.string, ), r'stackPrimaryAssetId': PropertySchema( - id: 15, + id: 16, name: r'stackPrimaryAssetId', type: IsarType.string, ), r'thumbhash': PropertySchema( - id: 16, + id: 17, name: r'thumbhash', type: IsarType.string, ), r'type': PropertySchema( - id: 17, + id: 18, name: r'type', type: IsarType.byte, enumMap: _AssettypeEnumValueMap, ), r'updatedAt': PropertySchema( - id: 18, + id: 19, name: r'updatedAt', type: IsarType.dateTime, ), r'width': PropertySchema( - id: 19, + id: 20, name: r'width', type: IsarType.int, ) @@ -239,18 +244,19 @@ void _assetSerialize( writer.writeInt(offsets[5], object.height); writer.writeBool(offsets[6], object.isArchived); writer.writeBool(offsets[7], object.isFavorite); - writer.writeBool(offsets[8], object.isTrashed); - writer.writeString(offsets[9], object.livePhotoVideoId); - writer.writeString(offsets[10], object.localId); - writer.writeLong(offsets[11], object.ownerId); - writer.writeString(offsets[12], object.remoteId); - writer.writeLong(offsets[13], object.stackCount); - writer.writeString(offsets[14], object.stackId); - writer.writeString(offsets[15], object.stackPrimaryAssetId); - writer.writeString(offsets[16], object.thumbhash); - writer.writeByte(offsets[17], object.type.index); - writer.writeDateTime(offsets[18], object.updatedAt); - writer.writeInt(offsets[19], object.width); + writer.writeBool(offsets[8], object.isOffline); + writer.writeBool(offsets[9], object.isTrashed); + writer.writeString(offsets[10], object.livePhotoVideoId); + writer.writeString(offsets[11], object.localId); + writer.writeLong(offsets[12], object.ownerId); + writer.writeString(offsets[13], object.remoteId); + writer.writeLong(offsets[14], object.stackCount); + writer.writeString(offsets[15], object.stackId); + writer.writeString(offsets[16], object.stackPrimaryAssetId); + writer.writeString(offsets[17], object.thumbhash); + writer.writeByte(offsets[18], object.type.index); + writer.writeDateTime(offsets[19], object.updatedAt); + writer.writeInt(offsets[20], object.width); } Asset _assetDeserialize( @@ -269,19 +275,20 @@ Asset _assetDeserialize( id: id, isArchived: reader.readBoolOrNull(offsets[6]) ?? false, isFavorite: reader.readBoolOrNull(offsets[7]) ?? false, - isTrashed: reader.readBoolOrNull(offsets[8]) ?? false, - livePhotoVideoId: reader.readStringOrNull(offsets[9]), - localId: reader.readStringOrNull(offsets[10]), - ownerId: reader.readLong(offsets[11]), - remoteId: reader.readStringOrNull(offsets[12]), - stackCount: reader.readLongOrNull(offsets[13]) ?? 0, - stackId: reader.readStringOrNull(offsets[14]), - stackPrimaryAssetId: reader.readStringOrNull(offsets[15]), - thumbhash: reader.readStringOrNull(offsets[16]), - type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ?? + isOffline: reader.readBoolOrNull(offsets[8]) ?? false, + isTrashed: reader.readBoolOrNull(offsets[9]) ?? false, + livePhotoVideoId: reader.readStringOrNull(offsets[10]), + localId: reader.readStringOrNull(offsets[11]), + ownerId: reader.readLong(offsets[12]), + remoteId: reader.readStringOrNull(offsets[13]), + stackCount: reader.readLongOrNull(offsets[14]) ?? 0, + stackId: reader.readStringOrNull(offsets[15]), + stackPrimaryAssetId: reader.readStringOrNull(offsets[16]), + thumbhash: reader.readStringOrNull(offsets[17]), + type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? AssetType.other, - updatedAt: reader.readDateTime(offsets[18]), - width: reader.readIntOrNull(offsets[19]), + updatedAt: reader.readDateTime(offsets[19]), + width: reader.readIntOrNull(offsets[20]), ); return object; } @@ -312,27 +319,29 @@ P _assetDeserializeProp

( case 8: return (reader.readBoolOrNull(offset) ?? false) as P; case 9: - return (reader.readStringOrNull(offset)) as P; + return (reader.readBoolOrNull(offset) ?? false) as P; case 10: return (reader.readStringOrNull(offset)) as P; case 11: - return (reader.readLong(offset)) as P; - case 12: return (reader.readStringOrNull(offset)) as P; + case 12: + return (reader.readLong(offset)) as P; case 13: - return (reader.readLongOrNull(offset) ?? 0) as P; - case 14: return (reader.readStringOrNull(offset)) as P; + case 14: + return (reader.readLongOrNull(offset) ?? 0) as P; case 15: return (reader.readStringOrNull(offset)) as P; case 16: return (reader.readStringOrNull(offset)) as P; case 17: + return (reader.readStringOrNull(offset)) as P; + case 18: return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? AssetType.other) as P; - case 18: - return (reader.readDateTime(offset)) as P; case 19: + return (reader.readDateTime(offset)) as P; + case 20: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -1353,6 +1362,16 @@ extension AssetQueryFilter on QueryBuilder { }); } + QueryBuilder isOfflineEqualTo( + bool value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'isOffline', + value: value, + )); + }); + } + QueryBuilder isTrashedEqualTo( bool value) { return QueryBuilder.apply(this, (query) { @@ -2628,6 +2647,18 @@ extension AssetQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByIsOffline() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isOffline', Sort.asc); + }); + } + + QueryBuilder sortByIsOfflineDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isOffline', Sort.desc); + }); + } + QueryBuilder sortByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -2882,6 +2913,18 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } + QueryBuilder thenByIsOffline() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isOffline', Sort.asc); + }); + } + + QueryBuilder thenByIsOfflineDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'isOffline', Sort.desc); + }); + } + QueryBuilder thenByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'isTrashed', Sort.asc); @@ -3078,6 +3121,12 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } + QueryBuilder distinctByIsOffline() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'isOffline'); + }); + } + QueryBuilder distinctByIsTrashed() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'isTrashed'); @@ -3214,6 +3263,12 @@ extension AssetQueryProperty on QueryBuilder { }); } + QueryBuilder isOfflineProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'isOffline'); + }); + } + QueryBuilder isTrashedProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'isTrashed'); diff --git a/mobile/lib/interfaces/album.interface.dart b/mobile/lib/interfaces/album.interface.dart index ba188f127009a..bdf11f18de8ac 100644 --- a/mobile/lib/interfaces/album.interface.dart +++ b/mobile/lib/interfaces/album.interface.dart @@ -2,6 +2,7 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/database.interface.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; abstract interface class IAlbumRepository implements IDatabaseRepository { Future create(Album album); @@ -38,6 +39,8 @@ abstract interface class IAlbumRepository implements IDatabaseRepository { Future removeAssets(Album album, List assets); Future recalculateMetadata(Album album); + + Future> search(String searchTerm, QuickFilterMode filterMode); } enum AlbumSort { remoteId, localId } diff --git a/mobile/lib/models/albums/album_search.model.dart b/mobile/lib/models/albums/album_search.model.dart new file mode 100644 index 0000000000000..ac4eedbff1bd8 --- /dev/null +++ b/mobile/lib/models/albums/album_search.model.dart @@ -0,0 +1,5 @@ +enum QuickFilterMode { + all, + sharedWithMe, + myAlbums, +} diff --git a/mobile/lib/pages/albums/albums.page.dart b/mobile/lib/pages/albums/albums.page.dart new file mode 100644 index 0000000000000..e466149ac3ee8 --- /dev/null +++ b/mobile/lib/pages/albums/albums.page.dart @@ -0,0 +1,469 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; +import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; + +@RoutePage() +class AlbumsPage extends HookConsumerWidget { + const AlbumsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = + ref.watch(albumProvider).where((album) => album.isRemote).toList(); + final albumSortOption = ref.watch(albumSortByOptionsProvider); + final albumSortIsReverse = ref.watch(albumSortOrderProvider); + final sorted = albumSortOption.sortFn(albums, albumSortIsReverse); + final isGrid = useState(false); + final searchController = useTextEditingController(); + final debounceTimer = useRef(null); + final filterMode = useState(QuickFilterMode.all); + final userId = ref.watch(currentUserProvider)?.id; + final searchFocusNode = useFocusNode(); + + toggleViewMode() { + isGrid.value = !isGrid.value; + } + + onSearch(String searchTerm, QuickFilterMode mode) { + debounceTimer.value?.cancel(); + debounceTimer.value = Timer(const Duration(milliseconds: 300), () { + ref.read(albumProvider.notifier).searchAlbums(searchTerm, mode); + }); + } + + changeFilter(QuickFilterMode mode) { + filterMode.value = mode; + } + + useEffect( + () { + searchController.addListener(() { + onSearch(searchController.text, filterMode.value); + }); + + return () { + searchController.removeListener(() { + onSearch(searchController.text, filterMode.value); + }); + debounceTimer.value?.cancel(); + }; + }, + [], + ); + + clearSearch() { + filterMode.value = QuickFilterMode.all; + searchController.clear(); + onSearch('', QuickFilterMode.all); + } + + return Scaffold( + appBar: ImmichAppBar( + showUploadButton: false, + actions: [ + IconButton( + icon: Icon( + Icons.add_rounded, + size: 28, + ), + onPressed: () => context.pushRoute( + CreateAlbumRoute(), + ), + ), + ], + ), + body: RefreshIndicator( + displacement: 70, + onRefresh: () async { + await ref.read(albumProvider.notifier).refreshRemoteAlbums(); + }, + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12), + children: [ + Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(0), + width: 0, + ), + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withOpacity(0.075), + context.colorScheme.primary.withOpacity(0.09), + context.colorScheme.primary.withOpacity(0.075), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + transform: GradientRotation(0.5 * pi), + ), + ), + child: TextField( + autofocus: false, + decoration: InputDecoration( + contentPadding: EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainer, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.primary.withAlpha(100), + ), + ), + hintText: 'search_albums'.tr(), + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear_rounded), + onPressed: clearSearch, + ) + : const SizedBox.shrink(), + ), + controller: searchController, + onChanged: (_) => + onSearch(searchController.text, filterMode.value), + focusNode: searchFocusNode, + onTapOutside: (_) => searchFocusNode.unfocus(), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + QuickFilterButton( + label: 'all'.tr(), + isSelected: filterMode.value == QuickFilterMode.all, + onTap: () { + changeFilter(QuickFilterMode.all); + onSearch(searchController.text, QuickFilterMode.all); + }, + ), + QuickFilterButton( + label: 'shared_with_me'.tr(), + isSelected: filterMode.value == QuickFilterMode.sharedWithMe, + onTap: () { + changeFilter(QuickFilterMode.sharedWithMe); + onSearch( + searchController.text, + QuickFilterMode.sharedWithMe, + ); + }, + ), + QuickFilterButton( + label: 'my_albums'.tr(), + isSelected: filterMode.value == QuickFilterMode.myAlbums, + onTap: () { + changeFilter(QuickFilterMode.myAlbums); + onSearch( + searchController.text, + QuickFilterMode.myAlbums, + ); + }, + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SortButton(), + IconButton( + icon: Icon( + isGrid.value + ? Icons.view_list_outlined + : Icons.grid_view_outlined, + size: 24, + ), + onPressed: toggleViewMode, + ), + ], + ), + const SizedBox(height: 5), + AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: isGrid.value + ? GridView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 250, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: .7, + ), + itemBuilder: (context, index) { + return AlbumThumbnailCard( + album: sorted[index], + onTap: () => context.pushRoute( + AlbumViewerRoute(albumId: sorted[index].id), + ), + showOwner: true, + ); + }, + itemCount: sorted.length, + ) + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sorted.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: LargeLeadingTile( + title: Text( + sorted[index].name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: sorted[index].ownerId == userId + ? Text( + '${sorted[index].assetCount} items', + overflow: TextOverflow.ellipsis, + style: + context.textTheme.bodyMedium?.copyWith( + color: context + .colorScheme.onSurfaceSecondary, + ), + ) + : sorted[index].ownerName != null + ? Text( + '${sorted[index].assetCount} items • ${'album_thumbnail_shared_by'.tr( + args: [ + sorted[index].ownerName!, + ], + )}', + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium + ?.copyWith( + color: context + .colorScheme.onSurfaceSecondary, + ), + ) + : null, + onTap: () => context.pushRoute( + AlbumViewerRoute(albumId: sorted[index].id), + ), + leadingPadding: const EdgeInsets.only( + right: 16, + ), + leading: ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(15), + ), + child: ImmichThumbnail( + asset: sorted[index].thumbnail.value, + width: 80, + height: 80, + ), + ), + // minVerticalPadding: 1, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class QuickFilterButton extends StatelessWidget { + const QuickFilterButton({ + super.key, + required this.isSelected, + required this.onTap, + required this.label, + }); + + final bool isSelected; + final VoidCallback onTap; + final String label; + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onTap, + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all( + isSelected ? context.colorScheme.primary : Colors.transparent, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(25), + width: 1, + ), + ), + ), + ), + child: Text( + label, + style: TextStyle( + color: isSelected + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + fontSize: 14, + ), + ), + ); + } +} + +class SortButton extends ConsumerWidget { + const SortButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albumSortOption = ref.watch(albumSortByOptionsProvider); + final albumSortIsReverse = ref.watch(albumSortOrderProvider); + + return MenuAnchor( + style: MenuStyle( + elevation: WidgetStatePropertyAll(1), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + padding: WidgetStatePropertyAll( + EdgeInsets.all(4), + ), + ), + consumeOutsideTap: true, + menuChildren: AlbumSortMode.values + .map( + (mode) => MenuItemButton( + leadingIcon: albumSortOption == mode + ? albumSortIsReverse + ? Icon( + Icons.keyboard_arrow_down, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : Icon( + Icons.keyboard_arrow_up_rounded, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface, + ) + : const Icon(Icons.abc, color: Colors.transparent), + onPressed: () { + final selected = albumSortOption == mode; + // Switch direction + if (selected) { + ref + .read(albumSortOrderProvider.notifier) + .changeSortDirection(!albumSortIsReverse); + } else { + ref + .read(albumSortByOptionsProvider.notifier) + .changeSortMode(mode); + } + }, + style: ButtonStyle( + padding: WidgetStateProperty.all( + const EdgeInsets.fromLTRB(16, 16, 32, 16), + ), + backgroundColor: WidgetStateProperty.all( + albumSortOption == mode + ? context.colorScheme.primary + : Colors.transparent, + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + ), + ), + child: Text( + mode.label.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + color: albumSortOption == mode + ? context.colorScheme.onPrimary + : context.colorScheme.onSurface.withAlpha(185), + ), + ), + ), + ) + .toList(), + builder: (context, controller, child) { + return GestureDetector( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 5), + child: Transform.rotate( + angle: 90 * pi / 180, + child: Icon( + Icons.compare_arrows_rounded, + size: 18, + color: context.colorScheme.onSurface.withAlpha(225), + ), + ), + ), + Text( + albumSortOption.label.tr(), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSurface.withAlpha(225), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/mobile/lib/pages/backup/backup_album_selection.page.dart b/mobile/lib/pages/backup/backup_album_selection.page.dart index 8dccece325d8f..0869e75e9fc14 100644 --- a/mobile/lib/pages/backup/backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/backup_album_selection.page.dart @@ -151,7 +151,7 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { handleSyncAlbumToggle(bool isEnable) async { if (isEnable) { - await ref.read(albumProvider.notifier).getAllAlbums(); + await ref.read(albumProvider.notifier).refreshRemoteAlbums(); for (final album in selectedBackupAlbums) { await ref.read(albumProvider.notifier).createSyncAlbum(album.name); } diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart index bb9d462e50bc4..d8baecf808d63 100644 --- a/mobile/lib/pages/backup/backup_controller.page.dart +++ b/mobile/lib/pages/backup/backup_controller.page.dart @@ -212,7 +212,7 @@ class BackupControllerPage extends HookConsumerWidget { .read(backupProvider.notifier) .backupAlbumSelectionDone(); // waited until backup albums are stored in DB - ref.read(albumProvider.notifier).getDeviceAlbums(); + ref.read(albumProvider.notifier).refreshDeviceAlbums(); }, child: const Text( "backup_controller_page_select", diff --git a/mobile/lib/pages/common/album_options.page.dart b/mobile/lib/pages/common/album_options.page.dart index 3cc30af7a97f1..93e4c180fed6b 100644 --- a/mobile/lib/pages/common/album_options.page.dart +++ b/mobile/lib/pages/common/album_options.page.dart @@ -6,7 +6,7 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/authentication.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -45,11 +45,11 @@ class AlbumOptionsPage extends HookConsumerWidget { try { final isSuccess = - await ref.read(sharedAlbumProvider.notifier).leaveAlbum(album); + await ref.read(albumProvider.notifier).leaveAlbum(album); if (isSuccess) { context.navigateTo( - const TabControllerRoute(children: [SharingRoute()]), + TabControllerRoute(children: [AlbumsRoute()]), ); } else { showErrorMessage(); @@ -65,9 +65,7 @@ class AlbumOptionsPage extends HookConsumerWidget { isProcessing.value = true; try { - await ref - .read(sharedAlbumProvider.notifier) - .removeUserFromAlbum(album, user); + await ref.read(albumProvider.notifier).removeUser(album, user); album.sharedUsers.remove(user); sharedUsers.value = album.sharedUsers.toList(); } catch (error) { @@ -200,8 +198,8 @@ class AlbumOptionsPage extends HookConsumerWidget { onChanged: (bool value) async { activityEnabled.value = value; if (await ref - .read(sharedAlbumProvider.notifier) - .setActivityEnabled(album, value)) { + .read(albumProvider.notifier) + .setActivitystatus(album, value)) { album.activityEnabled = value; } }, diff --git a/mobile/lib/pages/common/album_shared_user_selection.page.dart b/mobile/lib/pages/common/album_shared_user_selection.page.dart index aefa8e273612c..9dadef1a76f8a 100644 --- a/mobile/lib/pages/common/album_shared_user_selection.page.dart +++ b/mobile/lib/pages/common/album_shared_user_selection.page.dart @@ -5,8 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_title.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -25,20 +25,15 @@ class AlbumSharedUserSelectionPage extends HookConsumerWidget { final suggestedShareUsers = ref.watch(otherUsersProvider); createSharedAlbum() async { - var newAlbum = - await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum( - ref.watch(albumTitleProvider), - assets, - sharedUsersList.value, - ); + var newAlbum = await ref.watch(albumProvider.notifier).createAlbum( + ref.watch(albumTitleProvider), + assets, + ); if (newAlbum != null) { - await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); - // ref.watch(assetSelectionProvider.notifier).removeAll(); ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); context.maybePop(true); - context - .navigateTo(const TabControllerRoute(children: [SharingRoute()])); + context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); } ScaffoldMessenger( diff --git a/mobile/lib/pages/common/album_viewer.page.dart b/mobile/lib/pages/common/album_viewer.page.dart index 33b314f3b105b..b977128cfa25c 100644 --- a/mobile/lib/pages/common/album_viewer.page.dart +++ b/mobile/lib/pages/common/album_viewer.page.dart @@ -11,9 +11,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; -import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; @@ -50,9 +48,7 @@ class AlbumViewerPage extends HookConsumerWidget { Future onRemoveFromAlbumPressed(Iterable assets) async { final a = album.valueOrNull; final bool isSuccess = a != null && - await ref - .read(sharedAlbumProvider.notifier) - .removeAssetFromAlbum(a, assets); + await ref.read(albumProvider.notifier).removeAsset(a, assets); if (!isSuccess) { ImmichToast.show( @@ -81,9 +77,9 @@ class AlbumViewerPage extends HookConsumerWidget { // Check if there is new assets add isProcessing.value = true; - await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum( - returnPayload.selectedAssets, + await ref.watch(albumProvider.notifier).addAssets( albumInfo, + returnPayload.selectedAssets, ); isProcessing.value = false; @@ -98,9 +94,7 @@ class AlbumViewerPage extends HookConsumerWidget { if (sharedUserIds != null) { isProcessing.value = true; - await ref - .watch(albumServiceProvider) - .addAdditionalUserToAlbum(sharedUserIds, album); + await ref.watch(albumProvider.notifier).addUsers(album, sharedUserIds); isProcessing.value = false; } @@ -184,27 +178,29 @@ class AlbumViewerPage extends HookConsumerWidget { } Widget buildSharedUserIconsRow(Album album) { - return GestureDetector( - onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)), - child: SizedBox( - height: 50, - child: ListView.builder( - padding: const EdgeInsets.only(left: 16), - scrollDirection: Axis.horizontal, - itemBuilder: ((context, index) { - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: UserCircleAvatar( - user: album.sharedUsers.toList()[index], - radius: 18, - size: 36, + return album.sharedUsers.isNotEmpty + ? GestureDetector( + onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)), + child: SizedBox( + height: 50, + child: ListView.builder( + padding: const EdgeInsets.only(left: 16), + scrollDirection: Axis.horizontal, + itemBuilder: ((context, index) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: UserCircleAvatar( + user: album.sharedUsers.toList()[index], + radius: 18, + size: 36, + ), + ); + }), + itemCount: album.sharedUsers.length, ), - ); - }), - itemCount: album.sharedUsers.length, - ), - ), - ); + ), + ) + : const SizedBox.shrink(); } Widget buildHeader(Album album) { @@ -214,7 +210,7 @@ class AlbumViewerPage extends HookConsumerWidget { children: [ buildTitle(album), if (album.assets.isNotEmpty == true) buildAlbumDateRange(album), - if (album.shared) buildSharedUserIconsRow(album), + buildSharedUserIconsRow(album), ], ); } @@ -231,17 +227,17 @@ class AlbumViewerPage extends HookConsumerWidget { body: Stack( children: [ album.widgetWhen( - onData: (data) => MultiselectGrid( + onData: (albumInfo) => MultiselectGrid( renderListProvider: albumRenderlistProvider(albumId), topWidget: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - buildHeader(data), - if (data.isRemote) buildControlButton(data), + buildHeader(albumInfo), + if (albumInfo.isRemote) buildControlButton(albumInfo), ], ), onRemoveFromAlbum: onRemoveFromAlbumPressed, - editEnabled: data.ownerId == userId, + editEnabled: albumInfo.ownerId == userId, ), ), AnimatedPositioned( diff --git a/mobile/lib/pages/common/create_album.page.dart b/mobile/lib/pages/common/create_album.page.dart index 1fd860520d5c7..55261f6d55304 100644 --- a/mobile/lib/pages/common/create_album.page.dart +++ b/mobile/lib/pages/common/create_album.page.dart @@ -17,13 +17,11 @@ import 'package:immich_mobile/widgets/album/shared_album_thumbnail_image.dart'; @RoutePage() // ignore: must_be_immutable class CreateAlbumPage extends HookConsumerWidget { - final bool isSharedAlbum; - final List? initialAssets; + final List? assets; const CreateAlbumPage({ super.key, - required this.isSharedAlbum, - this.initialAssets, + this.assets, }); @override @@ -34,18 +32,9 @@ class CreateAlbumPage extends HookConsumerWidget { final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleEmpty = useState(true); final selectedAssets = useState>( - initialAssets != null ? Set.from(initialAssets!) : const {}, + assets != null ? Set.from(assets!) : const {}, ); - showSelectUserPage() async { - final bool? ok = await context.pushRoute( - AlbumSharedUserSelectionRoute(assets: selectedAssets.value), - ); - if (ok == true) { - selectedAssets.value = {}; - } - } - void onBackgroundTapped() { albumTitleTextFieldFocusNode.unfocus(); isAlbumTitleTextFieldFocus.value = false; @@ -199,7 +188,7 @@ class CreateAlbumPage extends HookConsumerWidget { ); if (newAlbum != null) { - ref.watch(albumProvider.notifier).getAllAlbums(); + ref.watch(albumProvider.notifier).refreshRemoteAlbums(); selectedAssets.value = {}; ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); @@ -223,36 +212,20 @@ class CreateAlbumPage extends HookConsumerWidget { 'share_create_album', ).tr(), actions: [ - if (isSharedAlbum) - TextButton( - onPressed: albumTitleController.text.isNotEmpty - ? showSelectUserPage - : null, - child: Text( - 'create_shared_album_page_share'.tr(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: albumTitleController.text.isEmpty - ? context.themeData.disabledColor - : context.primaryColor, - ), - ), - ), - if (!isSharedAlbum) - TextButton( - onPressed: albumTitleController.text.isNotEmpty - ? createNonSharedAlbum - : null, - child: Text( - 'create_shared_album_page_create'.tr(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: albumTitleController.text.isNotEmpty - ? context.primaryColor - : context.themeData.disabledColor, - ), + TextButton( + onPressed: albumTitleController.text.isNotEmpty + ? createNonSharedAlbum + : null, + child: Text( + 'create_shared_album_page_create'.tr(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: albumTitleController.text.isNotEmpty + ? context.primaryColor + : context.themeData.disabledColor, ), ), + ), ], ), body: GestureDetector( diff --git a/mobile/lib/pages/common/large_leading_tile.dart b/mobile/lib/pages/common/large_leading_tile.dart new file mode 100644 index 0000000000000..8213ca423f268 --- /dev/null +++ b/mobile/lib/pages/common/large_leading_tile.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class LargeLeadingTile extends StatelessWidget { + const LargeLeadingTile({ + super.key, + required this.leading, + required this.onTap, + required this.title, + this.subtitle, + this.leadingPadding = const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16.0, + ), + this.borderRadius = 20.0, + }); + + final Widget leading; + final VoidCallback onTap; + final Widget title; + final Widget? subtitle; + final EdgeInsetsGeometry leadingPadding; + final double borderRadius; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: leadingPadding, + child: leading, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: MediaQuery.of(context).size.width * 0.6, + child: title, + ), + subtitle ?? const SizedBox.shrink(), + ], + ), + ], + ), + ); + } +} diff --git a/mobile/lib/pages/common/tab_controller.page.dart b/mobile/lib/pages/common/tab_controller.page.dart index b619e003d2c3a..e9a870af471b3 100644 --- a/mobile/lib/pages/common/tab_controller.page.dart +++ b/mobile/lib/pages/common/tab_controller.page.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -16,10 +17,11 @@ class TabControllerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final refreshing = ref.watch(assetProvider); + final isRefreshingAssets = ref.watch(assetProvider); + final isRefreshingRemoteAlbums = ref.watch(isRefreshingRemoteAlbumProvider); - Widget buildIcon(Widget icon) { - if (!refreshing) return icon; + Widget buildIcon({required Widget icon, required bool isProcessing}) { + if (!isProcessing) return icon; return Stack( alignment: Alignment.center, clipBehavior: Clip.none, @@ -84,15 +86,15 @@ class TabControllerPage extends HookConsumerWidget { ), NavigationRailDestination( padding: const EdgeInsets.all(4), - icon: const Icon(Icons.share_rounded), - selectedIcon: const Icon(Icons.share), - label: const Text('tab_controller_nav_sharing').tr(), + icon: const Icon(Icons.photo_album_outlined), + selectedIcon: const Icon(Icons.photo_album), + label: const Text('albums').tr(), ), NavigationRailDestination( padding: const EdgeInsets.all(4), - icon: const Icon(Icons.photo_album_outlined), - selectedIcon: const Icon(Icons.photo_album), - label: const Text('tab_controller_nav_library').tr(), + icon: const Icon(Icons.space_dashboard_outlined), + selectedIcon: const Icon(Icons.space_dashboard_rounded), + label: const Text('library').tr(), ), ], ); @@ -118,7 +120,8 @@ class TabControllerPage extends HookConsumerWidget { Icons.photo_library_outlined, ), selectedIcon: buildIcon( - Icon( + isProcessing: isRefreshingAssets, + icon: Icon( Icons.photo_library, color: context.primaryColor, ), @@ -135,23 +138,27 @@ class TabControllerPage extends HookConsumerWidget { ), ), NavigationDestination( - label: 'tab_controller_nav_sharing'.tr(), + label: 'albums'.tr(), icon: const Icon( - Icons.group_outlined, + Icons.photo_album_outlined, ), - selectedIcon: Icon( - Icons.group, - color: context.primaryColor, + selectedIcon: buildIcon( + isProcessing: isRefreshingRemoteAlbums, + icon: Icon( + Icons.photo_album_rounded, + color: context.primaryColor, + ), ), ), NavigationDestination( - label: 'tab_controller_nav_library'.tr(), + label: 'library'.tr(), icon: const Icon( - Icons.photo_album_outlined, + Icons.space_dashboard_outlined, ), selectedIcon: buildIcon( - Icon( - Icons.photo_album_rounded, + isProcessing: isRefreshingAssets, + icon: Icon( + Icons.space_dashboard_rounded, color: context.primaryColor, ), ), @@ -162,11 +169,11 @@ class TabControllerPage extends HookConsumerWidget { final multiselectEnabled = ref.watch(multiselectProvider); return AutoTabsRouter( - routes: const [ - PhotosRoute(), - SearchRoute(), - SharingRoute(), - LibraryRoute(), + routes: [ + const PhotosRoute(), + SearchInputRoute(), + const AlbumsRoute(), + const LibraryRoute(), ], duration: const Duration(milliseconds: 600), transitionBuilder: (context, child, animation) => FadeTransition( diff --git a/mobile/lib/pages/editing/edit.page.dart b/mobile/lib/pages/editing/edit.page.dart index 32d3aa6ba9021..650d2dc912db9 100644 --- a/mobile/lib/pages/editing/edit.page.dart +++ b/mobile/lib/pages/editing/edit.page.dart @@ -69,7 +69,7 @@ class EditImagePage extends ConsumerWidget { imageData, title: "${p.withoutExtension(asset.fileName)}_edited.jpg", ); - await ref.read(albumProvider.notifier).getDeviceAlbums(); + await ref.read(albumProvider.notifier).refreshDeviceAlbums(); Navigator.of(context).popUntil((route) => route.isFirst); ImmichToast.show( durationInSecond: 3, diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 5f03ed68714c8..368f3d2ec37a6 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -1,324 +1,354 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; -import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/providers/partner.provider.dart'; +import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; +import 'package:immich_mobile/widgets/common/user_avatar.dart'; +import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; @RoutePage() -class LibraryPage extends HookConsumerWidget { +class LibraryPage extends ConsumerWidget { const LibraryPage({super.key}); - @override Widget build(BuildContext context, WidgetRef ref) { final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); - final albums = ref.watch(albumProvider); - final albumSortOption = ref.watch(albumSortByOptionsProvider); - final albumSortIsReverse = ref.watch(albumSortOrderProvider); - - useEffect( - () { - ref.read(albumProvider.notifier).getAllAlbums(); - return null; - }, - [], - ); - Widget buildSortButton() { - return PopupMenuButton( - position: PopupMenuPosition.over, - itemBuilder: (BuildContext context) { - return AlbumSortMode.values - .map>((option) { - final selected = albumSortOption == option; - return PopupMenuItem( - value: option, + return Scaffold( + appBar: ImmichAppBar(), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.only(top: 16.0), child: Row( children: [ - Padding( - padding: const EdgeInsets.only(right: 12.0), - child: Icon( - Icons.check, - color: - selected ? context.primaryColor : Colors.transparent, - ), + ActionButton( + onPressed: () => context.pushRoute(const FavoritesRoute()), + icon: Icons.favorite_outline_rounded, + label: 'favorites'.tr(), ), - Text( - option.label.tr(), - style: TextStyle( - color: selected ? context.primaryColor : null, - fontSize: 14.0, - ), + const SizedBox(width: 8), + ActionButton( + onPressed: () => context.pushRoute(const ArchiveRoute()), + icon: Icons.archive_outlined, + label: 'archived'.tr(), ), ], ), - ); - }).toList(); - }, - onSelected: (AlbumSortMode value) { - final selected = albumSortOption == value; - // Switch direction - if (selected) { - ref - .read(albumSortOrderProvider.notifier) - .changeSortDirection(!albumSortIsReverse); - } else { - ref.read(albumSortByOptionsProvider.notifier).changeSortMode(value); - } - }, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 5), - child: Icon( - albumSortIsReverse - ? Icons.arrow_downward_rounded - : Icons.arrow_upward_rounded, - size: 14, - color: context.primaryColor, - ), ), - Text( - albumSortOption.label.tr(), - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), + const SizedBox(height: 8), + Row( + children: [ + ActionButton( + onPressed: () => context.pushRoute(const SharedLinkRoute()), + icon: Icons.link_outlined, + label: 'shared_links'.tr(), + ), + const SizedBox(width: 8), + trashEnabled + ? ActionButton( + onPressed: () => context.pushRoute(const TrashRoute()), + icon: Icons.delete_outline_rounded, + label: 'trash'.tr(), + ) + : const SizedBox.shrink(), + ], + ), + const SizedBox(height: 12), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + PeopleCollectionCard(), + PlacesCollectionCard(), + LocalAlbumsCollectionCard(), + ], + ), + const SizedBox(height: 12), + QuickAccessButtons(), + const SizedBox( + height: 32, ), ], ), - ); - } + ), + ); + } +} - Widget buildCreateAlbumButton() { - return LayoutBuilder( - builder: (context, constraints) { - var cardSize = constraints.maxWidth; +class QuickAccessButtons extends ConsumerWidget { + const QuickAccessButtons({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final partners = ref.watch(partnerSharedWithProvider); - return GestureDetector( - onTap: () => - context.pushRoute(CreateAlbumRoute(isSharedAlbum: false)), - child: Padding( - padding: - const EdgeInsets.only(bottom: 32), // Adjust padding to suit - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: cardSize, - height: cardSize, - decoration: BoxDecoration( - color: context.colorScheme.surfaceContainer, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: Center( - child: Icon( - Icons.add_rounded, - size: 28, - color: context.primaryColor, - ), - ), - ), - Padding( - padding: const EdgeInsets.only( - top: 8.0, - bottom: 16, - ), - child: Text( - 'library_page_new_album', - style: context.textTheme.labelLarge?.copyWith( - color: context.colorScheme.onSurface, - ), - ).tr(), - ), - ], + return Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(10), + width: 1, + ), + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(10), + context.colorScheme.primary.withAlpha(15), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0), + bottomRight: Radius.circular(partners.isEmpty ? 20 : 0), ), ), - ); - }, - ); - } - - Widget buildLibraryNavButton( - String label, - IconData icon, - Function() onClick, - ) { - return Expanded( - child: FilledButton.icon( - onPressed: onClick, - label: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - label, - style: TextStyle( - color: context.colorScheme.onSurface, + leading: const Icon( + Icons.group_outlined, + size: 26, + ), + title: Text( + 'partners'.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, ), ), + onTap: () => context.pushRoute(const PartnerRoute()), ), - style: FilledButton.styleFrom( - elevation: 0, - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), - backgroundColor: context.colorScheme.surfaceContainer, - alignment: Alignment.centerLeft, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(20)), + PartnerList(partners: partners), + ], + ), + ); + } +} + +class PartnerList extends ConsumerWidget { + const PartnerList({super.key, required this.partners}); + + final List partners; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: partners.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final partner = partners[index]; + final isLastItem = index == partners.length - 1; + return ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(isLastItem ? 20 : 0), + bottomRight: Radius.circular(isLastItem ? 20 : 0), ), ), - icon: Icon( - icon, - color: context.primaryColor, + contentPadding: const EdgeInsets.only( + left: 12.0, + right: 18.0, ), - ), - ); - } - - final remote = albums.where((a) => a.isRemote).toList(); - final sorted = albumSortOption.sortFn(remote, albumSortIsReverse); - final local = albums.where((a) => a.isLocal).toList(); + leading: userAvatar(context, partner, radius: 16), + title: Text( + "partner_list_user_photos", + style: TextStyle( + fontWeight: FontWeight.w500, + ), + ).tr( + namedArgs: { + 'user': partner.name, + }, + ), + onTap: () => context.pushRoute( + (PartnerDetailRoute(partner: partner)), + ), + ); + }, + ); + } +} - Widget? shareTrashButton() { - return trashEnabled - ? InkWell( - onTap: () => context.pushRoute(const TrashRoute()), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Icon( - Icons.delete_rounded, - size: 25, - semanticLabel: 'profile_drawer_trash'.tr(), - ), - ) - : null; - } +class PeopleCollectionCard extends ConsumerWidget { + const PeopleCollectionCard({super.key}); - return Scaffold( - appBar: ImmichAppBar( - action: shareTrashButton(), - ), - body: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - left: 12.0, - right: 12.0, - top: 24.0, - bottom: 12.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - buildLibraryNavButton( - "library_page_favorites".tr(), Icons.favorite_border, () { - context.navigateTo(const FavoritesRoute()); - }), - const SizedBox(width: 12.0), - buildLibraryNavButton( - "library_page_archive".tr(), Icons.archive_outlined, () { - context.navigateTo(const ArchiveRoute()); - }), + @override + Widget build(BuildContext context, WidgetRef ref) { + final people = ref.watch(getAllPeopleProvider); + final size = MediaQuery.of(context).size.width * 0.5 - 20; + return GestureDetector( + onTap: () => context.pushRoute(const PeopleCollectionRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(30), + context.colorScheme.primary.withAlpha(25), ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), ), + child: people.widgetWhen( + onData: (people) { + return GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + crossAxisSpacing: 8, + mainAxisSpacing: 8, + physics: const NeverScrollableScrollPhysics(), + children: people.take(4).map((person) { + return CircleAvatar( + backgroundImage: NetworkImage( + getFaceThumbnailUrl(person.id), + headers: ApiService.getRequestHeaders(), + ), + ); + }).toList(), + ); + }, + ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - top: 12.0, - left: 12.0, - right: 12.0, - bottom: 20.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'library_page_albums', - style: context.textTheme.bodyLarge?.copyWith( - color: context.colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ).tr(), - buildSortButton(), - ], + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'people'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, ), ), ), - SliverPadding( - padding: const EdgeInsets.all(12.0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 250, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: .7, - ), - delegate: SliverChildBuilderDelegate( - childCount: sorted.length + 1, - (context, index) { - if (index == 0) { - return buildCreateAlbumButton(); - } + ], + ), + ); + } +} - return AlbumThumbnailCard( - album: sorted[index - 1], - onTap: () => context.pushRoute( - AlbumViewerRoute( - albumId: sorted[index - 1].id, - ), - ), - ); - }, +class LocalAlbumsCollectionCard extends HookConsumerWidget { + const LocalAlbumsCollectionCard({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(localAlbumsProvider); + + final size = MediaQuery.of(context).size.width * 0.5 - 20; + + return GestureDetector( + onTap: () => context.pushRoute( + const LocalAlbumsRoute(), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(30), + context.colorScheme.primary.withAlpha(25), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), ), + child: GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(12), + crossAxisSpacing: 8, + mainAxisSpacing: 8, + physics: const NeverScrollableScrollPhysics(), + children: albums.take(4).map((album) { + return AlbumThumbnailCard( + album: album, + showTitle: false, + ); + }).toList(), + ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - top: 12.0, - left: 12.0, - right: 12.0, - bottom: 20.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'library_page_device_albums', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ], + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'on_this_device'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, ), ), ), - SliverPadding( - padding: const EdgeInsets.all(12.0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 250, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: .7, - ), - delegate: SliverChildBuilderDelegate( - childCount: local.length, - (context, index) => AlbumThumbnailCard( - album: local[index], - onTap: () => context.pushRoute( - AlbumViewerRoute( - albumId: local[index].id, - ), - ), + ], + ), + ); + } +} + +class PlacesCollectionCard extends StatelessWidget { + const PlacesCollectionCard({super.key}); + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size.width * 0.5 - 20; + return GestureDetector( + onTap: () => context.pushRoute(const PlacesCollectionRoute()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: size, + width: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: context.colorScheme.secondaryContainer.withAlpha(100), + ), + child: IgnorePointer( + child: MapThumbnail( + zoom: 8, + centre: const LatLng( + 21.44950, + -157.91959, ), + showAttribution: false, + themeMode: + context.isDarkTheme ? ThemeMode.dark : ThemeMode.light, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'places'.tr(), + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, ), ), ), @@ -327,3 +357,52 @@ class LibraryPage extends HookConsumerWidget { ); } } + +class ActionButton extends StatelessWidget { + final VoidCallback onPressed; + final IconData icon; + final String label; + + const ActionButton({ + super.key, + required this.onPressed, + required this.icon, + required this.label, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: FilledButton.icon( + onPressed: onPressed, + label: Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + label, + style: TextStyle( + color: context.colorScheme.onSurface, + fontSize: 15, + ), + ), + ), + style: FilledButton.styleFrom( + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + backgroundColor: context.colorScheme.surfaceContainerLow, + alignment: Alignment.centerLeft, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(25)), + side: BorderSide( + color: context.colorScheme.onSurface.withAlpha(10), + width: 1, + ), + ), + ), + icon: Icon( + icon, + color: context.primaryColor, + ), + ), + ); + } +} diff --git a/mobile/lib/pages/library/local_albums.page.dart b/mobile/lib/pages/library/local_albums.page.dart new file mode 100644 index 0000000000000..164ea3bad883f --- /dev/null +++ b/mobile/lib/pages/library/local_albums.page.dart @@ -0,0 +1,55 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; + +@RoutePage() +class LocalAlbumsPage extends HookConsumerWidget { + const LocalAlbumsPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(localAlbumsProvider); + + return Scaffold( + appBar: AppBar( + title: Text('on_this_device'.tr()), + ), + body: ListView.builder( + padding: const EdgeInsets.all(18.0), + itemCount: albums.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: LargeLeadingTile( + leadingPadding: const EdgeInsets.only( + right: 16, + ), + leading: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(15)), + child: ImmichThumbnail( + asset: albums[index].thumbnail.value, + width: 80, + height: 80, + ), + ), + title: Text( + albums[index].name, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text('${albums[index].assetCount} items'), + onTap: () => context + .pushRoute(AlbumViewerRoute(albumId: albums[index].id)), + ), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/pages/sharing/partner/partner.page.dart b/mobile/lib/pages/library/partner/partner.page.dart similarity index 93% rename from mobile/lib/pages/sharing/partner/partner.page.dart rename to mobile/lib/pages/library/partner/partner.page.dart index 8dd31023c7cad..1e9e801210e5e 100644 --- a/mobile/lib/pages/sharing/partner/partner.page.dart +++ b/mobile/lib/pages/library/partner/partner.page.dart @@ -86,12 +86,10 @@ class PartnerPage extends HookConsumerWidget { children: [ Padding( padding: const EdgeInsets.only(left: 16.0, top: 16.0), - child: const Text( + child: Text( "partner_page_shared_to_title", - style: TextStyle( - fontSize: 14, - color: Colors.grey, - fontWeight: FontWeight.bold, + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface.withAlpha(200), ), ).tr(), ), @@ -104,10 +102,7 @@ class PartnerPage extends HookConsumerWidget { leading: userAvatar(context, users[index]), title: Text( users[index].email, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), + style: context.textTheme.bodyLarge, ), trailing: IconButton( icon: const Icon(Icons.person_remove), @@ -148,7 +143,7 @@ class PartnerPage extends HookConsumerWidget { return Scaffold( appBar: AppBar( - title: const Text("partner_page_title").tr(), + title: const Text("partners").tr(), elevation: 0, centerTitle: false, actions: [ diff --git a/mobile/lib/pages/sharing/partner/partner_detail.page.dart b/mobile/lib/pages/library/partner/partner_detail.page.dart similarity index 59% rename from mobile/lib/pages/sharing/partner/partner_detail.page.dart rename to mobile/lib/pages/library/partner/partner_detail.page.dart index 8a2dd4b820379..0874aacfa7f53 100644 --- a/mobile/lib/pages/sharing/partner/partner_detail.page.dart +++ b/mobile/lib/pages/library/partner/partner_detail.page.dart @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/providers/partner.provider.dart'; import 'package:immich_mobile/entities/user.entity.dart'; @@ -22,7 +23,11 @@ class PartnerDetailPage extends HookConsumerWidget { useEffect( () { - ref.read(assetProvider.notifier).getAllAsset(); + Future.microtask( + () async => { + await ref.read(assetProvider.notifier).getAllAsset(), + }, + ); return null; }, [], @@ -64,19 +69,47 @@ class PartnerDetailPage extends HookConsumerWidget { title: Text(partner.name), elevation: 0, centerTitle: false, - actions: [ - IconButton( - onPressed: toggleInTimeline, - icon: Icon( - inTimeline.value - ? Icons.collections - : Icons.collections_outlined, + ), + body: MultiselectGrid( + topWidget: Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(10), + width: 1, + ), + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(10), + context.colorScheme.primary.withAlpha(15), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: ListTile( + title: Text( + "Show in timeline", + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.primary, ), - tooltip: "Show/hide photos on your main timeline", ), - ], + subtitle: Text( + "Show photos and videos from this user in your timeline", + style: context.textTheme.bodyMedium, + ), + trailing: Switch( + value: inTimeline.value, + onChanged: (_) => toggleInTimeline(), + ), + ), ), - body: MultiselectGrid( + ), + ), renderListProvider: assetsProvider(partner.isarId), onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(), deleteEnabled: false, diff --git a/mobile/lib/pages/library/people/people_collection.page.dart b/mobile/lib/pages/library/people/people_collection.page.dart new file mode 100644 index 0000000000000..b3f688280810c --- /dev/null +++ b/mobile/lib/pages/library/people/people_collection.page.dart @@ -0,0 +1,104 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/search/people.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; + +@RoutePage() +class PeopleCollectionPage extends HookConsumerWidget { + const PeopleCollectionPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final people = ref.watch(getAllPeopleProvider); + final headers = ApiService.getRequestHeaders(); + + showNameEditModel( + String personId, + String personName, + ) { + return showDialog( + context: context, + builder: (BuildContext context) { + return PersonNameEditForm(personId: personId, personName: personName); + }, + ); + } + + return Scaffold( + appBar: AppBar( + title: Text('people'.tr()), + ), + body: people.when( + data: (people) { + return GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 0.85, + ), + padding: const EdgeInsets.symmetric(vertical: 32), + itemCount: people.length, + itemBuilder: (context, index) { + final person = people[index]; + + return Column( + children: [ + GestureDetector( + onTap: () { + context.pushRoute( + PersonResultRoute( + personId: person.id, + personName: person.name, + ), + ); + }, + child: Material( + shape: const CircleBorder(side: BorderSide.none), + elevation: 3, + child: CircleAvatar( + maxRadius: 96 / 2, + backgroundImage: NetworkImage( + getFaceThumbnailUrl(person.id), + headers: headers, + ), + ), + ), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: () => showNameEditModel(person.id, person.name), + child: person.name.isEmpty + ? Text( + 'add_a_name'.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.primary, + ), + ) + : Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + person.name, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ); + }, + ); + }, + error: (error, stack) => const Text("error"), + loading: () => const CircularProgressIndicator(), + ), + ); + } +} diff --git a/mobile/lib/pages/library/places/places_collection.part.dart b/mobile/lib/pages/library/places/places_collection.part.dart new file mode 100644 index 0000000000000..e24a9a79ef236 --- /dev/null +++ b/mobile/lib/pages/library/places/places_collection.part.dart @@ -0,0 +1,125 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +@RoutePage() +class PlacesCollectionPage extends HookConsumerWidget { + const PlacesCollectionPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final places = ref.watch(getAllPlacesProvider); + + return Scaffold( + appBar: AppBar( + title: Text('places'.tr()), + ), + body: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + height: 200, + width: context.width, + child: MapThumbnail( + onTap: (_, __) => context.pushRoute(const MapRoute()), + zoom: 8, + centre: const LatLng( + 21.44950, + -157.91959, + ), + showAttribution: false, + themeMode: + context.isDarkTheme ? ThemeMode.dark : ThemeMode.light, + ), + ), + ), + places.when( + data: (places) { + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: places.length, + itemBuilder: (context, index) { + final place = places[index]; + + return PlaceTile(id: place.id, name: place.label); + }, + ); + }, + error: (error, stask) => const Text('Error getting places'), + loading: () => Center(child: const CircularProgressIndicator()), + ), + ], + ), + ); + } +} + +class PlaceTile extends StatelessWidget { + const PlaceTile({super.key, required this.id, required this.name}); + + final String id; + final String name; + + @override + Widget build(BuildContext context) { + final thumbnailUrl = + '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail'; + + void navigateToPlace() { + context.pushRoute( + SearchInputRoute( + prefilter: SearchFilter( + people: {}, + location: SearchLocationFilter( + city: name, + ), + camera: SearchCameraFilter(), + date: SearchDateFilter(), + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: AssetType.other, + ), + ), + ); + } + + return LargeLeadingTile( + onTap: () => navigateToPlace(), + title: Text( + name, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + leading: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: CachedNetworkImage( + width: 80, + height: 80, + fit: BoxFit.cover, + imageUrl: thumbnailUrl, + httpHeaders: ApiService.getRequestHeaders(), + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported_outlined), + ), + ), + ); + } +} diff --git a/mobile/lib/pages/sharing/shared_link/shared_link.page.dart b/mobile/lib/pages/library/shared_link/shared_link.page.dart similarity index 100% rename from mobile/lib/pages/sharing/shared_link/shared_link.page.dart rename to mobile/lib/pages/library/shared_link/shared_link.page.dart diff --git a/mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart similarity index 100% rename from mobile/lib/pages/sharing/shared_link/shared_link_edit.page.dart rename to mobile/lib/pages/library/shared_link/shared_link_edit.page.dart diff --git a/mobile/lib/pages/photos/photos.page.dart b/mobile/lib/pages/photos/photos.page.dart index 3c5ff272962a3..14e5724155da3 100644 --- a/mobile/lib/pages/photos/photos.page.dart +++ b/mobile/lib/pages/photos/photos.page.dart @@ -7,7 +7,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; import 'package:immich_mobile/widgets/memories/memory_lane.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; @@ -33,8 +32,7 @@ class PhotosPage extends HookConsumerWidget { () { ref.read(websocketProvider.notifier).connect(); Future(() => ref.read(assetProvider.notifier).getAllAsset()); - ref.read(albumProvider.notifier).getAllAlbums(); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + Future(() => ref.read(albumProvider.notifier).refreshRemoteAlbums()); ref.read(serverInfoProvider.notifier).getServerInfo(); return; }, diff --git a/mobile/lib/pages/search/person_result.page.dart b/mobile/lib/pages/search/person_result.page.dart index 55824b8db91f6..8627c65bcccef 100644 --- a/mobile/lib/pages/search/person_result.page.dart +++ b/mobile/lib/pages/search/person_result.page.dart @@ -92,6 +92,7 @@ class PersonResultPage extends HookConsumerWidget { Text( name.value, style: context.textTheme.titleLarge, + overflow: TextOverflow.ellipsis, ), ], ), @@ -125,9 +126,11 @@ class PersonResultPage extends HookConsumerWidget { headers: ApiService.getRequestHeaders(), ), ), - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: buildTitleBlock(), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16.0, right: 16.0), + child: buildTitleBlock(), + ), ), ], ), diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index 173115185bd5a..a8be87cc6a84e 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -1,25 +1,11 @@ -import 'dart:math' as math; - import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/models/search/search_curated_content.model.dart'; -import 'package:immich_mobile/models/search/search_filter.model.dart'; -import 'package:immich_mobile/providers/search/people.provider.dart'; -import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; -import 'package:immich_mobile/widgets/search/curated_people_row.dart'; -import 'package:immich_mobile/widgets/search/curated_places_row.dart'; -import 'package:immich_mobile/widgets/search/person_name_edit_form.dart'; -import 'package:immich_mobile/widgets/search/search_row_section.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; -import 'package:immich_mobile/widgets/common/scaffold_error_body.dart'; @RoutePage() // ignore: must_be_immutable @@ -28,12 +14,6 @@ class SearchPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final places = ref.watch(getPreviewPlacesProvider); - final curatedPeople = ref.watch(getAllPeopleProvider); - final isMapEnabled = - ref.watch(serverInfoProvider.select((v) => v.serverFeatures.map)); - final double imageSize = math.min(context.width / 3, 150); - TextStyle categoryTitleStyle = const TextStyle( fontWeight: FontWeight.w500, fontSize: 15.0, @@ -41,87 +21,6 @@ class SearchPage extends HookConsumerWidget { Color categoryIconColor = context.colorScheme.onSurface; - showNameEditModel( - String personId, - String personName, - ) { - return showDialog( - context: context, - builder: (BuildContext context) { - return PersonNameEditForm(personId: personId, personName: personName); - }, - ); - } - - buildPeople() { - return curatedPeople.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (people) { - return SearchRowSection( - onViewAllPressed: () => context.pushRoute(const AllPeopleRoute()), - title: "search_page_people".tr(), - isEmpty: people.isEmpty, - child: CuratedPeopleRow( - padding: const EdgeInsets.symmetric(horizontal: 16), - content: people - .map((e) => SearchCuratedContent(label: e.name, id: e.id)) - .take(12) - .toList(), - onTap: (content, index) { - context.pushRoute( - PersonResultRoute( - personId: content.id, - personName: content.label, - ), - ); - }, - onNameTap: (person, index) => { - showNameEditModel(person.id, person.label), - }, - ), - ); - }, - ); - } - - buildPlaces() { - return places.widgetWhen( - onError: (error, stack) => const ScaffoldErrorBody(withIcon: false), - onData: (data) { - return SearchRowSection( - onViewAllPressed: () => context.pushRoute(const AllPlacesRoute()), - title: "search_page_places".tr(), - isEmpty: !isMapEnabled && data.isEmpty, - child: CuratedPlacesRow( - isMapEnabled: isMapEnabled, - content: data, - imageSize: imageSize, - onTap: (content, index) { - context.pushRoute( - SearchInputRoute( - prefilter: SearchFilter( - people: {}, - location: SearchLocationFilter( - city: content.label, - ), - camera: SearchCameraFilter(), - date: SearchDateFilter(), - display: SearchDisplayFilters( - isNotInAlbum: false, - isArchive: false, - isFavorite: false, - ), - mediaType: AssetType.other, - ), - ), - ); - }, - ), - ); - }, - ); - } - buildSearchButton() { return GestureDetector( onTap: () { @@ -165,20 +64,17 @@ class SearchPage extends HookConsumerWidget { body: ListView( children: [ buildSearchButton(), - const SizedBox(height: 8.0), - buildPeople(), - const SizedBox(height: 8.0), - buildPlaces(), const SizedBox(height: 24.0), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Text( - 'search_page_your_activity', + 'search_page_categories', style: context.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, ), ).tr(), ), + const SizedBox(height: 12.0), ListTile( leading: Icon( Icons.favorite_border_rounded, @@ -200,16 +96,7 @@ class SearchPage extends HookConsumerWidget { ).tr(), onTap: () => context.pushRoute(const RecentlyAddedRoute()), ), - const SizedBox(height: 24.0), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - 'search_page_categories', - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), + const CategoryDivider(), ListTile( title: Text('search_page_videos', style: categoryTitleStyle).tr(), leading: Icon( diff --git a/mobile/lib/pages/search/search_input.page.dart b/mobile/lib/pages/search/search_input.page.dart index 2ca2a379180dd..64e68ddfb4090 100644 --- a/mobile/lib/pages/search/search_input.page.dart +++ b/mobile/lib/pages/search/search_input.page.dart @@ -31,6 +31,7 @@ class SearchInputPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isContextualSearch = useState(true); final textSearchController = useTextEditingController(); + final focusNode = useFocusNode(); final filter = useState( SearchFilter( people: prefilter?.people ?? {}, @@ -440,6 +441,10 @@ class SearchInputPage extends HookConsumerWidget { } handleTextSubmitted(String value) { + if (value.isEmpty) { + return; + } + if (isContextualSearch.value) { filter.value = filter.value.copyWith( context: value, @@ -489,38 +494,82 @@ class SearchInputPage extends HookConsumerWidget { appBar: AppBar( automaticallyImplyLeading: true, actions: [ - IconButton( - icon: isContextualSearch.value - ? const Icon(Icons.abc_rounded) - : const Icon(Icons.image_search_rounded), - onPressed: () { - isContextualSearch.value = !isContextualSearch.value; - textSearchController.clear(); - }, + Padding( + padding: const EdgeInsets.only(right: 14.0), + child: IconButton( + icon: isContextualSearch.value + ? const Icon(Icons.abc_rounded) + : const Icon(Icons.image_search_rounded), + onPressed: () { + isContextualSearch.value = !isContextualSearch.value; + textSearchController.clear(); + }, + ), ), ], - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new_rounded), - onPressed: () => context.router.maybePop(), - ), - title: TextField( - controller: textSearchController, - decoration: InputDecoration( - hintText: isContextualSearch.value - ? 'contextual_search'.tr() - : 'filename_search'.tr(), - hintStyle: context.textTheme.bodyLarge?.copyWith( - color: context.themeData.colorScheme.onSurfaceSecondary, - fontWeight: FontWeight.w500, + title: Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(0), + width: 0, ), - enabledBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.transparent), + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withOpacity(0.075), + context.colorScheme.primary.withOpacity(0.09), + context.colorScheme.primary.withOpacity(0.075), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - focusedBorder: const UnderlineInputBorder( - borderSide: BorderSide(color: Colors.transparent), + ), + child: TextField( + controller: textSearchController, + decoration: InputDecoration( + contentPadding: EdgeInsets.all(8), + prefixIcon: prefilter != null + ? null + : Icon( + Icons.search_rounded, + color: context.colorScheme.primary, + ), + hintText: isContextualSearch.value + ? 'contextual_search'.tr() + : 'filename_search'.tr(), + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.themeData.colorScheme.onSurfaceSecondary, + fontWeight: FontWeight.w500, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceContainer, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.surfaceDim, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(25), + borderSide: BorderSide( + color: context.colorScheme.primary.withAlpha(100), + ), + ), ), + onSubmitted: handleTextSubmitted, + focusNode: focusNode, + onTapOutside: (_) => focusNode.unfocus(), ), - onSubmitted: handleTextSubmitted, ), ), body: Column( diff --git a/mobile/lib/pages/sharing/sharing.page.dart b/mobile/lib/pages/sharing/sharing.page.dart deleted file mode 100644 index 98d4cfafe9fe5..0000000000000 --- a/mobile/lib/pages/sharing/sharing.page.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/theme_extensions.dart'; -import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; -import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart'; -import 'package:immich_mobile/providers/partner.provider.dart'; -import 'package:immich_mobile/widgets/partner/partner_list.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/providers/user.provider.dart'; -import 'package:immich_mobile/widgets/common/immich_app_bar.dart'; -import 'package:immich_mobile/widgets/common/immich_thumbnail.dart'; - -@RoutePage() -class SharingPage extends HookConsumerWidget { - const SharingPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final albumSortOption = ref.watch(albumSortByOptionsProvider); - final albumSortIsReverse = ref.watch(albumSortOrderProvider); - final albums = ref.watch(sharedAlbumProvider); - final sharedAlbums = albumSortOption.sortFn(albums, albumSortIsReverse); - final userId = ref.watch(currentUserProvider)?.id; - final partner = ref.watch(partnerSharedWithProvider); - - useEffect( - () { - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - return null; - }, - [], - ); - - buildAlbumGrid() { - return SliverPadding( - padding: const EdgeInsets.all(18.0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 250, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: .7, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - return AlbumThumbnailCard( - album: sharedAlbums[index], - showOwner: true, - onTap: () => context.pushRoute( - AlbumViewerRoute(albumId: sharedAlbums[index].id), - ), - ); - }, - childCount: sharedAlbums.length, - ), - ), - ); - } - - buildAlbumList() { - return SliverList( - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - final album = sharedAlbums[index]; - final isOwner = album.ownerId == userId; - - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 12), - leading: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: ImmichThumbnail( - asset: album.thumbnail.value, - width: 60, - height: 60, - ), - ), - title: Text( - album.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurface, - fontWeight: FontWeight.w500, - ), - ), - subtitle: isOwner - ? Text( - 'album_thumbnail_owned'.tr(), - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurfaceSecondary, - ), - ) - : album.ownerName != null - ? Text( - 'album_thumbnail_shared_by' - .tr(args: [album.ownerName!]), - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurfaceSecondary, - ), - ) - : null, - onTap: () => context - .pushRoute(AlbumViewerRoute(albumId: sharedAlbums[index].id)), - ); - }, - childCount: sharedAlbums.length, - ), - ); - } - - buildTopBottons() { - return Padding( - padding: const EdgeInsets.only( - left: 12.0, - right: 12.0, - top: 24.0, - bottom: 12.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () => - context.pushRoute(CreateAlbumRoute(isSharedAlbum: true)), - icon: const Icon( - Icons.photo_album_outlined, - size: 20, - ), - label: const Text( - "sharing_silver_appbar_create_shared_album", - maxLines: 1, - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ).tr(), - ), - ), - const SizedBox(width: 12.0), - Expanded( - child: ElevatedButton.icon( - onPressed: () => context.pushRoute(const SharedLinkRoute()), - icon: const Icon( - Icons.link, - size: 20, - ), - label: const Text( - "sharing_silver_appbar_shared_links", - style: TextStyle( - fontWeight: FontWeight.w500, - fontSize: 12, - ), - maxLines: 1, - ).tr(), - ), - ), - ], - ), - ); - } - - buildEmptyListIndication() { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Card( - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(20)), - side: BorderSide( - color: context.isDarkTheme - ? const Color(0xFF383838) - : Colors.black12, - width: 1, - ), - ), - child: Padding( - padding: const EdgeInsets.all(18.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 5.0, bottom: 5), - child: Icon( - Icons.insert_photo_rounded, - size: 50, - color: context.primaryColor, - ), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'sharing_page_empty_list', - style: context.textTheme.displaySmall, - ).tr(), - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - 'sharing_page_description', - style: context.textTheme.bodyMedium, - ).tr(), - ), - ], - ), - ), - ), - ), - ); - } - - Widget sharePartnerButton() { - return InkWell( - onTap: () => context.pushRoute(const PartnerRoute()), - borderRadius: const BorderRadius.all(Radius.circular(12)), - child: Icon( - Icons.swap_horizontal_circle_rounded, - size: 25, - semanticLabel: 'partner_page_title'.tr(), - ), - ); - } - - return RefreshIndicator( - onRefresh: () async { - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - }, - child: Scaffold( - appBar: ImmichAppBar( - action: sharePartnerButton(), - ), - body: CustomScrollView( - slivers: [ - SliverToBoxAdapter(child: buildTopBottons()), - if (partner.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.all(12), - sliver: SliverToBoxAdapter( - child: Text( - "partner_page_title", - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ), - if (partner.isNotEmpty) PartnerList(partner: partner), - SliverPadding( - padding: const EdgeInsets.all(12), - sliver: SliverToBoxAdapter( - child: Text( - "sharing_page_album", - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), - ).tr(), - ), - ), - SliverLayoutBuilder( - builder: (context, constraints) { - if (sharedAlbums.isEmpty) { - return buildEmptyListIndication(); - } - - if (constraints.crossAxisExtent < 600) { - return buildAlbumList(); - } else { - return buildAlbumGrid(); - } - }, - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index ed9dc07f5e5c0..943671f1885ad 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -1,21 +1,21 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/store.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/utils/renderlist_generator.dart'; import 'package:isar/isar.dart'; +final isRefreshingRemoteAlbumProvider = StateProvider((ref) => false); + class AlbumNotifier extends StateNotifier> { - AlbumNotifier(this._albumService, Isar db) : super([]) { - final query = db.albums - .filter() - .owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId)); + AlbumNotifier(this._albumService, this.db, this.ref) : super([]) { + final query = db.albums.filter().remoteIdIsNotNull(); query.findAll().then((value) { if (mounted) { state = value; @@ -25,14 +25,22 @@ class AlbumNotifier extends StateNotifier> { } final AlbumService _albumService; + final Isar db; + final Ref ref; late final StreamSubscription> _streamSub; - Future getAllAlbums() => Future.wait([ - _albumService.refreshDeviceAlbums(), - _albumService.refreshRemoteAlbums(isShared: false), - ]); + Future refreshRemoteAlbums() async { + final isRefresing = + ref.read(isRefreshingRemoteAlbumProvider.notifier).state; + + if (isRefresing) return; + + ref.read(isRefreshingRemoteAlbumProvider.notifier).state = true; + await _albumService.refreshRemoteAlbums(); + ref.read(isRefreshingRemoteAlbumProvider.notifier).state = false; + } - Future getDeviceAlbums() => _albumService.refreshDeviceAlbums(); + Future refreshDeviceAlbums() => _albumService.refreshDeviceAlbums(); Future deleteAlbum(Album album) => _albumService.deleteAlbum(album); @@ -59,6 +67,50 @@ class AlbumNotifier extends StateNotifier> { await createAlbum(albumName, {}); } + Future leaveAlbum(Album album) async { + var res = await _albumService.leaveAlbum(album); + + if (res) { + await deleteAlbum(album); + return true; + } else { + return false; + } + } + + void searchAlbums(String searchTerm, QuickFilterMode filterMode) async { + state = await _albumService.search(searchTerm, filterMode); + } + + Future addUsers(Album album, List userIds) async { + await _albumService.addUsers(album, userIds); + } + + Future removeUser(Album album, User user) async { + final isRemoved = await _albumService.removeUser(album, user); + + if (isRemoved && album.sharedUsers.isEmpty) { + state = state.where((element) => element.id != album.id).toList(); + } + + return isRemoved; + } + + Future addAssets(Album album, Iterable assets) async { + await _albumService.addAssets(album, assets); + } + + Future removeAsset(Album album, Iterable assets) async { + return await _albumService.removeAsset(album, assets); + } + + Future setActivitystatus( + Album album, + bool enabled, + ) { + return _albumService.setActivityStatus(album, enabled); + } + @override void dispose() { _streamSub.cancel(); @@ -71,6 +123,7 @@ final albumProvider = return AlbumNotifier( ref.watch(albumServiceProvider), ref.watch(dbProvider), + ref, ); }); @@ -94,3 +147,31 @@ final albumRenderlistProvider = } return const Stream.empty(); }); + +class LocalAlbumsNotifier extends StateNotifier> { + LocalAlbumsNotifier(this.db) : super([]) { + final query = db.albums.where().remoteIdIsNull(); + + query.findAll().then((value) { + if (mounted) { + state = value; + } + }); + + _streamSub = query.watch().listen((data) => state = data); + } + + final Isar db; + late final StreamSubscription> _streamSub; + + @override + void dispose() { + _streamSub.cancel(); + super.dispose(); + } +} + +final localAlbumsProvider = + StateNotifierProvider.autoDispose>((ref) { + return LocalAlbumsNotifier(ref.watch(dbProvider)); +}); diff --git a/mobile/lib/providers/album/album_viewer.provider.dart b/mobile/lib/providers/album/album_viewer.provider.dart index f34ff4ef2257e..e41865778214a 100644 --- a/mobile/lib/providers/album/album_viewer.provider.dart +++ b/mobile/lib/providers/album/album_viewer.provider.dart @@ -1,6 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/albums/album_viewer_page_state.model.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -40,7 +39,6 @@ class AlbumViewerNotifier extends StateNotifier { if (isSuccess) { state = state.copyWith(editTitleText: "", isEditAlbum: false); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); return true; } diff --git a/mobile/lib/providers/album/shared_album.provider.dart b/mobile/lib/providers/album/shared_album.provider.dart deleted file mode 100644 index 0d581353757b8..0000000000000 --- a/mobile/lib/providers/album/shared_album.provider.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/providers/db.provider.dart'; -import 'package:isar/isar.dart'; - -class SharedAlbumNotifier extends StateNotifier> { - SharedAlbumNotifier(this._albumService, Isar db) : super([]) { - final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc(); - query.findAll().then((value) { - if (mounted) { - state = value; - } - }); - _streamSub = query.watch().listen((data) => state = data); - } - - final AlbumService _albumService; - late final StreamSubscription> _streamSub; - - Future createSharedAlbum( - String albumName, - Iterable assets, - Iterable sharedUsers, - ) async { - try { - return await _albumService.createAlbum( - albumName, - assets, - sharedUsers, - ); - } catch (e) { - debugPrint("Error createSharedAlbum ${e.toString()}"); - } - return null; - } - - Future getAllSharedAlbums() => - _albumService.refreshRemoteAlbums(isShared: true); - - Future deleteAlbum(Album album) => _albumService.deleteAlbum(album); - - Future leaveAlbum(Album album) async { - var res = await _albumService.leaveAlbum(album); - - if (res) { - await deleteAlbum(album); - return true; - } else { - return false; - } - } - - Future removeAssetFromAlbum(Album album, Iterable assets) { - return _albumService.removeAssetFromAlbum(album, assets); - } - - Future removeUserFromAlbum(Album album, User user) async { - final result = await _albumService.removeUserFromAlbum(album, user); - - if (result && album.sharedUsers.isEmpty) { - state = state.where((element) => element.id != album.id).toList(); - } - - return result; - } - - Future setActivityEnabled(Album album, bool activityEnabled) { - return _albumService.setActivityEnabled(album, activityEnabled); - } - - @override - void dispose() { - _streamSub.cancel(); - super.dispose(); - } -} - -final sharedAlbumProvider = - StateNotifierProvider.autoDispose>((ref) { - return SharedAlbumNotifier( - ref.watch(albumServiceProvider), - ref.watch(dbProvider), - ); -}); diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 5561d3fefd683..c06a99da35b62 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -1,6 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; @@ -58,11 +57,10 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(assetProvider.notifier).getAllAsset(); case TabEnum.search: // nothing to do - case TabEnum.sharing: - _ref.read(assetProvider.notifier).getAllAsset(); - _ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + case TabEnum.albums: + _ref.read(albumProvider.notifier).refreshRemoteAlbums(); case TabEnum.library: - _ref.read(albumProvider.notifier).getAllAlbums(); + // nothing to do } } diff --git a/mobile/lib/providers/authentication.provider.dart b/mobile/lib/providers/authentication.provider.dart index b56e71b11b3f6..1fe7db5d46f42 100644 --- a/mobile/lib/providers/authentication.provider.dart +++ b/mobile/lib/providers/authentication.provider.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_udid/flutter_udid.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/authentication/authentication_state.model.dart'; import 'package:immich_mobile/entities/user.entity.dart'; @@ -115,7 +114,6 @@ class AuthenticationNotifier extends StateNotifier { Store.delete(StoreKey.accessToken), ]); _ref.invalidate(albumProvider); - _ref.invalidate(sharedAlbumProvider); state = state.copyWith( deviceId: "", diff --git a/mobile/lib/providers/backup/backup_verification.provider.g.dart b/mobile/lib/providers/backup/backup_verification.provider.g.dart index f222c9bd83e12..e286f434219b5 100644 --- a/mobile/lib/providers/backup/backup_verification.provider.g.dart +++ b/mobile/lib/providers/backup/backup_verification.provider.g.dart @@ -7,7 +7,7 @@ part of 'backup_verification.provider.dart'; // ************************************************************************** String _$backupVerificationHash() => - r'b691e0cc27856eef189258d3c102cc73ce4812a4'; + r'021dfdf65e1903c932e4a1c14967b786dd3516fb'; /// See also [BackupVerification]. @ProviderFor(BackupVerification) diff --git a/mobile/lib/providers/tab.provider.dart b/mobile/lib/providers/tab.provider.dart index 2abed7c395e50..a4875115ce2a0 100644 --- a/mobile/lib/providers/tab.provider.dart +++ b/mobile/lib/providers/tab.provider.dart @@ -1,11 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -enum TabEnum { - home, - search, - sharing, - library, -} +enum TabEnum { home, search, albums, library } /// Provides the currently active tab final tabProvider = StateProvider( diff --git a/mobile/lib/repositories/album.repository.dart b/mobile/lib/repositories/album.repository.dart index 35f5cae32722c..2c78e4c2389f1 100644 --- a/mobile/lib/repositories/album.repository.dart +++ b/mobile/lib/repositories/album.repository.dart @@ -1,8 +1,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:isar/isar.dart'; @@ -118,4 +120,33 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository { @override Future deleteAllLocal() => txn(() => db.albums.where().localIdIsNotNull().deleteAll()); + + @override + Future> search( + String searchTerm, + QuickFilterMode filterMode, + ) async { + var query = db.albums + .filter() + .nameContains(searchTerm, caseSensitive: false) + .remoteIdIsNotNull(); + + switch (filterMode) { + case QuickFilterMode.sharedWithMe: + query = query.owner( + (q) => q.not().isarIdEqualTo(Store.get(StoreKey.currentUser).isarId), + ); + break; + case QuickFilterMode.myAlbums: + query = query.owner( + (q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId), + ); + break; + case QuickFilterMode.all: + default: + break; + } + + return await query.findAll(); + } } diff --git a/mobile/lib/repositories/partner_api.repository.dart b/mobile/lib/repositories/partner_api.repository.dart index 0b3d164ca3523..1ae16d9d52993 100644 --- a/mobile/lib/repositories/partner_api.repository.dart +++ b/mobile/lib/repositories/partner_api.repository.dart @@ -36,7 +36,7 @@ class PartnerApiRepository extends ApiRepository } @override - Future delete(String id) => checkNull(_api.removePartner(id)); + Future delete(String id) => _api.removePartner(id); @override Future update(String id, {required bool inTimeline}) async { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 1f26e0d6de1ed..c9970b02c55b1 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -13,6 +13,11 @@ import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; +import 'package:immich_mobile/pages/albums/albums.page.dart'; +import 'package:immich_mobile/pages/library/local_albums.page.dart'; +import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; +import 'package:immich_mobile/pages/library/places/places_collection.part.dart'; +import 'package:immich_mobile/pages/library/library.page.dart'; import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/album_additional_shared_user_selection.page.dart'; import 'package:immich_mobile/pages/common/album_asset_selection.page.dart'; @@ -32,7 +37,6 @@ import 'package:immich_mobile/pages/editing/crop.page.dart'; import 'package:immich_mobile/pages/editing/filter.page.dart'; import 'package:immich_mobile/pages/library/archive.page.dart'; import 'package:immich_mobile/pages/library/favorite.page.dart'; -import 'package:immich_mobile/pages/library/library.page.dart'; import 'package:immich_mobile/pages/library/trash.page.dart'; import 'package:immich_mobile/pages/login/change_password.page.dart'; import 'package:immich_mobile/pages/login/login.page.dart'; @@ -49,11 +53,10 @@ import 'package:immich_mobile/pages/search/person_result.page.dart'; import 'package:immich_mobile/pages/search/recently_added.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/search/search_input.page.dart'; -import 'package:immich_mobile/pages/sharing/partner/partner.page.dart'; -import 'package:immich_mobile/pages/sharing/partner/partner_detail.page.dart'; -import 'package:immich_mobile/pages/sharing/shared_link/shared_link.page.dart'; -import 'package:immich_mobile/pages/sharing/shared_link/shared_link_edit.page.dart'; -import 'package:immich_mobile/pages/sharing/sharing.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner.page.dart'; +import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link.page.dart'; +import 'package:immich_mobile/pages/library/shared_link/shared_link_edit.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; @@ -103,15 +106,16 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], ), AutoRoute( - page: SearchRoute.page, + page: SearchInputRoute.page, guards: [_authGuard, _duplicateGuard], + maintainState: false, ), AutoRoute( - page: SharingRoute.page, + page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard], ), AutoRoute( - page: LibraryRoute.page, + page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard], ), ], @@ -137,7 +141,11 @@ class AppRouter extends RootStackRouter { AutoRoute(page: EditImageRoute.page), AutoRoute(page: CropImageRoute.page), AutoRoute(page: FilterImageRoute.page), - AutoRoute(page: FavoritesRoute.page, guards: [_authGuard, _duplicateGuard]), + CustomRoute( + page: FavoritesRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), AutoRoute(page: AllVideosRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute( page: AllMotionPhotosRoute.page, @@ -183,8 +191,16 @@ class AppRouter extends RootStackRouter { AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]), AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]), - AutoRoute(page: ArchiveRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute(page: PartnerRoute.page, guards: [_authGuard, _duplicateGuard]), + CustomRoute( + page: ArchiveRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: PartnerRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), AutoRoute( page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard], @@ -200,10 +216,15 @@ class AppRouter extends RootStackRouter { page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard], ), - AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]), - AutoRoute( + CustomRoute( + page: TrashRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, ), AutoRoute( page: SharedLinkEditRoute.page, @@ -232,6 +253,26 @@ class AppRouter extends RootStackRouter { page: HeaderSettingsRoute.page, guards: [_duplicateGuard], ), + CustomRoute( + page: PeopleCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: AlbumsRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: LocalAlbumsRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), + CustomRoute( + page: PlacesCollectionRoute.page, + guards: [_authGuard, _duplicateGuard], + transitionsBuilder: TransitionsBuilders.slideLeft, + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 4b27ab155fc31..f230fc3f38013 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -319,6 +319,25 @@ class AlbumViewerRouteArgs { } } +/// generated route for +/// [AlbumsPage] +class AlbumsRoute extends PageRouteInfo { + const AlbumsRoute({List? children}) + : super( + AlbumsRoute.name, + initialChildren: children, + ); + + static const String name = 'AlbumsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const AlbumsPage(); + }, + ); +} + /// generated route for /// [AllMotionPhotosPage] class AllMotionPhotosRoute extends PageRouteInfo { @@ -560,15 +579,13 @@ class ChangePasswordRoute extends PageRouteInfo { class CreateAlbumRoute extends PageRouteInfo { CreateAlbumRoute({ Key? key, - required bool isSharedAlbum, - List? initialAssets, + List? assets, List? children, }) : super( CreateAlbumRoute.name, args: CreateAlbumRouteArgs( key: key, - isSharedAlbum: isSharedAlbum, - initialAssets: initialAssets, + assets: assets, ), initialChildren: children, ); @@ -578,11 +595,11 @@ class CreateAlbumRoute extends PageRouteInfo { static PageInfo page = PageInfo( name, builder: (data) { - final args = data.argsAs(); + final args = data.argsAs( + orElse: () => const CreateAlbumRouteArgs()); return CreateAlbumPage( key: args.key, - isSharedAlbum: args.isSharedAlbum, - initialAssets: args.initialAssets, + assets: args.assets, ); }, ); @@ -591,19 +608,16 @@ class CreateAlbumRoute extends PageRouteInfo { class CreateAlbumRouteArgs { const CreateAlbumRouteArgs({ this.key, - required this.isSharedAlbum, - this.initialAssets, + this.assets, }); final Key? key; - final bool isSharedAlbum; - - final List? initialAssets; + final List? assets; @override String toString() { - return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum, initialAssets: $initialAssets}'; + return 'CreateAlbumRouteArgs{key: $key, assets: $assets}'; } } @@ -909,6 +923,25 @@ class LibraryRoute extends PageRouteInfo { ); } +/// generated route for +/// [LocalAlbumsPage] +class LocalAlbumsRoute extends PageRouteInfo { + const LocalAlbumsRoute({List? children}) + : super( + LocalAlbumsRoute.name, + initialChildren: children, + ); + + static const String name = 'LocalAlbumsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LocalAlbumsPage(); + }, + ); +} + /// generated route for /// [LoginPage] class LoginRoute extends PageRouteInfo { @@ -1111,6 +1144,25 @@ class PartnerRoute extends PageRouteInfo { ); } +/// generated route for +/// [PeopleCollectionPage] +class PeopleCollectionRoute extends PageRouteInfo { + const PeopleCollectionRoute({List? children}) + : super( + PeopleCollectionRoute.name, + initialChildren: children, + ); + + static const String name = 'PeopleCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PeopleCollectionPage(); + }, + ); +} + /// generated route for /// [PermissionOnboardingPage] class PermissionOnboardingRoute extends PageRouteInfo { @@ -1201,6 +1253,25 @@ class PhotosRoute extends PageRouteInfo { ); } +/// generated route for +/// [PlacesCollectionPage] +class PlacesCollectionRoute extends PageRouteInfo { + const PlacesCollectionRoute({List? children}) + : super( + PlacesCollectionRoute.name, + initialChildren: children, + ); + + static const String name = 'PlacesCollectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const PlacesCollectionPage(); + }, + ); +} + /// generated route for /// [RecentlyAddedPage] class RecentlyAddedRoute extends PageRouteInfo { @@ -1429,25 +1500,6 @@ class SharedLinkRoute extends PageRouteInfo { ); } -/// generated route for -/// [SharingPage] -class SharingRoute extends PageRouteInfo { - const SharingRoute({List? children}) - : super( - SharingRoute.name, - initialChildren: children, - ); - - static const String name = 'SharingRoute'; - - static PageInfo page = PageInfo( - name, - builder: (data) { - return const SharingPage(); - }, - ); -} - /// generated route for /// [SplashScreenPage] class SplashScreenRoute extends PageRouteInfo { diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index e16fecb32392a..35a2942973054 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -1,12 +1,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/memory.provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/providers/search/search_page_state.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -21,14 +19,6 @@ class TabNavigationObserver extends AutoRouterObserver { required this.ref, }); - @override - void didInitTabRoute(TabPageRoute route, TabPageRoute? previousRoute) { - // Perform tasks on first navigation to SearchRoute - if (route.name == 'SearchRoute') { - // ref.refresh(getCuratedLocationProvider); - } - } - @override Future didChangeTabRoute( TabPageRoute route, @@ -41,15 +31,6 @@ class TabNavigationObserver extends AutoRouterObserver { ref.invalidate(getAllPeopleProvider); } - if (route.name == 'SharingRoute') { - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - Future(() => ref.read(assetProvider.notifier).getAllAsset()); - } - - if (route.name == 'LibraryRoute') { - ref.read(albumProvider.notifier).getAllAlbums(); - } - if (route.name == 'HomeRoute') { ref.invalidate(memoryFutureProvider); Future(() => ref.read(assetProvider.notifier).getAllAsset()); diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 091049edb59f1..53a65e2869aea 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; @@ -152,7 +153,7 @@ class AlbumService { /// Checks remote albums (owned if `isShared` is false) for changes, /// updates the local database and returns `true` if there were any changes - Future refreshRemoteAlbums({required bool isShared}) async { + Future refreshRemoteAlbums() async { if (!_remoteCompleter.isCompleted) { // guard against concurrent calls return _remoteCompleter.future; @@ -162,12 +163,21 @@ class AlbumService { bool changes = false; try { await _userService.refreshUsers(); - final List serverAlbums = - await _albumApiRepository.getAll(shared: isShared ? true : null); - changes = await _syncService.syncRemoteAlbumsToDb( - serverAlbums, - isShared: isShared, + final List sharedAlbum = + await _albumApiRepository.getAll(shared: true); + + final List ownedAlbum = + await _albumApiRepository.getAll(shared: null); + + final albums = HashSet( + equals: (a, b) => a.remoteId == b.remoteId, + hashCode: (a) => a.remoteId.hashCode, ); + + albums.addAll(sharedAlbum); + albums.addAll(ownedAlbum); + + changes = await _syncService.syncRemoteAlbumsToDb(albums.toList()); } finally { _remoteCompleter.complete(changes); } @@ -213,9 +223,9 @@ class AlbumService { ); } - Future addAdditionalAssetToAlbum( - Iterable assets, + Future addAssets( Album album, + Iterable assets, ) async { try { final result = await _albumApiRepository.addAssets( @@ -234,7 +244,7 @@ class AlbumService { successfullyAdded: addedAssets.length, ); } catch (e) { - debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); + debugPrint("Error addAssets ${e.toString()}"); } return null; } @@ -253,30 +263,14 @@ class AlbumService { await _albumRepository.update(album); }); - Future addAdditionalUserToAlbum( - List sharedUserIds, - Album album, - ) async { - try { - final updatedAlbum = - await _albumApiRepository.addUsers(album.remoteId!, sharedUserIds); - await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum); - await _albumRepository.update(updatedAlbum); - return true; - } catch (e) { - debugPrint("Error addAdditionalUserToAlbum ${e.toString()}"); - } - return false; - } - - Future setActivityEnabled(Album album, bool enabled) async { + Future setActivityStatus(Album album, bool enabled) async { try { final updatedAlbum = await _albumApiRepository.update( album.remoteId!, activityEnabled: enabled, ); - await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum); - await _albumRepository.update(updatedAlbum); + album.activityEnabled = updatedAlbum.activityEnabled; + await _albumRepository.update(album); return true; } catch (e) { debugPrint("Error setActivityEnabled ${e.toString()}"); @@ -327,7 +321,7 @@ class AlbumService { } } - Future removeAssetFromAlbum( + Future removeAsset( Album album, Iterable assets, ) async { @@ -346,7 +340,7 @@ class AlbumService { return false; } - Future removeUserFromAlbum( + Future removeUser( Album album, User user, ) async { @@ -363,22 +357,44 @@ class AlbumService { await _albumRepository.update(a!); return true; - } catch (e) { - debugPrint("Error removeUserFromAlbum ${e.toString()}"); + } catch (error) { + debugPrint("Error removeUser ${error.toString()}"); return false; } } + Future addUsers( + Album album, + List userIds, + ) async { + try { + final updatedAlbum = + await _albumApiRepository.addUsers(album.remoteId!, userIds); + + album.sharedUsers.addAll(updatedAlbum.remoteUsers); + album.shared = true; + + await _albumRepository.addUsers(album, album.sharedUsers.toList()); + await _albumRepository.update(album); + + return true; + } catch (error) { + debugPrint("Error addUsers ${error.toString()}"); + } + return false; + } + Future changeTitleAlbum( Album album, String newAlbumTitle, ) async { try { - album = await _albumApiRepository.update( + final updatedAlbum = await _albumApiRepository.update( album.remoteId!, name: newAlbumTitle, ); - await _entityService.fillAlbumWithDatabaseEntities(album); + + album.name = updatedAlbum.name; await _albumRepository.update(album); return true; } catch (e) { @@ -405,4 +421,15 @@ class AlbumService { } } } + + Future> getAll() async { + return _albumRepository.getAll(remote: true); + } + + Future> search( + String searchTerm, + QuickFilterMode filterMode, + ) async { + return _albumRepository.search(searchTerm, filterMode); + } } diff --git a/mobile/lib/services/entity.service.dart b/mobile/lib/services/entity.service.dart index 8297620bc70e3..ddbe77f8c9493 100644 --- a/mobile/lib/services/entity.service.dart +++ b/mobile/lib/services/entity.service.dart @@ -32,6 +32,7 @@ class EntityService { .getByIds(album.remoteUsers.map((user) => user.id).toList()); album.sharedUsers.clear(); album.sharedUsers.addAll(users); + album.shared = true; } if (album.remoteAssets.isNotEmpty) { // replace all assets with assets from database diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index e7a192e7834ea..d691b006ad0d7 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -95,10 +95,9 @@ class SyncService { /// Syncs remote albums to the database /// returns `true` if there were any changes Future syncRemoteAlbumsToDb( - List remote, { - required bool isShared, - }) => - _lock.run(() => _syncRemoteAlbumsToDb(remote, isShared)); + List remote, + ) => + _lock.run(() => _syncRemoteAlbumsToDb(remote)); /// Syncs all device albums and their assets to the database /// Returns `true` if there were any changes @@ -310,17 +309,14 @@ class SyncService { /// returns `true` if there were any changes Future _syncRemoteAlbumsToDb( List remoteAlbums, - bool isShared, ) async { remoteAlbums.sortBy((e) => e.remoteId!); - final User me = await _userRepository.me(); final List dbAlbums = await _albumRepository.getAll( remote: true, - shared: isShared ? true : null, - ownerId: isShared ? null : me.isarId, sortBy: AlbumSort.remoteId, ); + final List toDelete = []; final List existing = []; @@ -335,7 +331,7 @@ class SyncService { onlySecond: (dbAlbum) => _removeAlbumFromDb(dbAlbum, toDelete), ); - if (isShared && toDelete.isNotEmpty) { + if (toDelete.isNotEmpty) { final List idsToRemove = sharedAssetsToRemove(toDelete, existing); if (idsToRemove.isNotEmpty) { await _assetRepository.deleteById(idsToRemove); diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index 0aac5b476efda..c0cf60514f04d 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -190,17 +190,14 @@ ThemeData getThemeData({required ColorScheme colorScheme}) { displayLarge: TextStyle( fontSize: 26, fontWeight: FontWeight.bold, - color: isDark ? Colors.white : primaryColor, ), displayMedium: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, - color: isDark ? Colors.white : Colors.black87, ), displaySmall: TextStyle( fontSize: 12, fontWeight: FontWeight.bold, - color: primaryColor, ), titleSmall: const TextStyle( fontSize: 16.0, @@ -241,7 +238,7 @@ ThemeData getThemeData({required ColorScheme colorScheme}) { isDark ? colorScheme.surfaceContainer : colorScheme.surface, labelTextStyle: const WidgetStatePropertyAll( TextStyle( - fontSize: 13, + fontSize: 14, fontWeight: FontWeight.w500, ), ), diff --git a/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart b/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart index 46fa0b1fe8ac1..6856ae184d038 100644 --- a/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart +++ b/mobile/lib/widgets/album/add_to_album_bottom_sheet.dart @@ -5,7 +5,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -27,13 +26,11 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final albumService = ref.watch(albumServiceProvider); - final sharedAlbums = ref.watch(sharedAlbumProvider); useEffect( () { // Fetch album updates, e.g., cover image - ref.read(albumProvider.notifier).getAllAlbums(); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + ref.read(albumProvider.notifier).refreshRemoteAlbums(); return null; }, @@ -41,9 +38,9 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { ); void addToAlbum(Album album) async { - final result = await albumService.addAdditionalAssetToAlbum( - assets, + final result = await albumService.addAssets( album, + assets, ); if (result != null) { @@ -107,8 +104,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { onPressed: () { context.pushRoute( CreateAlbumRoute( - isSharedAlbum: false, - initialAssets: assets, + assets: assets, ), ); }, @@ -123,7 +119,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 16), sliver: AddToAlbumSliverList( albums: albums, - sharedAlbums: sharedAlbums, + sharedAlbums: albums.where((a) => a.shared).toList(), onAddToAlbum: addToAlbum, ), ), diff --git a/mobile/lib/widgets/album/album_thumbnail_card.dart b/mobile/lib/widgets/album/album_thumbnail_card.dart index 42fa55cdd4459..b728f2b5415fe 100644 --- a/mobile/lib/widgets/album/album_thumbnail_card.dart +++ b/mobile/lib/widgets/album/album_thumbnail_card.dart @@ -12,12 +12,14 @@ class AlbumThumbnailCard extends StatelessWidget { /// Whether or not to show the owner of the album (or "Owned") /// in the subtitle of the album final bool showOwner; + final bool showTitle; const AlbumThumbnailCard({ super.key, required this.album, this.onTap, this.showOwner = false, + this.showTitle = true, }); final Album album; @@ -76,7 +78,7 @@ class AlbumThumbnailCard extends StatelessWidget { : 'album_thumbnail_card_items' .tr(args: ['${album.assetCount}']), ), - if (owner != null) const TextSpan(text: ' · '), + if (owner != null) const TextSpan(text: ' • '), if (owner != null) TextSpan(text: owner), ], ), @@ -102,21 +104,23 @@ class AlbumThumbnailCard extends StatelessWidget { : buildAlbumThumbnail(), ), ), - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: SizedBox( - width: cardSize, - child: Text( - album.name, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colorScheme.onSurface, - fontWeight: FontWeight.w500, + if (showTitle) ...[ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SizedBox( + width: cardSize, + child: Text( + album.name, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleSmall?.copyWith( + color: context.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), ), ), ), - ), - buildAlbumTextRow(), + buildAlbumTextRow(), + ], ], ), ), diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index 1067d7241e3e4..89528cc4da365 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -7,7 +7,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_viewer.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -46,10 +45,8 @@ class AlbumViewerAppbar extends HookConsumerWidget final bool success; if (album.shared) { - success = - await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album); - context - .navigateTo(const TabControllerRoute(children: [SharingRoute()])); + success = await ref.watch(albumProvider.notifier).deleteAlbum(album); + context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); } else { success = await ref.watch(albumProvider.notifier).deleteAlbum(album); context @@ -113,11 +110,10 @@ class AlbumViewerAppbar extends HookConsumerWidget isProcessing.value = true; bool isSuccess = - await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(album); + await ref.watch(albumProvider.notifier).leaveAlbum(album); if (isSuccess) { - context - .navigateTo(const TabControllerRoute(children: [SharingRoute()])); + context.navigateTo(TabControllerRoute(children: [AlbumsRoute()])); } else { context.pop(); ImmichToast.show( diff --git a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart index e6d769a3d7aa2..ec054d08ee131 100644 --- a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart +++ b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; @@ -72,7 +71,8 @@ class ControlBottomAppBar extends HookConsumerWidget { final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); - final sharedAlbums = ref.watch(sharedAlbumProvider); + final sharedAlbums = + ref.watch(albumProvider).where((a) => a.shared).toList(); const bottomPadding = 0.20; final scrollController = useDraggableScrollController(); diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 14678903ba298..eeecfa9b58435 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -9,7 +9,6 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/stack.service.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; @@ -272,11 +271,10 @@ class MultiselectGrid extends HookConsumerWidget { if (assets.isEmpty) { return; } - final result = - await ref.read(albumServiceProvider).addAdditionalAssetToAlbum( - assets, - album, - ); + final result = await ref.read(albumServiceProvider).addAssets( + album, + assets, + ); if (result != null) { if (result.alreadyInAlbum.isNotEmpty) { @@ -323,8 +321,7 @@ class MultiselectGrid extends HookConsumerWidget { .createAlbumWithGeneratedName(assets); if (result != null) { - ref.watch(albumProvider.notifier).getAllAlbums(); - ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); + ref.watch(albumProvider.notifier).refreshRemoteAlbums(); selectionEnabledHook.value = false; context.pushRoute(AlbumViewerRoute(albumId: result.id)); diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index c3f1390dba04a..f550857b9d867 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -6,8 +6,8 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; -import 'package:immich_mobile/providers/album/shared_album.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; @@ -230,9 +230,7 @@ class BottomGalleryBar extends ConsumerWidget { handleRemoveFromAlbum() async { final album = ref.read(currentAlbumProvider); final bool isSuccess = album != null && - await ref - .read(sharedAlbumProvider.notifier) - .removeAssetFromAlbum(album, [asset]); + await ref.read(albumProvider.notifier).removeAsset(album, [asset]); if (isSuccess) { // Workaround for asset remaining in the gallery diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 8e2465fc9ca3d..1831a2d1689ab 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -18,9 +18,10 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); - final Widget? action; + final List? actions; + final bool showUploadButton; - const ImmichAppBar({super.key, this.action}); + const ImmichAppBar({super.key, this.actions, this.showUploadButton = true}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -184,12 +185,18 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { }, ), actions: [ - if (action != null) - Padding(padding: const EdgeInsets.only(right: 20), child: action!), - Padding( - padding: const EdgeInsets.only(right: 20), - child: buildBackupIndicator(), - ), + if (actions != null) + ...actions!.map( + (action) => Padding( + padding: const EdgeInsets.only(right: 16), + child: action, + ), + ), + if (showUploadButton) + Padding( + padding: const EdgeInsets.only(right: 20), + child: buildBackupIndicator(), + ), Padding( padding: const EdgeInsets.only(right: 20), child: buildProfileIndicator(), diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 01b717ef5b977..46e86718583df 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -176,7 +176,7 @@ class LoginForm extends HookConsumerWidget { populateTestLoginInfo1() { usernameController.text = 'testuser@email.com'; passwordController.text = 'password'; - serverEndpointController.text = 'http://192.168.1.16:2283/api'; + serverEndpointController.text = 'http://192.168.1.118:2283/api'; } login() async { diff --git a/mobile/lib/widgets/partner/partner_list.dart b/mobile/lib/widgets/partner/partner_list.dart deleted file mode 100644 index 53a27c48abad7..0000000000000 --- a/mobile/lib/widgets/partner/partner_list.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/user.entity.dart'; -import 'package:immich_mobile/widgets/common/user_avatar.dart'; - -class PartnerList extends HookConsumerWidget { - const PartnerList({super.key, required this.partner}); - - final List partner; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return SliverList( - delegate: - SliverChildBuilderDelegate(listEntry, childCount: partner.length), - ); - } - - Widget listEntry(BuildContext context, int index) { - final User p = partner[index]; - return ListTile( - contentPadding: const EdgeInsets.only( - left: 12.0, - right: 18.0, - ), - leading: userAvatar(context, p, radius: 24), - title: Text( - "partner_list_user_photos", - style: context.textTheme.labelLarge, - ).tr( - namedArgs: { - 'user': p.name, - }, - ), - trailing: Text( - "partner_list_view_all", - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ).tr(), - onTap: () => context.pushRoute((PartnerDetailRoute(partner: p))), - ); - } -} diff --git a/mobile/lib/widgets/search/search_map_thumbnail.dart b/mobile/lib/widgets/search/search_map_thumbnail.dart index 20747913fb14a..b4a12ab82634b 100644 --- a/mobile/lib/widgets/search/search_map_thumbnail.dart +++ b/mobile/lib/widgets/search/search_map_thumbnail.dart @@ -13,6 +13,7 @@ class SearchMapThumbnail extends StatelessWidget { }); final double size; + final bool showTitle = true; @override Widget build(BuildContext context) { diff --git a/mobile/test/services/album.service_test.dart b/mobile/test/services/album.service_test.dart index fb46dceed592f..848d7cfad7078 100644 --- a/mobile/test/services/album.service_test.dart +++ b/mobile/test/services/album.service_test.dart @@ -79,25 +79,35 @@ void main() { verifyNoMoreInteractions(syncService); }); }); + group('refreshRemoteAlbums', () { - test('isShared: false', () async { + test('is working', () async { when(() => userService.refreshUsers()).thenAnswer((_) async => true); + when(() => albumApiRepository.getAll(shared: true)) + .thenAnswer((_) async => [AlbumStub.sharedWithUser]); + when(() => albumApiRepository.getAll(shared: null)) .thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]); + when( - () => syncService.syncRemoteAlbumsToDb( - [AlbumStub.oneAsset, AlbumStub.twoAsset], - isShared: false, - ), + () => syncService.syncRemoteAlbumsToDb([ + AlbumStub.twoAsset, + AlbumStub.oneAsset, + AlbumStub.sharedWithUser, + ]), ).thenAnswer((_) async => true); - final result = await sut.refreshRemoteAlbums(isShared: false); + final result = await sut.refreshRemoteAlbums(); expect(result, true); verify(() => userService.refreshUsers()).called(1); + verify(() => albumApiRepository.getAll(shared: true)).called(1); verify(() => albumApiRepository.getAll(shared: null)).called(1); verify( () => syncService.syncRemoteAlbumsToDb( - [AlbumStub.oneAsset, AlbumStub.twoAsset], - isShared: false, + [ + AlbumStub.twoAsset, + AlbumStub.oneAsset, + AlbumStub.sharedWithUser, + ], ), ).called(1); verifyNoMoreInteractions(userService); @@ -166,9 +176,9 @@ void main() { () => albumRepository.update(AlbumStub.oneAsset), ).thenAnswer((_) async => AlbumStub.oneAsset); - final result = await sut.addAdditionalAssetToAlbum( - [AssetStub.image1, AssetStub.image2], + final result = await sut.addAssets( AlbumStub.oneAsset, + [AssetStub.image1, AssetStub.image2], ); expect(result != null, true); @@ -185,18 +195,23 @@ void main() { ).thenAnswer( (_) async => AlbumStub.sharedWithUser, ); + when( - () => entityService - .fillAlbumWithDatabaseEntities(AlbumStub.sharedWithUser), - ).thenAnswer((_) async => AlbumStub.sharedWithUser); + () => albumRepository.addUsers( + AlbumStub.emptyAlbum, + AlbumStub.emptyAlbum.sharedUsers.toList(), + ), + ).thenAnswer((_) async => AlbumStub.emptyAlbum); + when( - () => albumRepository.update(AlbumStub.sharedWithUser), - ).thenAnswer((_) async => AlbumStub.sharedWithUser); + () => albumRepository.update(AlbumStub.emptyAlbum), + ).thenAnswer((_) async => AlbumStub.emptyAlbum); - final result = await sut.addAdditionalUserToAlbum( - [UserStub.user2.id], + final result = await sut.addUsers( AlbumStub.emptyAlbum, + [UserStub.user2.id], ); + expect(result, true); }); }); From 53358c768cae41e7e6913b488a448c2a37b1420f Mon Sep 17 00:00:00 2001 From: Yashraj Jain Date: Thu, 10 Oct 2024 14:22:12 +0530 Subject: [PATCH 03/13] fix(mobile): trash sorting order (#13299) * fix: trash sorting order * updated after comparing with web --- mobile/lib/providers/trash.provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/providers/trash.provider.dart b/mobile/lib/providers/trash.provider.dart index 45ab1a518583e..8bbac853c7bab 100644 --- a/mobile/lib/providers/trash.provider.dart +++ b/mobile/lib/providers/trash.provider.dart @@ -167,6 +167,6 @@ final trashedAssetsProvider = StreamProvider((ref) { .filter() .ownerIdEqualTo(user.isarId) .isTrashedEqualTo(true) - .sortByFileCreatedAt(); + .sortByFileCreatedAtDesc(); return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none); }); From 1baa49edb7c4e987e45fd703f17d38f887a0b8bd Mon Sep 17 00:00:00 2001 From: Pranav tiwari <67794060+Pranav-8bit@users.noreply.github.com> Date: Thu, 10 Oct 2024 14:26:08 +0530 Subject: [PATCH 04/13] fix(web): User removal from option menu on the top in shared album (#12959) * bug fix * added few more type hint * onMount removed, removed current user to user * user check removed and conflict in view mode resolved between option and share info modal * format fix --------- Co-authored-by: Alex --- .../album-page/album-options.svelte | 145 ++++++++++++------ .../[[assetId=id]]/+page.svelte | 9 +- 2 files changed, 106 insertions(+), 48 deletions(-) diff --git a/web/src/lib/components/album-page/album-options.svelte b/web/src/lib/components/album-page/album-options.svelte index ebcf835649070..53fd355c4b0db 100644 --- a/web/src/lib/components/album-page/album-options.svelte +++ b/web/src/lib/components/album-page/album-options.svelte @@ -1,7 +1,13 @@ - -

-
-

{$t('settings').toUpperCase()}

-
- {#if order} - +
+
+

{$t('settings').toUpperCase()}

+
+ {#if order} + + {/if} + - {/if} - -
-
-
-
{$t('people').toUpperCase()}
-
- -
-
- -
-
{user.name}
-
{$t('owner')}
- {#each album.albumUsers as { user } (user.id)} -
+
+
+
{$t('people').toUpperCase()}
+
+ + +
{user.name}
+
{$t('owner')}
- {/each} + + {#each album.albumUsers as { user } (user.id)} +
+
+ +
+
{user.name}
+ {#if user.id !== album.ownerId} + + + handleMenuRemove(user)} text={$t('remove')} /> + + {/if} +
+ {/each} +
-
- + +{/if} + +{#if selectedRemoveUser} + (selectedRemoveUser = null)} + /> +{/if} diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 3df4a25b83e1f..fdf21a2bb82cd 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -345,7 +345,7 @@ } }; - const handleRemoveUser = async (userId: string) => { + const handleRemoveUser = async (userId: string, nextViewMode: ViewMode) => { if (userId == 'me' || userId === $user.id) { await goto(backUrl); return; @@ -353,7 +353,9 @@ try { await refreshAlbum(); - viewMode = album.albumUsers.length > 0 ? ViewMode.VIEW_USERS : ViewMode.VIEW; + + // Dynamically set the view mode based on the passed argument + viewMode = album.albumUsers.length > 0 ? nextViewMode : ViewMode.VIEW; } catch (error) { handleError(error, $t('errors.error_deleting_shared_user')); } @@ -730,7 +732,7 @@ (viewMode = ViewMode.VIEW)} {album} - onRemove={handleRemoveUser} + onRemove={(userId) => handleRemoveUser(userId, ViewMode.VIEW_USERS)} onRefreshAlbum={refreshAlbum} /> {/if} @@ -744,6 +746,7 @@ albumOrder = order; await setModeToView(); }} + onRemove={(userId) => handleRemoveUser(userId, ViewMode.OPTIONS)} onClose={() => (viewMode = ViewMode.VIEW)} onToggleEnabledActivity={handleToggleEnableActivity} onShowSelectSharedUser={() => (viewMode = ViewMode.SELECT_USERS)} From 4ce49e4666fea2f183913ee2e02dfb8460c610a9 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 10 Oct 2024 16:53:20 +0700 Subject: [PATCH 05/13] chore(mobile): clean up (#13338) --- mobile/lib/providers/album/album.provider.dart | 5 ----- mobile/lib/services/sync.service.dart | 3 +-- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index 943671f1885ad..53c8855c0a9cf 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -30,11 +30,6 @@ class AlbumNotifier extends StateNotifier> { late final StreamSubscription> _streamSub; Future refreshRemoteAlbums() async { - final isRefresing = - ref.read(isRefreshingRemoteAlbumProvider.notifier).state; - - if (isRefresing) return; - ref.read(isRefreshingRemoteAlbumProvider.notifier).state = true; await _albumService.refreshRemoteAlbums(); ref.read(isRefreshingRemoteAlbumProvider.notifier).state = false; diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index d691b006ad0d7..f1a6e9b0d7365 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -797,8 +797,7 @@ class SyncService { assets.sort(Asset.compareByOwnerChecksumCreatedModified); assets.uniqueConsecutive( compare: Asset.compareByOwnerChecksum, - onDuplicate: (a, b) => - _log.info("Ignoring duplicate assets on device:\n$a\n$b"), + onDuplicate: (a, b) => {}, ); final int duplicates = before - assets.length; if (duplicates > 0) { From ee461e59106807aff8d2d8bd6dce3977f95584a5 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 10 Oct 2024 16:53:33 +0700 Subject: [PATCH 06/13] chore(mobile): remove unused translation keys (#13335) --- mobile/assets/i18n/en-US.json | 1143 ++++++++++++--------------- mobile/scripts/check_i18n_keys.py | 18 +- mobile/scripts/check_key_uniform.py | 31 - 3 files changed, 532 insertions(+), 660 deletions(-) delete mode 100644 mobile/scripts/check_key_uniform.py diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 5938bc6ff17d1..0b36207a7e4b7 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,624 +1,521 @@ { - "all": "All", - "shared_with_me": "Shared with me", - "my_albums": "My albums", - "create_new": "CREATE NEW", - "create_album": "Create album", - "videos": "Videos", - "recently_added": "Recently added", - "partners": "Partners", - "partner_page_title": "Partners", - "library": "Library", - "on_this_device": "On this device", - "add_a_name": "Add a name", - "places": "Places", - "albums": "Albums", - "people": "People", - "shared_links": "Shared links", - "trash": "Trash", - "archived": "Archived", - "favorites": "Favorites", - "search_albums": "Search albums", - "action_common_back": "Back", - "action_common_cancel": "Cancel", - "action_common_clear": "Clear", - "action_common_confirm": "Confirm", - "action_common_save": "Save", - "action_common_select": "Select", - "action_common_update": "Update", - "add_to_album_bottom_sheet_added": "Added to {album}", - "add_to_album_bottom_sheet_already_exists": "Already in {album}", - "advanced_settings_log_level_title": "Log level: {}", - "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", - "advanced_settings_prefer_remote_title": "Prefer remote images", - "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", - "advanced_settings_proxy_headers_title": "Proxy Headers", - "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", - "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", - "advanced_settings_tile_subtitle": "Advanced user's settings", - "advanced_settings_tile_title": "Advanced", - "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", - "advanced_settings_troubleshooting_title": "Troubleshooting", - "album_info_card_backup_album_excluded": "EXCLUDED", - "album_info_card_backup_album_included": "INCLUDED", - "album_thumbnail_card_item": "1 item", - "album_thumbnail_card_items": "{} items", - "album_thumbnail_card_shared": " · Shared", - "album_thumbnail_owned": "Owned", - "album_thumbnail_shared_by": "Shared by {}", - "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", - "album_viewer_appbar_share_delete": "Delete album", - "album_viewer_appbar_share_err_delete": "Failed to delete album", - "album_viewer_appbar_share_err_leave": "Failed to leave album", - "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", - "album_viewer_appbar_share_err_title": "Failed to change album title", - "album_viewer_appbar_share_leave": "Leave album", - "album_viewer_appbar_share_remove": "Remove from album", - "album_viewer_appbar_share_to": "Share To", - "album_viewer_page_share_add_users": "Add users", - "all_people_page_title": "People", - "all_videos_page_title": "Videos", - "app_bar_signout_dialog_content": "Are you sure you want to sign out?", - "app_bar_signout_dialog_ok": "Yes", - "app_bar_signout_dialog_title": "Sign out", - "archive_page_no_archived_assets": "No archived assets found", - "archive_page_title": "Archive ({})", - "asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", - "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", - "asset_list_group_by_sub_title": "Group by", - "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", - "asset_list_layout_settings_group_automatically": "Automatic", - "asset_list_layout_settings_group_by": "Group assets by", - "asset_list_layout_settings_group_by_month": "Month", - "asset_list_layout_settings_group_by_month_day": "Month + day", - "asset_list_layout_sub_title": "Layout", - "asset_list_settings_subtitle": "Photo grid layout settings", - "asset_list_settings_title": "Photo Grid", - "asset_restored_successfully": "Asset restored successfully", - "assets_deleted_permanently": "{} asset(s) deleted permanently", - "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", - "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", - "assets_restored_successfully": "{} asset(s) restored successfully", - "assets_trashed": "{} asset(s) trashed", - "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", - "asset_viewer_settings_title": "Asset Viewer", - "backup_album_selection_page_albums_device": "Albums on device ({})", - "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", - "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", - "backup_album_selection_page_select_albums": "Select albums", - "backup_album_selection_page_selection_info": "Selection Info", - "backup_album_selection_page_total_assets": "Total unique assets", - "backup_all": "All", - "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", - "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", - "backup_background_service_current_upload_notification": "Uploading {}", - "backup_background_service_default_notification": "Checking for new assets…", - "backup_background_service_error_title": "Backup error", - "backup_background_service_in_progress_notification": "Backing up your assets…", - "backup_background_service_upload_failure_notification": "Failed to upload {}", - "backup_controller_page_albums": "Backup Albums", - "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", - "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", - "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", - "backup_controller_page_background_battery_info_link": "Show me how", - "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", - "backup_controller_page_background_battery_info_ok": "OK", - "backup_controller_page_background_battery_info_title": "Battery optimizations", - "backup_controller_page_background_charging": "Only while charging", - "backup_controller_page_background_configure_error": "Failed to configure the background service", - "backup_controller_page_background_delay": "Delay new assets backup: {}", - "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", - "backup_controller_page_background_is_off": "Automatic background backup is off", - "backup_controller_page_background_is_on": "Automatic background backup is on", - "backup_controller_page_background_turn_off": "Turn off background service", - "backup_controller_page_background_turn_on": "Turn on background service", - "ignore_icloud_photos": "Ignore iCloud photos", - "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", - "backup_controller_page_background_wifi": "Only on WiFi", - "backup_controller_page_backup": "Backup", - "backup_controller_page_backup_selected": "Selected: ", - "backup_controller_page_backup_sub": "Backed up photos and videos", - "backup_controller_page_cancel": "Cancel", - "backup_controller_page_created": "Created on: {}", - "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", - "backup_controller_page_excluded": "Excluded: ", - "backup_controller_page_failed": "Failed ({})", - "backup_controller_page_filename": "File name: {} [{}]", - "backup_controller_page_id": "ID: {}", - "backup_controller_page_info": "Backup Information", - "backup_controller_page_none_selected": "None selected", - "backup_controller_page_remainder": "Remainder", - "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", - "backup_controller_page_select": "Select", - "backup_controller_page_server_storage": "Server Storage", - "backup_controller_page_start_backup": "Start Backup", - "backup_controller_page_status_off": "Automatic foreground backup is off", - "backup_controller_page_status_on": "Automatic foreground backup is on", - "backup_controller_page_storage_format": "{} of {} used", - "backup_controller_page_to_backup": "Albums to be backed up", - "backup_controller_page_total": "Total", - "backup_controller_page_total_sub": "All unique photos and videos from selected albums", - "backup_controller_page_turn_off": "Turn off foreground backup", - "backup_controller_page_turn_on": "Turn on foreground backup", - "backup_controller_page_uploading_file_info": "Uploading file info", - "backup_err_only_album": "Cannot remove the only album", - "backup_info_card_assets": "assets", - "backup_manual_cancelled": "Cancelled", - "backup_manual_failed": "Failed", - "backup_manual_in_progress": "Upload already in progress. Try after sometime", - "backup_manual_success": "Success", - "backup_manual_title": "Upload status", - "backup_options_page_title": "Backup options", - "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", - "cache_settings_clear_cache_button": "Clear cache", - "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", - "cache_settings_duplicated_assets_clear_button": "CLEAR", - "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", - "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", - "cache_settings_image_cache_size": "Image cache size ({} assets)", - "cache_settings_statistics_album": "Library thumbnails", - "cache_settings_statistics_assets": "{} assets ({})", - "cache_settings_statistics_full": "Full images", - "cache_settings_statistics_shared": "Shared album thumbnails", - "cache_settings_statistics_thumbnail": "Thumbnails", - "cache_settings_statistics_title": "Cache usage", - "cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application", - "cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)", - "cache_settings_tile_subtitle": "Control the local storage behaviour", - "cache_settings_tile_title": "Local Storage", - "cache_settings_title": "Caching Settings", - "change_password_form_confirm_password": "Confirm Password", - "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", - "change_password_form_new_password": "New Password", - "change_password_form_password_mismatch": "Passwords do not match", - "change_password_form_reenter_new_password": "Re-enter New Password", - "client_cert_dialog_msg_confirm": "OK", - "client_cert_enter_password": "Enter Password", - "client_cert_import": "Import", - "client_cert_import_success_msg": "Client certificate is imported", - "client_cert_invalid_msg": "Invalid certificate file or wrong password", - "client_cert_remove": "Remove", - "client_cert_remove_msg": "Client certificate is removed", - "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", - "client_cert_title": "SSL Client Certificate", - "common_add_to_album": "Add to album", - "common_change_password": "Change Password", - "common_create_new_album": "Create new album", - "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", - "common_shared": "Shared", - "contextual_search": "Sunrise on the beach", - "control_bottom_app_bar_add_to_album": "Add to album", - "control_bottom_app_bar_album_info": "{} items", - "control_bottom_app_bar_album_info_shared": "{} items · Shared", - "control_bottom_app_bar_archive": "Archive", - "control_bottom_app_bar_create_new_album": "Create new album", - "control_bottom_app_bar_delete": "Delete", - "control_bottom_app_bar_delete_from_immich": "Delete from Immich", - "control_bottom_app_bar_delete_from_local": "Delete from device", - "control_bottom_app_bar_download": "Download", - "control_bottom_app_bar_edit": "Edit", - "control_bottom_app_bar_edit_location": "Edit Location", - "control_bottom_app_bar_edit_time": "Edit Date & Time", - "control_bottom_app_bar_favorite": "Favorite", - "control_bottom_app_bar_share": "Share", - "control_bottom_app_bar_share_to": "Share To", - "control_bottom_app_bar_stack": "Stack", - "control_bottom_app_bar_trash_from_immich": "Move to Trash", - "control_bottom_app_bar_unarchive": "Unarchive", - "control_bottom_app_bar_unfavorite": "Unfavorite", - "control_bottom_app_bar_upload": "Upload", - "create_album_page_untitled": "Untitled", - "create_shared_album_page_create": "Create", - "create_shared_album_page_share": "Share", - "create_shared_album_page_share_add_assets": "ADD ASSETS", - "create_shared_album_page_share_select_photos": "Select Photos", - "crop": "Crop", - "curated_location_page_title": "Places", - "curated_object_page_title": "Things", - "daily_title_text_date": "E, MMM dd", - "daily_title_text_date_year": "E, MMM dd, yyyy", - "date_format": "E, LLL d, y • h:mm a", - "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", - "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", - "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", - "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", - "delete_dialog_cancel": "Cancel", - "delete_dialog_ok": "Delete", - "delete_dialog_ok_force": "Delete Anyway", - "delete_dialog_title": "Delete Permanently", - "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", - "delete_local_dialog_ok_force": "Delete Anyway", - "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", - "delete_shared_link_dialog_title": "Delete Shared Link", - "description_input_hint_text": "Add description...", - "description_input_submit_error": "Error updating description, check the log for more details", - "download_error": "Download Error", - "download_started": "Download started", - "download_sucess": "Download success", - "download_sucess_android": "The media has been downloaded to DCIM/Immich", - "edit_date_time_dialog_date_time": "Date and Time", - "edit_date_time_dialog_timezone": "Timezone", - "edit_image_title": "Edit", - "edit_location_dialog_title": "Location", - "error_saving_image": "Error: {}", - "exif_bottom_sheet_description": "Add Description...", - "exif_bottom_sheet_details": "DETAILS", - "exif_bottom_sheet_location": "LOCATION", - "exif_bottom_sheet_location_add": "Add a location", - "exif_bottom_sheet_people": "PEOPLE", - "exif_bottom_sheet_person_add_person": "Add name", - "experimental_settings_new_asset_list_subtitle": "Work in progress", - "experimental_settings_new_asset_list_title": "Enable experimental photo grid", - "experimental_settings_subtitle": "Use at your own risk!", - "experimental_settings_title": "Experimental", - "favorites_page_no_favorites": "No favorite assets found", - "favorites_page_title": "Favorites", - "filename_search": "File name or extension", - "haptic_feedback_switch": "Enable haptic feedback", - "haptic_feedback_title": "Haptic Feedback", - "header_settings_add_header_tip": "Add Header", - "header_settings_field_validator_msg": "Value cannot be empty", - "header_settings_header_name_input": "Header name", - "header_settings_header_value_input": "Header value", - "header_settings_page_title": "Proxy Headers", - "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", - "headers_settings_tile_title": "Custom proxy headers", - "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", - "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", - "home_page_add_to_album_success": "Added {added} assets to album {album}.", - "home_page_album_err_partner": "Can not add partner assets to an album yet, skipping", - "home_page_archive_err_local": "Can not archive local assets yet, skipping", - "home_page_archive_err_partner": "Can not archive partner assets, skipping", - "home_page_building_timeline": "Building the timeline", - "home_page_delete_err_partner": "Can not delete partner assets, skipping", - "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", - "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", - "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", - "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", - "home_page_share_err_local": "Can not share local assets via link, skipping", - "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", - "image_saved_successfully": "Image saved", - "image_viewer_page_state_provider_download_error": "Download Error", - "image_viewer_page_state_provider_download_started": "Download Started", - "image_viewer_page_state_provider_download_success": "Download Success", - "image_viewer_page_state_provider_share_error": "Share Error", - "invalid_date": "Invalid date", - "invalid_date_format": "Invalid date format", - "library_page_albums": "Albums", - "library_page_archive": "Archive", - "library_page_device_albums": "Albums on Device", - "library_page_favorites": "Favorites", - "library_page_new_album": "New album", - "library_page_sharing": "Sharing", - "library_page_sort_asset_count": "Number of assets", - "library_page_sort_created": "Created date", - "library_page_sort_last_modified": "Last modified", - "library_page_sort_most_oldest_photo": "Oldest photo", - "library_page_sort_most_recent_photo": "Most recent photo", - "library_page_sort_title": "Album title", - "location_picker_choose_on_map": "Choose on map", - "location_picker_latitude": "Latitude", - "location_picker_latitude_error": "Enter a valid latitude", - "location_picker_latitude_hint": "Enter your latitude here", - "location_picker_longitude": "Longitude", - "location_picker_longitude_error": "Enter a valid longitude", - "location_picker_longitude_hint": "Enter your longitude here", - "login_disabled": "Login has been disabled", - "login_form_api_exception": "API exception. Please check the server URL and try again.", - "login_form_back_button_text": "Back", - "login_form_button_text": "Login", - "login_form_email_hint": "youremail@email.com", - "login_form_endpoint_hint": "http://your-server-ip:port/api", - "login_form_endpoint_url": "Server Endpoint URL", - "login_form_err_http": "Please specify http:// or https://", - "login_form_err_invalid_email": "Invalid Email", - "login_form_err_invalid_url": "Invalid URL", - "login_form_err_leading_whitespace": "Leading whitespace", - "login_form_err_trailing_whitespace": "Trailing whitespace", - "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", - "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", - "login_form_failed_login": "Error logging you in, check server URL, email and password", - "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", - "login_form_label_email": "Email", - "login_form_label_password": "Password", - "login_form_next_button": "Next", - "login_form_password_hint": "password", - "login_form_save_login": "Stay logged in", - "login_form_server_empty": "Enter a server URL.", - "login_form_server_error": "Could not connect to server.", - "login_password_changed_error": "There was an error updating your password", - "login_password_changed_success": "Password updated successfully", - "map_assets_in_bound": "{} photo", - "map_assets_in_bounds": "{} photos", - "map_cannot_get_user_location": "Cannot get user's location", - "map_location_dialog_cancel": "Cancel", - "map_location_dialog_yes": "Yes", - "map_location_picker_page_use_location": "Use this location", - "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", - "map_location_service_disabled_title": "Location Service disabled", - "map_no_assets_in_bounds": "No photos in this area", - "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", - "map_no_location_permission_title": "Location Permission denied", - "map_settings_dark_mode": "Dark mode", - "map_settings_date_range_option_all": "All", - "map_settings_date_range_option_day": "Past 24 hours", - "map_settings_date_range_option_days": "Past {} days", - "map_settings_date_range_option_year": "Past year", - "map_settings_date_range_option_years": "Past {} years", - "map_settings_dialog_cancel": "Cancel", - "map_settings_dialog_save": "Save", - "map_settings_dialog_title": "Map Settings", - "map_settings_include_show_archived": "Include Archived", - "map_settings_include_show_partners": "Include Partners", - "map_settings_only_relative_range": "Date range", - "map_settings_only_show_favorites": "Show Favorite Only", - "map_settings_theme_settings": "Map Theme", - "map_zoom_to_see_photos": "Zoom out to see photos", - "memories_all_caught_up": "All caught up", - "memories_check_back_tomorrow": "Check back tomorrow for more memories", - "memories_start_over": "Start Over", - "memories_swipe_to_close": "Swipe up to close", - "memories_year_ago": "A year ago", - "memories_years_ago": "{} years ago", - "monthly_title_text_date_format": "MMMM y", - "motion_photos_page_title": "Motion Photos", - "multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", - "multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", - "no_assets_to_show": "No assets to show", - "no_name": "No name", - "notification_permission_dialog_cancel": "Cancel", - "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", - "notification_permission_dialog_settings": "Settings", - "notification_permission_list_tile_content": "Grant permission to enable notifications.", - "notification_permission_list_tile_enable_button": "Enable Notifications", - "notification_permission_list_tile_title": "Notification Permission", - "partner_list_user_photos": "{user}'s photos", - "partner_page_add_partner": "Add partner", - "partner_page_empty_message": "Your photos are not yet shared with any partner.", - "partner_page_no_more_users": "No more users to add", - "partner_page_partner_add_failed": "Failed to add partner", - "partner_page_select_partner": "Select partner", - "partner_page_shared_to_title": "Shared to", - "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", - "partner_page_stop_sharing_title": "Stop sharing your photos?", - "permission_onboarding_back": "Back", - "permission_onboarding_continue_anyway": "Continue anyway", - "permission_onboarding_get_started": "Get started", - "permission_onboarding_go_to_settings": "Go to settings", - "permission_onboarding_grant_permission": "Grant permission", - "permission_onboarding_log_out": "Log out", - "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", - "permission_onboarding_permission_granted": "Permission granted! You are all set.", - "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", - "permission_onboarding_request": "Immich requires permission to view your photos and videos.", - "preferences_settings_title": "Preferences", - "profile_drawer_app_logs": "Logs", - "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", - "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", - "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", - "profile_drawer_documentation": "Documentation", - "profile_drawer_github": "GitHub", - "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", - "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", - "profile_drawer_settings": "Settings", - "profile_drawer_sign_out": "Sign Out", - "profile_drawer_trash": "Trash", - "recently_added_page_title": "Recently Added", - "save_to_gallery": "Save to gallery", - "scaffold_body_error_occurred": "Error occurred", - "search_bar_hint": "Search your photos", - "search_filter_apply": "Apply filter", - "search_filter_camera": "Camera", - "search_filter_camera_make": "Make", - "search_filter_camera_model": "Model", - "search_filter_camera_title": "Select camera type", - "search_filter_date": "Date", - "search_filter_date_interval": "{start} to {end}", - "search_filter_date_title": "Select a date range", - "search_filter_display_option_archive": "Archive", - "search_filter_display_option_favorite": "Favorite", - "search_filter_display_option_not_in_album": "Not in album", - "search_filter_display_options": "Display Options", - "search_filter_display_options_title": "Display options", - "search_filter_location": "Location", - "search_filter_location_city": "City", - "search_filter_location_country": "Country", - "search_filter_location_state": "State", - "search_filter_location_title": "Select location", - "search_filter_media_type": "Media Type", - "search_filter_media_type_all": "All", - "search_filter_media_type_image": "Image", - "search_filter_media_type_title": "Select media type", - "search_filter_media_type_video": "Video", - "search_filter_people": "People", - "search_filter_people_title": "Select people", - "search_page_categories": "Categories", - "search_page_favorites": "Favorites", - "search_page_motion_photos": "Motion Photos", - "search_page_no_objects": "No Objects Info Available", - "search_page_no_places": "No Places Info Available", - "search_page_people": "People", - "search_page_person_add_name_dialog_cancel": "Cancel", - "search_page_person_add_name_dialog_hint": "Name", - "search_page_person_add_name_dialog_save": "Save", - "search_page_person_add_name_dialog_title": "Add a name", - "search_page_person_add_name_subtitle": "Find them fast by name with search", - "search_page_person_add_name_title": "Add a name", - "search_page_person_edit_name": "Edit name", - "search_page_places": "Places", - "search_page_recently_added": "Recently added", - "search_page_screenshots": "Screenshots", - "search_page_selfies": "Selfies", - "search_page_things": "Things", - "search_page_videos": "Videos", - "search_page_view_all_button": "View all", - "search_page_your_activity": "Your activity", - "search_page_your_map": "Your Map", - "search_result_page_new_search_hint": "New Search", - "search_suggestion_list_smart_search_hint_1": "Smart search is enabled by default, to search for metadata use the syntax ", - "search_suggestion_list_smart_search_hint_2": "m:your-search-term", - "select_additional_user_for_sharing_page_suggestions": "Suggestions", - "select_user_for_sharing_page_err_album": "Failed to create album", - "select_user_for_sharing_page_share_suggestions": "Suggestions", - "server_info_box_app_version": "App Version", - "server_info_box_latest_release": "Latest Version", - "server_info_box_server_url": "Server URL", - "server_info_box_server_version": "Server Version", - "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", - "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", - "setting_image_viewer_original_title": "Load original image", - "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", - "setting_image_viewer_preview_title": "Load preview image", - "setting_image_viewer_title": "Images", - "setting_languages_apply": "Apply", - "setting_languages_title": "Languages", - "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", - "setting_notifications_notify_hours": "{} hours", - "setting_notifications_notify_immediately": "immediately", - "setting_notifications_notify_minutes": "{} minutes", - "setting_notifications_notify_never": "never", - "setting_notifications_notify_seconds": "{} seconds", - "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", - "setting_notifications_single_progress_title": "Show background backup detail progress", - "setting_notifications_subtitle": "Adjust your notification preferences", - "setting_notifications_title": "Notifications", - "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", - "setting_notifications_total_progress_title": "Show background backup total progress", - "setting_pages_app_bar_settings": "Settings", - "settings_require_restart": "Please restart Immich to apply this setting", - "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", - "setting_video_viewer_looping_title": "Looping", - "setting_video_viewer_title": "Videos", - "share_add": "Add", - "share_add_photos": "Add photos", - "share_add_title": "Add a title", - "share_assets_selected": "{} selected", - "share_create_album": "Create album", - "shared_album_activities_input_disable": "Comment is disabled", - "shared_album_activities_input_hint": "Say something", - "shared_album_activity_remove_content": "Do you want to delete this activity?", - "shared_album_activity_remove_title": "Delete Activity", - "shared_album_activity_setting_subtitle": "Let others respond", - "shared_album_activity_setting_title": "Comments & likes", - "shared_album_section_people_action_error": "Error leaving/removing from album", - "shared_album_section_people_action_leave": "Remove user from album", - "shared_album_section_people_action_remove_user": "Remove user from album", - "shared_album_section_people_owner_label": "Owner", - "shared_album_section_people_title": "PEOPLE", - "share_dialog_preparing": "Preparing...", - "shared_link_app_bar_title": "Shared Links", - "shared_link_clipboard_copied_massage": "Copied to clipboard", - "shared_link_clipboard_text": "Link: {}\nPassword: {}", - "shared_link_create_app_bar_title": "Create link to share", - "shared_link_create_error": "Error while creating shared link", - "shared_link_create_info": "Let anyone with the link see the selected photo(s)", - "shared_link_create_submit_button": "Create link", - "shared_link_edit_allow_download": "Allow public user to download", - "shared_link_edit_allow_upload": "Allow public user to upload", - "shared_link_edit_app_bar_title": "Edit link", - "shared_link_edit_change_expiry": "Change expiration time", - "shared_link_edit_description": "Description", - "shared_link_edit_description_hint": "Enter the share description", - "shared_link_edit_expire_after": "Expire after", - "shared_link_edit_expire_after_option_day": "1 day", - "shared_link_edit_expire_after_option_days": "{} days", - "shared_link_edit_expire_after_option_hour": "1 hour", - "shared_link_edit_expire_after_option_hours": "{} hours", - "shared_link_edit_expire_after_option_minute": "1 minute", - "shared_link_edit_expire_after_option_minutes": "{} minutes", - "shared_link_edit_expire_after_option_months": "{} months", - "shared_link_edit_expire_after_option_never": "Never", - "shared_link_edit_expire_after_option_year": "{} year", - "shared_link_edit_password": "Password", - "shared_link_edit_password_hint": "Enter the share password", - "shared_link_edit_show_meta": "Show metadata", - "shared_link_edit_submit_button": "Update link", - "shared_link_empty": "You don't have any shared links", - "shared_link_error_server_url_fetch": "Cannot fetch the server url", - "shared_link_expired": "Expired", - "shared_link_expires_day": "Expires in {} day", - "shared_link_expires_days": "Expires in {} days", - "shared_link_expires_hour": "Expires in {} hour", - "shared_link_expires_hours": "Expires in {} hours", - "shared_link_expires_minute": "Expires in {} minute", - "shared_link_expires_minutes": "Expires in {} minutes", - "shared_link_expires_never": "Expires ∞", - "shared_link_expires_second": "Expires in {} second", - "shared_link_expires_seconds": "Expires in {} seconds", - "shared_link_individual_shared": "Individual shared", - "shared_link_info_chip_download": "Download", - "shared_link_info_chip_metadata": "EXIF", - "shared_link_info_chip_upload": "Upload", - "shared_link_manage_links": "Manage Shared links", - "shared_link_public_album": "Public album", - "share_done": "Done", - "share_invite": "Invite to album", - "sharing_page_album": "Shared albums", - "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", - "sharing_page_empty_list": "EMPTY LIST", - "sharing_silver_appbar_create_shared_album": "New shared album", - "sharing_silver_appbar_shared_links": "Shared links", - "sharing_silver_appbar_share_partner": "Share with partner", - "sync": "Sync", - "sync_albums": "Sync albums", - "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", - "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", - "tab_controller_nav_library": "Library", - "tab_controller_nav_photos": "Photos", - "tab_controller_nav_search": "Search", - "tab_controller_nav_sharing": "Sharing", - "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", - "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", - "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", - "theme_setting_colorful_interface_title": "Colorful interface", - "theme_setting_dark_mode_switch": "Dark mode", - "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", - "theme_setting_image_viewer_quality_title": "Image viewer quality", - "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", - "theme_setting_primary_color_title": "Primary color", - "theme_setting_system_primary_color_title": "Use system color", - "theme_setting_system_theme_switch": "Automatic (Follow system setting)", - "theme_setting_theme_subtitle": "Choose the app's theme setting", - "theme_setting_theme_title": "Theme", - "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", - "theme_setting_three_stage_loading_title": "Enable three-stage loading", - "translated_text_options": "Options", - "trash_emptied": "Emptied trash", - "trash_page_delete": "Delete", - "trash_page_delete_all": "Delete All", - "trash_page_empty_trash_btn": "Empty trash", - "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", - "trash_page_empty_trash_dialog_ok": "Ok", - "trash_page_info": "Trashed items will be permanently deleted after {} days", - "trash_page_no_assets": "No trashed assets", - "trash_page_restore": "Restore", - "trash_page_restore_all": "Restore All", - "trash_page_select_assets_btn": "Select assets", - "trash_page_select_btn": "Select", - "trash_page_title": "Trash ({})", - "upload_dialog_cancel": "Cancel", - "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", - "upload_dialog_ok": "Upload", - "upload_dialog_title": "Upload Asset", - "version_announcement_overlay_ack": "Acknowledge", - "version_announcement_overlay_release_notes": "release notes", - "version_announcement_overlay_text_1": "Hi friend, there is a new release of", - "version_announcement_overlay_text_2": "please take your time to visit the ", - "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", - "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", - "viewer_remove_from_stack": "Remove from Stack", - "viewer_stack_use_as_main_asset": "Use as Main Asset", - "viewer_unstack": "Un-Stack", - "filter": "Filter", - "downloading_media": "Downloading media", - "download_finished": "Download finished", - "download_filename": "file: {}", - "downloading": "Downloading...", - "download_complete": "Download complete", - "download_failed": "Download failed", - "download_canceled": "Download canceled", - "download_paused": "Download paused", - "download_enqueue": "Download enqueued", - "download_notfound": "Download not found", - "download_waiting_to_retry": "Waiting to retry" -} + "all": "All", + "shared_with_me": "Shared with me", + "my_albums": "My albums", + "create_new": "CREATE NEW", + "create_album": "Create album", + "videos": "Videos", + "recently_added": "Recently added", + "partners": "Partners", + "library": "Library", + "on_this_device": "On this device", + "add_a_name": "Add a name", + "places": "Places", + "albums": "Albums", + "people": "People", + "shared_links": "Shared links", + "trash": "Trash", + "archived": "Archived", + "favorites": "Favorites", + "search_albums": "Search albums", + "action_common_back": "Back", + "action_common_cancel": "Cancel", + "action_common_clear": "Clear", + "action_common_confirm": "Confirm", + "action_common_save": "Save", + "action_common_select": "Select", + "action_common_update": "Update", + "add_to_album_bottom_sheet_added": "Added to {album}", + "add_to_album_bottom_sheet_already_exists": "Already in {album}", + "advanced_settings_log_level_title": "Log level: {}", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_title": "Prefer remote images", + "advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", + "advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates", + "advanced_settings_tile_title": "Advanced", + "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", + "advanced_settings_troubleshooting_title": "Troubleshooting", + "album_info_card_backup_album_excluded": "EXCLUDED", + "album_info_card_backup_album_included": "INCLUDED", + "album_thumbnail_card_item": "1 item", + "album_thumbnail_card_items": "{} items", + "album_thumbnail_card_shared": " \u00b7 Shared", + "album_thumbnail_owned": "Owned", + "album_thumbnail_shared_by": "Shared by {}", + "album_viewer_appbar_delete_confirm": "Are you sure you want to delete this album from your account?", + "album_viewer_appbar_share_delete": "Delete album", + "album_viewer_appbar_share_err_delete": "Failed to delete album", + "album_viewer_appbar_share_err_leave": "Failed to leave album", + "album_viewer_appbar_share_err_remove": "There are problems in removing assets from album", + "album_viewer_appbar_share_err_title": "Failed to change album title", + "album_viewer_appbar_share_leave": "Leave album", + "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_page_share_add_users": "Add users", + "all_people_page_title": "People", + "all_videos_page_title": "Videos", + "app_bar_signout_dialog_content": "Are you sure you want to sign out?", + "app_bar_signout_dialog_ok": "Yes", + "app_bar_signout_dialog_title": "Sign out", + "archive_page_title": "Archive ({})", + "asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", + "asset_list_group_by_sub_title": "Group by", + "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", + "asset_list_layout_settings_group_automatically": "Automatic", + "asset_list_layout_settings_group_by": "Group assets by", + "asset_list_layout_settings_group_by_month": "Month", + "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_layout_sub_title": "Layout", + "asset_list_settings_title": "Photo Grid", + "asset_restored_successfully": "Asset restored successfully", + "assets_deleted_permanently": "{} asset(s) deleted permanently", + "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", + "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", + "assets_restored_successfully": "{} asset(s) restored successfully", + "assets_trashed": "{} asset(s) trashed", + "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", + "asset_viewer_settings_title": "Asset Viewer", + "backup_album_selection_page_albums_device": "Albums on device ({})", + "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", + "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", + "backup_album_selection_page_select_albums": "Select albums", + "backup_album_selection_page_selection_info": "Selection Info", + "backup_all": "All", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying\u2026", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying\u2026", + "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_default_notification": "Checking for new assets\u2026", + "backup_background_service_error_title": "Backup error", + "backup_background_service_in_progress_notification": "Backing up your assets\u2026", + "backup_background_service_upload_failure_notification": "Failed to upload {}", + "backup_controller_page_albums": "Backup Albums", + "backup_controller_page_background_app_refresh_disabled_content": "Enable background app refresh in Settings > General > Background App Refresh in order to use background backup.", + "backup_controller_page_background_app_refresh_disabled_title": "Background app refresh disabled", + "backup_controller_page_background_app_refresh_enable_button_text": "Go to settings", + "backup_controller_page_background_battery_info_link": "Show me how", + "backup_controller_page_background_battery_info_message": "For the best background backup experience, please disable any battery optimizations restricting background activity for Immich.\n\nSince this is device-specific, please lookup the required information for your device manufacturer.", + "backup_controller_page_background_battery_info_ok": "OK", + "backup_controller_page_background_battery_info_title": "Battery optimizations", + "backup_controller_page_background_charging": "Only while charging", + "backup_controller_page_background_configure_error": "Failed to configure the background service", + "backup_controller_page_background_delay": "Delay new assets backup: {}", + "backup_controller_page_background_description": "Turn on the background service to automatically backup any new assets without needing to open the app", + "backup_controller_page_background_is_off": "Automatic background backup is off", + "backup_controller_page_background_is_on": "Automatic background backup is on", + "backup_controller_page_background_turn_off": "Turn off background service", + "backup_controller_page_background_turn_on": "Turn on background service", + "ignore_icloud_photos": "Ignore iCloud photos", + "ignore_icloud_photos_description": "Photos that are stored on iCloud will not be uploaded to the Immich server", + "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_backup": "Backup", + "backup_controller_page_backup_selected": "Selected: ", + "backup_controller_page_backup_sub": "Backed up photos and videos", + "backup_controller_page_cancel": "Cancel", + "backup_controller_page_created": "Created on: {}", + "backup_controller_page_desc_backup": "Turn on foreground backup to automatically upload new assets to the server when opening the app.", + "backup_controller_page_excluded": "Excluded: ", + "backup_controller_page_failed": "Failed ({})", + "backup_controller_page_filename": "File name: {} [{}]", + "backup_controller_page_id": "ID: {}", + "backup_controller_page_none_selected": "None selected", + "backup_controller_page_remainder": "Remainder", + "backup_controller_page_remainder_sub": "Remaining photos and videos to back up from selection", + "backup_controller_page_select": "Select", + "backup_controller_page_server_storage": "Server Storage", + "backup_controller_page_start_backup": "Start Backup", + "backup_controller_page_status_off": "Automatic foreground backup is off", + "backup_controller_page_status_on": "Automatic foreground backup is on", + "backup_controller_page_storage_format": "{} of {} used", + "backup_controller_page_to_backup": "Albums to be backed up", + "backup_controller_page_total": "Total", + "backup_controller_page_total_sub": "All unique photos and videos from selected albums", + "backup_controller_page_turn_off": "Turn off foreground backup", + "backup_controller_page_turn_on": "Turn on foreground backup", + "backup_controller_page_uploading_file_info": "Uploading file info", + "backup_info_card_assets": "assets", + "backup_manual_cancelled": "Cancelled", + "backup_manual_failed": "Failed", + "backup_manual_in_progress": "Upload already in progress. Try after sometime", + "backup_manual_success": "Success", + "backup_manual_title": "Upload status", + "backup_options_page_title": "Backup options", + "cache_settings_duplicated_assets_clear_button": "CLEAR", + "cache_settings_duplicated_assets_subtitle": "Photos and videos that are black listed by the app", + "cache_settings_duplicated_assets_title": "Duplicated Assets ({})", + "change_password_form_confirm_password": "Confirm Password", + "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", + "change_password_form_new_password": "New Password", + "change_password_form_password_mismatch": "Passwords do not match", + "change_password_form_reenter_new_password": "Re-enter New Password", + "client_cert_dialog_msg_confirm": "OK", + "client_cert_enter_password": "Enter Password", + "client_cert_import": "Import", + "client_cert_import_success_msg": "Client certificate is imported", + "client_cert_invalid_msg": "Invalid certificate file or wrong password", + "client_cert_remove": "Remove", + "client_cert_remove_msg": "Client certificate is removed", + "client_cert_subtitle": "Supports PKCS12 (.p12, .pfx) format only. Certificate Import/Remove is available only before login", + "client_cert_title": "SSL Client Certificate", + "common_add_to_album": "Add to album", + "common_change_password": "Change Password", + "common_create_new_album": "Create new album", + "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", + "common_shared": "Shared", + "contextual_search": "Sunrise on the beach", + "control_bottom_app_bar_archive": "Archive", + "control_bottom_app_bar_delete": "Delete", + "control_bottom_app_bar_delete_from_immich": "Delete from Immich", + "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_download": "Download", + "control_bottom_app_bar_edit": "Edit", + "control_bottom_app_bar_edit_location": "Edit Location", + "control_bottom_app_bar_edit_time": "Edit Date & Time", + "control_bottom_app_bar_favorite": "Favorite", + "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_share_to": "Share To", + "control_bottom_app_bar_stack": "Stack", + "control_bottom_app_bar_trash_from_immich": "Move to Trash", + "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_unfavorite": "Unfavorite", + "control_bottom_app_bar_upload": "Upload", + "create_album_page_untitled": "Untitled", + "create_shared_album_page_create": "Create", + "create_shared_album_page_share": "Share", + "create_shared_album_page_share_add_assets": "ADD ASSETS", + "create_shared_album_page_share_select_photos": "Select Photos", + "crop": "Crop", + "curated_location_page_title": "Places", + "date_format": "E, LLL d, y \u2022 h:mm a", + "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", + "delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", + "delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", + "delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", + "delete_dialog_cancel": "Cancel", + "delete_dialog_ok": "Delete", + "delete_dialog_title": "Delete Permanently", + "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", + "delete_local_dialog_ok_force": "Delete Anyway", + "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", + "delete_shared_link_dialog_title": "Delete Shared Link", + "description_input_hint_text": "Add description...", + "description_input_submit_error": "Error updating description, check the log for more details", + "edit_date_time_dialog_date_time": "Date and Time", + "edit_date_time_dialog_timezone": "Timezone", + "edit_image_title": "Edit", + "edit_location_dialog_title": "Location", + "error_saving_image": "Error: {}", + "exif_bottom_sheet_details": "DETAILS", + "exif_bottom_sheet_location": "LOCATION", + "exif_bottom_sheet_location_add": "Add a location", + "exif_bottom_sheet_people": "PEOPLE", + "exif_bottom_sheet_person_add_person": "Add name", + "favorites_page_title": "Favorites", + "filename_search": "File name or extension", + "haptic_feedback_switch": "Enable haptic feedback", + "haptic_feedback_title": "Haptic Feedback", + "header_settings_add_header_tip": "Add Header", + "header_settings_field_validator_msg": "Value cannot be empty", + "header_settings_header_name_input": "Header name", + "header_settings_header_value_input": "Header value", + "header_settings_page_title": "Proxy Headers", + "headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request", + "headers_settings_tile_title": "Custom proxy headers", + "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", + "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", + "home_page_add_to_album_success": "Added {added} assets to album {album}.", + "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_archive_err_partner": "Can not archive partner assets, skipping", + "home_page_building_timeline": "Building the timeline", + "home_page_delete_err_partner": "Can not delete partner assets, skipping", + "home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", + "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", + "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", + "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", + "home_page_share_err_local": "Can not share local assets via link, skipping", + "image_viewer_page_state_provider_share_error": "Share Error", + "invalid_date": "Invalid date", + "invalid_date_format": "Invalid date format", + "library_page_sort_asset_count": "Number of assets", + "library_page_sort_created": "Created date", + "library_page_sort_last_modified": "Last modified", + "library_page_sort_most_oldest_photo": "Oldest photo", + "library_page_sort_most_recent_photo": "Most recent photo", + "library_page_sort_title": "Album title", + "location_picker_choose_on_map": "Choose on map", + "location_picker_latitude": "Latitude", + "location_picker_latitude_error": "Enter a valid latitude", + "location_picker_latitude_hint": "Enter your latitude here", + "location_picker_longitude": "Longitude", + "location_picker_longitude_error": "Enter a valid longitude", + "location_picker_longitude_hint": "Enter your longitude here", + "login_disabled": "Login has been disabled", + "login_form_api_exception": "API exception. Please check the server URL and try again.", + "login_form_back_button_text": "Back", + "login_form_button_text": "Login", + "login_form_email_hint": "youremail@email.com", + "login_form_endpoint_hint": "http://your-server-ip:port/api", + "login_form_endpoint_url": "Server Endpoint URL", + "login_form_err_invalid_email": "Invalid Email", + "login_form_err_invalid_url": "Invalid URL", + "login_form_err_leading_whitespace": "Leading whitespace", + "login_form_err_trailing_whitespace": "Trailing whitespace", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_handshake_exception": "There was an Handshake Exception with the server. Enable self-signed certificate support in the settings if you are using a self-signed certificate.", + "login_form_label_email": "Email", + "login_form_label_password": "Password", + "login_form_next_button": "Next", + "login_form_password_hint": "password", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "login_password_changed_error": "There was an error updating your password", + "login_password_changed_success": "Password updated successfully", + "map_assets_in_bound": "{} photo", + "map_assets_in_bounds": "{} photos", + "map_cannot_get_user_location": "Cannot get user's location", + "map_location_dialog_cancel": "Cancel", + "map_location_dialog_yes": "Yes", + "map_location_picker_page_use_location": "Use this location", + "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", + "map_location_service_disabled_title": "Location Service disabled", + "map_no_assets_in_bounds": "No photos in this area", + "map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?", + "map_no_location_permission_title": "Location Permission denied", + "map_settings_date_range_option_all": "All", + "map_settings_date_range_option_day": "Past 24 hours", + "map_settings_date_range_option_days": "Past {} days", + "map_settings_date_range_option_year": "Past year", + "map_settings_date_range_option_years": "Past {} years", + "map_settings_include_show_archived": "Include Archived", + "map_settings_include_show_partners": "Include Partners", + "map_settings_only_relative_range": "Date range", + "map_settings_only_show_favorites": "Show Favorite Only", + "map_settings_theme_settings": "Map Theme", + "map_zoom_to_see_photos": "Zoom out to see photos", + "memories_all_caught_up": "All caught up", + "memories_check_back_tomorrow": "Check back tomorrow for more memories", + "memories_start_over": "Start Over", + "memories_swipe_to_close": "Swipe up to close", + "memories_year_ago": "A year ago", + "memories_years_ago": "{} years ago", + "motion_photos_page_title": "Motion Photos", + "no_assets_to_show": "No assets to show", + "no_name": "No name", + "notification_permission_dialog_cancel": "Cancel", + "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", + "notification_permission_dialog_settings": "Settings", + "notification_permission_list_tile_content": "Grant permission to enable notifications.", + "notification_permission_list_tile_enable_button": "Enable Notifications", + "notification_permission_list_tile_title": "Notification Permission", + "partner_list_user_photos": "{user}'s photos", + "partner_page_add_partner": "Add partner", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_no_more_users": "No more users to add", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_select_partner": "Select partner", + "partner_page_shared_to_title": "Shared to", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "permission_onboarding_back": "Back", + "permission_onboarding_continue_anyway": "Continue anyway", + "permission_onboarding_get_started": "Get started", + "permission_onboarding_go_to_settings": "Go to settings", + "permission_onboarding_grant_permission": "Grant permission", + "permission_onboarding_permission_denied": "Permission denied. To use Immich, grant photo and video permissions in Settings.", + "permission_onboarding_permission_granted": "Permission granted! You are all set.", + "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", + "permission_onboarding_request": "Immich requires permission to view your photos and videos.", + "preferences_settings_title": "Preferences", + "profile_drawer_app_logs": "Logs", + "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", + "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", + "profile_drawer_client_server_up_to_date": "Client and Server are up-to-date", + "profile_drawer_documentation": "Documentation", + "profile_drawer_github": "GitHub", + "profile_drawer_server_out_of_date_major": "Server is out of date. Please update to the latest major version.", + "profile_drawer_server_out_of_date_minor": "Server is out of date. Please update to the latest minor version.", + "profile_drawer_settings": "Settings", + "profile_drawer_sign_out": "Sign Out", + "recently_added_page_title": "Recently Added", + "save_to_gallery": "Save to gallery", + "scaffold_body_error_occurred": "Error occurred", + "search_bar_hint": "Search your photos", + "search_filter_apply": "Apply filter", + "search_filter_camera": "Camera", + "search_filter_camera_make": "Make", + "search_filter_camera_model": "Model", + "search_filter_camera_title": "Select camera type", + "search_filter_date": "Date", + "search_filter_date_interval": "{start} to {end}", + "search_filter_date_title": "Select a date range", + "search_filter_display_option_archive": "Archive", + "search_filter_display_option_favorite": "Favorite", + "search_filter_display_option_not_in_album": "Not in album", + "search_filter_display_options": "Display Options", + "search_filter_display_options_title": "Display options", + "search_filter_location": "Location", + "search_filter_location_city": "City", + "search_filter_location_country": "Country", + "search_filter_location_state": "State", + "search_filter_location_title": "Select location", + "search_filter_media_type": "Media Type", + "search_filter_media_type_all": "All", + "search_filter_media_type_image": "Image", + "search_filter_media_type_title": "Select media type", + "search_filter_media_type_video": "Video", + "search_filter_people": "People", + "search_filter_people_title": "Select people", + "search_page_categories": "Categories", + "search_page_favorites": "Favorites", + "search_page_motion_photos": "Motion Photos", + "search_page_person_add_name_dialog_cancel": "Cancel", + "search_page_person_add_name_dialog_hint": "Name", + "search_page_person_add_name_dialog_save": "Save", + "search_page_person_add_name_dialog_title": "Add a name", + "search_page_person_add_name_subtitle": "Find them fast by name with search", + "search_page_person_add_name_title": "Add a name", + "search_page_person_edit_name": "Edit name", + "search_page_recently_added": "Recently added", + "search_page_videos": "Videos", + "search_page_view_all_button": "View all", + "search_page_your_map": "Your Map", + "select_additional_user_for_sharing_page_suggestions": "Suggestions", + "select_user_for_sharing_page_err_album": "Failed to create album", + "select_user_for_sharing_page_share_suggestions": "Suggestions", + "server_info_box_app_version": "App Version", + "server_info_box_latest_release": "Latest Version", + "server_info_box_server_url": "Server URL", + "server_info_box_server_version": "Server Version", + "setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).", + "setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).", + "setting_image_viewer_original_title": "Load original image", + "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", + "setting_image_viewer_preview_title": "Load preview image", + "setting_image_viewer_title": "Images", + "setting_languages_apply": "Apply", + "setting_languages_title": "Languages", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_never": "never", + "setting_notifications_notify_seconds": "{} seconds", + "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset", + "setting_notifications_single_progress_title": "Show background backup detail progress", + "setting_notifications_title": "Notifications", + "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", + "setting_notifications_total_progress_title": "Show background backup total progress", + "setting_pages_app_bar_settings": "Settings", + "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", + "setting_video_viewer_looping_title": "Looping", + "setting_video_viewer_title": "Videos", + "share_add": "Add", + "share_add_photos": "Add photos", + "share_add_title": "Add a title", + "share_assets_selected": "{} selected", + "share_create_album": "Create album", + "shared_album_activities_input_disable": "Comment is disabled", + "shared_album_activities_input_hint": "Say something", + "shared_album_activity_remove_content": "Do you want to delete this activity?", + "shared_album_activity_remove_title": "Delete Activity", + "shared_album_activity_setting_subtitle": "Let others respond", + "shared_album_activity_setting_title": "Comments & likes", + "shared_album_section_people_action_error": "Error leaving/removing from album", + "shared_album_section_people_action_leave": "Remove user from album", + "shared_album_section_people_action_remove_user": "Remove user from album", + "shared_album_section_people_owner_label": "Owner", + "shared_album_section_people_title": "PEOPLE", + "share_dialog_preparing": "Preparing...", + "shared_link_app_bar_title": "Shared Links", + "shared_link_clipboard_copied_massage": "Copied to clipboard", + "shared_link_create_app_bar_title": "Create link to share", + "shared_link_create_error": "Error while creating shared link", + "shared_link_create_info": "Let anyone with the link see the selected photo(s)", + "shared_link_create_submit_button": "Create link", + "shared_link_edit_allow_download": "Allow public user to download", + "shared_link_edit_allow_upload": "Allow public user to upload", + "shared_link_edit_app_bar_title": "Edit link", + "shared_link_edit_change_expiry": "Change expiration time", + "shared_link_edit_description": "Description", + "shared_link_edit_description_hint": "Enter the share description", + "shared_link_edit_expire_after": "Expire after", + "shared_link_edit_expire_after_option_day": "1 day", + "shared_link_edit_expire_after_option_days": "{} days", + "shared_link_edit_expire_after_option_hour": "1 hour", + "shared_link_edit_expire_after_option_hours": "{} hours", + "shared_link_edit_expire_after_option_minute": "1 minute", + "shared_link_edit_expire_after_option_minutes": "{} minutes", + "shared_link_edit_expire_after_option_months": "{} months", + "shared_link_edit_expire_after_option_never": "Never", + "shared_link_edit_expire_after_option_year": "{} year", + "shared_link_edit_password": "Password", + "shared_link_edit_password_hint": "Enter the share password", + "shared_link_edit_show_meta": "Show metadata", + "shared_link_edit_submit_button": "Update link", + "shared_link_empty": "You don't have any shared links", + "shared_link_error_server_url_fetch": "Cannot fetch the server url", + "shared_link_expired": "Expired", + "shared_link_expires_day": "Expires in {} day", + "shared_link_expires_days": "Expires in {} days", + "shared_link_expires_hour": "Expires in {} hour", + "shared_link_expires_hours": "Expires in {} hours", + "shared_link_expires_minute": "Expires in {} minute", + "shared_link_expires_minutes": "Expires in {} minutes", + "shared_link_expires_never": "Expires \u221e", + "shared_link_expires_second": "Expires in {} second", + "shared_link_expires_seconds": "Expires in {} seconds", + "shared_link_individual_shared": "Individual shared", + "shared_link_info_chip_download": "Download", + "shared_link_info_chip_metadata": "EXIF", + "shared_link_info_chip_upload": "Upload", + "shared_link_manage_links": "Manage Shared links", + "shared_link_public_album": "Public album", + "share_done": "Done", + "share_invite": "Invite to album", + "sync": "Sync", + "sync_albums": "Sync albums", + "sync_albums_manual_subtitle": "Sync all uploaded videos and photos to the selected backup albums", + "sync_upload_album_setting_subtitle": "Create and upload your photos and videos to the selected albums on Immich", + "tab_controller_nav_photos": "Photos", + "tab_controller_nav_search": "Search", + "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", + "theme_setting_colorful_interface_title": "Colorful interface", + "theme_setting_dark_mode_switch": "Dark mode", + "theme_setting_primary_color_subtitle": "Pick a color for primary actions and accents.", + "theme_setting_primary_color_title": "Primary color", + "theme_setting_system_primary_color_title": "Use system color", + "theme_setting_system_theme_switch": "Automatic (Follow system setting)", + "theme_setting_theme_title": "Theme", + "translated_text_options": "Options", + "trash_emptied": "Emptied trash", + "trash_page_delete": "Delete", + "trash_page_delete_all": "Delete All", + "trash_page_empty_trash_btn": "Empty trash", + "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", + "trash_page_empty_trash_dialog_ok": "Ok", + "trash_page_info": "Trashed items will be permanently deleted after {} days", + "trash_page_no_assets": "No trashed assets", + "trash_page_restore": "Restore", + "trash_page_restore_all": "Restore All", + "trash_page_select_assets_btn": "Select assets", + "trash_page_select_btn": "Select", + "trash_page_title": "Trash ({})", + "upload_dialog_cancel": "Cancel", + "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", + "upload_dialog_ok": "Upload", + "upload_dialog_title": "Upload Asset", + "viewer_unstack": "Un-Stack", + "filter": "Filter", + "downloading_media": "Downloading media", + "download_finished": "Download finished", + "downloading": "Downloading...", + "download_complete": "Download complete", + "download_failed": "Download failed", + "download_canceled": "Download canceled", + "download_paused": "Download paused", + "download_enqueue": "Download enqueued", + "download_notfound": "Download not found", + "download_waiting_to_retry": "Waiting to retry" +} \ No newline at end of file diff --git a/mobile/scripts/check_i18n_keys.py b/mobile/scripts/check_i18n_keys.py index 8d748ceb06e4a..c3b53dc5a6818 100644 --- a/mobile/scripts/check_i18n_keys.py +++ b/mobile/scripts/check_i18n_keys.py @@ -1,18 +1,24 @@ #!/usr/bin/env python3 import json import subprocess - def main(): - with open('assets/i18n/en-US.json', 'r') as f: + with open('assets/i18n/en-US.json', 'r+') as f: data = json.load(f) + keys_to_delete = [] for k in data.keys(): - print(k) - sp = subprocess.run(['sh', '-c', f'grep -r --include="*.dart" "{k}"']) + sp = subprocess.run(['sh', '-c', f'grep -q -r --include="*.dart" "{k}"']) if sp.returncode != 0: - print("Not found in source code!") - return 1 + print("Not found in source code, key:", k) + keys_to_delete.append(k) + + for k in keys_to_delete: + del data[k] + + f.seek(0) + f.truncate() + json.dump(data, f, indent=4) if __name__ == '__main__': main() \ No newline at end of file diff --git a/mobile/scripts/check_key_uniform.py b/mobile/scripts/check_key_uniform.py deleted file mode 100644 index 970f491f365ab..0000000000000 --- a/mobile/scripts/check_key_uniform.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -import json -import subprocess - -def main(): - print("CHECK GERMAN TRANSLATIONS") - with open('assets/i18n/de-DE.json', 'r') as f: - data = json.load(f) - - for k in data.keys(): - print(k) - sp = subprocess.run(['sh', '-c', f'grep -r --include="./assets/i18n/en-US.json" "{k}"']) - - if sp.returncode != 0: - print(f"Outdated Key! {k}") - return 1 - - print("CHECK FRENCH TRANSLATIONS") - with open('assets/i18n/fr-FR.json', 'r') as f: - data = json.load(f) - - for k in data.keys(): - print(k) - sp = subprocess.run(['sh', '-c', f'grep -r --include="./assets/i18n/en-US.json" "{k}"']) - - if sp.returncode != 0: - print(f"Outdated Key! {k}") - return 1 - -if __name__ == '__main__': - main() \ No newline at end of file From 94048dedbd4df244bed038ecf897ac619ed02fa9 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 10 Oct 2024 12:16:37 +0100 Subject: [PATCH 07/13] fix(web): rtl text on map is hidden (#13342) --- web/package-lock.json | 116 +++++++++++++++++- web/package.json | 2 +- .../shared-components/map/map.svelte | 2 +- 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 5b8c71ae56c66..35760f58b1c58 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@mapbox/mapbox-gl-rtl-text": "^0.3.0", + "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", @@ -1471,6 +1471,13 @@ "geojson-rewind": "geojson-rewind" } }, + "node_modules/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC", + "peer": true + }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", @@ -1480,10 +1487,23 @@ } }, "node_modules/@mapbox/mapbox-gl-rtl-text": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.3.0.tgz", - "integrity": "sha512-OwQplFqAAEYRobrTKm2wiVP+wcpUVlgXXiUMNQ8tcm5gPN5SQRXFADmITdQOaec4LhDhuuFchS7TS8ua8dUl4w==", - "license": "BSD-2-Clause" + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-rtl-text/-/mapbox-gl-rtl-text-0.2.3.tgz", + "integrity": "sha512-RaCYfnxULUUUxNwcUimV9C/o2295ktTyLEUzD/+VWkqXqvaVfFcZ5slytGzb2Sd/Jj4MlbxD0DCZbfa6CzcmMw==", + "license": "BSD-2-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peer": true, + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } }, "node_modules/@mapbox/point-geometry": { "version": "0.1.0", @@ -3372,6 +3392,13 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT", + "peer": true + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4518,6 +4545,13 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC", + "peer": true + }, "node_modules/handlebars": { "version": "4.7.8", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", @@ -5308,6 +5342,78 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/mapbox-gl/node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "peer": true, + "dependencies": { + "kdbush": "^3.0.0" + } + }, "node_modules/maplibre-gl": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.0.1.tgz", diff --git a/web/package.json b/web/package.json index dfc0f4b2a55e5..b4505412b47d4 100644 --- a/web/package.json +++ b/web/package.json @@ -67,7 +67,7 @@ "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", "@immich/sdk": "file:../open-api/typescript-sdk", - "@mapbox/mapbox-gl-rtl-text": "^0.3.0", + "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.7.1", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2", diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 4f60131d69652..60d7f0e755bc2 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -8,7 +8,7 @@ import { colorTheme, mapSettings } from '$lib/stores/preferences.store'; import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils'; import { getServerConfig, type MapMarkerResponseDto } from '@immich/sdk'; - import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text?url'; + import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url'; import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js'; import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson'; import type { GeoJSONSource, LngLatLike, StyleSpecification } from 'maplibre-gl'; From bd779ff43790179baa1f692b1737994582ef62f1 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:04:44 +0200 Subject: [PATCH 08/13] chore: cli unit tests (#13343) --- server/src/services/cli.service.spec.ts | 40 ++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/server/src/services/cli.service.spec.ts b/server/src/services/cli.service.spec.ts index 3ccc122eceeb0..ef520070eaeb5 100644 --- a/server/src/services/cli.service.spec.ts +++ b/server/src/services/cli.service.spec.ts @@ -1,3 +1,4 @@ +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { CliService } from 'src/services/cli.service'; import { userStub } from 'test/fixtures/user.stub'; @@ -8,9 +9,18 @@ describe(CliService.name, () => { let sut: CliService; let userMock: Mocked; + let systemMock: Mocked; beforeEach(() => { - ({ sut, userMock } = newTestService(CliService)); + ({ sut, userMock, systemMock } = newTestService(CliService)); + }); + + describe('listUsers', () => { + it('should list users', async () => { + userMock.getList.mockResolvedValue([userStub.admin]); + await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]); + expect(userMock.getList).toHaveBeenCalledWith({ withDeleted: true }); + }); }); describe('resetAdminPassword', () => { @@ -51,4 +61,32 @@ describe(CliService.name, () => { expect(update.password).toBeDefined(); }); }); + + describe('disablePasswordLogin', () => { + it('should disable password login', async () => { + await sut.disablePasswordLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', { passwordLogin: { enabled: false } }); + }); + }); + + describe('enablePasswordLogin', () => { + it('should enable password login', async () => { + await sut.enablePasswordLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + }); + }); + + describe('disableOAuthLogin', () => { + it('should disable oauth login', async () => { + await sut.disableOAuthLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', {}); + }); + }); + + describe('enableOAuthLogin', () => { + it('should enable oauth login', async () => { + await sut.enableOAuthLogin(); + expect(systemMock.set).toHaveBeenCalledWith('system-config', { oauth: { enabled: true } }); + }); + }); }); From 79ae4e211bf66612c168f88af7f93305eecf1005 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:07:37 +0200 Subject: [PATCH 09/13] chore: database service unit tests (#13345) --- server/src/services/database.service.spec.ts | 502 +++++++++++-------- server/vitest.config.mjs | 8 +- 2 files changed, 294 insertions(+), 216 deletions(-) diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index 0bf851f41da9e..96d94453c49a1 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -43,282 +43,360 @@ describe(DatabaseService.name, () => { expect(sut).toBeDefined(); }); - it('should throw an error if PostgreSQL version is below minimum supported version', async () => { - databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); + describe('onBootstrap', () => { + it('should throw an error if PostgreSQL version is below minimum supported version', async () => { + databaseMock.getPostgresVersion.mockResolvedValueOnce('13.10.0'); - await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); + await expect(sut.onBootstrap()).rejects.toThrow('Invalid PostgreSQL version. Found 13.10.0'); - expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); - }); - - describe.each(>[ - { extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] }, - { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, - ])('should work with $extensionName', ({ extension, extensionName }) => { - beforeEach(() => { - configMock.getEnv.mockReturnValue( - mockEnvData({ - database: { - host: 'database', - port: 5432, - username: 'postgres', - password: 'postgres', - name: 'immich', - skipMigrations: false, - vectorExtension: extension, - }, - }), - ); + expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); }); - it(`should start up successfully with ${extension}`, async () => { - databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); - databaseMock.getExtensionVersion.mockResolvedValue({ - installedVersion: null, - availableVersion: minVersionInRange, + describe.each(>[ + { extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] }, + { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, + ])('should work with $extensionName', ({ extension, extensionName }) => { + beforeEach(() => { + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + name: 'immich', + skipMigrations: false, + vectorExtension: extension, + }, + }), + ); }); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); - - expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); - expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + it(`should start up successfully with ${extension}`, async () => { + databaseMock.getPostgresVersion.mockResolvedValue('14.0.0'); + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: null, + availableVersion: minVersionInRange, + }); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(databaseMock.getPostgresVersion).toHaveBeenCalled(); + expect(databaseMock.createExtension).toHaveBeenCalledWith(extension); + expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); - it(`should throw an error if the ${extension} extension is not installed`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); - const message = `The ${extensionName} extension is not available in this Postgres instance. + it(`should throw an error if the ${extension} extension is not installed`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: null }); + const message = `The ${extensionName} extension is not available in this Postgres instance. If using a container image, ensure the image has the extension installed.`; - await expect(sut.onBootstrap()).rejects.toThrow(message); + await expect(sut.onBootstrap()).rejects.toThrow(message); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); - it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - installedVersion: versionBelowRange, - availableVersion: versionBelowRange, + it(`should throw an error if the ${extension} extension version is below minimum supported version`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + installedVersion: versionBelowRange, + availableVersion: versionBelowRange, + }); + + await expect(sut.onBootstrap()).rejects.toThrow( + `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, + ); + + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); - await expect(sut.onBootstrap()).rejects.toThrow( - `The ${extensionName} extension version is ${versionBelowRange}, but Immich only supports ${extensionRange}`, - ); + it(`should throw an error if ${extension} extension version is a nightly`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).rejects.toThrow( + `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, + ); - it(`should throw an error if ${extension} extension version is a nightly`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: '0.0.0', availableVersion: '0.0.0' }); + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); - await expect(sut.onBootstrap()).rejects.toThrow( - `The ${extensionName} extension version is 0.0.0, which means it is a nightly release.`, - ); + it(`should do in-range update for ${extension} extension`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); - it(`should do in-range update for ${extension} extension`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: updateInRange, - installedVersion: minVersionInRange, + expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + it(`should not upgrade ${extension} if same version`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: minVersionInRange, + installedVersion: minVersionInRange, + }); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.getExtensionVersion).toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); - it(`should not upgrade ${extension} if same version`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: minVersionInRange, - installedVersion: minVersionInRange, + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + it(`should throw error if ${extension} available version is below range`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: versionBelowRange, + installedVersion: null, + }); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).rejects.toThrow(); - it(`should throw error if ${extension} available version is below range`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: versionBelowRange, - installedVersion: null, + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - await expect(sut.onBootstrap()).rejects.toThrow(); + it(`should throw error if ${extension} available version is above range`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: versionAboveRange, + installedVersion: minVersionInRange, + }); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).rejects.toThrow(); - it(`should throw error if ${extension} available version is above range`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: versionAboveRange, - installedVersion: minVersionInRange, + expect(databaseMock.createExtension).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - await expect(sut.onBootstrap()).rejects.toThrow(); + it('should throw error if available version is below installed version', async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: minVersionInRange, + installedVersion: updateInRange, + }); - expect(databaseMock.createExtension).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - }); + await expect(sut.onBootstrap()).rejects.toThrow( + `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, + ); - it('should throw error if available version is below installed version', async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: minVersionInRange, - installedVersion: updateInRange, + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + + it('should throw error if installed version is not in version range', async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: minVersionInRange, + installedVersion: versionAboveRange, + }); + + await expect(sut.onBootstrap()).rejects.toThrow( + `The ${extensionName} extension version is ${versionAboveRange}, but Immich only supports`, + ); + + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + + it(`should raise error if ${extension} extension upgrade failed`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); + + await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); + + expect(loggerMock.warn.mock.calls[0][0]).toContain( + `The ${extensionName} extension can be updated to ${updateInRange}.`, + ); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); + + it(`should warn if ${extension} extension update requires restart`, async () => { + databaseMock.getExtensionVersion.mockResolvedValue({ + availableVersion: updateInRange, + installedVersion: minVersionInRange, + }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(loggerMock.warn).toHaveBeenCalledTimes(1); + expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); + expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - await expect(sut.onBootstrap()).rejects.toThrow( - `The database currently has ${extensionName} ${updateInRange} activated, but the Postgres instance only has ${minVersionInRange} available.`, + it(`should reindex ${extension} indices if needed`, async () => { + databaseMock.shouldReindex.mockResolvedValue(true); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); + expect(databaseMock.reindex).toHaveBeenCalledTimes(2); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + + it(`should throw an error if reindexing fails`, async () => { + databaseMock.shouldReindex.mockResolvedValue(true); + databaseMock.reindex.mockRejectedValue(new Error('Error reindexing')); + + await expect(sut.onBootstrap()).rejects.toBeDefined(); + + expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(1); + expect(databaseMock.reindex).toHaveBeenCalledTimes(1); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(loggerMock.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not run vector reindexing checks.'), + ); + }); + + it(`should not reindex ${extension} indices if not needed`, async () => { + databaseMock.shouldReindex.mockResolvedValue(false); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); + expect(databaseMock.reindex).toHaveBeenCalledTimes(0); + expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal).not.toHaveBeenCalled(); + }); + }); + + it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + name: 'immich', + skipMigrations: true, + vectorExtension: DatabaseExtension.VECTORS, + }, + }), ); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - expect(loggerMock.fatal).not.toHaveBeenCalled(); }); - it(`should raise error if ${extension} extension upgrade failed`, async () => { + it(`should throw error if pgvector extension could not be created`, async () => { + configMock.getEnv.mockReturnValue( + mockEnvData({ + database: { + host: 'database', + port: 5432, + username: 'postgres', + password: 'postgres', + name: 'immich', + skipMigrations: true, + vectorExtension: DatabaseExtension.VECTOR, + }, + }), + ); databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: updateInRange, - installedVersion: minVersionInRange, + installedVersion: null, + availableVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockRejectedValue(new Error('Failed to update extension')); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension'); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - expect(loggerMock.warn.mock.calls[0][0]).toContain( - `The ${extensionName} extension can be updated to ${updateInRange}.`, + expect(loggerMock.fatal).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal.mock.calls[0][0]).toContain( + `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`, ); - expect(loggerMock.fatal).not.toHaveBeenCalled(); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); + expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); - it(`should warn if ${extension} extension update requires restart`, async () => { + it(`should throw error if pgvecto.rs extension could not be created`, async () => { databaseMock.getExtensionVersion.mockResolvedValue({ - availableVersion: updateInRange, - installedVersion: minVersionInRange, + installedVersion: null, + availableVersion: minVersionInRange, }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: true }); + databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); + databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); + + expect(loggerMock.fatal).toHaveBeenCalledTimes(1); + expect(loggerMock.fatal.mock.calls[0][0]).toContain( + `Alternatively, if your Postgres instance has pgvector, you may use this instead`, + ); + expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); + expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); + }); + }); - expect(loggerMock.warn).toHaveBeenCalledTimes(1); - expect(loggerMock.warn.mock.calls[0][0]).toContain(extensionName); - expect(databaseMock.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + describe('handleConnectionError', () => { + beforeAll(() => { + vi.useFakeTimers(); }); - it(`should reindex ${extension} indices if needed`, async () => { - databaseMock.shouldReindex.mockResolvedValue(true); + afterAll(() => { + vi.useRealTimers(); + }); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + it('should not override interval', () => { + sut.handleConnectionError(new Error('Error')); + expect(loggerMock.error).toHaveBeenCalled(); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(2); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + sut.handleConnectionError(new Error('foo')); + expect(loggerMock.error).toHaveBeenCalledTimes(1); }); - it(`should not reindex ${extension} indices if not needed`, async () => { - databaseMock.shouldReindex.mockResolvedValue(false); + it('should reconnect when interval elapses', async () => { + databaseMock.reconnect.mockResolvedValue(true); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + sut.handleConnectionError(new Error('error')); + await vi.advanceTimersByTimeAsync(5000); - expect(databaseMock.shouldReindex).toHaveBeenCalledTimes(2); - expect(databaseMock.reindex).toHaveBeenCalledTimes(0); - expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal).not.toHaveBeenCalled(); + expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); + expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); + + await vi.advanceTimersByTimeAsync(5000); + expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); }); - }); - it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { - configMock.getEnv.mockReturnValue( - mockEnvData({ - database: { - host: 'database', - port: 5432, - username: 'postgres', - password: 'postgres', - name: 'immich', - skipMigrations: true, - vectorExtension: DatabaseExtension.VECTORS, - }, - }), - ); - - await expect(sut.onBootstrap()).resolves.toBeUndefined(); - - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + it('should try again when reconnection fails', async () => { + databaseMock.reconnect.mockResolvedValueOnce(false); - it(`should throw error if pgvector extension could not be created`, async () => { - configMock.getEnv.mockReturnValue( - mockEnvData({ - database: { - host: 'database', - port: 5432, - username: 'postgres', - password: 'postgres', - name: 'immich', - skipMigrations: true, - vectorExtension: DatabaseExtension.VECTOR, - }, - }), - ); - databaseMock.getExtensionVersion.mockResolvedValue({ - installedVersion: null, - availableVersion: minVersionInRange, - }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - - await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( - `Alternatively, if your Postgres instance has pgvecto.rs, you may use this instead`, - ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + sut.handleConnectionError(new Error('error')); + await vi.advanceTimersByTimeAsync(5000); - it(`should throw error if pgvecto.rs extension could not be created`, async () => { - databaseMock.getExtensionVersion.mockResolvedValue({ - installedVersion: null, - availableVersion: minVersionInRange, + expect(databaseMock.reconnect).toHaveBeenCalledTimes(1); + expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining('Database connection failed')); + + databaseMock.reconnect.mockResolvedValueOnce(true); + await vi.advanceTimersByTimeAsync(5000); + expect(databaseMock.reconnect).toHaveBeenCalledTimes(2); + expect(loggerMock.log).toHaveBeenCalledWith('Database reconnected'); }); - databaseMock.updateVectorExtension.mockResolvedValue({ restartRequired: false }); - databaseMock.createExtension.mockRejectedValue(new Error('Failed to create extension')); - - await expect(sut.onBootstrap()).rejects.toThrow('Failed to create extension'); - - expect(loggerMock.fatal).toHaveBeenCalledTimes(1); - expect(loggerMock.fatal.mock.calls[0][0]).toContain( - `Alternatively, if your Postgres instance has pgvector, you may use this instead`, - ); - expect(databaseMock.createExtension).toHaveBeenCalledTimes(1); - expect(databaseMock.updateVectorExtension).not.toHaveBeenCalled(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); }); diff --git a/server/vitest.config.mjs b/server/vitest.config.mjs index df1a9a7654b19..92fc027d40fb9 100644 --- a/server/vitest.config.mjs +++ b/server/vitest.config.mjs @@ -17,10 +17,10 @@ export default defineConfig({ 'src/services/index.ts', ], thresholds: { - lines: 80, - statements: 80, - branches: 85, - functions: 80, + lines: 85, + statements: 85, + branches: 90, + functions: 85, }, }, server: { From 24e266cd52502a652d311deff81c0496a8c30a36 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 10 Oct 2024 15:45:50 +0100 Subject: [PATCH 10/13] ci: don't auto-update mapbox-gl-rtl-text (#13351) --- renovate.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index ccfb75b19c157..39e0e7f811f02 100644 --- a/renovate.json +++ b/renovate.json @@ -15,7 +15,7 @@ "groupName": "typescript-projects", "matchUpdateTypes": ["minor", "patch"], "excludePackagePrefixes": ["exiftool", "reflect-metadata"], - "excludePackageNames": ["node", "@types/node"], + "excludePackageNames": ["node", "@types/node", "@mapbox/mapbox-gl-rtl-text"], "schedule": "on tuesday" }, { From 97edf908891b5149375ffd5d1f77286e9421a799 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 10 Oct 2024 17:51:54 +0200 Subject: [PATCH 11/13] fix: library deletion (#13350) --- server/src/services/library.service.ts | 47 ++++++++++++++------------ 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 84a8a277f5618..6544ee37804e5 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -341,7 +341,10 @@ export class LibraryService extends BaseService { this.logger.debug(`Will delete all assets in library ${libraryId}`); for await (const assets of assetPagination) { - assetsFound = true; + if (assets.length > 0) { + assetsFound = true; + } + this.logger.debug(`Queueing deletion of ${assets.length} asset(s) in library ${libraryId}`); await this.jobRepository.queueAll( assets.map((asset) => ({ @@ -545,30 +548,30 @@ export class LibraryService extends BaseService { } } - if (validImportPaths) { - const assetsOnDisk = this.storageRepository.walk({ - pathsToCrawl: validImportPaths, - includeHidden: false, - exclusionPatterns: library.exclusionPatterns, - take: JOBS_LIBRARY_PAGINATION_SIZE, - }); + if (validImportPaths.length === 0) { + this.logger.warn(`No valid import paths found for library ${library.id}`); + } + + const assetsOnDisk = this.storageRepository.walk({ + pathsToCrawl: validImportPaths, + includeHidden: false, + exclusionPatterns: library.exclusionPatterns, + take: JOBS_LIBRARY_PAGINATION_SIZE, + }); - let count = 0; + let count = 0; - for await (const assetBatch of assetsOnDisk) { - count += assetBatch.length; - this.logger.debug(`Discovered ${count} asset(s) on disk for library ${library.id}...`); - await this.syncFiles(library, assetBatch); - this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); - } + for await (const assetBatch of assetsOnDisk) { + count += assetBatch.length; + this.logger.debug(`Discovered ${count} asset(s) on disk for library ${library.id}...`); + await this.syncFiles(library, assetBatch); + this.logger.verbose(`Queued scan of ${assetBatch.length} crawled asset(s) in library ${library.id}...`); + } - if (count > 0) { - this.logger.debug(`Finished queueing scan of ${count} assets on disk for library ${library.id}`); - } else { - this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); - } - } else { - this.logger.warn(`No valid import paths found for library ${library.id}`); + if (count > 0) { + this.logger.debug(`Finished queueing scan of ${count} assets on disk for library ${library.id}`); + } else if (validImportPaths.length > 0) { + this.logger.debug(`No non-excluded assets found in any import path for library ${library.id}`); } await this.libraryRepository.update({ id: job.id, refreshedAt: new Date() }); From 8daa8073ae3a8e69ba410d0184006915995bf6ad Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 10 Oct 2024 11:53:53 -0400 Subject: [PATCH 12/13] refactor(server): add base methods for access checks (#13349) --- server/src/services/activity.service.ts | 9 ++++---- server/src/services/album.service.ts | 19 ++++++++-------- server/src/services/asset-media.service.ts | 12 +++++------ server/src/services/asset.service.ts | 11 +++++----- server/src/services/audit.service.ts | 3 +-- server/src/services/base.service.ts | 15 ++++++++++--- server/src/services/download.service.ts | 9 ++++---- server/src/services/memory.service.ts | 13 ++++++----- server/src/services/partner.service.ts | 3 +-- server/src/services/person.service.ts | 25 +++++++++++----------- server/src/services/session.service.ts | 3 +-- server/src/services/shared-link.service.ts | 7 +++--- server/src/services/stack.service.ts | 11 +++++----- server/src/services/sync.service.ts | 5 ++--- server/src/services/tag.service.ts | 17 +++++++-------- server/src/services/timeline.service.ts | 9 ++++---- server/src/services/trash.service.ts | 3 +-- 17 files changed, 84 insertions(+), 90 deletions(-) diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index 4e17baebc32e5..fce104ecbdfbf 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -14,12 +14,11 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { ActivityEntity } from 'src/entities/activity.entity'; import { Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { requireAccess } from 'src/utils/access'; @Injectable() export class ActivityService extends BaseService { async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); const activities = await this.activityRepository.search({ userId: dto.userId, albumId: dto.albumId, @@ -31,12 +30,12 @@ export class ActivityService extends BaseService { } async getStatistics(auth: AuthDto, dto: ActivityDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); return { comments: await this.activityRepository.getStatistics(dto.assetId, dto.albumId) }; } async create(auth: AuthDto, dto: ActivityCreateDto): Promise> { - await requireAccess(this.accessRepository, { auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] }); const common = { userId: auth.user.id, @@ -70,7 +69,7 @@ export class ActivityService extends BaseService { } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ACTIVITY_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ACTIVITY_DELETE, ids: [id] }); await this.activityRepository.delete(id); } } diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index a9a678d605091..e8acce9b6c878 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -19,7 +19,6 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface'; import { BaseService } from 'src/services/base.service'; -import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() @@ -82,7 +81,7 @@ export class AlbumService extends BaseService { } async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [id] }); await this.albumRepository.updateThumbnails(); const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets; const album = await this.findOrFail(id, { withAssets }); @@ -106,7 +105,7 @@ export class AlbumService extends BaseService { } } - const allowedAssetIdsSet = await checkAccess(this.accessRepository, { + const allowedAssetIdsSet = await this.checkAccess({ auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds || [], @@ -130,7 +129,7 @@ export class AlbumService extends BaseService { } async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_UPDATE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: true }); @@ -153,13 +152,13 @@ export class AlbumService extends BaseService { } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_DELETE, ids: [id] }); await this.albumRepository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { const album = await this.findOrFail(id, { withAssets: false }); - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] }); const results = await addAssets( auth, @@ -182,7 +181,7 @@ export class AlbumService extends BaseService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); const results = await removeAssets( @@ -203,7 +202,7 @@ export class AlbumService extends BaseService { } async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); @@ -247,14 +246,14 @@ export class AlbumService extends BaseService { // non-admin can remove themselves if (auth.user.id !== userId) { - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); } await this.albumUserRepository.delete({ albumId: id, userId }); } async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); } diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index b320c32a213ae..70f4905de31e4 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -24,7 +24,7 @@ import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entit import { AssetStatus, AssetType, CacheControl, Permission, StorageFolder } from 'src/enum'; import { JobName } from 'src/interfaces/job.interface'; import { BaseService } from 'src/services/base.service'; -import { requireAccess, requireUploadAccess } from 'src/utils/access'; +import { requireUploadAccess } from 'src/utils/access'; import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; @@ -125,7 +125,7 @@ export class AssetMediaService extends BaseService { sidecarFile?: UploadFile, ): Promise { try { - await requireAccess(this.accessRepository, { + await this.requireAccess({ auth, permission: Permission.ASSET_UPLOAD, // do not need an id here, but the interface requires it @@ -159,7 +159,7 @@ export class AssetMediaService extends BaseService { sidecarFile?: UploadFile, ): Promise { try { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const asset = (await this.assetRepository.getById(id)) as AssetEntity; this.requireQuota(auth, file.size); @@ -182,7 +182,7 @@ export class AssetMediaService extends BaseService { } async downloadOriginal(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); const asset = await this.findOrFail(id); @@ -194,7 +194,7 @@ export class AssetMediaService extends BaseService { } async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; @@ -217,7 +217,7 @@ export class AssetMediaService extends BaseService { } async playbackVideo(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ASSET_VIEW, ids: [id] }); const asset = await this.findOrFail(id); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 5fef742f5a2eb..2f31806e81444 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -29,7 +29,6 @@ import { JobStatus, } from 'src/interfaces/job.interface'; import { BaseService } from 'src/services/base.service'; -import { requireAccess } from 'src/utils/access'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; @@ -86,7 +85,7 @@ export class AssetService extends BaseService { } async get(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [id] }); const asset = await this.assetRepository.getById( id, @@ -135,7 +134,7 @@ export class AssetService extends BaseService { } async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const repos = { asset: this.assetRepository, event: this.eventRepository }; @@ -178,7 +177,7 @@ export class AssetService extends BaseService { async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids }); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids }); for (const id of ids) { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); @@ -275,7 +274,7 @@ export class AssetService extends BaseService { async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise { const { ids, force } = dto; - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DELETE, ids }); + await this.requireAccess({ auth, permission: Permission.ASSET_DELETE, ids }); await this.assetRepository.updateAll(ids, { deletedAt: new Date(), status: force ? AssetStatus.DELETED : AssetStatus.TRASHED, @@ -284,7 +283,7 @@ export class AssetService extends BaseService { } async run(auth: AuthDto, dto: AssetJobsDto) { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); const jobs: JobItem[] = []; diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 60f8d6fa81617..d891c88b3911c 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -23,7 +23,6 @@ import { } from 'src/enum'; import { JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; import { BaseService } from 'src/services/base.service'; -import { requireAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; import { usePagination } from 'src/utils/pagination'; @@ -36,7 +35,7 @@ export class AuditService extends BaseService { async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise { const userId = dto.userId || auth.user.id; - await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); + await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [userId] }); const audits = await this.auditRepository.getAfter(dto.after, { userIds: [userId], diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index e98f88ade1462..2bb717b45b9c3 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -38,6 +38,7 @@ import { ITrashRepository } from 'src/interfaces/trash.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface'; import { IViewRepository } from 'src/interfaces/view.interface'; +import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; export class BaseService { @@ -95,7 +96,7 @@ export class BaseService { ); } - private get repos() { + private get configRepos() { return { configRepo: this.configRepository, metadataRepo: this.systemMetadataRepository, @@ -104,10 +105,18 @@ export class BaseService { } getConfig(options: { withCache: boolean }) { - return getConfig(this.repos, options); + return getConfig(this.configRepos, options); } updateConfig(newConfig: SystemConfig) { - return updateConfig(this.repos, newConfig); + return updateConfig(this.configRepos, newConfig); + } + + requireAccess(request: AccessRequest) { + return requireAccess(this.accessRepository, request); + } + + checkAccess(request: AccessRequest) { + return checkAccess(this.accessRepository, request); } } diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index d8ad67044e054..3d66f009cfb81 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -8,7 +8,6 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; import { ImmichReadStream } from 'src/interfaces/storage.interface'; import { BaseService } from 'src/services/base.service'; -import { requireAccess } from 'src/utils/access'; import { HumanReadableSize } from 'src/utils/bytes'; import { usePagination } from 'src/utils/pagination'; import { getPreferences } from 'src/utils/preferences'; @@ -62,7 +61,7 @@ export class DownloadService extends BaseService { } async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); + await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); const zip = this.storageRepository.createZipStream(); const assets = await this.assetRepository.getByIds(dto.assetIds); @@ -105,20 +104,20 @@ export class DownloadService extends BaseService { if (dto.assetIds) { const assetIds = dto.assetIds; - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); + await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true }); return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets })); } if (dto.albumId) { const albumId = dto.albumId; - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] }); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); } if (dto.userId) { const userId = dto.userId; - await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); + await this.requireAccess({ auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), ); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index f7d1ead6aaad9..816b0fddeb0fb 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -5,7 +5,6 @@ import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from ' import { AssetEntity } from 'src/entities/asset.entity'; import { Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @Injectable() @@ -16,7 +15,7 @@ export class MemoryService extends BaseService { } async get(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] }); const memory = await this.findOrFail(id); return mapMemory(memory); } @@ -25,7 +24,7 @@ export class MemoryService extends BaseService { // TODO validate type/data combination const assetIds = dto.assetIds || []; - const allowedAssetIds = await checkAccess(this.accessRepository, { + const allowedAssetIds = await this.checkAccess({ auth, permission: Permission.ASSET_SHARE, ids: assetIds, @@ -44,7 +43,7 @@ export class MemoryService extends BaseService { } async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); const memory = await this.memoryRepository.update({ id, @@ -57,12 +56,12 @@ export class MemoryService extends BaseService { } async remove(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.MEMORY_DELETE, ids: [id] }); await this.memoryRepository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] }); const repos = { access: this.accessRepository, bulk: this.memoryRepository }; const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids }); @@ -76,7 +75,7 @@ export class MemoryService extends BaseService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); const repos = { access: this.accessRepository, bulk: this.memoryRepository }; const results = await removeAssets(auth, repos, { diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index 39907ec5fe11f..ee36f1ce45ee1 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -6,7 +6,6 @@ import { PartnerEntity } from 'src/entities/partner.entity'; import { Permission } from 'src/enum'; import { PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface'; import { BaseService } from 'src/services/base.service'; -import { requireAccess } from 'src/utils/access'; @Injectable() export class PartnerService extends BaseService { @@ -41,7 +40,7 @@ export class PartnerService extends BaseService { } async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] }); + await this.requireAccess({ auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] }); const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; const entity = await this.partnerRepository.update({ ...partnerId, inTimeline: dto.inTimeline }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 624fb46b6d68b..e5f016d8ef24d 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -47,7 +47,6 @@ import { BoundingBox } from 'src/interfaces/machine-learning.interface'; import { CropOptions, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface'; import { UpdateFacesData } from 'src/interfaces/person.interface'; import { BaseService } from 'src/services/base.service'; -import { checkAccess, requireAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; @@ -80,7 +79,7 @@ export class PersonService extends BaseService { } async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); + await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); const person = await this.findOrFail(personId); const result: PersonResponseDto[] = []; const changeFeaturePhoto: string[] = []; @@ -88,7 +87,7 @@ export class PersonService extends BaseService { const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); for (const face of faces) { - await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_CREATE, ids: [face.id] }); + await this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [face.id] }); if (person.faceAssetId === null) { changeFeaturePhoto.push(person.id); } @@ -109,8 +108,8 @@ export class PersonService extends BaseService { } async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); - await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_CREATE, ids: [dto.id] }); + await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); + await this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [dto.id] }); const face = await this.personRepository.getFaceById(dto.id); const person = await this.findOrFail(personId); @@ -126,7 +125,7 @@ export class PersonService extends BaseService { } async getFacesById(auth: AuthDto, dto: FaceDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [dto.id] }); + await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.id] }); const faces = await this.personRepository.getFaces(dto.id); return faces.map((asset) => mapFaces(asset, auth)); } @@ -150,17 +149,17 @@ export class PersonService extends BaseService { } async getById(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); return this.findOrFail(id).then(mapPerson); } async getStatistics(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); return this.personRepository.getStatistics(id); } async getThumbnail(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); const person = await this.personRepository.getById(id); if (!person || !person.thumbnailPath) { throw new NotFoundException(); @@ -183,13 +182,13 @@ export class PersonService extends BaseService { } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [assetId] }); + await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [assetId] }); const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]); if (!face) { throw new BadRequestException('Invalid assetId for feature face'); @@ -584,13 +583,13 @@ export class PersonService extends BaseService { throw new BadRequestException('Cannot merge a person into themselves'); } - await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); let primaryPerson = await this.findOrFail(id); const primaryName = primaryPerson.name || primaryPerson.id; const results: BulkIdResponseDto[] = []; - const allowedIds = await checkAccess(this.accessRepository, { + const allowedIds = await this.checkAccess({ auth, permission: Permission.PERSON_MERGE, ids: mergeIds, diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index c68fb3088c9aa..2e27942c663a1 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -5,7 +5,6 @@ import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { Permission } from 'src/enum'; import { JobStatus } from 'src/interfaces/job.interface'; import { BaseService } from 'src/services/base.service'; -import { requireAccess } from 'src/utils/access'; @Injectable() export class SessionService extends BaseService { @@ -34,7 +33,7 @@ export class SessionService extends BaseService { } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); await this.sessionRepository.delete(id); } diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 5676531e5741f..a01a2f45a32c6 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -15,7 +15,6 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { Permission, SharedLinkType } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { checkAccess, requireAccess } from 'src/utils/access'; import { OpenGraphTags } from 'src/utils/misc'; @Injectable() @@ -49,7 +48,7 @@ export class SharedLinkService extends BaseService { if (!dto.albumId) { throw new BadRequestException('Invalid albumId'); } - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); break; } @@ -58,7 +57,7 @@ export class SharedLinkService extends BaseService { throw new BadRequestException('Invalid assetIds'); } - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds }); + await this.requireAccess({ auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds }); break; } @@ -119,7 +118,7 @@ export class SharedLinkService extends BaseService { const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id)); const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId)); - const allowedAssetIds = await checkAccess(this.accessRepository, { + const allowedAssetIds = await this.checkAccess({ auth, permission: Permission.ASSET_SHARE, ids: notPresentAssetIds, diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts index c965d3e73e885..58fccc8be27dd 100644 --- a/server/src/services/stack.service.ts +++ b/server/src/services/stack.service.ts @@ -4,7 +4,6 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto'; import { Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { requireAccess } from 'src/utils/access'; @Injectable() export class StackService extends BaseService { @@ -18,7 +17,7 @@ export class StackService extends BaseService { } async create(auth: AuthDto, dto: StackCreateDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); + await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds }); @@ -28,13 +27,13 @@ export class StackService extends BaseService { } async get(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.STACK_READ, ids: [id] }); const stack = await this.findOrFail(id); return mapStack(stack, { auth }); } async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.STACK_UPDATE, ids: [id] }); const stack = await this.findOrFail(id); if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) { throw new BadRequestException('Primary asset must be in the stack'); @@ -48,13 +47,13 @@ export class StackService extends BaseService { } async delete(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.STACK_DELETE, ids: [id] }); await this.stackRepository.delete(id); await this.eventRepository.emit('stack.delete', { stackId: id, userId: auth.user.id }); } async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_DELETE, ids: dto.ids }); + await this.requireAccess({ auth, permission: Permission.STACK_DELETE, ids: dto.ids }); await this.stackRepository.deleteAll(dto.ids); await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id }); } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index e09c06c778a08..f85200db489fa 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -5,7 +5,6 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto'; import { DatabaseAction, EntityType, Permission } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { requireAccess } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; import { setIsEqual } from 'src/utils/set'; @@ -15,7 +14,7 @@ export class SyncService extends BaseService { async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id const userId = dto.userId || auth.user.id; - await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [userId] }); + await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [userId] }); const assets = await this.assetRepository.getAllForUserFullSync({ ownerId: userId, updatedUntil: dto.updatedUntil, @@ -39,7 +38,7 @@ export class SyncService extends BaseService { return FULL_SYNC; } - await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds }); + await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: dto.userIds }); const limit = 10_000; const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 2824a9832ddea..5534d74efa63e 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -15,7 +15,6 @@ import { Permission } from 'src/enum'; import { JobStatus } from 'src/interfaces/job.interface'; import { AssetTagItem } from 'src/interfaces/tag.interface'; import { BaseService } from 'src/services/base.service'; -import { checkAccess, requireAccess } from 'src/utils/access'; import { addAssets, removeAssets } from 'src/utils/asset.util'; import { upsertTags } from 'src/utils/tag'; @@ -27,7 +26,7 @@ export class TagService extends BaseService { } async get(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [id] }); const tag = await this.findOrFail(id); return mapTag(tag); } @@ -35,7 +34,7 @@ export class TagService extends BaseService { async create(auth: AuthDto, dto: TagCreateDto) { let parent: TagEntity | undefined; if (dto.parentId) { - await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); + await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); parent = (await this.tagRepository.get(dto.parentId)) || undefined; if (!parent) { throw new BadRequestException('Tag not found'); @@ -55,7 +54,7 @@ export class TagService extends BaseService { } async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.TAG_UPDATE, ids: [id] }); const { color } = dto; const tag = await this.tagRepository.update({ id, color }); @@ -68,7 +67,7 @@ export class TagService extends BaseService { } async remove(auth: AuthDto, id: string): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.TAG_DELETE, ids: [id] }); // TODO sync tag changes for affected assets @@ -77,8 +76,8 @@ export class TagService extends BaseService { async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise { const [tagIds, assetIds] = await Promise.all([ - checkAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }), - checkAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), + this.checkAccess({ auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }), + this.checkAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), ]); const items: AssetTagItem[] = []; @@ -97,7 +96,7 @@ export class TagService extends BaseService { } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.TAG_ASSET, ids: [id] }); const results = await addAssets( auth, @@ -115,7 +114,7 @@ export class TagService extends BaseService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.TAG_ASSET, ids: [id] }); const results = await removeAssets( auth, diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 48e4daafd1096..04fd206fe7cbe 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -5,7 +5,6 @@ import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dt import { Permission } from 'src/enum'; import { TimeBucketOptions } from 'src/interfaces/asset.interface'; import { BaseService } from 'src/services/base.service'; -import { requireAccess } from 'src/utils/access'; import { getMyPartnerIds } from 'src/utils/asset.util'; export class TimelineService extends BaseService { @@ -48,20 +47,20 @@ export class TimelineService extends BaseService { private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { if (dto.albumId) { - await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); } else { dto.userId = dto.userId || auth.user.id; } if (dto.userId) { - await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] }); + await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] }); if (dto.isArchived !== false) { - await requireAccess(this.accessRepository, { auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); + await this.requireAccess({ auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); } } if (dto.tagId) { - await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] }); + await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.tagId] }); } if (dto.withPartners) { diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index add6e29f6bb14..91c359392eecd 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -5,7 +5,6 @@ import { TrashResponseDto } from 'src/dtos/trash.dto'; import { Permission } from 'src/enum'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; import { BaseService } from 'src/services/base.service'; -import { requireAccess } from 'src/utils/access'; import { usePagination } from 'src/utils/pagination'; export class TrashService extends BaseService { @@ -15,7 +14,7 @@ export class TrashService extends BaseService { return { count: 0 }; } - await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DELETE, ids }); + await this.requireAccess({ auth, permission: Permission.ASSET_DELETE, ids }); await this.trashRepository.restoreAll(ids); await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id }); From 930df46f74c0c9f543c919fa4a641d9c29f94db1 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 11 Oct 2024 00:44:38 -0400 Subject: [PATCH 13/13] chore(server): remove unused code (#13367) --- server/src/cores/storage.core.ts | 7 -- server/src/interfaces/asset.interface.ts | 6 -- server/src/interfaces/database.interface.ts | 1 - server/src/interfaces/map.interface.ts | 1 - server/src/interfaces/person.interface.ts | 2 - server/src/repositories/asset.repository.ts | 33 ------- .../src/repositories/database.repository.ts | 4 - server/src/repositories/map.repository.ts | 14 --- server/src/repositories/person.repository.ts | 9 -- server/src/services/auth.service.spec.ts | 36 ++++++-- server/src/services/library.service.spec.ts | 4 - server/src/services/person.service.spec.ts | 6 -- server/test/fixtures/album.stub.ts | 51 ---------- server/test/fixtures/api-key.stub.ts | 4 - server/test/fixtures/asset.stub.ts | 92 ++----------------- server/test/fixtures/audit.stub.ts | 16 ---- server/test/fixtures/auth.stub.ts | 57 ------------ server/test/fixtures/face.stub.ts | 15 --- server/test/fixtures/library.stub.ts | 17 +--- server/test/fixtures/person.stub.ts | 14 --- server/test/fixtures/shared-link.stub.ts | 15 --- server/test/fixtures/user.stub.ts | 40 -------- .../repositories/asset.repository.mock.ts | 1 - .../repositories/database.repository.mock.ts | 1 - .../test/repositories/map.repository.mock.ts | 1 - .../repositories/person.repository.mock.ts | 2 - 26 files changed, 34 insertions(+), 415 deletions(-) diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 8e42cd10764de..c49175172d66e 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -15,9 +15,6 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { getAssetFiles } from 'src/utils/asset.util'; import { getConfig } from 'src/utils/config'; -export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS)); -export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO)); - export interface MoveRequest { entityId: string; pathType: PathType; @@ -118,10 +115,6 @@ export class StorageCore { return normalizedPath.startsWith(normalizedAppMediaLocation); } - static isGeneratedAsset(path: string) { - return path.startsWith(THUMBNAIL_DIR) || path.startsWith(ENCODED_VIDEO_DIR); - } - async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) { const { id: entityId, files } = asset; const { thumbnailFile, previewFile } = getAssetFiles(files); diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 750a85209474c..37d3326a8abc5 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -172,12 +172,6 @@ export interface IAssetRepository { order?: FindOptionsOrder, ): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; - getWith( - pagination: PaginationOptions, - property: WithProperty, - libraryId?: string, - withDeleted?: boolean, - ): Paginated; getRandom(userIds: string[], count: number): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index e388f354f2ac1..79550d416ea5e 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -48,7 +48,6 @@ export interface IDatabaseRepository { getPostgresVersion(): Promise; getPostgresVersionRange(): string; createExtension(extension: DatabaseExtension): Promise; - updateExtension(extension: DatabaseExtension, version?: string): Promise; updateVectorExtension(extension: VectorExtension, version?: string): Promise; reindex(index: VectorIndex): Promise; shouldReindex(name: VectorIndex): Promise; diff --git a/server/src/interfaces/map.interface.ts b/server/src/interfaces/map.interface.ts index 80b37c3a5f182..0a04840a968a5 100644 --- a/server/src/interfaces/map.interface.ts +++ b/server/src/interfaces/map.interface.ts @@ -28,5 +28,4 @@ export interface IMapRepository { init(): Promise; reverseGeocode(point: GeoPoint): Promise; getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise; - fetchStyle(url: string): Promise; } diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index 57e46a439b308..b3e2c0990efd1 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -57,9 +57,7 @@ export interface IPersonRepository { create(person: Partial): Promise; createAll(people: Partial[]): Promise; - createFaces(entities: Partial[]): Promise; delete(entities: PersonEntity[]): Promise; - deleteAll(): Promise; deleteFaces(options: DeleteFacesOptions): Promise; refreshFaces( facesToAdd: Partial[], diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 8bca755c32e26..fd47a976a529a 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -499,39 +499,6 @@ export class AssetRepository implements IAssetRepository { }); } - getWith( - pagination: PaginationOptions, - property: WithProperty, - libraryId?: string, - withDeleted = false, - ): Paginated { - let where: FindOptionsWhere | FindOptionsWhere[] = {}; - - switch (property) { - case WithProperty.SIDECAR: { - where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; - break; - } - - default: { - throw new Error(`Invalid getWith property: ${property}`); - } - } - - if (libraryId) { - where = [{ ...where, libraryId }]; - } - - return paginate(this.repository, pagination, { - where, - withDeleted, - order: { - // Ensures correct order when paginating - createdAt: 'ASC', - }, - }); - } - getLastUpdatedAssetForAlbumId(albumId: string): Promise { return this.repository.findOne({ where: { albums: { id: albumId } }, diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 76998b523948f..547f03fc20014 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -74,10 +74,6 @@ export class DatabaseRepository implements IDatabaseRepository { await this.dataSource.query(`CREATE EXTENSION IF NOT EXISTS ${extension}`); } - async updateExtension(extension: DatabaseExtension, version?: string): Promise { - await this.dataSource.query(`ALTER EXTENSION ${extension} UPDATE${version ? ` TO '${version}'` : ''}`); - } - async updateVectorExtension(extension: VectorExtension, targetVersion?: string): Promise { const { availableVersion, installedVersion } = await this.getExtensionVersion(extension); if (!installedVersion) { diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 8ba9b4cab8c65..3e5c499f41993 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -113,20 +113,6 @@ export class MapRepository implements IMapRepository { })); } - async fetchStyle(url: string) { - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`); - } - - return response.json(); - } catch (error) { - throw new Error(`Failed to fetch data from ${url}: ${error}`); - } - } - async reverseGeocode(point: GeoPoint): Promise { this.logger.debug(`Request: ${point.latitude},${point.longitude}`); diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 3ba9e238872b9..c62c4b8739493 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -63,10 +63,6 @@ export class PersonRepository implements IPersonRepository { await this.personRepository.remove(entities); } - async deleteAll(): Promise { - await this.personRepository.clear(); - } - async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.assetFaceRepository .createQueryBuilder('asset_faces') @@ -269,11 +265,6 @@ export class PersonRepository implements IPersonRepository { return results.map((person) => person.id); } - async createFaces(entities: AssetFaceEntity[]): Promise { - const res = await this.assetFaceRepository.save(entities); - return res.map((row) => row.id); - } - async refreshFaces( facesToAdd: Partial[], faceIdsToRemove: string[], diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index f45affe0425a4..3701d3de56877 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -13,7 +13,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf import { IUserRepository } from 'src/interfaces/user.interface'; import { AuthService } from 'src/services/auth.service'; import { keyStub } from 'test/fixtures/api-key.stub'; -import { authStub, loginResponseStub } from 'test/fixtures/auth.stub'; +import { authStub } from 'test/fixtures/auth.stub'; import { sessionStub } from 'test/fixtures/session.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; @@ -21,6 +21,16 @@ import { userStub } from 'test/fixtures/user.stub'; import { newTestService } from 'test/utils'; import { Mocked } from 'vitest'; +const oauthResponse = { + accessToken: 'cmFuZG9tLWJ5dGVz', + userId: 'user-id', + userEmail: 'immich@test.com', + name: 'immich_name', + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, +}; + // const token = Buffer.from('my-api-key', 'utf8').toString('base64'); const email = 'test@immich.com'; @@ -100,7 +110,15 @@ describe('AuthService', () => { it('should successfully log the user in', async () => { userMock.getByEmail.mockResolvedValue(userStub.user1); sessionMock.create.mockResolvedValue(sessionStub.valid); - await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password); + await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({ + accessToken: 'cmFuZG9tLWJ5dGVz', + userId: 'user-id', + userEmail: 'immich@test.com', + name: 'immich_name', + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, + }); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); }); @@ -469,7 +487,7 @@ describe('AuthService', () => { sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); @@ -498,7 +516,7 @@ describe('AuthService', () => { sessionMock.create.mockResolvedValue(sessionStub.valid); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create @@ -546,7 +564,7 @@ describe('AuthService', () => { userMock.create.mockResolvedValue(userStub.user1); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); @@ -560,7 +578,7 @@ describe('AuthService', () => { oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); @@ -574,7 +592,7 @@ describe('AuthService', () => { oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith(oauthUserWithDefaultQuota); @@ -588,7 +606,7 @@ describe('AuthService', () => { oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith({ @@ -608,7 +626,7 @@ describe('AuthService', () => { oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( - loginResponseStub.user1oauth, + oauthResponse, ); expect(userMock.create).toHaveBeenCalledWith({ diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index e8e276a0e25a2..7993c7daccd3c 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -141,8 +141,6 @@ describe(LibraryService.name, () => { describe('handleQueueAssetRefresh', () => { it('should queue refresh of a new asset', async () => { - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); storageMock.walk.mockImplementation(mockWalk); @@ -179,8 +177,6 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(true); - assetMock.getWith.mockResolvedValue({ items: [], hasNextPage: false }); - libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); await sut.handleQueueSyncFiles({ id: libraryStub.externalLibraryWithImportPaths1.id }); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index d3d0b457c7c57..da4656be021a8 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -721,7 +721,6 @@ describe(PersonService.name, () => { '/uploads/user-id/thumbs/path.jpg', expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), ); - expect(personMock.createFaces).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); @@ -733,7 +732,6 @@ describe(PersonService.name, () => { }); it('should create a face with no person and queue recognition job', async () => { - personMock.createFaces.mockResolvedValue([faceStub.face1.id]); machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); assetMock.getByIds.mockResolvedValue([assetStub.image]); @@ -761,7 +759,6 @@ describe(PersonService.name, () => { }); it('should add new face and delete an existing face not among the new detected faces', async () => { - personMock.createFaces.mockResolvedValue([faceStub.face1.id]); machineLearningMock.detectFaces.mockResolvedValue(detectFaceMock); assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, faces: [faceStub.primaryFace1] }]); @@ -816,7 +813,6 @@ describe(PersonService.name, () => { expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.createFaces).not.toHaveBeenCalled(); }); it('should fail if face does not have asset', async () => { @@ -827,7 +823,6 @@ describe(PersonService.name, () => { expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.createFaces).not.toHaveBeenCalled(); }); it('should skip if face already has an assigned person', async () => { @@ -837,7 +832,6 @@ describe(PersonService.name, () => { expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled(); - expect(personMock.createFaces).not.toHaveBeenCalled(); }); it('should match existing person', async () => { diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index c2c59a8007c0a..3d2899d3c6820 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -155,55 +155,4 @@ export const albumStub = { isActivityEnabled: true, order: AssetOrder.DESC, }), - emptyWithInvalidThumbnail: Object.freeze({ - id: 'album-5', - albumName: 'Empty album with invalid thumbnail', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [], - albumThumbnailAsset: null, - albumThumbnailAssetId: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.DESC, - }), - oneAssetInvalidThumbnail: Object.freeze({ - id: 'album-6', - albumName: 'Album with one asset and invalid thumbnail', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [assetStub.image], - albumThumbnailAsset: assetStub.livePhotoMotionAsset, - albumThumbnailAssetId: assetStub.livePhotoMotionAsset.id, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.DESC, - }), - oneAssetValidThumbnail: Object.freeze({ - id: 'album-6', - albumName: 'Album with one asset and invalid thumbnail', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [assetStub.image], - albumThumbnailAsset: assetStub.image, - albumThumbnailAssetId: assetStub.image.id, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.DESC, - }), }; diff --git a/server/test/fixtures/api-key.stub.ts b/server/test/fixtures/api-key.stub.ts index 954c8f35a006e..f8b1832c84e37 100644 --- a/server/test/fixtures/api-key.stub.ts +++ b/server/test/fixtures/api-key.stub.ts @@ -11,7 +11,3 @@ export const keyStub = { user: userStub.admin, } as APIKeyEntity), }; - -export const apiKeyCreateStub = { - name: 'API Key', -}; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 119c0b6e5ab76..45390cf92ecd4 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -523,37 +523,6 @@ export const assetStub = { }, } as AssetEntity), - liveMotionWithThumb: Object.freeze({ - id: fileStub.livePhotoMotion.uuid, - status: AssetStatus.ACTIVE, - originalPath: fileStub.livePhotoMotion.originalPath, - ownerId: authStub.user1.user.id, - type: AssetType.VIDEO, - isVisible: false, - fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), - fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - files: [ - { - assetId: 'asset-id', - type: AssetFileType.PREVIEW, - path: '/uploads/user-id/thumbs/path.ext', - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - }, - { - assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, - path: '/uploads/user-id/webp/path.ext', - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - }, - ], - exifInfo: { - fileSizeInByte: 100_000, - timeZone: `America/New_York`, - }, - } as AssetEntity), - livePhotoStillAsset: Object.freeze({ id: 'live-photo-still-asset', status: AssetStatus.ACTIVE, @@ -570,22 +539,6 @@ export const assetStub = { }, } as AssetEntity), - livePhotoStillAssetWithTheSameLivePhotoMotionAsset: Object.freeze({ - id: 'live-photo-still-asset-1', - status: AssetStatus.ACTIVE, - originalPath: fileStub.livePhotoStill.originalPath, - ownerId: authStub.user1.user.id, - type: AssetType.IMAGE, - livePhotoVideoId: 'live-photo-motion-asset', - isVisible: true, - fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), - fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), - exifInfo: { - fileSizeInByte: 25_000, - timeZone: `America/New_York`, - }, - } as AssetEntity), - livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', status: AssetStatus.ACTIVE, @@ -645,6 +598,7 @@ export const assetStub = { duplicateId: null, isOffline: false, }), + sidecar: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -679,6 +633,7 @@ export const assetStub = { duplicateId: null, isOffline: false, }), + sidecarWithoutExt: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -751,45 +706,7 @@ export const assetStub = { duplicateId: null, isOffline: false, }), - missingFileExtension: Object.freeze({ - id: 'asset-id', - status: AssetStatus.ACTIVE, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isArchived: false, - isExternal: true, - duration: null, - isVisible: true, - livePhotoVideo: null, - livePhotoVideoId: null, - libraryId: 'library-id', - library: libraryStub.externalLibrary1, - tags: [], - sharedLinks: [], - originalFileName: 'photo', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - } as ExifEntity, - duplicateId: null, - isOffline: false, - }), + hasFileExtension: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -829,6 +746,7 @@ export const assetStub = { duplicateId: null, isOffline: false, }), + imageDng: Object.freeze({ id: 'asset-id', status: AssetStatus.ACTIVE, @@ -868,6 +786,7 @@ export const assetStub = { duplicateId: null, isOffline: false, }), + hasEmbedding: Object.freeze({ id: 'asset-id-embedding', status: AssetStatus.ACTIVE, @@ -909,6 +828,7 @@ export const assetStub = { }, isOffline: false, }), + hasDupe: Object.freeze({ id: 'asset-id-dupe', status: AssetStatus.ACTIVE, diff --git a/server/test/fixtures/audit.stub.ts b/server/test/fixtures/audit.stub.ts index 3e79a60819a15..24f78a17ce9e7 100644 --- a/server/test/fixtures/audit.stub.ts +++ b/server/test/fixtures/audit.stub.ts @@ -3,22 +3,6 @@ import { DatabaseAction, EntityType } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; export const auditStub = { - create: Object.freeze({ - id: 1, - entityId: 'asset-created', - action: DatabaseAction.CREATE, - entityType: EntityType.ASSET, - ownerId: authStub.admin.user.id, - createdAt: new Date(), - }), - update: Object.freeze({ - id: 2, - entityId: 'asset-updated', - action: DatabaseAction.UPDATE, - entityType: EntityType.ASSET, - ownerId: authStub.admin.user.id, - createdAt: new Date(), - }), delete: Object.freeze({ id: 3, entityId: 'asset-deleted', diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index bbb53d4db6254..2989c0cce1b81 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -35,17 +35,6 @@ export const authStub = { id: 'token-id', } as SessionEntity, }), - external1: Object.freeze({ - user: { - id: 'user-id', - email: 'immich@test.com', - isAdmin: false, - metadata: [] as UserMetadataEntity[], - } as UserEntity, - session: { - id: 'token-id', - } as SessionEntity, - }), adminSharedLink: Object.freeze({ user: { id: 'admin_id', @@ -76,20 +65,6 @@ export const authStub = { key: Buffer.from('shared-link-key'), } as SharedLinkEntity, }), - readonlySharedLink: Object.freeze({ - user: { - id: 'admin_id', - email: 'admin@test.com', - isAdmin: true, - metadata: [] as UserMetadataEntity[], - } as UserEntity, - sharedLink: { - id: '123', - allowUpload: false, - allowDownload: false, - showExif: true, - } as SharedLinkEntity, - }), passwordSharedLink: Object.freeze({ user: { id: 'admin_id', @@ -106,35 +81,3 @@ export const authStub = { } as SharedLinkEntity, }), }; - -export const loginResponseStub = { - admin: { - response: { - accessToken: expect.any(String), - name: 'Immich Admin', - isAdmin: true, - profileImagePath: '', - shouldChangePassword: true, - userEmail: 'admin@immich.app', - userId: expect.any(String), - }, - }, - user1oauth: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, - user1password: { - accessToken: 'cmFuZG9tLWJ5dGVz', - userId: 'user-id', - userEmail: 'immich@test.com', - name: 'immich_name', - profileImagePath: '', - isAdmin: false, - shouldChangePassword: false, - }, -}; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index e8c4592b8bac7..b8c68d5bf428c 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -51,21 +51,6 @@ export const faceStub = { sourceType: SourceType.MACHINE_LEARNING, faceSearch: { faceId: 'assetFaceId3', embedding: [1, 2, 3, 4] }, }), - mergeFace2: Object.freeze>({ - id: 'assetFaceId4', - assetId: assetStub.image1.id, - asset: assetStub.image1, - personId: personStub.mergePerson.id, - person: personStub.mergePerson, - boundingBoxX1: 0, - boundingBoxY1: 0, - boundingBoxX2: 1, - boundingBoxY2: 1, - imageHeight: 1024, - imageWidth: 1024, - sourceType: SourceType.MACHINE_LEARNING, - faceSearch: { faceId: 'assetFaceId4', embedding: [1, 2, 3, 4] }, - }), start: Object.freeze>({ id: 'assetFaceId5', assetId: assetStub.image.id, diff --git a/server/test/fixtures/library.stub.ts b/server/test/fixtures/library.stub.ts index 1a83ffe5d749a..b2e132da3e9bc 100644 --- a/server/test/fixtures/library.stub.ts +++ b/server/test/fixtures/library.stub.ts @@ -1,6 +1,3 @@ -import { join } from 'node:path'; -import { APP_MEDIA_LOCATION } from 'src/constants'; -import { THUMBNAIL_DIR } from 'src/cores/storage.core'; import { LibraryEntity } from 'src/entities/library.entity'; import { userStub } from 'test/fixtures/user.stub'; @@ -53,18 +50,6 @@ export const libraryStub = { refreshedAt: null, exclusionPatterns: [], }), - externalLibraryWithExclusionPattern: Object.freeze({ - id: 'library-id', - name: 'test_library', - assets: [], - owner: userStub.admin, - ownerId: 'user-id', - importPaths: [], - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-01'), - refreshedAt: null, - exclusionPatterns: ['**/dir1/**'], - }), patternPath: Object.freeze({ id: 'library-id1337', name: 'importpath-exclusion-library1', @@ -83,7 +68,7 @@ export const libraryStub = { assets: [], owner: userStub.admin, ownerId: 'user-id', - importPaths: [join(THUMBNAIL_DIR, 'library'), '/xyz', join(APP_MEDIA_LOCATION, 'library')], + importPaths: ['upload/thumbs', '/xyz', 'upload/library'], createdAt: new Date('2023-01-01'), updatedAt: new Date('2023-01-01'), refreshedAt: null, diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 3584d0486ea92..544894b31e1f4 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -44,20 +44,6 @@ export const personStub = { faceAsset: null, isHidden: false, }), - noBirthDate: Object.freeze({ - id: 'person-1', - createdAt: new Date('2021-01-01'), - updatedAt: new Date('2021-01-01'), - ownerId: userStub.admin.id, - owner: userStub.admin, - name: 'Person 1', - birthDate: null, - thumbnailPath: '/path/to/thumbnail.jpg', - faces: [], - faceAssetId: null, - faceAsset: null, - isHidden: false, - }), withBirthDate: Object.freeze({ id: 'person-1', createdAt: new Date('2021-01-01'), diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index f237e1dea942c..e446a6180b65a 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -309,21 +309,6 @@ export const sharedLinkResponseStub = { type: SharedLinkType.ALBUM, userId: 'admin_id', }), - readonly: Object.freeze({ - id: '123', - userId: 'admin_id', - key: sharedLinkBytes.toString('base64url'), - type: SharedLinkType.ALBUM, - createdAt: today, - expiresAt: tomorrow, - description: null, - password: null, - allowUpload: false, - allowDownload: false, - showMetadata: true, - album: albumResponse, - assets: [assetResponse], - }), readonlyNoMetadata: Object.freeze({ id: '123', userId: 'admin_id', diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 6f3a819eef80e..b65cd6b395822 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -2,30 +2,6 @@ import { UserEntity } from 'src/entities/user.entity'; import { UserAvatarColor, UserMetadataKey } from 'src/enum'; import { authStub } from 'test/fixtures/auth.stub'; -export const userDto = { - user1: { - email: 'user1@immich.app', - password: 'Password123', - name: 'User 1', - }, - user2: { - email: 'user2@immich.app', - password: 'Password123', - name: 'User 2', - }, - user3: { - email: 'user3@immich.app', - password: 'Password123', - name: 'User 3', - }, - userWithQuota: { - email: 'quota-user@immich.app', - password: 'Password123', - name: 'User with quota', - quotaSizeInBytes: 42, - }, -}; - export const userStub = { admin: Object.freeze({ ...authStub.admin.user, @@ -100,22 +76,6 @@ export const userStub = { quotaSizeInBytes: null, quotaUsageInBytes: 0, }), - externalPathRoot: Object.freeze({ - ...authStub.user1.user, - password: 'immich_password', - name: 'immich_name', - storageLabel: 'label-1', - oauthId: '', - shouldChangePassword: false, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - tags: [], - assets: [], - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }), profilePath: Object.freeze({ ...authStub.user1.user, password: 'immich_password', diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 50fff31e55e4c..982273ff69b96 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -17,7 +17,6 @@ export const newAssetRepositoryMock = (): Mocked => { getByChecksum: vitest.fn(), getByChecksums: vitest.fn(), getUploadAssetIdByChecksum: vitest.fn(), - getWith: vitest.fn(), getRandom: vitest.fn(), getLastUpdatedAssetForAlbumId: vitest.fn(), getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }), diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index 0e1d4ab3e71dd..da6417a38c565 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -9,7 +9,6 @@ export const newDatabaseRepositoryMock = (): Mocked => { getPostgresVersion: vitest.fn().mockResolvedValue('14.10 (Debian 14.10-1.pgdg120+1)'), getPostgresVersionRange: vitest.fn().mockReturnValue('>=14.0.0'), createExtension: vitest.fn().mockResolvedValue(void 0), - updateExtension: vitest.fn(), updateVectorExtension: vitest.fn(), reindex: vitest.fn(), shouldReindex: vitest.fn(), diff --git a/server/test/repositories/map.repository.mock.ts b/server/test/repositories/map.repository.mock.ts index 95965522e34d8..703e8696f10d3 100644 --- a/server/test/repositories/map.repository.mock.ts +++ b/server/test/repositories/map.repository.mock.ts @@ -6,6 +6,5 @@ export const newMapRepositoryMock = (): Mocked => { init: vitest.fn(), reverseGeocode: vitest.fn(), getMapMarkers: vitest.fn(), - fetchStyle: vitest.fn(), }; }; diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 286c527038356..d7b92d3eab498 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -16,7 +16,6 @@ export const newPersonRepositoryMock = (): Mocked => { update: vitest.fn(), updateAll: vitest.fn(), delete: vitest.fn(), - deleteAll: vitest.fn(), deleteFaces: vitest.fn(), getStatistics: vitest.fn(), @@ -26,7 +25,6 @@ export const newPersonRepositoryMock = (): Mocked => { reassignFaces: vitest.fn(), unassignFaces: vitest.fn(), - createFaces: vitest.fn(), refreshFaces: vitest.fn(), getFaces: vitest.fn(), reassignFace: vitest.fn(),