diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index f6c596f24a..6c5d974485 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -128,6 +128,18 @@ class RemoteAlbumService { return _repository.addUsers(albumId, userIds); } + Future removeUser(String albumId, {required String userId}) async { + await _albumApiRepository.removeUser(albumId, userId: userId); + + return _repository.removeUser(albumId, userId: userId); + } + + Future setActivityStatus(String albumId, bool enabled) async { + await _albumApiRepository.setActivityStatus(albumId, enabled); + + return _repository.setActivityStatus(albumId, enabled); + } + Future getCount() { return _repository.getCount(); } diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index 6bc6a7066d..acb1eddda5 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -220,12 +220,22 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { }); } + Future removeUser(String albumId, {required String userId}) { + return _db.remoteAlbumUserEntity.deleteWhere((row) => row.albumId.equals(albumId) & row.userId.equals(userId)); + } + Future deleteAlbum(String albumId) async { return _db.transaction(() async { await _db.remoteAlbumEntity.deleteWhere((table) => table.id.equals(albumId)); }); } + Future setActivityStatus(String albumId, bool isEnabled) async { + final query = _db.update(_db.remoteAlbumEntity)..where((row) => row.id.equals(albumId)); + + await query.write(RemoteAlbumEntityCompanion(isActivityEnabled: Value(isEnabled))); + } + Stream watchAlbum(String albumId) { final query = _db.remoteAlbumEntity.select().join([ diff --git a/mobile/lib/presentation/pages/drift_album_options.page.dart b/mobile/lib/presentation/pages/drift_album_options.page.dart new file mode 100644 index 0000000000..6c423be671 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_album_options.page.dart @@ -0,0 +1,238 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; + +@RoutePage() +class DriftAlbumOptionsPage extends HookConsumerWidget { + const DriftAlbumOptionsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final album = ref.watch(currentRemoteAlbumProvider); + if (album == null) { + return const SizedBox(); + } + + final sharedUsersAsync = ref.watch(remoteAlbumSharedUsersProvider(album.id)); + final userId = ref.watch(authProvider).userId; + final activityEnabled = useState(album.isActivityEnabled); + final isOwner = album.ownerId == userId; + + void showErrorMessage() { + context.pop(); + ImmichToast.show( + context: context, + msg: "shared_album_section_people_action_error".tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + + void leaveAlbum() async { + try { + await ref.read(remoteAlbumProvider.notifier).leaveAlbum(album.id, userId: userId); + context.navigateTo(const MainTimelineRoute(children: [DriftAlbumsRoute()])); + } catch (_) { + showErrorMessage(); + } + } + + void removeUserFromAlbum(UserDto user) async { + try { + await ref.read(remoteAlbumProvider.notifier).removeUser(album.id, user.id); + ref.invalidate(remoteAlbumSharedUsersProvider(album.id)); + } catch (error) { + showErrorMessage(); + } + + context.pop(); + } + + Future addUsers() async { + final newUsers = await context.pushRoute>(DriftUserSelectionRoute(album: album)); + + if (newUsers == null || newUsers.isEmpty) { + return; + } + + try { + await ref.read(remoteAlbumProvider.notifier).addUsers(album.id, newUsers); + + if (newUsers.isNotEmpty) { + ImmichToast.show( + context: context, + msg: "users_added_to_album_count".t(context: context, args: {'count': newUsers.length}), + toastType: ToastType.success, + ); + } + + ref.invalidate(remoteAlbumSharedUsersProvider(album.id)); + } catch (e) { + ImmichToast.show( + context: context, + msg: "Failed to add users to album: ${e.toString()}", + toastType: ToastType.error, + ); + } + } + + void handleUserClick(UserDto user) { + var actions = []; + + if (user.id == userId) { + actions = [ + ListTile( + leading: const Icon(Icons.exit_to_app_rounded), + title: const Text("shared_album_section_people_action_leave").tr(), + onTap: leaveAlbum, + ), + ]; + } + + if (isOwner) { + actions = [ + ListTile( + leading: const Icon(Icons.person_remove_rounded), + title: const Text("shared_album_section_people_action_remove_user").tr(), + onTap: () => removeUserFromAlbum(user), + ), + ]; + } + + showModalBottomSheet( + backgroundColor: context.colorScheme.surfaceContainer, + isScrollControlled: false, + context: context, + builder: (context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Column(mainAxisSize: MainAxisSize.min, children: [...actions]), + ), + ); + }, + ); + } + + buildOwnerInfo() { + if (isOwner) { + final owner = ref.watch(currentUserProvider); + return ListTile( + leading: owner != null ? UserCircleAvatar(user: owner) : const SizedBox(), + title: Text(album.ownerName, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(owner?.email ?? "", style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), + trailing: Text("owner", style: context.textTheme.labelLarge).tr(), + ); + } else { + final usersProvider = ref.watch(driftUsersProvider); + return usersProvider.maybeWhen( + data: (users) { + final user = users.firstWhereOrNull((u) => u.id == album.ownerId); + + if (user == null) { + return const SizedBox(); + } + + return ListTile( + leading: UserCircleAvatar(user: user, radius: 22), + title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), + trailing: Text("owner", style: context.textTheme.labelLarge).tr(), + ); + }, + orElse: () => const SizedBox(), + ); + } + } + + buildSharedUsersList() { + return sharedUsersAsync.maybeWhen( + data: (sharedUsers) => ListView.builder( + primary: false, + shrinkWrap: true, + itemCount: sharedUsers.length, + itemBuilder: (context, index) { + final user = sharedUsers[index]; + return ListTile( + leading: UserCircleAvatar(user: user, radius: 22), + title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)), + trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(), + onTap: userId == user.id || isOwner ? () => handleUserClick(user) : null, + ); + }, + ), + orElse: () => const Center(child: CircularProgressIndicator()), + ); + } + + buildSectionTitle(String text) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Text(text, style: context.textTheme.bodySmall), + ); + } + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + onPressed: () => context.maybePop(null), + ), + centerTitle: true, + title: Text("options".tr()), + ), + body: ListView( + children: [ + const SizedBox(height: 8), + if (isOwner) + SwitchListTile.adaptive( + value: activityEnabled.value, + onChanged: (bool value) async { + activityEnabled.value = value; + await ref.read(remoteAlbumProvider.notifier).setActivityStatus(album.id, value); + }, + activeColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor, + dense: true, + title: Text( + "comments_and_likes", + style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500), + ).tr(), + subtitle: Text( + "let_others_respond", + style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary), + ).tr(), + ), + buildSectionTitle("shared_album_section_people_title".tr()), + if (isOwner) ...[ + ListTile( + leading: const Icon(Icons.person_add_rounded), + title: Text("invite_people".tr()), + onTap: () async => addUsers(), + ), + const Divider(indent: 16), + ], + buildOwnerInfo(), + buildSharedUsersList(), + ], + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index ca7735808c..4359f842b5 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -156,6 +156,19 @@ class RemoteAlbumNotifier extends Notifier { Future addUsers(String albumId, List userIds) { return _remoteAlbumService.addUsers(albumId: albumId, userIds: userIds); } + + Future removeUser(String albumId, String userId) { + return _remoteAlbumService.removeUser(albumId, userId: userId); + } + + Future leaveAlbum(String albumId, {required String userId}) async { + await _remoteAlbumService.removeUser(albumId, userId: userId); + await deleteAlbum(albumId); + } + + Future setActivityStatus(String albumId, bool enabled) { + return _remoteAlbumService.setActivityStatus(albumId, enabled); + } } final remoteAlbumDateRangeProvider = FutureProvider.family<(DateTime, DateTime), String>((ref, albumId) async { diff --git a/mobile/lib/repositories/drift_album_api_repository.dart b/mobile/lib/repositories/drift_album_api_repository.dart index 6de025fb47..10d8a54e72 100644 --- a/mobile/lib/repositories/drift_album_api_repository.dart +++ b/mobile/lib/repositories/drift_album_api_repository.dart @@ -87,6 +87,15 @@ class DriftAlbumApiRepository extends ApiRepository { final response = await checkNull(_api.addUsersToAlbum(albumId, AddUsersDto(albumUsers: albumUsers))); return response.toRemoteAlbum(); } + + Future removeUser(String albumId, {required String userId}) async { + await _api.removeUserFromAlbum(albumId, userId); + } + + Future setActivityStatus(String albumId, bool isEnabled) async { + final response = await checkNull(_api.updateAlbumInfo(albumId, UpdateAlbumDto(isActivityEnabled: isEnabled))); + return response.isActivityEnabled; + } } extension on AlbumResponseDto { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 4fe1673893..e75da235df 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -23,11 +23,11 @@ import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart' import 'package:immich_mobile/pages/album/album_viewer.page.dart'; import 'package:immich_mobile/pages/albums/albums.page.dart'; import 'package:immich_mobile/pages/backup/album_preview.page.dart'; -import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart'; -import 'package:immich_mobile/pages/backup/drift_backup.page.dart'; import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; +import 'package:immich_mobile/pages/backup/drift_backup.page.dart'; +import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/drift_backup_options.page.dart'; import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; @@ -81,6 +81,7 @@ import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.da import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; @@ -95,9 +96,9 @@ import 'package:immich_mobile/presentation/pages/drift_person.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart'; @@ -329,6 +330,7 @@ class AppRouter extends RootStackRouter { AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]), AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index e8f0dd8b1f..f5b3728ffe 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -667,6 +667,22 @@ class CropImageRouteArgs { } } +/// generated route for +/// [DriftAlbumOptionsPage] +class DriftAlbumOptionsRoute extends PageRouteInfo { + const DriftAlbumOptionsRoute({List? children}) + : super(DriftAlbumOptionsRoute.name, initialChildren: children); + + static const String name = 'DriftAlbumOptionsRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftAlbumOptionsPage(); + }, + ); +} + /// generated route for /// [DriftAlbumsPage] class DriftAlbumsRoute extends PageRouteInfo { diff --git a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart index 7be5db1798..9f88b23f92 100644 --- a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart +++ b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart @@ -1,7 +1,9 @@ +import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; class RemoteAlbumSharedUserIcons extends ConsumerWidget { @@ -22,17 +24,20 @@ class RemoteAlbumSharedUserIcons extends ConsumerWidget { return const SizedBox(); } - return SizedBox( - height: 50, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemBuilder: ((context, index) { - return Padding( - padding: const EdgeInsets.only(right: 4.0), - child: UserCircleAvatar(user: sharedUsers[index], radius: 18, size: 36, hasBorder: true), - ); - }), - itemCount: sharedUsers.length, + return GestureDetector( + onTap: () => context.pushRoute(const DriftAlbumOptionsRoute()), + child: SizedBox( + height: 50, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemBuilder: ((context, index) { + return Padding( + padding: const EdgeInsets.only(right: 4.0), + child: UserCircleAvatar(user: sharedUsers[index], radius: 18, size: 36, hasBorder: true), + ); + }), + itemCount: sharedUsers.length, + ), ), ); },