diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt index 8337dce299597..660e1d55bac77 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt @@ -134,13 +134,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct } private fun stopEngine(result: Result?) { + clearBackgroundNotification() + engine?.destroy() + engine = null if (result != null) { Log.d(TAG, "stopEngine result=${result}") resolvableFuture.set(result) } - engine?.destroy() - engine = null - clearBackgroundNotification() waitOnSetForegroundAsync() } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index bacd9bb42d498..d1148df360511 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -35,10 +35,12 @@ void main() async { await Future.wait([ Hive.openBox(userInfoBox), Hive.openBox(hiveLoginInfoBox), - Hive.openBox(hiveBackupInfoBox), Hive.openBox(hiveGithubReleaseInfoBox), Hive.openBox(userSettingInfoBox), - Hive.openBox(duplicatedAssetsBox), + if (!Platform.isAndroid) Hive.openBox(hiveBackupInfoBox), + if (!Platform.isAndroid) + Hive.openBox(duplicatedAssetsBox), + if (!Platform.isAndroid) Hive.openBox(backgroundBackupInfoBox), EasyLocalization.ensureInitialized(), ]); @@ -86,8 +88,8 @@ class ImmichAppState extends ConsumerState var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated; if (isAuthenticated) { + ref.read(backupProvider.notifier).resumeBackup(); ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); - ref.watch(backupProvider.notifier).resumeBackup(); ref.watch(assetProvider.notifier).getAllAsset(); ref.watch(serverInfoProvider.notifier).getServerVersion(); } diff --git a/mobile/lib/modules/album/models/asset_selection_page_result.model.dart b/mobile/lib/modules/album/models/asset_selection_page_result.model.dart index e2fd8e3d120c6..b837efa689281 100644 --- a/mobile/lib/modules/album/models/asset_selection_page_result.model.dart +++ b/mobile/lib/modules/album/models/asset_selection_page_result.model.dart @@ -1,10 +1,9 @@ import 'package:collection/collection.dart'; - -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; class AssetSelectionPageResult { - final Set selectedNewAsset; - final Set selectedAdditionalAsset; + final Set selectedNewAsset; + final Set selectedAdditionalAsset; final bool isAlbumExist; AssetSelectionPageResult({ @@ -14,8 +13,8 @@ class AssetSelectionPageResult { }); AssetSelectionPageResult copyWith({ - Set? selectedNewAsset, - Set? selectedAdditionalAsset, + Set? selectedNewAsset, + Set? selectedAdditionalAsset, bool? isAlbumExist, }) { return AssetSelectionPageResult( diff --git a/mobile/lib/modules/album/models/asset_selection_state.model.dart b/mobile/lib/modules/album/models/asset_selection_state.model.dart index 6a0a9160a1ef0..084d86be598a5 100644 --- a/mobile/lib/modules/album/models/asset_selection_state.model.dart +++ b/mobile/lib/modules/album/models/asset_selection_state.model.dart @@ -1,12 +1,11 @@ import 'package:collection/collection.dart'; - -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; class AssetSelectionState { final Set selectedMonths; - final Set selectedNewAssetsForAlbum; - final Set selectedAdditionalAssetsForAlbum; - final Set selectedAssetsInAlbumViewer; + final Set selectedNewAssetsForAlbum; + final Set selectedAdditionalAssetsForAlbum; + final Set selectedAssetsInAlbumViewer; final bool isMultiselectEnable; /// Indicate the asset selection page is navigated from existing album @@ -22,9 +21,9 @@ class AssetSelectionState { AssetSelectionState copyWith({ Set? selectedMonths, - Set? selectedNewAssetsForAlbum, - Set? selectedAdditionalAssetsForAlbum, - Set? selectedAssetsInAlbumViewer, + Set? selectedNewAssetsForAlbum, + Set? selectedAdditionalAssetsForAlbum, + Set? selectedAssetsInAlbumViewer, bool? isMultiselectEnable, bool? isAlbumExist, }) { diff --git a/mobile/lib/modules/album/providers/album.provider.dart b/mobile/lib/modules/album/providers/album.provider.dart index 9a098aa49b208..5d7911fbd8439 100644 --- a/mobile/lib/modules/album/providers/album.provider.dart +++ b/mobile/lib/modules/album/providers/album.provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album_cache.service.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:openapi/api.dart'; class AlbumNotifier extends StateNotifier> { @@ -13,7 +14,6 @@ class AlbumNotifier extends StateNotifier> { } getAllAlbums() async { - if (await _albumCacheService.isValid() && state.isEmpty) { state = await _albumCacheService.get(); } @@ -34,7 +34,7 @@ class AlbumNotifier extends StateNotifier> { Future createAlbum( String albumTitle, - Set assets, + Set assets, ) async { AlbumResponseDto? album = await _albumService.createAlbum(albumTitle, assets, []); diff --git a/mobile/lib/modules/album/providers/asset_selection.provider.dart b/mobile/lib/modules/album/providers/asset_selection.provider.dart index 2b01b7cf9c2b9..927ab331fd603 100644 --- a/mobile/lib/modules/album/providers/asset_selection.provider.dart +++ b/mobile/lib/modules/album/providers/asset_selection.provider.dart @@ -1,7 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_state.model.dart'; - -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; class AssetSelectionNotifier extends StateNotifier { AssetSelectionNotifier() @@ -22,15 +21,15 @@ class AssetSelectionNotifier extends StateNotifier { void removeAssetsInMonth( String removedMonth, - List assetsInMonth, + List assetsInMonth, ) { - Set currentAssetList = state.selectedNewAssetsForAlbum; + Set currentAssetList = state.selectedNewAssetsForAlbum; Set currentMonthList = state.selectedMonths; currentMonthList .removeWhere((selectedMonth) => selectedMonth == removedMonth); - for (AssetResponseDto asset in assetsInMonth) { + for (Asset asset in assetsInMonth) { currentAssetList.removeWhere((e) => e.id == asset.id); } @@ -40,7 +39,7 @@ class AssetSelectionNotifier extends StateNotifier { ); } - void addAdditionalAssets(List assets) { + void addAdditionalAssets(List assets) { state = state.copyWith( selectedAdditionalAssetsForAlbum: { ...state.selectedAdditionalAssetsForAlbum, @@ -49,7 +48,7 @@ class AssetSelectionNotifier extends StateNotifier { ); } - void addAllAssetsInMonth(String month, List assetsInMonth) { + void addAllAssetsInMonth(String month, List assetsInMonth) { state = state.copyWith( selectedMonths: {...state.selectedMonths, month}, selectedNewAssetsForAlbum: { @@ -59,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier { ); } - void addNewAssets(List assets) { + void addNewAssets(List assets) { state = state.copyWith( selectedNewAssetsForAlbum: { ...state.selectedNewAssetsForAlbum, @@ -68,20 +67,20 @@ class AssetSelectionNotifier extends StateNotifier { ); } - void removeSelectedNewAssets(List assets) { - Set currentList = state.selectedNewAssetsForAlbum; + void removeSelectedNewAssets(List assets) { + Set currentList = state.selectedNewAssetsForAlbum; - for (AssetResponseDto asset in assets) { + for (Asset asset in assets) { currentList.removeWhere((e) => e.id == asset.id); } state = state.copyWith(selectedNewAssetsForAlbum: currentList); } - void removeSelectedAdditionalAssets(List assets) { - Set currentList = state.selectedAdditionalAssetsForAlbum; + void removeSelectedAdditionalAssets(List assets) { + Set currentList = state.selectedAdditionalAssetsForAlbum; - for (AssetResponseDto asset in assets) { + for (Asset asset in assets) { currentList.removeWhere((e) => e.id == asset.id); } @@ -109,7 +108,7 @@ class AssetSelectionNotifier extends StateNotifier { ); } - void addAssetsInAlbumViewer(List assets) { + void addAssetsInAlbumViewer(List assets) { state = state.copyWith( selectedAssetsInAlbumViewer: { ...state.selectedAssetsInAlbumViewer, @@ -118,10 +117,10 @@ class AssetSelectionNotifier extends StateNotifier { ); } - void removeAssetsInAlbumViewer(List assets) { - Set currentList = state.selectedAssetsInAlbumViewer; + void removeAssetsInAlbumViewer(List assets) { + Set currentList = state.selectedAssetsInAlbumViewer; - for (AssetResponseDto asset in assets) { + for (Asset asset in assets) { currentList.removeWhere((e) => e.id == asset.id); } diff --git a/mobile/lib/modules/album/providers/shared_album.provider.dart b/mobile/lib/modules/album/providers/shared_album.provider.dart index c759f3263b7cd..e428136c7c93f 100644 --- a/mobile/lib/modules/album/providers/shared_album.provider.dart +++ b/mobile/lib/modules/album/providers/shared_album.provider.dart @@ -2,10 +2,12 @@ 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/album/services/album_cache.service.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:openapi/api.dart'; class SharedAlbumNotifier extends StateNotifier> { - SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) : super([]); + SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) + : super([]); final AlbumService _sharedAlbumService; final SharedAlbumCacheService _sharedAlbumCacheService; @@ -16,7 +18,7 @@ class SharedAlbumNotifier extends StateNotifier> { Future createSharedAlbum( String albumName, - Set assets, + Set assets, List sharedUserIds, ) async { try { diff --git a/mobile/lib/modules/album/services/album.service.dart b/mobile/lib/modules/album/services/album.service.dart index 21b6fb430b8b7..5e53399e35984 100644 --- a/mobile/lib/modules/album/services/album.service.dart +++ b/mobile/lib/modules/album/services/album.service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:openapi/api.dart'; @@ -29,7 +30,7 @@ class AlbumService { Future createAlbum( String albumName, - Set assets, + Iterable assets, List sharedUserIds, ) async { try { @@ -65,7 +66,7 @@ class AlbumService { } Future createAlbumWithGeneratedName( - Set assets, + Iterable assets, ) async { return createAlbum( _getNextAlbumName(await getAlbums(isShared: false)), assets, []); @@ -81,7 +82,7 @@ class AlbumService { } Future addAdditionalAssetToAlbum( - Set assets, + Iterable assets, String albumId, ) async { try { diff --git a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart index ce9e708d36150..6566e76434428 100644 --- a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart +++ b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart @@ -1,18 +1,15 @@ import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/immich_image.dart'; class AlbumViewerThumbnail extends HookConsumerWidget { - final AssetResponseDto asset; - final List assetList; + final Asset asset; + final List assetList; final bool showStorageIndicator; const AlbumViewerThumbnail({ @@ -24,8 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - var box = Hive.box(userInfoBox); - var thumbnailRequestUrl = getThumbnailUrl(asset); var deviceId = ref.watch(authenticationProvider).deviceId; final selectedAssetsInAlbumViewer = ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; @@ -120,27 +115,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget { _buildThumbnailImage() { return Container( decoration: BoxDecoration(border: drawBorderColor()), - child: CachedNetworkImage( - cacheKey: asset.id, - width: 300, - height: 300, - memCacheHeight: 200, - fit: BoxFit.cover, - imageUrl: thumbnailRequestUrl, - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - fadeInDuration: const Duration(milliseconds: 250), - progressIndicatorBuilder: (context, url, downloadProgress) => - Transform.scale( - scale: 0.2, - child: CircularProgressIndicator(value: downloadProgress.progress), - ), - errorWidget: (context, url, error) { - return Icon( - Icons.image_not_supported_outlined, - color: Theme.of(context).primaryColor, - ); - }, - ), + child: ImmichImage(asset, width: 300, height: 300), ); } @@ -167,7 +142,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget { children: [ _buildThumbnailImage(), if (showStorageIndicator) _buildAssetStoreLocationIcon(), - if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(), + if (!asset.isImage) _buildVideoLabel(), if (isMultiSelectionEnable) _buildAssetSelectionIcon(), ], ), diff --git a/mobile/lib/modules/album/ui/asset_grid_by_month.dart b/mobile/lib/modules/album/ui/asset_grid_by_month.dart index 8489a9247f482..7dd523248b8fa 100644 --- a/mobile/lib/modules/album/ui/asset_grid_by_month.dart +++ b/mobile/lib/modules/album/ui/asset_grid_by_month.dart @@ -1,10 +1,10 @@ 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:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; class AssetGridByMonth extends HookConsumerWidget { - final List assetGroup; + final List assetGroup; const AssetGridByMonth({Key? key, required this.assetGroup}) : super(key: key); @override diff --git a/mobile/lib/modules/album/ui/month_group_title.dart b/mobile/lib/modules/album/ui/month_group_title.dart index ad136c374f385..e3a772d28757c 100644 --- a/mobile/lib/modules/album/ui/month_group_title.dart +++ b/mobile/lib/modules/album/ui/month_group_title.dart @@ -2,11 +2,11 @@ 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:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; class MonthGroupTitle extends HookConsumerWidget { final String month; - final List assetGroup; + final List assetGroup; const MonthGroupTitle({ Key? key, diff --git a/mobile/lib/modules/album/ui/selection_thumbnail_image.dart b/mobile/lib/modules/album/ui/selection_thumbnail_image.dart index 2c8c524eab9db..09b3160b6234f 100644 --- a/mobile/lib/modules/album/ui/selection_thumbnail_image.dart +++ b/mobile/lib/modules/album/ui/selection_thumbnail_image.dart @@ -1,29 +1,24 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/immich_image.dart'; class SelectionThumbnailImage extends HookConsumerWidget { - final AssetResponseDto asset; + final Asset asset; const SelectionThumbnailImage({Key? key, required this.asset}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { - var box = Hive.box(userInfoBox); - var thumbnailRequestUrl = getThumbnailUrl(asset); var selectedAsset = ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; var newAssetsForAlbum = ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; - Widget _buildSelectionIcon(AssetResponseDto asset) { + Widget _buildSelectionIcon(Asset asset) { var isSelected = selectedAsset.map((item) => item.id).contains(asset.id); var isNewlySelected = newAssetsForAlbum.map((item) => item.id).contains(asset.id); @@ -110,30 +105,7 @@ class SelectionThumbnailImage extends HookConsumerWidget { children: [ Container( decoration: BoxDecoration(border: drawBorderColor()), - child: CachedNetworkImage( - cacheKey: asset.id, - width: 150, - height: 150, - memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150, - fit: BoxFit.cover, - imageUrl: thumbnailRequestUrl, - httpHeaders: { - "Authorization": "Bearer ${box.get(accessTokenKey)}" - }, - fadeInDuration: const Duration(milliseconds: 250), - progressIndicatorBuilder: (context, url, downloadProgress) => - Transform.scale( - scale: 0.2, - child: - CircularProgressIndicator(value: downloadProgress.progress), - ), - errorWidget: (context, url, error) { - return Icon( - Icons.image_not_supported_outlined, - color: Theme.of(context).primaryColor, - ); - }, - ), + child: ImmichImage(asset, width: 150, height: 150), ), Padding( padding: const EdgeInsets.all(3.0), @@ -142,7 +114,7 @@ class SelectionThumbnailImage extends HookConsumerWidget { child: _buildSelectionIcon(asset), ), ), - if (asset.type != AssetTypeEnum.IMAGE) + if (!asset.isImage) Positioned( bottom: 5, right: 5, diff --git a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart index b4ea9fc7ae8cb..8cbe6423a8728 100644 --- a/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart +++ b/mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart @@ -1,49 +1,23 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/hive_box.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/immich_image.dart'; class SharedAlbumThumbnailImage extends HookConsumerWidget { - final AssetResponseDto asset; + final Asset asset; const SharedAlbumThumbnailImage({Key? key, required this.asset}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { - var box = Hive.box(userInfoBox); - return GestureDetector( onTap: () { // debugPrint("View ${asset.id}"); }, child: Stack( children: [ - CachedNetworkImage( - cacheKey: asset.id, - width: 500, - height: 500, - memCacheHeight: 500, - fit: BoxFit.cover, - imageUrl: getThumbnailUrl(asset), - httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, - fadeInDuration: const Duration(milliseconds: 250), - progressIndicatorBuilder: (context, url, downloadProgress) => - Transform.scale( - scale: 0.2, - child: - CircularProgressIndicator(value: downloadProgress.progress), - ), - errorWidget: (context, url, error) { - return Icon( - Icons.image_not_supported_outlined, - color: Theme.of(context).primaryColor, - ); - }, - ), + ImmichImage(asset, width: 500, height: 500), ], ), ); diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index 1bb786e0e2d3d..9e9c4af41a2a4 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -16,6 +16,7 @@ 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/routing/router.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'; @@ -38,9 +39,9 @@ class AlbumViewerPage extends HookConsumerWidget { /// If they exist, add to selected asset state to show they are already selected. void _onAddPhotosPressed(AlbumResponseDto albumInfo) async { if (albumInfo.assets.isNotEmpty == true) { - ref - .watch(assetSelectionProvider.notifier) - .addNewAssets(albumInfo.assets.toList()); + ref.watch(assetSelectionProvider.notifier).addNewAssets( + albumInfo.assets.map((e) => Asset.remote(e)).toList(), + ); } ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true); @@ -205,8 +206,9 @@ class AlbumViewerPage extends HookConsumerWidget { delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return AlbumViewerThumbnail( - asset: albumInfo.assets[index], - assetList: albumInfo.assets, + asset: Asset.remote(albumInfo.assets[index]), + assetList: + albumInfo.assets.map((e) => Asset.remote(e)).toList(), showStorageIndicator: showStorageIndicator, ); }, diff --git a/mobile/lib/modules/album/views/create_album_page.dart b/mobile/lib/modules/album/views/create_album_page.dart index 20cf0c26d5a54..374d8fb12f2a4 100644 --- a/mobile/lib/modules/album/views/create_album_page.dart +++ b/mobile/lib/modules/album/views/create_album_page.dart @@ -166,7 +166,7 @@ class CreateAlbumPage extends HookConsumerWidget { return GestureDetector( onTap: _onBackgroundTapped, child: SharedAlbumThumbnailImage( - asset: selectedAssets.toList()[index], + asset: selectedAssets.elementAt(index), ), ); }, diff --git a/mobile/lib/modules/album/views/sharing_page.dart b/mobile/lib/modules/album/views/sharing_page.dart index 3c8520595ea16..cb04ebf759d8b 100644 --- a/mobile/lib/modules/album/views/sharing_page.dart +++ b/mobile/lib/modules/album/views/sharing_page.dart @@ -18,7 +18,6 @@ class SharingPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { var box = Hive.box(userInfoBox); - var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail'; final List sharedAlbums = ref.watch(sharedAlbumProvider); useEffect( diff --git a/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart b/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart index c95b64f1db2c9..6d3ae83842ee5 100644 --- a/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart @@ -1,9 +1,9 @@ -import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/services/share.service.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/share_dialog.dart'; @@ -47,7 +47,7 @@ class ImageViewerStateNotifier extends StateNotifier { state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle); } - void shareAsset(AssetResponseDto asset, BuildContext context) async { + void shareAsset(Asset asset, BuildContext context) async { showDialog( context: context, builder: (BuildContext buildContext) { diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index 1136f3970deb9..bb88d54e6031c 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -2,12 +2,13 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:openapi/api.dart'; import 'package:path/path.dart' as p; import 'package:latlong2/latlong.dart'; class ExifBottomSheet extends ConsumerWidget { - final AssetResponseDto assetDetail; + final Asset assetDetail; const ExifBottomSheet({Key? key, required this.assetDetail}) : super(key: key); @@ -26,8 +27,8 @@ class ExifBottomSheet extends ConsumerWidget { child: FlutterMap( options: MapOptions( center: LatLng( - assetDetail.exifInfo?.latitude?.toDouble() ?? 0, - assetDetail.exifInfo?.longitude?.toDouble() ?? 0, + assetDetail.latitude ?? 0, + assetDetail.longitude ?? 0, ), zoom: 16.0, ), @@ -48,8 +49,8 @@ class ExifBottomSheet extends ConsumerWidget { Marker( anchorPos: AnchorPos.align(AnchorAlign.top), point: LatLng( - assetDetail.exifInfo?.latitude?.toDouble() ?? 0, - assetDetail.exifInfo?.longitude?.toDouble() ?? 0, + assetDetail.latitude ?? 0, + assetDetail.longitude ?? 0, ), builder: (ctx) => const Image( image: AssetImage('assets/location-pin.png'), @@ -63,9 +64,11 @@ class ExifBottomSheet extends ConsumerWidget { ); } + ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo; + _buildLocationText() { return Text( - "${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}", + "${exifInfo?.city}, ${exifInfo?.state}", style: TextStyle( fontSize: 12, color: Colors.grey[200], @@ -78,10 +81,10 @@ class ExifBottomSheet extends ConsumerWidget { padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8), child: ListView( children: [ - if (assetDetail.exifInfo?.dateTimeOriginal != null) + if (exifInfo?.dateTimeOriginal != null) Text( DateFormat('date_format'.tr()).format( - assetDetail.exifInfo!.dateTimeOriginal!.toLocal(), + exifInfo!.dateTimeOriginal!.toLocal(), ), style: TextStyle( color: Colors.grey[400], @@ -101,7 +104,7 @@ class ExifBottomSheet extends ConsumerWidget { ), // Location - if (assetDetail.exifInfo?.latitude != null) + if (assetDetail.latitude != null) Padding( padding: const EdgeInsets.only(top: 32.0), child: Column( @@ -115,21 +118,22 @@ class ExifBottomSheet extends ConsumerWidget { "exif_bottom_sheet_location", style: TextStyle(fontSize: 11, color: Colors.grey[400]), ).tr(), - if (assetDetail.exifInfo?.latitude != null && - assetDetail.exifInfo?.longitude != null) + if (assetDetail.latitude != null && + assetDetail.longitude != null) _buildMap(), - if (assetDetail.exifInfo?.city != null && - assetDetail.exifInfo?.state != null) + if (exifInfo != null && + exifInfo.city != null && + exifInfo.state != null) _buildLocationText(), Text( - "${assetDetail.exifInfo?.latitude?.toStringAsFixed(4)}, ${assetDetail.exifInfo?.longitude?.toStringAsFixed(4)}", + "${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}", style: TextStyle(fontSize: 12, color: Colors.grey[400]), ) ], ), ), // Detail - if (assetDetail.exifInfo != null) + if (exifInfo != null) Padding( padding: const EdgeInsets.only(top: 32.0), child: Column( @@ -153,16 +157,16 @@ class ExifBottomSheet extends ConsumerWidget { iconColor: Colors.grey[300], leading: const Icon(Icons.image), title: Text( - "${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}", + "${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}", style: const TextStyle(fontWeight: FontWeight.bold), ), - subtitle: assetDetail.exifInfo?.exifImageHeight != null + subtitle: exifInfo.exifImageHeight != null ? Text( - "${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ", + "${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${exifInfo.fileSizeInByte!}B ", ) : null, ), - if (assetDetail.exifInfo?.make != null) + if (exifInfo.make != null) ListTile( contentPadding: const EdgeInsets.all(0), dense: true, @@ -170,11 +174,11 @@ class ExifBottomSheet extends ConsumerWidget { iconColor: Colors.grey[300], leading: const Icon(Icons.camera), title: Text( - "${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}", + "${exifInfo.make} ${exifInfo.model}", style: const TextStyle(fontWeight: FontWeight.bold), ), subtitle: Text( - "ƒ/${assetDetail.exifInfo?.fNumber} 1/${(1 / (assetDetail.exifInfo?.exposureTime ?? 1)).toStringAsFixed(0)} ${assetDetail.exifInfo?.focalLength}mm ISO${assetDetail.exifInfo?.iso} ", + "ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength}mm ISO${exifInfo.iso} ", ), ), ], diff --git a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart b/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart index cc92ddc26ff2b..ff1b16a10561d 100644 --- a/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart +++ b/mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart @@ -1,17 +1,22 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:openapi/api.dart'; +import 'package:photo_manager/photo_manager.dart' + show AssetEntityImageProvider, ThumbnailSize; import 'package:photo_view/photo_view.dart'; enum _RemoteImageStatus { empty, thumbnail, preview, full } class _RemotePhotoViewState extends State { - late CachedNetworkImageProvider _imageProvider; + late ImageProvider _imageProvider; _RemoteImageStatus _status = _RemoteImageStatus.empty; bool _zoomedIn = false; - late CachedNetworkImageProvider fullProvider; - late CachedNetworkImageProvider previewProvider; - late CachedNetworkImageProvider thumbnailProvider; + late ImageProvider _fullProvider; + late ImageProvider _previewProvider; + late ImageProvider _thumbnailProvider; @override Widget build(BuildContext context) { @@ -68,7 +73,7 @@ class _RemotePhotoViewState extends State { void _performStateTransition( _RemoteImageStatus newStatus, - CachedNetworkImageProvider provider, + ImageProvider provider, ) { if (_status == newStatus) return; @@ -90,40 +95,58 @@ class _RemotePhotoViewState extends State { } void _loadImages() { - thumbnailProvider = _authorizedImageProvider( - widget.thumbnailUrl, - widget.cacheKey, - ); - _imageProvider = thumbnailProvider; + if (widget.asset.isLocal) { + _imageProvider = AssetEntityImageProvider( + widget.asset.local!, + isOriginal: false, + thumbnailSize: const ThumbnailSize.square(250), + ); + _fullProvider = AssetEntityImageProvider(widget.asset.local!); + _fullProvider.resolve(const ImageConfiguration()).addListener( + ImageStreamListener((ImageInfo image, _) { + _performStateTransition( + _RemoteImageStatus.full, + _fullProvider, + ); + }), + ); + return; + } - thumbnailProvider.resolve(const ImageConfiguration()).addListener( + _thumbnailProvider = _authorizedImageProvider( + getThumbnailUrl(widget.asset.remote!), + widget.asset.id, + ); + _imageProvider = _thumbnailProvider; + + _thumbnailProvider.resolve(const ImageConfiguration()).addListener( ImageStreamListener((ImageInfo imageInfo, _) { _performStateTransition( _RemoteImageStatus.thumbnail, - thumbnailProvider, + _thumbnailProvider, ); }), ); - if (widget.previewUrl != null) { - previewProvider = _authorizedImageProvider( - widget.previewUrl!, - "${widget.cacheKey}_previewStage", + if (widget.threeStageLoading) { + _previewProvider = _authorizedImageProvider( + getThumbnailUrl(widget.asset.remote!, type: ThumbnailFormat.JPEG), + "${widget.asset.id}_previewStage", ); - previewProvider.resolve(const ImageConfiguration()).addListener( + _previewProvider.resolve(const ImageConfiguration()).addListener( ImageStreamListener((ImageInfo imageInfo, _) { - _performStateTransition(_RemoteImageStatus.preview, previewProvider); + _performStateTransition(_RemoteImageStatus.preview, _previewProvider); }), ); } - fullProvider = _authorizedImageProvider( - widget.imageUrl, - "${widget.cacheKey}_fullStage", + _fullProvider = _authorizedImageProvider( + getImageUrl(widget.asset.remote!), + "${widget.asset.id}_fullStage", ); - fullProvider.resolve(const ImageConfiguration()).addListener( + _fullProvider.resolve(const ImageConfiguration()).addListener( ImageStreamListener((ImageInfo imageInfo, _) { - _performStateTransition(_RemoteImageStatus.full, fullProvider); + _performStateTransition(_RemoteImageStatus.full, _fullProvider); }), ); } @@ -139,11 +162,11 @@ class _RemotePhotoViewState extends State { super.dispose(); if (_status == _RemoteImageStatus.full) { - await fullProvider.evict(); + await _fullProvider.evict(); } else if (_status == _RemoteImageStatus.preview) { - await previewProvider.evict(); + await _previewProvider.evict(); } else if (_status == _RemoteImageStatus.thumbnail) { - await thumbnailProvider.evict(); + await _thumbnailProvider.evict(); } await _imageProvider.evict(); @@ -153,23 +176,18 @@ class _RemotePhotoViewState extends State { class RemotePhotoView extends StatefulWidget { const RemotePhotoView({ Key? key, - required this.thumbnailUrl, - required this.imageUrl, + required this.asset, required this.authToken, + required this.threeStageLoading, required this.isZoomedFunction, required this.isZoomedListener, required this.onSwipeDown, required this.onSwipeUp, - this.previewUrl, - required this.cacheKey, }) : super(key: key); - final String thumbnailUrl; - final String imageUrl; + final Asset asset; final String authToken; - final String? previewUrl; - final String cacheKey; - + final bool threeStageLoading; final void Function() onSwipeDown; final void Function() onSwipeUp; final void Function() isZoomedFunction; diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 3c23d9fe8b1b9..3cfd4a683aa03 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -1,7 +1,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { const TopControlAppBar({ @@ -13,9 +13,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { this.loading = false, }) : super(key: key); - final AssetResponseDto asset; + final Asset asset; final Function onMoreInfoPressed; - final Function onDownloadPressed; + final VoidCallback? onDownloadPressed; final Function onSharePressed; final bool loading; @@ -47,17 +47,16 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { child: const CircularProgressIndicator(strokeWidth: 2.0), ), ), - IconButton( - iconSize: iconSize, - splashRadius: iconSize, - onPressed: () { - onDownloadPressed(); - }, - icon: Icon( - Icons.cloud_download_rounded, - color: Colors.grey[200], + if (!asset.isLocal) + IconButton( + iconSize: iconSize, + splashRadius: iconSize, + onPressed: onDownloadPressed, + icon: Icon( + Icons.cloud_download_rounded, + color: Colors.grey[200], + ), ), - ), IconButton( iconSize: iconSize, splashRadius: iconSize, @@ -69,17 +68,18 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { color: Colors.grey[200], ), ), - IconButton( - iconSize: iconSize, - splashRadius: iconSize, - onPressed: () { - onMoreInfoPressed(); - }, - icon: Icon( - Icons.more_horiz_rounded, - color: Colors.grey[200], - ), - ) + if (asset.isRemote) + IconButton( + iconSize: iconSize, + splashRadius: iconSize, + onPressed: () { + onMoreInfoPressed(); + }, + icon: Icon( + Icons.more_horiz_rounded, + color: Colors.grey[200], + ), + ) ], ); } diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 13012d69384f0..b510cdd088052 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -14,12 +14,12 @@ import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart' import 'package:immich_mobile/modules/home/services/asset.service.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:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; // ignore: must_be_immutable class GalleryViewerPage extends HookConsumerWidget { - late List assetList; - final AssetResponseDto asset; + late List assetList; + final Asset asset; GalleryViewerPage({ Key? key, @@ -27,7 +27,7 @@ class GalleryViewerPage extends HookConsumerWidget { required this.asset, }) : super(key: key); - AssetResponseDto? assetDetail; + Asset? assetDetail; @override Widget build(BuildContext context, WidgetRef ref) { @@ -37,8 +37,7 @@ class GalleryViewerPage extends HookConsumerWidget { final loading = useState(false); final isZoomed = useState(false); ValueNotifier isZoomedListener = ValueNotifier(false); - - int indexOfAsset = assetList.indexOf(asset); + final indexOfAsset = useState(assetList.indexOf(asset)); PageController controller = PageController(initialPage: assetList.indexOf(asset)); @@ -52,15 +51,15 @@ class GalleryViewerPage extends HookConsumerWidget { [], ); - @override - initState(int index) { - indexOfAsset = index; - } - getAssetExif() async { - assetDetail = await ref - .watch(assetServiceProvider) - .getAssetById(assetList[indexOfAsset].id); + if (assetList[indexOfAsset.value].isRemote) { + assetDetail = await ref + .watch(assetServiceProvider) + .getAssetById(assetList[indexOfAsset.value].id); + } else { + // TODO local exif parsing? + assetDetail = assetList[indexOfAsset.value]; + } } void showInfo() { @@ -88,19 +87,20 @@ class GalleryViewerPage extends HookConsumerWidget { backgroundColor: Colors.black, appBar: TopControlAppBar( loading: loading.value, - asset: assetList[indexOfAsset], + asset: assetList[indexOfAsset.value], onMoreInfoPressed: () { showInfo(); }, - onDownloadPressed: () { - ref - .watch(imageViewerStateProvider.notifier) - .downloadAsset(assetList[indexOfAsset], context); - }, + onDownloadPressed: assetList[indexOfAsset.value].isLocal + ? null + : () { + ref.watch(imageViewerStateProvider.notifier).downloadAsset( + assetList[indexOfAsset.value].remote!, context); + }, onSharePressed: () { ref .watch(imageViewerStateProvider.notifier) - .shareAsset(assetList[indexOfAsset], context); + .shareAsset(assetList[indexOfAsset.value], context); }, ), body: SafeArea( @@ -113,14 +113,13 @@ class GalleryViewerPage extends HookConsumerWidget { itemCount: assetList.length, scrollDirection: Axis.horizontal, onPageChanged: (value) { + indexOfAsset.value = value; HapticFeedback.selectionClick(); }, itemBuilder: (context, index) { - initState(index); - getAssetExif(); - if (assetList[index].type == AssetTypeEnum.IMAGE) { + if (assetList[index].isImage) { return ImageViewerPage( authToken: 'Bearer ${box.get(accessTokenKey)}', isZoomedFunction: isZoomedMethod, @@ -139,11 +138,7 @@ class GalleryViewerPage extends HookConsumerWidget { }, child: Hero( tag: assetList[index].id, - child: VideoViewerPage( - asset: assetList[index], - videoUrl: - '${box.get(serverEndpointKey)}/asset/file?aid=${assetList[index].deviceAssetId}&did=${assetList[index].deviceId}', - ), + child: VideoViewerPage(asset: assetList[index]), ), ); } diff --git a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart index 34d7d1e0196c4..7047536e3df43 100644 --- a/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/image_viewer_page.dart @@ -8,13 +8,12 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart'; import 'package:immich_mobile/modules/home/services/asset.service.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; // ignore: must_be_immutable class ImageViewerPage extends HookConsumerWidget { final String heroTag; - final AssetResponseDto asset; + final Asset asset; final String authToken; final ValueNotifier isZoomedListener; final void Function() isZoomedFunction; @@ -30,7 +29,7 @@ class ImageViewerPage extends HookConsumerWidget { required this.threeStageLoading, }) : super(key: key); - AssetResponseDto? assetDetail; + Asset? assetDetail; @override Widget build(BuildContext context, WidgetRef ref) { @@ -38,8 +37,13 @@ class ImageViewerPage extends HookConsumerWidget { ref.watch(imageViewerStateProvider).downloadAssetStatus; getAssetExif() async { - assetDetail = - await ref.watch(assetServiceProvider).getAssetById(asset.id); + if (asset.isRemote) { + assetDetail = + await ref.watch(assetServiceProvider).getAssetById(asset.id); + } else { + // TODO local exif parsing? + assetDetail = asset; + } } useEffect( @@ -68,17 +72,13 @@ class ImageViewerPage extends HookConsumerWidget { child: Hero( tag: heroTag, child: RemotePhotoView( - thumbnailUrl: getThumbnailUrl(asset), - cacheKey: asset.id, - imageUrl: getImageUrl(asset), - previewUrl: threeStageLoading - ? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG) - : null, + asset: asset, authToken: authToken, + threeStageLoading: threeStageLoading, isZoomedFunction: isZoomedFunction, isZoomedListener: isZoomedListener, onSwipeDown: () => AutoRouter.of(context).pop(), - onSwipeUp: () => showInfo(), + onSwipeUp: asset.isRemote ? showInfo : () {}, ), ), ), diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index a2ca4e2f629f7..2d92033381dbc 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -6,24 +8,41 @@ import 'package:chewie/chewie.dart'; import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:photo_manager/photo_manager.dart'; import 'package:video_player/video_player.dart'; // ignore: must_be_immutable class VideoViewerPage extends HookConsumerWidget { - final String videoUrl; - final AssetResponseDto asset; - AssetResponseDto? assetDetail; + final Asset asset; - VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) - : super(key: key); + const VideoViewerPage({Key? key, required this.asset}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { + if (asset.isLocal) { + final AsyncValue videoFile = ref.watch(_fileFamily(asset.local!)); + return videoFile.when( + data: (data) => VideoThumbnailPlayer(file: data), + error: (error, stackTrace) => Icon( + Icons.image_not_supported_outlined, + color: Theme.of(context).primaryColor, + ), + loading: () => const Center( + child: SizedBox( + width: 75, + height: 75, + child: CircularProgressIndicator.adaptive(), + ), + ), + ); + } final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus; - - String jwtToken = Hive.box(userInfoBox).get(accessTokenKey); + final box = Hive.box(userInfoBox); + final String jwtToken = box.get(accessTokenKey); + final String videoUrl = + '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}'; return Stack( children: [ @@ -40,11 +59,21 @@ class VideoViewerPage extends HookConsumerWidget { } } -class VideoThumbnailPlayer extends StatefulWidget { - final String url; - final String? jwtToken; +final _fileFamily = + FutureProvider.family((ref, entity) async { + final file = await entity.file; + if (file == null) { + throw Exception(); + } + return file; +}); - const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken}) +class VideoThumbnailPlayer extends StatefulWidget { + final String? url; + final String? jwtToken; + final File? file; + + const VideoThumbnailPlayer({Key? key, this.url, this.jwtToken, this.file}) : super(key: key); @override @@ -63,10 +92,12 @@ class _VideoThumbnailPlayerState extends State { Future initializePlayer() async { try { - videoPlayerController = VideoPlayerController.network( - widget.url, - httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}, - ); + videoPlayerController = widget.file == null + ? VideoPlayerController.network( + widget.url!, + httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}, + ) + : VideoPlayerController.file(widget.file!); await videoPlayerController.initialize(); _createChewieController(); diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index ad3e35d8839b1..69000c3ba532e 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -50,6 +50,11 @@ class BackgroundService { _Throttle(_updateProgress, notifyInterval); late final _Throttle _throttledDetailNotify = _Throttle(_updateDetailProgress, notifyInterval); + Completer _hasAccessCompleter = Completer(); + late Future _hasAccess = + Platform.isAndroid ? _hasAccessCompleter.future : Future.value(true); + + Future get hasAccess => _hasAccess; bool get isBackgroundInitialized { return _isBackgroundInitialized; @@ -201,6 +206,15 @@ class BackgroundService { if (!Platform.isAndroid) { return true; } + if (_hasLock) { + debugPrint("WARNING: [acquireLock] called more than once"); + return true; + } + if (_hasAccessCompleter.isCompleted) { + debugPrint("WARNING: [acquireLock] _hasAccessCompleter is completed"); + _hasAccessCompleter = Completer(); + _hasAccess = _hasAccessCompleter.future; + } final int lockTime = Timeline.now; _wantsLockTime = lockTime; final ReceivePort rp = ReceivePort(_portNameLock); @@ -219,6 +233,7 @@ class BackgroundService { } _hasLock = true; rp.listen(_heartbeatListener); + _hasAccessCompleter.complete(true); return true; } @@ -271,6 +286,8 @@ class BackgroundService { } _wantsLockTime = 0; if (_hasLock) { + _hasAccessCompleter = Completer(); + _hasAccess = _hasAccessCompleter.future; IsolateNameServer.removePortNameMapping(_portNameLock); _waitingIsolate?.send(true); _waitingIsolate = null; diff --git a/mobile/lib/modules/backup/models/hive_backup_albums.model.dart b/mobile/lib/modules/backup/models/hive_backup_albums.model.dart index f4a7fe4a1a36f..e00d0835918d9 100644 --- a/mobile/lib/modules/backup/models/hive_backup_albums.model.dart +++ b/mobile/lib/modules/backup/models/hive_backup_albums.model.dart @@ -46,6 +46,17 @@ class HiveBackupAlbums { ); } + /// Returns a deep copy to allow safe modification without changing the global + /// state of [HiveBackupAlbums] before actually saving the changes + HiveBackupAlbums deepCopy() { + return HiveBackupAlbums( + selectedAlbumIds: selectedAlbumIds.toList(), + excludedAlbumsIds: excludedAlbumsIds.toList(), + lastSelectedBackupTime: lastSelectedBackupTime.toList(), + lastExcludedBackupTime: lastExcludedBackupTime.toList(), + ); + } + Map toMap() { final result = {}; diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index 172f6d6c4b014..ddf58fea84ce0 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -565,11 +565,16 @@ class BackupNotifier extends StateNotifier { state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground); final bool hasLock = await _backgroundService.acquireLock(); if (!hasLock) { + debugPrint("WARNING [resumeBackup] failed to acquireLock"); return; } - Box box = - await Hive.openBox(hiveBackupInfoBox); - HiveBackupAlbums? albums = box.get(backupInfoKey); + await Future.wait([ + Hive.openBox(hiveBackupInfoBox), + Hive.openBox(duplicatedAssetsBox), + Hive.openBox(backgroundBackupInfoBox), + ]); + final HiveBackupAlbums? albums = + Hive.box(hiveBackupInfoBox).get(backupInfoKey); Set selectedAlbums = state.selectedBackupAlbums; Set excludedAlbums = state.excludedBackupAlbums; if (albums != null) { @@ -584,8 +589,7 @@ class BackupNotifier extends StateNotifier { albums.lastExcludedBackupTime, ); } - await Hive.openBox(duplicatedAssetsBox); - final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox); + final Box backgroundBox = Hive.box(backgroundBackupInfoBox); state = state.copyWith( backupProgress: previous, selectedBackupAlbums: selectedAlbums, diff --git a/mobile/lib/modules/home/services/asset.service.dart b/mobile/lib/modules/home/services/asset.service.dart index b34c424143b83..3dbf0e8d3e687 100644 --- a/mobile/lib/modules/home/services/asset.service.dart +++ b/mobile/lib/modules/home/services/asset.service.dart @@ -1,34 +1,90 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; +import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; +import 'package:immich_mobile/modules/backup/services/backup.service.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:openapi/api.dart'; +import 'package:photo_manager/src/types/entity.dart'; final assetServiceProvider = Provider( (ref) => AssetService( ref.watch(apiServiceProvider), + ref.watch(backupServiceProvider), + ref.watch(backgroundServiceProvider), ), ); class AssetService { final ApiService _apiService; + final BackupService _backupService; + final BackgroundService _backgroundService; - AssetService(this._apiService); + AssetService(this._apiService, this._backupService, this._backgroundService); - Future?> getAllAsset() async { + /// Returns all local, remote assets in that order + Future> getAllAsset({bool urgent = false}) async { + final List assets = []; try { - return await _apiService.assetApi.getAllAssets(); + // not using `await` here to fetch local & remote assets concurrently + final Future?> remoteTask = + _apiService.assetApi.getAllAssets(); + final Iterable newLocalAssets; + final List localAssets = await _getLocalAssets(urgent); + final List remoteAssets = await remoteTask ?? []; + if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) { + final String deviceId = Hive.box(userInfoBox).get(deviceIdKey); + final Set existingIds = remoteAssets + .where((e) => e.deviceId == deviceId) + .map((e) => e.deviceAssetId) + .toSet(); + newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id)); + } else { + newLocalAssets = localAssets; + } + + assets.addAll(newLocalAssets.map((e) => Asset.local(e))); + // the order (first all local, then remote assets) is important! + assets.addAll(remoteAssets.map((e) => Asset.remote(e))); } catch (e) { debugPrint("Error [getAllAsset] ${e.toString()}"); - return null; + } + return assets; + } + + /// if [urgent] is `true`, do not block by waiting on the background service + /// to finish running. Returns an empty list instead after a timeout. + Future> _getLocalAssets(bool urgent) async { + try { + final Future hasAccess = urgent + ? _backgroundService.hasAccess + .timeout(const Duration(milliseconds: 250)) + : _backgroundService.hasAccess; + if (!await hasAccess) { + throw Exception("Error [getAllAsset] failed to gain access"); + } + final box = await Hive.openBox(hiveBackupInfoBox); + final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); + + return backupAlbumInfo != null + ? await _backupService + .buildUploadCandidates(backupAlbumInfo.deepCopy()) + : []; + } catch (e) { + debugPrint("Error [_getLocalAssets] ${e.toString()}"); + return []; } } - Future getAssetById(String assetId) async { + Future getAssetById(String assetId) async { try { - return await _apiService.assetApi.getAssetById(assetId); + return Asset.remote(await _apiService.assetApi.getAssetById(assetId)); } catch (e) { debugPrint("Error [getAssetById] ${e.toString()}"); return null; @@ -36,12 +92,12 @@ class AssetService { } Future?> deleteAssets( - Set deleteAssets, + Iterable deleteAssets, ) async { try { - List payload = []; + final List payload = []; - for (var asset in deleteAssets) { + for (final asset in deleteAssets) { payload.add(asset.id); } diff --git a/mobile/lib/modules/home/services/asset_cache.service.dart b/mobile/lib/modules/home/services/asset_cache.service.dart index 9675938b3b2f6..1275bfb54c559 100644 --- a/mobile/lib/modules/home/services/asset_cache.service.dart +++ b/mobile/lib/modules/home/services/asset_cache.service.dart @@ -1,27 +1,24 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/services/json_cache.dart'; -import 'package:openapi/api.dart'; - -class AssetCacheService extends JsonCache> { +class AssetCacheService extends JsonCache> { AssetCacheService() : super("asset_cache"); @override - void put(List data) { + void put(List data) { putRawData(data.map((e) => e.toJson()).toList()); } @override - Future> get() async { + Future> get() async { try { final mapList = await readRawData() as List; - final responseData = mapList - .map((e) => AssetResponseDto.fromJson(e)) - .whereNotNull() - .toList(); + final responseData = + mapList.map((e) => Asset.fromJson(e)).whereNotNull().toList(); return responseData; } catch (e) { @@ -33,5 +30,5 @@ class AssetCacheService extends JsonCache> { } final assetCacheServiceProvider = Provider( - (ref) => AssetCacheService(), + (ref) => AssetCacheService(), ); diff --git a/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart b/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart index 9343859599c6f..96264cf21f8df 100644 --- a/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart +++ b/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart @@ -1,6 +1,6 @@ import 'dart:math'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; enum RenderAssetGridElementType { assetRow, @@ -9,7 +9,7 @@ enum RenderAssetGridElementType { } class RenderAssetGridRow { - final List assets; + final List assets; RenderAssetGridRow(this.assets); } @@ -19,7 +19,7 @@ class RenderAssetGridElement { final RenderAssetGridRow? assetRow; final String? title; final DateTime date; - final List? relatedAssetList; + final List? relatedAssetList; RenderAssetGridElement( this.type, { @@ -31,13 +31,15 @@ class RenderAssetGridElement { } List assetsToRenderList( - List assets, int assetsPerRow) { + List assets, + int assetsPerRow, +) { List elements = []; int cursor = 0; while (cursor < assets.length) { int rowElements = min(assets.length - cursor, assetsPerRow); - final date = DateTime.parse(assets[cursor].createdAt); + final date = assets[cursor].createdAt; final rowElement = RenderAssetGridElement( RenderAssetGridElementType.assetRow, @@ -55,7 +57,9 @@ List assetsToRenderList( } List assetGroupsToRenderList( - Map> assetGroups, int assetsPerRow) { + Map> assetGroups, + int assetsPerRow, +) { List elements = []; DateTime? lastDate; @@ -64,8 +68,11 @@ List assetGroupsToRenderList( if (lastDate == null || lastDate!.month != date.month) { elements.add( - RenderAssetGridElement(RenderAssetGridElementType.monthTitle, - title: groupName, date: date), + RenderAssetGridElement( + RenderAssetGridElementType.monthTitle, + title: groupName, + date: date, + ), ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index 98ca796a6277a..f069be32cdb61 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -4,7 +4,7 @@ import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'asset_grid_data_structure.dart'; import 'daily_title_text.dart'; @@ -13,7 +13,7 @@ import 'draggable_scrollbar_custom.dart'; typedef ImmichAssetGridSelectionListener = void Function( bool, - Set, + Set, ); class ImmichAssetGridState extends State { @@ -24,20 +24,20 @@ class ImmichAssetGridState extends State { bool _scrolling = false; final Set _selectedAssets = HashSet(); - List get _assets { + List get _assets { return widget.renderList .map((e) { if (e.type == RenderAssetGridElementType.assetRow) { return e.assetRow!.assets; } else { - return List.empty(); + return List.empty(); } }) .flattened .toList(); } - Set _getSelectedAssets() { + Set _getSelectedAssets() { return _selectedAssets .map((e) => _assets.firstWhereOrNull((a) => a.id == e)) .whereNotNull() @@ -48,7 +48,7 @@ class ImmichAssetGridState extends State { widget.listener?.call(selectionActive, _getSelectedAssets()); } - void _selectAssets(List assets) { + void _selectAssets(List assets) { setState(() { for (var e in assets) { _selectedAssets.add(e.id); @@ -57,7 +57,7 @@ class ImmichAssetGridState extends State { }); } - void _deselectAssets(List assets) { + void _deselectAssets(List assets) { setState(() { for (var e in assets) { _selectedAssets.remove(e.id); @@ -74,7 +74,7 @@ class ImmichAssetGridState extends State { _callSelectionListener(false); } - bool _allAssetsSelected(List assets) { + bool _allAssetsSelected(List assets) { return widget.selectionActive && assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null; } @@ -85,7 +85,7 @@ class ImmichAssetGridState extends State { } Widget _buildThumbnailOrPlaceholder( - AssetResponseDto asset, + Asset asset, bool placeholder, ) { if (placeholder) { @@ -114,7 +114,7 @@ class ImmichAssetGridState extends State { return Row( key: Key("asset-row-${row.assets.first.id}"), - children: row.assets.map((AssetResponseDto asset) { + children: row.assets.map((Asset asset) { bool last = asset == row.assets.last; return Container( @@ -134,7 +134,7 @@ class ImmichAssetGridState extends State { Widget _buildTitle( BuildContext context, String title, - List assets, + List assets, ) { return DailyTitleText( isoDate: title, diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index fecdf66eb5f84..3dd5aca99c1f3 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -1,18 +1,15 @@ import 'package:auto_route/auto_route.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:hive_flutter/hive_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; -import 'package:openapi/api.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/immich_image.dart'; class ThumbnailImage extends HookConsumerWidget { - final AssetResponseDto asset; - final List assetList; + final Asset asset; + final List assetList; final bool showStorageIndicator; final bool useGrayBoxPlaceholder; final bool isSelected; @@ -34,12 +31,9 @@ class ThumbnailImage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - var box = Hive.box(userInfoBox); - var thumbnailRequestUrl = getThumbnailUrl(asset); var deviceId = ref.watch(authenticationProvider).deviceId; - - Widget buildSelectionIcon(AssetResponseDto asset) { + Widget buildSelectionIcon(Asset asset) { if (isSelected) { return Icon( Icons.check_circle, @@ -87,41 +81,11 @@ class ThumbnailImage extends HookConsumerWidget { ) : const Border(), ), - child: CachedNetworkImage( - cacheKey: 'thumbnail-image-${asset.id}', + child: ImmichImage( + asset, width: 300, height: 300, - memCacheHeight: 200, - maxWidthDiskCache: 200, - maxHeightDiskCache: 200, - fit: BoxFit.cover, - imageUrl: thumbnailRequestUrl, - httpHeaders: { - "Authorization": "Bearer ${box.get(accessTokenKey)}" - }, - fadeInDuration: const Duration(milliseconds: 250), - progressIndicatorBuilder: (context, url, downloadProgress) { - if (useGrayBoxPlaceholder) { - return const DecoratedBox( - decoration: BoxDecoration(color: Colors.grey), - ); - } - return Transform.scale( - scale: 0.2, - child: CircularProgressIndicator( - value: downloadProgress.progress, - ), - ); - }, - errorWidget: (context, url, error) { - debugPrint("Error getting thumbnail $url = $error"); - CachedNetworkImage.evictFromCache(thumbnailRequestUrl); - - return Icon( - Icons.image_not_supported_outlined, - color: Theme.of(context).primaryColor, - ); - }, + useGrayBoxPlaceholder: useGrayBoxPlaceholder, ), ), if (multiselectEnabled) @@ -137,14 +101,16 @@ class ThumbnailImage extends HookConsumerWidget { right: 10, bottom: 5, child: Icon( - (deviceId != asset.deviceId) - ? Icons.cloud_done_outlined - : Icons.photo_library_rounded, + asset.isRemote + ? (deviceId == asset.deviceId + ? Icons.cloud_done_outlined + : Icons.cloud_outlined) + : Icons.cloud_off_outlined, color: Colors.white, size: 18, ), ), - if (asset.type != AssetTypeEnum.IMAGE) + if (!asset.isImage) Positioned( top: 5, right: 5, diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 1b39170ccb99e..5b2fbbb416e5b 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -2,6 +2,7 @@ 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/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; @@ -14,6 +15,7 @@ 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/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart'; @@ -31,7 +33,7 @@ class HomePage extends HookConsumerWidget { final multiselectEnabled = ref.watch(multiselectProvider.notifier); final selectionEnabledHook = useState(false); - final selection = useState({}); + final selection = useState({}); final albums = ref.watch(albumProvider); final albumService = ref.watch(albumServiceProvider); @@ -60,7 +62,7 @@ class HomePage extends HookConsumerWidget { Widget buildBody() { void selectionListener( bool multiselect, - Set selectedAssets, + Set selectedAssets, ) { selectionEnabledHook.value = multiselect; selection.value = selectedAssets; @@ -76,9 +78,27 @@ class HomePage extends HookConsumerWidget { selectionEnabledHook.value = false; } + Iterable remoteOnlySelection() { + final Set assets = selection.value; + final bool onlyRemote = assets.every((e) => e.isRemote); + if (!onlyRemote) { + ImmichToast.show( + context: context, + msg: "Can not add local assets to albums yet, skipping", + gravity: ToastGravity.BOTTOM, + ); + return assets.where((a) => a.isRemote); + } + return assets; + } + void onAddToAlbum(AlbumResponseDto album) async { + final Iterable assets = remoteOnlySelection(); + if (assets.isEmpty) { + return; + } final result = await albumService.addAdditionalAssetToAlbum( - selection.value, + assets, album.id, ); @@ -103,6 +123,7 @@ class HomePage extends HookConsumerWidget { "added": result.successfullyAdded.toString(), }, ), + toastType: ToastType.success, ); } @@ -111,8 +132,11 @@ class HomePage extends HookConsumerWidget { } void onCreateNewAlbum() async { - final result = - await albumService.createAlbumWithGeneratedName(selection.value); + final Iterable assets = remoteOnlySelection(); + if (assets.isEmpty) { + return; + } + final result = await albumService.createAlbumWithGeneratedName(assets); if (result != null) { ref.watch(albumProvider.notifier).getAllAlbums(); diff --git a/mobile/lib/modules/search/models/search_result_page_state.model.dart b/mobile/lib/modules/search/models/search_result_page_state.model.dart index 1d79dff4b51e8..0bf045707c90d 100644 --- a/mobile/lib/modules/search/models/search_result_page_state.model.dart +++ b/mobile/lib/modules/search/models/search_result_page_state.model.dart @@ -1,13 +1,14 @@ import 'dart:convert'; import 'package:collection/collection.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:openapi/api.dart'; class SearchResultPageState { final bool isLoading; final bool isSuccess; final bool isError; - final List searchResult; + final List searchResult; SearchResultPageState({ required this.isLoading, @@ -20,7 +21,7 @@ class SearchResultPageState { bool? isLoading, bool? isSuccess, bool? isError, - List? searchResult, + List? searchResult, }) { return SearchResultPageState( isLoading: isLoading ?? this.isLoading, @@ -44,8 +45,9 @@ class SearchResultPageState { isLoading: map['isLoading'] ?? false, isSuccess: map['isSuccess'] ?? false, isError: map['isError'] ?? false, - searchResult: List.from( - map['searchResult']?.map((x) => AssetResponseDto.mapFromJson(x)), + searchResult: List.from( + map['searchResult'] + ?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))), ), ); } diff --git a/mobile/lib/modules/search/providers/search_result_page.provider.dart b/mobile/lib/modules/search/providers/search_result_page.provider.dart index c152be25e67bc..ae90e4ade4139 100644 --- a/mobile/lib/modules/search/providers/search_result_page.provider.dart +++ b/mobile/lib/modules/search/providers/search_result_page.provider.dart @@ -6,8 +6,8 @@ import 'package:immich_mobile/modules/search/models/search_result_page_state.mod import 'package:immich_mobile/modules/search/services/search.service.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:intl/intl.dart'; -import 'package:openapi/api.dart'; class SearchResultPageNotifier extends StateNotifier { SearchResultPageNotifier(this._searchService) @@ -30,8 +30,9 @@ class SearchResultPageNotifier extends StateNotifier { isSuccess: false, ); - List? assets = - await _searchService.searchAsset(searchTerm); + List? assets = (await _searchService.searchAsset(searchTerm)) + ?.map((e) => Asset.remote(e)) + .toList(); if (assets != null) { state = state.copyWith( @@ -61,12 +62,11 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) { var assets = ref.watch(searchResultPageProvider).searchResult; assets.sortByCompare( - (e) => DateTime.parse(e.createdAt), + (e) => e.createdAt, (a, b) => b.compareTo(a), ); return assets.groupListsBy( - (element) => DateFormat('y-MM-dd') - .format(DateTime.parse(element.createdAt).toLocal()), + (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()), ); }); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 95edb9d9cbc2f..3a00ae8c81273 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -22,6 +22,7 @@ import 'package:immich_mobile/modules/settings/views/settings_page.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/views/splash_screen.dart'; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 4d2132e6123e5..c64861bda65ad 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -65,8 +65,7 @@ class _$AppRouter extends RootStackRouter { final args = routeData.argsAs(); return MaterialPageX( routeData: routeData, - child: VideoViewerPage( - key: args.key, videoUrl: args.videoUrl, asset: args.asset)); + child: VideoViewerPage(key: args.key, asset: args.asset)); }, BackupControllerRoute.name: (routeData) { return MaterialPageX( @@ -258,9 +257,7 @@ class TabControllerRoute extends PageRouteInfo { /// [GalleryViewerPage] class GalleryViewerRoute extends PageRouteInfo { GalleryViewerRoute( - {Key? key, - required List assetList, - required AssetResponseDto asset}) + {Key? key, required List assetList, required Asset asset}) : super(GalleryViewerRoute.name, path: '/gallery-viewer-page', args: GalleryViewerRouteArgs( @@ -275,9 +272,9 @@ class GalleryViewerRouteArgs { final Key? key; - final List assetList; + final List assetList; - final AssetResponseDto asset; + final Asset asset; @override String toString() { @@ -291,7 +288,7 @@ class ImageViewerRoute extends PageRouteInfo { ImageViewerRoute( {Key? key, required String heroTag, - required AssetResponseDto asset, + required Asset asset, required String authToken, required void Function() isZoomedFunction, required ValueNotifier isZoomedListener, @@ -324,7 +321,7 @@ class ImageViewerRouteArgs { final String heroTag; - final AssetResponseDto asset; + final Asset asset; final String authToken; @@ -343,29 +340,24 @@ class ImageViewerRouteArgs { /// generated route for /// [VideoViewerPage] class VideoViewerRoute extends PageRouteInfo { - VideoViewerRoute( - {Key? key, required String videoUrl, required AssetResponseDto asset}) + VideoViewerRoute({Key? key, required Asset asset}) : super(VideoViewerRoute.name, path: '/video-viewer-page', - args: VideoViewerRouteArgs( - key: key, videoUrl: videoUrl, asset: asset)); + args: VideoViewerRouteArgs(key: key, asset: asset)); static const String name = 'VideoViewerRoute'; } class VideoViewerRouteArgs { - const VideoViewerRouteArgs( - {this.key, required this.videoUrl, required this.asset}); + const VideoViewerRouteArgs({this.key, required this.asset}); final Key? key; - final String videoUrl; - - final AssetResponseDto asset; + final Asset asset; @override String toString() { - return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl, asset: $asset}'; + return 'VideoViewerRouteArgs{key: $key, asset: $asset}'; } } diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart new file mode 100644 index 0000000000000..0d7f63892d66d --- /dev/null +++ b/mobile/lib/shared/models/asset.dart @@ -0,0 +1,117 @@ +import 'package:hive/hive.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:openapi/api.dart'; +import 'package:photo_manager/photo_manager.dart'; + +/// Asset (online or local) +class Asset { + Asset.remote(this.remote) { + local = null; + } + + Asset.local(this.local) { + remote = null; + } + + late final AssetResponseDto? remote; + late final AssetEntity? local; + + bool get isRemote => remote != null; + bool get isLocal => local != null; + + String get deviceId => + isRemote ? remote!.deviceId : Hive.box(userInfoBox).get(deviceIdKey); + + String get deviceAssetId => isRemote ? remote!.deviceAssetId : local!.id; + + String get id => isLocal ? local!.id : remote!.id; + + double? get latitude => + isLocal ? local!.latitude : remote!.exifInfo?.latitude?.toDouble(); + + double? get longitude => + isLocal ? local!.longitude : remote!.exifInfo?.longitude?.toDouble(); + + DateTime get createdAt => + isLocal ? local!.createDateTime : DateTime.parse(remote!.createdAt); + + bool get isImage => isLocal + ? local!.type == AssetType.image + : remote!.type == AssetTypeEnum.IMAGE; + + String get duration => isRemote + ? remote!.duration + : Duration(seconds: local!.duration).toString(); + + /// use only for tests + set createdAt(DateTime val) { + if (isRemote) { + remote!.createdAt = val.toIso8601String(); + } + } + + Map toJson() { + final json = {}; + if (isLocal) { + json["local"] = _assetEntityToJson(local!); + } else { + json["remote"] = remote!.toJson(); + } + return json; + } + + static Asset? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + final l = json["local"]; + if (l != null) { + return Asset.local(_assetEntityFromJson(l)); + } else { + return Asset.remote(AssetResponseDto.fromJson(json["remote"])); + } + } + return null; + } +} + +Map _assetEntityToJson(AssetEntity a) { + final json = {}; + json["id"] = a.id; + json["typeInt"] = a.typeInt; + json["width"] = a.width; + json["height"] = a.height; + json["duration"] = a.duration; + json["orientation"] = a.orientation; + json["isFavorite"] = a.isFavorite; + json["title"] = a.title; + json["createDateSecond"] = a.createDateSecond; + json["modifiedDateSecond"] = a.modifiedDateSecond; + json["latitude"] = a.latitude; + json["longitude"] = a.longitude; + json["mimeType"] = a.mimeType; + json["subtype"] = a.subtype; + return json; +} + +AssetEntity? _assetEntityFromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + return AssetEntity( + id: json["id"], + typeInt: json["typeInt"], + width: json["width"], + height: json["height"], + duration: json["duration"], + orientation: json["orientation"], + isFavorite: json["isFavorite"], + title: json["title"], + createDateSecond: json["createDateSecond"], + modifiedDateSecond: json["modifiedDateSecond"], + latitude: json["latitude"], + longitude: json["longitude"], + mimeType: json["mimeType"], + subtype: json["subtype"], + ); + } + return null; +} diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 386d71cae0fcd..9de766439a199 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -1,18 +1,23 @@ +import 'dart:collection'; + import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/home/services/asset.service.dart'; import 'package:immich_mobile/modules/home/services/asset_cache.service.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/services/device_info.service.dart'; import 'package:collection/collection.dart'; import 'package:intl/intl.dart'; import 'package:openapi/api.dart'; import 'package:photo_manager/photo_manager.dart'; -class AssetNotifier extends StateNotifier> { +class AssetNotifier extends StateNotifier> { final AssetService _assetService; final AssetCacheService _assetCacheService; final DeviceInfoService _deviceInfoService = DeviceInfoService(); + bool _getAllAssetInProgress = false; + bool _deleteInProgress = false; AssetNotifier(this._assetService, this._assetCacheService) : super([]); @@ -21,29 +26,38 @@ class AssetNotifier extends StateNotifier> { } getAllAsset() async { - final stopwatch = Stopwatch(); - - - if (await _assetCacheService.isValid() && state.isEmpty) { - stopwatch.start(); - state = await _assetCacheService.get(); - debugPrint("Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms"); - stopwatch.reset(); + if (_getAllAssetInProgress || _deleteInProgress) { + // guard against multiple calls to this method while it's still working + return; } + final stopwatch = Stopwatch(); + try { + _getAllAssetInProgress = true; + + final bool isCacheValid = await _assetCacheService.isValid(); + if (isCacheValid && state.isEmpty) { + stopwatch.start(); + state = await _assetCacheService.get(); + debugPrint( + "Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms"); + stopwatch.reset(); + } + + stopwatch.start(); + var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid); + debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms"); + stopwatch.reset(); + + state = allAssets; + } finally { + _getAllAssetInProgress = false; + } + debugPrint("[getAllAsset] setting new asset state"); stopwatch.start(); - var allAssets = await _assetService.getAllAsset(); - debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms"); + _cacheState(); + debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms"); stopwatch.reset(); - - if (allAssets != null) { - state = allAssets; - - stopwatch.start(); - _cacheState(); - debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms"); - stopwatch.reset(); - } } clearAllAsset() { @@ -52,80 +66,113 @@ class AssetNotifier extends StateNotifier> { } onNewAssetUploaded(AssetResponseDto newAsset) { - state = [...state, newAsset]; + final int i = state.indexWhere( + (a) => + a.isRemote || + (a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId), + ); + + if (i == -1 || state[i].deviceAssetId != newAsset.deviceAssetId) { + state = [...state, Asset.remote(newAsset)]; + } else { + // order is important to keep all local-only assets at the beginning! + state = [ + ...state.slice(0, i), + ...state.slice(i + 1), + Asset.remote(newAsset), + ]; + // TODO here is a place to unify local/remote assets by replacing the + // local-only asset in the state with a local&remote asset + } _cacheState(); } - deleteAssets(Set deleteAssets) async { + deleteAssets(Set deleteAssets) async { + _deleteInProgress = true; + try { + final localDeleted = await _deleteLocalAssets(deleteAssets); + final remoteDeleted = await _deleteRemoteAssets(deleteAssets); + final Set deleted = HashSet(); + deleted.addAll(localDeleted); + deleted.addAll(remoteDeleted); + if (deleted.isNotEmpty) { + state = state.where((a) => !deleted.contains(a.id)).toList(); + _cacheState(); + } + } finally { + _deleteInProgress = false; + } + } + + Future> _deleteLocalAssets(Set assetsToDelete) async { var deviceInfo = await _deviceInfoService.getDeviceInfo(); var deviceId = deviceInfo["deviceId"]; - var deleteIdList = []; + final List local = []; // Delete asset from device - for (var asset in deleteAssets) { - // Delete asset on device if present - if (asset.deviceId == deviceId) { + for (final Asset asset in assetsToDelete) { + if (asset.isLocal) { + local.add(asset.id); + } else if (asset.deviceId == deviceId) { + // Delete asset on device if it is still present var localAsset = await AssetEntity.fromId(asset.deviceAssetId); - if (localAsset != null) { - deleteIdList.add(localAsset.id); + local.add(localAsset.id); } } } - - try { - await PhotoManager.editor.deleteWithIds(deleteIdList); - } catch (e) { - debugPrint("Delete asset from device failed: $e"); - } - - // Delete asset on server - List? deleteAssetResult = - await _assetService.deleteAssets(deleteAssets); - - if (deleteAssetResult == null) { - return; - } - - for (var asset in deleteAssetResult) { - if (asset.status == DeleteAssetStatus.SUCCESS) { - state = - state.where((immichAsset) => immichAsset.id != asset.id).toList(); + if (local.isNotEmpty) { + try { + return await PhotoManager.editor.deleteWithIds(local); + } catch (e) { + debugPrint("Delete asset from device failed: $e"); } } + return []; + } - _cacheState(); + Future> _deleteRemoteAssets( + Set assetsToDelete, + ) async { + final Iterable remote = + assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!); + final List deleteAssetResult = + await _assetService.deleteAssets(remote) ?? []; + return deleteAssetResult + .where((a) => a.status == DeleteAssetStatus.SUCCESS) + .map((a) => a.id); } } -final assetProvider = - StateNotifierProvider>((ref) { +final assetProvider = StateNotifierProvider>((ref) { return AssetNotifier( ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider)); }); final assetGroupByDateTimeProvider = StateProvider((ref) { - var assets = ref.watch(assetProvider); + final assets = ref.watch(assetProvider).toList(); + // `toList()` ist needed to make a copy as to NOT sort the original list/state assets.sortByCompare( - (e) => DateTime.parse(e.createdAt), + (e) => e.createdAt, (a, b) => b.compareTo(a), ); return assets.groupListsBy( - (element) => DateFormat('y-MM-dd') - .format(DateTime.parse(element.createdAt).toLocal()), + (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()), ); }); final assetGroupByMonthYearProvider = StateProvider((ref) { - var assets = ref.watch(assetProvider); + // 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).where((e) => e.isRemote).toList(); assets.sortByCompare( - (e) => DateTime.parse(e.createdAt), + (e) => e.createdAt, (a, b) => b.compareTo(a), ); return assets.groupListsBy( - (element) => DateFormat('MMMM, y') - .format(DateTime.parse(element.createdAt).toLocal()), + (element) => DateFormat('MMMM, y').format(element.createdAt.toLocal()), ); }); diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart index 6a0e4823d7bef..185c83920ccfe 100644 --- a/mobile/lib/shared/services/share.service.dart +++ b/mobile/lib/shared/services/share.service.dart @@ -2,11 +2,11 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart'; -import 'package:openapi/api.dart'; +import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; -import 'package:path/path.dart' as p; import 'api.service.dart'; final shareServiceProvider = @@ -17,26 +17,28 @@ class ShareService { ShareService(this._apiService); - Future shareAsset(AssetResponseDto asset) async { + Future shareAsset(Asset asset) async { await shareAssets([asset]); } - Future shareAssets(List assets) async { + Future shareAssets(List assets) async { final downloadedFilePaths = assets.map((asset) async { - final res = await _apiService.assetApi.downloadFileWithHttpInfo( - asset.deviceAssetId, - asset.deviceId, - isThumb: false, - isWeb: false, - ); - - final fileName = p.basename(asset.originalPath); - - final tempDir = await getTemporaryDirectory(); - final tempFile = await File('${tempDir.path}/$fileName').create(); - tempFile.writeAsBytesSync(res.bodyBytes); - - return tempFile.path; + if (asset.isRemote) { + final tempDir = await getTemporaryDirectory(); + final fileName = basename(asset.remote!.originalPath); + final tempFile = await File('${tempDir.path}/$fileName').create(); + final res = await _apiService.assetApi.downloadFileWithHttpInfo( + asset.remote!.deviceAssetId, + asset.remote!.deviceId, + isThumb: false, + isWeb: false, + ); + tempFile.writeAsBytesSync(res.bodyBytes); + return tempFile.path; + } else { + File? f = await asset.local!.file; + return f!.path; + } }); Share.shareFiles( diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart new file mode 100644 index 0000000000000..094574b793e25 --- /dev/null +++ b/mobile/lib/shared/ui/immich_image.dart @@ -0,0 +1,96 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:photo_manager/photo_manager.dart'; + +/// Renders an Asset using local data if available, else remote data +class ImmichImage extends StatelessWidget { + const ImmichImage( + this.asset, { + required this.width, + required this.height, + this.useGrayBoxPlaceholder = false, + super.key, + }); + final Asset asset; + final bool useGrayBoxPlaceholder; + final double width; + final double height; + + @override + Widget build(BuildContext context) { + if (asset.isLocal) { + return Image( + image: AssetEntityImageProvider( + asset.local!, + isOriginal: false, + thumbnailSize: const ThumbnailSize.square(250), // like server thumbs + ), + width: width, + height: height, + fit: BoxFit.cover, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return (useGrayBoxPlaceholder + ? const SizedBox.square( + dimension: 250, + child: DecoratedBox( + decoration: BoxDecoration(color: Colors.grey), + ), + ) + : Transform.scale( + scale: 0.2, + child: const CircularProgressIndicator(), + )); + }, + errorBuilder: (context, error, stackTrace) { + debugPrint("Error getting thumb for assetId=${asset.id}: $error"); + return Icon( + Icons.image_not_supported_outlined, + color: Theme.of(context).primaryColor, + ); + }, + ); + } + final String token = Hive.box(userInfoBox).get(accessTokenKey); + final String thumbnailRequestUrl = getThumbnailUrl(asset.remote!); + return CachedNetworkImage( + imageUrl: thumbnailRequestUrl, + httpHeaders: {"Authorization": "Bearer $token"}, + cacheKey: 'thumbnail-image-${asset.id}', + width: width, + height: height, + // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and + // maxHeightDiskCache = null allows to simply store the webp thumbnail + // from the server and use it for all rendered thumbnail sizes + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 250), + progressIndicatorBuilder: (context, url, downloadProgress) { + if (useGrayBoxPlaceholder) { + return const DecoratedBox( + decoration: BoxDecoration(color: Colors.grey), + ); + } + return Transform.scale( + scale: 0.2, + child: CircularProgressIndicator( + value: downloadProgress.progress, + ), + ); + }, + errorWidget: (context, url, error) { + debugPrint("Error getting thumbnail $url = $error"); + CachedNetworkImage.evictFromCache(thumbnailRequestUrl); + return Icon( + Icons.image_not_supported_outlined, + color: Theme.of(context).primaryColor, + ); + }, + ); + } +} diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index 94d646ea07e8b..05d552d5a16b7 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -1,9 +1,10 @@ import 'package:flutter_test/flutter_test.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:openapi/api.dart'; void main() { - final List testAssets = []; + final List testAssets = []; for (int i = 0; i < 150; i++) { int month = i ~/ 31; @@ -11,39 +12,43 @@ void main() { DateTime date = DateTime(2022, month, day); - testAssets.add(AssetResponseDto( - type: AssetTypeEnum.IMAGE, - id: '$i', - deviceAssetId: '', - ownerId: '', - deviceId: '', - originalPath: '', - resizePath: '', - createdAt: date.toIso8601String(), - modifiedAt: date.toIso8601String(), - isFavorite: false, - mimeType: 'image/jpeg', - duration: '', - webpPath: '', - encodedVideoPath: '', - )); + testAssets.add( + Asset.remote( + AssetResponseDto( + type: AssetTypeEnum.IMAGE, + id: '$i', + deviceAssetId: '', + ownerId: '', + deviceId: '', + originalPath: '', + resizePath: '', + createdAt: date.toIso8601String(), + modifiedAt: date.toIso8601String(), + isFavorite: false, + mimeType: 'image/jpeg', + duration: '', + webpPath: '', + encodedVideoPath: '', + ), + ), + ); } - final Map> groups = { + final Map> groups = { '2022-01-05': testAssets.sublist(0, 5).map((e) { - e.createdAt = DateTime(2022, 1, 5).toIso8601String(); + e.createdAt = DateTime(2022, 1, 5); return e; }).toList(), '2022-01-10': testAssets.sublist(5, 10).map((e) { - e.createdAt = DateTime(2022, 1, 10).toIso8601String(); + e.createdAt = DateTime(2022, 1, 10); return e; }).toList(), '2022-02-17': testAssets.sublist(10, 15).map((e) { - e.createdAt = DateTime(2022, 2, 17).toIso8601String(); + e.createdAt = DateTime(2022, 2, 17); return e; }).toList(), '2022-10-15': testAssets.sublist(15, 30).map((e) { - e.createdAt = DateTime(2022, 10, 15).toIso8601String(); + e.createdAt = DateTime(2022, 10, 15); return e; }).toList() };