Compare commits

...

7 Commits

Author SHA1 Message Date
Alex
66640ebfeb Up version for release 2022-11-08 14:34:47 -06:00
Alex
9057e4b7d0 Update documentation 2022-11-08 14:22:42 -06:00
Matthias Rupp
0deb8f4090 Add equals and hashcode to Asset 2022-11-08 19:02:02 +01:00
Fynn Petersen-Frey
1633af7af6 feat(mobile): show local assets (#905)
* introduce Asset as composition of AssetResponseDTO and AssetEntity

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

* only allow remote images to be added to albums

* introduce ImmichImage to render Asset using local or remote data

* optimized deletion of local assets

* local video file playback

* allow multiple methods to wait on background service finished

* skip local assets when adding to album from home screen

* fix and optimize delete

* show gray box placeholder for local assets

* add comments

* fix bug: duplicate assets in state after onNewAssetUploaded
2022-11-08 11:00:24 -06:00
Jason Rasmussen
99da181cfc feat(web): favorite an asset (#939)
* feat(web): favorite an asset

* fix: test and linting

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

* addressed review comments and fix e2e test

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

* addressed review comments

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

View File

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

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

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

After

Width:  |  Height:  |  Size: 756 B

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

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

After

Width:  |  Height:  |  Size: 799 B

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

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

After

Width:  |  Height:  |  Size: 545 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,6 +46,17 @@ class HiveBackupAlbums {
); );
} }
/// Returns a deep copy to allow safe modification without changing the global
/// state of [HiveBackupAlbums] before actually saving the changes
HiveBackupAlbums deepCopy() {
return HiveBackupAlbums(
selectedAlbumIds: selectedAlbumIds.toList(),
excludedAlbumsIds: excludedAlbumsIds.toList(),
lastSelectedBackupTime: lastSelectedBackupTime.toList(),
lastExcludedBackupTime: lastExcludedBackupTime.toList(),
);
}
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
final result = <String, dynamic>{}; final result = <String, dynamic>{};

View File

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

View File

