diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 8b204f9b2e..082a3f6de9 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -257,6 +257,15 @@ "sharing_page_empty_list": "EMPTY LIST", "sharing_silver_appbar_create_shared_album": "Create shared album", "sharing_silver_appbar_share_partner": "Share with partner", + "partner_page_title": "Partner", + "partner_page_no_more_users": "No more users to add", + "partner_page_empty_message": "Your photos are not yet shared with any partner.", + "partner_page_shared_to_title": "Shared to", + "partner_page_select_partner": "Select partner", + "partner_page_add_partner": "Add partner", + "partner_page_partner_add_failed": "Failed to add partner", + "partner_page_stop_sharing_title": "Stop sharing your photos?", + "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "tab_controller_nav_library": "Library", "tab_controller_nav_photos": "Photos", "tab_controller_nav_search": "Search", diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index dda5bd604a..3c8cedc9de 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/etag.dart'; import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/models/logger_message.model.dart'; import 'package:immich_mobile/shared/models/store.dart'; @@ -89,6 +90,7 @@ Future loadDb() async { BackupAlbumSchema, DuplicatedAssetSchema, LoggerMessageSchema, + ETagSchema, ], directory: dir.path, maxSizeMiB: 256, diff --git a/mobile/lib/modules/album/providers/shared_album.provider.dart b/mobile/lib/modules/album/providers/shared_album.provider.dart index fd7b396dd4..a6fd8db23e 100644 --- a/mobile/lib/modules/album/providers/shared_album.provider.dart +++ b/mobile/lib/modules/album/providers/shared_album.provider.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:isar/isar.dart'; class SharedAlbumNotifier extends StateNotifier> { @@ -73,7 +74,9 @@ final sharedAlbumProvider = }); final sharedAlbumDetailProvider = - StreamProvider.autoDispose.family((ref, albumId) async* { + StreamProvider.family((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)) { diff --git a/mobile/lib/modules/album/providers/suggested_shared_users.provider.dart b/mobile/lib/modules/album/providers/suggested_shared_users.provider.dart index 552afa9e99..a928ae73c8 100644 --- a/mobile/lib/modules/album/providers/suggested_shared_users.provider.dart +++ b/mobile/lib/modules/album/providers/suggested_shared_users.provider.dart @@ -2,8 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/services/user.service.dart'; -final suggestedSharedUsersProvider = - FutureProvider.autoDispose>((ref) { +final otherUsersProvider = FutureProvider.autoDispose>((ref) { UserService userService = ref.watch(userServiceProvider); return userService.getUsersInDb(); diff --git a/mobile/lib/modules/album/ui/sharing_sliver_appbar.dart b/mobile/lib/modules/album/ui/sharing_sliver_appbar.dart deleted file mode 100644 index 9643f1929e..0000000000 --- a/mobile/lib/modules/album/ui/sharing_sliver_appbar.dart +++ /dev/null @@ -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(), - ), - ), - ) - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/modules/album/views/asset_selection_page.dart b/mobile/lib/modules/album/views/asset_selection_page.dart index 74aac07405..7ea60e2496 100644 --- a/mobile/lib/modules/album/views/asset_selection_page.dart +++ b/mobile/lib/modules/album/views/asset_selection_page.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; class AssetSelectionPage extends HookConsumerWidget { const AssetSelectionPage({ @@ -21,7 +22,8 @@ class AssetSelectionPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final renderList = ref.watch(remoteAssetsProvider); + final currentUser = ref.watch(currentUserProvider); + final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId)); final selected = useState>(existingAssets); final selectionEnabledHook = useState(true); diff --git a/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart b/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart index 1aadeb3a6e..ec7ddb17e4 100644 --- a/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart +++ b/mobile/lib/modules/album/views/select_additional_user_for_sharing_page.dart @@ -17,7 +17,7 @@ class SelectAdditionalUserForSharingPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final AsyncValue> suggestedShareUsers = - ref.watch(suggestedSharedUsersProvider); + ref.watch(otherUsersProvider); final sharedUsersList = useState>({}); addNewUsersHandler() { diff --git a/mobile/lib/modules/album/views/select_user_for_sharing_page.dart b/mobile/lib/modules/album/views/select_user_for_sharing_page.dart index 4130181c74..eaf9916459 100644 --- a/mobile/lib/modules/album/views/select_user_for_sharing_page.dart +++ b/mobile/lib/modules/album/views/select_user_for_sharing_page.dart @@ -20,8 +20,7 @@ class SelectUserForSharingPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final sharedUsersList = useState>({}); - AsyncValue> suggestedShareUsers = - ref.watch(suggestedSharedUsersProvider); + final suggestedShareUsers = ref.watch(otherUsersProvider); createSharedAlbum() async { var newAlbum = diff --git a/mobile/lib/modules/album/views/sharing_page.dart b/mobile/lib/modules/album/views/sharing_page.dart index 68a2dda855..b2025ef864 100644 --- a/mobile/lib/modules/album/views/sharing_page.dart +++ b/mobile/lib/modules/album/views/sharing_page.dart @@ -5,10 +5,11 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/ui/album_thumbnail_card.dart'; -import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart'; +import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; +import 'package:immich_mobile/modules/partner/ui/partner_list.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; -import 'package:immich_mobile/shared/models/store.dart' as store; +import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; class SharingPage extends HookConsumerWidget { @@ -17,7 +18,8 @@ class SharingPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final List 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", diff --git a/mobile/lib/modules/archive/providers/archive_asset_provider.dart b/mobile/lib/modules/archive/providers/archive_asset_provider.dart index 70c8ac89e8..26d0b2eea9 100644 --- a/mobile/lib/modules/archive/providers/archive_asset_provider.dart +++ b/mobile/lib/modules/archive/providers/archive_asset_provider.dart @@ -3,16 +3,18 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:isar/isar.dart'; final archiveProvider = StreamProvider((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); diff --git a/mobile/lib/modules/asset_viewer/ui/description_input.dart b/mobile/lib/modules/asset_viewer/ui/description_input.dart index 9b54633f21..cc459d4732 100644 --- a/mobile/lib/modules/asset_viewer/ui/description_input.dart +++ b/mobile/lib/modules/asset_viewer/ui/description_input.dart @@ -4,9 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/asset_description.provider.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:logging/logging.dart'; -import 'package:immich_mobile/shared/models/store.dart' as store; class DescriptionInput extends HookConsumerWidget { DescriptionInput({ @@ -25,9 +25,10 @@ class DescriptionInput extends HookConsumerWidget { final focusNode = useFocusNode(); final isFocus = useState(false); final isTextEmpty = useState(controller.text.isEmpty); - final descriptionProvider = ref.watch(assetDescriptionProvider(asset).notifier); + final descriptionProvider = + ref.watch(assetDescriptionProvider(asset).notifier); final description = ref.watch(assetDescriptionProvider(asset)); - final owner = store.Store.get(store.StoreKey.currentUser); + final owner = ref.watch(currentUserProvider); final hasError = useState(false); controller.text = description; @@ -67,7 +68,7 @@ class DescriptionInput extends HookConsumerWidget { } return TextField( - enabled: owner.isarId == asset.ownerId, + enabled: owner?.isarId == asset.ownerId, focusNode: focusNode, onTap: () => isFocus.value = true, onChanged: (value) { diff --git a/mobile/lib/modules/favorite/providers/favorite_provider.dart b/mobile/lib/modules/favorite/providers/favorite_provider.dart index d95742f890..4ddb73ea69 100644 --- a/mobile/lib/modules/favorite/providers/favorite_provider.dart +++ b/mobile/lib/modules/favorite/providers/favorite_provider.dart @@ -3,16 +3,18 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:isar/isar.dart'; final favoriteAssetsProvider = StreamProvider((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); diff --git a/mobile/lib/modules/home/ui/delete_dialog.dart b/mobile/lib/modules/home/ui/delete_dialog.dart index f1ba868641..7d290cd1a7 100644 --- a/mobile/lib/modules/home/ui/delete_dialog.dart +++ b/mobile/lib/modules/home/ui/delete_dialog.dart @@ -1,47 +1,16 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; -class DeleteDialog extends ConsumerWidget { +class DeleteDialog extends ConfirmDialog { final Function onDelete; - const DeleteDialog({Key? key, required this.onDelete}) : super(key: key); - - @override - Widget build(BuildContext context, WidgetRef ref) { - - return AlertDialog( - // backgroundColor: Colors.grey[200], - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - title: const Text("delete_dialog_title").tr(), - content: const Text("delete_dialog_alert").tr(), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text( - "delete_dialog_cancel", - style: TextStyle( - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.bold, - ), - ).tr(), - ), - TextButton( - onPressed: () { - onDelete(); - Navigator.of(context).pop(); - }, - child: Text( - "delete_dialog_ok", - style: TextStyle( - color: Colors.red[400], - fontWeight: FontWeight.bold, - ), - ).tr(), - ), - ], - ); - } + const DeleteDialog({Key? key, required this.onDelete}) + : super( + key: key, + title: "delete_dialog_title", + content: "delete_dialog_alert", + cancel: "delete_dialog_cancel", + ok: "delete_dialog_ok", + onOk: onDelete, + ); } diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index a7caf511f1..957b15133c 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -20,6 +20,7 @@ import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/services/share.service.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; @@ -38,6 +39,7 @@ class HomePage extends HookConsumerWidget { final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final sharedAlbums = ref.watch(sharedAlbumProvider); final albumService = ref.watch(albumServiceProvider); + final currentUser = ref.watch(currentUserProvider); final tipOneOpacity = useState(0.0); final refreshCount = useState(0); @@ -300,7 +302,7 @@ class HomePage extends HookConsumerWidget { bottom: false, child: Stack( children: [ - ref.watch(assetsProvider).when( + ref.watch(assetsProvider(currentUser?.isarId)).when( data: (data) => data.isEmpty ? buildLoadingIndicator() : ImmichAssetGrid( diff --git a/mobile/lib/modules/partner/providers/partner.provider.dart b/mobile/lib/modules/partner/providers/partner.provider.dart new file mode 100644 index 0000000000..d484356168 --- /dev/null +++ b/mobile/lib/modules/partner/providers/partner.provider.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart'; +import 'package:immich_mobile/shared/models/user.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +class PartnerSharedWithNotifier extends StateNotifier> { + 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>((ref) { + return PartnerSharedWithNotifier(ref.watch(dbProvider)); +}); + +class PartnerSharedByNotifier extends StateNotifier> { + 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> streamSub; + + @override + void dispose() { + streamSub.cancel(); + super.dispose(); + } +} + +final partnerSharedByProvider = + StateNotifierProvider>((ref) { + return PartnerSharedByNotifier(ref.watch(dbProvider)); +}); + +final partnerAvailableProvider = + FutureProvider.autoDispose>((ref) async { + final otherUsers = await ref.watch(otherUsersProvider.future); + final currentPartners = ref.watch(partnerSharedByProvider); + final available = Set.of(otherUsers); + available.removeAll(currentPartners); + return available.toList(); +}); diff --git a/mobile/lib/modules/partner/services/partner.service.dart b/mobile/lib/modules/partner/services/partner.service.dart new file mode 100644 index 0000000000..42fcdb4381 --- /dev/null +++ b/mobile/lib/modules/partner/services/partner.service.dart @@ -0,0 +1,72 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/user.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:isar/isar.dart'; +import 'package:logging/logging.dart'; + +final partnerServiceProvider = Provider( + (ref) => PartnerService( + ref.watch(apiServiceProvider), + ref.watch(dbProvider), + ), +); + +enum PartnerDirection { + sharedWith("shared-with"), + sharedBy("shared-by"); + + const PartnerDirection( + this._value, + ); + + final String _value; +} + +class PartnerService { + final ApiService _apiService; + final Isar _db; + final Logger _log = Logger("PartnerService"); + + PartnerService(this._apiService, this._db); + + Future?> 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 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 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; + } +} diff --git a/mobile/lib/modules/partner/ui/partner_list.dart b/mobile/lib/modules/partner/ui/partner_list.dart new file mode 100644 index 0000000000..92bfcd15fa --- /dev/null +++ b/mobile/lib/modules/partner/ui/partner_list.dart @@ -0,0 +1,30 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/user.dart'; +import 'package:immich_mobile/shared/ui/user_avatar.dart'; + +class PartnerList extends HookConsumerWidget { + const PartnerList({Key? key, required this.partner}) : super(key: key); + + final List 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)), + ); + } +} diff --git a/mobile/lib/modules/partner/views/partner_detail_page.dart b/mobile/lib/modules/partner/views/partner_detail_page.dart new file mode 100644 index 0000000000..a97e6a1d6c --- /dev/null +++ b/mobile/lib/modules/partner/views/partner_detail_page.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; +import 'package:immich_mobile/shared/models/user.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; + +class PartnerDetailPage extends HookConsumerWidget { + const PartnerDetailPage({Key? key, required this.partner}) : super(key: key); + + final User partner; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final assets = ref.watch(assetsProvider(partner.isarId)); + + return Scaffold( + appBar: AppBar( + title: Text("${partner.firstName} ${partner.lastName}"), + elevation: 0, + centerTitle: false, + ), + body: assets.when( + data: (renderList) => renderList.isEmpty + ? Padding( + padding: const EdgeInsets.all(16), + child: Text( + "It seems ${partner.firstName} does not have any photos...\n" + "Or your server version does not match the app version."), + ) + : ImmichAssetGrid( + renderList: renderList, + onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(), + ), + error: (e, _) => Text("Error loading partners:\n$e"), + loading: () => const Center(child: ImmichLoadingIndicator()), + ), + ); + } +} diff --git a/mobile/lib/modules/partner/views/partner_page.dart b/mobile/lib/modules/partner/views/partner_page.dart new file mode 100644 index 0000000000..789f036c4a --- /dev/null +++ b/mobile/lib/modules/partner/views/partner_page.dart @@ -0,0 +1,160 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/partner/providers/partner.provider.dart'; +import 'package:immich_mobile/modules/partner/services/partner.service.dart'; +import 'package:immich_mobile/shared/models/user.dart'; +import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:immich_mobile/shared/ui/user_avatar.dart'; + +class PartnerPage extends HookConsumerWidget { + const PartnerPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final List 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( + 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 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), + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 34b8d9132e..cd731dc109 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -6,6 +6,8 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart'; import 'package:immich_mobile/modules/album/views/asset_selection_page.dart'; import 'package:immich_mobile/modules/album/views/create_album_page.dart'; import 'package:immich_mobile/modules/album/views/library_page.dart'; +import 'package:immich_mobile/modules/partner/views/partner_detail_page.dart'; +import 'package:immich_mobile/modules/partner/views/partner_page.dart'; import 'package:immich_mobile/modules/album/views/select_additional_user_for_sharing_page.dart'; import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart'; import 'package:immich_mobile/modules/album/views/sharing_page.dart'; @@ -35,6 +37,7 @@ import 'package:immich_mobile/routing/duplicate_guard.dart'; import 'package:immich_mobile/routing/gallery_permission_guard.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/album.dart'; +import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/logger_message.model.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; @@ -136,6 +139,8 @@ part 'router.gr.dart'; DuplicateGuard, ], ), + AutoRoute(page: PartnerPage, guards: [AuthGuard, DuplicateGuard]), + AutoRoute(page: PartnerDetailPage, guards: [AuthGuard, DuplicateGuard]) ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 2a39ea6d7d..d79aac5f5d 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -256,6 +256,22 @@ class _$AppRouter extends RootStackRouter { child: const ArchivePage(), ); }, + PartnerRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, + child: const PartnerPage(), + ); + }, + PartnerDetailRoute.name: (routeData) { + final args = routeData.argsAs(); + return MaterialPageX( + routeData: routeData, + child: PartnerDetailPage( + key: args.key, + partner: args.partner, + ), + ); + }, HomeRoute.name: (routeData) { return MaterialPageX( 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 { static const String name = 'ArchiveRoute'; } +/// generated route for +/// [PartnerPage] +class PartnerRoute extends PageRouteInfo { + const PartnerRoute() + : super( + PartnerRoute.name, + path: '/partner-page', + ); + + static const String name = 'PartnerRoute'; +} + +/// generated route for +/// [PartnerDetailPage] +class PartnerDetailRoute extends PageRouteInfo { + 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 { diff --git a/mobile/lib/shared/models/album.dart b/mobile/lib/shared/models/album.dart index 508ab094aa..3223a465b9 100644 --- a/mobile/lib/shared/models/album.dart +++ b/mobile/lib/shared/models/album.dart @@ -87,8 +87,8 @@ class Album { remoteId == other.remoteId && localId == other.localId && name == other.name && - createdAt == other.createdAt && - modifiedAt == other.modifiedAt && + createdAt.isAtSameMomentAs(other.createdAt) && + modifiedAt.isAtSameMomentAs(other.modifiedAt) && shared == other.shared && owner.value == other.owner.value && thumbnail.value == other.thumbnail.value && diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index d8fedcd6b2..0bee110f8a 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -179,9 +179,9 @@ class Asset { localId == other.localId && deviceId == other.deviceId && ownerId == other.ownerId && - fileCreatedAt == other.fileCreatedAt && - fileModifiedAt == other.fileModifiedAt && - updatedAt == other.updatedAt && + fileCreatedAt.isAtSameMomentAs(other.fileCreatedAt) && + fileModifiedAt.isAtSameMomentAs(other.fileModifiedAt) && + updatedAt.isAtSameMomentAs(other.updatedAt) && durationInSeconds == other.durationInSeconds && type == other.type && width == other.width && diff --git a/mobile/lib/shared/models/etag.dart b/mobile/lib/shared/models/etag.dart new file mode 100644 index 0000000000..2f13898992 --- /dev/null +++ b/mobile/lib/shared/models/etag.dart @@ -0,0 +1,13 @@ +import 'package:immich_mobile/utils/hash.dart'; +import 'package:isar/isar.dart'; + +part 'etag.g.dart'; + +@Collection(inheritance: false) +class ETag { + ETag({required this.id, this.value}); + Id get isarId => fastHash(id); + @Index(unique: true, replace: true, type: IndexType.hash) + String id; + String? value; +} diff --git a/mobile/lib/shared/models/etag.g.dart b/mobile/lib/shared/models/etag.g.dart new file mode 100644 index 0000000000..acfec7040e Binary files /dev/null and b/mobile/lib/shared/models/etag.g.dart differ diff --git a/mobile/lib/shared/models/user.dart b/mobile/lib/shared/models/user.dart index 49a47c10a8..d6e3d487cd 100644 --- a/mobile/lib/shared/models/user.dart +++ b/mobile/lib/shared/models/user.dart @@ -14,6 +14,8 @@ class User { required this.firstName, required this.lastName, required this.isAdmin, + this.isPartnerSharedBy = false, + this.isPartnerSharedWith = false, }); Id get isarId => fastHash(id); @@ -26,6 +28,8 @@ class User { email = dto.email, firstName = dto.firstName, lastName = dto.lastName, + isPartnerSharedBy = false, + isPartnerSharedWith = false, isAdmin = dto.isAdmin; @Index(unique: true, replace: false, type: IndexType.hash) @@ -34,6 +38,8 @@ class User { String email; String firstName; String lastName; + bool isPartnerSharedBy; + bool isPartnerSharedWith; bool isAdmin; @Backlink(to: 'owner') final IsarLinks albums = IsarLinks(); @@ -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; } diff --git a/mobile/lib/shared/models/user.g.dart b/mobile/lib/shared/models/user.g.dart index 3ba4e0e998..d70fd02df2 100644 Binary files a/mobile/lib/shared/models/user.g.dart and b/mobile/lib/shared/models/user.g.dart differ diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 4b422ecd7a..c1384f4029 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/services/asset.service.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; @@ -10,6 +11,7 @@ import 'package:immich_mobile/modules/settings/providers/app_settings.provider.d import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/services/sync.service.dart'; +import 'package:immich_mobile/shared/services/user.service.dart'; import 'package:immich_mobile/utils/db.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -23,6 +25,7 @@ class AssetsState {} class AssetNotifier extends StateNotifier { 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 { AssetNotifier( this._assetService, this._albumService, + this._userService, this._syncService, this._db, ) : super(AssetsState()); @@ -51,6 +55,12 @@ class AssetNotifier extends StateNotifier { final bool newRemote = await _assetService.refreshRemoteAssets(); final bool newLocal = await _albumService.refreshDeviceAlbums(); debugPrint("newRemote: $newRemote, newLocal: $newLocal"); + await _userService.refreshUsers(); + final List 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((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((ref) async* { +final assetsProvider = + StreamProvider.family((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((ref) async* { }); final remoteAssetsProvider = - StreamProvider.autoDispose((ref) async* { + StreamProvider.family((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 = diff --git a/mobile/lib/shared/providers/user.provider.dart b/mobile/lib/shared/providers/user.provider.dart new file mode 100644 index 0000000000..df8ff328dd --- /dev/null +++ b/mobile/lib/shared/providers/user.provider.dart @@ -0,0 +1,26 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/models/user.dart'; + +class CurrentUserProvider extends StateNotifier { + CurrentUserProvider() : super(null) { + state = Store.tryGet(StoreKey.currentUser); + streamSub = + Store.watch(StoreKey.currentUser).listen((user) => state = user); + } + + late final StreamSubscription streamSub; + + @override + void dispose() { + streamSub.cancel(); + super.dispose(); + } +} + +final currentUserProvider = + StateNotifierProvider((ref) { + return CurrentUserProvider(); +}); diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index c957023f96..fc960a52e9 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -16,6 +16,7 @@ class ApiService { late AssetApi assetApi; late SearchApi searchApi; late ServerInfoApi serverInfoApi; + late PartnerApi partnerApi; ApiService() { final endpoint = Store.tryGet(StoreKey.serverEndpoint); @@ -37,6 +38,7 @@ class ApiService { assetApi = AssetApi(_apiClient); serverInfoApi = ServerInfoApi(_apiClient); searchApi = SearchApi(_apiClient); + partnerApi = PartnerApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index edd0df4366..6981cc5251 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -3,8 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/etag.dart'; import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; @@ -36,37 +38,47 @@ class AssetService { /// Checks the server for updated assets and updates the local database if /// required. Returns `true` if there were any changes. - Future refreshRemoteAssets() async { + Future 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?> _getRemoteAssets({ + Future?> _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? 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; diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart index b02aa9c15c..fae95a84fc 100644 --- a/mobile/lib/shared/services/sync.service.dart +++ b/mobile/lib/shared/services/sync.service.dart @@ -40,7 +40,9 @@ class SyncService { dbUsers, compare: (User a, User b) => a.id.compareTo(b.id), both: (User a, User b) { - if (!a.updatedAt.isAtSameMomentAs(b.updatedAt)) { + if (!a.updatedAt.isAtSameMomentAs(b.updatedAt) || + a.isPartnerSharedBy != b.isPartnerSharedBy || + a.isPartnerSharedWith != b.isPartnerSharedWith) { toUpsert.add(a); return true; } @@ -61,9 +63,10 @@ class SyncService { /// Syncs remote assets owned by the logged-in user to the DB /// Returns `true` if there were any changes Future syncRemoteAssetsToDb( + User user, FutureOr?> 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 _syncRemoteAssetsToDb( + User user, FutureOr?> Function() loadAssets, ) async { final List? remote = await loadAssets(); if (remote == null) { return false; } - final User user = Store.get(StoreKey.currentUser); final List 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)); diff --git a/mobile/lib/shared/services/user.service.dart b/mobile/lib/shared/services/user.service.dart index 757dde5b8d..89cf25f649 100644 --- a/mobile/lib/shared/services/user.service.dart +++ b/mobile/lib/shared/services/user.service.dart @@ -1,16 +1,19 @@ -import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; import 'package:http_parser/http_parser.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:immich_mobile/modules/partner/services/partner.service.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/sync.service.dart'; +import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/files_helper.dart'; import 'package:isar/isar.dart'; +import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; final userServiceProvider = Provider( @@ -18,6 +21,7 @@ final userServiceProvider = Provider( ref.watch(apiServiceProvider), ref.watch(dbProvider), ref.watch(syncServiceProvider), + ref.watch(partnerServiceProvider), ), ); @@ -25,15 +29,22 @@ class UserService { final ApiService _apiService; final Isar _db; final SyncService _syncService; + final PartnerService _partnerService; + final Logger _log = Logger("UserService"); - UserService(this._apiService, this._db, this._syncService); + UserService( + this._apiService, + this._db, + this._syncService, + this._partnerService, + ); Future?> _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 refreshUsers() async { final List? users = await _getAllUsers(isAll: true); - if (users == null) { + final List? sharedBy = + await _partnerService.getPartners(PartnerDirection.sharedBy); + final List? 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); } } diff --git a/mobile/lib/shared/ui/confirm_dialog.dart b/mobile/lib/shared/ui/confirm_dialog.dart new file mode 100644 index 0000000000..87d77ecd01 --- /dev/null +++ b/mobile/lib/shared/ui/confirm_dialog.dart @@ -0,0 +1,54 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class ConfirmDialog extends ConsumerWidget { + final Function onOk; + final String title; + final String content; + final String cancel; + final String ok; + + const ConfirmDialog({ + Key? key, + required this.onOk, + required this.title, + required this.content, + this.cancel = "delete_dialog_cancel", + this.ok = "backup_controller_page_background_battery_info_ok", + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + title: Text(title).tr(), + content: Text(content).tr(), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + cancel, + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + TextButton( + onPressed: () { + onOk(); + Navigator.of(context).pop(); + }, + child: Text( + ok, + style: TextStyle( + color: Colors.red[400], + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ], + ); + } +} diff --git a/mobile/lib/shared/ui/user_avatar.dart b/mobile/lib/shared/ui/user_avatar.dart new file mode 100644 index 0000000000..23272870ea --- /dev/null +++ b/mobile/lib/shared/ui/user_avatar.dart @@ -0,0 +1,21 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/models/user.dart'; + +Widget userAvatar(BuildContext context, User u, {double? radius}) { + final url = + "${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${u.id}"; + return CircleAvatar( + radius: radius, + backgroundColor: Theme.of(context).primaryColor.withAlpha(50), + foregroundImage: CachedNetworkImageProvider( + url, + headers: {"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}"}, + cacheKey: "user-${u.id}-profile", + ), + // silence errors if user has no profile image, use initials as fallback + onForegroundImageError: (exception, stackTrace) {}, + child: Text((u.firstName[0] + u.lastName[0]).toUpperCase()), + ); +} diff --git a/mobile/lib/utils/db.dart b/mobile/lib/utils/db.dart index 3892e20cb5..354fc08ca9 100644 --- a/mobile/lib/utils/db.dart +++ b/mobile/lib/utils/db.dart @@ -1,7 +1,9 @@ import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/models/etag.dart'; import 'package:immich_mobile/shared/models/exif_info.dart'; import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/models/user.dart'; import 'package:isar/isar.dart'; Future clearAssetsAndAlbums(Isar db) async { @@ -10,5 +12,7 @@ Future clearAssetsAndAlbums(Isar db) async { await db.assets.clear(); await db.exifInfos.clear(); await db.albums.clear(); + await db.eTags.clear(); + await db.users.clear(); }); } diff --git a/mobile/lib/utils/openapi_extensions.dart b/mobile/lib/utils/openapi_extensions.dart index d45cb94cf3..435410a344 100644 --- a/mobile/lib/utils/openapi_extensions.dart +++ b/mobile/lib/utils/openapi_extensions.dart @@ -14,9 +14,11 @@ extension WithETag on AssetApi { /// ETag of data already cached on the client Future<(List? 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)); diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index ed78743548..2e80103317 100644 Binary files a/mobile/openapi/doc/AssetApi.md and b/mobile/openapi/doc/AssetApi.md differ diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 1bde075f37..dfe61149b4 100644 Binary files a/mobile/openapi/lib/api/asset_api.dart and b/mobile/openapi/lib/api/asset_api.dart differ diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 91c3d613c0..cbbd403bb2 100644 Binary files a/mobile/openapi/test/asset_api_test.dart and b/mobile/openapi/test/asset_api_test.dart differ diff --git a/mobile/test/sync_service_test.dart b/mobile/test/sync_service_test.dart index edcc0851b5..177e9439c3 100644 --- a/mobile/test/sync_service_test.dart +++ b/mobile/test/sync_service_test.dart @@ -52,6 +52,14 @@ void main() { group('Test SyncService grouped', () { late final Isar db; + final owner = User( + id: "1", + updatedAt: DateTime.now(), + email: "a@b.c", + firstName: "first", + lastName: "last", + isAdmin: false, + ); setUpAll(() async { WidgetsFlutterBinding.ensureInitialized(); await Isar.initializeIsarCore(download: true); @@ -59,17 +67,7 @@ void main() { ImmichLogger(); db.writeTxnSync(() => db.clearSync()); Store.init(db); - await Store.put( - StoreKey.currentUser, - User( - id: "1", - updatedAt: DateTime.now(), - email: "a@b.c", - firstName: "first", - lastName: "last", - isAdmin: false, - ), - ); + await Store.put(StoreKey.currentUser, owner); }); final List 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); }); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 66fa091913..6ee02d3dea 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -150,7 +150,10 @@ export class AssetService { } public async getAllAssets(authUser: AuthUserDto, dto: AssetSearchDto): Promise { - 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)); } diff --git a/server/apps/immich/src/api-v1/asset/dto/asset-search.dto.ts b/server/apps/immich/src/api-v1/asset/dto/asset-search.dto.ts index ab7812220c..84bededd7b 100644 --- a/server/apps/immich/src/api-v1/asset/dto/asset-search.dto.ts +++ b/server/apps/immich/src/api-v1/asset/dto/asset-search.dto.ts @@ -1,5 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsNotEmpty, IsNumber, IsOptional } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator'; import { toBoolean } from '../../../utils/transform.util'; export class AssetSearchDto { @@ -18,4 +19,9 @@ export class AssetSearchDto { @IsOptional() @IsNumber() skip?: number; + + @IsOptional() + @IsUUID('4') + @ApiProperty({ format: 'uuid' }) + userId?: string; } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 225a2e9878..48181fe679 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2853,6 +2853,15 @@ "operationId": "getAllAssets", "description": "Get all AssetEntity belong to the user", "parameters": [ + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "isFavorite", "required": false, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index cc3e37ee77..e2a4ebe698 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -4599,6 +4599,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }, /** * Get all AssetEntity belong to the user + * @param {string} [userId] * @param {boolean} [isFavorite] * @param {boolean} [isArchived] * @param {number} [skip] @@ -4606,7 +4607,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAllAssets: async (isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise => { + getAllAssets: async (userId?: string, isFavorite?: boolean, isArchived?: boolean, skip?: number, ifNoneMatch?: string, options: AxiosRequestConfig = {}): Promise => { 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>> { - 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>> { + 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> { - 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> { + 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)); } /** diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 039110f808..50ce62990e 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -30,7 +30,7 @@ const getFavoriteCount = async () => { try { - const { data: assets } = await api.assetApi.getAllAssets(true, undefined); + const { data: assets } = await api.assetApi.getAllAssets(undefined, true, undefined); return { favorites: assets.length diff --git a/web/src/routes/(user)/archive/+page.svelte b/web/src/routes/(user)/archive/+page.svelte index 139b087bd3..6c4669ef91 100644 --- a/web/src/routes/(user)/archive/+page.svelte +++ b/web/src/routes/(user)/archive/+page.svelte @@ -24,7 +24,7 @@ onMount(async () => { try { - const { data: assets } = await api.assetApi.getAllAssets(undefined, true); + const { data: assets } = await api.assetApi.getAllAssets(undefined, undefined, true); $archivedAsset = assets; } catch { handleError(Error, 'Unable to load archived assets'); diff --git a/web/src/routes/(user)/favorites/+page.svelte b/web/src/routes/(user)/favorites/+page.svelte index 011c9bb4d7..63b135f096 100644 --- a/web/src/routes/(user)/favorites/+page.svelte +++ b/web/src/routes/(user)/favorites/+page.svelte @@ -20,7 +20,7 @@ onMount(async () => { try { - const { data: assets } = await api.assetApi.getAllAssets(true, undefined); + const { data: assets } = await api.assetApi.getAllAssets(undefined, true, undefined); favorites = assets; } catch { handleError(Error, 'Unable to load favorites');