1
0
forked from Cutlery/immich

refactor(mobile): reworked Asset, store all required fields from local & remote (#1539)

replace usage of AssetResponseDto with Asset

Add new class ExifInfo to store data from ExifResponseDto
This commit is contained in:
Fynn Petersen-Frey 2023-02-04 21:42:42 +01:00 committed by GitHub
parent 7bd2455175
commit 0048662182
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 626 additions and 504 deletions

View File

@ -5,6 +5,7 @@ const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3 const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4 const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
const String assetEtagKey = 'immichAssetEtagKey'; // Key 5 const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
const String userIdKey = 'immichUserIdKey'; // Key 6
// Login Info // Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box

View File

@ -85,9 +85,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
right: 10, right: 10,
bottom: 5, bottom: 5,
child: Icon( child: Icon(
(deviceId != asset.deviceId) asset.isRemote
? Icons.cloud_done_outlined ? (deviceId == asset.deviceId
: Icons.photo_library_rounded, ? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,
color: Colors.white, color: Colors.white,
size: 18, size: 18,
), ),

View File

@ -121,7 +121,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
child: Row( child: Row(
children: [ children: [
Text( Text(
asset.duration.substring(0, 7), asset.duration.toString().substring(0, 7),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 10, fontSize: 10,

View File

@ -7,7 +7,6 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/share.service.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/immich_toast.dart';
import 'package:immich_mobile/shared/ui/share_dialog.dart'; import 'package:immich_mobile/shared/ui/share_dialog.dart';
import 'package:openapi/api.dart';
class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> { class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
final ImageViewerService _imageViewerService; final ImageViewerService _imageViewerService;
@ -20,7 +19,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
), ),
); );
void downloadAsset(AssetResponseDto asset, BuildContext context) async { void downloadAsset(Asset asset, BuildContext context) async {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading); state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset); bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);

View File

@ -2,10 +2,9 @@ import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@ -18,14 +17,12 @@ class ImageViewerService {
ImageViewerService(this._apiService); ImageViewerService(this._apiService);
Future<bool> downloadAssetToDevice(AssetResponseDto asset) async { Future<bool> downloadAssetToDevice(Asset asset) async {
try { try {
String fileName = p.basename(asset.originalPath);
// Download LivePhotos image and motion part // Download LivePhotos image and motion part
if (asset.type == AssetTypeEnum.IMAGE && asset.livePhotoVideoId != null) { if (asset.isImage && asset.livePhotoVideoId != null) {
var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo( var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo(
asset.id, asset.remoteId!,
); );
var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo( var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo(
@ -43,28 +40,28 @@ class ImageViewerService {
entity = await PhotoManager.editor.darwin.saveLivePhoto( entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: imageFile, imageFile: imageFile,
videoFile: videoFile, videoFile: videoFile,
title: p.basename(asset.originalPath), title: asset.fileName,
); );
return entity != null; return entity != null;
} else { } else {
var res = await _apiService.assetApi.downloadFileWithHttpInfo( var res = await _apiService.assetApi
asset.id, .downloadFileWithHttpInfo(asset.remoteId!);
);
final AssetEntity? entity; final AssetEntity? entity;
if (asset.type == AssetTypeEnum.IMAGE) { if (asset.isImage) {
entity = await PhotoManager.editor.saveImage( entity = await PhotoManager.editor.saveImage(
res.bodyBytes, res.bodyBytes,
title: p.basename(asset.originalPath), title: asset.fileName,
); );
} else { } else {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
File tempFile = await File('${tempDir.path}/$fileName').create(); File tempFile =
await File('${tempDir.path}/${asset.fileName}').create();
tempFile.writeAsBytesSync(res.bodyBytes); tempFile.writeAsBytesSync(res.bodyBytes);
entity = entity = await PhotoManager.editor
await PhotoManager.editor.saveVideo(tempFile, title: fileName); .saveVideo(tempFile, title: asset.fileName);
} }
return entity != null; return entity != null;
} }

View File

@ -3,9 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/utils/bytes_units.dart';
@ -68,7 +67,7 @@ class ExifBottomSheet extends HookConsumerWidget {
final textColor = Theme.of(context).primaryColor; final textColor = Theme.of(context).primaryColor;
ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo; ExifInfo? exifInfo = assetDetail.exifInfo;
buildLocationText() { buildLocationText() {
return Text( return Text(
@ -81,6 +80,17 @@ class ExifBottomSheet extends HookConsumerWidget {
); );
} }
buildSizeText(Asset a) {
String resolution = a.width != null && a.height != null
? "${a.height} x ${a.width} "
: "";
String fileSize = a.exifInfo?.fileSize != null
? formatBytes(a.exifInfo!.fileSize!)
: "";
String text = resolution + fileSize;
return text.isEmpty ? null : Text(text);
}
return SingleChildScrollView( return SingleChildScrollView(
child: Card( child: Card(
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@ -101,19 +111,18 @@ class ExifBottomSheet extends HookConsumerWidget {
child: CustomDraggingHandle(), child: CustomDraggingHandle(),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
if (exifInfo?.dateTimeOriginal != null) Text(
Text( DateFormat('date_format'.tr()).format(
DateFormat('date_format'.tr()).format( assetDetail.createdAt.toLocal(),
exifInfo!.dateTimeOriginal!.toLocal(),
),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
), ),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
// Location // Location
if (assetDetail.latitude != null) if (assetDetail.latitude != null && assetDetail.longitude != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 32.0), padding: const EdgeInsets.only(top: 32.0),
child: Column( child: Column(
@ -126,74 +135,67 @@ class ExifBottomSheet extends HookConsumerWidget {
"exif_bottom_sheet_location", "exif_bottom_sheet_location",
style: TextStyle(fontSize: 11, color: textColor), style: TextStyle(fontSize: 11, color: textColor),
).tr(), ).tr(),
if (assetDetail.latitude != null && buildMap(),
assetDetail.longitude != null)
buildMap(),
if (exifInfo != null && if (exifInfo != null &&
exifInfo.city != null && exifInfo.city != null &&
exifInfo.state != null) exifInfo.state != null)
buildLocationText(), buildLocationText(),
Text( Text(
"${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}", "${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
) )
], ],
), ),
), ),
// Detail // Detail
if (exifInfo != null) Padding(
Padding( padding: const EdgeInsets.only(top: 32.0),
padding: const EdgeInsets.only(top: 32.0), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Divider(
Divider( thickness: 1,
thickness: 1, color: Colors.grey[600],
color: Colors.grey[600], ),
), Padding(
Padding( padding: const EdgeInsets.only(bottom: 8.0),
padding: const EdgeInsets.only(bottom: 8.0), child: Text(
child: Text( "exif_bottom_sheet_details",
"exif_bottom_sheet_details", style: TextStyle(fontSize: 11, color: textColor),
style: TextStyle(fontSize: 11, color: textColor), ).tr(),
).tr(), ),
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: const Icon(Icons.image),
title: Text(
assetDetail.fileName,
style: TextStyle(
fontWeight: FontWeight.bold,
color: textColor,
),
), ),
subtitle: buildSizeText(assetDetail),
),
if (exifInfo?.make != null)
ListTile( ListTile(
contentPadding: const EdgeInsets.all(0), contentPadding: const EdgeInsets.all(0),
dense: true, dense: true,
leading: const Icon(Icons.image), leading: const Icon(Icons.camera),
title: Text( title: Text(
"${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}", "${exifInfo!.make} ${exifInfo.model}",
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold,
color: textColor, color: textColor,
fontWeight: FontWeight.bold,
), ),
), ),
subtitle: exifInfo.exifImageHeight != null subtitle: Text(
? Text( "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
"${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${formatBytes(exifInfo.fileSizeInByte ?? 0)} ", ),
)
: null,
), ),
if (exifInfo.make != null) ],
ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: const Icon(Icons.camera),
title: Text(
"${exifInfo.make} ${exifInfo.model}",
style: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ",
),
),
],
),
), ),
),
const SizedBox( const SizedBox(
height: 50, height: 50,
), ),

View File

@ -43,7 +43,7 @@ class TopControlAppBar extends HookConsumerWidget {
), ),
), ),
actions: [ actions: [
if (asset.remote?.livePhotoVideoId != null) if (asset.livePhotoVideoId != null)
IconButton( IconButton(
iconSize: iconSize, iconSize: iconSize,
splashRadius: iconSize, splashRadius: iconSize,
@ -104,18 +104,17 @@ class TopControlAppBar extends HookConsumerWidget {
color: Colors.grey[200], color: Colors.grey[200],
), ),
), ),
if (asset.isRemote) IconButton(
IconButton( iconSize: iconSize,
iconSize: iconSize, splashRadius: iconSize,
splashRadius: iconSize, onPressed: () {
onPressed: () { onMoreInfoPressed();
onMoreInfoPressed(); },
}, icon: Icon(
icon: Icon( Icons.more_horiz_rounded,
Icons.more_horiz_rounded, color: Colors.grey[200],
color: Colors.grey[200],
),
), ),
),
], ],
); );
} }