@@ -1,34 +1,90 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:photo_manager/src/types/entity.dart';
final assetServiceProvider = Provider( final assetServiceProvider = Provider(
(ref) => AssetService( (ref) => AssetService(
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(backgroundServiceProvider),
), ),
); );
class AssetService { class AssetService {
final ApiService _apiService; final ApiService _apiService;
final BackupService _backupService;
final BackgroundService _backgroundService;
AssetService(this._apiService); AssetService(this._apiService, this._backupService, this._backgroundService);
Future<List<AssetResponseDto>?> getAllAsset() async { /// Returns all local, remote assets in that order
Future<List<Asset>> getAllAsset({bool urgent = false}) async {
final List<Asset> assets = [];
try { try {
return await _apiService.assetApi.getAllAssets(); // not using `await` here to fetch local & remote assets concurrently
final Future<List<AssetResponseDto>?> remoteTask =
_apiService.assetApi.getAllAssets();
final Iterable<AssetEntity> newLocalAssets;
final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final Set<String> existingIds = remoteAssets
.where((e) => e.deviceId == deviceId)
.map((e) => e.deviceAssetId)
.toSet();
newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id));
} else {
newLocalAssets = localAssets;
}
assets.addAll(newLocalAssets.map((e) => Asset.local(e)));
// the order (first all local, then remote assets) is important!
assets.addAll(remoteAssets.map((e) => Asset.remote(e)));
} catch (e) { } catch (e) {
debugPrint("Error [getAllAsset] ${e.toString()}"); debugPrint("Error [getAllAsset] ${e.toString()}");
return null; }
return assets;
}
/// if [urgent] is `true`, do not block by waiting on the background service
/// to finish running. Returns an empty list instead after a timeout.
Future<List<AssetEntity>> _getLocalAssets(bool urgent) async {
try {
final Future<bool> hasAccess = urgent
? _backgroundService.hasAccess
.timeout(const Duration(milliseconds: 250))
: _backgroundService.hasAccess;
if (!await hasAccess) {
throw Exception("Error [getAllAsset] failed to gain access");
}
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
return backupAlbumInfo != null
? await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy())
: [];
} catch (e) {
debugPrint("Error [_getLocalAssets] ${e.toString()}");
return [];
} }
} }
Future<AssetResponseDto?> getAssetById(String assetId) async { Future<Asset?> getAssetById(String assetId) async {
try { try {
return await _apiService.assetApi.getAssetById(assetId); return Asset.remote(await _apiService.assetApi.getAssetById(assetId));
} catch (e) { } catch (e) {
debugPrint("Error [getAssetById] ${e.toString()}"); debugPrint("Error [getAssetById] ${e.toString()}");
return null; return null;
@@ -36,12 +92,12 @@ class AssetService {
} }
Future<List<DeleteAssetResponseDto>?> deleteAssets( Future<List<DeleteAssetResponseDto>?> deleteAssets(
Set<AssetResponseDto> deleteAssets, Iterable<AssetResponseDto> deleteAssets,
) async { ) async {
try { try {
List<String> payload = []; final List<String> payload = [];
for (var asset in deleteAssets) { for (final asset in deleteAssets) {
payload.add(asset.id); payload.add(asset.id);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import 'package:immich_mobile/modules/settings/views/settings_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart'; import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/views/splash_screen.dart'; import 'package:immich_mobile/shared/views/splash_screen.dart';

View File

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

View File

@@ -0,0 +1,126 @@
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
/// Asset (online or local)
class Asset {
Asset.remote(this.remote) {
local = null;
}
Asset.local(this.local) {
remote = null;
}
late final AssetResponseDto? remote;
late final AssetEntity? local;
bool get isRemote => remote != null;
bool get isLocal => local != null;
String get deviceId =>
isRemote ? remote!.deviceId : Hive.box(userInfoBox).get(deviceIdKey);
String get deviceAssetId => isRemote ? remote!.deviceAssetId : local!.id;
String get id => isLocal ? local!.id : remote!.id;
double? get latitude =>
isLocal ? local!.latitude : remote!.exifInfo?.latitude?.toDouble();
double? get longitude =>
isLocal ? local!.longitude : remote!.exifInfo?.longitude?.toDouble();
DateTime get createdAt =>
isLocal ? local!.createDateTime : DateTime.parse(remote!.createdAt);
bool get isImage => isLocal
? local!.type == AssetType.image
: remote!.type == AssetTypeEnum.IMAGE;
String get duration => isRemote
? remote!.duration
: Duration(seconds: local!.duration).toString();
/// use only for tests
set createdAt(DateTime val) {
if (isRemote) {
remote!.createdAt = val.toIso8601String();
}
}
@override
bool operator ==(other) {
if (other is! Asset) return false;
return id == other.id && isLocal == other.isLocal;
}
@override
int get hashCode => id.hashCode;
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (isLocal) {
json["local"] = _assetEntityToJson(local!);
} else {
json["remote"] = remote!.toJson();
}
return json;
}
static Asset? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
final l = json["local"];
if (l != null) {
return Asset.local(_assetEntityFromJson(l));
} else {
return Asset.remote(AssetResponseDto.fromJson(json["remote"]));
}
}
return null;
}
}
Map<String, dynamic> _assetEntityToJson(AssetEntity a) {
final json = <String, dynamic>{};
json["id"] = a.id;
json["typeInt"] = a.typeInt;
json["width"] = a.width;
json["height"] = a.height;
json["duration"] = a.duration;
json["orientation"] = a.orientation;
json["isFavorite"] = a.isFavorite;
json["title"] = a.title;
json["createDateSecond"] = a.createDateSecond;
json["modifiedDateSecond"] = a.modifiedDateSecond;
json["latitude"] = a.latitude;
json["longitude"] = a.longitude;
json["mimeType"] = a.mimeType;
json["subtype"] = a.subtype;
return json;
}
AssetEntity? _assetEntityFromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEntity(
id: json["id"],
typeInt: json["typeInt"],
width: json["width"],
height: json["height"],
duration: json["duration"],
orientation: json["orientation"],
isFavorite: json["isFavorite"],
title: json["title"],
createDateSecond: json["createDateSecond"],
modifiedDateSecond: json["modifiedDateSecond"],
latitude: json["latitude"],
longitude: json["longitude"],
mimeType: json["mimeType"],
subtype: json["subtype"],
);
}
return null;
}

View File

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

View File

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

View File

@@ -0,0 +1,96 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:photo_manager/photo_manager.dart';
/// Renders an Asset using local data if available, else remote data
class ImmichImage extends StatelessWidget {
const ImmichImage(
this.asset, {
required this.width,
required this.height,
this.useGrayBoxPlaceholder = false,
super.key,
});
final Asset asset;
final bool useGrayBoxPlaceholder;
final double width;
final double height;
@override
Widget build(BuildContext context) {
if (asset.isLocal) {
return Image(
image: AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
),
width: width,
height: height,
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return (useGrayBoxPlaceholder
? const SizedBox.square(
dimension: 250,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
),
)
: Transform.scale(
scale: 0.2,
child: const CircularProgressIndicator(),
));
},
errorBuilder: (context, error, stackTrace) {
debugPrint("Error getting thumb for assetId=${asset.id}: $error");
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
);
}
final String token = Hive.box(userInfoBox).get(accessTokenKey);
final String thumbnailRequestUrl = getThumbnailUrl(asset.remote!);
return CachedNetworkImage(
imageUrl: thumbnailRequestUrl,
httpHeaders: {"Authorization": "Bearer $token"},
cacheKey: 'thumbnail-image-${asset.id}',
width: width,
height: height,
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
// maxHeightDiskCache = null allows to simply store the webp thumbnail
// from the server and use it for all rendered thumbnail sizes
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) {
if (useGrayBoxPlaceholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(
value: downloadProgress.progress,
),
);
},
errorWidget: (context, url, error) {
debugPrint("Error getting thumbnail $url = $error");
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
);
}
}

View File

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

View File

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

View File

@@ -25,6 +25,7 @@ Method | HTTP request | Description
[**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} |
[**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search |
[**serveFile**](AssetApi.md#servefile) | **GET** /asset/file | [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file |
[**updateAssetById**](AssetApi.md#updateassetbyid) | **PUT** /asset/assetById/{assetId} |
[**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | [**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) [[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** # **uploadFile**
> AssetFileUploadResponseDto uploadFile(assetData) > AssetFileUploadResponseDto uploadFile(assetData)

View File

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

View File

@@ -11,11 +11,13 @@ Method | HTTP request | Description
------------- | ------------- | ------------- ------------- | ------------- | -------------
[**createProfileImage**](UserApi.md#createprofileimage) | **POST** /user/profile-image | [**createProfileImage**](UserApi.md#createprofileimage) | **POST** /user/profile-image |
[**createUser**](UserApi.md#createuser) | **POST** /user | [**createUser**](UserApi.md#createuser) | **POST** /user |
[**deleteUser**](UserApi.md#deleteuser) | **DELETE** /user/{userId} |
[**getAllUsers**](UserApi.md#getallusers) | **GET** /user | [**getAllUsers**](UserApi.md#getallusers) | **GET** /user |
[**getMyUserInfo**](UserApi.md#getmyuserinfo) | **GET** /user/me | [**getMyUserInfo**](UserApi.md#getmyuserinfo) | **GET** /user/me |
[**getProfileImage**](UserApi.md#getprofileimage) | **GET** /user/profile-image/{userId} | [**getProfileImage**](UserApi.md#getprofileimage) | **GET** /user/profile-image/{userId} |
[**getUserById**](UserApi.md#getuserbyid) | **GET** /user/info/{userId} | [**getUserById**](UserApi.md#getuserbyid) | **GET** /user/info/{userId} |
[**getUserCount**](UserApi.md#getusercount) | **GET** /user/count | [**getUserCount**](UserApi.md#getusercount) | **GET** /user/count |
[**restoreUser**](UserApi.md#restoreuser) | **POST** /user/{userId}/restore |
[**updateUser**](UserApi.md#updateuser) | **PUT** /user | [**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) [[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** # **getAllUsers**
> List<UserResponseDto> getAllUsers(isAll) > 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) [[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** # **updateUser**
> UserResponseDto updateUser(updateUserDto) > UserResponseDto updateUser(updateUserDto)

View File

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

View File

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

View File

@@ -858,6 +858,67 @@ class AssetApi {
return null; 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]. /// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View File

@@ -120,6 +120,54 @@ class UserApi {
return null; 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]. /// Performs an HTTP 'GET /user' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
@@ -350,6 +398,54 @@ class UserApi {
return null; 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]. /// Performs an HTTP 'PUT /user' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@ import {
BadRequestException, BadRequestException,
UploadedFile, UploadedFile,
Header, Header,
Put,
} from '@nestjs/common'; } from '@nestjs/common';
import { Authenticated } from '../../decorators/authenticated.decorator'; import { Authenticated } from '../../decorators/authenticated.decorator';
import { AssetService } from './asset.service'; 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 { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto'; import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
@Authenticated() @Authenticated()
@ApiBearerAuth() @ApiBearerAuth()
@@ -222,6 +224,18 @@ export class AssetController {
return await this.assetService.getAssetById(authUser, assetId); 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('/') @Delete('/')
async deleteAsset( async deleteAsset(
@GetAuthUser() authUser: AuthUserDto, @GetAuthUser() authUser: AuthUserDto,

View File

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

View File

@@ -1,6 +1,7 @@
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Inject, Inject,
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
@@ -39,6 +40,7 @@ import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-use
import { timeUtils } from '@app/common/utils'; import { timeUtils } from '@app/common/utils';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto'; import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
import { UpdateAssetDto } from './dto/update-asset.dto';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@@ -123,6 +125,21 @@ export class AssetService {
return mapAsset(asset); 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) { public async downloadFile(query: ServeFileDto, res: Res) {
try { try {
let fileReadStream = null; let fileReadStream = null;

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import {
Controller, Controller,
Get, Get,
Post, Post,
Delete,
Body, Body,
Param, Param,
ValidationPipe, ValidationPipe,
@@ -67,6 +68,20 @@ export class UserController {
return await this.userService.getUserCount(); 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() @Authenticated()
@ApiBearerAuth() @ApiBearerAuth()
@Put() @Put()

View File

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

View File

@@ -1,11 +1,13 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException,
Inject, Inject,
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
Logger, Logger,
NotFoundException, NotFoundException,
StreamableFile, StreamableFile,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
@@ -38,8 +40,8 @@ export class UserService {
return allUserExceptRequestedUser.map(mapUser); return allUserExceptRequestedUser.map(mapUser);
} }
async getUserById(userId: string): Promise<UserResponseDto> { async getUserById(userId: string, withDeleted = false): Promise<UserResponseDto> {
const user = await this.userRepository.get(userId); const user = await this.userRepository.get(userId, withDeleted);
if (!user) { if (!user) {
throw new NotFoundException('User not found'); 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( async createProfileImage(
authUser: AuthUserDto, authUser: AuthUserDto,
fileInfo: Express.Multer.File, fileInfo: Express.Multer.File,

View File

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

View File

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

View File

@@ -2,10 +2,10 @@ import { Process, Processor } from '@nestjs/bull';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AssetEntity } from '@app/database/entities/asset.entity'; import { AssetEntity } from '@app/database/entities/asset.entity';
import fs from 'fs';
import { SmartInfoEntity } from '@app/database/entities/smart-info.entity'; import { SmartInfoEntity } from '@app/database/entities/smart-info.entity';
import { Job } from 'bull'; import { Job } from 'bull';
import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto'; import { AssetResponseDto } from '../../api-v1/asset/response-dto/asset-response.dto';
import { assetUtils } from '@app/common/utils';
@Processor('background-task') @Processor('background-task')
export class BackgroundTaskProcessor { export class BackgroundTaskProcessor {
@@ -23,37 +23,7 @@ export class BackgroundTaskProcessor {
const { assets } = job.data; const { assets } = job.data;
for (const asset of assets) { for (const asset of assets) {
fs.unlink(asset.originalPath, (err) => { assetUtils.deleteFiles(asset);
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);
}
});
}
} }
} }
} }

View File

@@ -5,10 +5,19 @@ import { AssetEntity } from '@app/database/entities/asset.entity';
import { ScheduleTasksService } from './schedule-tasks.service'; import { ScheduleTasksService } from './schedule-tasks.service';
import { QueueNameEnum } from '@app/job/constants/queue-name.constant'; import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
import { ExifEntity } from '@app/database/entities/exif.entity'; import { ExifEntity } from '@app/database/entities/exif.entity';
import { UserEntity } from '@app/database/entities/user.entity';
@Module({ @Module({
imports: [ 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({ BullModule.registerQueue({
name: QueueNameEnum.VIDEO_CONVERSION, name: QueueNameEnum.VIDEO_CONVERSION,
defaultJobOptions: { defaultJobOptions: {

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1391,6 +1391,19 @@ export interface UpdateAlbumDto {
*/ */
'albumThumbnailAssetId'?: string; 'albumThumbnailAssetId'?: string;
} }
/**
*
* @export
* @interface UpdateAssetDto
*/
export interface UpdateAssetDto {
/**
*
* @type {boolean}
* @memberof UpdateAssetDto
*/
'isFavorite': boolean;
}
/** /**
* *
* @export * @export
@@ -1575,6 +1588,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto * @memberof UserResponseDto
*/ */
'isAdmin': boolean; 'isAdmin': boolean;
/**
*
* @type {string}
* @memberof UserResponseDto
*/
'deletedAt': string | null;
} }
/** /**
* *
@@ -3052,6 +3071,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, 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 * @param {any} assetData
@@ -3273,6 +3336,18 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.serveFile(aid, did, isThumb, isWeb, options); const localVarAxiosArgs = await localVarAxiosParamCreator.serveFile(aid, did, isThumb, isWeb, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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 * @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> { 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)); 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 * @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)); 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 * @param {any} assetData
@@ -4711,6 +4810,43 @@ export const UserApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions, 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 * @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); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -4948,6 +5121,16 @@ export const UserApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.createUser(createUserDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.createUser(createUserDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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 * @param {boolean} isAll
@@ -4996,6 +5179,16 @@ export const UserApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getUserCount(options); const localVarAxiosArgs = await localVarAxiosParamCreator.getUserCount(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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 * @param {UpdateUserDto} updateUserDto
@@ -5034,6 +5227,15 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
createUser(createUserDto: CreateUserDto, options?: any): AxiosPromise<UserResponseDto> { createUser(createUserDto: CreateUserDto, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.createUser(createUserDto, options).then((request) => request(axios, basePath)); 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 * @param {boolean} isAll
@@ -5077,6 +5279,15 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
getUserCount(options?: any): AxiosPromise<UserCountResponseDto> { getUserCount(options?: any): AxiosPromise<UserCountResponseDto> {
return localVarFp.getUserCount(options).then((request) => request(axios, basePath)); 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 * @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)); 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 * @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)); 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 * @param {UpdateUserDto} updateUserDto

View File

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

View File

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

View File

@@ -3,9 +3,21 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import PencilOutline from 'svelte-material-icons/PencilOutline.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>; export let allUsers: Array<UserResponseDto>;
const dispatch = createEventDispatcher(); 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> </script>
<table class="text-left w-full my-5"> <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">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">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">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> </tr>
</thead> </thead>
<tbody <tbody
@@ -25,21 +37,44 @@
{#each allUsers as user, i} {#each allUsers as user, i}
<tr <tr
class={`text-center flex place-items-center w-full h-[80px] dark:text-immich-dark-bg ${ 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.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.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">{user.lastName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis" <td class="text-sm px-4 w-1/4 text-ellipsis">
><button {#if !isDeleted(user)}
on:click={() => { <button
dispatch('edit-user', { user }); 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 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"
></td ><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> </tr>
{/each} {/each}
</tbody> </tbody>

View File

@@ -9,6 +9,14 @@
import CircleIconButton from '../shared-components/circle-icon-button.svelte'; import CircleIconButton from '../shared-components/circle-icon-button.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte'; import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.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(); const dispatch = createEventDispatcher();
@@ -38,8 +46,15 @@
</div> </div>
<div class="text-white flex gap-2"> <div class="text-white flex gap-2">
<CircleIconButton logo={CloudDownloadOutline} on:click={() => dispatch('download')} /> <CircleIconButton logo={CloudDownloadOutline} on:click={() => dispatch('download')} />
<CircleIconButton logo={DeleteOutline} on:click={() => dispatch('delete')} />
<CircleIconButton logo={InformationOutline} on:click={() => dispatch('showDetail')} /> <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)} /> <CircleIconButton logo={DotsVertical} on:click={(event) => showOptionsMenu(event)} />
</div> </div>
</div> </div>

View File

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

View File

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