feat(mobile): show local assets (#905)

* introduce Asset as composition of AssetResponseDTO and AssetEntity

* filter out duplicate assets (that are both local and remote, take only remote for now)

* only allow remote images to be added to albums

* introduce ImmichImage to render Asset using local or remote data

* optimized deletion of local assets

* local video file playback

* allow multiple methods to wait on background service finished

* skip local assets when adding to album from home screen

* fix and optimize delete

* show gray box placeholder for local assets

* add comments

* fix bug: duplicate assets in state after onNewAssetUploaded
This commit is contained in:
Fynn Petersen-Frey 2022-11-08 18:00:24 +01:00 committed by GitHub
parent 99da181cfc
commit 1633af7af6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 830 additions and 514 deletions

View file

@ -134,13 +134,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
} }
private fun stopEngine(result: Result?) { private fun stopEngine(result: Result?) {
clearBackgroundNotification()
engine?.destroy()
engine = null
if (result != null) { if (result != null) {
Log.d(TAG, "stopEngine result=${result}") Log.d(TAG, "stopEngine result=${result}")
resolvableFuture.set(result) resolvableFuture.set(result)
} }
engine?.destroy()
engine = null
clearBackgroundNotification()
waitOnSetForegroundAsync() waitOnSetForegroundAsync()
} }

View file

@ -35,10 +35,12 @@ void main() async {
await Future.wait([ await Future.wait([
Hive.openBox(userInfoBox), Hive.openBox(userInfoBox),
Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox), Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
Hive.openBox(hiveGithubReleaseInfoBox), Hive.openBox(hiveGithubReleaseInfoBox),
Hive.openBox(userSettingInfoBox), Hive.openBox(userSettingInfoBox),
Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox), if (!Platform.isAndroid) Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
if (!Platform.isAndroid)
Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
if (!Platform.isAndroid) Hive.openBox(backgroundBackupInfoBox),
EasyLocalization.ensureInitialized(), EasyLocalization.ensureInitialized(),
]); ]);
@ -86,8 +88,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated; var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
if (isAuthenticated) { if (isAuthenticated) {
ref.read(backupProvider.notifier).resumeBackup();
ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
ref.watch(backupProvider.notifier).resumeBackup();
ref.watch(assetProvider.notifier).getAllAsset(); ref.watch(assetProvider.notifier).getAllAsset();
ref.watch(serverInfoProvider.notifier).getServerVersion(); ref.watch(serverInfoProvider.notifier).getServerVersion();
} }

View file