View File

@ -13,7 +13,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_s
import 'package:immich_mobile/modules/asset_viewer/ui/exif_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/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; 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/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart'; import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.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/settings/services/app_settings.service.dart';
@ -80,31 +80,34 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
} }
/// Thumbnail image of a remote asset. Required asset.remote != null /// Thumbnail image of a remote asset. Required asset.isRemote
ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) { ImageProvider remoteThumbnailImageProvider(
Asset asset,
api.ThumbnailFormat type,
) {
return CachedNetworkImageProvider( return CachedNetworkImageProvider(
getThumbnailUrl( getThumbnailUrl(
asset.remote!, asset,
type: type, type: type,
), ),
cacheKey: getThumbnailCacheKey( cacheKey: getThumbnailCacheKey(
asset.remote!, asset,
type: type, type: type,
), ),
headers: {"Authorization": authToken}, headers: {"Authorization": authToken},
); );
} }
/// Original (large) image of a remote asset. Required asset.remote != null /// Original (large) image of a remote asset. Required asset.isRemote
ImageProvider originalImageProvider(Asset asset) { ImageProvider originalImageProvider(Asset asset) {
return CachedNetworkImageProvider( return CachedNetworkImageProvider(
getImageUrl(asset.remote!), getImageUrl(asset),
cacheKey: getImageCacheKey(asset.remote!), cacheKey: getImageCacheKey(asset),
headers: {"Authorization": authToken}, headers: {"Authorization": authToken},
); );
} }
/// Thumbnail image of a local asset. Required asset.local != null /// Thumbnail image of a local asset. Required asset.isLocal
ImageProvider localThumbnailImageProvider(Asset asset) { ImageProvider localThumbnailImageProvider(Asset asset) {
return AssetEntityImageProvider( return AssetEntityImageProvider(
asset.local!, asset.local!,
@ -114,10 +117,9 @@ class GalleryViewerPage extends HookConsumerWidget {
MediaQuery.of(context).size.height.floor(), MediaQuery.of(context).size.height.floor(),
), ),
); );
} }
/// Original (large) image of a local asset. Required asset.local != null /// Original (large) image of a local asset. Required asset.isLocal
ImageProvider localImageProvider(Asset asset) { ImageProvider localImageProvider(Asset asset) {
return AssetEntityImageProvider(asset.local!); return AssetEntityImageProvider(asset.local!);
} }
@ -132,7 +134,7 @@ class GalleryViewerPage extends HookConsumerWidget {
// Probably load WEBP either way // Probably load WEBP either way
precacheImage( precacheImage(
remoteThumbnailImageProvider( remoteThumbnailImageProvider(
asset, asset,
api.ThumbnailFormat.WEBP, api.ThumbnailFormat.WEBP,
), ),
context, context,
@ -154,26 +156,23 @@ class GalleryViewerPage extends HookConsumerWidget {
context, context,
); );
} }
} }
} }
} }
void showInfo() { void showInfo() {
if (assetList[indexOfAsset.value].isRemote) { showModalBottomSheet(
showModalBottomSheet( shape: RoundedRectangleBorder(
shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15.0),
borderRadius: BorderRadius.circular(15.0), ),
), barrierColor: Colors.transparent,
barrierColor: Colors.transparent, backgroundColor: Colors.transparent,
backgroundColor: Colors.transparent, isScrollControlled: true,
isScrollControlled: true, context: context,
context: context, builder: (context) {
builder: (context) { return ExifBottomSheet(assetDetail: assetDetail!);
return ExifBottomSheet(assetDetail: assetDetail!); },
}, );
);
}
} }
void handleDelete(Asset deleteAsset) { void handleDelete(Asset deleteAsset) {
@ -244,7 +243,7 @@ class GalleryViewerPage extends HookConsumerWidget {
? null ? null
: () { : () {
ref.watch(imageViewerStateProvider.notifier).downloadAsset( ref.watch(imageViewerStateProvider.notifier).downloadAsset(
assetList[indexOfAsset.value].remote!, assetList[indexOfAsset.value],
context, context,
); );
}, },
@ -256,8 +255,10 @@ class GalleryViewerPage extends HookConsumerWidget {
onToggleMotionVideo: (() { onToggleMotionVideo: (() {
isPlayingMotionVideo.value = !isPlayingMotionVideo.value; isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
}), }),
onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])), onDeletePressed: () =>
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]), handleDelete((assetList[indexOfAsset.value])),
onAddToAlbumPressed: () =>
addToAlbum(assetList[indexOfAsset.value]),
), ),
), ),
); );
@ -268,117 +269,132 @@ class GalleryViewerPage extends HookConsumerWidget {
body: Stack( body: Stack(
children: [ children: [
PhotoViewGallery.builder( PhotoViewGallery.builder(
scaleStateChangedCallback: (state) { scaleStateChangedCallback: (state) {
isZoomed.value = state != PhotoViewScaleState.initial; isZoomed.value = state != PhotoViewScaleState.initial;
showAppBar.value = !isZoomed.value; showAppBar.value = !isZoomed.value;
}, },
pageController: controller, pageController: controller,
scrollPhysics: isZoomed.value scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS : (Platform.isIOS
? const BouncingScrollPhysics() // Use bouncing physics for iOS ? const BouncingScrollPhysics() // Use bouncing physics for iOS
: const ClampingScrollPhysics() // Use heavy physics for Android : const ClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: assetList.length,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
// Precache image
if (indexOfAsset.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;
HapticFeedback.selectionClick();
},
loadingBuilder: isLoadPreview.value ? (context, event) {
final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.WEBP),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.WEBP),
httpHeaders: { 'Authorization': authToken },
progressIndicatorBuilder: (_, __, ___) => const Center(child: ImmichLoadingIndicator(),),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
);
return CachedNetworkImage(
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG),
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG),
httpHeaders: { 'Authorization': authToken },
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
);
} else {
return Image(
image: localThumbnailImageProvider(asset),
fit: BoxFit.contain,
);
}
} : null,
builder: (context, index) {
getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
// Show photo
final ImageProvider provider;
if (assetList[index].isLocal) {
provider = localImageProvider(assetList[index]);
} else {
if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]);
} else {
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.JPEG,
);
}
}
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) => localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) => showAppBar.value = !showAppBar.value,
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
minScale: PhotoViewComputedScale.contained,
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) => localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) => showAppBar.value = !showAppBar.value,
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
maxScale: 1.0,
minScale: 1.0,
child: SafeArea(
child: VideoViewerPage(
asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () {
if (isPlayingMotionVideo.value) {
isPlayingMotionVideo.value = false;
}
},
),
), ),
); itemCount: assetList.length,
} scrollDirection: Axis.horizontal,
}, onPageChanged: (value) {
), // Precache image
Positioned( if (indexOfAsset.value < value) {
top: 0, // Moving forwards, so precache the next asset
left: 0, precacheNextImage(value + 1);
right: 0, } else {
child: buildAppBar(), // Moving backwards, so precache previous asset
), precacheNextImage(value - 1);
], }
indexOfAsset.value = value;
HapticFeedback.selectionClick();
},
loadingBuilder: isLoadPreview.value
? (context, event) {
final asset = assetList[indexOfAsset.value];
if (!asset.isLocal) {
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive
// Three-Stage Loading (WEBP -> JPEG -> Original)
final webPThumbnail = CachedNetworkImage(
imageUrl: getThumbnailUrl(asset),
cacheKey: getThumbnailCacheKey(asset),
httpHeaders: {'Authorization': authToken},
progressIndicatorBuilder: (_, __, ___) => const Center(
child: ImmichLoadingIndicator(),
),
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
);
return CachedNetworkImage(
imageUrl: getThumbnailUrl(
asset,
type: api.ThumbnailFormat.JPEG,
),
cacheKey: getThumbnailCacheKey(
asset,
type: api.ThumbnailFormat.JPEG,
),
httpHeaders: {'Authorization': authToken},
fit: BoxFit.contain,
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
);
} else {
return Image(
image: localThumbnailImageProvider(asset),
fit: BoxFit.contain,
);
}
}
: null,
builder: (context, index) {
getAssetExif();
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
// Show photo
final ImageProvider provider;
if (assetList[index].isLocal) {
provider = localImageProvider(assetList[index]);
} else {
if (isLoadOriginal.value) {
provider = originalImageProvider(assetList[index]);
} else {
provider = remoteThumbnailImageProvider(
assetList[index],
api.ThumbnailFormat.JPEG,
);
}
}
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) =>
showAppBar.value = !showAppBar.value,
imageProvider: provider,
heroAttributes:
PhotoViewHeroAttributes(tag: assetList[index].id),
minScale: PhotoViewComputedScale.contained,
);
} else {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
onTapDown: (_, __, ___) =>
showAppBar.value = !showAppBar.value,
heroAttributes:
PhotoViewHeroAttributes(tag: assetList[index].id),
maxScale: 1.0,
minScale: 1.0,
child: SafeArea(
child: VideoViewerPage(
asset: assetList[index],
isMotionVideo: isPlayingMotionVideo.value,
onVideoEnded: () {
if (isPlayingMotionVideo.value) {
isPlayingMotionVideo.value = false;
}
},
),
),
);
}
},
),
Positioned(
top: 0,
left: 0,
right: 0,
child: buildAppBar(),
),
],
), ),
); );
} }
} }

