Compare commits

..

5 Commits

Author SHA1 Message Date
Alex d261fa0fc9 button style 2025-10-02 12:08:06 -05:00
Alex 352298e4ca Merge branch 'main' into fix/bring-back-delete-backed-up-only 2025-10-02 11:30:47 -05:00
Kenny at FUTO 7dc9cb121f fix(docs): one-click doc fixes (#22554)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-10-02 11:26:59 -05:00
Alex ca8a6e5f95 fix: show activity in shared albunm (#22589) 2025-10-02 11:25:40 -05:00
shenlong-tanwen 9db0baa42b fix: show dialog on delete local action 2025-09-22 05:03:08 +05:30
13 changed files with 106 additions and 85 deletions
+3 -3
View File
@@ -5,7 +5,7 @@ sidebar_position: 65
# One-Click [Cloud Service] # One-Click [Cloud Service]
:::note :::note
This version of Immich is provided via cloud service provider's one-click marketplaces. Hosting costs are set by the cloud service providers. This version of Immich is provided via cloud service providers' one-click marketplaces. Hosting costs are set by the cloud service providers.
Support for these are provided by the individual cloud service providers. Support for these are provided by the individual cloud service providers.
**Please report issues to the corresponding [Github Repository][github].** **Please report issues to the corresponding [Github Repository][github].**
@@ -13,7 +13,7 @@ Support for these are provided by the individual cloud service providers.
## Installation ## Installation
Simply goto the providers marketplace and choose Immich, then follow the provided instructions. Go to the provider's marketplace and choose Immich, then follow the provided instructions.
## One-Click Immich marketplace providers ## One-Click Immich marketplace providers
@@ -29,4 +29,4 @@ https://www.vultr.com/marketplace/apps/immich
For issues, open an issue on the associated [GitHub Repository][github]. For issues, open an issue on the associated [GitHub Repository][github].
[github]: https://github.com/imagegenius/docker-immich/ [github]: https://github.com/immich-app/immich/
@@ -56,6 +56,8 @@ sealed class BaseAsset {
// Overridden in subclasses // Overridden in subclasses
AssetState get storage; AssetState get storage;
String? get localId;
String? get remoteId;
String get heroTag; String get heroTag;
@override @override
@@ -2,12 +2,12 @@ part of 'base_asset.model.dart';
class LocalAsset extends BaseAsset { class LocalAsset extends BaseAsset {
final String id; final String id;
final String? remoteId; final String? remoteAssetId;
final int orientation; final int orientation;
const LocalAsset({ const LocalAsset({
required this.id, required this.id,
this.remoteId, String? remoteId,
required super.name, required super.name,
super.checksum, super.checksum,
required super.type, required super.type,
@@ -19,7 +19,13 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false, super.isFavorite = false,
super.livePhotoVideoId, super.livePhotoVideoId,
this.orientation = 0, this.orientation = 0,
}); }) : remoteAssetId = remoteId;
@override
String? get localId => id;
@override
String? get remoteId => remoteAssetId;
@override @override
AssetState get storage => remoteId == null ? AssetState.local : AssetState.merged; AssetState get storage => remoteId == null ? AssetState.local : AssetState.merged;
@@ -5,7 +5,7 @@ enum AssetVisibility { timeline, hidden, archive, locked }
// Model for an asset stored in the server // Model for an asset stored in the server
class RemoteAsset extends BaseAsset { class RemoteAsset extends BaseAsset {
final String id; final String id;
final String? localId; final String? localAssetId;
final String? thumbHash; final String? thumbHash;
final AssetVisibility visibility; final AssetVisibility visibility;
final String ownerId; final String ownerId;
@@ -13,7 +13,7 @@ class RemoteAsset extends BaseAsset {
const RemoteAsset({ const RemoteAsset({
required this.id, required this.id,
this.localId, String? localId,
required super.name, required super.name,
required this.ownerId, required this.ownerId,
required super.checksum, required super.checksum,
@@ -28,7 +28,13 @@ class RemoteAsset extends BaseAsset {
this.visibility = AssetVisibility.timeline, this.visibility = AssetVisibility.timeline,
super.livePhotoVideoId, super.livePhotoVideoId,
this.stackId, this.stackId,
}); }) : localAssetId = localId;
@override
String? get localId => localAssetId;
@override
String? get remoteId => id;
@override @override
AssetState get storage => localId == null ? AssetState.remote : AssetState.merged; AssetState get storage => localId == null ? AssetState.remote : AssetState.merged;
@@ -62,7 +62,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.toDto( .toDto(
assetCount: row.read(assetCount) ?? 0, assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!, ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2, isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
), ),
) )
.get(); .get();
@@ -107,7 +107,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.toDto( .toDto(
assetCount: row.read(assetCount) ?? 0, assetCount: row.read(assetCount) ?? 0,
ownerName: row.read(_db.userEntity.name)!, ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2, isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
), ),
) )
.getSingleOrNull(); .getSingleOrNull();
@@ -305,8 +305,9 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
.readTable(_db.remoteAlbumEntity) .readTable(_db.remoteAlbumEntity)
.toDto( .toDto(
ownerName: row.read(_db.userEntity.name)!, ownerName: row.read(_db.userEntity.name)!,
isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 2, isShared: row.read(_db.remoteAlbumUserEntity.userId.count(distinct: true))! > 0,
); );
return album; return album;
}).watchSingleOrNull(); }).watchSingleOrNull();
} }
@@ -8,6 +8,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
/// This delete action has the following behavior: /// This delete action has the following behavior:
@@ -22,7 +23,17 @@ class DeleteLocalActionButton extends ConsumerWidget {
return; return;
} }
final result = await ref.read(actionProvider.notifier).deleteLocal(source); bool? backedUpOnly = await showDialog<bool>(
context: context,
builder: (BuildContext context) => DeleteLocalOnlyDialog(onDeleteLocal: (_) {}),
);
if (backedUpOnly == null) {
// User cancelled the dialog
return;
}
final result = await ref.read(actionProvider.notifier).deleteLocal(source, backedUpOnly);
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) { if (source == ActionSource.viewer) {
@@ -257,8 +257,15 @@ class ActionNotifier extends Notifier<void> {
} }
} }
Future<ActionResult> deleteLocal(ActionSource source) async { Future<ActionResult> deleteLocal(ActionSource source, bool backedUpOnly) async {
final ids = _getLocalIdsForSource(source); final List<String> ids;
if (backedUpOnly) {
final assets = _getAssets(source);
ids = assets.where((asset) => asset.storage == AssetState.merged).map((asset) => asset.localId!).toList();
} else {
ids = _getLocalIdsForSource(source);
}
try { try {
final deletedCount = await _service.deleteLocal(ids); final deletedCount = await _service.deleteLocal(ids);
return ActionResult(count: deletedCount, success: true); return ActionResult(count: deletedCount, success: true);
@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -21,11 +22,26 @@ final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.
class AssetMediaRepository { class AssetMediaRepository {
final AssetApiRepository _assetApiRepository; final AssetApiRepository _assetApiRepository;
static final Logger _log = Logger("AssetMediaRepository"); static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository); const AssetMediaRepository(this._assetApiRepository);
Future<List<String>> deleteAll(List<String> ids) => PhotoManager.editor.deleteWithIds(ids); Future<List<String>> deleteAll(List<String> ids) async {
if (CurrentPlatform.isIOS) {
return PhotoManager.editor.deleteWithIds(ids);
} else if (CurrentPlatform.isAndroid) {
final androidInfo = await DeviceInfoPlugin().androidInfo;
if (androidInfo.version.sdkInt < 30) {
return PhotoManager.editor.deleteWithIds(ids);
}
return PhotoManager.editor.android.moveToTrash(
// Only the id is needed
ids.map((id) => AssetEntity(id: id, width: 1, height: 1, typeInt: 0)).toList(),
);
}
return [];
}
Future<asset_entity.Asset?> get(String id) async { Future<asset_entity.Asset?> get(String id) async {
final entity = await AssetEntity.fromId(id); final entity = await AssetEntity.fromId(id);
@@ -22,12 +22,12 @@ class DeleteLocalOnlyDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
void onDeleteBackedUpOnly() { void onDeleteBackedUpOnly() {
context.pop(); context.pop(true);
onDeleteLocal(true); onDeleteLocal(true);
} }
void onForceDelete() { void onForceDelete() {
context.pop(); context.pop(false);
onDeleteLocal(false); onDeleteLocal(false);
} }
@@ -36,26 +36,44 @@ class DeleteLocalOnlyDialog extends StatelessWidget {
title: const Text("delete_dialog_title").tr(), title: const Text("delete_dialog_title").tr(),
content: const Text("delete_dialog_alert_local_non_backed_up").tr(), content: const Text("delete_dialog_alert_local_non_backed_up").tr(),
actions: [ actions: [
TextButton( SizedBox(
onPressed: () => context.pop(), width: double.infinity,
child: Text( height: 48,
"cancel", child: FilledButton(
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold), onPressed: () => context.pop(),
).tr(), style: FilledButton.styleFrom(
backgroundColor: context.colorScheme.surfaceDim,
foregroundColor: context.primaryColor,
),
child: const Text("cancel", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
),
), ),
TextButton( const SizedBox(height: 8),
onPressed: onDeleteBackedUpOnly, SizedBox(
child: Text( width: double.infinity,
"delete_local_dialog_ok_backed_up_only", height: 48,
style: TextStyle(color: context.colorScheme.tertiary, fontWeight: FontWeight.bold),
).tr(), child: FilledButton(
onPressed: onDeleteBackedUpOnly,
style: FilledButton.styleFrom(
backgroundColor: context.colorScheme.errorContainer,
foregroundColor: context.colorScheme.onErrorContainer,
),
child: const Text(
"delete_local_dialog_ok_backed_up_only",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
), ),
TextButton( const SizedBox(height: 8),
onPressed: onForceDelete, SizedBox(
child: Text( width: double.infinity,
"delete_local_dialog_ok_force", height: 48,
style: TextStyle(color: Colors.red[400], fontWeight: FontWeight.bold), child: FilledButton(
).tr(), onPressed: onForceDelete,
style: FilledButton.styleFrom(backgroundColor: Colors.red[400], foregroundColor: Colors.white),
child: const Text("delete_local_dialog_ok_force", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
),
), ),
], ],
); );
+1 -1
View File
@@ -25,7 +25,7 @@ export interface Events {
on_person_thumbnail: (personId: string) => void; on_person_thumbnail: (personId: string) => void;
on_server_version: (serverVersion: ServerVersionResponseDto) => void; on_server_version: (serverVersion: ServerVersionResponseDto) => void;
on_config_update: () => void; on_config_update: () => void;
on_new_release: (newRelease: ReleaseEvent) => void; on_new_release: (newRelase: ReleaseEvent) => void;
on_session_delete: (sessionId: string) => void; on_session_delete: (sessionId: string) => void;
on_notification: (notification: NotificationDto) => void; on_notification: (notification: NotificationDto) => void;
} }
-25
View File
@@ -1,25 +0,0 @@
import { getReleaseType } from '$lib/utils';
describe('utils', () => {
describe(getReleaseType.name, () => {
it('should return "major" for major version changes', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 2, minor: 0, patch: 0 })).toBe('major');
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 3, minor: 2, patch: 1 })).toBe('major');
});
it('should return "minor" for minor version changes', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 1, patch: 0 })).toBe('minor');
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 2, patch: 1 })).toBe('minor');
});
it('should return "patch" for patch version changes', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 1 })).toBe('patch');
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 5 })).toBe('patch');
});
it('should return "none" for matching versions', () => {
expect(getReleaseType({ major: 1, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 0 })).toBe('none');
expect(getReleaseType({ major: 1, minor: 2, patch: 3 }, { major: 1, minor: 2, patch: 3 })).toBe('none');
});
});
});
-20
View File
@@ -21,7 +21,6 @@ import {
unlinkOAuthAccount, unlinkOAuthAccount,
type MemoryResponseDto, type MemoryResponseDto,
type PersonResponseDto, type PersonResponseDto,
type ServerVersionResponseDto,
type SharedLinkResponseDto, type SharedLinkResponseDto,
type UserResponseDto, type UserResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
@@ -386,22 +385,3 @@ export function createDateFormatter(localeCode: string | undefined): DateFormatt
}, },
}; };
} }
export const getReleaseType = (
current: ServerVersionResponseDto,
newVersion: ServerVersionResponseDto,
): 'major' | 'minor' | 'patch' | 'none' => {
if (current.major !== newVersion.major) {
return 'major';
}
if (current.minor !== newVersion.minor) {
return 'minor';
}
if (current.patch !== newVersion.patch) {
return 'patch';
}
return 'none';
};
+2 -3
View File
@@ -18,7 +18,7 @@
websocketStore, websocketStore,
type ReleaseEvent, type ReleaseEvent,
} from '$lib/stores/websocket'; } from '$lib/stores/websocket';
import { copyToClipboard, getReleaseType } from '$lib/utils'; import { copyToClipboard } from '$lib/utils';
import { isAssetViewerRoute } from '$lib/utils/navigation'; import { isAssetViewerRoute } from '$lib/utils/navigation';
import type { ServerVersionResponseDto } from '@immich/sdk'; import type { ServerVersionResponseDto } from '@immich/sdk';
import { modalManager, setTranslations } from '@immich/ui'; import { modalManager, setTranslations } from '@immich/ui';
@@ -85,9 +85,8 @@
const releaseVersion = semverToName(release.releaseVersion); const releaseVersion = semverToName(release.releaseVersion);
const serverVersion = semverToName(release.serverVersion); const serverVersion = semverToName(release.serverVersion);
const type = getReleaseType(release.serverVersion, release.releaseVersion);
if (type === 'none' || type === 'patch' || localStorage.getItem('appVersion') === releaseVersion) { if (localStorage.getItem('appVersion') === releaseVersion) {
return; return;
} }