Browse Source

feat(mobile): partner sharing (#2541)

* feat(mobile): partner sharing

* getAllAssets for other users

* i18n

* fix tests

* try to fix web tests

* shared with/by confusion

* error logging

* guard against outdated server version
Fynn Petersen-Frey 2 years ago
parent
commit
bcc2c34eef
48 changed files with 1729 additions and 226 deletions
  1. 9 0
      mobile/assets/i18n/en-US.json
  2. 2 0
      mobile/lib/main.dart
  3. 4 1
      mobile/lib/modules/album/providers/shared_album.provider.dart
  4. 1 2
      mobile/lib/modules/album/providers/suggested_shared_users.provider.dart
  5. 0 85
      mobile/lib/modules/album/ui/sharing_sliver_appbar.dart
  6. 3 1
      mobile/lib/modules/album/views/asset_selection_page.dart
  7. 1 1
      mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart
  8. 1 2
      mobile/lib/modules/album/views/select_user_for_sharing_page.dart
  9. 95 9
      mobile/lib/modules/album/views/sharing_page.dart
  10. 4 2
      mobile/lib/modules/archive/providers/archive_asset_provider.dart
  11. 5 4
      mobile/lib/modules/asset_viewer/ui/description_input.dart
  12. 4 2
      mobile/lib/modules/favorite/providers/favorite_provider.dart
  13. 11 42
      mobile/lib/modules/home/ui/delete_dialog.dart
  14. 3 1
      mobile/lib/modules/home/views/home_page.dart
  15. 50 0
      mobile/lib/modules/partner/providers/partner.provider.dart
  16. 72 0
      mobile/lib/modules/partner/services/partner.service.dart
  17. 30 0
      mobile/lib/modules/partner/ui/partner_list.dart
  18. 40 0
      mobile/lib/modules/partner/views/partner_detail_page.dart
  19. 160 0
      mobile/lib/modules/partner/views/partner_page.dart
  20. 5 0
      mobile/lib/routing/router.dart
  21. 78 0
      mobile/lib/routing/router.gr.dart
  22. 2 2
      mobile/lib/shared/models/album.dart
  23. 3 3
      mobile/lib/shared/models/asset.dart
  24. 13 0
      mobile/lib/shared/models/etag.dart
  25. 724 0
      mobile/lib/shared/models/etag.g.dart
  26. 11 1
      mobile/lib/shared/models/user.dart
  27. 117 7
      mobile/lib/shared/models/user.g.dart
  28. 18 4
      mobile/lib/shared/providers/asset.provider.dart
  29. 26 0
      mobile/lib/shared/providers/user.provider.dart
  30. 2 0
      mobile/lib/shared/services/api.service.dart
  31. 22 10
      mobile/lib/shared/services/asset.service.dart
  32. 19 7
      mobile/lib/shared/services/sync.service.dart
  33. 45 5
      mobile/lib/shared/services/user.service.dart
  34. 54 0
      mobile/lib/shared/ui/confirm_dialog.dart
  35. 21 0
      mobile/lib/shared/ui/user_avatar.dart
  36. 4 0
      mobile/lib/utils/db.dart
  37. 2 0
      mobile/lib/utils/openapi_extensions.dart
  38. 4 2
      mobile/openapi/doc/AssetApi.md
  39. 10 3
      mobile/openapi/lib/api/asset_api.dart
  40. 1 1
      mobile/openapi/test/asset_api_test.dart
  41. 15 17
      mobile/test/sync_service_test.dart
  42. 4 1
      server/apps/immich/src/api-v1/asset/asset.service.ts
  43. 7 1
      server/apps/immich/src/api-v1/asset/dto/asset-search.dto.ts
  44. 9 0
      server/immich-openapi-specs.json
  45. 15 7
      web/src/api/open-api/api.ts
  46. 1 1
      web/src/lib/components/shared-components/side-bar/side-bar.svelte
  47. 1 1
      web/src/routes/(user)/archive/+page.svelte
  48. 1 1
      web/src/routes/(user)/favorites/+page.svelte

+ 9 - 0
mobile/assets/i18n/en-US.json

@@ -257,6 +257,15 @@
   "sharing_page_empty_list": "EMPTY LIST",
   "sharing_silver_appbar_create_shared_album": "Create shared album",
   "sharing_silver_appbar_share_partner": "Share with partner",
+  "partner_page_title": "Partner",
+  "partner_page_no_more_users": "No more users to add",
+  "partner_page_empty_message": "Your photos are not yet shared with any partner.",
+  "partner_page_shared_to_title": "Shared to",
+  "partner_page_select_partner": "Select partner",
+  "partner_page_add_partner": "Add partner",
+  "partner_page_partner_add_failed": "Failed to add partner",
+  "partner_page_stop_sharing_title": "Stop sharing your photos?",
+  "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.",
   "tab_controller_nav_library": "Library",
   "tab_controller_nav_photos": "Photos",
   "tab_controller_nav_search": "Search",

+ 2 - 0
mobile/lib/main.dart

@@ -20,6 +20,7 @@ import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/routing/tab_navigation_observer.dart';
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/etag.dart';
 import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:immich_mobile/shared/models/logger_message.model.dart';
 import 'package:immich_mobile/shared/models/store.dart';
@@ -89,6 +90,7 @@ Future<Isar> loadDb() async {
       BackupAlbumSchema,
       DuplicatedAssetSchema,
       LoggerMessageSchema,
+      ETagSchema,
     ],
     directory: dir.path,
     maxSizeMiB: 256,

+ 4 - 1
mobile/lib/modules/album/providers/shared_album.provider.dart

@@ -8,6 +8,7 @@ import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/providers/user.provider.dart';
 import 'package:isar/isar.dart';
 
 class SharedAlbumNotifier extends StateNotifier<List<Album>> {
@@ -73,7 +74,9 @@ final sharedAlbumProvider =
 });
 
 final sharedAlbumDetailProvider =
-    StreamProvider.autoDispose.family<Album, int>((ref, albumId) async* {
+    StreamProvider.family<Album, int>((ref, albumId) async* {
+  final user = ref.watch(currentUserProvider);
+  if (user == null) return;
   final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
 
   await for (final a in sharedAlbumService.watchAlbum(albumId)) {

+ 1 - 2
mobile/lib/modules/album/providers/suggested_shared_users.provider.dart

@@ -2,8 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/services/user.service.dart';
 
-final suggestedSharedUsersProvider =
-    FutureProvider.autoDispose<List<User>>((ref) {
+final otherUsersProvider = FutureProvider.autoDispose<List<User>>((ref) {
   UserService userService = ref.watch(userServiceProvider);
 
   return userService.getUsersInDb();

+ 0 - 85
mobile/lib/modules/album/ui/sharing_sliver_appbar.dart

@@ -1,85 +0,0 @@
-import 'package:auto_route/auto_route.dart';
-import 'package:easy_localization/easy_localization.dart';
-import 'package:flutter/material.dart';
-import 'package:immich_mobile/routing/router.dart';
-
-class SharingSliverAppBar extends StatelessWidget {
-  const SharingSliverAppBar({
-    Key? key,
-  }) : super(key: key);
-
-  @override
-  Widget build(BuildContext context) {
-    return SliverAppBar(
-      centerTitle: true,
-      floating: false,
-      pinned: true,
-      snap: false,
-      automaticallyImplyLeading: false,
-      title: Text(
-        'IMMICH',
-        style: TextStyle(
-          fontFamily: 'SnowburstOne',
-          fontWeight: FontWeight.bold,
-          fontSize: 22,
-          color: Theme.of(context).primaryColor,
-        ),
-      ),
-      bottom: PreferredSize(
-        preferredSize: const Size.fromHeight(50.0),
-        child: Padding(
-          padding: const EdgeInsets.symmetric(horizontal: 12.0),
-          child: Row(
-            mainAxisAlignment: MainAxisAlignment.spaceBetween,
-            children: [
-              Expanded(
-                child: Padding(
-                  padding: const EdgeInsets.only(right: 4.0),
-                  child: ElevatedButton.icon(
-                    onPressed: () {
-                      AutoRouter.of(context)
-                          .push(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.bold,
-                        fontSize: 11,
-                        // color: Theme.of(context).primaryColor,
-                      ),
-                    ).tr(),
-                  ),
-                ),
-              ),
-              Expanded(
-                child: Padding(
-                  padding: const EdgeInsets.only(left: 4.0),
-                  child: ElevatedButton.icon(
-                    onPressed: null,
-                    icon: const Icon(
-                      Icons.swap_horizontal_circle_outlined,
-                      size: 20,
-                    ),
-                    label: const Text(
-                      "sharing_silver_appbar_share_partner",
-                      style: TextStyle(
-                        fontWeight: FontWeight.bold,
-                        fontSize: 11,
-                      ),
-                      maxLines: 1,
-                    ).tr(),
-                  ),
-                ),
-              )
-            ],
-          ),
-        ),
-      ),
-    );
-  }
-}

+ 3 - 1
mobile/lib/modules/album/views/asset_selection_page.dart

@@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
 import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/providers/user.provider.dart';
 
 class AssetSelectionPage extends HookConsumerWidget {
   const AssetSelectionPage({
@@ -21,7 +22,8 @@ class AssetSelectionPage extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final renderList = ref.watch(remoteAssetsProvider);
+    final currentUser = ref.watch(currentUserProvider);
+    final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId));
     final selected = useState<Set<Asset>>(existingAssets);
     final selectionEnabledHook = useState(true);
 

+ 1 - 1
mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart

@@ -17,7 +17,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final AsyncValue<List<User>> suggestedShareUsers =
-        ref.watch(suggestedSharedUsersProvider);
+        ref.watch(otherUsersProvider);
     final sharedUsersList = useState<Set<User>>({});
 
     addNewUsersHandler() {

+ 1 - 2
mobile/lib/modules/album/views/select_user_for_sharing_page.dart

@@ -20,8 +20,7 @@ class SelectUserForSharingPage extends HookConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final sharedUsersList = useState<Set<User>>({});
-    AsyncValue<List<User>> suggestedShareUsers =
-        ref.watch(suggestedSharedUsersProvider);
+    final suggestedShareUsers = ref.watch(otherUsersProvider);
 
     createSharedAlbum() async {
       var newAlbum =

+ 95 - 9
mobile/lib/modules/album/views/sharing_page.dart

@@ -5,10 +5,11 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
 import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart';
-import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
+import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
+import 'package:immich_mobile/modules/partner/ui/partner_list.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/models/album.dart';
-import 'package:immich_mobile/shared/models/store.dart' as store;
+import 'package:immich_mobile/shared/providers/user.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_image.dart';
 
 class SharingPage extends HookConsumerWidget {
@@ -17,7 +18,8 @@ class SharingPage extends HookConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
-    final userId = store.Store.get(store.StoreKey.currentUser).id;
+    final userId = ref.watch(currentUserProvider)?.id;
+    final partner = ref.watch(partnerSharedWithProvider);
     var isDarkMode = Theme.of(context).brightness == Brightness.dark;
 
     useEffect(
@@ -63,8 +65,7 @@ class SharingPage extends HookConsumerWidget {
             final isOwner = album.ownerId == userId;
 
             return ListTile(
-              contentPadding:
-                  const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
+              contentPadding: const EdgeInsets.symmetric(horizontal: 12),
               leading: ClipRRect(
                 borderRadius: BorderRadius.circular(8),
                 child: ImmichImage(
@@ -93,7 +94,8 @@ class SharingPage extends HookConsumerWidget {
                     )
                   : album.ownerName != null
                       ? Text(
-                          'album_thumbnail_shared_by'.tr(args: [album.ownerName!]),
+                          'album_thumbnail_shared_by'
+                              .tr(args: [album.ownerName!]),
                           style: const TextStyle(
                             fontSize: 12.0,
                           ),
@@ -110,6 +112,75 @@ class SharingPage extends HookConsumerWidget {
       );
     }
 
+    buildTopBottons() {
+      return Padding(
+        padding: const EdgeInsets.only(
+          left: 12.0,
+          right: 12.0,
+          bottom: 12.0,
+        ),
+        child: Row(
+          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+          children: [
+            Expanded(
+              child: ElevatedButton.icon(
+                onPressed: () {
+                  AutoRouter.of(context)
+                      .push(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.bold,
+                    fontSize: 11,
+                  ),
+                ).tr(),
+              ),
+            ),
+            const SizedBox(width: 12.0),
+            Expanded(
+              child: ElevatedButton.icon(
+                onPressed: () =>
+                    AutoRouter.of(context).push(const PartnerRoute()),
+                icon: const Icon(
+                  Icons.swap_horizontal_circle_outlined,
+                  size: 20,
+                ),
+                label: const Text(
+                  "sharing_silver_appbar_share_partner",
+                  style: TextStyle(
+                    fontWeight: FontWeight.bold,
+                    fontSize: 11,
+                  ),
+                  maxLines: 1,
+                ).tr(),
+              ),
+            )
+          ],
+        ),
+      );
+    }
+
+    AppBar buildAppBar() {
+      return AppBar(
+        centerTitle: true,
+        automaticallyImplyLeading: false,
+        title: const Text(
+          'IMMICH',
+          style: TextStyle(
+            fontFamily: 'SnowburstOne',
+            fontWeight: FontWeight.bold,
+            fontSize: 22,
+          ),
+        ),
+      );
+    }
+
     buildEmptyListIndication() {
       return SliverToBoxAdapter(
         child: Padding(
@@ -123,7 +194,6 @@ class SharingPage extends HookConsumerWidget {
                 width: 0.5,
               ),
             ),
-            // color: Colors.transparent,
             child: Padding(
               padding: const EdgeInsets.all(18.0),
               child: Column(
@@ -160,11 +230,27 @@ class SharingPage extends HookConsumerWidget {
     }
 
     return Scaffold(
+      appBar: buildAppBar(),
       body: CustomScrollView(
         slivers: [
-          const SharingSliverAppBar(),
+          SliverToBoxAdapter(child: buildTopBottons()),
+          if (partner.isNotEmpty)
+            SliverPadding(
+              padding: const EdgeInsets.only(left: 12, right: 12, bottom: 4),
+              sliver: SliverToBoxAdapter(
+                child: const Text(
+                  "partner_page_title",
+                  style: TextStyle(fontWeight: FontWeight.bold),
+                ).tr(),
+              ),
+            ),
+          if (partner.isNotEmpty) PartnerList(partner: partner),
           SliverPadding(
-            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
+            padding: EdgeInsets.only(
+              left: 12,
+              right: 12,
+              top: partner.isEmpty ? 0 : 16,
+            ),
             sliver: SliverToBoxAdapter(
               child: const Text(
                 "sharing_page_album",

+ 4 - 2
mobile/lib/modules/archive/providers/archive_asset_provider.dart

@@ -3,16 +3,18 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/providers/user.provider.dart';
 import 'package:isar/isar.dart';
 
 final archiveProvider = StreamProvider<RenderList>((ref) async* {
+  final user = ref.watch(currentUserProvider);
+  if (user == null) return;
   final query = ref
       .watch(dbProvider)
       .assets
       .filter()
-      .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
+      .ownerIdEqualTo(user.isarId)
       .isArchivedEqualTo(true)
       .sortByFileCreatedAt();
   final settings = ref.watch(appSettingsServiceProvider);

+ 5 - 4
mobile/lib/modules/asset_viewer/ui/description_input.dart

@@ -4,9 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/asset_description.provider.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/providers/user.provider.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:logging/logging.dart';
-import 'package:immich_mobile/shared/models/store.dart' as store;
 
 class DescriptionInput extends HookConsumerWidget {
   DescriptionInput({
@@ -25,9 +25,10 @@ class DescriptionInput extends HookConsumerWidget {
     final focusNode = useFocusNode();
     final isFocus = useState(false);
     final isTextEmpty = useState(controller.text.isEmpty);
-    final descriptionProvider = ref.watch(assetDescriptionProvider(asset).notifier);
+    final descriptionProvider =
+        ref.watch(assetDescriptionProvider(asset).notifier);
     final description = ref.watch(assetDescriptionProvider(asset));
-    final owner = store.Store.get(store.StoreKey.currentUser);
+    final owner = ref.watch(currentUserProvider);
     final hasError = useState(false);
 
     controller.text = description;
@@ -67,7 +68,7 @@ class DescriptionInput extends HookConsumerWidget {
     }
 
     return TextField(
-      enabled: owner.isarId == asset.ownerId,
+      enabled: owner?.isarId == asset.ownerId,
       focusNode: focusNode,
       onTap: () => isFocus.value = true,
       onChanged: (value) {

+ 4 - 2
mobile/lib/modules/favorite/providers/favorite_provider.dart

@@ -3,16 +3,18 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/providers/user.provider.dart';
 import 'package:isar/isar.dart';
 
 final favoriteAssetsProvider = StreamProvider<RenderList>((ref) async* {
+  final user = ref.watch(currentUserProvider);
+  if (user == null) return;
   final query = ref
       .watch(dbProvider)
       .assets
       .filter()
-      .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
+      .ownerIdEqualTo(user.isarId)
       .isFavoriteEqualTo(true)
       .sortByFileCreatedAt();
   final settings = ref.watch(appSettingsServiceProvider);

+ 11 - 42
mobile/lib/modules/home/ui/delete_dialog.dart

@@ -1,47 +1,16 @@
-import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
 
-class DeleteDialog extends ConsumerWidget {
+class DeleteDialog extends ConfirmDialog {
   final Function onDelete;
 
-  const DeleteDialog({Key? key, required this.onDelete}) : super(key: key);
-
-  @override
-  Widget build(BuildContext context, WidgetRef ref) {
-
-    return AlertDialog(
-      // backgroundColor: Colors.grey[200],
-      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
-      title: const Text("delete_dialog_title").tr(),
-      content: const Text("delete_dialog_alert").tr(),
-      actions: [
-        TextButton(
-          onPressed: () {
-            Navigator.of(context).pop();
-          },
-          child: Text(
-            "delete_dialog_cancel",
-            style: TextStyle(
-              color: Theme.of(context).primaryColor,
-              fontWeight: FontWeight.bold,
-            ),
-          ).tr(),
-        ),
-        TextButton(
-          onPressed: () {
-            onDelete();
-            Navigator.of(context).pop();
-          },
-          child: Text(
-            "delete_dialog_ok",
-            style: TextStyle(
-              color: Colors.red[400],
-              fontWeight: FontWeight.bold,
-            ),
-          ).tr(),
-        ),
-      ],
-    );
-  }
+  const DeleteDialog({Key? key, required this.onDelete})
+      : super(
+          key: key,
+          title: "delete_dialog_title",
+          content: "delete_dialog_alert",
+          cancel: "delete_dialog_cancel",
+          ok: "delete_dialog_ok",
+          onOk: onDelete,
+        );
 }

+ 3 - 1
mobile/lib/modules/home/views/home_page.dart

@@ -20,6 +20,7 @@ import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
+import 'package:immich_mobile/shared/providers/user.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 import 'package:immich_mobile/shared/services/share.service.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
@@ -38,6 +39,7 @@ class HomePage extends HookConsumerWidget {
     final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
     final sharedAlbums = ref.watch(sharedAlbumProvider);
     final albumService = ref.watch(albumServiceProvider);
+    final currentUser = ref.watch(currentUserProvider);
 
     final tipOneOpacity = useState(0.0);
     final refreshCount = useState(0);
@@ -300,7 +302,7 @@ class HomePage extends HookConsumerWidget {
         bottom: false,
         child: Stack(
           children: [
-            ref.watch(assetsProvider).when(
+            ref.watch(assetsProvider(currentUser?.isarId)).when(
                   data: (data) => data.isEmpty
                       ? buildLoadingIndicator()
                       : ImmichAssetGrid(

+ 50 - 0
mobile/lib/modules/partner/providers/partner.provider.dart

@@ -0,0 +1,50 @@
+import 'dart:async';
+
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart';
+import 'package:immich_mobile/shared/models/user.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:isar/isar.dart';
+
+class PartnerSharedWithNotifier extends StateNotifier<List<User>> {
+  PartnerSharedWithNotifier(Isar db) : super([]) {
+    final query = db.users.filter().isPartnerSharedWithEqualTo(true);
+    query.findAll().then((partners) => state = partners);
+    query.watch().listen((partners) => state = partners);
+  }
+}
+
+final partnerSharedWithProvider =
+    StateNotifierProvider<PartnerSharedWithNotifier, List<User>>((ref) {
+  return PartnerSharedWithNotifier(ref.watch(dbProvider));
+});
+
+class PartnerSharedByNotifier extends StateNotifier<List<User>> {
+  PartnerSharedByNotifier(Isar db) : super([]) {
+    final query = db.users.filter().isPartnerSharedByEqualTo(true);
+    query.findAll().then((partners) => state = partners);
+    streamSub = query.watch().listen((partners) => state = partners);
+  }
+
+  late final StreamSubscription<List<User>> streamSub;
+
+  @override
+  void dispose() {
+    streamSub.cancel();
+    super.dispose();
+  }
+}
+
+final partnerSharedByProvider =
+    StateNotifierProvider<PartnerSharedByNotifier, List<User>>((ref) {
+  return PartnerSharedByNotifier(ref.watch(dbProvider));
+});
+
+final partnerAvailableProvider =
+    FutureProvider.autoDispose<List<User>>((ref) async {
+  final otherUsers = await ref.watch(otherUsersProvider.future);
+  final currentPartners = ref.watch(partnerSharedByProvider);
+  final available = Set<User>.of(otherUsers);
+  available.removeAll(currentPartners);
+  return available.toList();
+});

+ 72 - 0
mobile/lib/modules/partner/services/partner.service.dart

@@ -0,0 +1,72 @@
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/user.dart';
+import 'package:immich_mobile/shared/providers/api.provider.dart';
+import 'package:immich_mobile/shared/providers/db.provider.dart';
+import 'package:immich_mobile/shared/services/api.service.dart';
+import 'package:isar/isar.dart';
+import 'package:logging/logging.dart';
+
+final partnerServiceProvider = Provider(
+  (ref) => PartnerService(
+    ref.watch(apiServiceProvider),
+    ref.watch(dbProvider),
+  ),
+);
+
+enum PartnerDirection {
+  sharedWith("shared-with"),
+  sharedBy("shared-by");
+
+  const PartnerDirection(
+    this._value,
+  );
+
+  final String _value;
+}
+
+class PartnerService {
+  final ApiService _apiService;
+  final Isar _db;
+  final Logger _log = Logger("PartnerService");
+
+  PartnerService(this._apiService, this._db);
+
+  Future<List<User>?> getPartners(PartnerDirection direction) async {
+    try {
+      final userDtos =
+          await _apiService.partnerApi.getPartners(direction._value);
+      if (userDtos != null) {
+        return userDtos.map((u) => User.fromDto(u)).toList();
+      }
+    } catch (e) {
+      _log.warning("failed to get partners for direction $direction:\n$e");
+    }
+    return null;
+  }
+
+  Future<bool> removePartner(User partner) async {
+    try {
+      await _apiService.partnerApi.removePartner(partner.id);
+      partner.isPartnerSharedBy = false;
+      await _db.writeTxn(() => _db.users.put(partner));
+    } catch (e) {
+      _log.warning("failed to remove partner ${partner.id}:\n$e");
+      return false;
+    }
+    return true;
+  }
+
+  Future<bool> addPartner(User partner) async {
+    try {
+      final dto = await _apiService.partnerApi.createPartner(partner.id);
+      if (dto != null) {
+        partner.isPartnerSharedBy = true;
+        await _db.writeTxn(() => _db.users.put(partner));
+        return true;
+      }
+    } catch (e) {
+      _log.warning("failed to add partner ${partner.id}:\n$e");
+    }
+    return false;
+  }
+}

+ 30 - 0
mobile/lib/modules/partner/ui/partner_list.dart

@@ -0,0 +1,30 @@
+import 'package:auto_route/auto_route.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/models/user.dart';
+import 'package:immich_mobile/shared/ui/user_avatar.dart';
+
+class PartnerList extends HookConsumerWidget {
+  const PartnerList({Key? key, required this.partner}) : super(key: key);
+
+  final List<User> 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.symmetric(horizontal: 12.0),
+      leading: userAvatar(context, p, radius: 30),
+      title: Text("${p.firstName} ${p.lastName}"),
+      onTap: () => AutoRouter.of(context).push(PartnerDetailRoute(partner: p)),
+    );
+  }
+}

+ 40 - 0
mobile/lib/modules/partner/views/partner_detail_page.dart

@@ -0,0 +1,40 @@
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
+import 'package:immich_mobile/shared/models/user.dart';
+import 'package:immich_mobile/shared/providers/asset.provider.dart';
+import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
+
+class PartnerDetailPage extends HookConsumerWidget {
+  const PartnerDetailPage({Key? key, required this.partner}) : super(key: key);
+
+  final User partner;
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final assets = ref.watch(assetsProvider(partner.isarId));
+
+    return Scaffold(
+      appBar: AppBar(
+        title: Text("${partner.firstName} ${partner.lastName}"),
+        elevation: 0,
+        centerTitle: false,
+      ),
+      body: assets.when(
+        data: (renderList) => renderList.isEmpty
+            ? Padding(
+                padding: const EdgeInsets.all(16),
+                child: Text(
+                    "It seems ${partner.firstName} does not have any photos...\n"
+                    "Or your server version does not match the app version."),
+              )
+            : ImmichAssetGrid(
+                renderList: renderList,
+                onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(),
+              ),
+        error: (e, _) => Text("Error loading partners:\n$e"),
+        loading: () => const Center(child: ImmichLoadingIndicator()),
+      ),
+    );
+  }
+}

+ 160 - 0
mobile/lib/modules/partner/views/partner_page.dart

@@ -0,0 +1,160 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
+import 'package:immich_mobile/modules/partner/services/partner.service.dart';
+import 'package:immich_mobile/shared/models/user.dart';
+import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
+import 'package:immich_mobile/shared/ui/user_avatar.dart';
+
+class PartnerPage extends HookConsumerWidget {
+  const PartnerPage({Key? key}) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final List<User> partners = ref.watch(partnerSharedByProvider);
+    final availableUsers = ref.watch(partnerAvailableProvider);
+
+    addNewUsersHandler() async {
+      final users = availableUsers.value;
+      if (users == null || users.isEmpty) {
+        ImmichToast.show(
+          context: context,
+          msg: "partner_page_no_more_users".tr(),
+        );
+        return;
+      }
+
+      final selectedUser = await showDialog<User>(
+        context: context,
+        builder: (context) {
+          return SimpleDialog(
+            title: const Text("partner_page_select_partner").tr(),
+            children: [
+              for (User u in users)
+                SimpleDialogOption(
+                  onPressed: () => Navigator.pop(context, u),
+                  child: Row(
+                    children: [
+                      Padding(
+                        padding: const EdgeInsets.only(right: 8),
+                        child: userAvatar(context, u),
+                      ),
+                      Text("${u.firstName} ${u.lastName}"),
+                    ],
+                  ),
+                )
+            ],
+          );
+        },
+      );
+      if (selectedUser != null) {
+        final ok =
+            await ref.read(partnerServiceProvider).addPartner(selectedUser);
+        if (ok) {
+          ref.invalidate(partnerSharedByProvider);
+        } else {
+          ImmichToast.show(
+            context: context,
+            msg: "partner_page_partner_add_failed".tr(),
+            toastType: ToastType.error,
+          );
+        }
+      }
+    }
+
+    onDeleteUser(User u) {
+      return showDialog(
+        context: context,
+        builder: (BuildContext context) {
+          return ConfirmDialog(
+            title: "partner_page_stop_sharing_title",
+            content:
+                "partner_page_stop_sharing_content".tr(args: [u.firstName]),
+            onOk: () => ref.read(partnerServiceProvider).removePartner(u),
+          );
+        },
+      );
+    }
+
+    buildUserList(List<User> users) {
+      return Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Padding(
+            padding: const EdgeInsets.only(left: 16.0, top: 16.0),
+            child: const Text(
+              "partner_page_shared_to_title",
+              style: TextStyle(
+                fontSize: 14,
+                color: Colors.grey,
+                fontWeight: FontWeight.bold,
+              ),
+            ).tr(),
+          ),
+          if (users.isNotEmpty)
+            ListView.builder(
+              shrinkWrap: true,
+              itemCount: users.length,
+              itemBuilder: ((context, index) {
+                return ListTile(
+                  leading: userAvatar(context, users[index]),
+                  title: Text(
+                    users[index].email,
+                    style: const TextStyle(
+                      fontSize: 14,
+                      fontWeight: FontWeight.bold,
+                    ),
+                  ),
+                  trailing: IconButton(
+                    icon: const Icon(Icons.person_remove),
+                    onPressed: () => onDeleteUser(users[index]),
+                  ),
+                );
+              }),
+            ),
+          if (users.isEmpty)
+            Padding(
+              padding: const EdgeInsets.symmetric(horizontal: 16.0),
+              child: Column(
+                children: [
+                  Padding(
+                    padding: const EdgeInsets.symmetric(vertical: 8),
+                    child: const Text(
+                      "partner_page_empty_message",
+                      style: TextStyle(fontSize: 14),
+                    ).tr(),
+                  ),
+                  ElevatedButton.icon(
+                    onPressed: availableUsers.whenOrNull(
+                      data: (data) => addNewUsersHandler,
+                    ),
+                    icon: const Icon(Icons.person_add),
+                    label: const Text("partner_page_add_partner").tr(),
+                  ),
+                ],
+              ),
+            ),
+        ],
+      );
+    }
+
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text("partner_page_title").tr(),
+        elevation: 0,
+        centerTitle: false,
+        actions: [
+          IconButton(
+            onPressed:
+                availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
+            icon: const Icon(Icons.person_add),
+            tooltip: "partner_page_add_partner".tr(),
+          )
+        ],
+      ),
+      body: buildUserList(partners),
+    );
+  }
+}

+ 5 - 0
mobile/lib/routing/router.dart

@@ -6,6 +6,8 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
 import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
 import 'package:immich_mobile/modules/album/views/create_album_page.dart';
 import 'package:immich_mobile/modules/album/views/library_page.dart';
+import 'package:immich_mobile/modules/partner/views/partner_detail_page.dart';
+import 'package:immich_mobile/modules/partner/views/partner_page.dart';
 import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart';
 import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
 import 'package:immich_mobile/modules/album/views/sharing_page.dart';
@@ -35,6 +37,7 @@ import 'package:immich_mobile/routing/duplicate_guard.dart';
 import 'package:immich_mobile/routing/gallery_permission_guard.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/album.dart';
+import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/models/logger_message.model.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
@@ -136,6 +139,8 @@ part 'router.gr.dart';
         DuplicateGuard,
       ],
     ),
+    AutoRoute(page: PartnerPage, guards: [AuthGuard, DuplicateGuard]),
+    AutoRoute(page: PartnerDetailPage, guards: [AuthGuard, DuplicateGuard])
   ],
 )
 class AppRouter extends _$AppRouter {

+ 78 - 0
mobile/lib/routing/router.gr.dart

@@ -256,6 +256,22 @@ class _$AppRouter extends RootStackRouter {
         child: const ArchivePage(),
       );
     },
+    PartnerRoute.name: (routeData) {
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: const PartnerPage(),
+      );
+    },
+    PartnerDetailRoute.name: (routeData) {
+      final args = routeData.argsAs<PartnerDetailRouteArgs>();
+      return MaterialPageX<dynamic>(
+        routeData: routeData,
+        child: PartnerDetailPage(
+          key: args.key,
+          partner: args.partner,
+        ),
+      );
+    },
     HomeRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
         routeData: routeData,
@@ -523,6 +539,22 @@ class _$AppRouter extends RootStackRouter {
             duplicateGuard,
           ],
         ),
+        RouteConfig(
+          PartnerRoute.name,
+          path: '/partner-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
+        RouteConfig(
+          PartnerDetailRoute.name,
+          path: '/partner-detail-page',
+          guards: [
+            authGuard,
+            duplicateGuard,
+          ],
+        ),
       ];
 }
 
@@ -1113,6 +1145,52 @@ class ArchiveRoute extends PageRouteInfo<void> {
   static const String name = 'ArchiveRoute';
 }
 
+/// generated route for
+/// [PartnerPage]
+class PartnerRoute extends PageRouteInfo<void> {
+  const PartnerRoute()
+      : super(
+          PartnerRoute.name,
+          path: '/partner-page',
+        );
+
+  static const String name = 'PartnerRoute';
+}
+
+/// generated route for
+/// [PartnerDetailPage]
+class PartnerDetailRoute extends PageRouteInfo<PartnerDetailRouteArgs> {
+  PartnerDetailRoute({
+    Key? key,
+    required User partner,
+  }) : super(
+          PartnerDetailRoute.name,
+          path: '/partner-detail-page',
+          args: PartnerDetailRouteArgs(
+            key: key,
+            partner: partner,
+          ),
+        );
+
+  static const String name = 'PartnerDetailRoute';
+}
+
+class PartnerDetailRouteArgs {
+  const PartnerDetailRouteArgs({
+    this.key,
+    required this.partner,
+  });
+
+  final Key? key;
+
+  final User partner;
+
+  @override
+  String toString() {
+    return 'PartnerDetailRouteArgs{key: $key, partner: $partner}';
+  }
+}
+
 /// generated route for
 /// [HomePage]
 class HomeRoute extends PageRouteInfo<void> {

+ 2 - 2
mobile/lib/shared/models/album.dart

@@ -87,8 +87,8 @@ class Album {
         remoteId == other.remoteId &&
         localId == other.localId &&
         name == other.name &&
-        createdAt == other.createdAt &&
-        modifiedAt == other.modifiedAt &&
+        createdAt.isAtSameMomentAs(other.createdAt) &&
+        modifiedAt.isAtSameMomentAs(other.modifiedAt) &&
         shared == other.shared &&
         owner.value == other.owner.value &&
         thumbnail.value == other.thumbnail.value &&

+ 3 - 3
mobile/lib/shared/models/asset.dart

@@ -179,9 +179,9 @@ class Asset {
         localId == other.localId &&
         deviceId == other.deviceId &&
         ownerId == other.ownerId &&
-        fileCreatedAt == other.fileCreatedAt &&
-        fileModifiedAt == other.fileModifiedAt &&
-        updatedAt == other.updatedAt &&
+        fileCreatedAt.isAtSameMomentAs(other.fileCreatedAt) &&
+        fileModifiedAt.isAtSameMomentAs(other.fileModifiedAt) &&
+        updatedAt.isAtSameMomentAs(other.updatedAt) &&
         durationInSeconds == other.durationInSeconds &&
         type == other.type &&
         width == other.width &&

+ 13 - 0
mobile/lib/shared/models/etag.dart

@@ -0,0 +1,13 @@
+import 'package:immich_mobile/utils/hash.dart';
+import 'package:isar/isar.dart';
+
+part 'etag.g.dart';
+
+@Collection(inheritance: false)
+class ETag {
+  ETag({required this.id, this.value});
+  Id get isarId => fastHash(id);
+  @Index(unique: true, replace: true, type: IndexType.hash)
+  String id;
+  String? value;
+}

+ 724 - 0
mobile/lib/shared/models/etag.g.dart

@@ -0,0 +1,724 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'etag.dart';
+
+// **************************************************************************
+// IsarCollectionGenerator
+// **************************************************************************
+
+// coverage:ignore-file
+// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters
+
+extension GetETagCollection on Isar {
+  IsarCollection<ETag> get eTags => this.collection();
+}
+
+const ETagSchema = CollectionSchema(
+  name: r'ETag',
+  id: -644290296585643859,
+  properties: {
+    r'id': PropertySchema(
+      id: 0,
+      name: r'id',
+      type: IsarType.string,
+    ),
+    r'value': PropertySchema(
+      id: 1,
+      name: r'value',
+      type: IsarType.string,
+    )
+  },
+  estimateSize: _eTagEstimateSize,
+  serialize: _eTagSerialize,
+  deserialize: _eTagDeserialize,
+  deserializeProp: _eTagDeserializeProp,
+  idName: r'isarId',
+  indexes: {
+    r'id': IndexSchema(
+      id: -3268401673993471357,
+      name: r'id',
+      unique: true,
+      replace: true,
+      properties: [
+        IndexPropertySchema(
+          name: r'id',
+          type: IndexType.hash,
+          caseSensitive: true,
+        )
+      ],
+    )
+  },
+  links: {},
+  embeddedSchemas: {},
+  getId: _eTagGetId,
+  getLinks: _eTagGetLinks,
+  attach: _eTagAttach,
+  version: '3.0.5',
+);
+
+int _eTagEstimateSize(
+  ETag object,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  var bytesCount = offsets.last;
+  bytesCount += 3 + object.id.length * 3;
+  {
+    final value = object.value;
+    if (value != null) {
+      bytesCount += 3 + value.length * 3;
+    }
+  }
+  return bytesCount;
+}
+
+void _eTagSerialize(
+  ETag object,
+  IsarWriter writer,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  writer.writeString(offsets[0], object.id);
+  writer.writeString(offsets[1], object.value);
+}
+
+ETag _eTagDeserialize(
+  Id id,
+  IsarReader reader,
+  List<int> offsets,
+  Map<Type, List<int>> allOffsets,
+) {
+  final object = ETag(
+    id: reader.readString(offsets[0]),
+    value: reader.readStringOrNull(offsets[1]),
+  );
+  return object;
+}
+
+P _eTagDeserializeProp<P>(
+  IsarReader reader,
+  int propertyId,
+  int offset,
+  Map<Type, List<int>> allOffsets,
+) {
+  switch (propertyId) {
+    case 0:
+      return (reader.readString(offset)) as P;
+    case 1:
+      return (reader.readStringOrNull(offset)) as P;
+    default:
+      throw IsarError('Unknown property with id $propertyId');
+  }
+}
+
+Id _eTagGetId(ETag object) {
+  return object.isarId;
+}
+
+List<IsarLinkBase<dynamic>> _eTagGetLinks(ETag object) {
+  return [];
+}
+
+void _eTagAttach(IsarCollection<dynamic> col, Id id, ETag object) {}
+
+extension ETagByIndex on IsarCollection<ETag> {
+  Future<ETag?> getById(String id) {
+    return getByIndex(r'id', [id]);
+  }
+
+  ETag? getByIdSync(String id) {
+    return getByIndexSync(r'id', [id]);
+  }
+
+  Future<bool> deleteById(String id) {
+    return deleteByIndex(r'id', [id]);
+  }
+
+  bool deleteByIdSync(String id) {
+    return deleteByIndexSync(r'id', [id]);
+  }
+
+  Future<List<ETag?>> getAllById(List<String> idValues) {
+    final values = idValues.map((e) => [e]).toList();
+    return getAllByIndex(r'id', values);
+  }
+
+  List<ETag?> getAllByIdSync(List<String> idValues) {
+    final values = idValues.map((e) => [e]).toList();
+    return getAllByIndexSync(r'id', values);
+  }
+
+  Future<int> deleteAllById(List<String> idValues) {
+    final values = idValues.map((e) => [e]).toList();
+    return deleteAllByIndex(r'id', values);
+  }
+
+  int deleteAllByIdSync(List<String> idValues) {
+    final values = idValues.map((e) => [e]).toList();
+    return deleteAllByIndexSync(r'id', values);
+  }
+
+  Future<Id> putById(ETag object) {
+    return putByIndex(r'id', object);
+  }
+
+  Id putByIdSync(ETag object, {bool saveLinks = true}) {
+    return putByIndexSync(r'id', object, saveLinks: saveLinks);
+  }
+
+  Future<List<Id>> putAllById(List<ETag> objects) {
+    return putAllByIndex(r'id', objects);
+  }
+
+  List<Id> putAllByIdSync(List<ETag> objects, {bool saveLinks = true}) {
+    return putAllByIndexSync(r'id', objects, saveLinks: saveLinks);
+  }
+}
+
+extension ETagQueryWhereSort on QueryBuilder<ETag, ETag, QWhere> {
+  QueryBuilder<ETag, ETag, QAfterWhere> anyIsarId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(const IdWhereClause.any());
+    });
+  }
+}
+
+extension ETagQueryWhere on QueryBuilder<ETag, ETag, QWhereClause> {
+  QueryBuilder<ETag, ETag, QAfterWhereClause> isarIdEqualTo(Id isarId) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IdWhereClause.between(
+        lower: isarId,
+        upper: isarId,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterWhereClause> isarIdNotEqualTo(Id isarId) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(
+              IdWhereClause.lessThan(upper: isarId, includeUpper: false),
+            )
+            .addWhereClause(
+              IdWhereClause.greaterThan(lower: isarId, includeLower: false),
+            );
+      } else {
+        return query
+            .addWhereClause(
+              IdWhereClause.greaterThan(lower: isarId, includeLower: false),
+            )
+            .addWhereClause(
+              IdWhereClause.lessThan(upper: isarId, includeUpper: false),
+            );
+      }
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterWhereClause> isarIdGreaterThan(Id isarId,
+      {bool include = false}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(
+        IdWhereClause.greaterThan(lower: isarId, includeLower: include),
+      );
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterWhereClause> isarIdLessThan(Id isarId,
+      {bool include = false}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(
+        IdWhereClause.lessThan(upper: isarId, includeUpper: include),
+      );
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterWhereClause> isarIdBetween(
+    Id lowerIsarId,
+    Id upperIsarId, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IdWhereClause.between(
+        lower: lowerIsarId,
+        includeLower: includeLower,
+        upper: upperIsarId,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterWhereClause> idEqualTo(String id) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addWhereClause(IndexWhereClause.equalTo(
+        indexName: r'id',
+        value: [id],
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterWhereClause> idNotEqualTo(String id) {
+    return QueryBuilder.apply(this, (query) {
+      if (query.whereSort == Sort.asc) {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'id',
+              lower: [],
+              upper: [id],
+              includeUpper: false,
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'id',
+              lower: [id],
+              includeLower: false,
+              upper: [],
+            ));
+      } else {
+        return query
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'id',
+              lower: [id],
+              includeLower: false,
+              upper: [],
+            ))
+            .addWhereClause(IndexWhereClause.between(
+              indexName: r'id',
+              lower: [],
+              upper: [id],
+              includeUpper: false,
+            ));
+      }
+    });
+  }
+}
+
+extension ETagQueryFilter on QueryBuilder<ETag, ETag, QFilterCondition> {
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> idEqualTo(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> idGreaterThan(
+    String value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> idLessThan(
+    String value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> idBetween(
+    String lower,
+    String upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'id',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> idStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> idEndsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> idContains(String value,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'id',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> idMatches(String pattern,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'id',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> idIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'id',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> idIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'id',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> isarIdEqualTo(Id value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> isarIdGreaterThan(
+    Id value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> isarIdLessThan(
+    Id value, {
+    bool include = false,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'isarId',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> isarIdBetween(
+    Id lower,
+    Id upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'isarId',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueIsNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNull(
+        property: r'value',
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueIsNotNull() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(const FilterCondition.isNotNull(
+        property: r'value',
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueEqualTo(
+    String? value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'value',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueGreaterThan(
+    String? value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        include: include,
+        property: r'value',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueLessThan(
+    String? value, {
+    bool include = false,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.lessThan(
+        include: include,
+        property: r'value',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueBetween(
+    String? lower,
+    String? upper, {
+    bool includeLower = true,
+    bool includeUpper = true,
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.between(
+        property: r'value',
+        lower: lower,
+        includeLower: includeLower,
+        upper: upper,
+        includeUpper: includeUpper,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueStartsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.startsWith(
+        property: r'value',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueEndsWith(
+    String value, {
+    bool caseSensitive = true,
+  }) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.endsWith(
+        property: r'value',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueContains(String value,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.contains(
+        property: r'value',
+        value: value,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueMatches(String pattern,
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.matches(
+        property: r'value',
+        wildcard: pattern,
+        caseSensitive: caseSensitive,
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueIsEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'value',
+        value: '',
+      ));
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterFilterCondition> valueIsNotEmpty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.greaterThan(
+        property: r'value',
+        value: '',
+      ));
+    });
+  }
+}
+
+extension ETagQueryObject on QueryBuilder<ETag, ETag, QFilterCondition> {}
+
+extension ETagQueryLinks on QueryBuilder<ETag, ETag, QFilterCondition> {}
+
+extension ETagQuerySortBy on QueryBuilder<ETag, ETag, QSortBy> {
+  QueryBuilder<ETag, ETag, QAfterSortBy> sortById() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterSortBy> sortByIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.desc);
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterSortBy> sortByValue() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'value', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterSortBy> sortByValueDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'value', Sort.desc);
+    });
+  }
+}
+
+extension ETagQuerySortThenBy on QueryBuilder<ETag, ETag, QSortThenBy> {
+  QueryBuilder<ETag, ETag, QAfterSortBy> thenById() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterSortBy> thenByIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'id', Sort.desc);
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterSortBy> thenByIsarId() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isarId', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterSortBy> thenByIsarIdDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isarId', Sort.desc);
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterSortBy> thenByValue() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'value', Sort.asc);
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QAfterSortBy> thenByValueDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'value', Sort.desc);
+    });
+  }
+}
+
+extension ETagQueryWhereDistinct on QueryBuilder<ETag, ETag, QDistinct> {
+  QueryBuilder<ETag, ETag, QDistinct> distinctById(
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'id', caseSensitive: caseSensitive);
+    });
+  }
+
+  QueryBuilder<ETag, ETag, QDistinct> distinctByValue(
+      {bool caseSensitive = true}) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'value', caseSensitive: caseSensitive);
+    });
+  }
+}
+
+extension ETagQueryProperty on QueryBuilder<ETag, ETag, QQueryProperty> {
+  QueryBuilder<ETag, int, QQueryOperations> isarIdProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'isarId');
+    });
+  }
+
+  QueryBuilder<ETag, String, QQueryOperations> idProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'id');
+    });
+  }
+
+  QueryBuilder<ETag, String?, QQueryOperations> valueProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'value');
+    });
+  }
+}

+ 11 - 1
mobile/lib/shared/models/user.dart

@@ -14,6 +14,8 @@ class User {
     required this.firstName,
     required this.lastName,
     required this.isAdmin,
+    this.isPartnerSharedBy = false,
+    this.isPartnerSharedWith = false,
   });
 
   Id get isarId => fastHash(id);
@@ -26,6 +28,8 @@ class User {
         email = dto.email,
         firstName = dto.firstName,
         lastName = dto.lastName,
+        isPartnerSharedBy = false,
+        isPartnerSharedWith = false,
         isAdmin = dto.isAdmin;
 
   @Index(unique: true, replace: false, type: IndexType.hash)
@@ -34,6 +38,8 @@ class User {
   String email;
   String firstName;
   String lastName;
+  bool isPartnerSharedBy;
+  bool isPartnerSharedWith;
   bool isAdmin;
   @Backlink(to: 'owner')
   final IsarLinks<Album> albums = IsarLinks<Album>();
@@ -44,10 +50,12 @@ class User {
   bool operator ==(other) {
     if (other is! User) return false;
     return id == other.id &&
-        updatedAt == other.updatedAt &&
+        updatedAt.isAtSameMomentAs(other.updatedAt) &&
         email == other.email &&
         firstName == other.firstName &&
         lastName == other.lastName &&
+        isPartnerSharedBy == other.isPartnerSharedBy &&
+        isPartnerSharedWith == other.isPartnerSharedWith &&
         isAdmin == other.isAdmin;
   }
 
@@ -59,5 +67,7 @@ class User {
       email.hashCode ^
       firstName.hashCode ^
       lastName.hashCode ^
+      isPartnerSharedBy.hashCode ^
+      isPartnerSharedWith.hashCode ^
       isAdmin.hashCode;
 }

+ 117 - 7
mobile/lib/shared/models/user.g.dart

@@ -37,13 +37,23 @@ const UserSchema = CollectionSchema(
       name: r'isAdmin',
       type: IsarType.bool,
     ),
-    r'lastName': PropertySchema(
+    r'isPartnerSharedBy': PropertySchema(
       id: 4,
+      name: r'isPartnerSharedBy',
+      type: IsarType.bool,
+    ),
+    r'isPartnerSharedWith': PropertySchema(
+      id: 5,
+      name: r'isPartnerSharedWith',
+      type: IsarType.bool,
+    ),
+    r'lastName': PropertySchema(
+      id: 6,
       name: r'lastName',
       type: IsarType.string,
     ),
     r'updatedAt': PropertySchema(
-      id: 5,
+      id: 7,
       name: r'updatedAt',
       type: IsarType.dateTime,
     )
@@ -114,8 +124,10 @@ void _userSerialize(
   writer.writeString(offsets[1], object.firstName);
   writer.writeString(offsets[2], object.id);
   writer.writeBool(offsets[3], object.isAdmin);
-  writer.writeString(offsets[4], object.lastName);
-  writer.writeDateTime(offsets[5], object.updatedAt);
+  writer.writeBool(offsets[4], object.isPartnerSharedBy);
+  writer.writeBool(offsets[5], object.isPartnerSharedWith);
+  writer.writeString(offsets[6], object.lastName);
+  writer.writeDateTime(offsets[7], object.updatedAt);
 }
 
 User _userDeserialize(
@@ -129,8 +141,10 @@ User _userDeserialize(
     firstName: reader.readString(offsets[1]),
     id: reader.readString(offsets[2]),
     isAdmin: reader.readBool(offsets[3]),
-    lastName: reader.readString(offsets[4]),
-    updatedAt: reader.readDateTime(offsets[5]),
+    isPartnerSharedBy: reader.readBoolOrNull(offsets[4]) ?? false,
+    isPartnerSharedWith: reader.readBoolOrNull(offsets[5]) ?? false,
+    lastName: reader.readString(offsets[6]),
+    updatedAt: reader.readDateTime(offsets[7]),
   );
   return object;
 }
@@ -151,8 +165,12 @@ P _userDeserializeProp<P>(
     case 3:
       return (reader.readBool(offset)) as P;
     case 4:
-      return (reader.readString(offset)) as P;
+      return (reader.readBoolOrNull(offset) ?? false) as P;
     case 5:
+      return (reader.readBoolOrNull(offset) ?? false) as P;
+    case 6:
+      return (reader.readString(offset)) as P;
+    case 7:
       return (reader.readDateTime(offset)) as P;
     default:
       throw IsarError('Unknown property with id $propertyId');
@@ -741,6 +759,26 @@ extension UserQueryFilter on QueryBuilder<User, User, QFilterCondition> {
     });
   }
 
+  QueryBuilder<User, User, QAfterFilterCondition> isPartnerSharedByEqualTo(
+      bool value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'isPartnerSharedBy',
+        value: value,
+      ));
+    });
+  }
+
+  QueryBuilder<User, User, QAfterFilterCondition> isPartnerSharedWithEqualTo(
+      bool value) {
+    return QueryBuilder.apply(this, (query) {
+      return query.addFilterCondition(FilterCondition.equalTo(
+        property: r'isPartnerSharedWith',
+        value: value,
+      ));
+    });
+  }
+
   QueryBuilder<User, User, QAfterFilterCondition> isarIdEqualTo(Id value) {
     return QueryBuilder.apply(this, (query) {
       return query.addFilterCondition(FilterCondition.equalTo(
@@ -1140,6 +1178,30 @@ extension UserQuerySortBy on QueryBuilder<User, User, QSortBy> {
     });
   }
 
+  QueryBuilder<User, User, QAfterSortBy> sortByIsPartnerSharedBy() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isPartnerSharedBy', Sort.asc);
+    });
+  }
+
+  QueryBuilder<User, User, QAfterSortBy> sortByIsPartnerSharedByDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isPartnerSharedBy', Sort.desc);
+    });
+  }
+
+  QueryBuilder<User, User, QAfterSortBy> sortByIsPartnerSharedWith() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isPartnerSharedWith', Sort.asc);
+    });
+  }
+
+  QueryBuilder<User, User, QAfterSortBy> sortByIsPartnerSharedWithDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isPartnerSharedWith', Sort.desc);
+    });
+  }
+
   QueryBuilder<User, User, QAfterSortBy> sortByLastName() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'lastName', Sort.asc);
@@ -1214,6 +1276,30 @@ extension UserQuerySortThenBy on QueryBuilder<User, User, QSortThenBy> {
     });
   }
 
+  QueryBuilder<User, User, QAfterSortBy> thenByIsPartnerSharedBy() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isPartnerSharedBy', Sort.asc);
+    });
+  }
+
+  QueryBuilder<User, User, QAfterSortBy> thenByIsPartnerSharedByDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isPartnerSharedBy', Sort.desc);
+    });
+  }
+
+  QueryBuilder<User, User, QAfterSortBy> thenByIsPartnerSharedWith() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isPartnerSharedWith', Sort.asc);
+    });
+  }
+
+  QueryBuilder<User, User, QAfterSortBy> thenByIsPartnerSharedWithDesc() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addSortBy(r'isPartnerSharedWith', Sort.desc);
+    });
+  }
+
   QueryBuilder<User, User, QAfterSortBy> thenByIsarId() {
     return QueryBuilder.apply(this, (query) {
       return query.addSortBy(r'isarId', Sort.asc);
@@ -1279,6 +1365,18 @@ extension UserQueryWhereDistinct on QueryBuilder<User, User, QDistinct> {
     });
   }
 
+  QueryBuilder<User, User, QDistinct> distinctByIsPartnerSharedBy() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'isPartnerSharedBy');
+    });
+  }
+
+  QueryBuilder<User, User, QDistinct> distinctByIsPartnerSharedWith() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addDistinctBy(r'isPartnerSharedWith');
+    });
+  }
+
   QueryBuilder<User, User, QDistinct> distinctByLastName(
       {bool caseSensitive = true}) {
     return QueryBuilder.apply(this, (query) {
@@ -1324,6 +1422,18 @@ extension UserQueryProperty on QueryBuilder<User, User, QQueryProperty> {
     });
   }
 
+  QueryBuilder<User, bool, QQueryOperations> isPartnerSharedByProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'isPartnerSharedBy');
+    });
+  }
+
+  QueryBuilder<User, bool, QQueryOperations> isPartnerSharedWithProperty() {
+    return QueryBuilder.apply(this, (query) {
+      return query.addPropertyName(r'isPartnerSharedWith');
+    });
+  }
+
   QueryBuilder<User, String, QQueryOperations> lastNameProperty() {
     return QueryBuilder.apply(this, (query) {
       return query.addPropertyName(r'lastName');

+ 18 - 4
mobile/lib/shared/providers/asset.provider.dart

@@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/services/album.service.dart';
 import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/services/asset.service.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
@@ -10,6 +11,7 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/services/sync.service.dart';
+import 'package:immich_mobile/shared/services/user.service.dart';
 import 'package:immich_mobile/utils/db.dart';
 import 'package:isar/isar.dart';
 import 'package:logging/logging.dart';
@@ -23,6 +25,7 @@ class AssetsState {}
 class AssetNotifier extends StateNotifier<AssetsState> {
   final AssetService _assetService;
   final AlbumService _albumService;
+  final UserService _userService;
   final SyncService _syncService;
   final Isar _db;
   final log = Logger('AssetNotifier');
@@ -32,6 +35,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
   AssetNotifier(
     this._assetService,
     this._albumService,
+    this._userService,
     this._syncService,
     this._db,
   ) : super(AssetsState());
@@ -51,6 +55,12 @@ class AssetNotifier extends StateNotifier<AssetsState> {
       final bool newRemote = await _assetService.refreshRemoteAssets();
       final bool newLocal = await _albumService.refreshDeviceAlbums();
       debugPrint("newRemote: $newRemote, newLocal: $newLocal");
+      await _userService.refreshUsers();
+      final List<User> partners =
+          await _db.users.filter().isPartnerSharedWithEqualTo(true).findAll();
+      for (User u in partners) {
+        await _assetService.refreshRemoteAssets(u);
+      }
       log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
     } finally {
       _getAllAssetInProgress = false;
@@ -147,6 +157,7 @@ final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
   return AssetNotifier(
     ref.watch(assetServiceProvider),
     ref.watch(albumServiceProvider),
+    ref.watch(userServiceProvider),
     ref.watch(syncServiceProvider),
     ref.watch(dbProvider),
   );
@@ -161,12 +172,14 @@ final assetDetailProvider =
   }
 });
 
-final assetsProvider = StreamProvider.autoDispose<RenderList>((ref) async* {
+final assetsProvider =
+    StreamProvider.family<RenderList, int?>((ref, userId) async* {
+  if (userId == null) return;
   final query = ref
       .watch(dbProvider)
       .assets
       .filter()
-      .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
+      .ownerIdEqualTo(userId)
       .isArchivedEqualTo(false)
       .sortByFileCreatedAtDesc();
   final settings = ref.watch(appSettingsServiceProvider);
@@ -179,14 +192,15 @@ final assetsProvider = StreamProvider.autoDispose<RenderList>((ref) async* {
 });
 
 final remoteAssetsProvider =
-    StreamProvider.autoDispose<RenderList>((ref) async* {
+    StreamProvider.family<RenderList, int?>((ref, userId) async* {
+  if (userId == null) return;
   final query = ref
       .watch(dbProvider)
       .assets
       .where()
       .remoteIdIsNotNull()
       .filter()
-      .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
+      .ownerIdEqualTo(userId)
       .sortByFileCreatedAt();
   final settings = ref.watch(appSettingsServiceProvider);
   final groupBy =

+ 26 - 0
mobile/lib/shared/providers/user.provider.dart

@@ -0,0 +1,26 @@
+import 'dart:async';
+
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/models/user.dart';
+
+class CurrentUserProvider extends StateNotifier<User?> {
+  CurrentUserProvider() : super(null) {
+    state = Store.tryGet(StoreKey.currentUser);
+    streamSub =
+        Store.watch(StoreKey.currentUser).listen((user) => state = user);
+  }
+
+  late final StreamSubscription<User?> streamSub;
+
+  @override
+  void dispose() {
+    streamSub.cancel();
+    super.dispose();
+  }
+}
+
+final currentUserProvider =
+    StateNotifierProvider<CurrentUserProvider, User?>((ref) {
+  return CurrentUserProvider();
+});

+ 2 - 0
mobile/lib/shared/services/api.service.dart

@@ -16,6 +16,7 @@ class ApiService {
   late AssetApi assetApi;
   late SearchApi searchApi;
   late ServerInfoApi serverInfoApi;
+  late PartnerApi partnerApi;
 
   ApiService() {
     final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@@ -37,6 +38,7 @@ class ApiService {
     assetApi = AssetApi(_apiClient);
     serverInfoApi = ServerInfoApi(_apiClient);
     searchApi = SearchApi(_apiClient);
+    partnerApi = PartnerApi(_apiClient);
   }
 
   Future<String> resolveAndSetEndpoint(String serverUrl) async {

+ 22 - 10
mobile/lib/shared/services/asset.service.dart

@@ -3,8 +3,10 @@ import 'dart:async';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/etag.dart';
 import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
@@ -36,37 +38,47 @@ class AssetService {
 
   /// Checks the server for updated assets and updates the local database if
   /// required. Returns `true` if there were any changes.
-  Future<bool> refreshRemoteAssets() async {
+  Future<bool> refreshRemoteAssets([User? user]) async {
+    user ??= Store.get(StoreKey.currentUser);
     final Stopwatch sw = Stopwatch()..start();
     final int numOwnedRemoteAssets = await _db.assets
         .where()
         .remoteIdIsNotNull()
         .filter()
-        .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
+        .ownerIdEqualTo(user!.isarId)
         .count();
     final bool changes = await _syncService.syncRemoteAssetsToDb(
-      () async => (await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0))
-          ?.map(Asset.remote)
-          .toList(),
+      user,
+      () async => (await _getRemoteAssets(
+        hasCache: numOwnedRemoteAssets > 0,
+        user: user!,
+      )),
     );
     debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
     return changes;
   }
 
   /// Returns `null` if the server state did not change, else list of assets
-  Future<List<AssetResponseDto>?> _getRemoteAssets({
+  Future<List<Asset>?> _getRemoteAssets({
     required bool hasCache,
+    required User user,
   }) async {
     try {
-      final etag = hasCache ? Store.tryGet(StoreKey.assetETag) : null;
+      final etag = hasCache ? _db.eTags.getByIdSync(user.id)?.value : null;
       final (List<AssetResponseDto>? assets, String? newETag) =
-          await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
+          await _apiService.assetApi
+              .getAllAssetsWithETag(eTag: etag, userId: user.id);
       if (assets == null) {
         return null;
+      } else if (assets.isNotEmpty && assets.first.ownerId != user.id) {
+        log.warning("Make sure that server and app versions match!"
+            " The server returned assets for user ${assets.first.ownerId}"
+            " while requesting assets of user ${user.id}");
+        return null;
       } else if (newETag != etag) {
-        Store.put(StoreKey.assetETag, newETag);
+        _db.writeTxn(() => _db.eTags.put(ETag(id: user.id, value: newETag)));
       }
-      return assets;
+      return assets.map(Asset.remote).toList();
     } catch (e, stack) {
       log.severe('Error while getting remote assets', e, stack);
       return null;

+ 19 - 7
mobile/lib/shared/services/sync.service.dart

@@ -40,7 +40,9 @@ class SyncService {
       dbUsers,
       compare: (User a, User b) => a.id.compareTo(b.id),
       both: (User a, User b) {
-        if (!a.updatedAt.isAtSameMomentAs(b.updatedAt)) {
+        if (!a.updatedAt.isAtSameMomentAs(b.updatedAt) ||
+            a.isPartnerSharedBy != b.isPartnerSharedBy ||
+            a.isPartnerSharedWith != b.isPartnerSharedWith) {
           toUpsert.add(a);
           return true;
         }
@@ -61,9 +63,10 @@ class SyncService {
   /// Syncs remote assets owned by the logged-in user to the DB
   /// Returns `true` if there were any changes
   Future<bool> syncRemoteAssetsToDb(
+    User user,
     FutureOr<List<Asset>?> Function() loadAssets,
   ) =>
-      _lock.run(() => _syncRemoteAssetsToDb(loadAssets));
+      _lock.run(() => _syncRemoteAssetsToDb(user, loadAssets));
 
   /// Syncs remote albums to the database
   /// returns `true` if there were any changes
@@ -149,13 +152,13 @@ class SyncService {
   /// Syncs remote assets to the databas
   /// returns `true` if there were any changes
   Future<bool> _syncRemoteAssetsToDb(
+    User user,
     FutureOr<List<Asset>?> Function() loadAssets,
   ) async {
     final List<Asset>? remote = await loadAssets();
     if (remote == null) {
       return false;
     }
-    final User user = Store.get(StoreKey.currentUser);
     final List<Asset> inDb = await _db.assets
         .filter()
         .ownerIdEqualTo(user.isarId)
@@ -349,10 +352,19 @@ class SyncService {
       );
     } else if (album.shared) {
       final User user = Store.get(StoreKey.currentUser);
-      // delete assets in DB unless they belong to this user or are part of some other shared album
-      deleteCandidates.addAll(
-        await album.assets.filter().not().ownerIdEqualTo(user.isarId).findAll(),
-      );
+      // delete assets in DB unless they belong to this user or are part of some other shared album or belong to a partner
+      final userIds = await _db.users
+          .filter()
+          .isPartnerSharedWithEqualTo(true)
+          .isarIdProperty()
+          .findAll();
+      userIds.add(user.isarId);
+      final orphanedAssets = await album.assets
+          .filter()
+          .not()
+          .anyOf(userIds, (q, int id) => q.ownerIdEqualTo(id))
+          .findAll();
+      deleteCandidates.addAll(orphanedAssets);
     }
     try {
       final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id));

+ 45 - 5
mobile/lib/shared/services/user.service.dart

@@ -1,16 +1,19 @@
-import 'package:flutter/material.dart';
+import 'package:collection/collection.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:http/http.dart';
 import 'package:http_parser/http_parser.dart';
 import 'package:image_picker/image_picker.dart';
+import 'package:immich_mobile/modules/partner/services/partner.service.dart';
 import 'package:immich_mobile/shared/models/store.dart';
 import 'package:immich_mobile/shared/models/user.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/providers/db.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:immich_mobile/shared/services/sync.service.dart';
+import 'package:immich_mobile/utils/diff.dart';
 import 'package:immich_mobile/utils/files_helper.dart';
 import 'package:isar/isar.dart';
+import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 
 final userServiceProvider = Provider(
@@ -18,6 +21,7 @@ final userServiceProvider = Provider(
     ref.watch(apiServiceProvider),
     ref.watch(dbProvider),
     ref.watch(syncServiceProvider),
+    ref.watch(partnerServiceProvider),
   ),
 );
 
@@ -25,15 +29,22 @@ class UserService {
   final ApiService _apiService;
   final Isar _db;
   final SyncService _syncService;
+  final PartnerService _partnerService;
+  final Logger _log = Logger("UserService");
 
-  UserService(this._apiService, this._db, this._syncService);
+  UserService(
+    this._apiService,
+    this._db,
+    this._syncService,
+    this._partnerService,
+  );
 
   Future<List<User>?> _getAllUsers({required bool isAll}) async {
     try {
       final dto = await _apiService.userApi.getAllUsers(isAll);
       return dto?.map(User.fromDto).toList();
     } catch (e) {
-      debugPrint("Error [getAllUsersInfo]  ${e.toString()}");
+      _log.warning("Failed get all users:\n$e");
       return null;
     }
   }
@@ -62,16 +73,45 @@ class UserService {
         ),
       );
     } catch (e) {
-      debugPrint("Error [uploadProfileImage] ${e.toString()}");
+      _log.warning("Failed to upload profile image:\n$e");
       return null;
     }
   }
 
   Future<bool> refreshUsers() async {
     final List<User>? users = await _getAllUsers(isAll: true);
-    if (users == null) {
+    final List<User>? sharedBy =
+        await _partnerService.getPartners(PartnerDirection.sharedBy);
+    final List<User>? sharedWith =
+        await _partnerService.getPartners(PartnerDirection.sharedWith);
+
+    if (users == null || sharedBy == null || sharedWith == null) {
+      _log.warning("Failed to refresh users");
       return false;
     }
+
+    users.sortBy((u) => u.id);
+    sharedBy.sortBy((u) => u.id);
+    sharedWith.sortBy((u) => u.id);
+
+    diffSortedListsSync(
+      users,
+      sharedBy,
+      compare: (User a, User b) => a.id.compareTo(b.id),
+      both: (User a, User b) => a.isPartnerSharedBy = true,
+      onlyFirst: (_) {},
+      onlySecond: (_) {},
+    );
+
+    diffSortedListsSync(
+      users,
+      sharedWith,
+      compare: (User a, User b) => a.id.compareTo(b.id),
+      both: (User a, User b) => a.isPartnerSharedWith = true,
+      onlyFirst: (_) {},
+      onlySecond: (_) {},
+    );
+
     return _syncService.syncUsersFromServer(users);
   }
 }

+ 54 - 0
mobile/lib/shared/ui/confirm_dialog.dart

@@ -0,0 +1,54 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+
+class ConfirmDialog extends ConsumerWidget {
+  final Function onOk;
+  final String title;
+  final String content;
+  final String cancel;
+  final String ok;
+
+  const ConfirmDialog({
+    Key? key,
+    required this.onOk,
+    required this.title,
+    required this.content,
+    this.cancel = "delete_dialog_cancel",
+    this.ok = "backup_controller_page_background_battery_info_ok",
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    return AlertDialog(
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+      title: Text(title).tr(),
+      content: Text(content).tr(),
+      actions: [
+        TextButton(
+          onPressed: () => Navigator.of(context).pop(),
+          child: Text(
+            cancel,
+            style: TextStyle(
+              color: Theme.of(context).primaryColor,
+              fontWeight: FontWeight.bold,
+            ),
+          ).tr(),
+        ),
+        TextButton(
+          onPressed: () {
+            onOk();
+            Navigator.of(context).pop();
+          },
+          child: Text(
+            ok,
+            style: TextStyle(
+              color: Colors.red[400],
+              fontWeight: FontWeight.bold,
+            ),
+          ).tr(),
+        ),
+      ],
+    );
+  }
+}

+ 21 - 0
mobile/lib/shared/ui/user_avatar.dart

@@ -0,0 +1,21 @@
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/models/user.dart';
+
+Widget userAvatar(BuildContext context, User u, {double? radius}) {
+  final url =
+      "${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${u.id}";
+  return CircleAvatar(
+    radius: radius,
+    backgroundColor: Theme.of(context).primaryColor.withAlpha(50),
+    foregroundImage: CachedNetworkImageProvider(
+      url,
+      headers: {"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"},
+      cacheKey: "user-${u.id}-profile",
+    ),
+    // silence errors if user has no profile image, use initials as fallback
+    onForegroundImageError: (exception, stackTrace) {},
+    child: Text((u.firstName[0] + u.lastName[0]).toUpperCase()),
+  );
+}

+ 4 - 0
mobile/lib/utils/db.dart

@@ -1,7 +1,9 @@
 import 'package:immich_mobile/shared/models/album.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/etag.dart';
 import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:immich_mobile/shared/models/store.dart';
+import 'package:immich_mobile/shared/models/user.dart';
 import 'package:isar/isar.dart';
 
 Future<void> clearAssetsAndAlbums(Isar db) async {
@@ -10,5 +12,7 @@ Future<void> clearAssetsAndAlbums(Isar db) async {
     await db.assets.clear();
     await db.exifInfos.clear();
     await db.albums.clear();
+    await db.eTags.clear();
+    await db.users.clear();
   });
 }

+ 2 - 0
mobile/lib/utils/openapi_extensions.dart

@@ -14,9 +14,11 @@ extension WithETag on AssetApi {
   ///   ETag of data already cached on the client
   Future<(List<AssetResponseDto>? assets, String? eTag)> getAllAssetsWithETag({
     String? eTag,
+    String? userId,
   }) async {
     final response = await getAllAssetsWithHttpInfo(
       ifNoneMatch: eTag,
+      userId: userId,
     );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));

+ 4 - 2
mobile/openapi/doc/AssetApi.md

@@ -553,7 +553,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 # **getAllAssets**
-> List<AssetResponseDto> getAllAssets(isFavorite, isArchived, skip, ifNoneMatch)
+> List<AssetResponseDto> getAllAssets(userId, isFavorite, isArchived, skip, ifNoneMatch)
 
 
 
@@ -578,13 +578,14 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 final api_instance = AssetApi();
+final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final isFavorite = true; // bool | 
 final isArchived = true; // bool | 
 final skip = 8.14; // num | 
 final ifNoneMatch = ifNoneMatch_example; // String | ETag of data already cached on the client
 
 try {
-    final result = api_instance.getAllAssets(isFavorite, isArchived, skip, ifNoneMatch);
+    final result = api_instance.getAllAssets(userId, isFavorite, isArchived, skip, ifNoneMatch);
     print(result);
 } catch (e) {
     print('Exception when calling AssetApi->getAllAssets: $e\n');
@@ -595,6 +596,7 @@ try {
 
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
+ **userId** | **String**|  | [optional] 
  **isFavorite** | **bool**|  | [optional] 
  **isArchived** | **bool**|  | [optional] 
  **skip** | **num**|  | [optional] 

+ 10 - 3
mobile/openapi/lib/api/asset_api.dart

@@ -519,6 +519,8 @@ class AssetApi {
   ///
   /// Parameters:
   ///
+  /// * [String] userId:
+  ///
   /// * [bool] isFavorite:
   ///
   /// * [bool] isArchived:
@@ -527,7 +529,7 @@ class AssetApi {
   ///
   /// * [String] ifNoneMatch:
   ///   ETag of data already cached on the client
-  Future<Response> getAllAssetsWithHttpInfo({ bool? isFavorite, bool? isArchived, num? skip, String? ifNoneMatch, }) async {
+  Future<Response> getAllAssetsWithHttpInfo({ String? userId, bool? isFavorite, bool? isArchived, num? skip, String? ifNoneMatch, }) async {
     // ignore: prefer_const_declarations
     final path = r'/asset';
 
@@ -538,6 +540,9 @@ class AssetApi {
     final headerParams = <String, String>{};
     final formParams = <String, String>{};
 
+    if (userId != null) {
+      queryParams.addAll(_queryParams('', 'userId', userId));
+    }
     if (isFavorite != null) {
       queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
     }
@@ -570,6 +575,8 @@ class AssetApi {
   ///
   /// Parameters:
   ///
+  /// * [String] userId:
+  ///
   /// * [bool] isFavorite:
   ///
   /// * [bool] isArchived:
@@ -578,8 +585,8 @@ class AssetApi {
   ///
   /// * [String] ifNoneMatch:
   ///   ETag of data already cached on the client
-  Future<List<AssetResponseDto>?> getAllAssets({ bool? isFavorite, bool? isArchived, num? skip, String? ifNoneMatch, }) async {
-    final response = await getAllAssetsWithHttpInfo( isFavorite: isFavorite, isArchived: isArchived, skip: skip, ifNoneMatch: ifNoneMatch, );
+  Future<List<AssetResponseDto>?> getAllAssets({ String? userId, bool? isFavorite, bool? isArchived, num? skip, String? ifNoneMatch, }) async {
+    final response = await getAllAssetsWithHttpInfo( userId: userId, isFavorite: isFavorite, isArchived: isArchived, skip: skip, ifNoneMatch: ifNoneMatch, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }

+ 1 - 1
mobile/openapi/test/asset_api_test.dart

@@ -72,7 +72,7 @@ void main() {
 
     // Get all AssetEntity belong to the user
     //
-    //Future<List<AssetResponseDto>> getAllAssets({ bool isFavorite, bool isArchived, num skip, String ifNoneMatch }) async
+    //Future<List<AssetResponseDto>> getAllAssets({ String userId, bool isFavorite, bool isArchived, num skip, String ifNoneMatch }) async
     test('test getAllAssets', () async {
       // TODO
     });

+ 15 - 17
mobile/test/sync_service_test.dart

@@ -52,6 +52,14 @@ void main() {
 
   group('Test SyncService grouped', () {
     late final Isar db;
+    final owner = User(
+      id: "1",
+      updatedAt: DateTime.now(),
+      email: "a@b.c",
+      firstName: "first",
+      lastName: "last",
+      isAdmin: false,
+    );
     setUpAll(() async {
       WidgetsFlutterBinding.ensureInitialized();
       await Isar.initializeIsarCore(download: true);
@@ -59,17 +67,7 @@ void main() {
       ImmichLogger();
       db.writeTxnSync(() => db.clearSync());
       Store.init(db);
-      await Store.put(
-        StoreKey.currentUser,
-        User(
-          id: "1",
-          updatedAt: DateTime.now(),
-          email: "a@b.c",
-          firstName: "first",
-          lastName: "last",
-          isAdmin: false,
-        ),
-      );
+      await Store.put(StoreKey.currentUser, owner);
     });
     final List<Asset> initialAssets = [
       makeAsset(localId: "1", remoteId: "0-1", deviceId: 0),
@@ -92,7 +90,7 @@ void main() {
         makeAsset(localId: "1", remoteId: "1-1"),
       ];
       expect(db.assets.countSync(), 5);
-      final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
+      final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
       expect(c1, false);
       expect(db.assets.countSync(), 5);
     });
@@ -108,7 +106,7 @@ void main() {
         makeAsset(localId: "1", remoteId: "3-1", deviceId: 3),
       ];
       expect(db.assets.countSync(), 5);
-      final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
+      final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
       expect(c1, true);
       expect(db.assets.countSync(), 7);
     });
@@ -124,19 +122,19 @@ void main() {
         makeAsset(localId: "1", remoteId: "2-1d", deviceId: 2),
       ];
       expect(db.assets.countSync(), 5);
-      final bool c1 = await s.syncRemoteAssetsToDb(() => remoteAssets);
+      final bool c1 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
       expect(c1, true);
       expect(db.assets.countSync(), 8);
-      final bool c2 = await s.syncRemoteAssetsToDb(() => remoteAssets);
+      final bool c2 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
       expect(c2, false);
       expect(db.assets.countSync(), 8);
       remoteAssets.removeAt(4);
-      final bool c3 = await s.syncRemoteAssetsToDb(() => remoteAssets);
+      final bool c3 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
       expect(c3, true);
       expect(db.assets.countSync(), 7);
       remoteAssets.add(makeAsset(localId: "1", remoteId: "2-1e", deviceId: 2));
       remoteAssets.add(makeAsset(localId: "2", remoteId: "2-2", deviceId: 2));
-      final bool c4 = await s.syncRemoteAssetsToDb(() => remoteAssets);
+      final bool c4 = await s.syncRemoteAssetsToDb(owner, () => remoteAssets);
       expect(c4, true);
       expect(db.assets.countSync(), 9);
     });

+ 4 - 1
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -150,7 +150,10 @@ export class AssetService {
   }
 
   public async getAllAssets(authUser: AuthUserDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
-    const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
+    if (dto.userId && dto.userId !== authUser.id) {
+      await this.checkUserAccess(authUser, dto.userId);
+    }
+    const assets = await this._assetRepository.getAllByUserId(dto.userId || authUser.id, dto);
 
     return assets.map((asset) => mapAsset(asset));
   }

+ 7 - 1
server/apps/immich/src/api-v1/asset/dto/asset-search.dto.ts

@@ -1,5 +1,6 @@
+import { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
-import { IsBoolean, IsNotEmpty, IsNumber, IsOptional } from 'class-validator';
+import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator';
 import { toBoolean } from '../../../utils/transform.util';
 
 export class AssetSearchDto {
@@ -18,4 +19,9 @@ export class AssetSearchDto {
   @IsOptional()
   @IsNumber()
   skip?: number;
+
+  @IsOptional()
+  @IsUUID('4')
+  @ApiProperty({ format: 'uuid' })
+  userId?: string;
 }

+ 9 - 0
server/immich-openapi-specs.json

@@ -2853,6 +2853,15 @@
         "operationId": "getAllAssets",
         "description": "Get all AssetEntity belong to the user",
         "parameters": [
+          {
+            "name": "userId",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          },
           {
             "name": "isFavorite",
             "required": false,

+ 15 - 7
web/src/api/open-api/api.ts

@@ -4599,6 +4599,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
         },
         /**
          * Get all AssetEntity belong to the user
+         * @param {string} [userId] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isArchived] 
          * @param {number} [skip] 
@@ -4606,7 +4607,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getAllAssets: async (isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getAllAssets: async (userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             const localVarPath = `/asset`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -4628,6 +4629,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
             // http bearer authentication required
             await setBearerAuthToObject(localVarHeaderParameter, configuration)
 
+            if (userId !== undefined) {
+                localVarQueryParameter['userId'] = userId;
+            }
+
             if (isFavorite !== undefined) {
                 localVarQueryParameter['isFavorite'] = isFavorite;
             }
@@ -5551,6 +5556,7 @@ export const AssetApiFp = function(configuration?: Configuration) {
         },
         /**
          * Get all AssetEntity belong to the user
+         * @param {string} [userId] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isArchived] 
          * @param {number} [skip] 
@@ -5558,8 +5564,8 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getAllAssets(isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(isFavorite, isArchived, skip, ifNoneMatch, options);
+        async getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<AssetResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, skip, ifNoneMatch, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -5837,6 +5843,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         },
         /**
          * Get all AssetEntity belong to the user
+         * @param {string} [userId] 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isArchived] 
          * @param {number} [skip] 
@@ -5844,8 +5851,8 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getAllAssets(isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
-            return localVarFp.getAllAssets(isFavorite, isArchived, skip, ifNoneMatch, options).then((request) => request(axios, basePath));
+        getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: any): AxiosPromise<Array<AssetResponseDto>> {
+            return localVarFp.getAllAssets(userId, isFavorite, isArchived, skip, ifNoneMatch, options).then((request) => request(axios, basePath));
         },
         /**
          * 
@@ -6124,6 +6131,7 @@ export class AssetApi extends BaseAPI {
 
     /**
      * Get all AssetEntity belong to the user
+     * @param {string} [userId] 
      * @param {boolean} [isFavorite] 
      * @param {boolean} [isArchived] 
      * @param {number} [skip] 
@@ -6132,8 +6140,8 @@ export class AssetApi extends BaseAPI {
      * @throws {RequiredError}
      * @memberof AssetApi
      */
-    public getAllAssets(isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).getAllAssets(isFavorite, isArchived, skip, ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
+    public getAllAssets(userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).getAllAssets(userId, isFavorite, isArchived, skip, ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**

+ 1 - 1
web/src/lib/components/shared-components/side-bar/side-bar.svelte

@@ -30,7 +30,7 @@
 
 	const getFavoriteCount = async () => {
 		try {
-			const { data: assets } = await api.assetApi.getAllAssets(true, undefined);
+			const { data: assets } = await api.assetApi.getAllAssets(undefined, true, undefined);
 
 			return {
 				favorites: assets.length

+ 1 - 1
web/src/routes/(user)/archive/+page.svelte

@@ -24,7 +24,7 @@
 
 	onMount(async () => {
 		try {
-			const { data: assets } = await api.assetApi.getAllAssets(undefined, true);
+			const { data: assets } = await api.assetApi.getAllAssets(undefined, undefined, true);
 			$archivedAsset = assets;
 		} catch {
 			handleError(Error, 'Unable to load archived assets');

+ 1 - 1
web/src/routes/(user)/favorites/+page.svelte

@@ -20,7 +20,7 @@
 
 	onMount(async () => {
 		try {
-			const { data: assets } = await api.assetApi.getAllAssets(true, undefined);
+			const { data: assets } = await api.assetApi.getAllAssets(undefined, true, undefined);
 			favorites = assets;
 		} catch {
 			handleError(Error, 'Unable to load favorites');