View File

@ -53,8 +53,8 @@ class VideoViewerPage extends HookConsumerWidget {
final box = Hive.box(userInfoBox); final box = Hive.box(userInfoBox);
final String jwtToken = box.get(accessTokenKey); final String jwtToken = box.get(accessTokenKey);
final String videoUrl = isMotionVideo final String videoUrl = isMotionVideo
? '${box.get(serverEndpointKey)}/asset/file/${asset.remote?.livePhotoVideoId!}' ? '${box.get(serverEndpointKey)}/asset/file/${asset.livePhotoVideoId}'
: '${box.get(serverEndpointKey)}/asset/file/${asset.id}'; : '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}';
return Stack( return Stack(
children: [ children: [

View File

@ -75,6 +75,9 @@ class BackupService {
final filter = FilterOptionGroup( final filter = FilterOptionGroup(
containsPathModified: true, containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)], orders: [const OrderOption(type: OrderOptionType.updateDate)],
// title is needed to create Assets
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
); );
final now = DateTime.now(); final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums = final List<AssetPathEntity?> selectedAlbums =

View File

@ -1,76 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:openapi/api.dart';
class ImmichAssetGroupByDate {
final String date;
List<AssetResponseDto> assets;
ImmichAssetGroupByDate({
required this.date,
required this.assets,
});
ImmichAssetGroupByDate copyWith({
String? date,
List<AssetResponseDto>? assets,
}) {
return ImmichAssetGroupByDate(
date: date ?? this.date,
assets: assets ?? this.assets,
);
}
@override
String toString() => 'ImmichAssetGroupByDate(date: $date, assets: $assets)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ImmichAssetGroupByDate &&
other.date == date &&
listEquals(other.assets, assets);
}
@override
int get hashCode => date.hashCode ^ assets.hashCode;
}
class GetAllAssetResponse {
final int count;
final List<ImmichAssetGroupByDate> data;
final String nextPageKey;
GetAllAssetResponse({
required this.count,
required this.data,
required this.nextPageKey,
});
GetAllAssetResponse copyWith({
int? count,
List<ImmichAssetGroupByDate>? data,
String? nextPageKey,
}) {
return GetAllAssetResponse(
count: count ?? this.count,
data: data ?? this.data,
nextPageKey: nextPageKey ?? this.nextPageKey,
);
}
@override
String toString() =>
'GetAllAssetResponse(count: $count, data: $data, nextPageKey: $nextPageKey)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is GetAllAssetResponse &&
other.count == count &&
listEquals(other.data, data) &&
other.nextPageKey == nextPageKey;
}
@override
int get hashCode => count.hashCode ^ data.hashCode ^ nextPageKey.hashCode;
}

View File

@ -24,7 +24,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
bool _scrolling = false; bool _scrolling = false;
final Set<String> _selectedAssets = HashSet(); final Set<String> _selectedAssets = HashSet();
Set<Asset> _getSelectedAssets() { Set<Asset> _getSelectedAssets() {
return _selectedAssets return _selectedAssets
.map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e)) .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
@ -103,7 +102,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
return Row( return Row(
key: Key("asset-row-${row.assets.first.id}"), key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.map((Asset asset) { children: row.assets.map((Asset asset) {
bool last = asset == row.assets.last; bool last = asset.id == row.assets.last.id;
return Container( return Container(
key: Key("asset-${asset.id}"), key: Key("asset-${asset.id}"),
@ -224,7 +223,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
} }
} }
Future<bool> onWillPop() async { Future<bool> onWillPop() async {
if (widget.selectionActive && _selectedAssets.isNotEmpty) { if (widget.selectionActive && _selectedAssets.isNotEmpty) {
_deselectAll(); _deselectAll();
@ -234,8 +232,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
return true; return true;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return WillPopScope( return WillPopScope(

View File

@ -4,7 +4,7 @@ import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart'; import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart'; import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart';
@ -166,6 +166,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
var deviceInfo = await _deviceInfoService.getDeviceInfo(); var deviceInfo = await _deviceInfoService.getDeviceInfo();
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]); userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
userInfoHiveBox.put(accessTokenKey, accessToken); userInfoHiveBox.put(accessTokenKey, accessToken);
userInfoHiveBox.put(userIdKey, userResponseDto.id);
state = state.copyWith( state = state.copyWith(
isAuthenticated: true, isAuthenticated: true,

View File

@ -45,9 +45,11 @@ class SearchResultPageState {
isLoading: map['isLoading'] ?? false, isLoading: map['isLoading'] ?? false,
isSuccess: map['isSuccess'] ?? false, isSuccess: map['isSuccess'] ?? false,
isError: map['isError'] ?? false, isError: map['isError'] ?? false,
searchResult: List<Asset>.from( searchResult: List.from(
map['searchResult'] map['searchResult']
?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))), .map(AssetResponseDto.fromJson)
.where((e) => e != null)
.map(Asset.remote),
), ),
); );
} }

View File

@ -30,9 +30,7 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
isSuccess: false, isSuccess: false,
); );
List<Asset>? assets = (await _searchService.searchAsset(searchTerm)) List<Asset>? assets = await _searchService.searchAsset(searchTerm);
?.map((e) => Asset.remote(e))
.toList();
if (assets != null) { if (assets != null) {
state = state.copyWith( state = state.copyWith(

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@ -24,10 +25,14 @@ class SearchService {
} }
} }
Future<List<AssetResponseDto>?> searchAsset(String searchTerm) async { Future<List<Asset>?> searchAsset(String searchTerm) async {
try { try {
return await _apiService.assetApi final List<AssetResponseDto>? results = await _apiService.assetApi
.searchAsset(SearchAssetDto(searchTerm: searchTerm)); .searchAsset(SearchAssetDto(searchTerm: searchTerm));
if (results == null) {
return null;
}
return results.map((e) => Asset.remote(e)).toList();
} catch (e) { } catch (e) {
debugPrint("[ERROR] [searchAsset] ${e.toString()}"); debugPrint("[ERROR] [searchAsset] ${e.toString()}");
return null; return null;
@ -50,7 +55,7 @@ class SearchService {
return await _apiService.assetApi.getCuratedObjects(); return await _apiService.assetApi.getCuratedObjects();
} catch (e) { } catch (e) {
debugPrint("Error [getCuratedObjects] ${e.toString()}"); debugPrint("Error [getCuratedObjects] ${e.toString()}");
throw []; return [];
} }
} }
} }

View File

@ -1,63 +1,128 @@
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart';
import 'package:path/path.dart' as p;
/// Asset (online or local) /// Asset (online or local)
class Asset { class Asset {
Asset.remote(this.remote) { Asset.remote(AssetResponseDto remote)
local = null; : remoteId = remote.id,
} createdAt = DateTime.parse(remote.createdAt),
modifiedAt = DateTime.parse(remote.modifiedAt),
durationInSeconds = remote.duration.toDuration().inSeconds,
fileName = p.basename(remote.originalPath),
height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId,
deviceAssetId = remote.deviceAssetId,
deviceId = remote.deviceId,
ownerId = remote.ownerId,
latitude = remote.exifInfo?.latitude?.toDouble(),
longitude = remote.exifInfo?.longitude?.toDouble(),
exifInfo =
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null;
Asset.local(this.local) { Asset.local(AssetEntity local, String owner)
remote = null; : localId = local.id,
} latitude = local.latitude,
longitude = local.longitude,
late final AssetResponseDto? remote; durationInSeconds = local.duration,
late final AssetEntity? local; height = local.height,
width = local.width,
bool get isRemote => remote != null; fileName = local.title!,
bool get isLocal => local != null; deviceAssetId = local.id,
deviceId = Hive.box(userInfoBox).get(deviceIdKey),
String get deviceId => ownerId = owner,
isRemote ? remote!.deviceId : Hive.box(userInfoBox).get(deviceIdKey); modifiedAt = local.modifiedDateTime.toUtc(),
createdAt = local.createDateTime.toUtc() {
String get deviceAssetId => isRemote ? remote!.deviceAssetId : local!.id; if (createdAt.year == 1970) {
createdAt = modifiedAt;
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 {
if (isLocal) {
if (local!.createDateTime.year == 1970) {
return local!.modifiedDateTime;
}
return local!.createDateTime;
} else {
return DateTime.parse(remote!.createdAt);
} }
} }
bool get isImage => isLocal Asset({
? local!.type == AssetType.image this.localId,
: remote!.type == AssetTypeEnum.IMAGE; this.remoteId,
required this.deviceAssetId,
required this.deviceId,
required this.ownerId,
required this.createdAt,
required this.modifiedAt,
this.latitude,
this.longitude,
required this.durationInSeconds,
this.width,
this.height,
required this.fileName,
this.livePhotoVideoId,
this.exifInfo,
});
String get duration => isRemote AssetEntity? _local;
? remote!.duration
: Duration(seconds: local!.duration).toString();
/// use only for tests AssetEntity? get local {
set createdAt(DateTime val) { if (isLocal && _local == null) {
if (isRemote) { _local = AssetEntity(
remote!.createdAt = val.toIso8601String(); id: localId!.toString(),
typeInt: isImage ? 1 : 2,
width: width!,
height: height!,
duration: durationInSeconds,
createDateSecond: createdAt.millisecondsSinceEpoch ~/ 1000,
latitude: latitude,
longitude: longitude,
modifiedDateSecond: modifiedAt.millisecondsSinceEpoch ~/ 1000,
title: fileName,
);
} }
return _local;
} }
String? localId;
String? remoteId;
String deviceAssetId;
String deviceId;
String ownerId;
DateTime createdAt;
DateTime modifiedAt;
double? latitude;
double? longitude;
int durationInSeconds;
int? width;
int? height;
String fileName;
String? livePhotoVideoId;
ExifInfo? exifInfo;
String get id => isLocal ? localId.toString() : remoteId!;
String get name => p.withoutExtension(fileName);
bool get isRemote => remoteId != null;
bool get isLocal => localId != null;
bool get isImage => durationInSeconds == 0;
Duration get duration => Duration(seconds: durationInSeconds);
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Asset) return false; if (other is! Asset) return false;
@ -67,12 +132,26 @@ class Asset {
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode;
// methods below are only required for caching as JSON
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
if (isLocal) { json["localId"] = localId;
json["local"] = _assetEntityToJson(local!); json["remoteId"] = remoteId;
} else { json["deviceAssetId"] = deviceAssetId;
json["remote"] = remote!.toJson(); json["deviceId"] = deviceId;
json["ownerId"] = ownerId;
json["createdAt"] = createdAt.millisecondsSinceEpoch;
json["modifiedAt"] = modifiedAt.millisecondsSinceEpoch;
json["latitude"] = latitude;
json["longitude"] = longitude;
json["durationInSeconds"] = durationInSeconds;
json["width"] = width;
json["height"] = height;
json["fileName"] = fileName;
json["livePhotoVideoId"] = livePhotoVideoId;
if (exifInfo != null) {
json["exifInfo"] = exifInfo!.toJson();
} }
return json; return json;
} }
@ -80,55 +159,28 @@ class Asset {
static Asset? fromJson(dynamic value) { static Asset? fromJson(dynamic value) {
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
final l = json["local"]; return Asset(
if (l != null) { localId: json["localId"],
return Asset.local(_assetEntityFromJson(l)); remoteId: json["remoteId"],
} else { deviceAssetId: json["deviceAssetId"],
return Asset.remote(AssetResponseDto.fromJson(json["remote"])); deviceId: json["deviceId"],
} ownerId: json["ownerId"],
createdAt:
DateTime.fromMillisecondsSinceEpoch(json["createdAt"], isUtc: true),
modifiedAt: DateTime.fromMillisecondsSinceEpoch(
json["modifiedAt"],
isUtc: true,
),
latitude: json["latitude"],
longitude: json["longitude"],
durationInSeconds: json["durationInSeconds"],
width: json["width"],
height: json["height"],
fileName: json["fileName"],
livePhotoVideoId: json["livePhotoVideoId"],
exifInfo: ExifInfo.fromJson(json["exifInfo"]),
);
} }
return null; return null;
} }
} }
Map<String, dynamic> _assetEntityToJson(AssetEntity a) {
final json = <String, dynamic>{};
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<String, dynamic>();
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;
}

