mirror of
https://github.com/immich-app/immich.git
synced 2025-12-25 12:25:46 -08:00
Compare commits
7 Commits
v1.34.0_53
...
v1.35.0_54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66640ebfeb | ||
|
|
9057e4b7d0 | ||
|
|
0deb8f4090 | ||
|
|
1633af7af6 | ||
|
|
99da181cfc | ||
|
|
8a9b0347bb | ||
|
|
fe4b307fe6 |
@@ -2,4 +2,18 @@
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
# FAQ
|
||||
# FAQ
|
||||
|
||||
### What is the difference between the cloud icon on the mobile app?
|
||||
|
||||
| Icon | Description |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|  | 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 |
|
||||
|  | Asset is only available locally and has not yet been backed up |
|
||||
|  | 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
1
docs/static/img/cloud-done.svg
vendored
Normal 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
1
docs/static/img/cloud-off.svg
vendored
Normal 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
1
docs/static/img/cloud.svg
vendored
Normal 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 |
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
* Local assets are now shown in the app
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}) {
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -166,7 +166,7 @@ class CreateAlbumPage extends HookConsumerWidget {
|
||||
return GestureDetector(
|
||||
onTap: _onBackgroundTapped,
|
||||
child: SharedAlbumThumbnailImage(
|
||||
asset: selectedAssets.toList()[index],
|
||||
asset: selectedAssets.elementAt(index),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} ",
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 : () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>{};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
126
mobile/lib/shared/models/asset.dart
Normal file
126
mobile/lib/shared/models/asset.dart
Normal 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;
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
96
mobile/lib/shared/ui/immich_image.dart
Normal file
96
mobile/lib/shared/ui/immich_image.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
15
mobile/openapi/doc/UpdateAssetDto.md
Normal file
15
mobile/openapi/doc/UpdateAssetDto.md
Normal 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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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:
|
||||
///
|
||||
|
||||
@@ -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:
|
||||
///
|
||||
|
||||
@@ -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':
|
||||
|
||||
111
mobile/openapi/lib/model/update_asset_dto.dart
Normal file
111
mobile/openapi/lib/model/update_asset_dto.dart
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
27
mobile/openapi/test/update_asset_dto_test.dart
Normal file
27
mobile/openapi/test/update_asset_dto_test.dart
Normal 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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -97,6 +97,7 @@ describe('AssetService', () => {
|
||||
beforeAll(() => {
|
||||
assetRepositoryMock = {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
getAllByUserId: jest.fn(),
|
||||
getAllByDeviceId: jest.fn(),
|
||||
getAssetCountByTimeBucket: jest.fn(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class UpdateAssetDto {
|
||||
@IsBoolean()
|
||||
isFavorite!: boolean;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface IServerVersion {
|
||||
|
||||
export const serverVersion: IServerVersion = {
|
||||
major: 1,
|
||||
minor: 34,
|
||||
minor: 35,
|
||||
patch: 0,
|
||||
build: 53,
|
||||
build: 54,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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
39
server/libs/common/src/utils/asset-utils.ts
Normal file
39
server/libs/common/src/utils/asset-utils.ts
Normal 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 };
|
||||
@@ -1 +1,3 @@
|
||||
export * from './time-utils';
|
||||
export * from './asset-utils';
|
||||
export * from './user-utils';
|
||||
|
||||
19
server/libs/common/src/utils/user-utils.spec.ts
Normal file
19
server/libs/common/src/utils/user-utils.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
16
server/libs/common/src/utils/user-utils.ts
Normal file
16
server/libs/common/src/utils/user-utils.ts
Normal 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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,8 @@ export enum MachineLearningJobNameEnum {
|
||||
OBJECT_DETECTION = 'detect-object',
|
||||
IMAGE_TAGGING = 'tag-image',
|
||||
}
|
||||
|
||||
/**
|
||||
* User deletion Queue Jobs
|
||||
*/
|
||||
export const userDeletionProcessorName = 'user-deletion';
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
40
web/src/lib/components/admin-page/restore-dialoge.svelte
Normal file
40
web/src/lib/components/admin-page/restore-dialoge.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user