You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat(mobile): lazy loading of assets (#2413)
This commit is contained in:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							93863b0629
						
					
				
				
					commit
					0dde76bbbc
				
			
							
								
								
									
										2
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/test.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -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" | ||||
| } | ||||
| } | ||||
| @@ -2,47 +2,20 @@ import 'package:collection/collection.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| class AssetSelectionPageResult { | ||||
|   final Set<Asset> selectedNewAsset; | ||||
|   final Set<Asset> selectedAdditionalAsset; | ||||
|   final bool isAlbumExist; | ||||
|   final Set<Asset> selectedAssets; | ||||
|  | ||||
|   AssetSelectionPageResult({ | ||||
|     required this.selectedNewAsset, | ||||
|     required this.selectedAdditionalAsset, | ||||
|     required this.isAlbumExist, | ||||
|     required this.selectedAssets, | ||||
|   }); | ||||
|  | ||||
|   AssetSelectionPageResult copyWith({ | ||||
|     Set<Asset>? selectedNewAsset, | ||||
|     Set<Asset>? 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; | ||||
| } | ||||
|   | ||||
| @@ -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<List<Album>> { | ||||
|   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<List<Album>> _streamSub; | ||||
|  | ||||
|   Future<void> getAllAlbums() async { | ||||
|     final User me = Store.get(StoreKey.currentUser); | ||||
|     List<Album> 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<void> getAllAlbums() => Future.wait([ | ||||
|         _albumService.refreshDeviceAlbums(), | ||||
|         _albumService.refreshRemoteAlbums(isShared: false), | ||||
|       ]); | ||||
|  | ||||
|   Future<bool> deleteAlbum(Album album) async { | ||||
|     state = state.where((a) => a.id != album.id).toList(); | ||||
|     return _albumService.deleteAlbum(album); | ||||
|   } | ||||
|   Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album); | ||||
|  | ||||
|   Future<Album?> createAlbum( | ||||
|     String albumTitle, | ||||
|     Set<Asset> 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<AlbumNotifier, List<Album>>((ref) { | ||||
| final albumProvider = | ||||
|     StateNotifierProvider.autoDispose<AlbumNotifier, List<Album>>((ref) { | ||||
|   return AlbumNotifier( | ||||
|     ref.watch(albumServiceProvider), | ||||
|     ref.watch(dbProvider), | ||||
|   | ||||
| @@ -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<AssetSelectionState> { | ||||
|   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<Asset> assetsInMonth, | ||||
|   ) { | ||||
|     Set<Asset> currentAssetList = state.selectedNewAssetsForAlbum; | ||||
|     Set<String> 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<Asset> assets) { | ||||
|     state = state.copyWith( | ||||
|       selectedAdditionalAssetsForAlbum: { | ||||
|         ...state.selectedAdditionalAssetsForAlbum, | ||||
|         ...assets | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void addAllAssetsInMonth(String month, List<Asset> assetsInMonth) { | ||||
|     state = state.copyWith( | ||||
|       selectedMonths: {...state.selectedMonths, month}, | ||||
|       selectedNewAssetsForAlbum: { | ||||
|         ...state.selectedNewAssetsForAlbum, | ||||
|         ...assetsInMonth | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void addNewAssets(Iterable<Asset> assets) { | ||||
|     state = state.copyWith( | ||||
|       selectedNewAssetsForAlbum: { | ||||
|         ...state.selectedNewAssetsForAlbum, | ||||
|         ...assets | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void removeSelectedNewAssets(List<Asset> assets) { | ||||
|     Set<Asset> currentList = state.selectedNewAssetsForAlbum; | ||||
|  | ||||
|     for (Asset asset in assets) { | ||||
|       currentList.removeWhere((e) => e.id == asset.id); | ||||
|     } | ||||
|  | ||||
|     state = state.copyWith(selectedNewAssetsForAlbum: currentList); | ||||
|   } | ||||
|  | ||||
|   void removeSelectedAdditionalAssets(List<Asset> assets) { | ||||
|     Set<Asset> 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<Asset> assets) { | ||||
|     state = state.copyWith( | ||||
|       selectedAssetsInAlbumViewer: { | ||||
|         ...state.selectedAssetsInAlbumViewer, | ||||
|         ...assets | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void removeAssetsInAlbumViewer(List<Asset> assets) { | ||||
|     Set<Asset> currentList = state.selectedAssetsInAlbumViewer; | ||||
|  | ||||
|     for (Asset asset in assets) { | ||||
|       currentList.removeWhere((e) => e.id == asset.id); | ||||
|     } | ||||
|  | ||||
|     state = state.copyWith(selectedAssetsInAlbumViewer: currentList); | ||||
|   } | ||||
| } | ||||
|  | ||||
| final assetSelectionProvider = | ||||
|     StateNotifierProvider<AssetSelectionNotifier, AssetSelectionState>((ref) { | ||||
|   return AssetSelectionNotifier(); | ||||
| }); | ||||
| @@ -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<List<Album>> { | ||||
|   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<List<Album>> _streamSub; | ||||
|  | ||||
|   Future<Album?> createSharedAlbum( | ||||
|     String albumName, | ||||
| @@ -20,46 +26,21 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> { | ||||
|     Iterable<User> 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<void> 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<void> getAllSharedAlbums() => | ||||
|       _albumService.refreshRemoteAlbums(isShared: true); | ||||
|  | ||||
|   Future<bool> deleteAlbum(Album album) { | ||||
|     state = state.where((a) => a.id != album.id).toList(); | ||||
|     return _albumService.deleteAlbum(album); | ||||
|   } | ||||
|   Future<bool> deleteAlbum(Album album) => _albumService.deleteAlbum(album); | ||||
|  | ||||
|   Future<bool> leaveAlbum(Album album) async { | ||||
|     var res = await _albumService.leaveAlbum(album); | ||||
| @@ -75,10 +56,16 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> { | ||||
|   Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) { | ||||
|     return _albumService.removeAssetFromAlbum(album, assets); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _streamSub.cancel(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| final sharedAlbumProvider = | ||||
|     StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) { | ||||
|     StateNotifierProvider.autoDispose<SharedAlbumNotifier, List<Album>>((ref) { | ||||
|   return SharedAlbumNotifier( | ||||
|     ref.watch(albumServiceProvider), | ||||
|     ref.watch(dbProvider), | ||||
| @@ -86,10 +73,15 @@ final sharedAlbumProvider = | ||||
| }); | ||||
|  | ||||
| final sharedAlbumDetailProvider = | ||||
|     FutureProvider.autoDispose.family<Album?, int>((ref, albumId) async { | ||||
|     StreamProvider.autoDispose.family<Album, int>((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; | ||||
|     } | ||||
|   } | ||||
| }); | ||||
|   | ||||
| @@ -214,8 +214,9 @@ class AlbumService { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Future<Album?> getAlbumDetail(int albumId) { | ||||
|     return _db.albums.get(albumId); | ||||
|   Stream<Album?> watchAlbum(int albumId) async* { | ||||
|     yield await _db.albums.get(albumId); | ||||
|     yield* _db.albums.watchObject(albumId); | ||||
|   } | ||||
|  | ||||
|   Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum( | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -9,12 +9,14 @@ class AddToAlbumSliverList extends HookConsumerWidget { | ||||
|   final List<Album> albums; | ||||
|   final List<Album> 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) : () {}, | ||||
|         ); | ||||
|       }), | ||||
|     ); | ||||
|   | ||||
| @@ -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<Asset> 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) | ||||
|   | ||||
| @@ -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<Asset> 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(), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -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<Asset> 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, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -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<Asset> 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, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -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, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -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<Set<Asset>>({}); | ||||
|     final multiSelectEnabled = useState(false); | ||||
|     bool? isTop; | ||||
|  | ||||
|     Future<bool> onWillPop() async { | ||||
|       if (multiSelectEnabled.value) { | ||||
|         selection.value = {}; | ||||
|         multiSelectEnabled.value = false; | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     void selectionListener(bool active, Set<Asset> 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<AssetSelectionPageResult?>(const AssetSelectionRoute()); | ||||
|       AssetSelectionPageResult? returnPayload = | ||||
|           await AutoRouter.of(context).push<AssetSelectionPageResult?>( | ||||
|         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<bool> 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(), | ||||
|         ), | ||||
|   | ||||
| @@ -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<Asset> 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<Widget> imageGridGroup = []; | ||||
|     final renderList = ref.watch(remoteAssetsProvider); | ||||
|     final selected = useState<Set<Asset>>(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<AssetSelectionPageResult>(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()), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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<Set<Asset>>(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<bool?>(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<AssetSelectionPageResult?>(const AssetSelectionRoute()); | ||||
|  | ||||
|       AssetSelectionPageResult? selectedAsset = | ||||
|           await AutoRouter.of(context).push<AssetSelectionPageResult?>( | ||||
|         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(), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|   | ||||
| @@ -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<Asset> 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()])); | ||||
|       } | ||||
|   | ||||
| @@ -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<Set<int>> { | ||||
|   ArchiveSelectionNotifier(this.db, this.assetNotifier) : super({}) { | ||||
|     state = db.assets | ||||
|         .filter() | ||||
|         .isArchivedEqualTo(true) | ||||
|         .findAllSync() | ||||
|         .map((e) => e.id) | ||||
|         .toSet(); | ||||
| final archiveProvider = StreamProvider<RenderList>((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<void> 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<void> addToArchives(Iterable<Asset> assets) { | ||||
|     state = state.union(assets.map((a) => a.id).toSet()); | ||||
|     return assetNotifier.toggleArchive(assets, true); | ||||
|   } | ||||
| } | ||||
|  | ||||
| final archiveProvider = | ||||
|     StateNotifierProvider<ArchiveSelectionNotifier, Set<int>>((ref) { | ||||
|   return ArchiveSelectionNotifier( | ||||
|     ref.watch(dbProvider), | ||||
|     ref.watch(assetProvider.notifier), | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -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<List<Asset>>([]); | ||||
|     final archivedAssets = ref.watch(archiveProvider); | ||||
|     final selectionEnabledHook = useState(false); | ||||
|     final selection = useState(<Asset>{}); | ||||
|  | ||||
|     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()) | ||||
|                 ], | ||||
|               ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +0,0 @@ | ||||
| class RequestDownloadAssetInfo { | ||||
|   final String assetId; | ||||
|   final String deviceId; | ||||
|  | ||||
|   RequestDownloadAssetInfo(this.assetId, this.deviceId); | ||||
| } | ||||
| @@ -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<RenderList, List<Asset>>((ref, assets) { | ||||
|   var settings = ref.watch(appSettingsServiceProvider); | ||||
| final renderListProvider = | ||||
|     FutureProvider.family<RenderList, List<Asset>>((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); | ||||
| }); | ||||
|   | ||||
| @@ -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], | ||||
|   | ||||
| @@ -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<Asset> 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<bool>(false); | ||||
|     final showAppBar = useState<bool>(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<bool>(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) { | ||||
|   | ||||
| @@ -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<Set<int>> { | ||||
|   FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) { | ||||
|     state = assetsState.allAssets | ||||
|         .where((asset) => asset.isFavorite) | ||||
|         .map((asset) => asset.id) | ||||
|         .toSet(); | ||||
| final favoriteAssetsProvider = StreamProvider<RenderList>((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<void> 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<void> addToFavorites(Iterable<Asset> 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<FavoriteSelectionNotifier, Set<int>>((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(); | ||||
| }); | ||||
|   | ||||
| @@ -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<Asset> 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, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -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(<Asset>{}); | ||||
|     final processing = useState(false); | ||||
|  | ||||
|     void selectionListener( | ||||
|       bool multiselect, | ||||
|       Set<Asset> 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() | ||||
|                     ], | ||||
|                   ), | ||||
|           ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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<Asset> assets; | ||||
|   final List<double> widthDistribution; | ||||
|  | ||||
|   RenderAssetGridRow(this.assets, this.widthDistribution); | ||||
| } | ||||
|  | ||||
| class RenderAssetGridElement { | ||||
|   final RenderAssetGridElementType type; | ||||
|   final RenderAssetGridRow? assetRow; | ||||
|   final String? title; | ||||
|   final DateTime date; | ||||
|   final List<Asset>? 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<Asset> assets; | ||||
|   final AssetGridLayoutParameters layout; | ||||
|  | ||||
|   _AssetGroupsToRenderListComputeParameters( | ||||
|     this.assets, | ||||
|     this.layout, | ||||
|   ); | ||||
|   month, | ||||
|   auto, | ||||
|   none, | ||||
|   ; | ||||
| } | ||||
|  | ||||
| class RenderList { | ||||
|   final List<RenderAssetGridElement> elements; | ||||
|   final List<Asset>? allAssets; | ||||
|   final QueryBuilder<Asset, Asset, QAfterSortBy>? query; | ||||
|   final int totalAssets; | ||||
|  | ||||
|   RenderList(this.elements); | ||||
|   /// reference to batch of assets loaded from DB with offset [_bufOffset] | ||||
|   List<Asset> _buf = []; | ||||
|  | ||||
|   static Map<DateTime, List<Asset>> _groupAssets( | ||||
|     List<Asset> 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<Asset> 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<RenderList> _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<RenderList> fromQuery( | ||||
|     QueryBuilder<Asset, Asset, QAfterSortBy> query, | ||||
|     GroupAssetsBy groupBy, | ||||
|   ) => | ||||
|       _buildRenderList(null, query, groupBy); | ||||
|  | ||||
|   static Future<RenderList> _buildRenderList( | ||||
|     List<Asset>? assets, | ||||
|     QueryBuilder<Asset, Asset, QAfterSortBy>? 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<RenderAssetGridElement> elements = []; | ||||
|  | ||||
|     List<RenderAssetGridElement> 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<RenderList> fromAssets( | ||||
|     List<Asset> 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); | ||||
| } | ||||
|   | ||||
| @@ -396,8 +396,8 @@ class DraggableScrollbarState extends State<DraggableScrollbar> | ||||
|           widget.scrollStateListener(true); | ||||
|  | ||||
|           dragHaltTimer = Timer( | ||||
|             const Duration(milliseconds: 200), | ||||
|                 () { | ||||
|             const Duration(milliseconds: 500), | ||||
|             () { | ||||
|               widget.scrollStateListener(false); | ||||
|             }, | ||||
|           ); | ||||
|   | ||||
| @@ -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, | ||||
|       ), | ||||
|   | ||||
| @@ -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<Asset> assets; | ||||
|   final List<Asset>? assets; | ||||
|   final RenderList? renderList; | ||||
|   final Future<void> Function()? onRefresh; | ||||
|   final Set<Asset>? 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(), | ||||
|   | ||||
| @@ -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<ImmichAssetGridView> { | ||||
|       ItemPositionsListener.create(); | ||||
|  | ||||
|   bool _scrolling = false; | ||||
|   final Set<int> _selectedAssets = HashSet(); | ||||
|   final Set<Asset> _selectedAssets = | ||||
|       HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); | ||||
|  | ||||
|   Set<Asset> _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<ImmichAssetGridView> { | ||||
|  | ||||
|   void _selectAssets(List<Asset> assets) { | ||||
|     setState(() { | ||||
|       for (var e in assets) { | ||||
|         _selectedAssets.add(e.id); | ||||
|       } | ||||
|       _selectedAssets.addAll(assets); | ||||
|       _callSelectionListener(true); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   void _deselectAssets(List<Asset> 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<ImmichAssetGridView> { | ||||
|   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<Asset> 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<Asset> 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<ImmichAssetGridView> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   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<ImmichAssetGridView> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   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<Asset> 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<ImmichAssetGridView> { | ||||
|   } | ||||
|  | ||||
|   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<ImmichAssetGridView> { | ||||
|       setState(() { | ||||
|         _selectedAssets.clear(); | ||||
|       }); | ||||
|     } else if (widget.preselectedAssets != null) { | ||||
|       setState(() { | ||||
|         _selectedAssets.addAll(widget.preselectedAssets!); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -241,14 +333,33 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> { | ||||
|   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<ImmichAssetGridView> { | ||||
|       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<Asset> allAssets; | ||||
|   final Future<void> Function()? onRefresh; | ||||
|   final Set<Asset>? 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 | ||||
|   | ||||
| @@ -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<Asset> 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, | ||||
|   | ||||
| @@ -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<Album> albums; | ||||
|   final List<Album> 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) { | ||||
|   | ||||
| @@ -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<Asset> remoteOnlySelection({String? localErrorMessage}) { | ||||
|       List<Asset> remoteOnlySelection({String? localErrorMessage}) { | ||||
|         final Set<Asset> 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<Asset> 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<Asset> 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<Asset> 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<Asset> 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<void> 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()) | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|   | ||||
| @@ -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<AuthenticationState> { | ||||
|   AuthenticationNotifier( | ||||
|     this._apiService, | ||||
|     this._db, | ||||
|   ) : super( | ||||
|           AuthenticationState( | ||||
|             deviceId: "", | ||||
| @@ -31,6 +35,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
|         ); | ||||
|  | ||||
|   final ApiService _apiService; | ||||
|   final Isar _db; | ||||
|  | ||||
|   Future<bool> login( | ||||
|     String email, | ||||
| @@ -91,7 +96,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
|     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<AuthenticationNotifier, AuthenticationState>((ref) { | ||||
|   return AuthenticationNotifier( | ||||
|     ref.watch(apiServiceProvider), | ||||
|     ref.watch(dbProvider), | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -8,6 +8,8 @@ class SearchResultGrid extends HookConsumerWidget { | ||||
|  | ||||
|   final List<Asset> 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, | ||||
|         ); | ||||
|       }, | ||||
|   | ||||
| @@ -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<bool>(AppSettingsEnum.dynamicLayout); | ||||
|         groupBy.value = | ||||
|             GroupAssetsBy.values[appSettingService.getSetting<int>(AppSettingsEnum.groupAssetsBy)]; | ||||
|         groupBy.value = GroupAssetsBy.values[ | ||||
|             appSettingService.getSetting<int>(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, | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -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<bool>(AppSettingsEnum.storageIndicator); | ||||
|         showStorageIndicator.value = appSettingService | ||||
|             .getSetting<bool>(AppSettingsEnum.storageIndicator); | ||||
|  | ||||
|         return null; | ||||
|       }, | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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<AssetSelectionRouteArgs>(); | ||||
|       return CustomPage<AssetSelectionPageResult?>( | ||||
|         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<SelectUserForSharingRouteArgs>(); | ||||
|       return CustomPage<List<String>>( | ||||
|         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<void> { | ||||
| class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> { | ||||
|   GalleryViewerRoute({ | ||||
|     Key? key, | ||||
|     required List<Asset> 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<GalleryViewerRouteArgs> { | ||||
| 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<Asset> 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<VideoViewerRouteArgs> { | ||||
|     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<void> { | ||||
|  | ||||
| /// generated route for | ||||
| /// [AssetSelectionPage] | ||||
| class AssetSelectionRoute extends PageRouteInfo<void> { | ||||
|   const AssetSelectionRoute() | ||||
|       : super( | ||||
| class AssetSelectionRoute extends PageRouteInfo<AssetSelectionRouteArgs> { | ||||
|   AssetSelectionRoute({ | ||||
|     Key? key, | ||||
|     required Set<Asset> 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<Asset> existingAssets; | ||||
|  | ||||
|   final bool isNewAlbum; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [SelectUserForSharingPage] | ||||
| class SelectUserForSharingRoute extends PageRouteInfo<void> { | ||||
|   const SelectUserForSharingRoute() | ||||
|       : super( | ||||
| class SelectUserForSharingRoute | ||||
|     extends PageRouteInfo<SelectUserForSharingRouteArgs> { | ||||
|   SelectUserForSharingRoute({ | ||||
|     Key? key, | ||||
|     required Set<Asset> 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<Asset> assets; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'SelectUserForSharingRouteArgs{key: $key, assets: $assets}'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [AlbumViewerPage] | ||||
| class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> { | ||||
|   | ||||
| @@ -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<User> sharedUsers = IsarLinks<User>(); | ||||
|   final IsarLinks<Asset> assets = IsarLinks<Asset>(); | ||||
|  | ||||
|   List<Asset> _sortedAssets = []; | ||||
|   RenderList _renderList = RenderList.empty(); | ||||
|  | ||||
|   @ignore | ||||
|   List<Asset> get sortedAssets => _sortedAssets; | ||||
|   RenderList get renderList => _renderList; | ||||
|  | ||||
|   @ignore | ||||
|   bool get isRemote => remoteId != null; | ||||
| @@ -69,8 +70,14 @@ class Album { | ||||
|     return name.join(' '); | ||||
|   } | ||||
|  | ||||
|   Future<void> loadSortedAssets() async { | ||||
|     _sortedAssets = await assets.filter().sortByFileCreatedAt().findAll(); | ||||
|   Stream<void> 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 | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -35,6 +35,10 @@ class Store { | ||||
|     return value; | ||||
|   } | ||||
|  | ||||
|   /// Watches a specific key for changes | ||||
|   static Stream<T?> watch<T>(StoreKey<T> key) => | ||||
|       _db.storeValues.watchObject(key.id).map((e) => e?._extract(key)); | ||||
|  | ||||
|   /// Returns the stored value for the given key (possibly null) | ||||
|   static T? tryGet<T>(StoreKey<T> key) => _cache[key.id]; | ||||
|  | ||||
|   | ||||
| @@ -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<Asset> allAssets; | ||||
|   final RenderList? renderList; | ||||
|  | ||||
|   AssetsState(this.allAssets, {this.renderList}); | ||||
|  | ||||
|   Future<AssetsState> withRenderDataStructure( | ||||
|     AssetGridLayoutParameters layout, | ||||
|   ) async { | ||||
|     return AssetsState( | ||||
|       allAssets, | ||||
|       renderList: await RenderList.fromAssets( | ||||
|         allAssets, | ||||
|         layout, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   AssetsState withAdditionalAssets(List<Asset> toAdd) { | ||||
|     return AssetsState([...allAssets, ...toAdd]); | ||||
|   } | ||||
|  | ||||
|   static AssetsState fromAssetList(List<Asset> assets) { | ||||
|     return AssetsState(assets); | ||||
|   } | ||||
|  | ||||
|   static AssetsState empty() { | ||||
|     return AssetsState([]); | ||||
|   } | ||||
| } | ||||
| class AssetsState {} | ||||
|  | ||||
| class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|   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<void> _updateAssetsState(List<Asset> 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<void> rebuildAssetGridDataStructure() async { | ||||
|     await _updateAssetsState(state.allAssets); | ||||
|   } | ||||
|   ) : super(AssetsState()); | ||||
|  | ||||
|   Future<void> getAllAsset({bool clear = false}) async { | ||||
|     if (_getAllAssetInProgress || _deleteInProgress) { | ||||
| @@ -97,79 +44,32 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|     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<List<Asset>> _getUserAssets(int userId) => | ||||
|       _userAssetQuery(userId).sortByFileCreatedAtDesc().findAll(); | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> _userAssetQuery( | ||||
|     int userId, | ||||
|   ) => | ||||
|       _db.assets.filter().ownerIdEqualTo(userId).isArchivedEqualTo(false); | ||||
|  | ||||
|   Future<void> clearAllAsset() { | ||||
|     state = AssetsState.empty(); | ||||
|     return clearAssetsAndAlbums(_db); | ||||
|   } | ||||
|  | ||||
|   Future<void> 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<void> deleteAssets(Set<Asset> 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<AssetsState> { | ||||
|     } | ||||
|     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<AssetsState> { | ||||
|         .map((a) => a.id); | ||||
|   } | ||||
|  | ||||
|   Future<bool> 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<void> toggleFavorite(List<Asset> 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<void> toggleArchive(Iterable<Asset> assets, bool status) async { | ||||
|     final newAssets = await Future.wait( | ||||
|       assets.map((a) => _assetService.changeArchiveStatus(a, status)), | ||||
|     ); | ||||
|   Future<void> toggleArchive(List<Asset> 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<AssetsState> { | ||||
| final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((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<DateTime>( | ||||
|     (e) => e.fileCreatedAt, | ||||
|     (a, b) => b.compareTo(a), | ||||
|   ); | ||||
|  | ||||
|   return assets.groupListsBy( | ||||
|     (element) => DateFormat('MMMM, y').format(element.fileCreatedAt.toLocal()), | ||||
|   ); | ||||
| final assetDetailProvider = | ||||
|     StreamProvider.autoDispose.family<Asset, Asset>((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<RenderList>((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<RenderList>((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); | ||||
|   } | ||||
| }); | ||||
|   | ||||
| @@ -97,15 +97,18 @@ class AssetService { | ||||
|   /// the exif info from the server (remote assets only) | ||||
|   Future<Asset> 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<Asset?> updateAsset( | ||||
|     Asset asset, | ||||
|   Future<List<Asset?>> updateAssets( | ||||
|     List<Asset> 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<AssetResponseDto?> 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<Asset?> changeFavoriteStatus(Asset asset, bool isFavorite) { | ||||
|     return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite)); | ||||
|   Future<List<Asset?>> changeFavoriteStatus( | ||||
|     List<Asset> assets, | ||||
|     bool isFavorite, | ||||
|   ) { | ||||
|     return updateAssets(assets, UpdateAssetDto(isFavorite: isFavorite)); | ||||
|   } | ||||
|  | ||||
|   Future<Asset?> changeArchiveStatus(Asset asset, bool isArchive) { | ||||
|     return updateAsset(asset, UpdateAssetDto(isArchived: isArchive)); | ||||
|   Future<List<Asset?>> changeArchiveStatus(List<Asset> assets, bool isArchive) { | ||||
|     return updateAssets(assets, UpdateAssetDto(isArchived: isArchive)); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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<User>(); | ||||
|  | ||||
| @@ -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<void> _upsertAssetsWithExif(List<Asset> assets) async { | ||||
|   Future<void> upsertAssetsWithExif(List<Asset> assets) async { | ||||
|     if (assets.isEmpty) { | ||||
|       return; | ||||
|     } | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
|  | ||||
| extension DurationExtension on String { | ||||
|   Duration? toDuration() { | ||||
|     try { | ||||
| @@ -34,4 +36,12 @@ extension ListExtension<E> on List<E> { | ||||
|     length = length == 0 ? 0 : j; | ||||
|     return this; | ||||
|   } | ||||
|  | ||||
|   ListSlice<E> nestedSlice(int start, int end) { | ||||
|     if (this is ListSlice) { | ||||
|       final ListSlice<E> self = this as ListSlice<E>; | ||||
|       return ListSlice<E>(self.source, self.start + start, self.start + end); | ||||
|     } | ||||
|     return ListSlice<E>(this, start, end); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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<AssetsState>(), | ||||
|   MockSpec<AssetNotifier>(), | ||||
| ]) | ||||
| 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<FavoriteSelectionNotifier, Set<int>> | ||||
|         testFavoritesProvider; | ||||
|  | ||||
|     setUp( | ||||
|       () { | ||||
|         assetsState = MockAssetsState(); | ||||
|         assetNotifier = MockAssetNotifier(); | ||||
|         container = ProviderContainer(); | ||||
|  | ||||
|         testFavoritesProvider = | ||||
|             StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) { | ||||
|           return FavoriteSelectionNotifier( | ||||
|             assetsState, | ||||
|             assetNotifier, | ||||
|           ); | ||||
|         }); | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
|     test("Empty favorites provider", () { | ||||
|       when(assetsState.allAssets).thenReturn([]); | ||||
|       expect(<int>{}, 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(<int>{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(<int>{2}, container.read(testFavoritesProvider)); | ||||
|  | ||||
|       container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset2); | ||||
|       expect(<int>{}, container.read(testFavoritesProvider)); | ||||
|  | ||||
|       container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset1); | ||||
|       expect(<int>{1}, container.read(testFavoritesProvider)); | ||||
|     }); | ||||
|  | ||||
|     test("Add favorites", () { | ||||
|       when(assetNotifier.toggleFavorite(null, false)) | ||||
|           .thenAnswer((_) async => false); | ||||
|  | ||||
|       when(assetsState.allAssets).thenReturn([]); | ||||
|  | ||||
|       expect(<int>{}, container.read(testFavoritesProvider)); | ||||
|  | ||||
|       container.read(testFavoritesProvider.notifier).addToFavorites( | ||||
|         [ | ||||
|           _getTestAsset(1, false), | ||||
|           _getTestAsset(2, false), | ||||
|         ], | ||||
|       ); | ||||
|  | ||||
|       expect(<int>{1, 2}, container.read(testFavoritesProvider)); | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
| @@ -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<void> rebuildAssetGridDataStructure() => (super.noSuchMethod( | ||||
|         Invocation.method( | ||||
|           #rebuildAssetGridDataStructure, | ||||
|           [], | ||||
|         ), | ||||
|         returnValue: _i5.Future<void>.value(), | ||||
|         returnValueForMissingStub: _i5.Future<void>.value(), | ||||
|       ) as _i5.Future<void>); | ||||
|   @override | ||||
|   _i5.Future<void> getAllAsset({bool? clear = false}) => (super.noSuchMethod( | ||||
|         Invocation.method( | ||||
|           #getAllAsset, | ||||
|           [], | ||||
|           {#clear: clear}, | ||||
|         ), | ||||
|         returnValue: _i5.Future<void>.value(), | ||||
|         returnValueForMissingStub: _i5.Future<void>.value(), | ||||
|       ) as _i5.Future<void>); | ||||
|   @override | ||||
|   _i5.Future<void> clearAllAsset() => (super.noSuchMethod( | ||||
|         Invocation.method( | ||||
|           #clearAllAsset, | ||||
|           [], | ||||
|         ), | ||||
|         returnValue: _i5.Future<void>.value(), | ||||
|         returnValueForMissingStub: _i5.Future<void>.value(), | ||||
|       ) as _i5.Future<void>); | ||||
|   @override | ||||
|   _i5.Future<void> onNewAssetUploaded(_i4.Asset? newAsset) => | ||||
|       (super.noSuchMethod( | ||||
|         Invocation.method( | ||||
|           #onNewAssetUploaded, | ||||
|           [newAsset], | ||||
|         ), | ||||
|         returnValue: _i5.Future<void>.value(), | ||||
|         returnValueForMissingStub: _i5.Future<void>.value(), | ||||
|       ) as _i5.Future<void>); | ||||
|   @override | ||||
|   _i5.Future<void> deleteAssets(Set<_i4.Asset>? deleteAssets) => | ||||
|       (super.noSuchMethod( | ||||
|         Invocation.method( | ||||
|           #deleteAssets, | ||||
|           [deleteAssets], | ||||
|         ), | ||||
|         returnValue: _i5.Future<void>.value(), | ||||
|         returnValueForMissingStub: _i5.Future<void>.value(), | ||||
|       ) as _i5.Future<void>); | ||||
|   @override | ||||
|   _i5.Future<bool> toggleFavorite( | ||||
|     _i4.Asset? asset, | ||||
|     bool? status, | ||||
|   ) => | ||||
|       (super.noSuchMethod( | ||||
|         Invocation.method( | ||||
|           #toggleFavorite, | ||||
|           [ | ||||
|             asset, | ||||
|             status, | ||||
|           ], | ||||
|         ), | ||||
|         returnValue: _i5.Future<bool>.value(false), | ||||
|         returnValueForMissingStub: _i5.Future<bool>.value(false), | ||||
|       ) as _i5.Future<bool>); | ||||
|   @override | ||||
|   _i5.Future<void> toggleArchive( | ||||
|     Iterable<_i4.Asset>? assets, | ||||
|     bool? status, | ||||
|   ) => | ||||
|       (super.noSuchMethod( | ||||
|         Invocation.method( | ||||
|           #toggleArchive, | ||||
|           [ | ||||
|             assets, | ||||
|             status, | ||||
|           ], | ||||
|         ), | ||||
|         returnValue: _i5.Future<void>.value(), | ||||
|         returnValueForMissingStub: _i5.Future<void>.value(), | ||||
|       ) as _i5.Future<void>); | ||||
|   @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, | ||||
|       ); | ||||
| } | ||||
		Reference in New Issue
	
	Block a user