View File

@ -0,0 +1,86 @@
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart';
class ExifInfo {
int? fileSize;
String? make;
String? model;
String? orientation;
String? lensModel;
double? fNumber;
double? focalLength;
int? iso;
double? exposureTime;
String? city;
String? state;
String? country;
ExifInfo.fromDto(ExifResponseDto dto)
: fileSize = dto.fileSizeInByte,
make = dto.make,
model = dto.model,
orientation = dto.orientation,
lensModel = dto.lensModel,
fNumber = dto.fNumber?.toDouble(),
focalLength = dto.focalLength?.toDouble(),
iso = dto.iso?.toInt(),
exposureTime = dto.exposureTime?.toDouble(),
city = dto.city,
state = dto.state,
country = dto.country;
// stuff below is only required for caching as JSON
ExifInfo(
this.fileSize,
this.make,
this.model,
this.orientation,
this.lensModel,
this.fNumber,
this.focalLength,
this.iso,
this.exposureTime,
this.city,
this.state,
this.country,
);
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json["fileSize"] = fileSize;
json["make"] = make;
json["model"] = model;
json["orientation"] = orientation;
json["lensModel"] = lensModel;
json["fNumber"] = fNumber;
json["focalLength"] = focalLength;
json["iso"] = iso;
json["exposureTime"] = exposureTime;
json["city"] = city;
json["state"] = state;
json["country"] = country;
return json;
}
static ExifInfo? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return ExifInfo(
json["fileSize"],
json["make"],
json["model"],
json["orientation"],
json["lensModel"],
json["fNumber"],
json["focalLength"],
json["iso"],
json["exposureTime"],
json["city"],
json["state"],
json["country"],
);
}
return null;
}
}

