diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 9cb03f6758..9327780f1d 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -69,7 +69,7 @@ custom_lint: # acceptable exceptions for the time being (until Isar is fully replaced) - integration_test/test_utils/general_helper.dart - lib/main.dart - - lib/pages/common/album_asset_selection.page.dart + - lib/pages/album/album_asset_selection.page.dart - lib/routing/router.dart - lib/services/immich_logger.service.dart # not really a service... more a util - lib/utils/{db,migration,renderlist_generator}.dart diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 46e9758d85..91fffd6deb 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -1,4 +1,6 @@ { + "change_display_order": "Change display order", + "error_change_sort_album": "Failed to change album sort order", "action_common_back": "Back", "action_common_cancel": "Cancel", "action_common_clear": "Clear", diff --git a/mobile/ios/Runner/BackgroundSync/BackgroundSyncWorker.swift b/mobile/ios/Runner/BackgroundSync/BackgroundSyncWorker.swift index e391f5187e..88d9368308 100644 --- a/mobile/ios/Runner/BackgroundSync/BackgroundSyncWorker.swift +++ b/mobile/ios/Runner/BackgroundSync/BackgroundSyncWorker.swift @@ -86,7 +86,7 @@ class BackgroundSyncWorker { result(false) break default: - result(FlutterError()) + result(FlutterError()) self.complete(UIBackgroundFetchResult.failed) } } diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart new file mode 100644 index 0000000000..a9b5107426 --- /dev/null +++ b/mobile/lib/constants/enums.dart @@ -0,0 +1,4 @@ +enum SortOrder { + asc, + desc, +} diff --git a/mobile/lib/entities/album.entity.dart b/mobile/lib/entities/album.entity.dart index 6331c4b9f0..8caff2255f 100644 --- a/mobile/lib/entities/album.entity.dart +++ b/mobile/lib/entities/album.entity.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart'; @@ -23,6 +24,7 @@ class Album { this.lastModifiedAssetTimestamp, required this.shared, required this.activityEnabled, + this.sortOrder = SortOrder.desc, }); // fields stored in DB @@ -39,6 +41,8 @@ class Album { DateTime? lastModifiedAssetTimestamp; bool shared; bool activityEnabled; + @enumerated + SortOrder sortOrder; final IsarLink owner = IsarLink(); final IsarLink thumbnail = IsarLink(); final IsarLinks sharedUsers = IsarLinks(); @@ -154,6 +158,11 @@ class Album { ); a.remoteAssetCount = dto.assetCount; a.owner.value = await db.users.getById(dto.ownerId); + if (dto.order != null) { + a.sortOrder = + dto.order == AssetOrder.asc ? SortOrder.asc : SortOrder.desc; + } + if (dto.albumThumbnailAssetId != null) { a.thumbnail.value = await db.assets .where() diff --git a/mobile/lib/entities/album.entity.g.dart b/mobile/lib/entities/album.entity.g.dart index b1e322e397..327dc606ca 100644 Binary files a/mobile/lib/entities/album.entity.g.dart and b/mobile/lib/entities/album.entity.g.dart differ diff --git a/mobile/lib/interfaces/album_api.interface.dart b/mobile/lib/interfaces/album_api.interface.dart index 33b589841f..b751ccc170 100644 --- a/mobile/lib/interfaces/album_api.interface.dart +++ b/mobile/lib/interfaces/album_api.interface.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/album.entity.dart'; abstract interface class IAlbumApiRepository { @@ -17,6 +18,7 @@ abstract interface class IAlbumApiRepository { String? thumbnailAssetId, String? description, bool? activityEnabled, + SortOrder? sortOrder, }); Future delete(String albumId); diff --git a/mobile/lib/pages/common/album_additional_shared_user_selection.page.dart b/mobile/lib/pages/album/album_additional_shared_user_selection.page.dart similarity index 100% rename from mobile/lib/pages/common/album_additional_shared_user_selection.page.dart rename to mobile/lib/pages/album/album_additional_shared_user_selection.page.dart diff --git a/mobile/lib/pages/common/album_asset_selection.page.dart b/mobile/lib/pages/album/album_asset_selection.page.dart similarity index 100% rename from mobile/lib/pages/common/album_asset_selection.page.dart rename to mobile/lib/pages/album/album_asset_selection.page.dart diff --git a/mobile/lib/pages/album/album_control_button.dart b/mobile/lib/pages/album/album_control_button.dart new file mode 100644 index 0000000000..b0ac3fbfa5 --- /dev/null +++ b/mobile/lib/pages/album/album_control_button.dart @@ -0,0 +1,53 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; + +// ignore: must_be_immutable +class AlbumControlButton extends ConsumerWidget { + void Function() onAddPhotosPressed; + void Function() onAddUsersPressed; + + AlbumControlButton({ + super.key, + required this.onAddPhotosPressed, + required this.onAddUsersPressed, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userId = ref.watch(authProvider).userId; + final isOwner = ref.watch( + currentAlbumProvider.select((album) { + return album?.ownerId == userId; + }), + ); + + return Padding( + padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16), + child: SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + AlbumActionFilledButton( + key: const ValueKey('add_photos_button'), + iconData: Icons.add_photo_alternate_outlined, + onPressed: onAddPhotosPressed, + labelText: "share_add_photos".tr(), + ), + if (isOwner) + AlbumActionFilledButton( + key: const ValueKey('add_users_button'), + iconData: Icons.person_add_alt_rounded, + onPressed: onAddUsersPressed, + labelText: "album_viewer_page_share_add_users".tr(), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/pages/album/album_date_range.dart b/mobile/lib/pages/album/album_date_range.dart new file mode 100644 index 0000000000..5f7ef40d4b --- /dev/null +++ b/mobile/lib/pages/album/album_date_range.dart @@ -0,0 +1,61 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; + +class AlbumDateRange extends ConsumerWidget { + const AlbumDateRange({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final data = ref.watch( + currentAlbumProvider.select((album) { + if (album == null || album.assets.isEmpty) { + return null; + } + + final startDate = album.startDate; + final endDate = album.endDate; + if (startDate == null || endDate == null) { + return null; + } + return (startDate, endDate, album.shared); + }), + ); + + if (data == null) { + return const SizedBox(); + } + final (startDate, endDate, shared) = data; + + return Padding( + padding: shared + ? const EdgeInsets.only( + left: 16.0, + bottom: 0.0, + ) + : const EdgeInsets.only(left: 16.0, bottom: 8.0), + child: Text( + _getDateRangeText(startDate, endDate), + style: context.textTheme.labelLarge, + ), + ); + } + + @pragma('vm:prefer-inline') + String _getDateRangeText(DateTime startDate, DateTime endDate) { + if (startDate.day == endDate.day && + startDate.month == endDate.month && + startDate.year == endDate.year) { + return DateFormat.yMMMd().format(startDate); + } + + final String startDateText = (startDate.year == endDate.year + ? DateFormat.MMMd() + : DateFormat.yMMMd()) + .format(startDate); + final String endDateText = DateFormat.yMMMd().format(endDate); + return "$startDateText - $endDateText"; + } +} diff --git a/mobile/lib/pages/common/album_options.page.dart b/mobile/lib/pages/album/album_options.page.dart similarity index 96% rename from mobile/lib/pages/common/album_options.page.dart rename to mobile/lib/pages/album/album_options.page.dart index 93dfad00c4..0e9bfeb2ce 100644 --- a/mobile/lib/pages/common/album_options.page.dart +++ b/mobile/lib/pages/album/album_options.page.dart @@ -7,22 +7,25 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/utils/immich_loading_overlay.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; @RoutePage() class AlbumOptionsPage extends HookConsumerWidget { - final Album album; - - const AlbumOptionsPage({super.key, required this.album}); + const AlbumOptionsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final album = ref.watch(currentAlbumProvider); + if (album == null) { + return const SizedBox(); + } + final sharedUsers = useState(album.sharedUsers.toList()); final owner = album.owner.value; final userId = ref.watch(authProvider).userId; diff --git a/mobile/lib/pages/album/album_shared_user_icons.dart b/mobile/lib/pages/album/album_shared_user_icons.dart new file mode 100644 index 0000000000..4cb9804e25 --- /dev/null +++ b/mobile/lib/pages/album/album_shared_user_icons.dart @@ -0,0 +1,56 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/user.entity.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; + +class AlbumSharedUserIcons extends HookConsumerWidget { + const AlbumSharedUserIcons({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sharedUsers = useRef>(const []); + sharedUsers.value = ref.watch( + currentAlbumProvider.select((album) { + if (album == null) { + return const []; + } + + if (album.sharedUsers.length == sharedUsers.value.length) { + return sharedUsers.value; + } + + return album.sharedUsers.toList(growable: false); + }), + ); + + if (sharedUsers.value.isEmpty) { + return const SizedBox(); + } + + return GestureDetector( + onTap: () => context.pushRoute(AlbumOptionsRoute()), + child: SizedBox( + height: 50, + child: ListView.builder( + padding: const EdgeInsets.only(left: 16), + scrollDirection: Axis.horizontal, + itemBuilder: ((context, index) { + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: UserCircleAvatar( + user: sharedUsers.value[index], + radius: 18, + size: 36, + ), + ); + }), + itemCount: sharedUsers.value.length, + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/album_shared_user_selection.page.dart b/mobile/lib/pages/album/album_shared_user_selection.page.dart similarity index 100% rename from mobile/lib/pages/common/album_shared_user_selection.page.dart rename to mobile/lib/pages/album/album_shared_user_selection.page.dart diff --git a/mobile/lib/pages/album/album_title.dart b/mobile/lib/pages/album/album_title.dart new file mode 100644 index 0000000000..435e282523 --- /dev/null +++ b/mobile/lib/pages/album/album_title.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; + +class AlbumTitle extends ConsumerWidget { + const AlbumTitle({super.key, required this.titleFocusNode}); + + final FocusNode titleFocusNode; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userId = ref.watch(authProvider).userId; + final (isOwner, isRemote, albumName) = ref.watch( + currentAlbumProvider.select((album) { + if (album == null) { + return const (false, false, ''); + } + + return (album.ownerId == userId, album.isRemote, album.name); + }), + ); + + if (isOwner && isRemote) { + return Padding( + padding: const EdgeInsets.only(left: 8, right: 8), + child: AlbumViewerEditableTitle( + albumName: albumName, + titleFocusNode: titleFocusNode, + ), + ); + } + + return Padding( + padding: const EdgeInsets.only(left: 16, right: 8), + child: Text(albumName, style: context.textTheme.headlineMedium), + ); + } +} diff --git a/mobile/lib/pages/album/album_viewer.dart b/mobile/lib/pages/album/album_viewer.dart new file mode 100644 index 0000000000..19782c4e30 --- /dev/null +++ b/mobile/lib/pages/album/album_viewer.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart'; +import 'package:immich_mobile/pages/album/album_control_button.dart'; +import 'package:immich_mobile/pages/album/album_date_range.dart'; +import 'package:immich_mobile/pages/album/album_shared_user_icons.dart'; +import 'package:immich_mobile/pages/album/album_title.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; +import 'package:immich_mobile/utils/immich_loading_overlay.dart'; +import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class AlbumViewer extends HookConsumerWidget { + const AlbumViewer({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final album = ref.watch(currentAlbumProvider); + if (album == null) { + return const SizedBox(); + } + + final titleFocusNode = useFocusNode(); + final userId = ref.watch(authProvider).userId; + final isMultiselecting = ref.watch(multiselectProvider); + final isProcessing = useProcessingOverlay(); + + Future onRemoveFromAlbumPressed(Iterable assets) async { + final bool isSuccess = + await ref.read(albumProvider.notifier).removeAsset(album, assets); + + if (!isSuccess) { + ImmichToast.show( + context: context, + msg: "album_viewer_appbar_share_err_remove".tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + return isSuccess; + } + + /// Find out if the assets in album exist on the device + /// If they exist, add to selected asset state to show they are already selected. + void onAddPhotosPressed() async { + AssetSelectionPageResult? returnPayload = + await context.pushRoute( + AlbumAssetSelectionRoute( + existingAssets: album.assets, + canDeselect: false, + query: getRemoteAssetQuery(ref), + ), + ); + + if (returnPayload != null && returnPayload.selectedAssets.isNotEmpty) { + // Check if there is new assets add + isProcessing.value = true; + + await ref + .watch(albumProvider.notifier) + .addAssets(album, returnPayload.selectedAssets); + + isProcessing.value = false; + } + } + + void onAddUsersPressed() async { + List? sharedUserIds = await context.pushRoute?>( + AlbumAdditionalSharedUserSelectionRoute(album: album), + ); + + if (sharedUserIds != null) { + isProcessing.value = true; + + await ref.watch(albumProvider.notifier).addUsers(album, sharedUserIds); + + isProcessing.value = false; + } + } + + onActivitiesPressed() { + if (album.remoteId != null) { + context.pushRoute( + const ActivitiesRoute(), + ); + } + } + + return Stack( + children: [ + MultiselectGrid( + key: const ValueKey("albumViewerMultiselectGrid"), + renderListProvider: albumRenderlistProvider(album.id), + topWidget: Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AlbumTitle( + key: const ValueKey("albumTitle"), + titleFocusNode: titleFocusNode, + ), + const AlbumDateRange(), + const AlbumSharedUserIcons(), + if (album.isRemote) + AlbumControlButton( + key: const ValueKey("albumControlButton"), + onAddPhotosPressed: onAddPhotosPressed, + onAddUsersPressed: onAddUsersPressed, + ), + ], + ), + onRemoveFromAlbum: onRemoveFromAlbumPressed, + editEnabled: album.ownerId == userId, + ), + AnimatedPositioned( + key: const ValueKey("albumViewerAppbarPositioned"), + duration: const Duration(milliseconds: 300), + top: isMultiselecting ? -(kToolbarHeight + context.padding.top) : 0, + left: 0, + right: 0, + child: AlbumViewerAppbar( + key: const ValueKey("albumViewerAppbar"), + titleFocusNode: titleFocusNode, + userId: userId, + onAddPhotos: onAddPhotosPressed, + onAddUsers: onAddUsersPressed, + onActivities: onActivitiesPressed, + ), + ), + ], + ); + } +} diff --git a/mobile/lib/pages/album/album_viewer.page.dart b/mobile/lib/pages/album/album_viewer.page.dart new file mode 100644 index 0000000000..491bd3bb8d --- /dev/null +++ b/mobile/lib/pages/album/album_viewer.page.dart @@ -0,0 +1,27 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/pages/album/album_viewer.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; + +@RoutePage() +class AlbumViewerPage extends HookConsumerWidget { + final int albumId; + + const AlbumViewerPage({super.key, required this.albumId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Listen provider to prevent autoDispose when navigating to other routes from within the viewer page + ref.listen(currentAlbumProvider, (_, __) {}); + + ref.listen(albumWatcher(albumId), (_, albumFuture) { + albumFuture.whenData( + (value) => ref.read(currentAlbumProvider.notifier).set(value), + ); + }); + + return const Scaffold(body: AlbumViewer()); + } +} diff --git a/mobile/lib/pages/common/album_viewer.page.dart b/mobile/lib/pages/common/album_viewer.page.dart deleted file mode 100644 index 4822c57a07..0000000000 --- a/mobile/lib/pages/common/album_viewer.page.dart +++ /dev/null @@ -1,267 +0,0 @@ -import 'dart:async'; - -import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart'; -import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/album/current_album.provider.dart'; -import 'package:immich_mobile/utils/immich_loading_overlay.dart'; -import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; -import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart'; -import 'package:immich_mobile/providers/multiselect.provider.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; -import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/album.entity.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; - -@RoutePage() -class AlbumViewerPage extends HookConsumerWidget { - final int albumId; - - const AlbumViewerPage({super.key, required this.albumId}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - FocusNode titleFocusNode = useFocusNode(); - final album = ref.watch(albumWatcher(albumId)); - // Listen provider to prevent autoDispose when navigating to other routes from within the viewer page - ref.listen(currentAlbumProvider, (_, __) {}); - album.whenData( - (value) => Future.microtask( - () => ref.read(currentAlbumProvider.notifier).set(value), - ), - ); - final userId = ref.watch(authProvider).userId; - final isProcessing = useProcessingOverlay(); - - Future onRemoveFromAlbumPressed(Iterable assets) async { - final a = album.valueOrNull; - final bool isSuccess = a != null && - await ref.read(albumProvider.notifier).removeAsset(a, assets); - - if (!isSuccess) { - ImmichToast.show( - context: context, - msg: "album_viewer_appbar_share_err_remove".tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - return isSuccess; - } - - /// Find out if the assets in album exist on the device - /// If they exist, add to selected asset state to show they are already selected. - void onAddPhotosPressed(Album albumInfo) async { - AssetSelectionPageResult? returnPayload = - await context.pushRoute( - AlbumAssetSelectionRoute( - existingAssets: albumInfo.assets, - canDeselect: false, - query: getRemoteAssetQuery(ref), - ), - ); - - if (returnPayload != null && returnPayload.selectedAssets.isNotEmpty) { - // Check if there is new assets add - isProcessing.value = true; - - await ref.watch(albumProvider.notifier).addAssets( - albumInfo, - returnPayload.selectedAssets, - ); - - isProcessing.value = false; - } - } - - void onAddUsersPressed(Album album) async { - List? sharedUserIds = await context.pushRoute?>( - AlbumAdditionalSharedUserSelectionRoute(album: album), - ); - - if (sharedUserIds != null) { - isProcessing.value = true; - - await ref.watch(albumProvider.notifier).addUsers(album, sharedUserIds); - - isProcessing.value = false; - } - } - - Widget buildControlButton(Album album) { - return Padding( - padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 16), - child: SizedBox( - height: 40, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - AlbumActionFilledButton( - iconData: Icons.add_photo_alternate_outlined, - onPressed: () => onAddPhotosPressed(album), - labelText: "share_add_photos".tr(), - ), - if (userId == album.ownerId) - AlbumActionFilledButton( - iconData: Icons.person_add_alt_rounded, - onPressed: () => onAddUsersPressed(album), - labelText: "album_viewer_page_share_add_users".tr(), - ), - ], - ), - ), - ); - } - - Widget buildTitle(Album album) { - return Padding( - padding: const EdgeInsets.only(left: 8, right: 8), - child: userId == album.ownerId && album.isRemote - ? AlbumViewerEditableTitle( - album: album, - titleFocusNode: titleFocusNode, - ) - : Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - album.name, - style: context.textTheme.headlineMedium, - ), - ), - ); - } - - Widget buildAlbumDateRange(Album album) { - final DateTime? startDate = album.startDate; - final DateTime? endDate = album.endDate; - - if (startDate == null || endDate == null) { - return const SizedBox(); - } - - final String dateRangeText; - if (startDate.day == endDate.day && - startDate.month == endDate.month && - startDate.year == endDate.year) { - dateRangeText = DateFormat.yMMMd().format(startDate); - } else { - final String startDateText = (startDate.year == endDate.year - ? DateFormat.MMMd() - : DateFormat.yMMMd()) - .format(startDate); - final String endDateText = DateFormat.yMMMd().format(endDate); - dateRangeText = "$startDateText - $endDateText"; - } - - return Padding( - padding: EdgeInsets.only( - left: 16.0, - bottom: album.shared ? 0.0 : 8.0, - ), - child: Text( - dateRangeText, - style: context.textTheme.labelLarge, - ), - ); - } - - Widget buildSharedUserIconsRow(Album album) { - return album.sharedUsers.isNotEmpty - ? GestureDetector( - onTap: () => context.pushRoute(AlbumOptionsRoute(album: album)), - child: SizedBox( - height: 50, - child: ListView.builder( - padding: const EdgeInsets.only(left: 16), - scrollDirection: Axis.horizontal, - itemBuilder: ((context, index) { - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: UserCircleAvatar( - user: album.sharedUsers.toList()[index], - radius: 18, - size: 36, - ), - ); - }), - itemCount: album.sharedUsers.length, - ), - ), - ) - : const SizedBox.shrink(); - } - - Widget buildHeader(Album album) { - return Column( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - buildTitle(album), - if (album.assets.isNotEmpty == true) buildAlbumDateRange(album), - buildSharedUserIconsRow(album), - ], - ); - } - - onActivitiesPressed(Album album) { - if (album.remoteId != null) { - context.pushRoute( - const ActivitiesRoute(), - ); - } - } - - return Scaffold( - body: Stack( - children: [ - album.widgetWhen( - onData: (albumInfo) => MultiselectGrid( - renderListProvider: albumRenderlistProvider(albumId), - topWidget: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - buildHeader(albumInfo), - if (albumInfo.isRemote) buildControlButton(albumInfo), - ], - ), - onRemoveFromAlbum: onRemoveFromAlbumPressed, - editEnabled: albumInfo.ownerId == userId, - ), - ), - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - top: ref.watch(multiselectProvider) - ? -(kToolbarHeight + context.padding.top) - : 0, - left: 0, - right: 0, - child: album.when( - data: (data) => AlbumViewerAppbar( - titleFocusNode: titleFocusNode, - album: data, - userId: userId, - onAddPhotos: onAddPhotosPressed, - onAddUsers: onAddUsersPressed, - onActivities: onActivitiesPressed, - ), - error: (error, stackTrace) => AppBar(title: const Text("Error")), - loading: () => AppBar(), - ), - ), - ], - ), - ); - } -} diff --git a/mobile/lib/providers/album/album.provider.dart b/mobile/lib/providers/album/album.provider.dart index 53c8855c0a..b3d619a815 100644 --- a/mobile/lib/providers/album/album.provider.dart +++ b/mobile/lib/providers/album/album.provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/services/album.service.dart'; @@ -106,6 +107,13 @@ class AlbumNotifier extends StateNotifier> { return _albumService.setActivityStatus(album, enabled); } + Future toggleSortOrder(Album album) { + final order = + album.sortOrder == SortOrder.asc ? SortOrder.desc : SortOrder.asc; + + return _albumService.updateSortOrder(album, order); + } + @override void dispose() { _streamSub.cancel(); @@ -135,11 +143,22 @@ final albumWatcher = final albumRenderlistProvider = StreamProvider.autoDispose.family((ref, albumId) { final album = ref.watch(albumWatcher(albumId)).value; + if (album != null) { - final query = - album.assets.filter().isTrashedEqualTo(false).sortByFileCreatedAtDesc(); - return renderListGeneratorWithGroupBy(query, GroupAssetsBy.none); + final query = album.assets.filter().isTrashedEqualTo(false); + if (album.sortOrder == SortOrder.asc) { + return renderListGeneratorWithGroupBy( + query.sortByFileCreatedAt(), + GroupAssetsBy.none, + ); + } else if (album.sortOrder == SortOrder.desc) { + return renderListGeneratorWithGroupBy( + query.sortByFileCreatedAtDesc(), + GroupAssetsBy.none, + ); + } } + return const Stream.empty(); }); diff --git a/mobile/lib/providers/asset_viewer/render_list_status_provider.dart b/mobile/lib/providers/asset_viewer/render_list_status_provider.dart new file mode 100644 index 0000000000..903007031e --- /dev/null +++ b/mobile/lib/providers/asset_viewer/render_list_status_provider.dart @@ -0,0 +1,20 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +enum RenderListStatusEnum { complete, empty, error, loading } + +final renderListStatusProvider = + StateNotifierProvider((ref) { + return RenderListStatus(ref); +}); + +class RenderListStatus extends StateNotifier { + RenderListStatus(this.ref) : super(RenderListStatusEnum.complete); + + final Ref ref; + + RenderListStatusEnum get status => state; + + set status(RenderListStatusEnum value) { + state = value; + } +} diff --git a/mobile/lib/providers/backup/backup_verification.provider.g.dart b/mobile/lib/providers/backup/backup_verification.provider.g.dart index e286f43421..9b52698847 100644 Binary files a/mobile/lib/providers/backup/backup_verification.provider.g.dart and b/mobile/lib/providers/backup/backup_verification.provider.g.dart differ diff --git a/mobile/lib/repositories/album_api.repository.dart b/mobile/lib/repositories/album_api.repository.dart index 5d0b56dc78..2438304158 100644 --- a/mobile/lib/repositories/album_api.repository.dart +++ b/mobile/lib/repositories/album_api.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; @@ -56,7 +57,13 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { String? thumbnailAssetId, String? description, bool? activityEnabled, + SortOrder? sortOrder, }) async { + AssetOrder? order; + if (sortOrder != null) { + order = sortOrder == SortOrder.asc ? AssetOrder.asc : AssetOrder.desc; + } + final response = await checkNull( _api.updateAlbumInfo( albumId, @@ -65,9 +72,11 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { albumThumbnailAssetId: thumbnailAssetId, description: description, isActivityEnabled: activityEnabled, + order: order, ), ), ); + return _toAlbum(response); } @@ -152,6 +161,7 @@ class AlbumApiRepository extends ApiRepository implements IAlbumApiRepository { startDate: dto.startDate, endDate: dto.endDate, activityEnabled: dto.isActivityEnabled, + sortOrder: dto.order == AssetOrder.asc ? SortOrder.asc : SortOrder.desc, ); album.remoteAssetCount = dto.assetCount; album.owner.value = User.fromSimpleUserDto(dto.owner); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 785d23a7ad..5adfeb4061 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -20,11 +20,11 @@ import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; import 'package:immich_mobile/pages/library/places/places_collection.page.dart'; import 'package:immich_mobile/pages/library/library.page.dart'; import 'package:immich_mobile/pages/common/activities.page.dart'; -import 'package:immich_mobile/pages/common/album_additional_shared_user_selection.page.dart'; -import 'package:immich_mobile/pages/common/album_asset_selection.page.dart'; -import 'package:immich_mobile/pages/common/album_options.page.dart'; -import 'package:immich_mobile/pages/common/album_shared_user_selection.page.dart'; -import 'package:immich_mobile/pages/common/album_viewer.page.dart'; +import 'package:immich_mobile/pages/album/album_additional_shared_user_selection.page.dart'; +import 'package:immich_mobile/pages/album/album_asset_selection.page.dart'; +import 'package:immich_mobile/pages/album/album_options.page.dart'; +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/common/app_log.page.dart'; import 'package:immich_mobile/pages/common/app_log_detail.page.dart'; import 'package:immich_mobile/pages/common/create_album.page.dart'; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 48ee4db5fd..3bd8966175 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -139,13 +139,11 @@ class AlbumAssetSelectionRouteArgs { class AlbumOptionsRoute extends PageRouteInfo { AlbumOptionsRoute({ Key? key, - required Album album, List? children, }) : super( AlbumOptionsRoute.name, args: AlbumOptionsRouteArgs( key: key, - album: album, ), initialChildren: children, ); @@ -158,25 +156,19 @@ class AlbumOptionsRoute extends PageRouteInfo { final args = data.argsAs(); return AlbumOptionsPage( key: args.key, - album: args.album, ); }, ); } class AlbumOptionsRouteArgs { - const AlbumOptionsRouteArgs({ - this.key, - required this.album, - }); + const AlbumOptionsRouteArgs({this.key}); final Key? key; - final Album album; - @override String toString() { - return 'AlbumOptionsRouteArgs{key: $key, album: $album}'; + return 'AlbumOptionsRouteArgs{key: $key}'; } } diff --git a/mobile/lib/services/album.service.dart b/mobile/lib/services/album.service.dart index 1a23705916..5f013c0e53 100644 --- a/mobile/lib/services/album.service.dart +++ b/mobile/lib/services/album.service.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; @@ -436,4 +437,17 @@ class AlbumService { ) async { return _albumRepository.search(searchTerm, filterMode); } + + Future updateSortOrder(Album album, SortOrder order) async { + try { + final updateAlbum = + await _albumApiRepository.update(album.remoteId!, sortOrder: order); + album.sortOrder = updateAlbum.sortOrder; + + return _albumRepository.update(album); + } catch (error, stackTrace) { + _log.severe("Error updating album sort order", error, stackTrace); + } + return null; + } } diff --git a/mobile/lib/services/sync.service.dart b/mobile/lib/services/sync.service.dart index f1a6e9b0d7..086ec097d1 100644 --- a/mobile/lib/services/sync.service.dart +++ b/mobile/lib/services/sync.service.dart @@ -403,6 +403,8 @@ class SyncService { album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp; album.shared = dto.shared; album.activityEnabled = dto.activityEnabled; + album.sortOrder = dto.sortOrder; + final remoteThumbnailAssetId = dto.remoteThumbnailAssetId; if (remoteThumbnailAssetId != null && album.thumbnail.value?.remoteId != remoteThumbnailAssetId) { diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 67ff060075..681f8a22ce 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/utils/db.dart'; import 'package:isar/isar.dart'; -const int targetVersion = 7; +const int targetVersion = 8; Future migrateDatabaseIfNeeded(Isar db) async { final int version = Store.get(StoreKey.version, 1); diff --git a/mobile/lib/widgets/album/album_viewer_appbar.dart b/mobile/lib/widgets/album/album_viewer_appbar.dart index 525bfa1242..7c36ebc21d 100644 --- a/mobile/lib/widgets/album/album_viewer_appbar.dart +++ b/mobile/lib/widgets/album/album_viewer_appbar.dart @@ -1,22 +1,21 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/album/album_viewer.provider.dart'; -import 'package:immich_mobile/utils/immich_loading_overlay.dart'; +import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; class AlbumViewerAppbar extends HookConsumerWidget implements PreferredSizeWidget { const AlbumViewerAppbar({ super.key, - required this.album, required this.userId, required this.titleFocusNode, this.onAddPhotos, @@ -24,34 +23,48 @@ class AlbumViewerAppbar extends HookConsumerWidget required this.onActivities, }); - final Album album; final String userId; final FocusNode titleFocusNode; - final Function(Album album)? onAddPhotos; - final Function(Album album)? onAddUsers; - final Function(Album album) onActivities; + final void Function()? onAddPhotos; + final void Function()? onAddUsers; + final void Function() onActivities; @override Widget build(BuildContext context, WidgetRef ref) { - final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText; - final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum; - final isProcessing = useProcessingOverlay(); + final albumState = useState(ref.read(currentAlbumProvider)); + final album = albumState.value; + ref.listen(currentAlbumProvider, (_, newAlbum) { + final oldAlbum = albumState.value; + if (oldAlbum != null && newAlbum != null && oldAlbum.id == newAlbum.id) { + return; + } + + albumState.value = newAlbum; + }); + + if (album == null) { + return const SizedBox(); + } + + final albumViewer = ref.watch(albumViewerProvider); + final newAlbumTitle = albumViewer.editTitleText; + final isEditAlbum = albumViewer.isEditAlbum; + final comments = album.shared ? ref.watch(activityStatisticsProvider(album.remoteId!)) : 0; deleteAlbum() async { - isProcessing.value = true; + final bool success = + await ref.watch(albumProvider.notifier).deleteAlbum(album); - final bool success; if (album.shared) { - success = await ref.watch(albumProvider.notifier).deleteAlbum(album); context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])); } else { - success = await ref.watch(albumProvider.notifier).deleteAlbum(album); context .navigateTo(const TabControllerRoute(children: [LibraryRoute()])); } + if (!success) { ImmichToast.show( context: context, @@ -60,11 +73,9 @@ class AlbumViewerAppbar extends HookConsumerWidget gravity: ToastGravity.BOTTOM, ); } - - isProcessing.value = false; } - Future showConfirmationDialog() async { + Future onDeleteAlbumPressed() { return showDialog( context: context, barrierDismissible: false, // user must tap button! @@ -102,13 +113,7 @@ class AlbumViewerAppbar extends HookConsumerWidget ); } - void onDeleteAlbumPressed() async { - showConfirmationDialog(); - } - void onLeaveAlbumPressed() async { - isProcessing.value = true; - bool isSuccess = await ref.watch(albumProvider.notifier).leaveAlbum(album); @@ -123,8 +128,6 @@ class AlbumViewerAppbar extends HookConsumerWidget gravity: ToastGravity.BOTTOM, ); } - - isProcessing.value = false; } buildBottomSheetActions() { @@ -136,7 +139,7 @@ class AlbumViewerAppbar extends HookConsumerWidget 'album_viewer_appbar_share_delete', style: TextStyle(fontWeight: FontWeight.w500), ).tr(), - onTap: () => onDeleteAlbumPressed(), + onTap: onDeleteAlbumPressed, ) : ListTile( leading: const Icon(Icons.person_remove_rounded), @@ -144,25 +147,52 @@ class AlbumViewerAppbar extends HookConsumerWidget 'album_viewer_appbar_share_leave', style: TextStyle(fontWeight: FontWeight.w500), ).tr(), - onTap: () => onLeaveAlbumPressed(), + onTap: onLeaveAlbumPressed, ), ]; // } } + void onSortOrderToggled() async { + final updatedAlbum = + await ref.read(albumProvider.notifier).toggleSortOrder(album); + + if (updatedAlbum == null) { + ImmichToast.show( + context: context, + msg: "error_change_sort_album".tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + + context.pop(); + } + void buildBottomSheet() { final ownerActions = [ ListTile( leading: const Icon(Icons.person_add_alt_rounded), onTap: () { context.pop(); - onAddUsers!(album); + final onAddUsers = this.onAddUsers; + if (onAddUsers != null) { + onAddUsers(); + } }, title: const Text( "album_viewer_page_share_add_users", style: TextStyle(fontWeight: FontWeight.w500), ).tr(), ), + ListTile( + leading: const Icon(Icons.swap_vert_rounded), + onTap: onSortOrderToggled, + title: const Text( + "change_display_order", + style: TextStyle(fontWeight: FontWeight.w500), + ).tr(), + ), ListTile( leading: const Icon(Icons.share_rounded), onTap: () { @@ -176,7 +206,7 @@ class AlbumViewerAppbar extends HookConsumerWidget ), ListTile( leading: const Icon(Icons.settings_rounded), - onTap: () => context.navigateTo(AlbumOptionsRoute(album: album)), + onTap: () => context.navigateTo(AlbumOptionsRoute()), title: const Text( "translated_text_options", style: TextStyle(fontWeight: FontWeight.w500), @@ -189,7 +219,10 @@ class AlbumViewerAppbar extends HookConsumerWidget leading: const Icon(Icons.add_photo_alternate_outlined), onTap: () { context.pop(); - onAddPhotos!(album); + final onAddPhotos = this.onAddPhotos; + if (onAddPhotos != null) { + onAddPhotos(); + } }, title: const Text( "share_add_photos", @@ -222,9 +255,7 @@ class AlbumViewerAppbar extends HookConsumerWidget Widget buildActivitiesButton() { return IconButton( - onPressed: () { - onActivities(album); - }, + onPressed: onActivities, icon: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -271,7 +302,7 @@ class AlbumViewerAppbar extends HookConsumerWidget ); } else { return IconButton( - onPressed: () async => await context.maybePop(), + onPressed: context.maybePop, icon: const Icon(Icons.arrow_back_ios_rounded), splashRadius: 25, ); @@ -285,12 +316,13 @@ class AlbumViewerAppbar extends HookConsumerWidget actions: [ if (album.shared && (album.activityEnabled || comments != 0)) buildActivitiesButton(), - if (album.isRemote) + if (album.isRemote) ...[ IconButton( splashRadius: 25, onPressed: buildBottomSheet, icon: const Icon(Icons.more_horiz_rounded), ), + ], ], ); } diff --git a/mobile/lib/widgets/album/album_viewer_editable_title.dart b/mobile/lib/widgets/album/album_viewer_editable_title.dart index b585883871..7547dff932 100644 --- a/mobile/lib/widgets/album/album_viewer_editable_title.dart +++ b/mobile/lib/widgets/album/album_viewer_editable_title.dart @@ -4,20 +4,19 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album_viewer.provider.dart'; -import 'package:immich_mobile/entities/album.entity.dart'; class AlbumViewerEditableTitle extends HookConsumerWidget { - final Album album; + final String albumName; final FocusNode titleFocusNode; const AlbumViewerEditableTitle({ super.key, - required this.album, + required this.albumName, required this.titleFocusNode, }); @override Widget build(BuildContext context, WidgetRef ref) { - final titleTextEditController = useTextEditingController(text: album.name); + final titleTextEditController = useTextEditingController(text: albumName); void onFocusModeChange() { if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) { @@ -51,7 +50,7 @@ class AlbumViewerEditableTitle extends HookConsumerWidget { onTap: () { context.focusScope.requestFocus(titleFocusNode); - ref.watch(albumViewerProvider.notifier).setEditTitleText(album.name); + ref.watch(albumViewerProvider.notifier).setEditTitleText(albumName); ref.watch(albumViewerProvider.notifier).enableEditAlbum(); if (titleTextEditController.text == 'Untitled') { diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index 2bceafb595..6bcd6e5784 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -433,6 +433,7 @@ class MultiselectGrid extends HookConsumerWidget { ), if (selectionEnabledHook.value) ControlBottomAppBar( + key: const ValueKey("controlBottomAppBar"), onShare: onShareAssets, onFavorite: favoriteEnabled ? onFavoriteAssets : null, onArchive: archiveEnabled ? onArchiveAsset : null, diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid_status_indicator.dart b/mobile/lib/widgets/asset_grid/multiselect_grid_status_indicator.dart new file mode 100644 index 0000000000..b17029f2af --- /dev/null +++ b/mobile/lib/widgets/asset_grid/multiselect_grid_status_indicator.dart @@ -0,0 +1,35 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/asset_viewer/render_list_status_provider.dart'; +import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart'; + +class MultiselectGridStatusIndicator extends HookConsumerWidget { + const MultiselectGridStatusIndicator({ + super.key, + this.buildLoadingIndicator, + this.emptyIndicator, + }); + + final Widget Function()? buildLoadingIndicator; + final Widget? emptyIndicator; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final renderListStatus = ref.watch(renderListStatusProvider); + return switch (renderListStatus) { + RenderListStatusEnum.loading => buildLoadingIndicator == null + ? const Center( + child: DelayedLoadingIndicator( + delay: Duration(milliseconds: 500), + ), + ) + : buildLoadingIndicator!(), + RenderListStatusEnum.empty => + emptyIndicator ?? Center(child: const Text("no_assets_to_show").tr()), + RenderListStatusEnum.error => + Center(child: const Text("error_loading_assets").tr()), + RenderListStatusEnum.complete => const SizedBox() + }; + } +} diff --git a/mobile/openapi/devtools_options.yaml b/mobile/openapi/devtools_options.yaml new file mode 100644 index 0000000000..fa0b357c4f --- /dev/null +++ b/mobile/openapi/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: