diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 10898d51b0..ef9dc8adae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -115,7 +115,7 @@ jobs: flutter-version: "3.10.0" - name: Run tests working-directory: ./mobile - run: flutter test + run: flutter test -j 1 generated-api-up-to-date: name: Check generated files are up-to-date diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 03c6aed40b..344b5127d5 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -21,6 +21,7 @@ "asset_list_layout_settings_group_by": "Group assets by", "asset_list_layout_settings_group_by_month": "Month", "asset_list_layout_settings_group_by_month_day": "Month + day", + "asset_list_layout_settings_group_automatically": "Automatic", "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", "backup_album_selection_page_albums_device": "Albums on device ({})", @@ -276,4 +277,4 @@ "description_input_hint_text": "Add description...", "archive_page_title": "Archive ({})", "archive_page_no_archived_assets": "No archived assets found" -} +} \ No newline at end of file diff --git a/mobile/lib/modules/album/models/asset_selection_page_result.model.dart b/mobile/lib/modules/album/models/asset_selection_page_result.model.dart index b837efa689..8d8ff5a84b 100644 --- a/mobile/lib/modules/album/models/asset_selection_page_result.model.dart +++ b/mobile/lib/modules/album/models/asset_selection_page_result.model.dart @@ -2,47 +2,20 @@ import 'package:collection/collection.dart'; import 'package:immich_mobile/shared/models/asset.dart'; class AssetSelectionPageResult { - final Set selectedNewAsset; - final Set selectedAdditionalAsset; - final bool isAlbumExist; + final Set selectedAssets; AssetSelectionPageResult({ - required this.selectedNewAsset, - required this.selectedAdditionalAsset, - required this.isAlbumExist, + required this.selectedAssets, }); - - AssetSelectionPageResult copyWith({ - Set? selectedNewAsset, - Set? selectedAdditionalAsset, - bool? isAlbumExist, - }) { - return AssetSelectionPageResult( - selectedNewAsset: selectedNewAsset ?? this.selectedNewAsset, - selectedAdditionalAsset: - selectedAdditionalAsset ?? this.selectedAdditionalAsset, - isAlbumExist: isAlbumExist ?? this.isAlbumExist, - ); - } - - @override - String toString() => - 'AssetSelectionPageResult(selectedNewAsset: $selectedNewAsset, selectedAdditionalAsset: $selectedAdditionalAsset, isAlbumExist: $isAlbumExist)'; - @override bool operator ==(Object other) { if (identical(this, other)) return true; final setEquals = const DeepCollectionEquality().equals; return other is AssetSelectionPageResult && - setEquals(other.selectedNewAsset, selectedNewAsset) && - setEquals(other.selectedAdditionalAsset, selectedAdditionalAsset) && - other.isAlbumExist == isAlbumExist; + setEquals(other.selectedAssets, selectedAssets); } @override - int get hashCode => - selectedNewAsset.hashCode ^ - selectedAdditionalAsset.hashCode ^ - isAlbumExist.hashCode; + int get hashCode => selectedAssets.hashCode; } diff --git a/mobile/lib/modules/album/providers/album.provider.dart b/mobile/lib/modules/album/providers/album.provider.dart index 6011a7a9e9..24679c5178 100644 --- a/mobile/lib/modules/album/providers/album.provider.dart +++ b/mobile/lib/modules/album/providers/album.provider.dart @@ -1,4 +1,5 @@ -import 'package:collection/collection.dart'; +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; @@ -9,50 +10,38 @@ import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:isar/isar.dart'; class AlbumNotifier extends StateNotifier> { - AlbumNotifier(this._albumService, this._db) : super([]); + AlbumNotifier(this._albumService, Isar db) : super([]) { + final query = db.albums + .filter() + .owner((q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId)); + query.findAll().then((value) => state = value); + _streamSub = query.watch().listen((data) => state = data); + } final AlbumService _albumService; - final Isar _db; + late final StreamSubscription> _streamSub; - Future getAllAlbums() async { - final User me = Store.get(StoreKey.currentUser); - List albums = await _db.albums - .filter() - .owner((q) => q.isarIdEqualTo(me.isarId)) - .findAll(); - if (!const ListEquality().equals(albums, state)) { - state = albums; - } - await Future.wait([ - _albumService.refreshDeviceAlbums(), - _albumService.refreshRemoteAlbums(isShared: false), - ]); - albums = await _db.albums - .filter() - .owner((q) => q.isarIdEqualTo(me.isarId)) - .findAll(); - if (!const ListEquality().equals(albums, state)) { - state = albums; - } - } + Future getAllAlbums() => Future.wait([ + _albumService.refreshDeviceAlbums(), + _albumService.refreshRemoteAlbums(isShared: false), + ]); - Future deleteAlbum(Album album) async { - state = state.where((a) => a.id != album.id).toList(); - return _albumService.deleteAlbum(album); - } + Future deleteAlbum(Album album) => _albumService.deleteAlbum(album); Future createAlbum( String albumTitle, Set assets, - ) async { - Album? album = await _albumService.createAlbum(albumTitle, assets, []); - if (album != null) { - state = [...state, album]; - } - return album; + ) => + _albumService.createAlbum(albumTitle, assets, []); + + @override + void dispose() { + _streamSub.cancel(); + super.dispose(); } } -final albumProvider = StateNotifierProvider>((ref) { +final albumProvider = + StateNotifierProvider.autoDispose>((ref) { return AlbumNotifier( ref.watch(albumServiceProvider), ref.watch(dbProvider), diff --git a/mobile/lib/modules/album/providers/asset_selection.provider.dart b/mobile/lib/modules/album/providers/asset_selection.provider.dart deleted file mode 100644 index 6dfc2a462e..0000000000 --- a/mobile/lib/modules/album/providers/asset_selection.provider.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/album/models/asset_selection_state.model.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; - -class AssetSelectionNotifier extends StateNotifier { - AssetSelectionNotifier() - : super( - AssetSelectionState( - selectedNewAssetsForAlbum: {}, - selectedMonths: {}, - selectedAdditionalAssetsForAlbum: {}, - selectedAssetsInAlbumViewer: {}, - isAlbumExist: false, - isMultiselectEnable: false, - ), - ); - - void setIsAlbumExist(bool isAlbumExist) { - state = state.copyWith(isAlbumExist: isAlbumExist); - } - - void removeAssetsInMonth( - String removedMonth, - List assetsInMonth, - ) { - Set currentAssetList = state.selectedNewAssetsForAlbum; - Set currentMonthList = state.selectedMonths; - - currentMonthList - .removeWhere((selectedMonth) => selectedMonth == removedMonth); - - for (Asset asset in assetsInMonth) { - currentAssetList.removeWhere((e) => e.id == asset.id); - } - - state = state.copyWith( - selectedNewAssetsForAlbum: currentAssetList, - selectedMonths: currentMonthList, - ); - } - - void addAdditionalAssets(List assets) { - state = state.copyWith( - selectedAdditionalAssetsForAlbum: { - ...state.selectedAdditionalAssetsForAlbum, - ...assets - }, - ); - } - - void addAllAssetsInMonth(String month, List assetsInMonth) { - state = state.copyWith( - selectedMonths: {...state.selectedMonths, month}, - selectedNewAssetsForAlbum: { - ...state.selectedNewAssetsForAlbum, - ...assetsInMonth - }, - ); - } - - void addNewAssets(Iterable assets) { - state = state.copyWith( - selectedNewAssetsForAlbum: { - ...state.selectedNewAssetsForAlbum, - ...assets - }, - ); - } - - void removeSelectedNewAssets(List assets) { - Set currentList = state.selectedNewAssetsForAlbum; - - for (Asset asset in assets) { - currentList.removeWhere((e) => e.id == asset.id); - } - - state = state.copyWith(selectedNewAssetsForAlbum: currentList); - } - - void removeSelectedAdditionalAssets(List assets) { - Set currentList = state.selectedAdditionalAssetsForAlbum; - - for (Asset asset in assets) { - currentList.removeWhere((e) => e.id == asset.id); - } - - state = state.copyWith(selectedAdditionalAssetsForAlbum: currentList); - } - - void removeAll() { - state = state.copyWith( - selectedNewAssetsForAlbum: {}, - selectedMonths: {}, - selectedAdditionalAssetsForAlbum: {}, - selectedAssetsInAlbumViewer: {}, - isAlbumExist: false, - ); - } - - void enableMultiselection() { - state = state.copyWith(isMultiselectEnable: true); - } - - void disableMultiselection() { - state = state.copyWith( - isMultiselectEnable: false, - selectedAssetsInAlbumViewer: {}, - ); - } - - void addAssetsInAlbumViewer(List assets) { - state = state.copyWith( - selectedAssetsInAlbumViewer: { - ...state.selectedAssetsInAlbumViewer, - ...assets - }, - ); - } - - void removeAssetsInAlbumViewer(List assets) { - Set currentList = state.selectedAssetsInAlbumViewer; - - for (Asset asset in assets) { - currentList.removeWhere((e) => e.id == asset.id); - } - - state = state.copyWith(selectedAssetsInAlbumViewer: currentList); - } -} - -final assetSelectionProvider = - StateNotifierProvider((ref) { - return AssetSelectionNotifier(); -}); diff --git a/mobile/lib/modules/album/providers/shared_album.provider.dart b/mobile/lib/modules/album/providers/shared_album.provider.dart index 24a57a993d..fd7b396dd4 100644 --- a/mobile/lib/modules/album/providers/shared_album.provider.dart +++ b/mobile/lib/modules/album/providers/shared_album.provider.dart @@ -1,7 +1,9 @@ -import 'package:collection/collection.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/user.dart'; @@ -9,10 +11,14 @@ import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:isar/isar.dart'; class SharedAlbumNotifier extends StateNotifier> { - SharedAlbumNotifier(this._albumService, this._db) : super([]); + SharedAlbumNotifier(this._albumService, Isar db) : super([]) { + final query = db.albums.filter().sharedEqualTo(true).sortByCreatedAtDesc(); + query.findAll().then((value) => state = value); + _streamSub = query.watch().listen((data) => state = data); + } final AlbumService _albumService; - final Isar _db; + late final StreamSubscription> _streamSub; Future createSharedAlbum( String albumName, @@ -20,46 +26,21 @@ class SharedAlbumNotifier extends StateNotifier> { Iterable sharedUsers, ) async { try { - final Album? newAlbum = await _albumService.createAlbum( + return await _albumService.createAlbum( albumName, assets, sharedUsers, ); - - if (newAlbum != null) { - state = [...state, newAlbum]; - return newAlbum; - } } catch (e) { debugPrint("Error createSharedAlbum ${e.toString()}"); } return null; } - Future getAllSharedAlbums() async { - var albums = await _db.albums - .filter() - .sharedEqualTo(true) - .sortByCreatedAtDesc() - .findAll(); - if (!const ListEquality().equals(albums, state)) { - state = albums; - } - await _albumService.refreshRemoteAlbums(isShared: true); - albums = await _db.albums - .filter() - .sharedEqualTo(true) - .sortByCreatedAtDesc() - .findAll(); - if (!const ListEquality().equals(albums, state)) { - state = albums; - } - } + Future getAllSharedAlbums() => + _albumService.refreshRemoteAlbums(isShared: true); - Future deleteAlbum(Album album) { - state = state.where((a) => a.id != album.id).toList(); - return _albumService.deleteAlbum(album); - } + Future deleteAlbum(Album album) => _albumService.deleteAlbum(album); Future leaveAlbum(Album album) async { var res = await _albumService.leaveAlbum(album); @@ -75,10 +56,16 @@ class SharedAlbumNotifier extends StateNotifier> { Future removeAssetFromAlbum(Album album, Iterable assets) { return _albumService.removeAssetFromAlbum(album, assets); } + + @override + void dispose() { + _streamSub.cancel(); + super.dispose(); + } } final sharedAlbumProvider = - StateNotifierProvider>((ref) { + StateNotifierProvider.autoDispose>((ref) { return SharedAlbumNotifier( ref.watch(albumServiceProvider), ref.watch(dbProvider), @@ -86,10 +73,15 @@ final sharedAlbumProvider = }); final sharedAlbumDetailProvider = - FutureProvider.autoDispose.family((ref, albumId) async { + StreamProvider.autoDispose.family((ref, albumId) async* { final AlbumService sharedAlbumService = ref.watch(albumServiceProvider); - final Album? a = await sharedAlbumService.getAlbumDetail(albumId); - await a?.loadSortedAssets(); - return a; + await for (final a in sharedAlbumService.watchAlbum(albumId)) { + if (a == null) { + throw Exception("Album with ID=$albumId does not exist anymore!"); + } + await for (final _ in a.watchRenderList(GroupAssetsBy.none)) { + yield a; + } + } }); diff --git a/mobile/lib/modules/album/services/album.service.dart b/mobile/lib/modules/album/services/album.service.dart index 4feabcf033..2e45d87c8d 100644 --- a/mobile/lib/modules/album/services/album.service.dart +++ b/mobile/lib/modules/album/services/album.service.dart @@ -214,8 +214,9 @@ class AlbumService { ); } - Future getAlbumDetail(int albumId) { - return _db.albums.get(albumId); + Stream watchAlbum(int albumId) async* { + yield await _db.albums.get(albumId); + yield* _db.albums.watchObject(albumId); } Future addAdditionalAssetToAlbum( diff --git a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart index 8834b9827f..3abe0d8078 100644 --- a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart +++ b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; -import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; @@ -110,12 +109,6 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { TextStyle(color: Theme.of(context).primaryColor), ), onPressed: () { - ref - .watch(assetSelectionProvider.notifier) - .removeAll(); - ref - .watch(assetSelectionProvider.notifier) - .addNewAssets(assets); AutoRouter.of(context).push( CreateAlbumRoute( isSharedAlbum: false, diff --git a/mobile/lib/modules/album/ui/add_to_album_sliverlist.dart b/mobile/lib/modules/album/ui/add_to_album_sliverlist.dart index 4fcd3beadb..d18740a9ad 100644 --- a/mobile/lib/modules/album/ui/add_to_album_sliverlist.dart +++ b/mobile/lib/modules/album/ui/add_to_album_sliverlist.dart @@ -9,12 +9,14 @@ class AddToAlbumSliverList extends HookConsumerWidget { final List albums; final List sharedAlbums; final void Function(Album) onAddToAlbum; + final bool enabled; const AddToAlbumSliverList({ Key? key, required this.onAddToAlbum, required this.albums, required this.sharedAlbums, + this.enabled = true, }) : super(key: key); @override @@ -28,14 +30,14 @@ class AddToAlbumSliverList extends HookConsumerWidget { return Padding( padding: const EdgeInsets.only(bottom: 8), child: ExpansionTile( - title: Text('common_shared'.tr()), + title: Text('common_shared'.tr()), tilePadding: const EdgeInsets.symmetric(horizontal: 10.0), leading: const Icon(Icons.group), children: sharedAlbums .map( (album) => AlbumThumbnailListTile( album: album, - onTap: () => onAddToAlbum(album), + onTap: enabled ? () => onAddToAlbum(album) : () {}, ), ) .toList(), @@ -48,7 +50,7 @@ class AddToAlbumSliverList extends HookConsumerWidget { final album = albums[offset]; return AlbumThumbnailListTile( album: album, - onTap: () => onAddToAlbum(album), + onTap: enabled ? () => onAddToAlbum(album) : () {}, ); }), ); diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index ec74e2fd13..91964b5cdf 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -5,10 +5,10 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; -import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; @@ -18,17 +18,19 @@ class AlbumViewerAppbar extends HookConsumerWidget Key? key, required this.album, required this.userId, + required this.selected, + required this.selectionDisabled, + required this.titleFocusNode, }) : super(key: key); final Album album; final String userId; + final Set selected; + final void Function() selectionDisabled; + final FocusNode titleFocusNode; @override Widget build(BuildContext context, WidgetRef ref) { - final isMultiSelectionEnable = - ref.watch(assetSelectionProvider).isMultiselectEnable; - final selectedAssetsInAlbum = - ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText; final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum; @@ -86,12 +88,12 @@ class AlbumViewerAppbar extends HookConsumerWidget bool isSuccess = await ref.watch(sharedAlbumProvider.notifier).removeAssetFromAlbum( album, - selectedAssetsInAlbum, + selected, ); if (isSuccess) { Navigator.pop(context); - ref.watch(assetSelectionProvider.notifier).disableMultiselection(); + selectionDisabled(); ref.watch(albumProvider.notifier).getAllAlbums(); ref.invalidate(sharedAlbumDetailProvider(album.id)); } else { @@ -108,7 +110,7 @@ class AlbumViewerAppbar extends HookConsumerWidget } buildBottomSheetActionButton() { - if (isMultiSelectionEnable) { + if (selected.isNotEmpty) { if (album.ownerId == userId) { return ListTile( leading: const Icon(Icons.delete_sweep_rounded), @@ -163,11 +165,9 @@ class AlbumViewerAppbar extends HookConsumerWidget } buildLeadingButton() { - if (isMultiSelectionEnable) { + if (selected.isNotEmpty) { return IconButton( - onPressed: () => ref - .watch(assetSelectionProvider.notifier) - .disableMultiselection(), + onPressed: selectionDisabled, icon: const Icon(Icons.close_rounded), splashRadius: 25, ); @@ -202,9 +202,7 @@ class AlbumViewerAppbar extends HookConsumerWidget return AppBar( elevation: 0, leading: buildLeadingButton(), - title: isMultiSelectionEnable - ? Text('${selectedAssetsInAlbum.length}') - : null, + title: selected.isNotEmpty ? Text('${selected.length}') : null, centerTitle: false, actions: [ if (album.isRemote) diff --git a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart deleted file mode 100644 index 8453de4994..0000000000 --- a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; -import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; -import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/ui/immich_image.dart'; -import 'package:immich_mobile/utils/storage_indicator.dart'; - -class AlbumViewerThumbnail extends HookConsumerWidget { - final Asset asset; - final List assetList; - final bool showStorageIndicator; - - const AlbumViewerThumbnail({ - Key? key, - required this.asset, - required this.assetList, - this.showStorageIndicator = true, - }) : super(key: key); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final selectedAssetsInAlbumViewer = - ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; - final isMultiSelectionEnable = - ref.watch(assetSelectionProvider).isMultiselectEnable; - final isFavorite = ref.watch(favoriteProvider).contains(asset.id); - - viewAsset() { - AutoRouter.of(context).push( - GalleryViewerRoute( - asset: asset, - assetList: assetList, - ), - ); - } - - BoxBorder drawBorderColor() { - if (selectedAssetsInAlbumViewer.contains(asset)) { - return Border.all( - color: Theme.of(context).primaryColorLight, - width: 10, - ); - } else { - return const Border(); - } - } - - enableMultiSelection() { - ref.watch(assetSelectionProvider.notifier).enableMultiselection(); - ref - .watch(assetSelectionProvider.notifier) - .addAssetsInAlbumViewer([asset]); - } - - disableMultiSelection() { - ref.watch(assetSelectionProvider.notifier).disableMultiselection(); - } - - buildVideoLabel() { - return Positioned( - top: 5, - right: 5, - child: Row( - children: [ - Text( - asset.duration.toString().substring(0, 7), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - const Icon( - Icons.play_circle_outline_rounded, - color: Colors.white, - ), - ], - ), - ); - } - - buildAssetStoreLocationIcon() { - return Positioned( - right: 10, - bottom: 5, - child: Icon( - storageIcon(asset), - color: Colors.white, - size: 18, - ), - ); - } - - buildAssetFavoriteIcon() { - return const Positioned( - left: 10, - bottom: 5, - child: Icon( - Icons.favorite, - color: Colors.white, - size: 18, - ), - ); - } - - buildAssetSelectionIcon() { - bool isSelected = selectedAssetsInAlbumViewer.contains(asset); - - return Positioned( - left: 10, - top: 5, - child: isSelected - ? Icon( - Icons.check_circle_rounded, - color: Theme.of(context).primaryColor, - ) - : const Icon( - Icons.check_circle_outline_rounded, - color: Colors.white, - ), - ); - } - - buildThumbnailImage() { - return Container( - decoration: BoxDecoration(border: drawBorderColor()), - child: ImmichImage(asset, width: 300, height: 300), - ); - } - - handleSelectionGesture() { - if (selectedAssetsInAlbumViewer.contains(asset)) { - ref - .watch(assetSelectionProvider.notifier) - .removeAssetsInAlbumViewer([asset]); - - if (selectedAssetsInAlbumViewer.isEmpty) { - disableMultiSelection(); - } - } else { - ref - .watch(assetSelectionProvider.notifier) - .addAssetsInAlbumViewer([asset]); - } - } - - return GestureDetector( - onTap: isMultiSelectionEnable ? handleSelectionGesture : viewAsset, - onLongPress: enableMultiSelection, - child: Stack( - children: [ - buildThumbnailImage(), - if (isFavorite) buildAssetFavoriteIcon(), - if (showStorageIndicator) buildAssetStoreLocationIcon(), - if (!asset.isImage) buildVideoLabel(), - if (isMultiSelectionEnable) buildAssetSelectionIcon(), - ], - ), - ); - } -} diff --git a/mobile/lib/modules/album/ui/asset_grid_by_month.dart b/mobile/lib/modules/album/ui/asset_grid_by_month.dart deleted file mode 100644 index 7dd523248b..0000000000 --- a/mobile/lib/modules/album/ui/asset_grid_by_month.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/album/ui/selection_thumbnail_image.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; - -class AssetGridByMonth extends HookConsumerWidget { - final List assetGroup; - const AssetGridByMonth({Key? key, required this.assetGroup}) - : super(key: key); - @override - Widget build(BuildContext context, WidgetRef ref) { - return SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - crossAxisSpacing: 5.0, - mainAxisSpacing: 5, - ), - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return SelectionThumbnailImage(asset: assetGroup[index]); - }, - childCount: assetGroup.length, - ), - ); - } -} diff --git a/mobile/lib/modules/album/ui/month_group_title.dart b/mobile/lib/modules/album/ui/month_group_title.dart deleted file mode 100644 index 5d33d44ab0..0000000000 --- a/mobile/lib/modules/album/ui/month_group_title.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; - -class MonthGroupTitle extends HookConsumerWidget { - final String month; - final List assetGroup; - - const MonthGroupTitle({ - Key? key, - required this.month, - required this.assetGroup, - }) : super(key: key); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final selectedDateGroup = ref.watch(assetSelectionProvider).selectedMonths; - final selectedAssets = - ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; - final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; - - handleTitleIconClick() { - HapticFeedback.heavyImpact(); - - if (isAlbumExist) { - if (selectedDateGroup.contains(month)) { - ref - .watch(assetSelectionProvider.notifier) - .removeAssetsInMonth(month, []); - ref - .watch(assetSelectionProvider.notifier) - .removeSelectedAdditionalAssets(assetGroup); - } else { - ref - .watch(assetSelectionProvider.notifier) - .addAllAssetsInMonth(month, []); - - // Deep clone assetGroup - var assetGroupWithNewItems = [...assetGroup]; - - for (var selectedAsset in selectedAssets) { - assetGroupWithNewItems.removeWhere((a) => a.id == selectedAsset.id); - } - - ref - .watch(assetSelectionProvider.notifier) - .addAdditionalAssets(assetGroupWithNewItems); - } - } else { - if (selectedDateGroup.contains(month)) { - ref - .watch(assetSelectionProvider.notifier) - .removeAssetsInMonth(month, assetGroup); - } else { - ref - .watch(assetSelectionProvider.notifier) - .addAllAssetsInMonth(month, assetGroup); - } - } - } - - getSimplifiedMonth() { - var monthAndYear = month.split(','); - var yearText = monthAndYear[1].trim(); - var monthText = monthAndYear[0].trim(); - var currentYear = DateTime.now().year.toString(); - - if (yearText == currentYear) { - return monthText; - } else { - return month; - } - } - - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only( - top: 29.0, - bottom: 29.0, - left: 14.0, - right: 8.0, - ), - child: Row( - children: [ - GestureDetector( - onTap: handleTitleIconClick, - child: selectedDateGroup.contains(month) - ? Icon( - Icons.check_circle_rounded, - color: Theme.of(context).primaryColor, - ) - : const Icon( - Icons.circle_outlined, - color: Colors.grey, - ), - ), - GestureDetector( - onTap: handleTitleIconClick, - child: Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text( - getSimplifiedMonth(), - style: TextStyle( - fontSize: 24, - color: Theme.of(context).primaryColor, - ), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/modules/album/ui/selection_thumbnail_image.dart b/mobile/lib/modules/album/ui/selection_thumbnail_image.dart deleted file mode 100644 index 51cd2766c8..0000000000 --- a/mobile/lib/modules/album/ui/selection_thumbnail_image.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/ui/immich_image.dart'; - -class SelectionThumbnailImage extends HookConsumerWidget { - final Asset asset; - - const SelectionThumbnailImage({Key? key, required this.asset}) - : super(key: key); - - @override - Widget build(BuildContext context, WidgetRef ref) { - var selectedAsset = - ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; - var newAssetsForAlbum = - ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; - var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; - - Widget buildSelectionIcon(Asset asset) { - var isSelected = selectedAsset.map((item) => item.id).contains(asset.id); - var isNewlySelected = - newAssetsForAlbum.map((item) => item.id).contains(asset.id); - - if (isSelected && !isAlbumExist) { - return Icon( - Icons.check_circle, - color: Theme.of(context).primaryColor, - ); - } else if (isSelected && isAlbumExist) { - return const Icon( - Icons.check_circle, - color: Color.fromARGB(255, 233, 233, 233), - ); - } else if (isNewlySelected && isAlbumExist) { - return Icon( - Icons.check_circle, - color: Theme.of(context).primaryColor, - ); - } else { - return const Icon( - Icons.circle_outlined, - color: Colors.white, - ); - } - } - - BoxBorder drawBorderColor() { - var isSelected = selectedAsset.map((item) => item.id).contains(asset.id); - var isNewlySelected = - newAssetsForAlbum.map((item) => item.id).contains(asset.id); - - if (isSelected && !isAlbumExist) { - return Border.all( - color: Theme.of(context).primaryColorLight, - width: 10, - ); - } else if (isSelected && isAlbumExist) { - return Border.all( - color: const Color.fromARGB(255, 190, 190, 190), - width: 10, - ); - } else if (isNewlySelected && isAlbumExist) { - return Border.all( - color: Theme.of(context).primaryColorLight, - width: 10, - ); - } - return const Border(); - } - - return GestureDetector( - onTap: () { - var isSelected = - selectedAsset.map((item) => item.id).contains(asset.id); - var isNewlySelected = - newAssetsForAlbum.map((item) => item.id).contains(asset.id); - - if (isAlbumExist) { - // Operation for existing album - if (!isSelected) { - if (isNewlySelected) { - ref - .watch(assetSelectionProvider.notifier) - .removeSelectedAdditionalAssets([asset]); - } else { - ref - .watch(assetSelectionProvider.notifier) - .addAdditionalAssets([asset]); - } - } - } else { - // Operation for new album - if (isSelected) { - ref - .watch(assetSelectionProvider.notifier) - .removeSelectedNewAssets([asset]); - } else { - ref.watch(assetSelectionProvider.notifier).addNewAssets([asset]); - } - } - }, - child: Stack( - children: [ - Container( - decoration: BoxDecoration(border: drawBorderColor()), - child: ImmichImage(asset, width: 150, height: 150), - ), - Padding( - padding: const EdgeInsets.all(3.0), - child: Align( - alignment: Alignment.topLeft, - child: buildSelectionIcon(asset), - ), - ), - if (!asset.isImage) - Positioned( - bottom: 5, - right: 5, - child: Row( - children: [ - Text( - asset.duration.toString().substring(0, 7), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - const Icon( - Icons.play_circle_outline_rounded, - color: Colors.white, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index 748fcec8e4..b1adeb8219 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -5,21 +5,18 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/album/providers/album.provider.dart'; -import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart'; -import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; -import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; -import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart'; -import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart'; import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart'; -import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart'; -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/modules/home/ui/asset_grid/immich_asset_grid.dart'; +import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; +import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; +import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; @@ -32,33 +29,51 @@ class AlbumViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { FocusNode titleFocusNode = useFocusNode(); - ScrollController scrollController = useScrollController(); final album = ref.watch(sharedAlbumDetailProvider(albumId)); - final userId = ref.watch(authenticationProvider).userId; + final selection = useState>({}); + final multiSelectEnabled = useState(false); + bool? isTop; + + Future onWillPop() async { + if (multiSelectEnabled.value) { + selection.value = {}; + multiSelectEnabled.value = false; + return false; + } + + return true; + } + + void selectionListener(bool active, Set selected) { + selection.value = selected; + multiSelectEnabled.value = selected.isNotEmpty; + } + + void disableSelection() { + selection.value = {}; + multiSelectEnabled.value = false; + } /// 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 { - if (albumInfo.assets.isNotEmpty == true) { - ref.watch(assetSelectionProvider.notifier).addNewAssets( - albumInfo.assets, - ); - } - - ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true); - - AssetSelectionPageResult? returnPayload = await AutoRouter.of(context) - .push(const AssetSelectionRoute()); + AssetSelectionPageResult? returnPayload = + await AutoRouter.of(context).push( + AssetSelectionRoute( + existingAssets: albumInfo.assets, + isNewAlbum: false, + ), + ); if (returnPayload != null) { // Check if there is new assets add - if (returnPayload.selectedAdditionalAsset.isNotEmpty) { + if (returnPayload.selectedAssets.isNotEmpty) { ImmichLoadingOverlayController.appLoader.show(); var addAssetsResult = await ref.watch(albumServiceProvider).addAdditionalAssetToAlbum( - returnPayload.selectedAdditionalAsset, + returnPayload.selectedAssets, albumInfo, ); @@ -70,10 +85,6 @@ class AlbumViewerPage extends HookConsumerWidget { ImmichLoadingOverlayController.appLoader.hide(); } - - ref.watch(assetSelectionProvider.notifier).removeAll(); - } else { - ref.watch(assetSelectionProvider.notifier).removeAll(); } } @@ -91,13 +102,38 @@ class AlbumViewerPage extends HookConsumerWidget { .addAdditionalUserToAlbum(sharedUserIds, album); if (isSuccess) { - ref.invalidate(sharedAlbumDetailProvider(albumId)); + ref.invalidate(sharedAlbumDetailProvider(album.id)); } ImmichLoadingOverlayController.appLoader.hide(); } } + Widget buildControlButton(Album album) { + return Padding( + padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8), + child: SizedBox( + height: 40, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + AlbumActionOutlinedButton( + iconData: Icons.add_photo_alternate_outlined, + onPressed: () => onAddPhotosPressed(album), + labelText: "share_add_photos".tr(), + ), + if (userId == album.ownerId) + AlbumActionOutlinedButton( + 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, top: 16), @@ -146,171 +182,104 @@ class AlbumViewerPage extends HookConsumerWidget { } Widget buildHeader(Album album) { - return SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - buildTitle(album), - if (album.assets.isNotEmpty == true) buildAlbumDateRange(album), - if (album.shared) - SizedBox( - height: 60, - 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: CircleAvatar( - backgroundColor: Colors.grey[300], - radius: 18, - child: Padding( - padding: const EdgeInsets.all(2.0), - child: ClipRRect( - borderRadius: BorderRadius.circular(50.0), - child: Image.asset( - 'assets/immich-logo-no-outline.png', - ), + return Column( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildTitle(album), + if (album.assets.isNotEmpty == true) buildAlbumDateRange(album), + if (album.shared) + 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: CircleAvatar( + backgroundColor: Colors.grey[300], + radius: 18, + child: Padding( + padding: const EdgeInsets.all(2.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(50.0), + child: Image.asset( + 'assets/immich-logo-no-outline.png', ), ), ), - ); - }), - itemCount: album.sharedUsers.length, - ), - ) - ], - ), - ); - } - - Widget buildImageGrid(Album album) { - final appSettingService = ref.watch(appSettingsServiceProvider); - final bool showStorageIndicator = - appSettingService.getSetting(AppSettingsEnum.storageIndicator); - - if (album.sortedAssets.isNotEmpty) { - return SliverPadding( - padding: const EdgeInsets.only(top: 10.0), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - appSettingService.getSetting(AppSettingsEnum.tilesPerRow), - crossAxisSpacing: 5.0, - mainAxisSpacing: 5, - ), - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return AlbumViewerThumbnail( - asset: album.sortedAssets[index], - assetList: album.sortedAssets, - showStorageIndicator: showStorageIndicator, - ); - }, - childCount: album.assetCount, - ), - ), - ); - } - return const SliverToBoxAdapter(); - } - - Widget buildControlButton(Album album) { - return Padding( - padding: const EdgeInsets.only(left: 16.0, top: 8, bottom: 8), - child: SizedBox( - height: 40, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - AlbumActionOutlinedButton( - iconData: Icons.add_photo_alternate_outlined, - onPressed: () => onAddPhotosPressed(album), - labelText: "share_add_photos".tr(), - ), - if (userId == album.ownerId) - AlbumActionOutlinedButton( - iconData: Icons.person_add_alt_rounded, - onPressed: () => onAddUsersPressed(album), - labelText: "album_viewer_page_share_add_users".tr(), - ), - ], - ), - ), - ); - } - - Future onWillPop() async { - final isMultiselectEnable = ref - .read(assetSelectionProvider) - .selectedAssetsInAlbumViewer - .isNotEmpty; - if (isMultiselectEnable) { - ref.watch(assetSelectionProvider.notifier).removeAll(); - return false; - } - - return true; - } - - Widget buildBody(Album album) { - return WillPopScope( - onWillPop: onWillPop, - child: GestureDetector( - onTap: () { - titleFocusNode.unfocus(); - }, - child: DraggableScrollbar.semicircle( - backgroundColor: Theme.of(context).hintColor, - controller: scrollController, - heightScrollThumb: 48.0, - child: CustomScrollView( - controller: scrollController, - slivers: [ - buildHeader(album), - if (album.isRemote) - SliverPersistentHeader( - pinned: true, - delegate: ImmichSliverPersistentAppBarDelegate( - minHeight: 50, - maxHeight: 50, - child: Container( - color: Theme.of(context).scaffoldBackgroundColor, - child: buildControlButton(album), - ), ), - ), - SliverSafeArea( - sliver: buildImageGrid(album), - ), - ], + ); + }), + itemCount: album.sharedUsers.length, + ), ), - ), - ), + ], ); } + final scroll = ScrollController(); + return Scaffold( appBar: album.when( - data: (Album? data) { - if (data != null) { - return AlbumViewerAppbar( - album: data, - userId: userId, - ); - } - return null; - }, - error: (e, _) => null, - loading: () => null, + data: (data) => AlbumViewerAppbar( + titleFocusNode: titleFocusNode, + album: data, + userId: userId, + selected: selection.value, + selectionDisabled: disableSelection, + ), + error: (error, stackTrace) => AppBar(title: const Text("Error")), + loading: () => AppBar(), ), body: album.when( - data: (albumInfo) => albumInfo != null - ? buildBody(albumInfo) - : const Center( - child: CircularProgressIndicator(), + data: (data) => WillPopScope( + onWillPop: onWillPop, + child: GestureDetector( + onTap: () { + titleFocusNode.unfocus(); + }, + child: NestedScrollView( + controller: scroll, + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverToBoxAdapter(child: buildHeader(data)), + SliverPersistentHeader( + pinned: true, + delegate: ImmichSliverPersistentAppBarDelegate( + minHeight: 50, + maxHeight: 50, + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: buildControlButton(data), + ), + ), + ) + ], + body: ImmichAssetGrid( + renderList: data.renderList, + listener: selectionListener, + selectionActive: multiSelectEnabled.value, + showMultiSelectIndicator: false, + visibleItemsListener: (start, end) { + final top = start.index == 0 && start.itemLeadingEdge == 0.0; + if (top != isTop) { + isTop = top; + scroll.animateTo( + top + ? scroll.position.minScrollExtent + : scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 500), + curve: top ? Curves.easeOut : Curves.easeIn, + ); + } + }, ), - error: (e, _) => Center(child: Text("Error loading album info $e")), + ), + ), + ), + error: (e, _) => Center(child: Text("Error loading album info!\n$e")), loading: () => const Center( child: ImmichLoadingIndicator(), ), diff --git a/mobile/lib/modules/album/views/asset_selection_page.dart b/mobile/lib/modules/album/views/asset_selection_page.dart index 3654545179..74aac07405 100644 --- a/mobile/lib/modules/album/views/asset_selection_page.dart +++ b/mobile/lib/modules/album/views/asset_selection_page.dart @@ -4,54 +4,42 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; -import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; -import 'package:immich_mobile/modules/album/ui/asset_grid_by_month.dart'; -import 'package:immich_mobile/modules/album/ui/month_group_title.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; +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/modules/home/ui/draggable_scrollbar.dart'; class AssetSelectionPage extends HookConsumerWidget { - const AssetSelectionPage({Key? key}) : super(key: key); + const AssetSelectionPage({ + Key? key, + required this.existingAssets, + this.isNewAlbum = false, + }) : super(key: key); + + final Set existingAssets; + final bool isNewAlbum; @override Widget build(BuildContext context, WidgetRef ref) { - ScrollController scrollController = useScrollController(); - var assetGroupMonthYear = ref.watch(assetGroupByMonthYearProvider); - final selectedAssets = - ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; - final newAssetsForAlbum = - ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; - final isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; - - List imageGridGroup = []; + final renderList = ref.watch(remoteAssetsProvider); + final selected = useState>(existingAssets); + final selectionEnabledHook = useState(true); String buildAssetCountText() { - if (isAlbumExist) { - return (selectedAssets.length + newAssetsForAlbum.length).toString(); - } else { - return selectedAssets.length.toString(); - } + return selected.value.length.toString(); } - Widget buildBody() { - assetGroupMonthYear.forEach((monthYear, assetGroup) { - imageGridGroup - .add(MonthGroupTitle(month: monthYear, assetGroup: assetGroup)); - imageGridGroup.add(AssetGridByMonth(assetGroup: assetGroup)); - }); - - return Stack( - children: [ - DraggableScrollbar.semicircle( - backgroundColor: Theme.of(context).hintColor, - controller: scrollController, - heightScrollThumb: 48.0, - child: CustomScrollView( - controller: scrollController, - slivers: [...imageGridGroup], - ), - ), - ], + Widget buildBody(RenderList renderList) { + return ImmichAssetGrid( + renderList: renderList, + listener: (active, assets) { + selectionEnabledHook.value = active; + selected.value = assets; + }, + selectionActive: true, + preselectedAssets: isNewAlbum ? selected.value : existingAssets, + canDeselect: isNewAlbum, + showMultiSelectIndicator: false, ); } @@ -61,11 +49,10 @@ class AssetSelectionPage extends HookConsumerWidget { leading: IconButton( icon: const Icon(Icons.close_rounded), onPressed: () { - ref.watch(assetSelectionProvider.notifier).removeAll(); - AutoRouter.of(context).pop(null); + AutoRouter.of(context).popForced(null); }, ), - title: selectedAssets.isEmpty + title: selected.value.isEmpty ? const Text( 'share_add_photos', style: TextStyle(fontSize: 18), @@ -76,16 +63,13 @@ class AssetSelectionPage extends HookConsumerWidget { ), centerTitle: false, actions: [ - if ((!isAlbumExist && selectedAssets.isNotEmpty) || - (isAlbumExist && newAssetsForAlbum.isNotEmpty)) + if (selected.value.isNotEmpty) TextButton( onPressed: () { - var payload = AssetSelectionPageResult( - isAlbumExist: isAlbumExist, - selectedAdditionalAsset: newAssetsForAlbum, - selectedNewAsset: selectedAssets, - ); - AutoRouter.of(context).pop(payload); + var payload = + AssetSelectionPageResult(selectedAssets: selected.value); + AutoRouter.of(context) + .popForced(payload); }, child: const Text( "share_add", @@ -94,7 +78,13 @@ class AssetSelectionPage extends HookConsumerWidget { ), ], ), - body: buildBody(), + body: renderList.when( + data: (data) => buildBody(data), + error: (error, stackTrace) => Center( + child: Text(error.toString()), + ), + loading: () => const Center(child: CircularProgressIndicator()), + ), ); } } diff --git a/mobile/lib/modules/album/views/create_album_page.dart b/mobile/lib/modules/album/views/create_album_page.dart index 0b6e19b530..c799eb3f17 100644 --- a/mobile/lib/modules/album/views/create_album_page.dart +++ b/mobile/lib/modules/album/views/create_album_page.dart @@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_title.provider.dart'; -import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart'; import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart'; import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart'; @@ -31,12 +30,15 @@ class CreateAlbumPage extends HookConsumerWidget { final albumTitleTextFieldFocusNode = useFocusNode(); final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleEmpty = useState(true); - final selectedAssets = - ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; + final selectedAssets = useState>(const {}); final isDarkTheme = Theme.of(context).brightness == Brightness.dark; - showSelectUserPage() { - AutoRouter.of(context).push(const SelectUserForSharingRoute()); + showSelectUserPage() async { + final bool? ok = await AutoRouter.of(context) + .push(SelectUserForSharingRoute(assets: selectedAssets.value)); + if (ok == true) { + selectedAssets.value = {}; + } } void onBackgroundTapped() { @@ -52,13 +54,17 @@ class CreateAlbumPage extends HookConsumerWidget { } onSelectPhotosButtonPressed() async { - ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(false); - - AssetSelectionPageResult? selectedAsset = await AutoRouter.of(context) - .push(const AssetSelectionRoute()); - + AssetSelectionPageResult? selectedAsset = + await AutoRouter.of(context).push( + AssetSelectionRoute( + existingAssets: selectedAssets.value, + isNewAlbum: true, + ), + ); if (selectedAsset == null) { - ref.watch(assetSelectionProvider.notifier).removeAll(); + selectedAssets.value = const {}; + } else { + selectedAssets.value = selectedAsset.selectedAssets; } } @@ -78,7 +84,7 @@ class CreateAlbumPage extends HookConsumerWidget { } buildTitle() { - if (selectedAssets.isEmpty) { + if (selectedAssets.value.isEmpty) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(top: 200, left: 18), @@ -97,7 +103,7 @@ class CreateAlbumPage extends HookConsumerWidget { } buildSelectPhotosButton() { - if (selectedAssets.isEmpty) { + if (selectedAssets.value.isEmpty) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.only(top: 16, left: 18, right: 18), @@ -158,7 +164,7 @@ class CreateAlbumPage extends HookConsumerWidget { } buildSelectedImageGrid() { - if (selectedAssets.isNotEmpty) { + if (selectedAssets.value.isNotEmpty) { return SliverPadding( padding: const EdgeInsets.only(top: 16), sliver: SliverGrid( @@ -172,11 +178,11 @@ class CreateAlbumPage extends HookConsumerWidget { return GestureDetector( onTap: onBackgroundTapped, child: SharedAlbumThumbnailImage( - asset: selectedAssets.elementAt(index), + asset: selectedAssets.value.elementAt(index), ), ); }, - childCount: selectedAssets.length, + childCount: selectedAssets.value.length, ), ), ); @@ -188,12 +194,12 @@ class CreateAlbumPage extends HookConsumerWidget { createNonSharedAlbum() async { var newAlbum = await ref.watch(albumProvider.notifier).createAlbum( ref.watch(albumTitleProvider), - ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum, + selectedAssets.value, ); if (newAlbum != null) { ref.watch(albumProvider.notifier).getAllAlbums(); - ref.watch(assetSelectionProvider.notifier).removeAll(); + selectedAssets.value = {}; ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); AutoRouter.of(context).replace(AlbumViewerRoute(albumId: newAlbum.id)); @@ -207,7 +213,7 @@ class CreateAlbumPage extends HookConsumerWidget { backgroundColor: Theme.of(context).scaffoldBackgroundColor, leading: IconButton( onPressed: () { - ref.watch(assetSelectionProvider.notifier).removeAll(); + selectedAssets.value = {}; AutoRouter.of(context).pop(); }, icon: const Icon(Icons.close_rounded), @@ -237,7 +243,7 @@ class CreateAlbumPage extends HookConsumerWidget { if (!isSharedAlbum) TextButton( onPressed: albumTitleController.text.isNotEmpty && - selectedAssets.isNotEmpty + selectedAssets.value.isNotEmpty ? createNonSharedAlbum : null, child: Text( @@ -264,7 +270,7 @@ class CreateAlbumPage extends HookConsumerWidget { child: Column( children: [ buildTitleInputField(), - if (selectedAssets.isNotEmpty) buildControlButton(), + if (selectedAssets.value.isNotEmpty) buildControlButton(), ], ), ), 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 976a781e29..4130181c74 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 @@ -4,15 +4,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album_title.provider.dart'; -import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/suggested_shared_users.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; class SelectUserForSharingPage extends HookConsumerWidget { - const SelectUserForSharingPage({Key? key}) : super(key: key); + const SelectUserForSharingPage({Key? key, required this.assets}) + : super(key: key); + + final Set assets; @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,15 +27,15 @@ class SelectUserForSharingPage extends HookConsumerWidget { var newAlbum = await ref.watch(sharedAlbumProvider.notifier).createSharedAlbum( ref.watch(albumTitleProvider), - ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum, + assets, sharedUsersList.value, ); if (newAlbum != null) { await ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); - ref.watch(assetSelectionProvider.notifier).removeAll(); + // ref.watch(assetSelectionProvider.notifier).removeAll(); ref.watch(albumTitleProvider.notifier).clearAlbumTitle(); - + AutoRouter.of(context).pop(true); AutoRouter.of(context) .navigate(const TabControllerRoute(children: [SharingRoute()])); } diff --git a/mobile/lib/modules/archive/providers/archive_asset_provider.dart b/mobile/lib/modules/archive/providers/archive_asset_provider.dart index 28d8d74f65..70c8ac89e8 100644 --- a/mobile/lib/modules/archive/providers/archive_asset_provider.dart +++ b/mobile/lib/modules/archive/providers/archive_asset_provider.dart @@ -1,55 +1,25 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; +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/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:isar/isar.dart'; -class ArchiveSelectionNotifier extends StateNotifier> { - ArchiveSelectionNotifier(this.db, this.assetNotifier) : super({}) { - state = db.assets - .filter() - .isArchivedEqualTo(true) - .findAllSync() - .map((e) => e.id) - .toSet(); +final archiveProvider = StreamProvider((ref) async* { + final query = ref + .watch(dbProvider) + .assets + .filter() + .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .isArchivedEqualTo(true) + .sortByFileCreatedAt(); + final settings = ref.watch(appSettingsServiceProvider); + final groupBy = + GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; + yield await RenderList.fromQuery(query, groupBy); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, groupBy); } - - final Isar db; - final AssetNotifier assetNotifier; - - void _setArchiveForAssetId(int id, bool archive) { - if (!archive) { - state = state.difference({id}); - } else { - state = state.union({id}); - } - } - - bool _isArchive(int id) { - return state.contains(id); - } - - Future toggleArchive(Asset asset) async { - if (asset.storage == AssetState.local) return; - - _setArchiveForAssetId(asset.id, !_isArchive(asset.id)); - - await assetNotifier.toggleArchive( - [asset], - state.contains(asset.id), - ); - } - - Future addToArchives(Iterable assets) { - state = state.union(assets.map((a) => a.id).toSet()); - return assetNotifier.toggleArchive(assets, true); - } -} - -final archiveProvider = - StateNotifierProvider>((ref) { - return ArchiveSelectionNotifier( - ref.watch(dbProvider), - ref.watch(assetProvider.notifier), - ); }); diff --git a/mobile/lib/modules/archive/providers/store_providers_here.txt b/mobile/lib/modules/archive/providers/store_providers_here.txt deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/mobile/lib/modules/archive/views/archive_page.dart b/mobile/lib/modules/archive/views/archive_page.dart index b884a41009..f5ca80406b 100644 --- a/mobile/lib/modules/archive/views/archive_page.dart +++ b/mobile/lib/modules/archive/views/archive_page.dart @@ -1,46 +1,25 @@ 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' hide Store; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart'; 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/models/store.dart'; -import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; -import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; -import 'package:isar/isar.dart'; class ArchivePage extends HookConsumerWidget { const ArchivePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final User me = Store.get(StoreKey.currentUser); - final query = ref - .watch(dbProvider) - .assets - .filter() - .ownerIdEqualTo(me.isarId) - .isArchivedEqualTo(true); - final stream = query.watch(); - final archivedAssets = useState>([]); + final archivedAssets = ref.watch(archiveProvider); final selectionEnabledHook = useState(false); final selection = useState({}); - - useEffect( - () { - query.findAll().then((value) => archivedAssets.value = value); - final subscription = stream.listen((e) { - archivedAssets.value = e; - }); - // Cancel the subscription when the widget is disposed - return subscription.cancel; - }, - [], - ); + final processing = useState(false); void selectionListener( bool multiselect, @@ -50,7 +29,7 @@ class ArchivePage extends HookConsumerWidget { selection.value = selectedAssets; } - AppBar buildAppBar() { + AppBar buildAppBar(String count) { return AppBar( leading: IconButton( onPressed: () => AutoRouter.of(context).pop(), @@ -60,7 +39,7 @@ class ArchivePage extends HookConsumerWidget { automaticallyImplyLeading: false, title: const Text( 'archive_page_title', - ).tr(args: [archivedAssets.value.length.toString()]), + ).tr(args: [count]), ); } @@ -84,24 +63,34 @@ class ArchivePage extends HookConsumerWidget { 'control_bottom_app_bar_unarchive'.tr(), style: const TextStyle(fontSize: 14), ), - onTap: () { - if (selection.value.isNotEmpty) { - ref - .watch(assetProvider.notifier) - .toggleArchive(selection.value, false); + onTap: processing.value + ? null + : () async { + processing.value = true; + try { + if (selection.value.isNotEmpty) { + await ref + .watch(assetProvider.notifier) + .toggleArchive( + selection.value.toList(), + false, + ); - final assetOrAssets = - selection.value.length > 1 ? 'assets' : 'asset'; - ImmichToast.show( - context: context, - msg: - 'Moved ${selection.value.length} $assetOrAssets to library', - gravity: ToastGravity.CENTER, - ); - } - - selectionEnabledHook.value = false; - }, + final assetOrAssets = selection.value.length > 1 + ? 'assets' + : 'asset'; + ImmichToast.show( + context: context, + msg: + 'Moved ${selection.value.length} $assetOrAssets to library', + gravity: ToastGravity.CENTER, + ); + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + }, ) ], ), @@ -111,22 +100,34 @@ class ArchivePage extends HookConsumerWidget { ); } - return Scaffold( - appBar: buildAppBar(), - body: archivedAssets.value.isEmpty - ? Center( - child: Text('archive_page_no_archived_assets'.tr()), - ) - : Stack( - children: [ - ImmichAssetGrid( - assets: archivedAssets.value, - listener: selectionListener, - selectionActive: selectionEnabledHook.value, - ), - if (selectionEnabledHook.value) buildBottomBar() - ], - ), + return archivedAssets.when( + loading: () => Scaffold( + appBar: buildAppBar("?"), + body: const Center(child: CircularProgressIndicator()), + ), + error: (error, stackTrace) => Scaffold( + appBar: buildAppBar("Error"), + body: Center(child: Text(error.toString())), + ), + data: (data) => Scaffold( + appBar: buildAppBar(data.totalAssets.toString()), + body: data.isEmpty + ? Center( + child: Text('archive_page_no_archived_assets'.tr()), + ) + : Stack( + children: [ + ImmichAssetGrid( + renderList: data, + listener: selectionListener, + selectionActive: selectionEnabledHook.value, + ), + if (selectionEnabledHook.value) buildBottomBar(), + if (processing.value) + const Center(child: ImmichLoadingIndicator()) + ], + ), + ), ); } } diff --git a/mobile/lib/modules/asset_viewer/models/request_download_asset_info.model.dart b/mobile/lib/modules/asset_viewer/models/request_download_asset_info.model.dart deleted file mode 100644 index 80a99bfe9c..0000000000 --- a/mobile/lib/modules/asset_viewer/models/request_download_asset_info.model.dart +++ /dev/null @@ -1,6 +0,0 @@ -class RequestDownloadAssetInfo { - final String assetId; - final String deviceId; - - RequestDownloadAssetInfo(this.assetId, this.deviceId); -} diff --git a/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart b/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart index e106e97ed1..2273380843 100644 --- a/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart @@ -4,14 +4,12 @@ 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'; -final renderListProvider = FutureProvider.family>((ref, assets) { - var settings = ref.watch(appSettingsServiceProvider); +final renderListProvider = + FutureProvider.family>((ref, assets) { + final settings = ref.watch(appSettingsServiceProvider); - final layout = AssetGridLayoutParameters( - settings.getSetting(AppSettingsEnum.tilesPerRow), - settings.getSetting(AppSettingsEnum.dynamicLayout), + return RenderList.fromAssets( + assets, GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)], ); - - return RenderList.fromAssets(assets, layout); }); diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 436a5bb28e..b79f240bba 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -21,7 +21,7 @@ class TopControlAppBar extends HookConsumerWidget { final VoidCallback? onDownloadPressed; final VoidCallback onToggleMotionVideo; final VoidCallback onAddToAlbumPressed; - final VoidCallback onFavorite; + final VoidCallback? onFavorite; final bool isPlayingMotionVideo; final bool isFavorite; @@ -31,9 +31,7 @@ class TopControlAppBar extends HookConsumerWidget { Widget buildFavoriteButton() { return IconButton( - onPressed: () { - onFavorite(); - }, + onPressed: onFavorite, icon: Icon( isFavorite ? Icons.favorite : Icons.favorite_border, color: Colors.grey[200], diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index b5ad98ddfc..29e932b354 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -12,9 +12,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; -import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; import 'package:immich_mobile/shared/models/store.dart'; -import 'package:immich_mobile/shared/services/asset.service.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; @@ -32,16 +30,16 @@ import 'package:openapi/api.dart' as api; // ignore: must_be_immutable class GalleryViewerPage extends HookConsumerWidget { - final List assetList; - final Asset asset; + final Asset Function(int index) loadAsset; + final int totalAssets; + final int initialIndex; GalleryViewerPage({ super.key, - required this.assetList, - required this.asset, - }) : controller = PageController(initialPage: assetList.indexOf(asset)); - - Asset? assetDetail; + required this.initialIndex, + required this.loadAsset, + required this.totalAssets, + }) : controller = PageController(initialPage: initialIndex); final PageController controller; @@ -52,11 +50,15 @@ class GalleryViewerPage extends HookConsumerWidget { final isLoadOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue); final isZoomed = useState(false); final showAppBar = useState(true); - final indexOfAsset = useState(assetList.indexOf(asset)); final isPlayingMotionVideo = useState(false); final isPlayingVideo = useState(false); late Offset localPosition; final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}'; + final currentIndex = useState(initialIndex); + final currentAsset = loadAsset(currentIndex.value); + final watchedAsset = ref.watch(assetDetailProvider(currentAsset)); + + Asset asset() => watchedAsset.value ?? currentAsset; showAppBar.addListener(() { // Change to and from immersive mode, hiding navigation and app bar @@ -79,16 +81,9 @@ class GalleryViewerPage extends HookConsumerWidget { [], ); - void toggleFavorite(Asset asset) { - ref.watch(favoriteProvider.notifier).toggleFavorite(asset); - } - - void getAssetExif() async { - assetDetail = assetList[indexOfAsset.value]; - assetDetail = await ref - .watch(assetServiceProvider) - .loadExif(assetList[indexOfAsset.value]); - } + void toggleFavorite(Asset asset) => ref + .watch(assetProvider.notifier) + .toggleFavorite([asset], !asset.isFavorite); /// Thumbnail image of a remote asset. Required asset.isRemote ImageProvider remoteThumbnailImageProvider( @@ -138,8 +133,8 @@ class GalleryViewerPage extends HookConsumerWidget { } void precacheNextImage(int index) { - if (index < assetList.length && index >= 0) { - final asset = assetList[index]; + if (index < totalAssets && index >= 0) { + final asset = loadAsset(index); if (asset.isLocal) { // Preload the local asset @@ -193,13 +188,13 @@ class GalleryViewerPage extends HookConsumerWidget { if (ref .watch(appSettingsServiceProvider) .getSetting(AppSettingsEnum.advancedTroubleshooting)) { - return AdvancedBottomSheet(assetDetail: assetDetail!); + return AdvancedBottomSheet(assetDetail: asset()); } return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), - child: ExifBottomSheet(asset: assetDetail!), + child: ExifBottomSheet(asset: asset()), ); }, ); @@ -211,7 +206,7 @@ class GalleryViewerPage extends HookConsumerWidget { builder: (BuildContext _) { return DeleteDialog( onDelete: () { - if (assetList.length == 1) { + if (totalAssets == 1) { // Handle only one asset AutoRouter.of(context).pop(); } else { @@ -221,7 +216,6 @@ class GalleryViewerPage extends HookConsumerWidget { curve: Curves.fastLinearToSlowEaseIn, ); } - assetList.remove(deleteAsset); ref.watch(assetProvider.notifier).deleteAssets({deleteAsset}); }, ); @@ -267,9 +261,7 @@ class GalleryViewerPage extends HookConsumerWidget { } shareAsset() { - ref - .watch(imageViewerStateProvider.notifier) - .shareAsset(assetList[indexOfAsset.value], context); + ref.watch(imageViewerStateProvider.notifier).shareAsset(asset(), context); } handleArchive(Asset asset) { @@ -291,30 +283,21 @@ class GalleryViewerPage extends HookConsumerWidget { color: Colors.black.withOpacity(0.4), child: TopControlAppBar( isPlayingMotionVideo: isPlayingMotionVideo.value, - asset: assetList[indexOfAsset.value], - isFavorite: ref.watch(favoriteProvider).contains( - assetList[indexOfAsset.value].id, - ), - onMoreInfoPressed: () { - showInfo(); - }, - onFavorite: () { - toggleFavorite(assetList[indexOfAsset.value]); - }, - onDownloadPressed: assetList[indexOfAsset.value].storage == - AssetState.local + asset: asset(), + isFavorite: asset().isFavorite, + onMoreInfoPressed: showInfo, + onFavorite: asset().isRemote ? () => toggleFavorite(asset()) : null, + onDownloadPressed: asset().storage == AssetState.local ? null - : () { + : () => ref.watch(imageViewerStateProvider.notifier).downloadAsset( - assetList[indexOfAsset.value], + asset(), context, - ); - }, + ), onToggleMotionVideo: (() { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), - onAddToAlbumPressed: () => - addToAlbum(assetList[indexOfAsset.value]), + onAddToAlbumPressed: () => addToAlbum(asset()), ), ), ); @@ -324,8 +307,6 @@ class GalleryViewerPage extends HookConsumerWidget { final show = (showAppBar.value || // onTap has the final say (showAppBar.value && !isZoomed.value)) && !isPlayingVideo.value; - final currentAsset = assetList[indexOfAsset.value]; - return AnimatedOpacity( duration: const Duration(milliseconds: 100), opacity: show ? 1.0 : 0.0, @@ -343,7 +324,7 @@ class GalleryViewerPage extends HookConsumerWidget { label: 'control_bottom_app_bar_share'.tr(), tooltip: 'control_bottom_app_bar_share'.tr(), ), - currentAsset.isArchived + asset().isArchived ? BottomNavigationBarItem( icon: const Icon(Icons.unarchive_rounded), label: 'control_bottom_app_bar_unarchive'.tr(), @@ -366,10 +347,10 @@ class GalleryViewerPage extends HookConsumerWidget { shareAsset(); break; case 1: - handleArchive(assetList[indexOfAsset.value]); + handleArchive(asset()); break; case 2: - handleDelete(assetList[indexOfAsset.value]); + handleDelete(asset()); break; } }, @@ -399,33 +380,33 @@ class GalleryViewerPage extends HookConsumerWidget { ? const ScrollPhysics() // Use bouncing physics for iOS : const ClampingScrollPhysics() // Use heavy physics for Android ), - itemCount: assetList.length, + itemCount: totalAssets, scrollDirection: Axis.horizontal, onPageChanged: (value) { // Precache image - if (indexOfAsset.value < value) { + if (currentIndex.value < value) { // Moving forwards, so precache the next asset precacheNextImage(value + 1); } else { // Moving backwards, so precache previous asset precacheNextImage(value - 1); } - indexOfAsset.value = value; + currentIndex.value = value; HapticFeedback.selectionClick(); }, loadingBuilder: isLoadPreview.value ? (context, event) { - final asset = assetList[indexOfAsset.value]; - if (!asset.isLocal) { + final a = asset(); + if (!a.isLocal) { // Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve // Three-Stage Loading (WEBP -> JPEG -> Original) final webPThumbnail = CachedNetworkImage( imageUrl: getThumbnailUrl( - asset, + a, type: api.ThumbnailFormat.WEBP, ), cacheKey: getThumbnailCacheKey( - asset, + a, type: api.ThumbnailFormat.WEBP, ), httpHeaders: {'Authorization': authToken}, @@ -444,11 +425,11 @@ class GalleryViewerPage extends HookConsumerWidget { // makes sense if the original is loaded in the builder return CachedNetworkImage( imageUrl: getThumbnailUrl( - asset, + a, type: api.ThumbnailFormat.JPEG, ), cacheKey: getThumbnailCacheKey( - asset, + a, type: api.ThumbnailFormat.JPEG, ), httpHeaders: {'Authorization': authToken}, @@ -462,30 +443,30 @@ class GalleryViewerPage extends HookConsumerWidget { } } else { return Image( - image: localThumbnailImageProvider(asset), + image: localThumbnailImageProvider(a), fit: BoxFit.contain, ); } } : null, builder: (context, index) { - getAssetExif(); - if (assetList[index].isImage && !isPlayingMotionVideo.value) { + final asset = loadAsset(index); + if (asset.isImage && !isPlayingMotionVideo.value) { // Show photo final ImageProvider provider; - if (assetList[index].isLocal) { - provider = localImageProvider(assetList[index]); + if (asset.isLocal) { + provider = localImageProvider(asset); } else { if (isLoadOriginal.value) { - provider = originalImageProvider(assetList[index]); + provider = originalImageProvider(asset); } else if (isLoadPreview.value) { provider = remoteThumbnailImageProvider( - assetList[index], + asset, api.ThumbnailFormat.JPEG, ); } else { provider = remoteThumbnailImageProvider( - assetList[index], + asset, api.ThumbnailFormat.WEBP, ); } @@ -499,13 +480,13 @@ class GalleryViewerPage extends HookConsumerWidget { showAppBar.value = !showAppBar.value, imageProvider: provider, heroAttributes: PhotoViewHeroAttributes( - tag: assetList[index].id, + tag: asset.id, ), filterQuality: FilterQuality.high, tightMode: true, minScale: PhotoViewComputedScale.contained, errorBuilder: (context, error, stackTrace) => ImmichImage( - assetList[indexOfAsset.value], + asset, fit: BoxFit.contain, ), ); @@ -516,7 +497,7 @@ class GalleryViewerPage extends HookConsumerWidget { onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( - tag: assetList[index].id, + tag: asset.id, ), filterQuality: FilterQuality.high, maxScale: 1.0, @@ -526,7 +507,7 @@ class GalleryViewerPage extends HookConsumerWidget { child: VideoViewerPage( onPlaying: () => isPlayingVideo.value = true, onPaused: () => isPlayingVideo.value = false, - asset: assetList[index], + asset: asset, isMotionVideo: isPlayingMotionVideo.value, onVideoEnded: () { if (isPlayingMotionVideo.value) { diff --git a/mobile/lib/modules/favorite/providers/favorite_provider.dart b/mobile/lib/modules/favorite/providers/favorite_provider.dart index 19fe396ce5..d95742f890 100644 --- a/mobile/lib/modules/favorite/providers/favorite_provider.dart +++ b/mobile/lib/modules/favorite/providers/favorite_provider.dart @@ -1,68 +1,25 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; +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/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:isar/isar.dart'; -class FavoriteSelectionNotifier extends StateNotifier> { - FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) { - state = assetsState.allAssets - .where((asset) => asset.isFavorite) - .map((asset) => asset.id) - .toSet(); +final favoriteAssetsProvider = StreamProvider((ref) async* { + final query = ref + .watch(dbProvider) + .assets + .filter() + .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .isFavoriteEqualTo(true) + .sortByFileCreatedAt(); + final settings = ref.watch(appSettingsServiceProvider); + final groupBy = + GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; + yield await RenderList.fromQuery(query, groupBy); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, groupBy); } - - final AssetsState assetsState; - final AssetNotifier assetNotifier; - - void _setFavoriteForAssetId(int id, bool favorite) { - if (!favorite) { - state = state.difference({id}); - } else { - state = state.union({id}); - } - } - - bool _isFavorite(int id) { - return state.contains(id); - } - - Future toggleFavorite(Asset asset) async { - // TODO support local favorite assets - if (asset.storage == AssetState.local) return; - _setFavoriteForAssetId(asset.id, !_isFavorite(asset.id)); - - await assetNotifier.toggleFavorite( - asset, - state.contains(asset.id), - ); - } - - Future addToFavorites(Iterable assets) { - state = state.union(assets.map((a) => a.id).toSet()); - final futures = assets.map( - (a) => assetNotifier.toggleFavorite( - a, - true, - ), - ); - - return Future.wait(futures); - } -} - -final favoriteProvider = - StateNotifierProvider>((ref) { - return FavoriteSelectionNotifier( - ref.watch(assetProvider), - ref.watch(assetProvider.notifier), - ); -}); - -final favoriteAssetProvider = StateProvider((ref) { - final favorites = ref.watch(favoriteProvider); - - return ref - .watch(assetProvider) - .allAssets - .where((element) => favorites.contains(element.id)) - .toList(); }); diff --git a/mobile/lib/modules/favorite/ui/favorite_image.dart b/mobile/lib/modules/favorite/ui/favorite_image.dart deleted file mode 100644 index 26bef32f96..0000000000 --- a/mobile/lib/modules/favorite/ui/favorite_image.dart +++ /dev/null @@ -1,36 +0,0 @@ - -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/asset.dart'; -import 'package:immich_mobile/shared/ui/immich_image.dart'; - -class FavoriteImage extends HookConsumerWidget { - final Asset asset; - final List assets; - - const FavoriteImage(this.asset, this.assets, {super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - void viewAsset() { - AutoRouter.of(context).push( - GalleryViewerRoute( - asset: asset, - assetList: assets, - ), - ); - } - - return GestureDetector( - onTap: viewAsset, - child: ImmichImage( - asset, - width: 300, - height: 300, - ), - ); - } - -} \ No newline at end of file diff --git a/mobile/lib/modules/favorite/views/favorites_page.dart b/mobile/lib/modules/favorite/views/favorites_page.dart index ff7e6ae62a..fa68ff03c2 100644 --- a/mobile/lib/modules/favorite/views/favorites_page.dart +++ b/mobile/lib/modules/favorite/views/favorites_page.dart @@ -1,15 +1,32 @@ 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/modules/favorite/providers/favorite_provider.dart'; 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/ui/immich_toast.dart'; class FavoritesPage extends HookConsumerWidget { const FavoritesPage({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { + final selectionEnabledHook = useState(false); + final selection = useState({}); + final processing = useState(false); + + void selectionListener( + bool multiselect, + Set selectedAssets, + ) { + selectionEnabledHook.value = multiselect; + selection.value = selectedAssets; + } + AppBar buildAppBar() { return AppBar( leading: IconButton( @@ -24,15 +41,77 @@ class FavoritesPage extends HookConsumerWidget { ); } + void unfavorite() async { + try { + if (selection.value.isNotEmpty) { + await ref.watch(assetProvider.notifier).toggleFavorite( + selection.value.toList(), + false, + ); + final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset'; + ImmichToast.show( + context: context, + msg: + 'Removed ${selection.value.length} $assetOrAssets from favorites', + gravity: ToastGravity.CENTER, + ); + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + + Widget buildBottomBar() { + return SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + height: 64, + child: Card( + child: Column( + children: [ + ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + leading: const Icon( + Icons.star_border, + ), + title: const Text( + "Unfavorite", + style: TextStyle(fontSize: 14), + ), + onTap: processing.value ? null : unfavorite, + ) + ], + ), + ), + ), + ), + ); + } + return Scaffold( appBar: buildAppBar(), - body: ref.watch(favoriteAssetProvider).isEmpty - ? Center( - child: Text('favorites_page_no_favorites'.tr()), - ) - : ImmichAssetGrid( - assets: ref.watch(favoriteAssetProvider), - ), + body: ref.watch(favoriteAssetsProvider).when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stackTrace) => Center(child: Text(error.toString())), + data: (data) => data.isEmpty + ? Center( + child: Text('favorites_page_no_favorites'.tr()), + ) + : Stack( + children: [ + ImmichAssetGrid( + renderList: data, + selectionActive: selectionEnabledHook.value, + listener: selectionListener, + ), + if (selectionEnabledHook.value) buildBottomBar() + ], + ), + ), ); } } diff --git a/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart b/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart index b787ab783c..d5a0c0d928 100644 --- a/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart +++ b/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart @@ -2,212 +2,313 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; final log = Logger('AssetGridDataStructure'); enum RenderAssetGridElementType { + assets, assetRow, groupDividerTitle, monthTitle; } -class RenderAssetGridRow { - final List assets; - final List widthDistribution; - - RenderAssetGridRow(this.assets, this.widthDistribution); -} - class RenderAssetGridElement { final RenderAssetGridElementType type; - final RenderAssetGridRow? assetRow; final String? title; final DateTime date; - final List? relatedAssetList; + final int count; + final int offset; + final int totalCount; RenderAssetGridElement( this.type, { - this.assetRow, this.title, required this.date, - this.relatedAssetList, + this.count = 0, + this.offset = 0, + this.totalCount = 0, }); } enum GroupAssetsBy { day, - month; -} - -class AssetGridLayoutParameters { - final int perRow; - final bool dynamicLayout; - final GroupAssetsBy groupBy; - - AssetGridLayoutParameters( - this.perRow, - this.dynamicLayout, - this.groupBy, - ); -} - -class _AssetGroupsToRenderListComputeParameters { - final List assets; - final AssetGridLayoutParameters layout; - - _AssetGroupsToRenderListComputeParameters( - this.assets, - this.layout, - ); + month, + auto, + none, + ; } class RenderList { final List elements; + final List? allAssets; + final QueryBuilder? query; + final int totalAssets; - RenderList(this.elements); + /// reference to batch of assets loaded from DB with offset [_bufOffset] + List _buf = []; - static Map> _groupAssets( - List assets, - GroupAssetsBy groupBy, - ) { - if (groupBy == GroupAssetsBy.day) { - return assets.groupListsBy( - (element) { - final date = element.fileCreatedAt.toLocal(); - return DateTime(date.year, date.month, date.day); - }, - ); - } else if (groupBy == GroupAssetsBy.month) { - return assets.groupListsBy( - (element) { - final date = element.fileCreatedAt.toLocal(); - return DateTime(date.year, date.month); - }, - ); + /// global offset of assets in [_buf] + int _bufOffset = 0; + + RenderList(this.elements, this.query, this.allAssets) + : totalAssets = allAssets?.length ?? query!.countSync(); + + bool get isEmpty => totalAssets == 0; + + /// Loads the requested assets from the database to an internal buffer if not cached + /// and returns a slice of that buffer + List loadAssets(int offset, int count) { + assert(offset >= 0); + assert(count > 0); + assert(offset + count <= totalAssets); + if (allAssets != null) { + // if we already loaded all assets (e.g. from search result) + // simply return the requested slice of that array + return allAssets!.slice(offset, offset + count); + } else if (query != null) { + // general case: we have the query to load assets via offset from the DB on demand + if (offset < _bufOffset || offset + count > _bufOffset + _buf.length) { + // the requested slice (offset:offset+count) is not contained in the cache buffer `_buf` + // thus, fill the buffer with a new batch of assets that at least contains the requested + // assets and some more + + final bool forward = _bufOffset < offset; + // if the requested offset is greater than the cached offset, the user scrolls forward "down" + const batchSize = 256; + const oppositeSize = 64; + + // make sure to load a meaningful amount of data (and not only the requested slice) + // otherwise, each call to [loadAssets] would result in DB call trashing performance + // fills small requests to [batchSize], adds some legroom into the opposite scroll direction for large requests + final len = max(batchSize, count + oppositeSize); + // when scrolling forward, start shortly before the requested offset... + // when scrolling backward, end shortly after the requested offset... + // ... to guard against the user scrolling in the other direction + // a tiny bit resulting in a another required load from the DB + final start = max( + 0, + forward + ? offset - oppositeSize + : (len > batchSize ? offset : offset + count - len), + ); + // load the calculated batch (start:start+len) from the DB and put it into the buffer + _buf = query!.offset(start).limit(len).findAllSync(); + _bufOffset = start; + } + assert(_bufOffset <= offset); + assert(_bufOffset + _buf.length >= offset + count); + // return the requested slice from the buffer (we made sure before that the assets are loaded!) + return _buf.slice(offset - _bufOffset, offset - _bufOffset + count); } - - return {}; + throw Exception("RenderList has neither assets nor query"); } - static Future _processAssetGroupData( - _AssetGroupsToRenderListComputeParameters data, + /// Returns the requested asset either from cached buffer or directly from the database + Asset loadAsset(int index) { + if (allAssets != null) { + // all assets are already loaded (e.g. from search result) + return allAssets![index]; + } else if (query != null) { + // general case: we have the DB query to load asset(s) on demand + if (index >= _bufOffset && index < _bufOffset + _buf.length) { + // lucky case: the requested asset is already cached in the buffer! + return _buf[index - _bufOffset]; + } + // request the asset from the database (not changing the buffer!) + final asset = query!.offset(index).findFirstSync(); + if (asset == null) { + throw Exception( + "Asset at index $index does no longer exist in database", + ); + } + return asset; + } + throw Exception("RenderList has neither assets nor query"); + } + + static Future fromQuery( + QueryBuilder query, + GroupAssetsBy groupBy, + ) => + _buildRenderList(null, query, groupBy); + + static Future _buildRenderList( + List? assets, + QueryBuilder? query, + GroupAssetsBy groupBy, ) async { - // TODO: Make DateFormat use the configured locale. - final monthFormat = DateFormat.yMMM(); - final dayFormatSameYear = DateFormat.MMMEd(); - final dayFormatOtherYear = DateFormat.yMMMEd(); - final allAssets = data.assets; - final perRow = data.layout.perRow; - final dynamicLayout = data.layout.dynamicLayout; - final groupBy = data.layout.groupBy; + final List elements = []; - List elements = []; - DateTime? lastDate; - - final groups = _groupAssets(allAssets, groupBy); - - groups.entries.sortedBy((e) => e.key).reversed.forEach((entry) { - final date = entry.key; - final assets = entry.value; - - try { - // Month title - if (groupBy == GroupAssetsBy.day && - (lastDate == null || lastDate!.month != date.month)) { - elements.add( - RenderAssetGridElement( - RenderAssetGridElementType.monthTitle, - title: monthFormat.format(date), - date: date, - ), - ); - } - - // Group divider title (day or month) - var formatDate = dayFormatOtherYear; - - if (DateTime.now().year == date.year) { - formatDate = dayFormatSameYear; - } - - if (groupBy == GroupAssetsBy.month) { - formatDate = monthFormat; - } + const pageSize = 500; + const sectionSize = 60; // divides evenly by 2,3,4,5,6 + if (groupBy == GroupAssetsBy.none) { + final int total = assets?.length ?? query!.countSync(); + for (int i = 0; i < total; i += sectionSize) { + final date = assets != null + ? assets[i].fileCreatedAt + : await query!.offset(i).fileCreatedAtProperty().findFirst(); + final int count = i + sectionSize > total ? total - i : sectionSize; + if (date == null) break; elements.add( RenderAssetGridElement( - RenderAssetGridElementType.groupDividerTitle, - title: formatDate.format(date), + RenderAssetGridElementType.assets, date: date, - relatedAssetList: assets, + count: count, + totalCount: total, + offset: i, ), ); - - // Add rows - int cursor = 0; - while (cursor < assets.length) { - int rowElements = min(assets.length - cursor, perRow); - final rowAssets = assets.sublist(cursor, cursor + rowElements); - - // Default: All assets have the same width - var widthDistribution = List.filled(rowElements, 1.0); - - if (dynamicLayout) { - final aspectRatios = - rowAssets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList(); - final meanAspectRatio = aspectRatios.sum / rowElements; - - // 1: mean width - // 0.5: width < mean - threshold - // 1.5: width > mean + threshold - final arConfiguration = aspectRatios.map((e) { - if (e - meanAspectRatio > 0.3) return 1.5; - if (e - meanAspectRatio < -0.3) return 0.5; - return 1.0; - }); - - // Normalize: - final sum = arConfiguration.sum; - widthDistribution = - arConfiguration.map((e) => (e * rowElements) / sum).toList(); - } - - final rowElement = RenderAssetGridElement( - RenderAssetGridElementType.assetRow, - date: date, - assetRow: RenderAssetGridRow( - rowAssets, - widthDistribution, - ), - ); - - elements.add(rowElement); - cursor += rowElements; - } - - lastDate = date; - } catch (e, stackTrace) { - log.severe(e, stackTrace); } - }); + return RenderList(elements, query, assets); + } - return RenderList(elements); + final formatSameYear = + groupBy == GroupAssetsBy.month ? DateFormat.MMMM() : DateFormat.MMMEd(); + final formatOtherYear = groupBy == GroupAssetsBy.month + ? DateFormat.yMMMM() + : DateFormat.yMMMEd(); + final currentYear = DateTime.now().year; + final formatMergedSameYear = DateFormat.MMMd(); + final formatMergedOtherYear = DateFormat.yMMMd(); + + int offset = 0; + DateTime? last; + DateTime? current; + int lastOffset = 0; + int count = 0; + int monthCount = 0; + int lastMonthIndex = 0; + + String formatDateRange(DateTime from, DateTime to) { + final startDate = (from.year == currentYear + ? formatMergedSameYear + : formatMergedOtherYear) + .format(from); + final endDate = (to.year == currentYear + ? formatMergedSameYear + : formatMergedOtherYear) + .format(to); + if (DateTime(from.year, from.month, from.day) == + DateTime(to.year, to.month, to.day)) { + // format range with time when both dates are on the same day + final startTime = DateFormat.Hm().format(from); + final endTime = DateFormat.Hm().format(to); + return "$startDate $startTime - $endTime"; + } + return "$startDate - $endDate"; + } + + void mergeMonth() { + if (last != null && + groupBy == GroupAssetsBy.auto && + monthCount <= 30 && + elements.length > lastMonthIndex + 1) { + // merge all days into a single section + assert(elements[lastMonthIndex].date.month == last.month); + final e = elements[lastMonthIndex]; + + elements[lastMonthIndex] = RenderAssetGridElement( + RenderAssetGridElementType.monthTitle, + date: e.date, + count: monthCount, + totalCount: monthCount, + offset: e.offset, + title: formatDateRange(e.date, elements.last.date), + ); + elements.removeRange(lastMonthIndex + 1, elements.length); + } + } + + void addElems(DateTime d, DateTime? prevDate) { + final bool newMonth = + last == null || last.year != d.year || last.month != d.month; + if (newMonth) { + mergeMonth(); + lastMonthIndex = elements.length; + monthCount = 0; + } + for (int j = 0; j < count; j += sectionSize) { + final type = j == 0 + ? (groupBy != GroupAssetsBy.month && newMonth + ? RenderAssetGridElementType.monthTitle + : RenderAssetGridElementType.groupDividerTitle) + : (groupBy == GroupAssetsBy.auto + ? RenderAssetGridElementType.groupDividerTitle + : RenderAssetGridElementType.assets); + final sectionCount = j + sectionSize > count ? count - j : sectionSize; + assert(sectionCount > 0 && sectionCount <= sectionSize); + elements.add( + RenderAssetGridElement( + type, + date: d, + count: sectionCount, + totalCount: groupBy == GroupAssetsBy.auto ? sectionCount : count, + offset: lastOffset + j, + title: j == 0 + ? (d.year == currentYear + ? formatSameYear.format(d) + : formatOtherYear.format(d)) + : (groupBy == GroupAssetsBy.auto + ? formatDateRange(d, prevDate ?? d) + : null), + ), + ); + } + monthCount += count; + } + + DateTime? prevDate; + while (true) { + // this iterates all assets (only their createdAt property) in batches + // memory usage is okay, however runtime is linear with number of assets + // TODO replace with groupBy once Isar supports such queries + final dates = assets != null + ? assets.map((a) => a.fileCreatedAt) + : await query! + .offset(offset) + .limit(pageSize) + .fileCreatedAtProperty() + .findAll(); + int i = 0; + for (final date in dates) { + final d = DateTime( + date.year, + date.month, + groupBy == GroupAssetsBy.month ? 1 : date.day, + ); + current ??= d; + if (current != d) { + addElems(current, prevDate); + last = current; + current = d; + lastOffset = offset + i; + count = 0; + } + prevDate = date; + count++; + i++; + } + + if (assets != null || dates.length != pageSize) break; + offset += pageSize; + } + if (count > 0 && current != null) { + addElems(current, prevDate); + mergeMonth(); + } + assert(elements.every((e) => e.count <= sectionSize), "too large section"); + return RenderList(elements, query, assets); } + static RenderList empty() => RenderList([], null, []); + static Future fromAssets( List assets, - AssetGridLayoutParameters layout, - ) async { - // Compute only allows for one parameter. Therefore we pass all parameters in a map - return compute( - _processAssetGroupData, - _AssetGroupsToRenderListComputeParameters( - assets, - layout, - ), - ); - } + GroupAssetsBy groupBy, + ) => + _buildRenderList(assets, null, groupBy); } diff --git a/mobile/lib/modules/home/ui/asset_grid/draggable_scrollbar_custom.dart b/mobile/lib/modules/home/ui/asset_grid/draggable_scrollbar_custom.dart index dbdc125d28..d28d825c97 100644 --- a/mobile/lib/modules/home/ui/asset_grid/draggable_scrollbar_custom.dart +++ b/mobile/lib/modules/home/ui/asset_grid/draggable_scrollbar_custom.dart @@ -396,8 +396,8 @@ class DraggableScrollbarState extends State widget.scrollStateListener(true); dragHaltTimer = Timer( - const Duration(milliseconds: 200), - () { + const Duration(milliseconds: 500), + () { widget.scrollStateListener(false); }, ); diff --git a/mobile/lib/modules/home/ui/asset_grid/group_divider_title.dart b/mobile/lib/modules/home/ui/asset_grid/group_divider_title.dart index 6a92c9e21f..cedfa46e51 100644 --- a/mobile/lib/modules/home/ui/asset_grid/group_divider_title.dart +++ b/mobile/lib/modules/home/ui/asset_grid/group_divider_title.dart @@ -19,8 +19,6 @@ class GroupDividerTitle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - - void handleTitleIconClick() { if (selected) { onDeselect(); @@ -32,7 +30,7 @@ class GroupDividerTitle extends ConsumerWidget { return Padding( padding: const EdgeInsets.only( top: 29.0, - bottom: 29.0, + bottom: 10.0, left: 12.0, right: 12.0, ), diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index 021418df25..45f5b2ad4e 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -8,6 +8,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/ui/immich_loading_indicator.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class ImmichAssetGrid extends HookConsumerWidget { final int? assetsPerRow; @@ -15,13 +16,19 @@ class ImmichAssetGrid extends HookConsumerWidget { final bool? showStorageIndicator; final ImmichAssetGridSelectionListener? listener; final bool selectionActive; - final List assets; + final List? assets; final RenderList? renderList; final Future Function()? onRefresh; + final Set? preselectedAssets; + final bool canDeselect; + final bool? dynamicLayout; + final bool showMultiSelectIndicator; + final void Function(ItemPosition start, ItemPosition end)? + visibleItemsListener; const ImmichAssetGrid({ super.key, - required this.assets, + this.assets, this.onRefresh, this.renderList, this.assetsPerRow, @@ -29,12 +36,16 @@ class ImmichAssetGrid extends HookConsumerWidget { this.listener, this.margin = 5.0, this.selectionActive = false, + this.preselectedAssets, + this.canDeselect = true, + this.dynamicLayout, + this.showMultiSelectIndicator = true, + this.visibleItemsListener, }); @override Widget build(BuildContext context, WidgetRef ref) { var settings = ref.watch(appSettingsServiceProvider); - final renderListFuture = ref.watch(renderListProvider(assets)); // Needs to suppress hero animations when navigating to this widget final enableHeroAnimations = useState(false); @@ -64,34 +75,12 @@ class ImmichAssetGrid extends HookConsumerWidget { return true; } - if (renderList != null) { + Widget buildAssetGridView(RenderList renderList) { return WillPopScope( onWillPop: onWillPop, child: HeroMode( enabled: enableHeroAnimations.value, child: ImmichAssetGridView( - allAssets: assets, - onRefresh: onRefresh, - assetsPerRow: assetsPerRow ?? - settings.getSetting(AppSettingsEnum.tilesPerRow), - listener: listener, - showStorageIndicator: showStorageIndicator ?? - settings.getSetting(AppSettingsEnum.storageIndicator), - renderList: renderList!, - margin: margin, - selectionActive: selectionActive, - ), - ), - ); - } - - return renderListFuture.when( - data: (renderList) => WillPopScope( - onWillPop: onWillPop, - child: HeroMode( - enabled: enableHeroAnimations.value, - child: ImmichAssetGridView( - allAssets: assets, onRefresh: onRefresh, assetsPerRow: assetsPerRow ?? settings.getSetting(AppSettingsEnum.tilesPerRow), @@ -101,9 +90,22 @@ class ImmichAssetGrid extends HookConsumerWidget { renderList: renderList, margin: margin, selectionActive: selectionActive, + preselectedAssets: preselectedAssets, + canDeselect: canDeselect, + dynamicLayout: dynamicLayout ?? + settings.getSetting(AppSettingsEnum.dynamicLayout), + showMultiSelectIndicator: showMultiSelectIndicator, + visibleItemsListener: visibleItemsListener, ), ), - ), + ); + } + + if (renderList != null) return buildAssetGridView(renderList!); + + final renderListFuture = ref.watch(renderListProvider(assets!)); + return renderListFuture.when( + data: (renderList) => buildAssetGridView(renderList), error: (err, stack) => Center(child: Text("$err")), loading: () => const Center( child: ImmichLoadingIndicator(), diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 0b3b447c63..c564927ad9 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -1,4 +1,5 @@ import 'dart:collection'; +import 'dart:math'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -6,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/utils/builtin_extensions.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'asset_grid_data_structure.dart'; import 'group_divider_title.dart'; @@ -23,13 +25,11 @@ class ImmichAssetGridViewState extends State { ItemPositionsListener.create(); bool _scrolling = false; - final Set _selectedAssets = HashSet(); + final Set _selectedAssets = + HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); Set _getSelectedAssets() { - return _selectedAssets - .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e)) - .whereNotNull() - .toSet(); + return Set.from(_selectedAssets); } void _callSelectionListener(bool selectionActive) { @@ -38,18 +38,14 @@ class ImmichAssetGridViewState extends State { void _selectAssets(List assets) { setState(() { - for (var e in assets) { - _selectedAssets.add(e.id); - } + _selectedAssets.addAll(assets); _callSelectionListener(true); }); } void _deselectAssets(List assets) { setState(() { - for (var e in assets) { - _selectedAssets.remove(e.id); - } + _selectedAssets.removeAll(assets); _callSelectionListener(_selectedAssets.isNotEmpty); }); } @@ -57,64 +53,86 @@ class ImmichAssetGridViewState extends State { void _deselectAll() { setState(() { _selectedAssets.clear(); + if (!widget.canDeselect && + widget.preselectedAssets != null && + widget.preselectedAssets!.isNotEmpty) { + _selectedAssets.addAll(widget.preselectedAssets!); + } + _callSelectionListener(false); }); - - _callSelectionListener(false); } bool _allAssetsSelected(List assets) { return widget.selectionActive && - assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null; + assets.firstWhereOrNull((e) => !_selectedAssets.contains(e)) == null; } - Widget _buildThumbnailOrPlaceholder( - Asset asset, - bool placeholder, - ) { - if (placeholder) { - return const DecoratedBox( - decoration: BoxDecoration(color: Colors.grey), - ); - } + Widget _buildThumbnailOrPlaceholder(Asset asset, int index) { return ThumbnailImage( asset: asset, - assetList: widget.allAssets, + index: index, + loadAsset: widget.renderList.loadAsset, + totalAssets: widget.renderList.totalAssets, multiselectEnabled: widget.selectionActive, - isSelected: widget.selectionActive && _selectedAssets.contains(asset.id), + isSelected: widget.selectionActive && _selectedAssets.contains(asset), onSelect: () => _selectAssets([asset]), - onDeselect: () => _deselectAssets([asset]), + onDeselect: widget.canDeselect || + widget.preselectedAssets == null || + !widget.preselectedAssets!.contains(asset) + ? () => _deselectAssets([asset]) + : null, useGrayBoxPlaceholder: true, showStorageIndicator: widget.showStorageIndicator, ); } Widget _buildAssetRow( + Key key, BuildContext context, - RenderAssetGridRow row, - bool scrolling, + List assets, + int absoluteOffset, + double width, ) { - return LayoutBuilder( - builder: (context, constraints) { - final size = constraints.maxWidth / widget.assetsPerRow - - widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow; - return Row( - key: Key("asset-row-${row.assets.first.id}"), - children: row.assets.mapIndexed((int index, Asset asset) { - bool last = asset.id == row.assets.last.id; + // Default: All assets have the same width + final widthDistribution = List.filled(assets.length, 1.0); - return Container( - key: Key("asset-${asset.id}"), - width: size * row.widthDistribution[index], - height: size, - margin: EdgeInsets.only( - top: widget.margin, - right: last ? 0.0 : widget.margin, - ), - child: _buildThumbnailOrPlaceholder(asset, scrolling), - ); - }).toList(), + if (widget.dynamicLayout) { + final aspectRatios = + assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList(); + final meanAspectRatio = aspectRatios.sum / assets.length; + + // 1: mean width + // 0.5: width < mean - threshold + // 1.5: width > mean + threshold + final arConfiguration = aspectRatios.map((e) { + if (e - meanAspectRatio > 0.3) return 1.5; + if (e - meanAspectRatio < -0.3) return 0.5; + return 1.0; + }); + + // Normalize: + final sum = arConfiguration.sum; + widthDistribution.setRange( + 0, + widthDistribution.length, + arConfiguration.map((e) => (e * assets.length) / sum), + ); + } + return Row( + key: key, + children: assets.mapIndexed((int index, Asset asset) { + final bool last = index + 1 == widget.assetsPerRow; + return Container( + key: ValueKey(index), + width: width * widthDistribution[index], + height: width, + margin: EdgeInsets.only( + top: widget.margin, + right: last ? 0.0 : widget.margin, + ), + child: _buildThumbnailOrPlaceholder(asset, absoluteOffset + index), ); - }, + }).toList(), ); } @@ -132,10 +150,14 @@ class ImmichAssetGridViewState extends State { ); } - Widget _buildMonthTitle(BuildContext context, String title) { + Widget _buildMonthTitle(BuildContext context, DateTime date) { + final monthFormat = DateTime.now().year == date.year + ? DateFormat.MMMM() + : DateFormat.yMMMM(); + final String title = monthFormat.format(date); return Padding( key: Key("month-$title"), - padding: const EdgeInsets.only(left: 12.0, top: 32), + padding: const EdgeInsets.only(left: 12.0, top: 30), child: Text( title, style: TextStyle( @@ -147,18 +169,84 @@ class ImmichAssetGridViewState extends State { ); } + Widget _buildPlaceHolderRow(Key key, int num, double width, double height) { + return Row( + key: key, + children: [ + for (int i = 0; i < num; i++) + Container( + key: ValueKey(i), + width: width, + height: height, + margin: EdgeInsets.only( + top: widget.margin, + right: i + 1 == num ? 0.0 : widget.margin, + ), + color: Colors.grey, + ) + ], + ); + } + + Widget _buildSection( + BuildContext context, + RenderAssetGridElement section, + bool scrolling, + ) { + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth / widget.assetsPerRow - + widget.margin * (widget.assetsPerRow - 1) / widget.assetsPerRow; + final rows = + (section.count + widget.assetsPerRow - 1) ~/ widget.assetsPerRow; + final List assetsToRender = scrolling + ? [] + : widget.renderList.loadAssets(section.offset, section.count); + return Column( + key: ValueKey(section.offset), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (section.type == RenderAssetGridElementType.monthTitle) + _buildMonthTitle(context, section.date), + if (section.type == RenderAssetGridElementType.groupDividerTitle || + section.type == RenderAssetGridElementType.monthTitle) + _buildTitle( + context, + section.title!, + scrolling + ? [] + : widget.renderList + .loadAssets(section.offset, section.totalCount), + ), + for (int i = 0; i < rows; i++) + scrolling + ? _buildPlaceHolderRow( + ValueKey(i), + i + 1 == rows + ? section.count - i * widget.assetsPerRow + : widget.assetsPerRow, + width, + width, + ) + : _buildAssetRow( + ValueKey(i), + context, + assetsToRender.nestedSlice( + i * widget.assetsPerRow, + min((i + 1) * widget.assetsPerRow, section.count), + ), + section.offset + i * widget.assetsPerRow, + width, + ), + ], + ); + }, + ); + } + Widget _itemBuilder(BuildContext c, int position) { final item = widget.renderList.elements[position]; - - if (item.type == RenderAssetGridElementType.groupDividerTitle) { - return _buildTitle(c, item.title!, item.relatedAssetList!); - } else if (item.type == RenderAssetGridElementType.monthTitle) { - return _buildMonthTitle(c, item.title!); - } else if (item.type == RenderAssetGridElementType.assetRow) { - return _buildAssetRow(c, item.assetRow!, _scrolling); - } - - return const Text("Invalid widget type!"); + return _buildSection(c, item, _scrolling); } Text _labelBuilder(int pos) { @@ -180,7 +268,7 @@ class ImmichAssetGridViewState extends State { } Widget _buildAssetGrid() { - final useDragScrolling = widget.allAssets.length >= 20; + final useDragScrolling = widget.renderList.totalAssets >= 20; void dragScrolling(bool active) { setState(() { @@ -225,6 +313,10 @@ class ImmichAssetGridViewState extends State { setState(() { _selectedAssets.clear(); }); + } else if (widget.preselectedAssets != null) { + setState(() { + _selectedAssets.addAll(widget.preselectedAssets!); + }); } } @@ -241,14 +333,33 @@ class ImmichAssetGridViewState extends State { void initState() { super.initState(); scrollToTopNotifierProvider.addListener(_scrollToTop); + if (widget.visibleItemsListener != null) { + _itemPositionsListener.itemPositions.addListener(_positionListener); + } } @override void dispose() { scrollToTopNotifierProvider.removeListener(_scrollToTop); + if (widget.visibleItemsListener != null) { + _itemPositionsListener.itemPositions.removeListener(_positionListener); + } super.dispose(); } + void _positionListener() { + final values = _itemPositionsListener.itemPositions.value; + final start = values.firstOrNull; + final end = values.lastOrNull; + if (start != null && end != null) { + if (start.index <= end.index) { + widget.visibleItemsListener?.call(start, end); + } else { + widget.visibleItemsListener?.call(end, start); + } + } + } + void _scrollToTop() { // for some reason, this is necessary as well in order // to correctly reposition the drag thumb scroll bar @@ -268,7 +379,8 @@ class ImmichAssetGridViewState extends State { child: Stack( children: [ _buildAssetGrid(), - if (widget.selectionActive) _buildMultiSelectIndicator(), + if (widget.showMultiSelectIndicator && widget.selectionActive) + _buildMultiSelectIndicator(), ], ), ); @@ -282,19 +394,28 @@ class ImmichAssetGridView extends StatefulWidget { final bool showStorageIndicator; final ImmichAssetGridSelectionListener? listener; final bool selectionActive; - final List allAssets; final Future Function()? onRefresh; + final Set? preselectedAssets; + final bool canDeselect; + final bool dynamicLayout; + final bool showMultiSelectIndicator; + final void Function(ItemPosition start, ItemPosition end)? + visibleItemsListener; const ImmichAssetGridView({ super.key, required this.renderList, - required this.allAssets, required this.assetsPerRow, required this.showStorageIndicator, this.listener, this.margin = 5.0, this.selectionActive = false, this.onRefresh, + this.preselectedAssets, + this.canDeselect = true, + this.dynamicLayout = true, + this.showMultiSelectIndicator = true, + this.visibleItemsListener, }); @override diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index ff01ee9131..1373df7d3c 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; @@ -10,7 +9,9 @@ import 'package:immich_mobile/utils/storage_indicator.dart'; class ThumbnailImage extends HookConsumerWidget { final Asset asset; - final List assetList; + final int index; + final Asset Function(int index) loadAsset; + final int totalAssets; final bool showStorageIndicator; final bool useGrayBoxPlaceholder; final bool isSelected; @@ -21,7 +22,9 @@ class ThumbnailImage extends HookConsumerWidget { const ThumbnailImage({ Key? key, required this.asset, - required this.assetList, + required this.index, + required this.loadAsset, + required this.totalAssets, this.showStorageIndicator = true, this.useGrayBoxPlaceholder = false, this.isSelected = false, @@ -57,8 +60,9 @@ class ThumbnailImage extends HookConsumerWidget { } else { AutoRouter.of(context).push( GalleryViewerRoute( - assetList: assetList, - asset: asset, + initialIndex: index, + loadAsset: loadAsset, + totalAssets: totalAssets, ), ); } @@ -100,7 +104,9 @@ class ThumbnailImage extends HookConsumerWidget { decoration: BoxDecoration( border: multiselectEnabled && isSelected ? Border.all( - color: Theme.of(context).primaryColorLight, + color: onDeselect == null + ? Colors.grey + : Theme.of(context).primaryColorLight, width: 10, ) : const Border(), @@ -130,7 +136,7 @@ class ThumbnailImage extends HookConsumerWidget { size: 18, ), ), - if (ref.watch(favoriteProvider).contains(asset.id)) + if (asset.isFavorite) const Positioned( left: 10, bottom: 5, diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index b852dbd44b..df5321a535 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -7,15 +7,16 @@ import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/models/album.dart'; class ControlBottomAppBar extends ConsumerWidget { - final Function onShare; - final Function onFavorite; - final Function onArchive; - final Function onDelete; + final void Function() onShare; + final void Function() onFavorite; + final void Function() onArchive; + final void Function() onDelete; final Function(Album album) onAddToAlbum; final void Function() onCreateNewAlbum; final List albums; final List sharedAlbums; + final bool enabled; const ControlBottomAppBar({ Key? key, @@ -27,6 +28,7 @@ class ControlBottomAppBar extends ConsumerWidget { required this.albums, required this.onAddToAlbum, required this.onCreateNewAlbum, + this.enabled = true, }) : super(key: key); @override @@ -39,35 +41,31 @@ class ControlBottomAppBar extends ConsumerWidget { ControlBoxButton( iconData: Icons.ios_share_rounded, label: "control_bottom_app_bar_share".tr(), - onPressed: () { - onShare(); - }, + onPressed: enabled ? onShare : null, ), ControlBoxButton( iconData: Icons.favorite_border_rounded, label: "control_bottom_app_bar_favorite".tr(), - onPressed: () { - onFavorite(); - }, + onPressed: enabled ? onFavorite : null, ), ControlBoxButton( iconData: Icons.delete_outline_rounded, label: "control_bottom_app_bar_delete".tr(), - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return DeleteDialog( - onDelete: onDelete, - ); - }, - ); - }, + onPressed: enabled + ? () => showDialog( + context: context, + builder: (BuildContext context) { + return DeleteDialog( + onDelete: onDelete, + ); + }, + ) + : null, ), ControlBoxButton( iconData: Icons.archive, label: "control_bottom_app_bar_archive".tr(), - onPressed: () => onArchive(), + onPressed: enabled ? onArchive : null, ), ], ); @@ -108,7 +106,9 @@ class ControlBottomAppBar extends ConsumerWidget { endIndent: 16, thickness: 1, ), - AddToAlbumTitleRow(onCreateNewAlbum: onCreateNewAlbum), + AddToAlbumTitleRow( + onCreateNewAlbum: enabled ? onCreateNewAlbum : null, + ), ], ), ), @@ -118,6 +118,7 @@ class ControlBottomAppBar extends ConsumerWidget { albums: albums, sharedAlbums: sharedAlbums, onAddToAlbum: onAddToAlbum, + enabled: enabled, ), ), const SliverToBoxAdapter( @@ -137,7 +138,7 @@ class AddToAlbumTitleRow extends StatelessWidget { required this.onCreateNewAlbum, }); - final VoidCallback onCreateNewAlbum; + final VoidCallback? onCreateNewAlbum; @override Widget build(BuildContext context) { diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index f6c4ea2d4e..a7caf511f1 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -10,14 +10,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; -import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; import 'package:immich_mobile/modules/home/ui/home_page_app_bar.dart'; import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart'; -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/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; @@ -34,7 +31,6 @@ class HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final appSettingService = ref.watch(appSettingsServiceProvider); final multiselectEnabled = ref.watch(multiselectProvider.notifier); final selectionEnabledHook = useState(false); @@ -45,6 +41,7 @@ class HomePage extends HookConsumerWidget { final tipOneOpacity = useState(0.0); final refreshCount = useState(0); + final processing = useState(false); useEffect( () { @@ -97,7 +94,7 @@ class HomePage extends HookConsumerWidget { selectionEnabledHook.value = false; } - Iterable remoteOnlySelection({String? localErrorMessage}) { + List remoteOnlySelection({String? localErrorMessage}) { final Set assets = selection.value; final bool onlyRemote = assets.every((e) => e.isRemote); if (!onlyRemote) { @@ -108,113 +105,139 @@ class HomePage extends HookConsumerWidget { gravity: ToastGravity.BOTTOM, ); } - return assets.where((a) => a.isRemote); + return assets.where((a) => a.isRemote).toList(); } - return assets; + return assets.toList(); } - void onFavoriteAssets() { - final remoteAssets = remoteOnlySelection( - localErrorMessage: 'home_page_favorite_err_local'.tr(), - ); - if (remoteAssets.isNotEmpty) { - ref.watch(favoriteProvider.notifier).addToFavorites(remoteAssets); - - final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset'; - ImmichToast.show( - context: context, - msg: 'Added ${remoteAssets.length} $assetOrAssets to favorites', - gravity: ToastGravity.BOTTOM, + void onFavoriteAssets() async { + processing.value = true; + try { + final remoteAssets = remoteOnlySelection( + localErrorMessage: 'home_page_favorite_err_local'.tr(), ); - } + if (remoteAssets.isNotEmpty) { + await ref + .watch(assetProvider.notifier) + .toggleFavorite(remoteAssets, true); - selectionEnabledHook.value = false; + final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset'; + ImmichToast.show( + context: context, + msg: 'Added ${remoteAssets.length} $assetOrAssets to favorites', + gravity: ToastGravity.BOTTOM, + ); + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } } - void onArchiveAsset() { - final remoteAssets = remoteOnlySelection( - localErrorMessage: 'home_page_archive_err_local'.tr(), - ); - if (remoteAssets.isNotEmpty) { - ref.watch(assetProvider.notifier).toggleArchive(remoteAssets, true); - - final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset'; - ImmichToast.show( - context: context, - msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive', - gravity: ToastGravity.CENTER, + void onArchiveAsset() async { + processing.value = true; + try { + final remoteAssets = remoteOnlySelection( + localErrorMessage: 'home_page_archive_err_local'.tr(), ); - } + if (remoteAssets.isNotEmpty) { + await ref + .watch(assetProvider.notifier) + .toggleArchive(remoteAssets, true); - selectionEnabledHook.value = false; + final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset'; + ImmichToast.show( + context: context, + msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive', + gravity: ToastGravity.CENTER, + ); + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } } - void onDelete() { - ref.watch(assetProvider.notifier).deleteAssets(selection.value); - selectionEnabledHook.value = false; + void onDelete() async { + processing.value = true; + try { + await ref.watch(assetProvider.notifier).deleteAssets(selection.value); + selectionEnabledHook.value = false; + } finally { + processing.value = false; + } } void onAddToAlbum(Album album) async { - final Iterable assets = remoteOnlySelection( - localErrorMessage: "home_page_add_to_album_err_local".tr(), - ); - if (assets.isEmpty) { - return; - } - final result = await albumService.addAdditionalAssetToAlbum( - assets, - album, - ); - - if (result != null) { - if (result.alreadyInAlbum.isNotEmpty) { - ImmichToast.show( - context: context, - msg: "home_page_add_to_album_conflicts".tr( - namedArgs: { - "album": album.name, - "added": result.successfullyAdded.toString(), - "failed": result.alreadyInAlbum.length.toString() - }, - ), - ); - } else { - ImmichToast.show( - context: context, - msg: "home_page_add_to_album_success".tr( - namedArgs: { - "album": album.name, - "added": result.successfullyAdded.toString(), - }, - ), - toastType: ToastType.success, - ); + processing.value = true; + try { + final Iterable assets = remoteOnlySelection( + localErrorMessage: "home_page_add_to_album_err_local".tr(), + ); + if (assets.isEmpty) { + return; } + final result = await albumService.addAdditionalAssetToAlbum( + assets, + album, + ); + if (result != null) { + if (result.alreadyInAlbum.isNotEmpty) { + ImmichToast.show( + context: context, + msg: "home_page_add_to_album_conflicts".tr( + namedArgs: { + "album": album.name, + "added": result.successfullyAdded.toString(), + "failed": result.alreadyInAlbum.length.toString() + }, + ), + ); + } else { + ImmichToast.show( + context: context, + msg: "home_page_add_to_album_success".tr( + namedArgs: { + "album": album.name, + "added": result.successfullyAdded.toString(), + }, + ), + toastType: ToastType.success, + ); + } + } + } finally { + processing.value = false; selectionEnabledHook.value = false; } } void onCreateNewAlbum() async { - final Iterable assets = remoteOnlySelection( - localErrorMessage: "home_page_add_to_album_err_local".tr(), - ); - if (assets.isEmpty) { - return; - } - final result = await albumService.createAlbumWithGeneratedName(assets); + processing.value = true; + try { + final Iterable assets = remoteOnlySelection( + localErrorMessage: "home_page_add_to_album_err_local".tr(), + ); + if (assets.isEmpty) { + return; + } + final result = + await albumService.createAlbumWithGeneratedName(assets); - if (result != null) { - ref.watch(albumProvider.notifier).getAllAlbums(); - ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); - selectionEnabledHook.value = false; + if (result != null) { + ref.watch(albumProvider.notifier).getAllAlbums(); + ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); + selectionEnabledHook.value = false; - AutoRouter.of(context).push(AlbumViewerRoute(albumId: result.id)); + AutoRouter.of(context).push(AlbumViewerRoute(albumId: result.id)); + } + } finally { + processing.value = false; } } Future refreshAssets() async { - debugPrint("refreshCount.value ${refreshCount.value}"); final fullRefresh = refreshCount.value > 0; await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); if (fullRefresh) { @@ -277,20 +300,18 @@ class HomePage extends HookConsumerWidget { bottom: false, child: Stack( children: [ - ref.watch(assetProvider).renderList == null || - ref.watch(assetProvider).allAssets.isEmpty - ? buildLoadingIndicator() - : ImmichAssetGrid( - renderList: ref.watch(assetProvider).renderList!, - assets: ref.read(assetProvider).allAssets, - assetsPerRow: appSettingService - .getSetting(AppSettingsEnum.tilesPerRow), - showStorageIndicator: appSettingService - .getSetting(AppSettingsEnum.storageIndicator), - listener: selectionListener, - selectionActive: selectionEnabledHook.value, - onRefresh: refreshAssets, - ), + ref.watch(assetsProvider).when( + data: (data) => data.isEmpty + ? buildLoadingIndicator() + : ImmichAssetGrid( + renderList: data, + listener: selectionListener, + selectionActive: selectionEnabledHook.value, + onRefresh: refreshAssets, + ), + error: (error, _) => Center(child: Text(error.toString())), + loading: buildLoadingIndicator, + ), if (selectionEnabledHook.value) ControlBottomAppBar( onShare: onShareAssets, @@ -301,7 +322,9 @@ class HomePage extends HookConsumerWidget { albums: albums, sharedAlbums: sharedAlbums, onCreateNewAlbum: onCreateNewAlbum, + enabled: !processing.value, ), + if (processing.value) const Center(child: ImmichLoadingIndicator()) ], ), ); diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index 38b526c874..2b8cc88aec 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -9,13 +9,17 @@ import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.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/utils/db.dart'; import 'package:immich_mobile/utils/hash.dart'; +import 'package:isar/isar.dart'; import 'package:openapi/api.dart'; class AuthenticationNotifier extends StateNotifier { AuthenticationNotifier( this._apiService, + this._db, ) : super( AuthenticationState( deviceId: "", @@ -31,6 +35,7 @@ class AuthenticationNotifier extends StateNotifier { ); final ApiService _apiService; + final Isar _db; Future login( String email, @@ -91,7 +96,7 @@ class AuthenticationNotifier extends StateNotifier { try { await Future.wait([ _apiService.authenticationApi.logout(), - Store.delete(StoreKey.assetETag), + clearAssetsAndAlbums(_db), Store.delete(StoreKey.currentUser), Store.delete(StoreKey.accessToken), ]); @@ -170,5 +175,6 @@ final authenticationProvider = StateNotifierProvider((ref) { return AuthenticationNotifier( ref.watch(apiServiceProvider), + ref.watch(dbProvider), ); }); diff --git a/mobile/lib/modules/search/ui/search_result_grid.dart b/mobile/lib/modules/search/ui/search_result_grid.dart index 14ccfb2949..2975999be4 100644 --- a/mobile/lib/modules/search/ui/search_result_grid.dart +++ b/mobile/lib/modules/search/ui/search_result_grid.dart @@ -8,6 +8,8 @@ class SearchResultGrid extends HookConsumerWidget { final List assets; + Asset _loadAsset(int index) => assets[index]; + @override Widget build(BuildContext context, WidgetRef ref) { return GridView.builder( @@ -22,7 +24,9 @@ class SearchResultGrid extends HookConsumerWidget { final asset = assets[index]; return ThumbnailImage( asset: asset, - assetList: assets, + index: index, + loadAsset: _loadAsset, + totalAssets: assets.length, useGrayBoxPlaceholder: true, ); }, diff --git a/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_layout_settings.dart b/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_layout_settings.dart index dec1f09f4c..e378743355 100644 --- a/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_layout_settings.dart +++ b/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_layout_settings.dart @@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; 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/providers/asset.provider.dart'; class LayoutSettings extends HookConsumerWidget { const LayoutSettings({ @@ -22,14 +21,17 @@ class LayoutSettings extends HookConsumerWidget { void switchChanged(bool value) { appSettingService.setSetting(AppSettingsEnum.dynamicLayout, value); useDynamicLayout.value = value; - ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure(); + ref.invalidate(appSettingsServiceProvider); } void changeGroupValue(GroupAssetsBy? value) { if (value != null) { - appSettingService.setSetting(AppSettingsEnum.groupAssetsBy, value.index); + appSettingService.setSetting( + AppSettingsEnum.groupAssetsBy, + value.index, + ); groupBy.value = value; - ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure(); + ref.invalidate(appSettingsServiceProvider); } } @@ -37,8 +39,8 @@ class LayoutSettings extends HookConsumerWidget { () { useDynamicLayout.value = appSettingService.getSetting(AppSettingsEnum.dynamicLayout); - groupBy.value = - GroupAssetsBy.values[appSettingService.getSetting(AppSettingsEnum.groupAssetsBy)]; + groupBy.value = GroupAssetsBy.values[ + appSettingService.getSetting(AppSettingsEnum.groupAssetsBy)]; return null; }, @@ -93,6 +95,19 @@ class LayoutSettings extends HookConsumerWidget { onChanged: changeGroupValue, controlAffinity: ListTileControlAffinity.trailing, ), + RadioListTile( + activeColor: Theme.of(context).primaryColor, + title: const Text( + "asset_list_layout_settings_group_automatically", + style: TextStyle( + fontSize: 12, + ), + ).tr(), + value: GroupAssetsBy.auto, + groupValue: groupBy.value, + onChanged: changeGroupValue, + controlAffinity: ListTileControlAffinity.trailing, + ), ], ); } diff --git a/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_storage_indicator.dart b/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_storage_indicator.dart index 9cd8ee5e9c..376ed35765 100644 --- a/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_storage_indicator.dart +++ b/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_storage_indicator.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/providers/asset.provider.dart'; class StorageIndicator extends HookConsumerWidget { const StorageIndicator({ @@ -20,12 +19,13 @@ class StorageIndicator extends HookConsumerWidget { void switchChanged(bool value) { appSettingService.setSetting(AppSettingsEnum.storageIndicator, value); showStorageIndicator.value = value; - ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure(); + ref.invalidate(appSettingsServiceProvider); } useEffect( () { - showStorageIndicator.value = appSettingService.getSetting(AppSettingsEnum.storageIndicator); + showStorageIndicator.value = appSettingService + .getSetting(AppSettingsEnum.storageIndicator); return null; }, diff --git a/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart b/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart index 5e25dfce4f..eeb0f6c019 100644 --- a/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart +++ b/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/providers/asset.provider.dart'; class TilesPerRow extends HookConsumerWidget { const TilesPerRow({ @@ -20,10 +19,7 @@ class TilesPerRow extends HookConsumerWidget { void sliderChanged(double value) { appSettingService.setSetting(AppSettingsEnum.tilesPerRow, value.toInt()); itemsValue.value = value; - } - - void sliderChangedEnd(double _) { - ref.watch(assetProvider.notifier).rebuildAssetGridDataStructure(); + ref.invalidate(appSettingsServiceProvider); } useEffect( @@ -49,7 +45,6 @@ class TilesPerRow extends HookConsumerWidget { ).tr(args: ["${itemsValue.value.toInt()}"]), ), Slider( - onChangeEnd: sliderChangedEnd, onChanged: sliderChanged, value: itemsValue.value, min: 2, diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index b574068ceb..2a39ea6d7d 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -67,8 +67,9 @@ class _$AppRouter extends RootStackRouter { routeData: routeData, child: GalleryViewerPage( key: args.key, - assetList: args.assetList, - asset: args.asset, + initialIndex: args.initialIndex, + loadAsset: args.loadAsset, + totalAssets: args.totalAssets, ), ); }, @@ -150,18 +151,27 @@ class _$AppRouter extends RootStackRouter { ); }, AssetSelectionRoute.name: (routeData) { + final args = routeData.argsAs(); return CustomPage( routeData: routeData, - child: const AssetSelectionPage(), + child: AssetSelectionPage( + key: args.key, + existingAssets: args.existingAssets, + isNewAlbum: args.isNewAlbum, + ), transitionsBuilder: TransitionsBuilders.slideBottom, opaque: true, barrierDismissible: false, ); }, SelectUserForSharingRoute.name: (routeData) { + final args = routeData.argsAs(); return CustomPage>( routeData: routeData, - child: const SelectUserForSharingPage(), + child: SelectUserForSharingPage( + key: args.key, + assets: args.assets, + ), transitionsBuilder: TransitionsBuilders.slideBottom, opaque: true, barrierDismissible: false, @@ -582,15 +592,17 @@ class TabControllerRoute extends PageRouteInfo { class GalleryViewerRoute extends PageRouteInfo { GalleryViewerRoute({ Key? key, - required List assetList, - required Asset asset, + required int initialIndex, + required Asset Function(int) loadAsset, + required int totalAssets, }) : super( GalleryViewerRoute.name, path: '/gallery-viewer-page', args: GalleryViewerRouteArgs( key: key, - assetList: assetList, - asset: asset, + initialIndex: initialIndex, + loadAsset: loadAsset, + totalAssets: totalAssets, ), ); @@ -600,19 +612,22 @@ class GalleryViewerRoute extends PageRouteInfo { class GalleryViewerRouteArgs { const GalleryViewerRouteArgs({ this.key, - required this.assetList, - required this.asset, + required this.initialIndex, + required this.loadAsset, + required this.totalAssets, }); final Key? key; - final List assetList; + final int initialIndex; - final Asset asset; + final Asset Function(int) loadAsset; + + final int totalAssets; @override String toString() { - return 'GalleryViewerRouteArgs{key: $key, assetList: $assetList, asset: $asset}'; + return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets}'; } } @@ -623,9 +638,9 @@ class VideoViewerRoute extends PageRouteInfo { Key? key, required Asset asset, required bool isMotionVideo, - required void Function() onVideoEnded, - void Function()? onPlaying, - void Function()? onPaused, + required dynamic onVideoEnded, + dynamic onPlaying, + dynamic onPaused, }) : super( VideoViewerRoute.name, path: '/video-viewer-page', @@ -658,11 +673,11 @@ class VideoViewerRouteArgs { final bool isMotionVideo; - final void Function() onVideoEnded; + final dynamic onVideoEnded; - final void Function()? onPlaying; + final dynamic onPlaying; - final void Function()? onPaused; + final dynamic onPaused; @override String toString() { @@ -829,28 +844,78 @@ class RecentlyAddedRoute extends PageRouteInfo { /// generated route for /// [AssetSelectionPage] -class AssetSelectionRoute extends PageRouteInfo { - const AssetSelectionRoute() - : super( +class AssetSelectionRoute extends PageRouteInfo { + AssetSelectionRoute({ + Key? key, + required Set existingAssets, + bool isNewAlbum = false, + }) : super( AssetSelectionRoute.name, path: '/asset-selection-page', + args: AssetSelectionRouteArgs( + key: key, + existingAssets: existingAssets, + isNewAlbum: isNewAlbum, + ), ); static const String name = 'AssetSelectionRoute'; } +class AssetSelectionRouteArgs { + const AssetSelectionRouteArgs({ + this.key, + required this.existingAssets, + this.isNewAlbum = false, + }); + + final Key? key; + + final Set existingAssets; + + final bool isNewAlbum; + + @override + String toString() { + return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}'; + } +} + /// generated route for /// [SelectUserForSharingPage] -class SelectUserForSharingRoute extends PageRouteInfo { - const SelectUserForSharingRoute() - : super( +class SelectUserForSharingRoute + extends PageRouteInfo { + SelectUserForSharingRoute({ + Key? key, + required Set assets, + }) : super( SelectUserForSharingRoute.name, path: '/select-user-for-sharing-page', + args: SelectUserForSharingRouteArgs( + key: key, + assets: assets, + ), ); static const String name = 'SelectUserForSharingRoute'; } +class SelectUserForSharingRouteArgs { + const SelectUserForSharingRouteArgs({ + this.key, + required this.assets, + }); + + final Key? key; + + final Set assets; + + @override + String toString() { + return 'SelectUserForSharingRouteArgs{key: $key, assets: $assets}'; + } +} + /// generated route for /// [AlbumViewerPage] class AlbumViewerRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/models/album.dart b/mobile/lib/shared/models/album.dart index 553c8f0548..508ab094aa 100644 --- a/mobile/lib/shared/models/album.dart +++ b/mobile/lib/shared/models/album.dart @@ -1,4 +1,5 @@ import 'package:flutter/cupertino.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/user.dart'; @@ -34,10 +35,10 @@ class Album { final IsarLinks sharedUsers = IsarLinks(); final IsarLinks assets = IsarLinks(); - List _sortedAssets = []; + RenderList _renderList = RenderList.empty(); @ignore - List get sortedAssets => _sortedAssets; + RenderList get renderList => _renderList; @ignore bool get isRemote => remoteId != null; @@ -69,8 +70,14 @@ class Album { return name.join(' '); } - Future loadSortedAssets() async { - _sortedAssets = await assets.filter().sortByFileCreatedAt().findAll(); + Stream watchRenderList(GroupAssetsBy groupAssetsBy) async* { + final query = assets.filter().sortByFileCreatedAt(); + _renderList = await RenderList.fromQuery(query, groupAssetsBy); + yield _renderList; + await for (final _ in query.watchLazy()) { + _renderList = await RenderList.fromQuery(query, groupAssetsBy); + yield _renderList; + } } @override diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index a2e23268c9..d8fedcd6b2 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -225,7 +225,6 @@ class Asset { a.isLocal && !isLocal || width == null && a.width != null || height == null && a.height != null || - exifInfo == null && a.exifInfo != null || livePhotoVideoId == null && a.livePhotoVideoId != null || !isRemote && a.isRemote && isFavorite != a.isFavorite || !isRemote && a.isRemote && isArchived != a.isArchived; diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart index c694168dbe..4609c0487c 100644 --- a/mobile/lib/shared/models/exif_info.dart +++ b/mobile/lib/shared/models/exif_info.dart @@ -114,6 +114,45 @@ class ExifInfo { country: country ?? this.country, description: description ?? this.description, ); + + @override + bool operator ==(other) { + if (other is! ExifInfo) return false; + return id == other.id && + fileSize == other.fileSize && + make == other.make && + model == other.model && + lens == other.lens && + f == other.f && + mm == other.mm && + iso == other.iso && + exposureSeconds == other.exposureSeconds && + lat == other.lat && + long == other.long && + city == other.city && + state == other.state && + country == other.country && + description == other.description; + } + + @override + @ignore + int get hashCode => + id.hashCode ^ + fileSize.hashCode ^ + make.hashCode ^ + model.hashCode ^ + lens.hashCode ^ + f.hashCode ^ + mm.hashCode ^ + iso.hashCode ^ + exposureSeconds.hashCode ^ + lat.hashCode ^ + long.hashCode ^ + city.hashCode ^ + state.hashCode ^ + country.hashCode ^ + description.hashCode; } double? _exposureTimeToSeconds(String? s) { diff --git a/mobile/lib/shared/models/store.dart b/mobile/lib/shared/models/store.dart index 201ffc5a2d..7a07e62719 100644 --- a/mobile/lib/shared/models/store.dart +++ b/mobile/lib/shared/models/store.dart @@ -35,6 +35,10 @@ class Store { return value; } + /// Watches a specific key for changes + static Stream watch(StoreKey key) => + _db.storeValues.watchObject(key.id).map((e) => e?._extract(key)); + /// Returns the stored value for the given key (possibly null) static T? tryGet(StoreKey key) => _cache[key.id]; diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 02eabded3a..4b422ecd7a 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -3,18 +3,14 @@ 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'; 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:collection/collection.dart'; import 'package:immich_mobile/shared/services/sync.service.dart'; -import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/db.dart'; -import 'package:intl/intl.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -22,72 +18,23 @@ import 'package:photo_manager/photo_manager.dart'; /// State does not contain archived assets. /// Use database provider if you want to access the isArchived assets -class AssetsState { - final List allAssets; - final RenderList? renderList; - - AssetsState(this.allAssets, {this.renderList}); - - Future withRenderDataStructure( - AssetGridLayoutParameters layout, - ) async { - return AssetsState( - allAssets, - renderList: await RenderList.fromAssets( - allAssets, - layout, - ), - ); - } - - AssetsState withAdditionalAssets(List toAdd) { - return AssetsState([...allAssets, ...toAdd]); - } - - static AssetsState fromAssetList(List assets) { - return AssetsState(assets); - } - - static AssetsState empty() { - return AssetsState([]); - } -} +class AssetsState {} class AssetNotifier extends StateNotifier { final AssetService _assetService; - final AppSettingsService _settingsService; final AlbumService _albumService; final SyncService _syncService; final Isar _db; final log = Logger('AssetNotifier'); bool _getAllAssetInProgress = false; bool _deleteInProgress = false; - final AsyncMutex _stateUpdateLock = AsyncMutex(); AssetNotifier( this._assetService, - this._settingsService, this._albumService, this._syncService, this._db, - ) : super(AssetsState.fromAssetList([])); - - Future _updateAssetsState(List newAssetList) async { - final layout = AssetGridLayoutParameters( - _settingsService.getSetting(AppSettingsEnum.tilesPerRow), - _settingsService.getSetting(AppSettingsEnum.dynamicLayout), - GroupAssetsBy - .values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)], - ); - - state = await AssetsState.fromAssetList(newAssetList) - .withRenderDataStructure(layout); - } - - // Just a little helper to trigger a rebuild of the state object - Future rebuildAssetGridDataStructure() async { - await _updateAssetsState(state.allAssets); - } + ) : super(AssetsState()); Future getAllAsset({bool clear = false}) async { if (_getAllAssetInProgress || _deleteInProgress) { @@ -97,79 +44,32 @@ class AssetNotifier extends StateNotifier { final stopwatch = Stopwatch()..start(); try { _getAllAssetInProgress = true; - final User me = Store.get(StoreKey.currentUser); if (clear) { await clearAssetsAndAlbums(_db); log.info("Manual refresh requested, cleared assets and albums from db"); - } else if (_stateUpdateLock.enqueued <= 1) { - final int cachedCount = await _userAssetQuery(me.isarId).count(); - if (cachedCount > 0 && cachedCount != state.allAssets.length) { - await _stateUpdateLock.run( - () async => _updateAssetsState(await _getUserAssets(me.isarId)), - ); - log.info( - "Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms", - ); - stopwatch.reset(); - } } final bool newRemote = await _assetService.refreshRemoteAssets(); final bool newLocal = await _albumService.refreshDeviceAlbums(); debugPrint("newRemote: $newRemote, newLocal: $newLocal"); log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms"); - stopwatch.reset(); - if (!newRemote && - !newLocal && - state.allAssets.length == await _userAssetQuery(me.isarId).count()) { - log.info("state is already up-to-date"); - return; - } - stopwatch.reset(); - if (_stateUpdateLock.enqueued <= 1) { - _stateUpdateLock.run(() async { - final assets = await _getUserAssets(me.isarId); - if (!const ListEquality().equals(assets, state.allAssets)) { - log.info("setting new asset state"); - await _updateAssetsState(assets); - } - }); - } } finally { _getAllAssetInProgress = false; } } - Future> _getUserAssets(int userId) => - _userAssetQuery(userId).sortByFileCreatedAtDesc().findAll(); - - QueryBuilder _userAssetQuery( - int userId, - ) => - _db.assets.filter().ownerIdEqualTo(userId).isArchivedEqualTo(false); - Future clearAllAsset() { - state = AssetsState.empty(); return clearAssetsAndAlbums(_db); } Future onNewAssetUploaded(Asset newAsset) async { - final bool ok = await _syncService.syncNewAssetToDb(newAsset); - if (ok && _stateUpdateLock.enqueued <= 1) { - // run this sequentially if there is at most 1 other task waiting - await _stateUpdateLock.run(() async { - final userId = Store.get(StoreKey.currentUser).isarId; - final assets = await _getUserAssets(userId); - await _updateAssetsState(assets); - }); - } + // eTag on device is not valid after partially modifying the assets + Store.delete(StoreKey.assetETag); + await _syncService.syncNewAssetToDb(newAsset); } Future deleteAssets(Set deleteAssets) async { _deleteInProgress = true; try { - _updateAssetsState( - state.allAssets.whereNot(deleteAssets.contains).toList(), - ); final localDeleted = await _deleteLocalAssets(deleteAssets); final remoteDeleted = await _deleteRemoteAssets(deleteAssets); if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) { @@ -201,7 +101,7 @@ class AssetNotifier extends StateNotifier { } if (local.isNotEmpty) { try { - await PhotoManager.editor.deleteWithIds(local); + return await PhotoManager.editor.deleteWithIds(local); } catch (e, stack) { log.severe("Failed to delete asset from device", e, stack); } @@ -220,53 +120,25 @@ class AssetNotifier extends StateNotifier { .map((a) => a.id); } - Future toggleFavorite(Asset asset, bool status) async { - final newAsset = await _assetService.changeFavoriteStatus(asset, status); - - if (newAsset == null) { - log.severe("Change favorite status failed for asset ${asset.id}"); - return asset.isFavorite; + Future toggleFavorite(List assets, bool status) async { + final newAssets = await _assetService.changeFavoriteStatus(assets, status); + for (Asset? newAsset in newAssets) { + if (newAsset == null) { + log.severe("Change favorite status failed for asset"); + continue; + } } - - final index = state.allAssets.indexWhere((a) => asset.id == a.id); - if (index != -1) { - state.allAssets[index] = newAsset; - _updateAssetsState(state.allAssets); - } - - return newAsset.isFavorite; } - Future toggleArchive(Iterable assets, bool status) async { - final newAssets = await Future.wait( - assets.map((a) => _assetService.changeArchiveStatus(a, status)), - ); + Future toggleArchive(List assets, bool status) async { + final newAssets = await _assetService.changeArchiveStatus(assets, status); int i = 0; - bool unArchived = false; for (Asset oldAsset in assets) { final newAsset = newAssets[i++]; if (newAsset == null) { log.severe("Change archive status failed for asset ${oldAsset.id}"); continue; } - final index = state.allAssets.indexWhere((a) => oldAsset.id == a.id); - if (newAsset.isArchived) { - // remove from state - if (index != -1) { - state.allAssets.removeAt(index); - } - } else { - // add to state is difficult because the list is sorted - unArchived = true; - } - } - if (unArchived) { - final User me = Store.get(StoreKey.currentUser); - await _stateUpdateLock.run( - () async => _updateAssetsState(await _getUserAssets(me.isarId)), - ); - } else { - _updateAssetsState(state.allAssets); } } } @@ -274,26 +146,53 @@ class AssetNotifier extends StateNotifier { final assetProvider = StateNotifierProvider((ref) { return AssetNotifier( ref.watch(assetServiceProvider), - ref.watch(appSettingsServiceProvider), ref.watch(albumServiceProvider), ref.watch(syncServiceProvider), ref.watch(dbProvider), ); }); -final assetGroupByMonthYearProvider = StateProvider((ref) { - // TODO: remove `where` once temporary workaround is no longer needed (to only - // allow remote assets to be added to album). Keep `toList()` as to NOT sort - // the original list/state - final assets = - ref.watch(assetProvider).allAssets.where((e) => e.isRemote).toList(); - - assets.sortByCompare( - (e) => e.fileCreatedAt, - (a, b) => b.compareTo(a), - ); - - return assets.groupListsBy( - (element) => DateFormat('MMMM, y').format(element.fileCreatedAt.toLocal()), - ); +final assetDetailProvider = + StreamProvider.autoDispose.family((ref, asset) async* { + yield await ref.watch(assetServiceProvider).loadExif(asset); + final db = ref.watch(dbProvider); + await for (final a in db.assets.watchObject(asset.id)) { + if (a != null) yield await ref.watch(assetServiceProvider).loadExif(a); + } +}); + +final assetsProvider = StreamProvider.autoDispose((ref) async* { + final query = ref + .watch(dbProvider) + .assets + .filter() + .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .isArchivedEqualTo(false) + .sortByFileCreatedAtDesc(); + final settings = ref.watch(appSettingsServiceProvider); + final groupBy = + GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; + yield await RenderList.fromQuery(query, groupBy); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, groupBy); + } +}); + +final remoteAssetsProvider = + StreamProvider.autoDispose((ref) async* { + final query = ref + .watch(dbProvider) + .assets + .where() + .remoteIdIsNotNull() + .filter() + .ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId) + .sortByFileCreatedAt(); + final settings = ref.watch(appSettingsServiceProvider); + final groupBy = + GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; + yield await RenderList.fromQuery(query, groupBy); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, groupBy); + } }); diff --git a/mobile/lib/shared/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart index 8ba88b48f4..f7eb2a066d 100644 --- a/mobile/lib/shared/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -97,15 +97,18 @@ class AssetService { /// the exif info from the server (remote assets only) Future loadExif(Asset a) async { a.exifInfo ??= await _db.exifInfos.get(a.id); - if (a.exifInfo?.iso == null) { + // fileSize is always filled on the server but not set on client + if (a.exifInfo?.fileSize == null) { if (a.isRemote) { final dto = await _apiService.assetApi.getAssetById(a.remoteId!); if (dto != null && dto.exifInfo != null) { - a.exifInfo = Asset.remote(dto).exifInfo!.copyWith(id: a.id); - if (a.isInDb) { - _db.writeTxn(() => a.put(_db)); - } else { - debugPrint("[loadExif] parameter Asset is not from DB!"); + final newExif = Asset.remote(dto).exifInfo!.copyWith(id: a.id); + if (newExif != a.exifInfo) { + if (a.isInDb) { + _db.writeTxn(() => a.put(_db)); + } else { + debugPrint("[loadExif] parameter Asset is not from DB!"); + } } } } else { @@ -115,27 +118,39 @@ class AssetService { return a; } - Future updateAsset( - Asset asset, + Future> updateAssets( + List assets, UpdateAssetDto updateAssetDto, ) async { - final dto = - await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto); - if (dto != null) { - final updated = asset.updatedCopy(Asset.remote(dto)); - if (updated.isInDb) { - await _db.writeTxn(() => updated.put(_db)); + final List dtos = await Future.wait( + assets.map( + (a) => _apiService.assetApi.updateAsset(a.remoteId!, updateAssetDto), + ), + ); + bool allInDb = true; + for (int i = 0; i < assets.length; i++) { + final dto = dtos[i], old = assets[i]; + if (dto != null) { + final remote = Asset.remote(dto); + if (old.canUpdate(remote)) { + assets[i] = old.updatedCopy(remote); + } + allInDb &= assets[i].isInDb; } - return updated; } - return null; + final toUpdate = allInDb ? assets : assets.where((e) => e.isInDb).toList(); + await _syncService.upsertAssetsWithExif(toUpdate); + return assets; } - Future changeFavoriteStatus(Asset asset, bool isFavorite) { - return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite)); + Future> changeFavoriteStatus( + List assets, + bool isFavorite, + ) { + return updateAssets(assets, UpdateAssetDto(isFavorite: isFavorite)); } - Future changeArchiveStatus(Asset asset, bool isArchive) { - return updateAsset(asset, UpdateAssetDto(isArchived: isArchive)); + Future> changeArchiveStatus(List assets, bool isArchive) { + return updateAssets(assets, UpdateAssetDto(isArchived: isArchive)); } } diff --git a/mobile/lib/shared/services/sync.service.dart b/mobile/lib/shared/services/sync.service.dart index 664045c2fe..efd0954ee5 100644 --- a/mobile/lib/shared/services/sync.service.dart +++ b/mobile/lib/shared/services/sync.service.dart @@ -172,7 +172,7 @@ class SyncService { final idsToDelete = diff.third.map((e) => e.id).toList(); try { await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete)); - await _upsertAssetsWithExif(diff.first + diff.second); + await upsertAssetsWithExif(diff.first + diff.second); } on IsarError catch (e) { _log.severe("Failed to sync remote assets to db: $e"); } @@ -272,7 +272,7 @@ class SyncService { // for shared album: put missing album assets into local DB final resultPair = await _linkWithExistingFromDb(toAdd); - await _upsertAssetsWithExif(resultPair.second); + await upsertAssetsWithExif(resultPair.second); final assetsToLink = resultPair.first + resultPair.second; final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast(); @@ -329,7 +329,7 @@ class SyncService { // put missing album assets into local DB final result = await _linkWithExistingFromDb(dto.getAssets()); existing.addAll(result.first); - await _upsertAssetsWithExif(result.second); + await upsertAssetsWithExif(result.second); final Album a = await Album.remote(dto); await _db.writeTxn(() => _db.albums.store(a)); @@ -540,7 +540,7 @@ class SyncService { _log.info( "${result.first.length} assets already existed in DB, to upsert ${result.second.length}", ); - await _upsertAssetsWithExif(result.second); + await upsertAssetsWithExif(result.second); existing.addAll(result.first); a.assets.addAll(result.first); a.assets.addAll(result.second); @@ -600,7 +600,7 @@ class SyncService { } /// Inserts or updates the assets in the database with their ExifInfo (if any) - Future _upsertAssetsWithExif(List assets) async { + Future upsertAssetsWithExif(List assets) async { if (assets.isEmpty) { return; } diff --git a/mobile/lib/shared/ui/drag_sheet.dart b/mobile/lib/shared/ui/drag_sheet.dart index 1b57a1909e..574962cc06 100644 --- a/mobile/lib/shared/ui/drag_sheet.dart +++ b/mobile/lib/shared/ui/drag_sheet.dart @@ -21,19 +21,19 @@ class ControlBoxButton extends StatelessWidget { Key? key, required this.label, required this.iconData, - required this.onPressed, + this.onPressed, }) : super(key: key); final String label; final IconData iconData; - final Function onPressed; + final void Function()? onPressed; @override Widget build(BuildContext context) { return MaterialButton( padding: const EdgeInsets.all(10), shape: const CircleBorder(), - onPressed: () => onPressed(), + onPressed: onPressed, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/mobile/lib/utils/builtin_extensions.dart b/mobile/lib/utils/builtin_extensions.dart index 9be774c527..3a3a723dc1 100644 --- a/mobile/lib/utils/builtin_extensions.dart +++ b/mobile/lib/utils/builtin_extensions.dart @@ -1,3 +1,5 @@ +import 'package:collection/collection.dart'; + extension DurationExtension on String { Duration? toDuration() { try { @@ -34,4 +36,12 @@ extension ListExtension on List { length = length == 0 ? 0 : j; return this; } + + ListSlice nestedSlice(int start, int end) { + if (this is ListSlice) { + final ListSlice self = this as ListSlice; + return ListSlice(self.source, self.start + start, self.start + end); + } + return ListSlice(this, start, end); + } } diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index 875abe1fdd..78c553f5a8 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -60,11 +60,7 @@ void main() { test('test grouped check months', () async { final renderList = await RenderList.fromAssets( assets, - AssetGridLayoutParameters( - 3, - false, - GroupAssetsBy.day, - ), + GroupAssetsBy.day, ); // Oct @@ -78,32 +74,33 @@ void main() { // 5 Assets => 2 Rows // Day 1 // 5 Assets => 2 Rows - expect(renderList.elements.length, 18); + expect(renderList.elements.length, 4); expect( renderList.elements[0].type, RenderAssetGridElementType.monthTitle, ); - expect(renderList.elements[0].date.month, 10); + expect(renderList.elements[0].date.month, 1); expect( - renderList.elements[7].type, + renderList.elements[1].type, + RenderAssetGridElementType.groupDividerTitle, + ); + expect(renderList.elements[1].date.month, 1); + expect( + renderList.elements[2].type, RenderAssetGridElementType.monthTitle, ); - expect(renderList.elements[7].date.month, 2); + expect(renderList.elements[2].date.month, 2); expect( - renderList.elements[11].type, + renderList.elements[3].type, RenderAssetGridElementType.monthTitle, ); - expect(renderList.elements[11].date.month, 1); + expect(renderList.elements[3].date.month, 10); }); test('test grouped check types', () async { final renderList = await RenderList.fromAssets( assets, - AssetGridLayoutParameters( - 5, - false, - GroupAssetsBy.day, - ), + GroupAssetsBy.day, ); // Oct @@ -120,17 +117,8 @@ void main() { final types = [ RenderAssetGridElementType.monthTitle, RenderAssetGridElementType.groupDividerTitle, - RenderAssetGridElementType.assetRow, - RenderAssetGridElementType.assetRow, - RenderAssetGridElementType.assetRow, RenderAssetGridElementType.monthTitle, - RenderAssetGridElementType.groupDividerTitle, - RenderAssetGridElementType.assetRow, RenderAssetGridElementType.monthTitle, - RenderAssetGridElementType.groupDividerTitle, - RenderAssetGridElementType.assetRow, - RenderAssetGridElementType.groupDividerTitle, - RenderAssetGridElementType.assetRow, ]; expect(renderList.elements.length, types.length); diff --git a/mobile/test/favorite_provider_test.dart b/mobile/test/favorite_provider_test.dart deleted file mode 100644 index 505187a7d7..0000000000 --- a/mobile/test/favorite_provider_test.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/providers/asset.provider.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -@GenerateNiceMocks([ - MockSpec(), - MockSpec(), -]) -import 'favorite_provider_test.mocks.dart'; - -Asset _getTestAsset(int id, bool favorite) { - final Asset a = Asset( - remoteId: id.toString(), - localId: id.toString(), - deviceId: 1, - ownerId: 1, - fileCreatedAt: DateTime.now(), - fileModifiedAt: DateTime.now(), - updatedAt: DateTime.now(), - isLocal: false, - durationInSeconds: 0, - type: AssetType.image, - fileName: '', - isFavorite: favorite, - isArchived: false, - ); - a.id = id; - return a; -} - -void main() { - group("Test favoriteProvider", () { - late MockAssetsState assetsState; - late MockAssetNotifier assetNotifier; - late ProviderContainer container; - late StateNotifierProvider> - testFavoritesProvider; - - setUp( - () { - assetsState = MockAssetsState(); - assetNotifier = MockAssetNotifier(); - container = ProviderContainer(); - - testFavoritesProvider = - StateNotifierProvider>((ref) { - return FavoriteSelectionNotifier( - assetsState, - assetNotifier, - ); - }); - }, - ); - - test("Empty favorites provider", () { - when(assetsState.allAssets).thenReturn([]); - expect({}, container.read(testFavoritesProvider)); - }); - - test("Non-empty favorites provider", () { - when(assetsState.allAssets).thenReturn([ - _getTestAsset(1, false), - _getTestAsset(2, true), - _getTestAsset(3, false), - _getTestAsset(4, false), - _getTestAsset(5, true), - ]); - - expect({2, 5}, container.read(testFavoritesProvider)); - }); - - test("Toggle favorite", () { - when(assetNotifier.toggleFavorite(null, false)) - .thenAnswer((_) async => false); - - final testAsset1 = _getTestAsset(1, false); - final testAsset2 = _getTestAsset(2, true); - - when(assetsState.allAssets).thenReturn([testAsset1, testAsset2]); - - expect({2}, container.read(testFavoritesProvider)); - - container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset2); - expect({}, container.read(testFavoritesProvider)); - - container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset1); - expect({1}, container.read(testFavoritesProvider)); - }); - - test("Add favorites", () { - when(assetNotifier.toggleFavorite(null, false)) - .thenAnswer((_) async => false); - - when(assetsState.allAssets).thenReturn([]); - - expect({}, container.read(testFavoritesProvider)); - - container.read(testFavoritesProvider.notifier).addToFavorites( - [ - _getTestAsset(1, false), - _getTestAsset(2, false), - ], - ); - - expect({1, 2}, container.read(testFavoritesProvider)); - }); - }); -} diff --git a/mobile/test/favorite_provider_test.mocks.dart b/mobile/test/favorite_provider_test.mocks.dart deleted file mode 100644 index 569c12e791..0000000000 --- a/mobile/test/favorite_provider_test.mocks.dart +++ /dev/null @@ -1,298 +0,0 @@ -// Mocks generated by Mockito 5.3.2 from annotations -// in immich_mobile/test/favorite_provider_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; - -import 'package:hooks_riverpod/hooks_riverpod.dart' as _i7; -import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart' - as _i6; -import 'package:immich_mobile/shared/models/asset.dart' as _i4; -import 'package:immich_mobile/shared/providers/asset.provider.dart' as _i2; -import 'package:logging/logging.dart' as _i3; -import 'package:mockito/mockito.dart' as _i1; -import 'package:state_notifier/state_notifier.dart' as _i8; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -class _FakeAssetsState_0 extends _i1.SmartFake implements _i2.AssetsState { - _FakeAssetsState_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -class _FakeLogger_1 extends _i1.SmartFake implements _i3.Logger { - _FakeLogger_1( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - -/// A class which mocks [AssetsState]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockAssetsState extends _i1.Mock implements _i2.AssetsState { - @override - List<_i4.Asset> get allAssets => (super.noSuchMethod( - Invocation.getter(#allAssets), - returnValue: <_i4.Asset>[], - returnValueForMissingStub: <_i4.Asset>[], - ) as List<_i4.Asset>); - @override - _i5.Future<_i2.AssetsState> withRenderDataStructure( - _i6.AssetGridLayoutParameters? layout) => - (super.noSuchMethod( - Invocation.method( - #withRenderDataStructure, - [layout], - ), - returnValue: _i5.Future<_i2.AssetsState>.value(_FakeAssetsState_0( - this, - Invocation.method( - #withRenderDataStructure, - [layout], - ), - )), - returnValueForMissingStub: - _i5.Future<_i2.AssetsState>.value(_FakeAssetsState_0( - this, - Invocation.method( - #withRenderDataStructure, - [layout], - ), - )), - ) as _i5.Future<_i2.AssetsState>); - @override - _i2.AssetsState withAdditionalAssets(List<_i4.Asset>? toAdd) => - (super.noSuchMethod( - Invocation.method( - #withAdditionalAssets, - [toAdd], - ), - returnValue: _FakeAssetsState_0( - this, - Invocation.method( - #withAdditionalAssets, - [toAdd], - ), - ), - returnValueForMissingStub: _FakeAssetsState_0( - this, - Invocation.method( - #withAdditionalAssets, - [toAdd], - ), - ), - ) as _i2.AssetsState); -} - -/// A class which mocks [AssetNotifier]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier { - @override - _i3.Logger get log => (super.noSuchMethod( - Invocation.getter(#log), - returnValue: _FakeLogger_1( - this, - Invocation.getter(#log), - ), - returnValueForMissingStub: _FakeLogger_1( - this, - Invocation.getter(#log), - ), - ) as _i3.Logger); - @override - set onError(_i7.ErrorListener? _onError) => super.noSuchMethod( - Invocation.setter( - #onError, - _onError, - ), - returnValueForMissingStub: null, - ); - @override - bool get mounted => (super.noSuchMethod( - Invocation.getter(#mounted), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - @override - _i5.Stream<_i2.AssetsState> get stream => (super.noSuchMethod( - Invocation.getter(#stream), - returnValue: _i5.Stream<_i2.AssetsState>.empty(), - returnValueForMissingStub: _i5.Stream<_i2.AssetsState>.empty(), - ) as _i5.Stream<_i2.AssetsState>); - @override - _i2.AssetsState get state => (super.noSuchMethod( - Invocation.getter(#state), - returnValue: _FakeAssetsState_0( - this, - Invocation.getter(#state), - ), - returnValueForMissingStub: _FakeAssetsState_0( - this, - Invocation.getter(#state), - ), - ) as _i2.AssetsState); - @override - set state(_i2.AssetsState? value) => super.noSuchMethod( - Invocation.setter( - #state, - value, - ), - returnValueForMissingStub: null, - ); - @override - _i2.AssetsState get debugState => (super.noSuchMethod( - Invocation.getter(#debugState), - returnValue: _FakeAssetsState_0( - this, - Invocation.getter(#debugState), - ), - returnValueForMissingStub: _FakeAssetsState_0( - this, - Invocation.getter(#debugState), - ), - ) as _i2.AssetsState); - @override - bool get hasListeners => (super.noSuchMethod( - Invocation.getter(#hasListeners), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - @override - _i5.Future rebuildAssetGridDataStructure() => (super.noSuchMethod( - Invocation.method( - #rebuildAssetGridDataStructure, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - @override - _i5.Future getAllAsset({bool? clear = false}) => (super.noSuchMethod( - Invocation.method( - #getAllAsset, - [], - {#clear: clear}, - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - @override - _i5.Future clearAllAsset() => (super.noSuchMethod( - Invocation.method( - #clearAllAsset, - [], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - @override - _i5.Future onNewAssetUploaded(_i4.Asset? newAsset) => - (super.noSuchMethod( - Invocation.method( - #onNewAssetUploaded, - [newAsset], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - @override - _i5.Future deleteAssets(Set<_i4.Asset>? deleteAssets) => - (super.noSuchMethod( - Invocation.method( - #deleteAssets, - [deleteAssets], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - @override - _i5.Future toggleFavorite( - _i4.Asset? asset, - bool? status, - ) => - (super.noSuchMethod( - Invocation.method( - #toggleFavorite, - [ - asset, - status, - ], - ), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), - ) as _i5.Future); - @override - _i5.Future toggleArchive( - Iterable<_i4.Asset>? assets, - bool? status, - ) => - (super.noSuchMethod( - Invocation.method( - #toggleArchive, - [ - assets, - status, - ], - ), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), - ) as _i5.Future); - @override - bool updateShouldNotify( - _i2.AssetsState? old, - _i2.AssetsState? current, - ) => - (super.noSuchMethod( - Invocation.method( - #updateShouldNotify, - [ - old, - current, - ], - ), - returnValue: false, - returnValueForMissingStub: false, - ) as bool); - @override - _i7.RemoveListener addListener( - _i8.Listener<_i2.AssetsState>? listener, { - bool? fireImmediately = true, - }) => - (super.noSuchMethod( - Invocation.method( - #addListener, - [listener], - {#fireImmediately: fireImmediately}, - ), - returnValue: () {}, - returnValueForMissingStub: () {}, - ) as _i7.RemoveListener); - @override - void dispose() => super.noSuchMethod( - Invocation.method( - #dispose, - [], - ), - returnValueForMissingStub: null, - ); -}