View File

@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart'; import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart'; import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@ -36,7 +36,7 @@ class AssetsState {
return AssetsState([...allAssets, ...toAdd]); return AssetsState([...allAssets, ...toAdd]);
} }
_groupByDate() async { Future<Map<String, List<Asset>>> _groupByDate() async {
sortCompare(List<Asset> assets) { sortCompare(List<Asset> assets) {
assets.sortByCompare<DateTime>( assets.sortByCompare<DateTime>(
(e) => e.createdAt, (e) => e.createdAt,
@ -50,11 +50,11 @@ class AssetsState {
return await compute(sortCompare, allAssets.toList()); return await compute(sortCompare, allAssets.toList());
} }
static fromAssetList(List<Asset> assets) { static AssetsState fromAssetList(List<Asset> assets) {
return AssetsState(assets); return AssetsState(assets);
} }
static empty() { static AssetsState empty() {
return AssetsState([]); return AssetsState([]);
} }
} }
@ -82,7 +82,10 @@ class AssetNotifier extends StateNotifier<AssetsState> {
this._settingsService, this._settingsService,
) : super(AssetsState.fromAssetList([])); ) : super(AssetsState.fromAssetList([]));
_updateAssetsState(List<Asset> newAssetList, {bool cache = true}) async { Future<void> _updateAssetsState(
List<Asset> newAssetList, {
bool cache = true,
}) async {
if (cache) { if (cache) {
_assetCacheService.put(newAssetList); _assetCacheService.put(newAssetList);
} }
@ -101,20 +104,26 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final stopwatch = Stopwatch(); final stopwatch = Stopwatch();
try { try {
_getAllAssetInProgress = true; _getAllAssetInProgress = true;
final bool isCacheValid = await _assetCacheService.isValid(); bool isCacheValid = await _assetCacheService.isValid();
stopwatch.start(); stopwatch.start();
final Box box = Hive.box(userInfoBox); final Box box = Hive.box(userInfoBox);
if (isCacheValid && state.allAssets.isEmpty) {
final List<Asset>? cachedData = await _assetCacheService.get();
if (cachedData == null) {
isCacheValid = false;
log.warning("Cached asset data is invalid, fetching new data");
} else {
await _updateAssetsState(cachedData, cache: false);
log.info(
"Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms",
);
}
stopwatch.reset();
}
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid); final localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
final remoteTask = _assetService.getRemoteAssets( final remoteTask = _assetService.getRemoteAssets(
etag: isCacheValid ? box.get(assetEtagKey) : null, etag: isCacheValid ? box.get(assetEtagKey) : null,
); );
if (isCacheValid && state.allAssets.isEmpty) {
await _updateAssetsState(await _assetCacheService.get(), cache: false);
log.info(
"Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote); int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote);
remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin; remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
@ -184,7 +193,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
_updateAssetsState([]); _updateAssetsState([]);
} }
onNewAssetUploaded(AssetResponseDto newAsset) { void onNewAssetUploaded(Asset newAsset) {
final int i = state.allAssets.indexWhere( final int i = state.allAssets.indexWhere(
(a) => (a) =>
a.isRemote || a.isRemote ||
@ -192,13 +201,13 @@ class AssetNotifier extends StateNotifier<AssetsState> {
); );
if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) { if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) {
_updateAssetsState([...state.allAssets, Asset.remote(newAsset)]); _updateAssetsState([...state.allAssets, newAsset]);
} else { } else {
// order is important to keep all local-only assets at the beginning! // order is important to keep all local-only assets at the beginning!
_updateAssetsState([ _updateAssetsState([
...state.allAssets.slice(0, i), ...state.allAssets.slice(0, i),
...state.allAssets.slice(i + 1), ...state.allAssets.slice(i + 1),
Asset.remote(newAsset), newAsset,
]); ]);
// TODO here is a place to unify local/remote assets by replacing the // TODO here is a place to unify local/remote assets by replacing the
// local-only asset in the state with a local&remote asset // local-only asset in the state with a local&remote asset
@ -230,7 +239,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
// Delete asset from device // Delete asset from device
for (final Asset asset in assetsToDelete) { for (final Asset asset in assetsToDelete) {
if (asset.isLocal) { if (asset.isLocal) {
local.add(asset.id); local.add(asset.localId!);
} else if (asset.deviceId == deviceId) { } else if (asset.deviceId == deviceId) {
// Delete asset on device if it is still present // Delete asset on device if it is still present
var localAsset = await AssetEntity.fromId(asset.deviceAssetId); var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
@ -252,8 +261,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
Future<Iterable<String>> _deleteRemoteAssets( Future<Iterable<String>> _deleteRemoteAssets(
Set<Asset> assetsToDelete, Set<Asset> assetsToDelete,
) async { ) async {
final Iterable<AssetResponseDto> remote = final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!);
final List<DeleteAssetResponseDto> deleteAssetResult = final List<DeleteAssetResponseDto> deleteAssetResult =
await _assetService.deleteAssets(remote) ?? []; await _assetService.deleteAssets(remote) ?? [];
return deleteAssetResult return deleteAssetResult

View File

@ -5,6 +5,7 @@ import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
@ -91,14 +92,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
state = WebsocketState(isConnected: false, socket: null); state = WebsocketState(isConnected: false, socket: null);
}); });
socket.on('on_upload_success', (data) { socket.on('on_upload_success', _handleOnUploadSuccess);
var jsonString = jsonDecode(data.toString());
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
if (newAsset != null) {
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
}
});
} catch (e) { } catch (e) {
debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
} }
@ -122,14 +116,16 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
listenUploadEvent() { listenUploadEvent() {
debugPrint("Start listening to event on_upload_success"); debugPrint("Start listening to event on_upload_success");
state.socket?.on('on_upload_success', (data) { state.socket?.on('on_upload_success', _handleOnUploadSuccess);
var jsonString = jsonDecode(data.toString()); }
AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString);
if (newAsset != null) { _handleOnUploadSuccess(dynamic data) {
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset); final jsonString = jsonDecode(data.toString());
} final dto = AssetResponseDto.fromJson(jsonString);
}); if (dto != null) {
final newAsset = Asset.remote(dto);
ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset);
}
} }
} }

View File

@ -62,10 +62,11 @@ class AssetService {
} }
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
final String userId = Hive.box(userInfoBox).get(userIdKey);
if (backupAlbumInfo != null) { if (backupAlbumInfo != null) {
return (await _backupService return (await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy())) .buildUploadCandidates(backupAlbumInfo.deepCopy()))
.map(Asset.local) .map((e) => Asset.local(e, userId))
.toList(growable: false); .toList(growable: false);
} }
} catch (e) { } catch (e) {
@ -76,21 +77,24 @@ class AssetService {
Future<Asset?> getAssetById(String assetId) async { Future<Asset?> getAssetById(String assetId) async {
try { try {
return Asset.remote(await _apiService.assetApi.getAssetById(assetId)); final dto = await _apiService.assetApi.getAssetById(assetId);
if (dto != null) {
return Asset.remote(dto);
}
} catch (e) { } catch (e) {
debugPrint("Error [getAssetById] ${e.toString()}"); debugPrint("Error [getAssetById] ${e.toString()}");
return null;
} }
return null;
} }
Future<List<DeleteAssetResponseDto>?> deleteAssets( Future<List<DeleteAssetResponseDto>?> deleteAssets(
Iterable<AssetResponseDto> deleteAssets, Iterable<Asset> deleteAssets,
) async { ) async {
try { try {
final List<String> payload = []; final List<String> payload = [];
for (final asset in deleteAssets) { for (final asset in deleteAssets) {
payload.add(asset.id); payload.add(asset.remoteId!);
} }
return await _apiService.assetApi return await _apiService.assetApi

View File

@ -23,17 +23,15 @@ class AssetCacheService extends JsonCache<List<Asset>> {
} }
@override @override
Future<List<Asset>> get() async { Future<List<Asset>?> get() async {
try { try {
final mapList = await readRawData() as List<dynamic>; final mapList = await readRawData() as List<dynamic>;
final responseData = await compute(_computeEncode, mapList); final responseData = await compute(_computeEncode, mapList);
return responseData; return responseData;
} catch (e) { } catch (e) {
debugPrint(e.toString()); debugPrint(e.toString());
await invalidate();
return []; return null;
} }
} }
} }

View File

@ -60,5 +60,5 @@ abstract class JsonCache<T> {
} }
void put(T data); void put(T data);
Future<T> get(); Future<T?> get();
} }

