mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04:00 
			
		
		
		
	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
This commit is contained in:
		
							parent
							
								
									1613ae9185
								
							
						
					
					
						commit
						bcc2c34eef
					
				| @ -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", | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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)) { | ||||
|  | ||||
| @ -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(); | ||||
|  | ||||
| @ -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(), | ||||
|                   ), | ||||
|                 ), | ||||
|               ) | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -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); | ||||
| 
 | ||||
|  | ||||
| @ -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() { | ||||
|  | ||||
| @ -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 = | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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); | ||||
|  | ||||
| @ -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, | ||||
|         ); | ||||
| } | ||||
|  | ||||
| @ -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
									
								
								mobile/lib/modules/partner/providers/partner.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								mobile/lib/modules/partner/providers/partner.provider.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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
									
								
								mobile/lib/modules/partner/services/partner.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								mobile/lib/modules/partner/services/partner.service.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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
									
								
								mobile/lib/modules/partner/ui/partner_list.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								mobile/lib/modules/partner/ui/partner_list.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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
									
								
								mobile/lib/modules/partner/views/partner_detail_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								mobile/lib/modules/partner/views/partner_detail_page.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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
									
								
								mobile/lib/modules/partner/views/partner_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								mobile/lib/modules/partner/views/partner_page.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -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 { | ||||
|  | ||||
| @ -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> { | ||||
|  | ||||
| @ -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 && | ||||
|  | ||||
| @ -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
									
								
								mobile/lib/shared/models/etag.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								mobile/lib/shared/models/etag.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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
									
								
								mobile/lib/shared/models/etag.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										724
									
								
								mobile/lib/shared/models/etag.g.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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'); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| @ -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; | ||||
| } | ||||
|  | ||||
| @ -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'); | ||||
|  | ||||
| @ -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
									
								
								mobile/lib/shared/providers/user.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								mobile/lib/shared/providers/user.provider.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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(); | ||||
| }); | ||||
| @ -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 { | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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)); | ||||
|  | ||||
| @ -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
									
								
								mobile/lib/shared/ui/confirm_dialog.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								mobile/lib/shared/ui/confirm_dialog.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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
									
								
								mobile/lib/shared/ui/user_avatar.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								mobile/lib/shared/ui/user_avatar.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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()), | ||||
|   ); | ||||
| } | ||||
| @ -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(); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| @ -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)); | ||||
|  | ||||
							
								
								
									
										6
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							| @ -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]  | ||||
|  | ||||
							
								
								
									
										13
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							| @ -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)); | ||||
|     } | ||||
|  | ||||
							
								
								
									
										2
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							| @ -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 | ||||
|     }); | ||||
|  | ||||
| @ -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); | ||||
|     }); | ||||
|  | ||||
| @ -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)); | ||||
|   } | ||||
|  | ||||
| @ -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; | ||||
| } | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
							
								
								
									
										22
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										22
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @ -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)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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'); | ||||
|  | ||||
| @ -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'); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user