Compare commits

..

7 Commits

Author SHA1 Message Date
Alex
66640ebfeb Up version for release 2022-11-08 14:34:47 -06:00
Alex
9057e4b7d0 Update documentation 2022-11-08 14:22:42 -06:00
Matthias Rupp
0deb8f4090 Add equals and hashcode to Asset 2022-11-08 19:02:02 +01:00
Fynn Petersen-Frey
1633af7af6 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
2022-11-08 11:00:24 -06:00
Jason Rasmussen
99da181cfc feat(web): favorite an asset (#939)
* feat(web): favorite an asset

* fix: test and linting

* fix: asset dto type
2022-11-08 10:20:36 -06:00
Jason Rasmussen
8a9b0347bb fix(server): increase json body payload limit (#941) 2022-11-08 09:24:49 -06:00
Zeeshan Khan
fe4b307fe6 feat(server,web): Delete and restore user from the admin portal (#935)
* delete and restore user from admin UI

* addressed review comments and fix e2e test

* added cron job to delete user, and some formatting changes

* addressed review comments

* adding missing queue registration
2022-11-07 15:53:47 -06:00
97 changed files with 2122 additions and 591 deletions

View File

@@ -2,4 +2,18 @@
sidebar_position: 6
---
# FAQ
# FAQ
### What is the difference between the cloud icon on the mobile app?
| Icon | Description |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| ![cloud](/img/cloud.svg) | Asset is only available in the cloud and was uploaded from some other device (like the web client) or was deleted from this device after upload |
| ![cloud-cross](/img/cloud-off.svg) | Asset is only available locally and has not yet been backed up |
| ![cloud-done](/img/cloud-done.svg) | Asset was uploaded from this device and is now backed up in the cloud/server and still available in original on the device |
### How can I sync an existing directory with Immich's server?
Immich doesn't have the mechanism to sync an existing directory with the server. There is however, a helper CLI tool to help you bulk upload the existing photos and videos to the server. You can find the guide to use the CLI tool [here](/docs/usage/bulk-upload.md).
### Why doesn't Immich watch an existing photo gallery directory?
The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well.

1
docs/static/img/cloud-done.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" fill="#4250af"><path d="M12.65 39q-4 0-6.825-2.825T3 29.3q0-3.7 2.5-6.475Q8 20.05 11.6 19.7q.75-4.65 4.275-7.65 3.525-3 8.225-3 5.2 0 8.825 3.775Q36.55 16.6 36.55 21.9v1.9h.6q3.3-.1 5.575 2.075Q45 28.05 45 31.4q0 3.1-2.25 5.35Q40.5 39 37.4 39Zm7.95-6.2q.3 0 .55-.125.25-.125.5-.325l8.95-9q.3-.3.325-.75.025-.45-.325-.75-.3-.35-.75-.35t-.75.35l-8.45 8.4-4.05-4.05q-.3-.25-.75-.275-.45-.025-.75.275-.35.35-.35.8 0 .45.35.75l4.55 4.6q.2.2.45.325t.5.125Zm-7.95 3.95H37.4q2.2 0 3.775-1.575Q42.75 33.6 42.75 31.4t-1.575-3.75Q39.6 26.1 37.4 26.1h-3.1v-4.2q0-4.4-3.025-7.5-3.025-3.1-7.375-3.1-4.3 0-7.35 3.1t-3.05 7.5h-1q-2.95 0-5.1 2.15-2.15 2.15-2.15 5.3 0 3.1 2.175 5.25t5.225 2.15ZM24 24Z"/></svg>

After

Width:  |  Height:  |  Size: 756 B

1
docs/static/img/cloud-off.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" fill="#4250af"><path d="m42.15 37.1-1.8-1.8q1.15-.8 1.775-1.8t.625-2.4q0-2.1-1.525-3.625T37.55 25.95H34.3V21.9q0-4.3-3.025-7.275-3.025-2.975-7.325-2.975-1.4 0-2.925.425T18.2 13.45l-1.6-1.65q1.8-1.3 3.6-1.85t3.7-.55q5.25 0 8.95 3.7 3.7 3.7 3.7 8.9v1.75h.6q3.3-.05 5.575 2.05Q45 27.9 45 31.1q0 1.55-.675 3.2-.675 1.65-2.175 2.8Zm-1.75 6.55-5-5.05H12.55q-4.05 0-6.8-2.725T3 29.1q0-3.85 2.525-6.4 2.525-2.55 6.075-2.9.05-.75.375-1.875T12.8 16L5.55 8.75q-.3-.3-.325-.775Q5.2 7.5 5.55 7.15q.35-.35.8-.35.45 0 .8.35l34.9 34.9q.3.3.325.775.025.475-.325.825-.35.35-.825.35t-.825-.35Zm-27.85-7.3h20.6L14.6 17.8q-.55.85-.825 1.975Q13.5 20.9 13.5 22h-.95q-3.05 0-5.175 2T5.25 29q0 3.05 2.125 5.2 2.125 2.15 5.175 2.15ZM29.3 24.4ZM23.85 27Z"/></svg>

After

Width:  |  Height:  |  Size: 799 B

1
docs/static/img/cloud.svg vendored Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" fill="#4250af"><path d="M12.65 39q-4 0-6.825-2.825T3 29.3q0-3.65 2.45-6.45 2.45-2.8 6.15-3.15.75-4.65 4.275-7.65 3.525-3 8.225-3 5.2 0 8.825 3.775Q36.55 16.6 36.55 21.9v1.9h.6q3.3-.1 5.575 2.075Q45 28.05 45 31.4q0 3.1-2.25 5.35Q40.5 39 37.4 39Zm0-2.25H37.4q2.2 0 3.775-1.575Q42.75 33.6 42.75 31.4t-1.575-3.75Q39.6 26.1 37.4 26.1h-3.1v-4.2q0-4.4-3.025-7.5-3.025-3.1-7.375-3.1-4.3 0-7.35 3.1t-3.05 7.5h-.95q-3.05 0-5.175 2.15t-2.125 5.3q0 3.05 2.175 5.225t5.225 2.175ZM24 24Z"/></svg>

After

Width:  |  Height:  |  Size: 545 B

View File

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

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 53,
"android.injected.version.name" => "1.34.0",
"android.injected.version.code" => 54,
"android.injected.version.name" => "1.35.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -0,0 +1 @@
* Local assets are now shown in the app

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.34.0"
version_number: "1.35.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/models/asset_selection_state.model.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
AssetSelectionNotifier()
@@ -22,15 +21,15 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
void removeAssetsInMonth(
String removedMonth,
List<AssetResponseDto> assetsInMonth,
List<Asset> assetsInMonth,
) {
Set<AssetResponseDto> currentAssetList = state.selectedNewAssetsForAlbum;
Set<Asset> currentAssetList = state.selectedNewAssetsForAlbum;
Set<String> currentMonthList = state.selectedMonths;
currentMonthList
.removeWhere((selectedMonth) => selectedMonth == removedMonth);
for (AssetResponseDto asset in assetsInMonth) {
for (Asset asset in assetsInMonth) {
currentAssetList.removeWhere((e) => e.id == asset.id);
}
@@ -40,7 +39,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
);
}
void addAdditionalAssets(List<AssetResponseDto> assets) {
void addAdditionalAssets(List<Asset> assets) {
state = state.copyWith(
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(
selectedMonths: {...state.selectedMonths, month},
selectedNewAssetsForAlbum: {
@@ -59,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
);
}
void addNewAssets(List<AssetResponseDto> assets) {
void addNewAssets(List<Asset> assets) {
state = state.copyWith(
selectedNewAssetsForAlbum: {
...state.selectedNewAssetsForAlbum,
@@ -68,20 +67,20 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
);
}
void removeSelectedNewAssets(List<AssetResponseDto> assets) {
Set<AssetResponseDto> currentList = state.selectedNewAssetsForAlbum;
void removeSelectedNewAssets(List<Asset> assets) {
Set<Asset> currentList = state.selectedNewAssetsForAlbum;
for (AssetResponseDto asset in assets) {
for (Asset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
state = state.copyWith(selectedNewAssetsForAlbum: currentList);
}
void removeSelectedAdditionalAssets(List<AssetResponseDto> assets) {
Set<AssetResponseDto> currentList = state.selectedAdditionalAssetsForAlbum;
void removeSelectedAdditionalAssets(List<Asset> assets) {
Set<Asset> currentList = state.selectedAdditionalAssetsForAlbum;
for (AssetResponseDto asset in assets) {
for (Asset asset in assets) {
currentList.removeWhere((e) => e.id == asset.id);
}
@@ -109,7 +108,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
);
}
void addAssetsInAlbumViewer(List<AssetResponseDto> assets) {
void addAssetsInAlbumViewer(List<Asset> assets) {
state = state.copyWith(
selectedAssetsInAlbumViewer: {
...state.selectedAssetsInAlbumViewer,
@@ -118,10 +117,10 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
);
}
void removeAssetsInAlbumViewer(List<AssetResponseDto> assets) {
Set<AssetResponseDto> currentList = state.selectedAssetsInAlbumViewer;
void removeAssetsInAlbumViewer(List<Asset> assets) {
Set<Asset> currentList = state.selectedAssetsInAlbumViewer;
for (AssetResponseDto asset in assets) {
for (Asset asset in assets) {
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:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) : super([]);
SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService)
: super([]);
final AlbumService _sharedAlbumService;
final SharedAlbumCacheService _sharedAlbumCacheService;
@@ -16,7 +18,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
Future<AlbumResponseDto?> createSharedAlbum(
String albumName,
Set<AssetResponseDto> assets,
Set<Asset> assets,
List<String> sharedUserIds,
) async {
try {

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
@@ -29,7 +30,7 @@ class AlbumService {
Future<AlbumResponseDto?> createAlbum(
String albumName,
Set<AssetResponseDto> assets,
Iterable<Asset> assets,
List<String> sharedUserIds,
) async {
try {
@@ -65,7 +66,7 @@ class AlbumService {
}
Future<AlbumResponseDto?> createAlbumWithGeneratedName(
Set<AssetResponseDto> assets,
Iterable<Asset> assets,
) async {
return createAlbum(
_getNextAlbumName(await getAlbums(isShared: false)), assets, []);
@@ -81,7 +82,7 @@ class AlbumService {
}
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
Set<AssetResponseDto> assets,
Iterable<Asset> assets,
String albumId,
) async {
try {

View File

@@ -1,18 +1,15 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class AlbumViewerThumbnail extends HookConsumerWidget {
final AssetResponseDto asset;
final List<AssetResponseDto> assetList;
final Asset asset;
final List<Asset> assetList;
final bool showStorageIndicator;
const AlbumViewerThumbnail({
@@ -24,8 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset);
var deviceId = ref.watch(authenticationProvider).deviceId;
final selectedAssetsInAlbumViewer =
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
@@ -120,27 +115,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
_buildThumbnailImage() {
return Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage(
cacheKey: asset.id,
width: 300,
height: 300,
memCacheHeight: 200,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) =>
Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
),
child: ImmichImage(asset, width: 300, height: 300),
);
}
@@ -167,7 +142,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
children: [
_buildThumbnailImage(),
if (showStorageIndicator) _buildAssetStoreLocationIcon(),
if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(),
if (!asset.isImage) _buildVideoLabel(),
if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
],
),

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/ui/selection_thumbnail_image.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
class AssetGridByMonth extends HookConsumerWidget {
final List<AssetResponseDto> assetGroup;
final List<Asset> assetGroup;
const AssetGridByMonth({Key? key, required this.assetGroup})
: super(key: key);
@override

View File

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

View File

@@ -1,29 +1,24 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class SelectionThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset;
final Asset asset;
const SelectionThumbnailImage({Key? key, required this.asset})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset);
var selectedAsset =
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
var newAssetsForAlbum =
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
Widget _buildSelectionIcon(AssetResponseDto asset) {
Widget _buildSelectionIcon(Asset asset) {
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
var isNewlySelected =
newAssetsForAlbum.map((item) => item.id).contains(asset.id);
@@ -110,30 +105,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
children: [
Container(
decoration: BoxDecoration(border: drawBorderColor()),
child: CachedNetworkImage(
cacheKey: asset.id,
width: 150,
height: 150,
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) =>
Transform.scale(
scale: 0.2,
child:
CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
),
child: ImmichImage(asset, width: 150, height: 150),
),
Padding(
padding: const EdgeInsets.all(3.0),
@@ -142,7 +114,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
child: _buildSelectionIcon(asset),
),
),
if (asset.type != AssetTypeEnum.IMAGE)
if (!asset.isImage)
Positioned(
bottom: 5,
right: 5,

View File

@@ -1,49 +1,23 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class SharedAlbumThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset;
final Asset asset;
const SharedAlbumThumbnailImage({Key? key, required this.asset})
: super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
return GestureDetector(
onTap: () {
// debugPrint("View ${asset.id}");
},
child: Stack(
children: [
CachedNetworkImage(
cacheKey: asset.id,
width: 500,
height: 500,
memCacheHeight: 500,
fit: BoxFit.cover,
imageUrl: getThumbnailUrl(asset),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) =>
Transform.scale(
scale: 0.2,
child:
CircularProgressIndicator(value: downloadProgress.progress),
),
errorWidget: (context, url, error) {
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
),
ImmichImage(asset, width: 500, height: 500),
],
),
);

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -50,6 +50,11 @@ class BackgroundService {
_Throttle(_updateProgress, notifyInterval);
late final _Throttle _throttledDetailNotify =
_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 {
return _isBackgroundInitialized;
@@ -201,6 +206,15 @@ class BackgroundService {
if (!Platform.isAndroid) {
return true;
}
if (_hasLock) {
debugPrint("WARNING: [acquireLock] called more than once");
return true;
}
if (_hasAccessCompleter.isCompleted) {
debugPrint("WARNING: [acquireLock] _hasAccessCompleter is completed");
_hasAccessCompleter = Completer();
_hasAccess = _hasAccessCompleter.future;
}
final int lockTime = Timeline.now;
_wantsLockTime = lockTime;
final ReceivePort rp = ReceivePort(_portNameLock);
@@ -219,6 +233,7 @@ class BackgroundService {
}
_hasLock = true;
rp.listen(_heartbeatListener);
_hasAccessCompleter.complete(true);
return true;
}
@@ -271,6 +286,8 @@ class BackgroundService {
}
_wantsLockTime = 0;
if (_hasLock) {
_hasAccessCompleter = Completer();
_hasAccess = _hasAccessCompleter.future;
IsolateNameServer.removePortNameMapping(_portNameLock);
_waitingIsolate?.send(true);
_waitingIsolate = null;

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

View File

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

View File

@@ -1,34 +1,90 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/src/types/entity.dart';
final assetServiceProvider = Provider(
(ref) => AssetService(
ref.watch(apiServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(backgroundServiceProvider),
),
);
class AssetService {
final ApiService _apiService;
final BackupService _backupService;
final BackgroundService _backgroundService;
AssetService(this._apiService);
AssetService(this._apiService, this._backupService, this._backgroundService);
Future<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 {
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) {
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 {
return await _apiService.assetApi.getAssetById(assetId);
return Asset.remote(await _apiService.assetApi.getAssetById(assetId));
} catch (e) {
debugPrint("Error [getAssetById] ${e.toString()}");
return null;
@@ -36,12 +92,12 @@ class AssetService {
}
Future<List<DeleteAssetResponseDto>?> deleteAssets(
Set<AssetResponseDto> deleteAssets,
Iterable<AssetResponseDto> deleteAssets,
) async {
try {
List<String> payload = [];
final List<String> payload = [];
for (var asset in deleteAssets) {
for (final asset in deleteAssets) {
payload.add(asset.id);
}

View File

@@ -1,27 +1,24 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
import 'package:openapi/api.dart';
class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
class AssetCacheService extends JsonCache<List<Asset>> {
AssetCacheService() : super("asset_cache");
@override
void put(List<AssetResponseDto> data) {
void put(List<Asset> data) {
putRawData(data.map((e) => e.toJson()).toList());
}
@override
Future<List<AssetResponseDto>> get() async {
Future<List<Asset>> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData = mapList
.map((e) => AssetResponseDto.fromJson(e))
.whereNotNull()
.toList();
final responseData =
mapList.map((e) => Asset.fromJson(e)).whereNotNull().toList();
return responseData;
} catch (e) {
@@ -33,5 +30,5 @@ class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
}
final assetCacheServiceProvider = Provider(
(ref) => AssetCacheService(),
(ref) => AssetCacheService(),
);

View File

@@ -1,6 +1,6 @@
import 'dart:math';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
enum RenderAssetGridElementType {
assetRow,
@@ -9,7 +9,7 @@ enum RenderAssetGridElementType {
}
class RenderAssetGridRow {
final List<AssetResponseDto> assets;
final List<Asset> assets;
RenderAssetGridRow(this.assets);
}
@@ -19,7 +19,7 @@ class RenderAssetGridElement {
final RenderAssetGridRow? assetRow;
final String? title;
final DateTime date;
final List<AssetResponseDto>? relatedAssetList;
final List<Asset>? relatedAssetList;
RenderAssetGridElement(
this.type, {
@@ -31,13 +31,15 @@ class RenderAssetGridElement {
}
List<RenderAssetGridElement> assetsToRenderList(
List<AssetResponseDto> assets, int assetsPerRow) {
List<Asset> assets,
int assetsPerRow,
) {
List<RenderAssetGridElement> elements = [];
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, assetsPerRow);
final date = DateTime.parse(assets[cursor].createdAt);
final date = assets[cursor].createdAt;
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
@@ -55,7 +57,9 @@ List<RenderAssetGridElement> assetsToRenderList(
}
List<RenderAssetGridElement> assetGroupsToRenderList(
Map<String, List<AssetResponseDto>> assetGroups, int assetsPerRow) {
Map<String, List<Asset>> assetGroups,
int assetsPerRow,
) {
List<RenderAssetGridElement> elements = [];
DateTime? lastDate;
@@ -64,8 +68,11 @@ List<RenderAssetGridElement> assetGroupsToRenderList(
if (lastDate == null || lastDate!.month != date.month) {
elements.add(
RenderAssetGridElement(RenderAssetGridElementType.monthTitle,
title: groupName, date: date),
RenderAssetGridElement(
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:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
import 'daily_title_text.dart';
@@ -13,7 +13,7 @@ import 'draggable_scrollbar_custom.dart';
typedef ImmichAssetGridSelectionListener = void Function(
bool,
Set<AssetResponseDto>,
Set<Asset>,
);
class ImmichAssetGridState extends State<ImmichAssetGrid> {
@@ -24,20 +24,20 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
bool _scrolling = false;
final Set<String> _selectedAssets = HashSet();
List<AssetResponseDto> get _assets {
List<Asset> get _assets {
return widget.renderList
.map((e) {
if (e.type == RenderAssetGridElementType.assetRow) {
return e.assetRow!.assets;
} else {
return List<AssetResponseDto>.empty();
return List<Asset>.empty();
}
})
.flattened
.toList();
}
Set<AssetResponseDto> _getSelectedAssets() {
Set<Asset> _getSelectedAssets() {
return _selectedAssets
.map((e) => _assets.firstWhereOrNull((a) => a.id == e))
.whereNotNull()
@@ -48,7 +48,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
widget.listener?.call(selectionActive, _getSelectedAssets());
}
void _selectAssets(List<AssetResponseDto> assets) {
void _selectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.add(e.id);
@@ -57,7 +57,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
});
}
void _deselectAssets(List<AssetResponseDto> assets) {
void _deselectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.remove(e.id);
@@ -74,7 +74,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
_callSelectionListener(false);
}
bool _allAssetsSelected(List<AssetResponseDto> assets) {
bool _allAssetsSelected(List<Asset> assets) {
return widget.selectionActive &&
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
}
@@ -85,7 +85,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
}
Widget _buildThumbnailOrPlaceholder(
AssetResponseDto asset,
Asset asset,
bool placeholder,
) {
if (placeholder) {
@@ -114,7 +114,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.map((AssetResponseDto asset) {
children: row.assets.map((Asset asset) {
bool last = asset == row.assets.last;
return Container(
@@ -134,7 +134,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
Widget _buildTitle(
BuildContext context,
String title,
List<AssetResponseDto> assets,
List<Asset> assets,
) {
return DailyTitleText(
isoDate: title,

View File

@@ -1,18 +1,15 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class ThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset;
final List<AssetResponseDto> assetList;
final Asset asset;
final List<Asset> assetList;
final bool showStorageIndicator;
final bool useGrayBoxPlaceholder;
final bool isSelected;
@@ -34,12 +31,9 @@ class ThumbnailImage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset);
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget buildSelectionIcon(AssetResponseDto asset) {
Widget buildSelectionIcon(Asset asset) {
if (isSelected) {
return Icon(
Icons.check_circle,
@@ -87,41 +81,11 @@ class ThumbnailImage extends HookConsumerWidget {
)
: const Border(),
),
child: CachedNetworkImage(
cacheKey: 'thumbnail-image-${asset.id}',
child: ImmichImage(
asset,
width: 300,
height: 300,
memCacheHeight: 200,
maxWidthDiskCache: 200,
maxHeightDiskCache: 200,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) {
if (useGrayBoxPlaceholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(
value: downloadProgress.progress,
),
);
},
errorWidget: (context, url, error) {
debugPrint("Error getting thumbnail $url = $error");
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
useGrayBoxPlaceholder: useGrayBoxPlaceholder,
),
),
if (multiselectEnabled)
@@ -137,14 +101,16 @@ class ThumbnailImage extends HookConsumerWidget {
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId)
? Icons.cloud_done_outlined
: Icons.photo_library_rounded,
asset.isRemote
? (deviceId == asset.deviceId
? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,
color: Colors.white,
size: 18,
),
),
if (asset.type != AssetTypeEnum.IMAGE)
if (!asset.isImage)
Positioned(
top: 5,
right: 5,

View File

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

View File

@@ -1,13 +1,14 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
class SearchResultPageState {
final bool isLoading;
final bool isSuccess;
final bool isError;
final List<AssetResponseDto> searchResult;
final List<Asset> searchResult;
SearchResultPageState({
required this.isLoading,
@@ -20,7 +21,7 @@ class SearchResultPageState {
bool? isLoading,
bool? isSuccess,
bool? isError,
List<AssetResponseDto>? searchResult,
List<Asset>? searchResult,
}) {
return SearchResultPageState(
isLoading: isLoading ?? this.isLoading,
@@ -44,8 +45,9 @@ class SearchResultPageState {
isLoading: map['isLoading'] ?? false,
isSuccess: map['isSuccess'] ?? false,
isError: map['isError'] ?? false,
searchResult: List<AssetResponseDto>.from(
map['searchResult']?.map((x) => AssetResponseDto.mapFromJson(x)),
searchResult: List<Asset>.from(
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/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:intl/intl.dart';
import 'package:openapi/api.dart';
class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
SearchResultPageNotifier(this._searchService)
@@ -30,8 +30,9 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
isSuccess: false,
);
List<AssetResponseDto>? assets =
await _searchService.searchAsset(searchTerm);
List<Asset>? assets = (await _searchService.searchAsset(searchTerm))
?.map((e) => Asset.remote(e))
.toList();
if (assets != null) {
state = state.copyWith(
@@ -61,12 +62,11 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(searchResultPageProvider).searchResult;
assets.sortByCompare<DateTime>(
(e) => DateTime.parse(e.createdAt),
(e) => e.createdAt,
(a, b) => b.compareTo(a),
);
return assets.groupListsBy(
(element) => DateFormat('y-MM-dd')
.format(DateTime.parse(element.createdAt).toLocal()),
(element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()),
);
});

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

View File

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

View File

@@ -0,0 +1,126 @@
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();
}
}
@override
bool operator ==(other) {
if (other is! Asset) return false;
return id == other.id && isLocal == other.isLocal;
}
@override
int get hashCode => id.hashCode;
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/services/asset.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart';
import 'package:intl/intl.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
class AssetNotifier extends StateNotifier<List<Asset>> {
final AssetService _assetService;
final AssetCacheService _assetCacheService;
final DeviceInfoService _deviceInfoService = DeviceInfoService();
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
AssetNotifier(this._assetService, this._assetCacheService) : super([]);
@@ -21,29 +26,38 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
}
getAllAsset() async {
final stopwatch = Stopwatch();
if (await _assetCacheService.isValid() && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint("Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (_getAllAssetInProgress || _deleteInProgress) {
// guard against multiple calls to this method while it's still working
return;
}
final stopwatch = Stopwatch();
try {
_getAllAssetInProgress = true;
final bool isCacheValid = await _assetCacheService.isValid();
if (isCacheValid && state.isEmpty) {
stopwatch.start();
state = await _assetCacheService.get();
debugPrint(
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
stopwatch.start();
var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid);
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
state = allAssets;
} finally {
_getAllAssetInProgress = false;
}
debugPrint("[getAllAsset] setting new asset state");
stopwatch.start();
var allAssets = await _assetService.getAllAsset();
debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (allAssets != null) {
state = allAssets;
stopwatch.start();
_cacheState();
debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
}
}
clearAllAsset() {
@@ -52,80 +66,113 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
}
onNewAssetUploaded(AssetResponseDto newAsset) {
state = [...state, newAsset];
final int i = state.indexWhere(
(a) =>
a.isRemote ||
(a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId),
);
if (i == -1 || state[i].deviceAssetId != newAsset.deviceAssetId) {
state = [...state, Asset.remote(newAsset)];
} else {
// order is important to keep all local-only assets at the beginning!
state = [
...state.slice(0, i),
...state.slice(i + 1),
Asset.remote(newAsset),
];
// TODO here is a place to unify local/remote assets by replacing the
// local-only asset in the state with a local&remote asset
}
_cacheState();
}
deleteAssets(Set<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 deviceId = deviceInfo["deviceId"];
var deleteIdList = <String>[];
final List<String> local = [];
// Delete asset from device
for (var asset in deleteAssets) {
// Delete asset on device if present
if (asset.deviceId == deviceId) {
for (final Asset asset in assetsToDelete) {
if (asset.isLocal) {
local.add(asset.id);
} else if (asset.deviceId == deviceId) {
// Delete asset on device if it is still present
var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
if (localAsset != null) {
deleteIdList.add(localAsset.id);
local.add(localAsset.id);
}
}
}
try {
await PhotoManager.editor.deleteWithIds(deleteIdList);
} catch (e) {
debugPrint("Delete asset from device failed: $e");
}
// Delete asset on server
List<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();
if (local.isNotEmpty) {
try {
return await PhotoManager.editor.deleteWithIds(local);
} catch (e) {
debugPrint("Delete asset from device failed: $e");
}
}
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 =
StateNotifierProvider<AssetNotifier, List<AssetResponseDto>>((ref) {
final assetProvider = StateNotifierProvider<AssetNotifier, List<Asset>>((ref) {
return AssetNotifier(
ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider));
});
final assetGroupByDateTimeProvider = StateProvider((ref) {
var assets = ref.watch(assetProvider);
final assets = ref.watch(assetProvider).toList();
// `toList()` ist needed to make a copy as to NOT sort the original list/state
assets.sortByCompare<DateTime>(
(e) => DateTime.parse(e.createdAt),
(e) => e.createdAt,
(a, b) => b.compareTo(a),
);
return assets.groupListsBy(
(element) => DateFormat('y-MM-dd')
.format(DateTime.parse(element.createdAt).toLocal()),
(element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()),
);
});
final assetGroupByMonthYearProvider = StateProvider((ref) {
var assets = ref.watch(assetProvider);
// TODO: remove `where` once temporary workaround is no longer needed (to only
// allow remote assets to be added to album). Keep `toList()` as to NOT sort
// the original list/state
final assets = ref.watch(assetProvider).where((e) => e.isRemote).toList();
assets.sortByCompare<DateTime>(
(e) => DateTime.parse(e.createdAt),
(e) => e.createdAt,
(a, b) => b.compareTo(a),
);
return assets.groupListsBy(
(element) => DateFormat('MMMM, y')
.format(DateTime.parse(element.createdAt).toLocal()),
(element) => DateFormat('MMMM, y').format(element.createdAt.toLocal()),
);
});

View File

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

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

@@ -58,6 +58,7 @@ doc/SmartInfoResponseDto.md
doc/ThumbnailFormat.md
doc/TimeGroupEnum.md
doc/UpdateAlbumDto.md
doc/UpdateAssetDto.md
doc/UpdateDeviceInfoDto.md
doc/UpdateUserDto.md
doc/UsageByUserDto.md
@@ -132,6 +133,7 @@ lib/model/smart_info_response_dto.dart
lib/model/thumbnail_format.dart
lib/model/time_group_enum.dart
lib/model/update_album_dto.dart
lib/model/update_asset_dto.dart
lib/model/update_device_info_dto.dart
lib/model/update_user_dto.dart
lib/model/usage_by_user_dto.dart

View File

@@ -92,6 +92,7 @@ Class | Method | HTTP request | Description
*AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} |
*AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search |
*AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file |
*AssetApi* | [**updateAssetById**](doc//AssetApi.md#updateassetbyid) | **PUT** /asset/assetById/{assetId} |
*AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload |
*AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
@@ -108,11 +109,13 @@ Class | Method | HTTP request | Description
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
*UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image |
*UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user |
*UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /user/{userId} |
*UserApi* | [**getAllUsers**](doc//UserApi.md#getallusers) | **GET** /user |
*UserApi* | [**getMyUserInfo**](doc//UserApi.md#getmyuserinfo) | **GET** /user/me |
*UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /user/profile-image/{userId} |
*UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /user/info/{userId} |
*UserApi* | [**getUserCount**](doc//UserApi.md#getusercount) | **GET** /user/count |
*UserApi* | [**restoreUser**](doc//UserApi.md#restoreuser) | **POST** /user/{userId}/restore |
*UserApi* | [**updateUser**](doc//UserApi.md#updateuser) | **PUT** /user |
@@ -168,6 +171,7 @@ Class | Method | HTTP request | Description
- [ThumbnailFormat](doc//ThumbnailFormat.md)
- [TimeGroupEnum](doc//TimeGroupEnum.md)
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateDeviceInfoDto](doc//UpdateDeviceInfoDto.md)
- [UpdateUserDto](doc//UpdateUserDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)

View File

@@ -25,6 +25,7 @@ Method | HTTP request | Description
[**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} |
[**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search |
[**serveFile**](AssetApi.md#servefile) | **GET** /asset/file |
[**updateAssetById**](AssetApi.md#updateassetbyid) | **PUT** /asset/assetById/{assetId} |
[**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload |
@@ -784,6 +785,57 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updateAssetById**
> AssetResponseDto updateAssetById(assetId, updateAssetDto)
Update an asset
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final assetId = assetId_example; // String |
final updateAssetDto = UpdateAssetDto(); // UpdateAssetDto |
try {
final result = api_instance.updateAssetById(assetId, updateAssetDto);
print(result);
} catch (e) {
print('Exception when calling AssetApi->updateAssetById: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**assetId** | **String**| |
**updateAssetDto** | [**UpdateAssetDto**](UpdateAssetDto.md)| |
### Return type
[**AssetResponseDto**](AssetResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **uploadFile**
> AssetFileUploadResponseDto uploadFile(assetData)

View File

@@ -0,0 +1,15 @@
# openapi.model.UpdateAssetDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**isFavorite** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -11,11 +11,13 @@ Method | HTTP request | Description
------------- | ------------- | -------------
[**createProfileImage**](UserApi.md#createprofileimage) | **POST** /user/profile-image |
[**createUser**](UserApi.md#createuser) | **POST** /user |
[**deleteUser**](UserApi.md#deleteuser) | **DELETE** /user/{userId} |
[**getAllUsers**](UserApi.md#getallusers) | **GET** /user |
[**getMyUserInfo**](UserApi.md#getmyuserinfo) | **GET** /user/me |
[**getProfileImage**](UserApi.md#getprofileimage) | **GET** /user/profile-image/{userId} |
[**getUserById**](UserApi.md#getuserbyid) | **GET** /user/info/{userId} |
[**getUserCount**](UserApi.md#getusercount) | **GET** /user/count |
[**restoreUser**](UserApi.md#restoreuser) | **POST** /user/{userId}/restore |
[**updateUser**](UserApi.md#updateuser) | **PUT** /user |
@@ -113,6 +115,53 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **deleteUser**
> UserResponseDto deleteUser(userId)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = UserApi();
final userId = userId_example; // String |
try {
final result = api_instance.deleteUser(userId);
print(result);
} catch (e) {
print('Exception when calling UserApi->deleteUser: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**userId** | **String**| |
### Return type
[**UserResponseDto**](UserResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAllUsers**
> List<UserResponseDto> getAllUsers(isAll)
@@ -322,6 +371,53 @@ No authorization required
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **restoreUser**
> UserResponseDto restoreUser(userId)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = UserApi();
final userId = userId_example; // String |
try {
final result = api_instance.restoreUser(userId);
print(result);
} catch (e) {
print('Exception when calling UserApi->restoreUser: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**userId** | **String**| |
### Return type
[**UserResponseDto**](UserResponseDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updateUser**
> UserResponseDto updateUser(updateUserDto)

View File

@@ -16,6 +16,7 @@ Name | Type | Description | Notes
**profileImagePath** | **String** | |
**shouldChangePassword** | **bool** | |
**isAdmin** | **bool** | |
**deletedAt** | [**DateTime**](DateTime.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -85,6 +85,7 @@ part 'model/smart_info_response_dto.dart';
part 'model/thumbnail_format.dart';
part 'model/time_group_enum.dart';
part 'model/update_album_dto.dart';
part 'model/update_asset_dto.dart';
part 'model/update_device_info_dto.dart';
part 'model/update_user_dto.dart';
part 'model/usage_by_user_dto.dart';

View File

@@ -858,6 +858,67 @@ class AssetApi {
return null;
}
///
///
/// Update an asset
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] assetId (required):
///
/// * [UpdateAssetDto] updateAssetDto (required):
Future<Response> updateAssetByIdWithHttpInfo(String assetId, UpdateAssetDto updateAssetDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset/assetById/{assetId}'
.replaceAll('{assetId}', assetId);
// ignore: prefer_final_locals
Object? postBody = updateAssetDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
///
///
/// Update an asset
///
/// Parameters:
///
/// * [String] assetId (required):
///
/// * [UpdateAssetDto] updateAssetDto (required):
Future<AssetResponseDto?> updateAssetById(String assetId, UpdateAssetDto updateAssetDto,) async {
final response = await updateAssetByIdWithHttpInfo(assetId, updateAssetDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetResponseDto',) as AssetResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
/// Parameters:
///

View File

@@ -120,6 +120,54 @@ class UserApi {
return null;
}
/// Performs an HTTP 'DELETE /user/{userId}' operation and returns the [Response].
/// Parameters:
///
/// * [String] userId (required):
Future<Response> deleteUserWithHttpInfo(String userId,) async {
// ignore: prefer_const_declarations
final path = r'/user/{userId}'
.replaceAll('{userId}', userId);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] userId (required):
Future<UserResponseDto?> deleteUser(String userId,) async {
final response = await deleteUserWithHttpInfo(userId,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /user' operation and returns the [Response].
/// Parameters:
///
@@ -350,6 +398,54 @@ class UserApi {
return null;
}
/// Performs an HTTP 'POST /user/{userId}/restore' operation and returns the [Response].
/// Parameters:
///
/// * [String] userId (required):
Future<Response> restoreUserWithHttpInfo(String userId,) async {
// ignore: prefer_const_declarations
final path = r'/user/{userId}/restore'
.replaceAll('{userId}', userId);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] userId (required):
Future<UserResponseDto?> restoreUser(String userId,) async {
final response = await restoreUserWithHttpInfo(userId,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserResponseDto',) as UserResponseDto;
}
return null;
}
/// Performs an HTTP 'PUT /user' operation and returns the [Response].
/// Parameters:
///

View File

@@ -292,6 +292,8 @@ class ApiClient {
return TimeGroupEnumTypeTransformer().decode(value);
case 'UpdateAlbumDto':
return UpdateAlbumDto.fromJson(value);
case 'UpdateAssetDto':
return UpdateAssetDto.fromJson(value);
case 'UpdateDeviceInfoDto':
return UpdateDeviceInfoDto.fromJson(value);
case 'UpdateUserDto':

View File

@@ -0,0 +1,111 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class UpdateAssetDto {
/// Returns a new [UpdateAssetDto] instance.
UpdateAssetDto({
required this.isFavorite,
});
bool isFavorite;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto &&
other.isFavorite == isFavorite;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(isFavorite.hashCode);
@override
String toString() => 'UpdateAssetDto[isFavorite=$isFavorite]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'isFavorite'] = isFavorite;
return _json;
}
/// Returns a new [UpdateAssetDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UpdateAssetDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "UpdateAssetDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "UpdateAssetDto[$key]" has a null value in JSON.');
});
return true;
}());
return UpdateAssetDto(
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
);
}
return null;
}
static List<UpdateAssetDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateAssetDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UpdateAssetDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UpdateAssetDto> mapFromJson(dynamic json) {
final map = <String, UpdateAssetDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UpdateAssetDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UpdateAssetDto-objects as value to a dart map
static Map<String, List<UpdateAssetDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateAssetDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UpdateAssetDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'isFavorite',
};
}

View File

@@ -21,6 +21,7 @@ class UserResponseDto {
required this.profileImagePath,
required this.shouldChangePassword,
required this.isAdmin,
required this.deletedAt,
});
String id;
@@ -39,6 +40,8 @@ class UserResponseDto {
bool isAdmin;
DateTime? deletedAt;
@override
bool operator ==(Object other) => identical(this, other) || other is UserResponseDto &&
other.id == id &&
@@ -48,7 +51,8 @@ class UserResponseDto {
other.createdAt == createdAt &&
other.profileImagePath == profileImagePath &&
other.shouldChangePassword == shouldChangePassword &&
other.isAdmin == isAdmin;
other.isAdmin == isAdmin &&
other.deletedAt == deletedAt;
@override
int get hashCode =>
@@ -60,10 +64,11 @@ class UserResponseDto {
(createdAt.hashCode) +
(profileImagePath.hashCode) +
(shouldChangePassword.hashCode) +
(isAdmin.hashCode);
(isAdmin.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode);
@override
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin]';
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
@@ -75,6 +80,11 @@ class UserResponseDto {
_json[r'profileImagePath'] = profileImagePath;
_json[r'shouldChangePassword'] = shouldChangePassword;
_json[r'isAdmin'] = isAdmin;
if (deletedAt != null) {
_json[r'deletedAt'] = deletedAt!.toUtc().toIso8601String();
} else {
_json[r'deletedAt'] = null;
}
return _json;
}
@@ -105,6 +115,7 @@ class UserResponseDto {
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
deletedAt: mapDateTime(json, r'deletedAt', ''),
);
}
return null;
@@ -162,6 +173,7 @@ class UserResponseDto {
'profileImagePath',
'shouldChangePassword',
'isAdmin',
'deletedAt',
};
}

View File

@@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for UpdateAssetDto
void main() {
// final instance = UpdateAssetDto();
group('test UpdateAssetDto', () {
// bool isFavorite
test('to test the property `isFavorite`', () async {
// TODO
});
});
}

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.34.0+53
version: 1.35.0+54
environment:
sdk: ">=2.17.0 <3.0.0"

View File

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

View File

@@ -4,8 +4,8 @@ import { BadRequestException, NotFoundException, ForbiddenException } from '@nes
import { AlbumEntity } from '@app/database/entities/album.entity';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { IAssetRepository } from '../asset/asset-repository';
import {AddAssetsResponseDto} from "./response-dto/add-assets-response.dto";
import {IAlbumRepository} from "./album-repository";
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { IAlbumRepository } from './album-repository';
describe('Album service', () => {
let sut: AlbumService;
@@ -125,6 +125,7 @@ describe('Album service', () => {
assetRepositoryMock = {
create: jest.fn(),
update: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),
@@ -333,7 +334,7 @@ describe('Album service', () => {
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1
successfullyAdded: 1,
};
const albumId = albumEntity.id;
@@ -341,13 +342,13 @@ describe('Album service', () => {
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
const result = await sut.addAssetsToAlbum(
const result = (await sut.addAssetsToAlbum(
authUser,
{
assetIds: ['1'],
},
albumId,
) as AddAssetsResponseDto;
)) as AddAssetsResponseDto;
// TODO: stub and expect album rendered
expect(result.album?.id).toEqual(albumId);
@@ -358,7 +359,7 @@ describe('Album service', () => {
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1
successfullyAdded: 1,
};
const albumId = albumEntity.id;
@@ -366,13 +367,13 @@ describe('Album service', () => {
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
const result = await sut.addAssetsToAlbum(
const result = (await sut.addAssetsToAlbum(
authUser,
{
assetIds: ['1'],
},
albumId,
) as AddAssetsResponseDto;
)) as AddAssetsResponseDto;
// TODO: stub and expect album rendered
expect(result.album?.id).toEqual(albumId);
@@ -383,7 +384,7 @@ describe('Album service', () => {
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1
successfullyAdded: 1,
};
const albumId = albumEntity.id;
@@ -447,7 +448,7 @@ describe('Album service', () => {
const albumResponse: AddAssetsResponseDto = {
alreadyInAlbum: [],
successfullyAdded: 1
successfullyAdded: 1,
};
const albumId = albumEntity.id;

View File

@@ -13,6 +13,7 @@ import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-use
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { In } from 'typeorm/find-options/operator/In';
import { UpdateAssetDto } from './dto/update-asset.dto';
export interface IAssetRepository {
create(
@@ -22,6 +23,7 @@ export interface IAssetRepository {
mimeType: string,
checksum?: Buffer,
): Promise<AssetEntity>;
update(asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
getAllByUserId(userId: string): Promise<AssetEntity[]>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
getById(assetId: string): Promise<AssetEntity>;
@@ -252,6 +254,15 @@ export class AssetRepository implements IAssetRepository {
return createdAsset;
}
/**
* Update asset
*/
async update(asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity> {
asset.isFavorite = dto.isFavorite ?? asset.isFavorite;
return await this.assetRepository.save(asset);
}
/**
* Get assets by device's Id on the database
* @param userId

View File

@@ -15,6 +15,7 @@ import {
BadRequestException,
UploadedFile,
Header,
Put,
} from '@nestjs/common';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service';
@@ -50,6 +51,7 @@ import { QueryFailedError } from 'typeorm';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
@Authenticated()
@ApiBearerAuth()
@@ -222,6 +224,18 @@ export class AssetController {
return await this.assetService.getAssetById(authUser, assetId);
}
/**
* Update an asset
*/
@Put('/assetById/:assetId')
async updateAssetById(
@GetAuthUser() authUser: AuthUserDto,
@Param('assetId') assetId: string,
@Body() dto: UpdateAssetDto,
): Promise<AssetResponseDto> {
return await this.assetService.updateAssetById(authUser, assetId, dto);
}
@Delete('/')
async deleteAsset(
@GetAuthUser() authUser: AuthUserDto,

View File

@@ -97,6 +97,7 @@ describe('AssetService', () => {
beforeAll(() => {
assetRepositoryMock = {
create: jest.fn(),
update: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getAssetCountByTimeBucket: jest.fn(),

View File

@@ -1,6 +1,7 @@
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
InternalServerErrorException,
@@ -39,6 +40,7 @@ import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-use
import { timeUtils } from '@app/common/utils';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
const fileInfo = promisify(stat);
@@ -123,6 +125,21 @@ export class AssetService {
return mapAsset(asset);
}
public async updateAssetById(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
const asset = await this._assetRepository.getById(assetId);
if (!asset) {
throw new BadRequestException('Asset not found');
}
if (authUser.id !== asset.userId) {
throw new ForbiddenException('Not the owner');
}
const updatedAsset = await this._assetRepository.update(asset, dto);
return mapAsset(updatedAsset);
}
public async downloadFile(query: ServeFileDto, res: Res) {
try {
let fileReadStream = null;

View File

@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class UpdateAssetDto {
@IsBoolean()
isFavorite!: boolean;
}

View File

@@ -9,6 +9,7 @@ export class UserResponseDto {
profileImagePath!: string;
shouldChangePassword!: boolean;
isAdmin!: boolean;
deletedAt!: Date | null;
}
export function mapUser(entity: UserEntity): UserResponseDto {
@@ -21,5 +22,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin,
deletedAt: entity.deletedAt || null,
};
}

View File

@@ -7,12 +7,14 @@ import * as bcrypt from 'bcrypt';
import { UpdateUserDto } from './dto/update-user.dto';
export interface IUserRepository {
get(userId: string): Promise<UserEntity | null>;
get(userId: string, withDeleted?: boolean): Promise<UserEntity | null>;
getByEmail(email: string): Promise<UserEntity | null>;
getList(filter?: { excludeId?: string }): Promise<UserEntity[]>;
create(createUserDto: CreateUserDto): Promise<UserEntity>;
update(user: UserEntity, updateUserDto: UpdateUserDto): Promise<UserEntity>;
createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity>;
delete(user: UserEntity): Promise<UserEntity>;
restore(user: UserEntity): Promise<UserEntity>;
}
export const USER_REPOSITORY = 'USER_REPOSITORY';
@@ -27,8 +29,8 @@ export class UserRepository implements IUserRepository {
return bcrypt.hash(password, salt);
}
async get(userId: string): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id: userId } });
async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
}
async getByEmail(email: string): Promise<UserEntity | null> {
@@ -40,9 +42,10 @@ export class UserRepository implements IUserRepository {
if (!excludeId) {
return this.userRepository.find(); // TODO: this should also be ordered the same as below
}
return this.userRepository.find({
return this.userRepository
.find({
where: { id: Not(excludeId) },
withDeleted: true,
order: {
createdAt: 'DESC',
},
@@ -88,6 +91,17 @@ export class UserRepository implements IUserRepository {
return this.userRepository.save(user);
}
async delete(user: UserEntity): Promise<UserEntity> {
if (user.isAdmin) {
throw new BadRequestException('Cannot delete admin user! stay sane!');
}
return this.userRepository.softRemove(user);
}
async restore(user: UserEntity): Promise<UserEntity> {
return this.userRepository.recover(user);
}
async createProfileImage(user: UserEntity, fileInfo: Express.Multer.File): Promise<UserEntity> {
user.profileImagePath = fileInfo.path;
return this.userRepository.save(user);

View File

@@ -2,6 +2,7 @@ import {
Controller,
Get,
Post,
Delete,
Body,
Param,
ValidationPipe,
@@ -67,6 +68,20 @@ export class UserController {
return await this.userService.getUserCount();
}
@Authenticated({ admin: true })
@ApiBearerAuth()
@Delete('/:userId')
async deleteUser(@GetAuthUser() authUser: AuthUserDto, @Param('userId') userId: string): Promise<UserResponseDto> {
return await this.userService.deleteUser(authUser, userId);
}
@Authenticated({ admin: true })
@ApiBearerAuth()
@Post('/:userId/restore')
async restoreUser(@GetAuthUser() authUser: AuthUserDto, @Param('userId') userId: string): Promise<UserResponseDto> {
return await this.userService.restoreUser(authUser, userId);
}
@Authenticated()
@ApiBearerAuth()
@Put()

View File

@@ -65,6 +65,8 @@ describe('UserService', () => {
getByEmail: jest.fn(),
getList: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
};
sui = new UserService(userRepositoryMock);

View File

@@ -1,11 +1,13 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
StreamableFile,
UnauthorizedException,
} from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto';
@@ -38,8 +40,8 @@ export class UserService {
return allUserExceptRequestedUser.map(mapUser);
}
async getUserById(userId: string): Promise<UserResponseDto> {
const user = await this.userRepository.get(userId);
async getUserById(userId: string, withDeleted = false): Promise<UserResponseDto> {
const user = await this.userRepository.get(userId, withDeleted);
if (!user) {
throw new NotFoundException('User not found');
}
@@ -105,6 +107,48 @@ export class UserService {
}
}
async deleteUser(authUser: AuthUserDto, userId: string): Promise<UserResponseDto> {
const requestor = await this.userRepository.get(authUser.id);
if (!requestor) {
throw new UnauthorizedException('Requestor not found');
}
if (!requestor.isAdmin) {
throw new ForbiddenException('Unauthorized');
}
const user = await this.userRepository.get(userId);
if (!user) {
throw new BadRequestException('User not found');
}
try {
const deletedUser = await this.userRepository.delete(user);
return mapUser(deletedUser);
} catch (e) {
Logger.error(e, 'Failed to delete user');
throw new InternalServerErrorException('Failed to delete user');
}
}
async restoreUser(authUser: AuthUserDto, userId: string): Promise<UserResponseDto> {
const requestor = await this.userRepository.get(authUser.id);
if (!requestor) {
throw new UnauthorizedException('Requestor not found');
}
if (!requestor.isAdmin) {
throw new ForbiddenException('Unauthorized');
}
const user = await this.userRepository.get(userId, true);
if (!user) {
throw new BadRequestException('User not found');
}
try {
const restoredUser = await this.userRepository.restore(user);
return mapUser(restoredUser);
} catch (e) {
Logger.error(e, 'Failed to restore deleted user');
throw new InternalServerErrorException('Failed to restore deleted user');
}
}
async createProfileImage(
authUser: AuthUserDto,
fileInfo: Express.Multer.File,

View File

@@ -10,7 +10,7 @@ export interface IServerVersion {
export const serverVersion: IServerVersion = {
major: 1,
minor: 34,
minor: 35,
patch: 0,
build: 53,
build: 54,
};

View File

@@ -8,12 +8,14 @@ import path from 'path';
import { AppModule } from './app.module';
import { serverVersion } from './constants/server_version.constant';
import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
import { json } from 'body-parser';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.set('trust proxy');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (process.env.NODE_ENV === 'development') {
app.enableCors();
}

View File

@@ -2,10 +2,10 @@ import { Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetEntity } from '@app/database/entities/asset.entity';
import fs from 'fs';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { Job } from 'bull';
import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto';
import { assetUtils } from '@app/common/utils';
@Processor('background-task')
export class BackgroundTaskProcessor {
@@ -23,37 +23,7 @@ export class BackgroundTaskProcessor {
const { assets } = job.data;
for (const asset of assets) {
fs.unlink(asset.originalPath, (err) => {
if (err) {
console.log('error deleting ', asset.originalPath);
}
});
// TODO: what if there is no asset.resizePath. Should fail the Job?
// => panoti report: Job not fail
if (asset.resizePath) {
fs.unlink(asset.resizePath, (err) => {
if (err) {
console.log('error deleting ', asset.resizePath);
}
});
}
if (asset.webpPath) {
fs.unlink(asset.webpPath, (err) => {
if (err) {
console.log('error deleting ', asset.webpPath);
}
});
}
if (asset.encodedVideoPath) {
fs.unlink(asset.encodedVideoPath, (err) => {
if (err) {
console.log('error deleting ', asset.encodedVideoPath);
}
});
}
assetUtils.deleteFiles(asset);
}
}
}

View File

@@ -5,10 +5,19 @@ import { AssetEntity } from '@app/database/entities/asset.entity';
import { ScheduleTasksService } from './schedule-tasks.service';
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
import { ExifEntity } from '@app/database/entities/exif.entity';
import { UserEntity } from '@app/database/entities/user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
TypeOrmModule.forFeature([AssetEntity, ExifEntity, UserEntity]),
BullModule.registerQueue({
name: QueueNameEnum.USER_DELETION,
defaultJobOptions: {
attempts: 3,
removeOnComplete: true,
removeOnFail: false,
},
}),
BullModule.registerQueue({
name: QueueNameEnum.VIDEO_CONVERSION,
defaultJobOptions: {

View File

@@ -8,6 +8,7 @@ import { Queue } from 'bull';
import { randomUUID } from 'crypto';
import { ExifEntity } from '@app/database/entities/exif.entity';
import {
userDeletionProcessorName,
exifExtractionProcessorName,
generateWEBPThumbnailProcessorName,
IMetadataExtractionJob,
@@ -18,10 +19,16 @@ import {
videoMetadataExtractionProcessorName,
} from '@app/job';
import { ConfigService } from '@nestjs/config';
import { UserEntity } from '@app/database/entities/user.entity';
import { IUserDeletionJob } from '@app/job/interfaces/user-deletion.interface';
import { userUtils } from '@app/common';
@Injectable()
export class ScheduleTasksService {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@@ -37,6 +44,9 @@ export class ScheduleTasksService {
@InjectQueue(QueueNameEnum.METADATA_EXTRACTION)
private metadataExtractionQueue: Queue<IMetadataExtractionJob>,
@InjectQueue(QueueNameEnum.USER_DELETION)
private userDeletionQueue: Queue<IUserDeletionJob>,
private configService: ConfigService,
) {}
@@ -128,4 +138,14 @@ export class ScheduleTasksService {
}
}
}
@Cron(CronExpression.EVERY_DAY_AT_11PM)
async deleteUserAndRelatedAssets() {
const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
for (const user of usersToDelete) {
if (userUtils.isReadyForDeletion(user)) {
await this.userDeletionQueue.add(userDeletionProcessorName, { user: user }, { jobId: randomUUID() });
}
}
}
}

View File

@@ -104,6 +104,7 @@ describe('User', () => {
isAdmin: false,
shouldChangePassword: true,
profileImagePath: '',
deletedAt: null,
},
{
email: userTwoEmail,
@@ -114,6 +115,7 @@ describe('User', () => {
isAdmin: false,
shouldChangePassword: true,
profileImagePath: '',
deletedAt: null,
},
]),
);

View File

@@ -0,0 +1,35 @@
import { APP_UPLOAD_LOCATION, userUtils } from '@app/common';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { QueueNameEnum, userDeletionProcessorName } from '@app/job';
import { IUserDeletionJob } from '@app/job/interfaces/user-deletion.interface';
import { Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm';
import { Job } from 'bull';
import { join } from 'path';
import fs from 'fs';
import { Repository } from 'typeorm';
@Processor(QueueNameEnum.USER_DELETION)
export class UserDeletionProcessor {
constructor(
@InjectRepository(UserEntity)
private userRepository: Repository<UserEntity>,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
) {}
@Process(userDeletionProcessorName)
async processUserDeletion(job: Job<IUserDeletionJob>) {
const { user } = job.data;
// just for extra protection here
if (userUtils.isReadyForDeletion(user)) {
const basePath = APP_UPLOAD_LOCATION;
const userAssetDir = join(basePath, user.id)
fs.rmSync(userAssetDir, { recursive: true, force: true })
await this.assetRepository.delete({ userId: user.id })
await this.userRepository.remove(user);
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,39 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
import { AssetResponseDto } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
import fs from 'fs';
const deleteFiles = (asset: AssetEntity | AssetResponseDto) => {
fs.unlink(asset.originalPath, (err) => {
if (err) {
console.log('error deleting ', asset.originalPath);
}
});
// TODO: what if there is no asset.resizePath. Should fail the Job?
// => panoti report: Job not fail
if (asset.resizePath) {
fs.unlink(asset.resizePath, (err) => {
if (err) {
console.log('error deleting ', asset.resizePath);
}
});
}
if (asset.webpPath) {
fs.unlink(asset.webpPath, (err) => {
if (err) {
console.log('error deleting ', asset.webpPath);
}
});
}
if (asset.encodedVideoPath) {
fs.unlink(asset.encodedVideoPath, (err) => {
if (err) {
console.log('error deleting ', asset.encodedVideoPath);
}
});
}
};
export const assetUtils = { deleteFiles };

View File

@@ -1 +1,3 @@
export * from './time-utils';
export * from './asset-utils';
export * from './user-utils';

View File

@@ -0,0 +1,19 @@
// create unit test for user utils
import { UserEntity } from '@app/database/entities/user.entity';
import { userUtils } from './user-utils';
describe('User Utilities', () => {
describe('checkIsReadyForDeletion', () => {
it('check that user is not ready to be deleted', () => {
const result = userUtils.isReadyForDeletion({ deletedAt: new Date() } as UserEntity);
expect(result).toBeFalsy();
});
it('check that user is ready to be deleted', () => {
const aWeekAgo = new Date(new Date().getTime() - 8 * 86400000);
const result = userUtils.isReadyForDeletion({ deletedAt: aWeekAgo } as UserEntity);
expect(result).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,16 @@
import { UserEntity } from '@app/database/entities/user.entity';
function createUserUtils() {
const isReadyForDeletion = (user: UserEntity): boolean => {
if (user.deletedAt == null) return false;
const millisecondsInDay = 86400000;
// get this number (7 days) from some configuration perhaps ?
const millisecondsDeleteWait = millisecondsInDay * 7;
const millisecondsSinceDelete = new Date().getTime() - (user.deletedAt?.getTime() ?? 0);
return millisecondsSinceDelete >= millisecondsDeleteWait;
};
return { isReadyForDeletion };
}
export const userUtils = createUserUtils();

View File

@@ -1,4 +1,4 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, DeleteDateColumn } from 'typeorm';
@Entity('users')
export class UserEntity {
@@ -31,4 +31,7 @@ export class UserEntity {
@CreateDateColumn()
createdAt!: string;
@DeleteDateColumn()
deletedAt?: Date;
}

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddingDeletedAtColumnInUserEntity1667762360744 implements MigrationInterface {
name = 'AddingDeletedAtColumnInUserEntity1667762360744';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "deletedAt" TIMESTAMP`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "deletedAt"`);
}
}

View File

@@ -29,3 +29,8 @@ export enum MachineLearningJobNameEnum {
OBJECT_DETECTION = 'detect-object',
IMAGE_TAGGING = 'tag-image',
}
/**
* User deletion Queue Jobs
*/
export const userDeletionProcessorName = 'user-deletion';

View File

@@ -5,4 +5,5 @@ export enum QueueNameEnum {
CHECKSUM_GENERATION = 'generate-checksum-queue',
ASSET_UPLOADED = 'asset-uploaded-queue',
MACHINE_LEARNING = 'machine-learning-queue',
USER_DELETION = 'user-deletion-queue',
}

View File

@@ -0,0 +1,8 @@
import { UserEntity } from '@app/database/entities/user.entity';
export interface IUserDeletionJob {
/**
* The user entity that was saved in the database
*/
user: UserEntity;
}

View File

@@ -1391,6 +1391,19 @@ export interface UpdateAlbumDto {
*/
'albumThumbnailAssetId'?: string;
}
/**
*
* @export
* @interface UpdateAssetDto
*/
export interface UpdateAssetDto {
/**
*
* @type {boolean}
* @memberof UpdateAssetDto
*/
'isFavorite': boolean;
}
/**
*
* @export
@@ -1575,6 +1588,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto
*/
'isAdmin': boolean;
/**
*
* @type {string}
* @memberof UserResponseDto
*/
'deletedAt': string | null;
}
/**
*
@@ -3052,6 +3071,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions,
};
},
/**
* Update an asset
* @summary
* @param {string} assetId
* @param {UpdateAssetDto} updateAssetDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateAssetById: async (assetId: string, updateAssetDto: UpdateAssetDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetId' is not null or undefined
assertParamExists('updateAssetById', 'assetId', assetId)
// verify required parameter 'updateAssetDto' is not null or undefined
assertParamExists('updateAssetById', 'updateAssetDto', updateAssetDto)
const localVarPath = `/asset/assetById/{assetId}`
.replace(`{${"assetId"}}`, encodeURIComponent(String(assetId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(updateAssetDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {any} assetData
@@ -3273,6 +3336,18 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.serveFile(aid, did, isThumb, isWeb, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
* Update an asset
* @summary
* @param {string} assetId
* @param {UpdateAssetDto} updateAssetDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateAssetById(assetId: string, updateAssetDto: UpdateAssetDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssetById(assetId, updateAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {any} assetData
@@ -3444,6 +3519,17 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
serveFile(aid: string, did: string, isThumb?: boolean, isWeb?: boolean, options?: any): AxiosPromise<object> {
return localVarFp.serveFile(aid, did, isThumb, isWeb, options).then((request) => request(axios, basePath));
},
/**
* Update an asset
* @summary
* @param {string} assetId
* @param {UpdateAssetDto} updateAssetDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateAssetById(assetId: string, updateAssetDto: UpdateAssetDto, options?: any): AxiosPromise<AssetResponseDto> {
return localVarFp.updateAssetById(assetId, updateAssetDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {any} assetData
@@ -3646,6 +3732,19 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).serveFile(aid, did, isThumb, isWeb, options).then((request) => request(this.axios, this.basePath));
}
/**
* Update an asset
* @summary
* @param {string} assetId
* @param {UpdateAssetDto} updateAssetDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public updateAssetById(assetId: string, updateAssetDto: UpdateAssetDto, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).updateAssetById(assetId, updateAssetDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {any} assetData
@@ -4711,6 +4810,43 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteUser: async (userId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'userId' is not null or undefined
assertParamExists('deleteUser', 'userId', userId)
const localVarPath = `/user/{userId}`
.replace(`{${"userId"}}`, encodeURIComponent(String(userId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {boolean} isAll
@@ -4870,6 +5006,43 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreUser: async (userId: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'userId' is not null or undefined
assertParamExists('restoreUser', 'userId', userId)
const localVarPath = `/user/{userId}/restore`
.replace(`{${"userId"}}`, encodeURIComponent(String(userId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -4948,6 +5121,16 @@ export const UserApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.createUser(createUserDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteUser(userId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteUser(userId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {boolean} isAll
@@ -4996,6 +5179,16 @@ export const UserApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getUserCount(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async restoreUser(userId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.restoreUser(userId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {UpdateUserDto} updateUserDto
@@ -5034,6 +5227,15 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
createUser(createUserDto: CreateUserDto, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.createUser(createUserDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteUser(userId: string, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.deleteUser(userId, options).then((request) => request(axios, basePath));
},
/**
*
* @param {boolean} isAll
@@ -5077,6 +5279,15 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
getUserCount(options?: any): AxiosPromise<UserCountResponseDto> {
return localVarFp.getUserCount(options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreUser(userId: string, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.restoreUser(userId, options).then((request) => request(axios, basePath));
},
/**
*
* @param {UpdateUserDto} updateUserDto
@@ -5118,6 +5329,17 @@ export class UserApi extends BaseAPI {
return UserApiFp(this.configuration).createUser(createUserDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UserApi
*/
public deleteUser(userId: string, options?: AxiosRequestConfig) {
return UserApiFp(this.configuration).deleteUser(userId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {boolean} isAll
@@ -5171,6 +5393,17 @@ export class UserApi extends BaseAPI {
return UserApiFp(this.configuration).getUserCount(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} userId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UserApi
*/
public restoreUser(userId: string, options?: AxiosRequestConfig) {
return UserApiFp(this.configuration).restoreUser(userId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {UpdateUserDto} updateUserDto

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
export let user: UserResponseDto;
const dispatch = createEventDispatcher();
const deleteUser = async () => {
const deletedUser = await api.userApi.deleteUser(user.id);
if (deletedUser.data.deletedAt != null) dispatch('user-delete-success');
else dispatch('user-delete-fail');
};
</script>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
Confirm User Deletion
</h1>
</div>
<div>
<p class="ml-4 text-md py-5 text-center">
{user.firstName}
{user.lastName} account and assets along will be marked to delete completely after 7 days. are
you sure you want to proceed ?
</p>
<div class="flex w-full px-4 gap-4 mt-8">
<button
on:click={deleteUser}
class="flex-1 transition-colors bg-red-500 hover:bg-red-400 px-6 py-3 text-white rounded-full w-full font-medium"
>Confirm
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
export let user: UserResponseDto;
const dispatch = createEventDispatcher();
const restoreUser = async () => {
const restoredUser = await api.userApi.restoreUser(user.id);
if (restoredUser.data.deletedAt == null) dispatch('user-restore-success');
else dispatch('user-restore-fail');
};
</script>
<div
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] rounded-3xl py-8 dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
Restore User
</h1>
</div>
<div>
<p class="ml-4 text-md py-5 text-center">
{user.firstName}
{user.lastName} account will restored
</p>
<div class="flex w-full px-4 gap-4 mt-8">
<button
on:click={restoreUser}
class="flex-1 transition-colors bg-lime-600 hover:bg-lime-500 px-6 py-3 text-white rounded-full w-full font-medium"
>Confirm
</button>
</div>
</div>
</div>

View File

@@ -3,9 +3,21 @@
import { createEventDispatcher } from 'svelte';
import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import DeleteRestore from 'svelte-material-icons/DeleteRestore.svelte';
import moment from 'moment';
export let allUsers: Array<UserResponseDto>;
const dispatch = createEventDispatcher();
const isDeleted = (user: UserResponseDto): boolean => {
return user.deletedAt != null;
};
const getDeleteDate = (user: UserResponseDto): string => {
return moment(user.deletedAt).add(7, 'days').format('LL');
};
</script>
<table class="text-left w-full my-5">
@@ -16,7 +28,7 @@
<th class="text-center w-1/4 font-medium text-sm">Email</th>
<th class="text-center w-1/4 font-medium text-sm">First name</th>
<th class="text-center w-1/4 font-medium text-sm">Last name</th>
<th class="text-center w-1/4 font-medium text-sm">Edit</th>
<th class="text-center w-1/4 font-medium text-sm">Action</th>
</tr>
</thead>
<tbody
@@ -25,21 +37,44 @@
{#each allUsers as user, i}
<tr
class={`text-center flex place-items-center w-full h-[80px] dark:text-immich-dark-bg ${
i % 2 == 0 ? 'bg-immich-gray dark:bg-[#e5e5e5]' : 'bg-immich-bg dark:bg-[#eeeeee]'
isDeleted(user)
? 'bg-red-50'
: i % 2 == 0
? 'bg-immich-gray dark:bg-[#e5e5e5]'
: 'bg-immich-bg dark:bg-[#eeeeee]'
}`}
>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.firstName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis"
><button
on:click={() => {
dispatch('edit-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><PencilOutline size="20" /></button
></td
>
<td class="text-sm px-4 w-1/4 text-ellipsis">
{#if !isDeleted(user)}
<button
on:click={() => {
dispatch('edit-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><PencilOutline size="16" /></button
>
<button
on:click={() => {
dispatch('delete-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
><TrashCanOutline size="16" /></button
>
{/if}
{#if isDeleted(user)}
<button
on:click={() => {
dispatch('restore-user', { user });
}}
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
title={`scheduled removal on ${getDeleteDate(user)}`}
><DeleteRestore size="16" /></button
>
{/if}
</td>
</tr>
{/each}
</tbody>

View File

@@ -9,6 +9,14 @@
import CircleIconButton from '../shared-components/circle-icon-button.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import Star from 'svelte-material-icons/Star.svelte';
import StarOutline from 'svelte-material-icons/StarOutline.svelte';
import { page } from '$app/stores';
import { AssetResponseDto } from '../../../api';
export let asset: AssetResponseDto;
const isOwner = asset.ownerId === $page.data.user.id;
const dispatch = createEventDispatcher();
@@ -38,8 +46,15 @@
</div>
<div class="text-white flex gap-2">
<CircleIconButton logo={CloudDownloadOutline} on:click={() => dispatch('download')} />
<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} />
<CircleIconButton logo={InformationOutline} on:click={() => dispatch('showDetail')} />
{#if isOwner}
<CircleIconButton
logo={asset.isFavorite ? Star : StarOutline}
on:click={() => dispatch('favorite')}
title="Favorite"
/>
{/if}
<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} />
<CircleIconButton logo={DotsVertical} on:click={(event) => showOptionsMenu(event)} />
</div>
</div>

View File

@@ -178,6 +178,14 @@
}
};
const toggleFavorite = async () => {
const { data } = await api.assetApi.updateAssetById(asset.id, {
isFavorite: !asset.isFavorite
});
asset.isFavorite = data.isFavorite;
};
const openAlbumPicker = (shared: boolean) => {
isShowAlbumPicker = true;
addToSharedAlbum = shared;
@@ -218,10 +226,12 @@
>
<div class="col-start-1 col-span-4 row-start-1 row-span-1 z-[1000] transition-transform">
<AsserViewerNavBar
{asset}
on:goBack={closeViewer}
on:showDetail={showDetailInfoHandler}
on:download={downloadFile}
on:delete={deleteAsset}
on:favorite={toggleFavorite}
on:addToAlbum={() => openAlbumPicker(false)}
on:addToSharedAlbum={() => openAlbumPicker(true)}
/>

View File

@@ -11,21 +11,25 @@
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialoge.svelte';
import StatusBox from '$lib/components/shared-components/status-box.svelte';
import type { PageData } from './$types';
import { api, ServerStatsResponseDto, UserResponseDto } from '@api';
import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
import ServerStatsPanel from '$lib/components/admin-page/server-stats/server-stats-panel.svelte';
import RestoreDialoge from '$lib/components/admin-page/restore-dialoge.svelte';
let selectedAction: AdminSideBarSelection = AdminSideBarSelection.USER_MANAGEMENT;
export let data: PageData;
let editUser: UserResponseDto;
let selectedUser: UserResponseDto;
let shouldShowEditUserForm = false;
let shouldShowCreateUserForm = false;
let shouldShowInfoPanel = false;
let shouldShowDeleteConfirmDialog = false;
let shouldShowRestoreDialog = false;
let serverStat: ServerStatsResponseDto;
const onButtonClicked = (buttonType: CustomEvent) => {
@@ -45,7 +49,7 @@
const editUserHandler = async (event: CustomEvent) => {
const { user } = event.detail;
editUser = user;
selectedUser = user;
shouldShowEditUserForm = true;
};
@@ -62,6 +66,43 @@
shouldShowInfoPanel = true;
};
const deleteUserHandler = async (event: CustomEvent) => {
const { user } = event.detail;
selectedUser = user;
shouldShowDeleteConfirmDialog = true;
};
const onUserDeleteSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowDeleteConfirmDialog = false;
};
const onUserDeleteFail = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowDeleteConfirmDialog = false;
};
const restoreUserHandler = async (event: CustomEvent) => {
const { user } = event.detail;
selectedUser = user;
shouldShowRestoreDialog = true;
};
const onUserRestoreSuccess = async () => {
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowRestoreDialog = false;
};
const onUserRestoreFail = async () => {
// show fail dialog
const getAllUsersRes = await api.userApi.getAllUsers(false);
data.allUsers = getAllUsersRes.data;
shouldShowRestoreDialog = false;
};
const getServerStats = async () => {
try {
const res = await api.serverInfoApi.getStats();
@@ -87,13 +128,33 @@
{#if shouldShowEditUserForm}
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
<EditUserForm
user={editUser}
user={selectedUser}
on:edit-success={onEditUserSuccess}
on:reset-password-success={onEditPasswordSuccess}
/>
</FullScreenModal>
{/if}
{#if shouldShowDeleteConfirmDialog}
<FullScreenModal on:clickOutside={() => (shouldShowDeleteConfirmDialog = false)}>
<DeleteConfirmDialog
user={selectedUser}
on:user-delete-success={onUserDeleteSuccess}
on:user-delete-fail={onUserDeleteFail}
/>
</FullScreenModal>
{/if}
{#if shouldShowRestoreDialog}
<FullScreenModal on:clickOutside={() => (shouldShowRestoreDialog = false)}>
<RestoreDialoge
user={selectedUser}
on:user-restore-success={onUserRestoreSuccess}
on:user-restore-fail={onUserRestoreFail}
/>
</FullScreenModal>
{/if}
{#if shouldShowInfoPanel}
<FullScreenModal on:clickOutside={() => (shouldShowInfoPanel = false)}>
<div class="border bg-white shadow-sm w-[500px] rounded-3xl p-8 text-sm">
@@ -160,6 +221,8 @@
allUsers={data.allUsers}
on:create-user={() => (shouldShowCreateUserForm = true)}
on:edit-user={editUserHandler}
on:delete-user={deleteUserHandler}
on:restore-user={restoreUserHandler}
/>
{/if}
{#if selectedAction === AdminSideBarSelection.JOBS}