@ -1,10 +1,9 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
class AssetSelectionPageResult { class AssetSelectionPageResult {
final Set<AssetResponseDto> selectedNewAsset; final Set<Asset> selectedNewAsset;
final Set<AssetResponseDto> selectedAdditionalAsset; final Set<Asset> selectedAdditionalAsset;
final bool isAlbumExist; final bool isAlbumExist;
AssetSelectionPageResult({ AssetSelectionPageResult({
@ -14,8 +13,8 @@ class AssetSelectionPageResult {
}); });
AssetSelectionPageResult copyWith({ AssetSelectionPageResult copyWith({
Set<AssetResponseDto>? selectedNewAsset, Set<Asset>? selectedNewAsset,
Set<AssetResponseDto>? selectedAdditionalAsset, Set<Asset>? selectedAdditionalAsset,
bool? isAlbumExist, bool? isAlbumExist,
}) { }) {
return AssetSelectionPageResult( return AssetSelectionPageResult(

View file

@ -1,12 +1,11 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
class AssetSelectionState { class AssetSelectionState {
final Set<String> selectedMonths; final Set<String> selectedMonths;
final Set<AssetResponseDto> selectedNewAssetsForAlbum; final Set<Asset> selectedNewAssetsForAlbum;
final Set<AssetResponseDto> selectedAdditionalAssetsForAlbum; final Set<Asset> selectedAdditionalAssetsForAlbum;
final Set<AssetResponseDto> selectedAssetsInAlbumViewer; final Set<Asset> selectedAssetsInAlbumViewer;
final bool isMultiselectEnable; final bool isMultiselectEnable;
/// Indicate the asset selection page is navigated from existing album /// Indicate the asset selection page is navigated from existing album
@ -22,9 +21,9 @@ class AssetSelectionState {
AssetSelectionState copyWith({ AssetSelectionState copyWith({
Set<String>? selectedMonths, Set<String>? selectedMonths,
Set<AssetResponseDto>? selectedNewAssetsForAlbum, Set<Asset>? selectedNewAssetsForAlbum,
Set<AssetResponseDto>? selectedAdditionalAssetsForAlbum, Set<Asset>? selectedAdditionalAssetsForAlbum,
Set<AssetResponseDto>? selectedAssetsInAlbumViewer, Set<Asset>? selectedAssetsInAlbumViewer,
bool? isMultiselectEnable, bool? isMultiselectEnable,
bool? isAlbumExist, bool? isAlbumExist,
}) { }) {

View file

@ -1,6 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.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.service.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/shared/models/asset.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> { class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
@ -13,7 +14,6 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
} }
getAllAlbums() async { getAllAlbums() async {
if (await _albumCacheService.isValid() && state.isEmpty) { if (await _albumCacheService.isValid() && state.isEmpty) {
state = await _albumCacheService.get(); state = await _albumCacheService.get();
} }
@ -34,7 +34,7 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
Future<AlbumResponseDto?> createAlbum( Future<AlbumResponseDto?> createAlbum(
String albumTitle, String albumTitle,
Set<AssetResponseDto> assets, Set<Asset> assets,
) async { ) async {
AlbumResponseDto? album = AlbumResponseDto? album =
await _albumService.createAlbum(albumTitle, assets, []); await _albumService.createAlbum(albumTitle, assets, []);

View file

@ -1,7 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_state.model.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_state.model.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
AssetSelectionNotifier() AssetSelectionNotifier()
@ -22,15 +21,15 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
void removeAssetsInMonth( void removeAssetsInMonth(
String removedMonth, String removedMonth,
List<AssetResponseDto> assetsInMonth, List<Asset> assetsInMonth,
) { ) {
Set<AssetResponseDto> currentAssetList = state.selectedNewAssetsForAlbum; Set<Asset> currentAssetList = state.selectedNewAssetsForAlbum;
Set<String> currentMonthList = state.selectedMonths; Set<String> currentMonthList = state.selectedMonths;
currentMonthList currentMonthList
.removeWhere((selectedMonth) => selectedMonth == removedMonth); .removeWhere((selectedMonth) => selectedMonth == removedMonth);
for (AssetResponseDto asset in assetsInMonth) { for (Asset asset in assetsInMonth) {
currentAssetList.removeWhere((e) => e.id == asset.id); currentAssetList.removeWhere((e) => e.id == asset.id);
} }
@ -40,7 +39,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
); );
} }
void addAdditionalAssets(List<AssetResponseDto> assets) { void addAdditionalAssets(List<Asset> assets) {
state = state.copyWith( state = state.copyWith(
selectedAdditionalAssetsForAlbum: { selectedAdditionalAssetsForAlbum: {
...state.selectedAdditionalAssetsForAlbum, ...state.selectedAdditionalAssetsForAlbum,
@ -49,7 +48,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
); );
} }
void addAllAssetsInMonth(String month, List<AssetResponseDto> assetsInMonth) { void addAllAssetsInMonth(String month, List<Asset> assetsInMonth) {
state = state.copyWith( state = state.copyWith(
selectedMonths: {...state.selectedMonths, month}, selectedMonths: {...state.selectedMonths, month},
selectedNewAssetsForAlbum: { selectedNewAssetsForAlbum: {
@ -59,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
); );
} }
void addNewAssets(List<AssetResponseDto> assets) { void addNewAssets(List<Asset> assets) {
state = state.copyWith( state = state.copyWith(
selectedNewAssetsForAlbum: { selectedNewAssetsForAlbum: {
...state.selectedNewAssetsForAlbum, ...state.selectedNewAssetsForAlbum,
@ -68,20 +67,20 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
); );
} }
void removeSelectedNewAssets(List<AssetResponseDto> assets) { void removeSelectedNewAssets(List<Asset> assets) {
Set<AssetResponseDto> currentList = state.selectedNewAssetsForAlbum; Set<Asset> currentList = state.selectedNewAssetsForAlbum;
for (AssetResponseDto asset in assets) { for (Asset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id); currentList.removeWhere((e) => e.id == asset.id);
} }
state = state.copyWith(selectedNewAssetsForAlbum: currentList); state = state.copyWith(selectedNewAssetsForAlbum: currentList);
} }
void removeSelectedAdditionalAssets(List<AssetResponseDto> assets) { void removeSelectedAdditionalAssets(List<Asset> assets) {
Set<AssetResponseDto> currentList = state.selectedAdditionalAssetsForAlbum; Set<Asset> currentList = state.selectedAdditionalAssetsForAlbum;
for (AssetResponseDto asset in assets) { for (Asset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id); currentList.removeWhere((e) => e.id == asset.id);
} }
@ -109,7 +108,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
); );
} }
void addAssetsInAlbumViewer(List<AssetResponseDto> assets) { void addAssetsInAlbumViewer(List<Asset> assets) {
state = state.copyWith( state = state.copyWith(
selectedAssetsInAlbumViewer: { selectedAssetsInAlbumViewer: {
...state.selectedAssetsInAlbumViewer, ...state.selectedAssetsInAlbumViewer,
@ -118,10 +117,10 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
); );
} }
void removeAssetsInAlbumViewer(List<AssetResponseDto> assets) { void removeAssetsInAlbumViewer(List<Asset> assets) {
Set<AssetResponseDto> currentList = state.selectedAssetsInAlbumViewer; Set<Asset> currentList = state.selectedAssetsInAlbumViewer;
for (AssetResponseDto asset in assets) { for (Asset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id); currentList.removeWhere((e) => e.id == asset.id);
} }

View file

@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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.service.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/shared/models/asset.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> { class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) : super([]); SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService)
: super([]);
final AlbumService _sharedAlbumService; final AlbumService _sharedAlbumService;
final SharedAlbumCacheService _sharedAlbumCacheService; final SharedAlbumCacheService _sharedAlbumCacheService;
@ -16,7 +18,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
Future<AlbumResponseDto?> createSharedAlbum( Future<AlbumResponseDto?> createSharedAlbum(
String albumName, String albumName,
Set<AssetResponseDto> assets, Set<Asset> assets,
List<String> sharedUserIds, List<String> sharedUserIds,
) async { ) async {
try { try {

View file

@ -2,6 +2,7 @@ import 'dart:async';
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:openapi/api.dart';
@ -29,7 +30,7 @@ class AlbumService {
Future<AlbumResponseDto?> createAlbum( Future<AlbumResponseDto?> createAlbum(
String albumName, String albumName,
Set<AssetResponseDto> assets, Iterable<Asset> assets,
List<String> sharedUserIds, List<String> sharedUserIds,
) async { ) async {
try { try {
@ -65,7 +66,7 @@ class AlbumService {
} }
Future<AlbumResponseDto?> createAlbumWithGeneratedName( Future<AlbumResponseDto?> createAlbumWithGeneratedName(
Set<AssetResponseDto> assets, Iterable<Asset> assets,
) async { ) async {
return createAlbum( return createAlbum(
_getNextAlbumName(await getAlbums(isShared: false)), assets, []); _getNextAlbumName(await getAlbums(isShared: false)), assets, []);
@ -81,7 +82,7 @@ class AlbumService {
} }
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum( Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
Set<AssetResponseDto> assets, Iterable<Asset> assets,
String albumId, String albumId,
) async { ) async {
try { try {

View file

@ -1,18 +1,15 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.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/modules/login/providers/authentication.provider.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/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
class AlbumViewerThumbnail extends HookConsumerWidget { class AlbumViewerThumbnail extends HookConsumerWidget {
final AssetResponseDto asset; final Asset asset;
final List<AssetResponseDto> assetList; final List<Asset> assetList;
final bool showStorageIndicator; final bool showStorageIndicator;
const AlbumViewerThumbnail({ const AlbumViewerThumbnail({
@ -24,8 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset);
var deviceId = ref.watch(authenticationProvider).deviceId; var deviceId = ref.watch(authenticationProvider).deviceId;
final selectedAssetsInAlbumViewer = final selectedAssetsInAlbumViewer =
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
@ -120,27 +115,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
_buildThumbnailImage() { _buildThumbnailImage() {
return Container( return Container(
decoration: BoxDecoration(border: drawBorderColor()), decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage( child: ImmichImage(asset, width: 300, height: 300),
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,
);
},
),
); );
} }
@ -167,7 +142,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
children: [ children: [
_buildThumbnailImage(), _buildThumbnailImage(),
if (showStorageIndicator) _buildAssetStoreLocationIcon(), if (showStorageIndicator) _buildAssetStoreLocationIcon(),
if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(), if (!asset.isImage) _buildVideoLabel(),
if (isMultiSelectionEnable) _buildAssetSelectionIcon(), if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
], ],
), ),

View file

@ -1,10 +1,10 @@
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/modules/album/ui/selection_thumbnail_image.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 { class AssetGridByMonth extends HookConsumerWidget {
final List<AssetResponseDto> assetGroup; final List<Asset> assetGroup;
const AssetGridByMonth({Key? key, required this.assetGroup}) const AssetGridByMonth({Key? key, required this.assetGroup})
: super(key: key); : super(key: key);
@override @override

View file

@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.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 { class MonthGroupTitle extends HookConsumerWidget {
final String month; final String month;
final List<AssetResponseDto> assetGroup; final List<Asset> assetGroup;
const MonthGroupTitle({ const MonthGroupTitle({
Key? key, Key? key,

View file

@ -1,29 +1,24 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.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/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
class SelectionThumbnailImage extends HookConsumerWidget { class SelectionThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset; final Asset asset;
const SelectionThumbnailImage({Key? key, required this.asset}) const SelectionThumbnailImage({Key? key, required this.asset})
: super(key: key); : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset);
var selectedAsset = var selectedAsset =
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
var newAssetsForAlbum = var newAssetsForAlbum =
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; 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 isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
var isNewlySelected = var isNewlySelected =
newAssetsForAlbum.map((item) => item.id).contains(asset.id); newAssetsForAlbum.map((item) => item.id).contains(asset.id);
@ -110,30 +105,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
children: [ children: [
Container( Container(
decoration: BoxDecoration(border: drawBorderColor()), decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage( child: ImmichImage(asset, width: 150, height: 150),
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,
);
},
),
), ),
Padding( Padding(
padding: const EdgeInsets.all(3.0), padding: const EdgeInsets.all(3.0),
@ -142,7 +114,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
child: _buildSelectionIcon(asset), child: _buildSelectionIcon(asset),
), ),
), ),
if (asset.type != AssetTypeEnum.IMAGE) if (!asset.isImage)
Positioned( Positioned(
bottom: 5, bottom: 5,
right: 5, right: 5,

View file

@ -1,49 +1,23 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.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/shared/models/asset.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:openapi/api.dart';
class SharedAlbumThumbnailImage extends HookConsumerWidget { class SharedAlbumThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset; final Asset asset;
const SharedAlbumThumbnailImage({Key? key, required this.asset}) const SharedAlbumThumbnailImage({Key? key, required this.asset})
: super(key: key); : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
// debugPrint("View ${asset.id}"); // debugPrint("View ${asset.id}");
}, },
child: Stack( child: Stack(
children: [ children: [
CachedNetworkImage( ImmichImage(asset, width: 500, height: 500),
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,
);
},
),
], ],
), ),
); );

View file

@ -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/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';
import 'package:immich_mobile/routing/router.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_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart'; import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.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. /// If they exist, add to selected asset state to show they are already selected.
void _onAddPhotosPressed(AlbumResponseDto albumInfo) async { void _onAddPhotosPressed(AlbumResponseDto albumInfo) async {
if (albumInfo.assets.isNotEmpty == true) { if (albumInfo.assets.isNotEmpty == true) {
ref ref.watch(assetSelectionProvider.notifier).addNewAssets(
.watch(assetSelectionProvider.notifier) albumInfo.assets.map((e) => Asset.remote(e)).toList(),
.addNewAssets(albumInfo.assets.toList()); );
} }
ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true); ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true);
@ -205,8 +206,9 @@ class AlbumViewerPage extends HookConsumerWidget {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
return AlbumViewerThumbnail( return AlbumViewerThumbnail(
asset: albumInfo.assets[index], asset: Asset.remote(albumInfo.assets[index]),
assetList: albumInfo.assets, assetList:
albumInfo.assets.map((e) => Asset.remote(e)).toList(),
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,
); );
}, },

View file

@ -166,7 +166,7 @@ class CreateAlbumPage extends HookConsumerWidget {
return GestureDetector( return GestureDetector(
onTap: _onBackgroundTapped, onTap: _onBackgroundTapped,
child: SharedAlbumThumbnailImage( child: SharedAlbumThumbnailImage(
asset: selectedAssets.toList()[index], asset: selectedAssets.elementAt(index),
), ),
); );
}, },

View file

@ -18,7 +18,6 @@ class SharingPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox); var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider); final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
useEffect( useEffect(

View file

@ -1,9 +1,9 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/models/image_viewer_page_state.model.dart';
import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.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/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';
@ -47,7 +47,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle); state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
} }
void shareAsset(AssetResponseDto asset, BuildContext context) async { void shareAsset(Asset asset, BuildContext context) async {
showDialog( showDialog(
context: context, context: context,
builder: (BuildContext buildContext) { builder: (BuildContext buildContext) {

View file

@ -2,12 +2,13 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; 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:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
class ExifBottomSheet extends ConsumerWidget { class ExifBottomSheet extends ConsumerWidget {
final AssetResponseDto assetDetail; final Asset assetDetail;
const ExifBottomSheet({Key? key, required this.assetDetail}) const ExifBottomSheet({Key? key, required this.assetDetail})
: super(key: key); : super(key: key);
@ -26,8 +27,8 @@ class ExifBottomSheet extends ConsumerWidget {
child: FlutterMap( child: FlutterMap(
options: MapOptions( options: MapOptions(
center: LatLng( center: LatLng(
assetDetail.exifInfo?.latitude?.toDouble() ?? 0, assetDetail.latitude ?? 0,
assetDetail.exifInfo?.longitude?.toDouble() ?? 0, assetDetail.longitude ?? 0,
), ),
zoom: 16.0, zoom: 16.0,
), ),
@ -48,8 +49,8 @@ class ExifBottomSheet extends ConsumerWidget {
Marker( Marker(
anchorPos: AnchorPos.align(AnchorAlign.top), anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng( point: LatLng(
assetDetail.exifInfo?.latitude?.toDouble() ?? 0, assetDetail.latitude ?? 0,
assetDetail.exifInfo?.longitude?.toDouble() ?? 0, assetDetail.longitude ?? 0,
), ),
builder: (ctx) => const Image( builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'), image: AssetImage('assets/location-pin.png'),
@ -63,9 +64,11 @@ class ExifBottomSheet extends ConsumerWidget {
); );
} }
ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo;
_buildLocationText() { _buildLocationText() {
return Text( return Text(
"${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}", "${exifInfo?.city}, ${exifInfo?.state}",
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[200], color: Colors.grey[200],
@ -78,10 +81,10 @@ class ExifBottomSheet extends ConsumerWidget {
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
child: ListView( child: ListView(
children: [ children: [
if (assetDetail.exifInfo?.dateTimeOriginal != null) if (exifInfo?.dateTimeOriginal != null)
Text( Text(
DateFormat('date_format'.tr()).format( DateFormat('date_format'.tr()).format(
assetDetail.exifInfo!.dateTimeOriginal!.toLocal(), exifInfo!.dateTimeOriginal!.toLocal(),
), ),
style: TextStyle( style: TextStyle(
color: Colors.grey[400], color: Colors.grey[400],
@ -101,7 +104,7 @@ class ExifBottomSheet extends ConsumerWidget {
), ),
// Location // Location
if (assetDetail.exifInfo?.latitude != null) if (assetDetail.latitude != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 32.0), padding: const EdgeInsets.only(top: 32.0),
child: Column( child: Column(
@ -115,21 +118,22 @@ class ExifBottomSheet extends ConsumerWidget {
"exif_bottom_sheet_location", "exif_bottom_sheet_location",
style: TextStyle(fontSize: 11, color: Colors.grey[400]), style: TextStyle(fontSize: 11, color: Colors.grey[400]),
).tr(), ).tr(),
if (assetDetail.exifInfo?.latitude != null && if (assetDetail.latitude != null &&
assetDetail.exifInfo?.longitude != null) assetDetail.longitude != null)
_buildMap(), _buildMap(),
if (assetDetail.exifInfo?.city != null && if (exifInfo != null &&
assetDetail.exifInfo?.state != null) exifInfo.city != null &&
exifInfo.state != null)
_buildLocationText(), _buildLocationText(),
Text( 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]), style: TextStyle(fontSize: 12, color: Colors.grey[400]),
) )
], ],
), ),
), ),
// Detail // Detail
if (assetDetail.exifInfo != null) if (exifInfo != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 32.0), padding: const EdgeInsets.only(top: 32.0),
child: Column( child: Column(
@ -153,16 +157,16 @@ class ExifBottomSheet extends ConsumerWidget {
iconColor: Colors.grey[300], iconColor: Colors.grey[300],
leading: const Icon(Icons.image), leading: const Icon(Icons.image),
title: Text( title: Text(
"${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}", "${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}",
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: assetDetail.exifInfo?.exifImageHeight != null subtitle: exifInfo.exifImageHeight != null
? Text( ? Text(
"${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth} ${assetDetail.exifInfo?.fileSizeInByte!}B ", "${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${exifInfo.fileSizeInByte!}B ",
) )
: null, : null,
), ),
if (assetDetail.exifInfo?.make != null) if (exifInfo.make != null)
ListTile( ListTile(
contentPadding: const EdgeInsets.all(0), contentPadding: const EdgeInsets.all(0),
dense: true, dense: true,
@ -170,11 +174,11 @@ class ExifBottomSheet extends ConsumerWidget {
iconColor: Colors.grey[300], iconColor: Colors.grey[300],
leading: const Icon(Icons.camera), leading: const Icon(Icons.camera),
title: Text( title: Text(
"${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}", "${exifInfo.make} ${exifInfo.model}",
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: Text( 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} ",
), ),
), ),
], ],

View file

@ -1,17 +1,22 @@
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: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'; import 'package:photo_view/photo_view.dart';
enum _RemoteImageStatus { empty, thumbnail, preview, full } enum _RemoteImageStatus { empty, thumbnail, preview, full }
class _RemotePhotoViewState extends State<RemotePhotoView> { class _RemotePhotoViewState extends State<RemotePhotoView> {
late CachedNetworkImageProvider _imageProvider; late ImageProvider _imageProvider;
_RemoteImageStatus _status = _RemoteImageStatus.empty; _RemoteImageStatus _status = _RemoteImageStatus.empty;
bool _zoomedIn = false; bool _zoomedIn = false;
late CachedNetworkImageProvider fullProvider; late ImageProvider _fullProvider;
late CachedNetworkImageProvider previewProvider; late ImageProvider _previewProvider;
late CachedNetworkImageProvider thumbnailProvider; late ImageProvider _thumbnailProvider;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -68,7 +73,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
void _performStateTransition( void _performStateTransition(
_RemoteImageStatus newStatus, _RemoteImageStatus newStatus,
CachedNetworkImageProvider provider, ImageProvider provider,
) { ) {
if (_status == newStatus) return; if (_status == newStatus) return;
@ -90,40 +95,58 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
} }
void _loadImages() { void _loadImages() {
thumbnailProvider = _authorizedImageProvider( if (widget.asset.isLocal) {
widget.thumbnailUrl, _imageProvider = AssetEntityImageProvider(
widget.cacheKey, widget.asset.local!,
); isOriginal: false,
_imageProvider = thumbnailProvider; 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, _) { ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition( _performStateTransition(
_RemoteImageStatus.thumbnail, _RemoteImageStatus.thumbnail,
thumbnailProvider, _thumbnailProvider,
); );
}), }),
); );
if (widget.previewUrl != null) { if (widget.threeStageLoading) {
previewProvider = _authorizedImageProvider( _previewProvider = _authorizedImageProvider(
widget.previewUrl!, getThumbnailUrl(widget.asset.remote!, type: ThumbnailFormat.JPEG),
"${widget.cacheKey}_previewStage", "${widget.asset.id}_previewStage",
); );
previewProvider.resolve(const ImageConfiguration()).addListener( _previewProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) { ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.preview, previewProvider); _performStateTransition(_RemoteImageStatus.preview, _previewProvider);
}), }),
); );
} }
fullProvider = _authorizedImageProvider( _fullProvider = _authorizedImageProvider(
widget.imageUrl, getImageUrl(widget.asset.remote!),
"${widget.cacheKey}_fullStage", "${widget.asset.id}_fullStage",
); );
fullProvider.resolve(const ImageConfiguration()).addListener( _fullProvider.resolve(const ImageConfiguration()).addListener(
ImageStreamListener((ImageInfo imageInfo, _) { ImageStreamListener((ImageInfo imageInfo, _) {
_performStateTransition(_RemoteImageStatus.full, fullProvider); _performStateTransition(_RemoteImageStatus.full, _fullProvider);
}), }),
); );
} }
@ -139,11 +162,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
super.dispose(); super.dispose();
if (_status == _RemoteImageStatus.full) { if (_status == _RemoteImageStatus.full) {
await fullProvider.evict(); await _fullProvider.evict();
} else if (_status == _RemoteImageStatus.preview) { } else if (_status == _RemoteImageStatus.preview) {
await previewProvider.evict(); await _previewProvider.evict();
} else if (_status == _RemoteImageStatus.thumbnail) { } else if (_status == _RemoteImageStatus.thumbnail) {
await thumbnailProvider.evict(); await _thumbnailProvider.evict();
} }
await _imageProvider.evict(); await _imageProvider.evict();
@ -153,23 +176,18 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
class RemotePhotoView extends StatefulWidget { class RemotePhotoView extends StatefulWidget {
const RemotePhotoView({ const RemotePhotoView({
Key? key, Key? key,
required this.thumbnailUrl, required this.asset,
required this.imageUrl,
required this.authToken, required this.authToken,
required this.threeStageLoading,
required this.isZoomedFunction, required this.isZoomedFunction,
required this.isZoomedListener, required this.isZoomedListener,
required this.onSwipeDown, required this.onSwipeDown,
required this.onSwipeUp, required this.onSwipeUp,
this.previewUrl,
required this.cacheKey,
}) : super(key: key); }) : super(key: key);
final String thumbnailUrl; final Asset asset;
final String imageUrl;
final String authToken; final String authToken;
final String? previewUrl; final bool threeStageLoading;
final String cacheKey;
final void Function() onSwipeDown; final void Function() onSwipeDown;
final void Function() onSwipeUp; final void Function() onSwipeUp;
final void Function() isZoomedFunction; final void Function() isZoomedFunction;

View file

@ -1,7 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
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:openapi/api.dart'; import 'package:immich_mobile/shared/models/asset.dart';
class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
const TopControlAppBar({ const TopControlAppBar({
@ -13,9 +13,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
this.loading = false, this.loading = false,
}) : super(key: key); }) : super(key: key);
final AssetResponseDto asset; final Asset asset;
final Function onMoreInfoPressed; final Function onMoreInfoPressed;
final Function onDownloadPressed; final VoidCallback? onDownloadPressed;
final Function onSharePressed; final Function onSharePressed;
final bool loading; final bool loading;
@ -47,17 +47,16 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
child: const CircularProgressIndicator(strokeWidth: 2.0), child: const CircularProgressIndicator(strokeWidth: 2.0),
), ),
), ),
IconButton( if (!asset.isLocal)
iconSize: iconSize, IconButton(
splashRadius: iconSize, iconSize: iconSize,
onPressed: () { splashRadius: iconSize,
onDownloadPressed(); onPressed: onDownloadPressed,
}, icon: Icon(
icon: Icon( Icons.cloud_download_rounded,
Icons.cloud_download_rounded, color: Colors.grey[200],
color: Colors.grey[200], ),
), ),
),
IconButton( IconButton(
iconSize: iconSize, iconSize: iconSize,
splashRadius: iconSize, splashRadius: iconSize,
@ -69,17 +68,18 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
color: Colors.grey[200], color: Colors.grey[200],
), ),
), ),
IconButton( if (asset.isRemote)
iconSize: iconSize, IconButton(
splashRadius: iconSize, iconSize: iconSize,
onPressed: () { splashRadius: iconSize,
onMoreInfoPressed(); onPressed: () {
}, onMoreInfoPressed();
icon: Icon( },
Icons.more_horiz_rounded, icon: Icon(
color: Colors.grey[200], Icons.more_horiz_rounded,
), color: Colors.grey[200],
) ),
)
], ],
); );
} }

View file

@ -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/home/services/asset.service.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';
import 'package:openapi/api.dart'; import 'package:immich_mobile/shared/models/asset.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class GalleryViewerPage extends HookConsumerWidget { class GalleryViewerPage extends HookConsumerWidget {
late List<AssetResponseDto> assetList; late List<Asset> assetList;
final AssetResponseDto asset; final Asset asset;
GalleryViewerPage({ GalleryViewerPage({
Key? key, Key? key,
@ -27,7 +27,7 @@ class GalleryViewerPage extends HookConsumerWidget {
required this.asset, required this.asset,
}) : super(key: key); }) : super(key: key);
AssetResponseDto? assetDetail; Asset? assetDetail;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -37,8 +37,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final loading = useState(false); final loading = useState(false);
final isZoomed = useState<bool>(false); final isZoomed = useState<bool>(false);
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false); ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
final indexOfAsset = useState(assetList.indexOf(asset));
int indexOfAsset = assetList.indexOf(asset);
PageController controller = PageController controller =
PageController(initialPage: assetList.indexOf(asset)); PageController(initialPage: assetList.indexOf(asset));
@ -52,15 +51,15 @@ class GalleryViewerPage extends HookConsumerWidget {
[], [],
); );
@override
initState(int index) {
indexOfAsset = index;
}
getAssetExif() async { getAssetExif() async {
assetDetail = await ref if (assetList[indexOfAsset.value].isRemote) {
.watch(assetServiceProvider) assetDetail = await ref
.getAssetById(assetList[indexOfAsset].id); .watch(assetServiceProvider)
.getAssetById(assetList[indexOfAsset.value].id);
} else {
// TODO local exif parsing?
assetDetail = assetList[indexOfAsset.value];
}
} }
void showInfo() { void showInfo() {
@ -88,19 +87,20 @@ class GalleryViewerPage extends HookConsumerWidget {
backgroundColor: Colors.black, backgroundColor: Colors.black,
appBar: TopControlAppBar( appBar: TopControlAppBar(
loading: loading.value, loading: loading.value,
asset: assetList[indexOfAsset], asset: assetList[indexOfAsset.value],
onMoreInfoPressed: () { onMoreInfoPressed: () {
showInfo(); showInfo();
}, },
onDownloadPressed: () { onDownloadPressed: assetList[indexOfAsset.value].isLocal
ref ? null
.watch(imageViewerStateProvider.notifier) : () {
.downloadAsset(assetList[indexOfAsset], context); ref.watch(imageViewerStateProvider.notifier).downloadAsset(
}, assetList[indexOfAsset.value].remote!, context);
},
onSharePressed: () { onSharePressed: () {
ref ref
.watch(imageViewerStateProvider.notifier) .watch(imageViewerStateProvider.notifier)
.shareAsset(assetList[indexOfAsset], context); .shareAsset(assetList[indexOfAsset.value], context);
}, },
), ),
body: SafeArea( body: SafeArea(
@ -113,14 +113,13 @@ class GalleryViewerPage extends HookConsumerWidget {
itemCount: assetList.length, itemCount: assetList.length,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
onPageChanged: (value) { onPageChanged: (value) {
indexOfAsset.value = value;
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
initState(index);
getAssetExif(); getAssetExif();
if (assetList[index].type == AssetTypeEnum.IMAGE) { if (assetList[index].isImage) {
return ImageViewerPage( return ImageViewerPage(
authToken: 'Bearer ${box.get(accessTokenKey)}', authToken: 'Bearer ${box.get(accessTokenKey)}',
isZoomedFunction: isZoomedMethod, isZoomedFunction: isZoomedMethod,
@ -139,11 +138,7 @@ class GalleryViewerPage extends HookConsumerWidget {
}, },
child: Hero( child: Hero(
tag: assetList[index].id, tag: assetList[index].id,
child: VideoViewerPage( child: VideoViewerPage(asset: assetList[index]),
asset: assetList[index],
videoUrl:
'${box.get(serverEndpointKey)}/asset/file?aid=${assetList[index].deviceAssetId}&did=${assetList[index].deviceId}',
),
), ),
); );
} }

View file

@ -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/exif_bottom_sheet.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.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/modules/home/services/asset.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class ImageViewerPage extends HookConsumerWidget { class ImageViewerPage extends HookConsumerWidget {
final String heroTag; final String heroTag;
final AssetResponseDto asset; final Asset asset;
final String authToken; final String authToken;
final ValueNotifier<bool> isZoomedListener; final ValueNotifier<bool> isZoomedListener;
final void Function() isZoomedFunction; final void Function() isZoomedFunction;
@ -30,7 +29,7 @@ class ImageViewerPage extends HookConsumerWidget {
required this.threeStageLoading, required this.threeStageLoading,
}) : super(key: key); }) : super(key: key);
AssetResponseDto? assetDetail; Asset? assetDetail;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -38,8 +37,13 @@ class ImageViewerPage extends HookConsumerWidget {
ref.watch(imageViewerStateProvider).downloadAssetStatus; ref.watch(imageViewerStateProvider).downloadAssetStatus;
getAssetExif() async { getAssetExif() async {
assetDetail = if (asset.isRemote) {
await ref.watch(assetServiceProvider).getAssetById(asset.id); assetDetail =
await ref.watch(assetServiceProvider).getAssetById(asset.id);
} else {
// TODO local exif parsing?
assetDetail = asset;
}
} }
useEffect( useEffect(
@ -68,17 +72,13 @@ class ImageViewerPage extends HookConsumerWidget {
child: Hero( child: Hero(
tag: heroTag, tag: heroTag,
child: RemotePhotoView( child: RemotePhotoView(
thumbnailUrl: getThumbnailUrl(asset), asset: asset,
cacheKey: asset.id,
imageUrl: getImageUrl(asset),
previewUrl: threeStageLoading
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
: null,
authToken: authToken, authToken: authToken,
threeStageLoading: threeStageLoading,
isZoomedFunction: isZoomedFunction, isZoomedFunction: isZoomedFunction,
isZoomedListener: isZoomedListener, isZoomedListener: isZoomedListener,
onSwipeDown: () => AutoRouter.of(context).pop(), onSwipeDown: () => AutoRouter.of(context).pop(),
onSwipeUp: () => showInfo(), onSwipeUp: asset.isRemote ? showInfo : () {},
), ),
), ),
), ),

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.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';
@ -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/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/providers/image_viewer_page_state.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.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'; import 'package:video_player/video_player.dart';
// ignore: must_be_immutable // ignore: must_be_immutable
class VideoViewerPage extends HookConsumerWidget { class VideoViewerPage extends HookConsumerWidget {
final String videoUrl; final Asset asset;
final AssetResponseDto asset;
AssetResponseDto? assetDetail;
VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) const VideoViewerPage({Key? key, required this.asset}) : super(key: key);
: super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
if (asset.isLocal) {
final AsyncValue<File> 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 = final downloadAssetStatus =
ref.watch(imageViewerStateProvider).downloadAssetStatus; ref.watch(imageViewerStateProvider).downloadAssetStatus;
final box = Hive.box(userInfoBox);
String jwtToken = Hive.box(userInfoBox).get(accessTokenKey); final String jwtToken = box.get(accessTokenKey);
final String videoUrl =
'${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}';
return Stack( return Stack(
children: [ children: [
@ -40,11 +59,21 @@ class VideoViewerPage extends HookConsumerWidget {
} }
} }
class VideoThumbnailPlayer extends StatefulWidget { final _fileFamily =
final String url; FutureProvider.family<File, AssetEntity>((ref, entity) async {
final String? jwtToken; 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); : super(key: key);
@override @override
@ -63,10 +92,12 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
Future<void> initializePlayer() async { Future<void> initializePlayer() async {
try { try {
videoPlayerController = VideoPlayerController.network( videoPlayerController = widget.file == null
widget.url, ? VideoPlayerController.network(
httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}, widget.url!,
); httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
)
: VideoPlayerController.file(widget.file!);
await videoPlayerController.initialize(); await videoPlayerController.initialize();
_createChewieController(); _createChewieController();

View file

@ -50,6 +50,11 @@ class BackgroundService {
_Throttle(_updateProgress, notifyInterval); _Throttle(_updateProgress, notifyInterval);
late final _Throttle _throttledDetailNotify = late final _Throttle _throttledDetailNotify =
_Throttle(_updateDetailProgress, notifyInterval); _Throttle(_updateDetailProgress, notifyInterval);
Completer<bool> _hasAccessCompleter = Completer();
late Future<bool> _hasAccess =
Platform.isAndroid ? _hasAccessCompleter.future : Future.value(true);
Future<bool> get hasAccess => _hasAccess;
bool get isBackgroundInitialized { bool get isBackgroundInitialized {
return _isBackgroundInitialized; return _isBackgroundInitialized;
@ -201,6 +206,15 @@ class BackgroundService {
if (!Platform.isAndroid) { if (!Platform.isAndroid) {
return true; 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; final int lockTime = Timeline.now;
_wantsLockTime = lockTime; _wantsLockTime = lockTime;
final ReceivePort rp = ReceivePort(_portNameLock); final ReceivePort rp = ReceivePort(_portNameLock);
@ -219,6 +233,7 @@ class BackgroundService {
} }
_hasLock = true; _hasLock = true;
rp.listen(_heartbeatListener); rp.listen(_heartbeatListener);
_hasAccessCompleter.complete(true);
return true; return true;
} }
@ -271,6 +286,8 @@ class BackgroundService {
} }
_wantsLockTime = 0; _wantsLockTime = 0;
if (_hasLock) { if (_hasLock) {
_hasAccessCompleter = Completer();
_hasAccess = _hasAccessCompleter.future;
IsolateNameServer.removePortNameMapping(_portNameLock); IsolateNameServer.removePortNameMapping(_portNameLock);
_waitingIsolate?.send(true); _waitingIsolate?.send(true);
_waitingIsolate = null; _waitingIsolate = null;

View file

@ -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<String, dynamic> toMap() { Map<String, dynamic> toMap() {
final result = <String, dynamic>{}; final result = <String, dynamic>{};

View file

@ -565,11 +565,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground); state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
final bool hasLock = await _backgroundService.acquireLock(); final bool hasLock = await _backgroundService.acquireLock();
if (!hasLock) { if (!hasLock) {
debugPrint("WARNING [resumeBackup] failed to acquireLock");
return; return;
} }
Box<HiveBackupAlbums> box = await Future.wait([
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
HiveBackupAlbums? albums = box.get(backupInfoKey); Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
Hive.openBox(backgroundBackupInfoBox),
]);
final HiveBackupAlbums? albums =
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey);
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums; Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums; Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (albums != null) { if (albums != null) {
@ -584,8 +589,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
albums.lastExcludedBackupTime, albums.lastExcludedBackupTime,
); );
} }
await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox); final Box backgroundBox = Hive.box(backgroundBackupInfoBox);
final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
state = state.copyWith( state = state.copyWith(
backupProgress: previous, backupProgress: previous,
selectedBackupAlbums: selectedAlbums, selectedBackupAlbums: selectedAlbums,

View file

@ -1,34 +1,90 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.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/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/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';
import 'package:photo_manager/src/types/entity.dart';
final assetServiceProvider = Provider( final assetServiceProvider = Provider(
(ref) => AssetService( (ref) => AssetService(
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(backgroundServiceProvider),
), ),
); );
class AssetService { class AssetService {
final ApiService _apiService; final ApiService _apiService;
final BackupService _backupService;
final BackgroundService _backgroundService;
AssetService(this._apiService); AssetService(this._apiService, this._backupService, this._backgroundService);
Future<List<AssetResponseDto>?> getAllAsset() async { /// Returns all local, remote assets in that order
Future<List<Asset>> getAllAsset({bool urgent = false}) async {
final List<Asset> assets = [];
try { try {
return await _apiService.assetApi.getAllAssets(); // not using `await` here to fetch local & remote assets concurrently
final Future<List<AssetResponseDto>?> remoteTask =
_apiService.assetApi.getAllAssets();
final Iterable<AssetEntity> newLocalAssets;
final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final Set<String> 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) { } catch (e) {
debugPrint("Error [getAllAsset] ${e.toString()}"); 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<List<AssetEntity>> _getLocalAssets(bool urgent) async {
try {
final Future<bool> 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<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
return backupAlbumInfo != null
? await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy())
: [];
} catch (e) {
debugPrint("Error [_getLocalAssets] ${e.toString()}");
return [];
} }
} }
Future<AssetResponseDto?> getAssetById(String assetId) async { Future<Asset?> getAssetById(String assetId) async {
try { try {
return await _apiService.assetApi.getAssetById(assetId); return Asset.remote(await _apiService.assetApi.getAssetById(assetId));
} catch (e) { } catch (e) {
debugPrint("Error [getAssetById] ${e.toString()}"); debugPrint("Error [getAssetById] ${e.toString()}");
return null; return null;
@ -36,12 +92,12 @@ class AssetService {
} }
Future<List<DeleteAssetResponseDto>?> deleteAssets( Future<List<DeleteAssetResponseDto>?> deleteAssets(
Set<AssetResponseDto> deleteAssets, Iterable<AssetResponseDto> deleteAssets,
) async { ) async {
try { try {
List<String> payload = []; final List<String> payload = [];
for (var asset in deleteAssets) { for (final asset in deleteAssets) {
payload.add(asset.id); payload.add(asset.id);
} }

View file

@ -1,27 +1,24 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
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/services/json_cache.dart'; import 'package:immich_mobile/shared/services/json_cache.dart';
import 'package:openapi/api.dart';
class AssetCacheService extends JsonCache<List<Asset>> {
class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
AssetCacheService() : super("asset_cache"); AssetCacheService() : super("asset_cache");
@override @override
void put(List<AssetResponseDto> data) { void put(List<Asset> data) {
putRawData(data.map((e) => e.toJson()).toList()); putRawData(data.map((e) => e.toJson()).toList());
} }
@override @override
Future<List<AssetResponseDto>> 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 = mapList final responseData =
.map((e) => AssetResponseDto.fromJson(e)) mapList.map((e) => Asset.fromJson(e)).whereNotNull().toList();
.whereNotNull()
.toList();
return responseData; return responseData;
} catch (e) { } catch (e) {
@ -33,5 +30,5 @@ class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
} }
final assetCacheServiceProvider = Provider( final assetCacheServiceProvider = Provider(
(ref) => AssetCacheService(), (ref) => AssetCacheService(),
); );

View file

@ -1,6 +1,6 @@
import 'dart:math'; import 'dart:math';
import 'package:openapi/api.dart'; import 'package:immich_mobile/shared/models/asset.dart';
enum RenderAssetGridElementType { enum RenderAssetGridElementType {
assetRow, assetRow,
@ -9,7 +9,7 @@ enum RenderAssetGridElementType {
} }
class RenderAssetGridRow { class RenderAssetGridRow {
final List<AssetResponseDto> assets; final List<Asset> assets;
RenderAssetGridRow(this.assets); RenderAssetGridRow(this.assets);
} }
@ -19,7 +19,7 @@ class RenderAssetGridElement {
final RenderAssetGridRow? assetRow; final RenderAssetGridRow? assetRow;
final String? title; final String? title;
final DateTime date; final DateTime date;
final List<AssetResponseDto>? relatedAssetList; final List<Asset>? relatedAssetList;
RenderAssetGridElement( RenderAssetGridElement(
this.type, { this.type, {
@ -31,13 +31,15 @@ class RenderAssetGridElement {
} }
List<RenderAssetGridElement> assetsToRenderList( List<RenderAssetGridElement> assetsToRenderList(
List<AssetResponseDto> assets, int assetsPerRow) { List<Asset> assets,
int assetsPerRow,
) {
List<RenderAssetGridElement> elements = []; List<RenderAssetGridElement> elements = [];
int cursor = 0; int cursor = 0;
while (cursor < assets.length) { while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, assetsPerRow); int rowElements = min(assets.length - cursor, assetsPerRow);
final date = DateTime.parse(assets[cursor].createdAt); final date = assets[cursor].createdAt;
final rowElement = RenderAssetGridElement( final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow, RenderAssetGridElementType.assetRow,
@ -55,7 +57,9 @@ List<RenderAssetGridElement> assetsToRenderList(
} }
List<RenderAssetGridElement> assetGroupsToRenderList( List<RenderAssetGridElement> assetGroupsToRenderList(
Map<String, List<AssetResponseDto>> assetGroups, int assetsPerRow) { Map<String, List<Asset>> assetGroups,
int assetsPerRow,
) {
List<RenderAssetGridElement> elements = []; List<RenderAssetGridElement> elements = [];
DateTime? lastDate; DateTime? lastDate;
@ -64,8 +68,11 @@ List<RenderAssetGridElement> assetGroupsToRenderList(
if (lastDate == null || lastDate!.month != date.month) { if (lastDate == null || lastDate!.month != date.month) {
elements.add( elements.add(
RenderAssetGridElement(RenderAssetGridElementType.monthTitle, RenderAssetGridElement(
title: groupName, date: date), RenderAssetGridElementType.monthTitle,
title: groupName,
date: date,
),
); );
} }

View file

@ -4,7 +4,7 @@ import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.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 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart'; import 'asset_grid_data_structure.dart';
import 'daily_title_text.dart'; import 'daily_title_text.dart';
@ -13,7 +13,7 @@ import 'draggable_scrollbar_custom.dart';
typedef ImmichAssetGridSelectionListener = void Function( typedef ImmichAssetGridSelectionListener = void Function(
bool, bool,
Set<AssetResponseDto>, Set<Asset>,
); );
class ImmichAssetGridState extends State<ImmichAssetGrid> { class ImmichAssetGridState extends State<ImmichAssetGrid> {
@ -24,20 +24,20 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
bool _scrolling = false; bool _scrolling = false;
final Set<String> _selectedAssets = HashSet(); final Set<String> _selectedAssets = HashSet();
List<AssetResponseDto> get _assets { List<Asset> get _assets {
return widget.renderList return widget.renderList
.map((e) { .map((e) {
if (e.type == RenderAssetGridElementType.assetRow) { if (e.type == RenderAssetGridElementType.assetRow) {
return e.assetRow!.assets; return e.assetRow!.assets;
} else { } else {
return List<AssetResponseDto>.empty(); return List<Asset>.empty();
} }
}) })
.flattened .flattened
.toList(); .toList();
} }
Set<AssetResponseDto> _getSelectedAssets() { Set<Asset> _getSelectedAssets() {
return _selectedAssets return _selectedAssets
.map((e) => _assets.firstWhereOrNull((a) => a.id == e)) .map((e) => _assets.firstWhereOrNull((a) => a.id == e))
.whereNotNull() .whereNotNull()
@ -48,7 +48,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
widget.listener?.call(selectionActive, _getSelectedAssets()); widget.listener?.call(selectionActive, _getSelectedAssets());
} }
void _selectAssets(List<AssetResponseDto> assets) { void _selectAssets(List<Asset> assets) {
setState(() { setState(() {
for (var e in assets) { for (var e in assets) {
_selectedAssets.add(e.id); _selectedAssets.add(e.id);
@ -57,7 +57,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
}); });
} }
void _deselectAssets(List<AssetResponseDto> assets) { void _deselectAssets(List<Asset> assets) {
setState(() { setState(() {
for (var e in assets) { for (var e in assets) {
_selectedAssets.remove(e.id); _selectedAssets.remove(e.id);
@ -74,7 +74,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
_callSelectionListener(false); _callSelectionListener(false);
} }
bool _allAssetsSelected(List<AssetResponseDto> assets) { bool _allAssetsSelected(List<Asset> assets) {
return widget.selectionActive && return widget.selectionActive &&
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null; assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
} }
@ -85,7 +85,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
} }
Widget _buildThumbnailOrPlaceholder( Widget _buildThumbnailOrPlaceholder(
AssetResponseDto asset, Asset asset,
bool placeholder, bool placeholder,
) { ) {
if (placeholder) { if (placeholder) {
@ -114,7 +114,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((AssetResponseDto asset) { children: row.assets.map((Asset asset) {
bool last = asset == row.assets.last; bool last = asset == row.assets.last;
return Container( return Container(
@ -134,7 +134,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
Widget _buildTitle( Widget _buildTitle(
BuildContext context, BuildContext context,
String title, String title,
List<AssetResponseDto> assets, List<Asset> assets,
) { ) {
return DailyTitleText( return DailyTitleText(
isoDate: title, isoDate: title,

View file

@ -1,18 +1,15 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.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:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.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/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
class ThumbnailImage extends HookConsumerWidget { class ThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset; final Asset asset;
final List<AssetResponseDto> assetList; final List<Asset> assetList;
final bool showStorageIndicator; final bool showStorageIndicator;
final bool useGrayBoxPlaceholder; final bool useGrayBoxPlaceholder;
final bool isSelected; final bool isSelected;
@ -34,12 +31,9 @@ class ThumbnailImage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset);
var deviceId = ref.watch(authenticationProvider).deviceId; var deviceId = ref.watch(authenticationProvider).deviceId;
Widget buildSelectionIcon(Asset asset) {
Widget buildSelectionIcon(AssetResponseDto asset) {
if (isSelected) { if (isSelected) {
return Icon( return Icon(
Icons.check_circle, Icons.check_circle,
@ -87,41 +81,11 @@ class ThumbnailImage extends HookConsumerWidget {
) )
: const Border(), : const Border(),
), ),
child: CachedNetworkImage( child: ImmichImage(
cacheKey: 'thumbnail-image-${asset.id}', asset,
width: 300, width: 300,
height: 300, height: 300,
memCacheHeight: 200, useGrayBoxPlaceholder: useGrayBoxPlaceholder,
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,
);
},
), ),
), ),
if (multiselectEnabled) if (multiselectEnabled)
@ -137,14 +101,16 @@ class ThumbnailImage 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,
), ),
), ),
if (asset.type != AssetTypeEnum.IMAGE) if (!asset.isImage)
Positioned( Positioned(
top: 5, top: 5,
right: 5, right: 5,

View file

@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.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/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';
import 'package:immich_mobile/routing/router.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/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.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 multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false); final selectionEnabledHook = useState(false);
final selection = useState(<AssetResponseDto>{}); final selection = useState(<Asset>{});
final albums = ref.watch(albumProvider); final albums = ref.watch(albumProvider);
final albumService = ref.watch(albumServiceProvider); final albumService = ref.watch(albumServiceProvider);
@ -60,7 +62,7 @@ class HomePage extends HookConsumerWidget {
Widget buildBody() { Widget buildBody() {
void selectionListener( void selectionListener(
bool multiselect, bool multiselect,
Set<AssetResponseDto> selectedAssets, Set<Asset> selectedAssets,
) { ) {
selectionEnabledHook.value = multiselect; selectionEnabledHook.value = multiselect;
selection.value = selectedAssets; selection.value = selectedAssets;
@ -76,9 +78,27 @@ class HomePage extends HookConsumerWidget {
selectionEnabledHook.value = false; selectionEnabledHook.value = false;
} }
Iterable<Asset> remoteOnlySelection() {
final Set<Asset> 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 { void onAddToAlbum(AlbumResponseDto album) async {
final Iterable<Asset> assets = remoteOnlySelection();
if (assets.isEmpty) {
return;
}
final result = await albumService.addAdditionalAssetToAlbum( final result = await albumService.addAdditionalAssetToAlbum(
selection.value, assets,
album.id, album.id,
); );
@ -103,6 +123,7 @@ class HomePage extends HookConsumerWidget {
"added": result.successfullyAdded.toString(), "added": result.successfullyAdded.toString(),
}, },
), ),
toastType: ToastType.success,
); );
} }
@ -111,8 +132,11 @@ class HomePage extends HookConsumerWidget {
} }
void onCreateNewAlbum() async { void onCreateNewAlbum() async {
final result = final Iterable<Asset> assets = remoteOnlySelection();
await albumService.createAlbumWithGeneratedName(selection.value); if (assets.isEmpty) {
return;
}
final result = await albumService.createAlbumWithGeneratedName(assets);
if (result != null) { if (result != null) {
ref.watch(albumProvider.notifier).getAllAlbums(); ref.watch(albumProvider.notifier).getAllAlbums();

View file

@ -1,13 +1,14 @@
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class SearchResultPageState { class SearchResultPageState {
final bool isLoading; final bool isLoading;
final bool isSuccess; final bool isSuccess;
final bool isError; final bool isError;
final List<AssetResponseDto> searchResult; final List<Asset> searchResult;
SearchResultPageState({ SearchResultPageState({
required this.isLoading, required this.isLoading,
@ -20,7 +21,7 @@ class SearchResultPageState {
bool? isLoading, bool? isLoading,
bool? isSuccess, bool? isSuccess,
bool? isError, bool? isError,
List<AssetResponseDto>? searchResult, List<Asset>? searchResult,
}) { }) {
return SearchResultPageState( return SearchResultPageState(
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
@ -44,8 +45,9 @@ 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<AssetResponseDto>.from( searchResult: List<Asset>.from(
map['searchResult']?.map((x) => AssetResponseDto.mapFromJson(x)), map['searchResult']
?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))),
), ),
); );
} }

View file

@ -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/search/services/search.service.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';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:openapi/api.dart';
class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> { class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
SearchResultPageNotifier(this._searchService) SearchResultPageNotifier(this._searchService)
@ -30,8 +30,9 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
isSuccess: false, isSuccess: false,
); );
List<AssetResponseDto>? assets = List<Asset>? assets = (await _searchService.searchAsset(searchTerm))
await _searchService.searchAsset(searchTerm); ?.map((e) => Asset.remote(e))
.toList();
if (assets != null) { if (assets != null) {
state = state.copyWith( state = state.copyWith(
@ -61,12 +62,11 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(searchResultPageProvider).searchResult; var assets = ref.watch(searchResultPageProvider).searchResult;
assets.sortByCompare<DateTime>( assets.sortByCompare<DateTime>(
(e) => DateTime.parse(e.createdAt), (e) => e.createdAt,
(a, b) => b.compareTo(a), (a, b) => b.compareTo(a),
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => DateFormat('y-MM-dd') (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()),
.format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });

View file

@ -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/routing/auth_guard.dart';
import 'package:immich_mobile/modules/backup/views/backup_controller_page.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/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/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/views/splash_screen.dart'; import 'package:immich_mobile/shared/views/splash_screen.dart';

View file

@ -65,8 +65,7 @@ class _$AppRouter extends RootStackRouter {
final args = routeData.argsAs<VideoViewerRouteArgs>(); final args = routeData.argsAs<VideoViewerRouteArgs>();
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, routeData: routeData,
child: VideoViewerPage( child: VideoViewerPage(key: args.key, asset: args.asset));
key: args.key, videoUrl: args.videoUrl, asset: args.asset));
}, },
BackupControllerRoute.name: (routeData) { BackupControllerRoute.name: (routeData) {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
@ -258,9 +257,7 @@ class TabControllerRoute extends PageRouteInfo<void> {
/// [GalleryViewerPage] /// [GalleryViewerPage]
class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> { class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
GalleryViewerRoute( GalleryViewerRoute(
{Key? key, {Key? key, required List<Asset> assetList, required Asset asset})
required List<AssetResponseDto> assetList,
required AssetResponseDto asset})
: super(GalleryViewerRoute.name, : super(GalleryViewerRoute.name,
path: '/gallery-viewer-page', path: '/gallery-viewer-page',
args: GalleryViewerRouteArgs( args: GalleryViewerRouteArgs(
@ -275,9 +272,9 @@ class GalleryViewerRouteArgs {
final Key? key; final Key? key;
final List<AssetResponseDto> assetList; final List<Asset> assetList;
final AssetResponseDto asset; final Asset asset;
@override @override
String toString() { String toString() {
@ -291,7 +288,7 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
ImageViewerRoute( ImageViewerRoute(
{Key? key, {Key? key,
required String heroTag, required String heroTag,
required AssetResponseDto asset, required Asset asset,
required String authToken, required String authToken,
required void Function() isZoomedFunction, required void Function() isZoomedFunction,
required ValueNotifier<bool> isZoomedListener, required ValueNotifier<bool> isZoomedListener,
@ -324,7 +321,7 @@ class ImageViewerRouteArgs {
final String heroTag; final String heroTag;
final AssetResponseDto asset; final Asset asset;
final String authToken; final String authToken;
@ -343,29 +340,24 @@ class ImageViewerRouteArgs {
/// generated route for /// generated route for
/// [VideoViewerPage] /// [VideoViewerPage]
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> { class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
VideoViewerRoute( VideoViewerRoute({Key? key, required Asset asset})
{Key? key, required String videoUrl, required AssetResponseDto asset})
: super(VideoViewerRoute.name, : super(VideoViewerRoute.name,
path: '/video-viewer-page', path: '/video-viewer-page',
args: VideoViewerRouteArgs( args: VideoViewerRouteArgs(key: key, asset: asset));
key: key, videoUrl: videoUrl, asset: asset));
static const String name = 'VideoViewerRoute'; static const String name = 'VideoViewerRoute';
} }
class VideoViewerRouteArgs { class VideoViewerRouteArgs {
const VideoViewerRouteArgs( const VideoViewerRouteArgs({this.key, required this.asset});
{this.key, required this.videoUrl, required this.asset});
final Key? key; final Key? key;
final String videoUrl; final Asset asset;
final AssetResponseDto asset;
@override @override
String toString() { String toString() {
return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl, asset: $asset}'; return 'VideoViewerRouteArgs{key: $key, asset: $asset}';
} }
} }

View file

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
final l = json["local"];
if (l != null) {
return Asset.local(_assetEntityFromJson(l));
} else {
return Asset.remote(AssetResponseDto.fromJson(json["remote"]));
}
}
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

@ -1,18 +1,23 @@
import 'dart:collection';
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/modules/home/services/asset.service.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/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:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.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';
class AssetNotifier extends StateNotifier<List<AssetResponseDto>> { class AssetNotifier extends StateNotifier<List<Asset>> {
final AssetService _assetService; final AssetService _assetService;
final AssetCacheService _assetCacheService; final AssetCacheService _assetCacheService;
final DeviceInfoService _deviceInfoService = DeviceInfoService(); final DeviceInfoService _deviceInfoService = DeviceInfoService();
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
AssetNotifier(this._assetService, this._assetCacheService) : super([]); AssetNotifier(this._assetService, this._assetCacheService) : super([]);
@ -21,29 +26,38 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
} }
getAllAsset() async { getAllAsset() async {
final stopwatch = Stopwatch(); if (_getAllAssetInProgress || _deleteInProgress) {
// guard against multiple calls to this method while it's still working
return;
if (await _assetCacheService.isValid() && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint("Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
} }
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(); stopwatch.start();
var allAssets = await _assetService.getAllAsset(); _cacheState();
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms"); debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset(); stopwatch.reset();
if (allAssets != null) {
state = allAssets;
stopwatch.start();
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
} }
clearAllAsset() { clearAllAsset() {
@ -52,80 +66,113 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
} }
onNewAssetUploaded(AssetResponseDto newAsset) { 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(); _cacheState();
} }
deleteAssets(Set<AssetResponseDto> deleteAssets) async { deleteAssets(Set<Asset> deleteAssets) async {
_deleteInProgress = true;
try {
final localDeleted = await _deleteLocalAssets(deleteAssets);
final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
final Set<String> 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<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo(); var deviceInfo = await _deviceInfoService.getDeviceInfo();
var deviceId = deviceInfo["deviceId"]; var deviceId = deviceInfo["deviceId"];
var deleteIdList = <String>[]; final List<String> local = [];
// Delete asset from device // Delete asset from device
for (var asset in deleteAssets) { for (final Asset asset in assetsToDelete) {
// Delete asset on device if present if (asset.isLocal) {
if (asset.deviceId == deviceId) { 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); var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
if (localAsset != null) { if (localAsset != null) {
deleteIdList.add(localAsset.id); local.add(localAsset.id);
} }
} }
} }
if (local.isNotEmpty) {
try { try {
await PhotoManager.editor.deleteWithIds(deleteIdList); return await PhotoManager.editor.deleteWithIds(local);
} catch (e) { } catch (e) {
debugPrint("Delete asset from device failed: $e"); debugPrint("Delete asset from device failed: $e");
}
// Delete asset on server
List<DeleteAssetResponseDto>? 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();
} }
} }
return [];
}
_cacheState(); Future<Iterable<String>> _deleteRemoteAssets(
Set<Asset> assetsToDelete,
) async {
final Iterable<AssetResponseDto> remote =
assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!);
final List<DeleteAssetResponseDto> deleteAssetResult =
await _assetService.deleteAssets(remote) ?? [];
return deleteAssetResult
.where((a) => a.status == DeleteAssetStatus.SUCCESS)
.map((a) => a.id);
} }
} }
final assetProvider = final assetProvider = StateNotifierProvider<AssetNotifier, List<Asset>>((ref) {
StateNotifierProvider<AssetNotifier, List<AssetResponseDto>>((ref) {
return AssetNotifier( return AssetNotifier(
ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider)); ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider));
}); });
final assetGroupByDateTimeProvider = StateProvider((ref) { 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<DateTime>( assets.sortByCompare<DateTime>(
(e) => DateTime.parse(e.createdAt), (e) => e.createdAt,
(a, b) => b.compareTo(a), (a, b) => b.compareTo(a),
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => DateFormat('y-MM-dd') (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()),
.format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });
final assetGroupByMonthYearProvider = StateProvider((ref) { 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<DateTime>( assets.sortByCompare<DateTime>(
(e) => DateTime.parse(e.createdAt), (e) => e.createdAt,
(a, b) => b.compareTo(a), (a, b) => b.compareTo(a),
); );
return assets.groupListsBy( return assets.groupListsBy(
(element) => DateFormat('MMMM, y') (element) => DateFormat('MMMM, y').format(element.createdAt.toLocal()),
.format(DateTime.parse(element.createdAt).toLocal()),
); );
}); });

View file

@ -2,11 +2,11 @@ import 'dart:io';
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:openapi/api.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 'package:path/path.dart' as p;
import 'api.service.dart'; import 'api.service.dart';
final shareServiceProvider = final shareServiceProvider =
@ -17,26 +17,28 @@ class ShareService {
ShareService(this._apiService); ShareService(this._apiService);
Future<void> shareAsset(AssetResponseDto asset) async { Future<void> shareAsset(Asset asset) async {
await shareAssets([asset]); await shareAssets([asset]);
} }
Future<void> shareAssets(List<AssetResponseDto> assets) async { Future<void> shareAssets(List<Asset> assets) async {
final downloadedFilePaths = assets.map((asset) async { final downloadedFilePaths = assets.map((asset) async {
final res = await _apiService.assetApi.downloadFileWithHttpInfo( if (asset.isRemote) {
asset.deviceAssetId, final tempDir = await getTemporaryDirectory();
asset.deviceId, final fileName = basename(asset.remote!.originalPath);
isThumb: false, final tempFile = await File('${tempDir.path}/$fileName').create();
isWeb: false, final res = await _apiService.assetApi.downloadFileWithHttpInfo(
); asset.remote!.deviceAssetId,
asset.remote!.deviceId,
final fileName = p.basename(asset.originalPath); isThumb: false,
isWeb: false,
final tempDir = await getTemporaryDirectory(); );
final tempFile = await File('${tempDir.path}/$fileName').create(); tempFile.writeAsBytesSync(res.bodyBytes);
tempFile.writeAsBytesSync(res.bodyBytes); return tempFile.path;
} else {
return tempFile.path; File? f = await asset.local!.file;
return f!.path;
}
}); });
Share.shareFiles( Share.shareFiles(

View file

@ -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,
);
},
);
}
}

View file

@ -1,9 +1,10 @@
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:openapi/api.dart'; import 'package:openapi/api.dart';
void main() { void main() {
final List<AssetResponseDto> testAssets = []; final List<Asset> testAssets = [];
for (int i = 0; i < 150; i++) { for (int i = 0; i < 150; i++) {
int month = i ~/ 31; int month = i ~/ 31;
@ -11,39 +12,43 @@ void main() {
DateTime date = DateTime(2022, month, day); DateTime date = DateTime(2022, month, day);
testAssets.add(AssetResponseDto( testAssets.add(
type: AssetTypeEnum.IMAGE, Asset.remote(
id: '$i', AssetResponseDto(
deviceAssetId: '', type: AssetTypeEnum.IMAGE,
ownerId: '', id: '$i',
deviceId: '', deviceAssetId: '',
originalPath: '', ownerId: '',
resizePath: '', deviceId: '',
createdAt: date.toIso8601String(), originalPath: '',
modifiedAt: date.toIso8601String(), resizePath: '',
isFavorite: false, createdAt: date.toIso8601String(),
mimeType: 'image/jpeg', modifiedAt: date.toIso8601String(),
duration: '', isFavorite: false,
webpPath: '', mimeType: 'image/jpeg',
encodedVideoPath: '', duration: '',
)); webpPath: '',
encodedVideoPath: '',
),
),
);
} }
final Map<String, List<AssetResponseDto>> groups = { final Map<String, List<Asset>> groups = {
'2022-01-05': testAssets.sublist(0, 5).map((e) { '2022-01-05': testAssets.sublist(0, 5).map((e) {
e.createdAt = DateTime(2022, 1, 5).toIso8601String(); e.createdAt = DateTime(2022, 1, 5);
return e; return e;
}).toList(), }).toList(),
'2022-01-10': testAssets.sublist(5, 10).map((e) { '2022-01-10': testAssets.sublist(5, 10).map((e) {
e.createdAt = DateTime(2022, 1, 10).toIso8601String(); e.createdAt = DateTime(2022, 1, 10);
return e; return e;
}).toList(), }).toList(),
'2022-02-17': testAssets.sublist(10, 15).map((e) { '2022-02-17': testAssets.sublist(10, 15).map((e) {
e.createdAt = DateTime(2022, 2, 17).toIso8601String(); e.createdAt = DateTime(2022, 2, 17);
return e; return e;
}).toList(), }).toList(),
'2022-10-15': testAssets.sublist(15, 30).map((e) { '2022-10-15': testAssets.sublist(15, 30).map((e) {
e.createdAt = DateTime(2022, 10, 15).toIso8601String(); e.createdAt = DateTime(2022, 10, 15);
return e; return e;
}).toList() }).toList()
}; };