View File

@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'api.service.dart'; import 'api.service.dart';
@ -25,11 +24,10 @@ class ShareService {
final downloadedXFiles = assets.map<Future<XFile>>((asset) async { final downloadedXFiles = assets.map<Future<XFile>>((asset) async {
if (asset.isRemote) { if (asset.isRemote) {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final fileName = basename(asset.remote!.originalPath); final fileName = asset.fileName;
final tempFile = await File('${tempDir.path}/$fileName').create(); final tempFile = await File('${tempDir.path}/$fileName').create();
final res = await _apiService.assetApi.downloadFileWithHttpInfo( final res = await _apiService.assetApi
asset.remote!.id, .downloadFileWithHttpInfo(asset.remoteId!);
);
tempFile.writeAsBytesSync(res.bodyBytes); tempFile.writeAsBytesSync(res.bodyBytes);
return XFile(tempFile.path); return XFile(tempFile.path);
} else { } else {

View File

@ -1,5 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
@ -15,13 +16,28 @@ class ImmichImage extends StatelessWidget {
this.useGrayBoxPlaceholder = false, this.useGrayBoxPlaceholder = false,
super.key, super.key,
}); });
final Asset asset; final Asset? asset;
final bool useGrayBoxPlaceholder; final bool useGrayBoxPlaceholder;
final double width; final double width;
final double height; final double height;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (this.asset == null) {
return Container(
decoration: const BoxDecoration(
color: Colors.grey,
),
child: SizedBox(
width: width,
height: height,
child: const Center(
child: Icon(Icons.no_photography),
),
),
);
}
final Asset asset = this.asset!;
if (asset.isLocal) { if (asset.isLocal) {
return Image( return Image(
image: AssetEntityImageProvider( image: AssetEntityImageProvider(
@ -49,7 +65,16 @@ class ImmichImage extends StatelessWidget {
)); ));
}, },
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
debugPrint("Error getting thumb for assetId=${asset.id}: $error"); if (error is PlatformException &&
error.code == "The asset not found!") {
debugPrint(
"Asset ${asset.localId} does not exist anymore on device!",
);
} else {
debugPrint(
"Error getting thumb for assetId=${asset.localId}: $error",
);
}
return Icon( return Icon(
Icons.image_not_supported_outlined, Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
@ -57,12 +82,12 @@ class ImmichImage extends StatelessWidget {
}, },
); );
} }
final String token = Hive.box(userInfoBox).get(accessTokenKey); final String? token = Hive.box(userInfoBox).get(accessTokenKey);
final String thumbnailRequestUrl = getThumbnailUrl(asset.remote!); final String thumbnailRequestUrl = getThumbnailUrl(asset);
return CachedNetworkImage( return CachedNetworkImage(
imageUrl: thumbnailRequestUrl, imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer $token"}, httpHeaders: {"Authorization": "Bearer $token"},
cacheKey: getThumbnailCacheKey(asset.remote!), cacheKey: getThumbnailCacheKey(asset),
width: width, width: width,
height: height, height: height,
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and

View File

@ -0,0 +1,11 @@
extension DurationExtension on String {
Duration toDuration() {
final parts =
split(':').map((e) => double.parse(e).toInt()).toList(growable: false);
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
}
double? toDouble() {
return double.tryParse(this);
}
}

View File

@ -1,17 +1,18 @@
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import '../constants/hive_box.dart'; import '../constants/hive_box.dart';
String getThumbnailUrl( String getThumbnailUrl(
final AssetResponseDto asset, { final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP, ThumbnailFormat type = ThumbnailFormat.WEBP,
}) { }) {
return _getThumbnailUrl(asset.id, type: type); return _getThumbnailUrl(asset.remoteId!, type: type);
} }
String getThumbnailCacheKey( String getThumbnailCacheKey(
final AssetResponseDto asset, { final Asset asset, {
ThumbnailFormat type = ThumbnailFormat.WEBP, ThumbnailFormat type = ThumbnailFormat.WEBP,
}) { }) {
return _getThumbnailCacheKey(asset.id, type); return _getThumbnailCacheKey(asset.id, type);
@ -45,12 +46,12 @@ String getAlbumThumbNailCacheKey(
return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type); return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type);
} }
String getImageUrl(final AssetResponseDto asset) { String getImageUrl(final Asset asset) {
final box = Hive.box(userInfoBox); final box = Hive.box(userInfoBox);
return '${box.get(serverEndpointKey)}/asset/file/${asset.id}?isThumb=false'; return '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}?isThumb=false';
} }
String getImageCacheKey(final AssetResponseDto asset) { String getImageCacheKey(final Asset asset) {
return '${asset.id}_fullStage'; return '${asset.id}_fullStage';
} }

View File

@ -1,7 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; 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/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
void main() { void main() {
final List<Asset> testAssets = []; final List<Asset> testAssets = [];
@ -13,24 +12,14 @@ void main() {
DateTime date = DateTime(2022, month, day); DateTime date = DateTime(2022, month, day);
testAssets.add( testAssets.add(
Asset.remote( Asset(
AssetResponseDto( deviceAssetId: '$i',
type: AssetTypeEnum.IMAGE, deviceId: '',
id: '$i', ownerId: '',
deviceAssetId: '', createdAt: date,
ownerId: '', modifiedAt: date,
deviceId: '', durationInSeconds: 0,
originalPath: '', fileName: '',
resizePath: '',
createdAt: date.toIso8601String(),
modifiedAt: date.toIso8601String(),
isFavorite: false,
mimeType: 'image/jpeg',
duration: '',
webpPath: '',
encodedVideoPath: '',
livePhotoVideoId: '',
),
), ),
); );
} }
@ -70,11 +59,20 @@ void main() {
// Day 1 // Day 1
// 15 Assets => 5 Rows // 15 Assets => 5 Rows
expect(renderList.elements.length, 18); expect(renderList.elements.length, 18);
expect(renderList.elements[0].type, RenderAssetGridElementType.monthTitle); expect(
renderList.elements[0].type,
RenderAssetGridElementType.monthTitle,
);
expect(renderList.elements[0].date.month, 1); expect(renderList.elements[0].date.month, 1);
expect(renderList.elements[7].type, RenderAssetGridElementType.monthTitle); expect(
renderList.elements[7].type,
RenderAssetGridElementType.monthTitle,
);
expect(renderList.elements[7].date.month, 2); expect(renderList.elements[7].date.month, 2);
expect(renderList.elements[11].type, RenderAssetGridElementType.monthTitle); expect(
renderList.elements[11].type,
RenderAssetGridElementType.monthTitle,
);
expect(renderList.elements[11].date.month, 10); expect(renderList.elements[11].date.month, 10);
}); });