Compare commits
9 Commits
feat/group
...
feat/mobil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc9ec29c83 | ||
|
|
c8c6f86518 | ||
|
|
f972b8d514 | ||
|
|
6b50d958f4 | ||
|
|
27c456eb75 | ||
|
|
e7d051db3c | ||
|
|
86d31d7d29 | ||
|
|
f416342eff | ||
|
|
d73335ecbc |
@@ -64,7 +64,7 @@ Once you have a new OAuth client application configured, Immich can be configure
|
||||
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
|
||||
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
|
||||
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |
|
||||
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
|
||||
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (empty for unlimited quota) |
|
||||
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
|
||||
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
|
||||
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
|
||||
@@ -106,6 +106,89 @@ Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to
|
||||
|
||||
## Example Configuration
|
||||
|
||||
<details>
|
||||
<summary>Authelia Example</summary>
|
||||
|
||||
### Authelia Example
|
||||
|
||||
Here's an example of OAuth configured for Authelia:
|
||||
|
||||
This assumes there exist an attribute `immichquota` in the user schema, which is used to set the user's storage quota in Immich.
|
||||
The configuration concerning the quota is optional.
|
||||
|
||||
```yaml
|
||||
authentication_backend:
|
||||
ldap:
|
||||
# The LDAP server configuration goes here.
|
||||
# See: https://www.authelia.com/c/ldap
|
||||
attributes:
|
||||
extra:
|
||||
immichquota: # The attribute name from LDAP
|
||||
name: 'immich_quota'
|
||||
multi_valued: false
|
||||
value_type: 'integer'
|
||||
identity_providers:
|
||||
oidc:
|
||||
## The other portions of the mandatory OpenID Connect 1.0 configuration go here.
|
||||
## See: https://www.authelia.com/c/oidc
|
||||
claims_policies:
|
||||
immich_policy:
|
||||
custom_claims:
|
||||
immich_quota:
|
||||
attribute: 'immich_quota'
|
||||
scopes:
|
||||
immich_scope:
|
||||
claims:
|
||||
- 'immich_quota'
|
||||
|
||||
clients:
|
||||
- client_id: 'immich'
|
||||
client_name: 'Immich'
|
||||
# https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#how-do-i-generate-a-client-identifier-or-client-secret
|
||||
client_secret: $pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng'
|
||||
public: false
|
||||
require_pkce: false
|
||||
redirect_uris:
|
||||
- 'https://example.immich.app/auth/login'
|
||||
- 'https://example.immich.app/user-settings'
|
||||
- 'app.immich:///oauth-callback'
|
||||
scopes:
|
||||
- 'openid'
|
||||
- 'profile'
|
||||
- 'email'
|
||||
- 'immich_scope'
|
||||
claims_policy: 'immich_policy'
|
||||
response_types:
|
||||
- 'code'
|
||||
grant_types:
|
||||
- 'authorization_code'
|
||||
id_token_signed_response_alg: 'RS256'
|
||||
userinfo_signed_response_alg: 'RS256'
|
||||
token_endpoint_auth_method: 'client_secret_post'
|
||||
```
|
||||
|
||||
Configuration of OAuth in Immich System Settings
|
||||
|
||||
| Setting | Value |
|
||||
| ---------------------------------- | ------------------------------------------------------------------- |
|
||||
| Issuer URL | `https://example.immich.app/.well-known/openid-configuration` |
|
||||
| Client ID | immich |
|
||||
| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... |
|
||||
| Token Endpoint Auth Method | client_secret_post |
|
||||
| Scope | openid email profile immich_scope |
|
||||
| ID Token Signed Response Algorithm | RS256 |
|
||||
| Userinfo Signed Response Algorithm | RS256 |
|
||||
| Storage Label Claim | uid |
|
||||
| Storage Quota Claim | immich_quota |
|
||||
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||
| Button Text | Sign in with Authelia (optional) |
|
||||
| Auto Register | Enabled (optional) |
|
||||
| Auto Launch | Enabled (optional) |
|
||||
| Mobile Redirect URI Override | Disable |
|
||||
| Mobile Redirect URI | |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Authentik Example</summary>
|
||||
|
||||
@@ -128,7 +211,7 @@ Configuration of OAuth in Immich System Settings
|
||||
| Signing Algorithm | RS256 |
|
||||
| Storage Label Claim | preferred_username |
|
||||
| Storage Quota Claim | immich_quota |
|
||||
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
|
||||
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||
| Button Text | Sign in with Authentik (optional) |
|
||||
| Auto Register | Enabled (optional) |
|
||||
| Auto Launch | Enabled (optional) |
|
||||
@@ -159,7 +242,7 @@ Configuration of OAuth in Immich System Settings
|
||||
| Signing Algorithm | RS256 |
|
||||
| Storage Label Claim | preferred_username |
|
||||
| Storage Quota Claim | immich_quota |
|
||||
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
|
||||
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
|
||||
| Button Text | Sign in with Google (optional) |
|
||||
| Auto Register | Enabled (optional) |
|
||||
| Auto Launch | Enabled |
|
||||
|
||||
@@ -653,6 +653,7 @@
|
||||
"clear": "Clear",
|
||||
"clear_all": "Clear all",
|
||||
"clear_all_recent_searches": "Clear all recent searches",
|
||||
"clear_file_cache": "Clear File Cache",
|
||||
"clear_message": "Clear message",
|
||||
"clear_value": "Clear value",
|
||||
"client_cert_dialog_msg_confirm": "OK",
|
||||
@@ -834,6 +835,7 @@
|
||||
"edit_birthday": "Edit Birthday",
|
||||
"edit_date": "Edit date",
|
||||
"edit_date_and_time": "Edit date and time",
|
||||
"edit_date_and_time_action_prompt": "{count} date and time edited",
|
||||
"edit_description": "Edit description",
|
||||
"edit_description_prompt": "Please select a new description:",
|
||||
"edit_exclusion_pattern": "Edit exclusion pattern",
|
||||
@@ -1786,9 +1788,15 @@
|
||||
"shared_link_expires_seconds": "Expires in {count} seconds",
|
||||
"shared_link_individual_shared": "Individual shared",
|
||||
"shared_link_info_chip_metadata": "EXIF",
|
||||
"shared_link_invalid_password": "Invalid password",
|
||||
"shared_link_manage_links": "Manage Shared links",
|
||||
"shared_link_options": "Shared link options",
|
||||
"shared_link_password_description": "Require a password to access this shared link",
|
||||
"shared_link_password_dialog_content": "Enter the password for the shared link.",
|
||||
"shared_link_password_dialog_title": "Shared Link Password",
|
||||
"shared_link": "Shared link",
|
||||
"shared_link_upload": "Uploaded to shared link",
|
||||
"shared_link_download": "Downloaded from shared link",
|
||||
"shared_links": "Shared links",
|
||||
"shared_links_description": "Share photos and videos with a link",
|
||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
|
||||
|
||||
21
mobile/lib/domain/models/album/shared_album.model.dart
Normal file
21
mobile/lib/domain/models/album/shared_album.model.dart
Normal file
@@ -0,0 +1,21 @@
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
|
||||
class SharedRemoteAlbum extends RemoteAlbum {
|
||||
final List<RemoteAsset> assets;
|
||||
|
||||
const SharedRemoteAlbum({
|
||||
required super.id,
|
||||
required this.assets,
|
||||
required super.name,
|
||||
required super.ownerId,
|
||||
required super.description,
|
||||
required super.createdAt,
|
||||
required super.updatedAt,
|
||||
super.thumbnailAssetId,
|
||||
required super.isActivityEnabled,
|
||||
required super.order,
|
||||
required super.assetCount,
|
||||
required super.ownerName,
|
||||
});
|
||||
}
|
||||
@@ -104,6 +104,7 @@ class HashService {
|
||||
DLog.log("Hashed ${hashed.length}/${toHash.length} assets");
|
||||
|
||||
await _localAssetRepository.updateHashes(hashed);
|
||||
await _storageRepository.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
34
mobile/lib/domain/services/remote_shared_album.service.dart
Normal file
34
mobile/lib/domain/services/remote_shared_album.service.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/domain/models/album/shared_album.model.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
|
||||
class RemoteSharedAlbumService {
|
||||
final DriftAlbumApiRepository _albumApiRepository;
|
||||
final AssetApiRepository _assetApiRepository;
|
||||
|
||||
const RemoteSharedAlbumService(this._albumApiRepository, this._assetApiRepository);
|
||||
|
||||
Future<SharedRemoteAlbum?> getSharedAlbum(String albumId) {
|
||||
return _albumApiRepository.getShared(albumId);
|
||||
}
|
||||
|
||||
Future<int> uploadAssets(String albumId, List<XFile> files) async {
|
||||
// Start all uploads concurrently
|
||||
final uploadFutures = files.map((file) => _assetApiRepository.uploadAsset(file)).toList();
|
||||
|
||||
// Wait for all uploads to complete
|
||||
final assetIds = await Future.wait(uploadFutures);
|
||||
|
||||
// Filter out null assetIds
|
||||
final completedUploads = assetIds.whereType<String>().toList();
|
||||
|
||||
if (completedUploads.isNotEmpty) {
|
||||
await _albumApiRepository.addAssets(albumId, completedUploads);
|
||||
}
|
||||
|
||||
return completedUploads.length;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/search_result.model.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart' as api show AssetVisibility;
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
|
||||
class SearchService {
|
||||
@@ -52,41 +51,3 @@ class SearchService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetResponseDto {
|
||||
RemoteAsset toDto() {
|
||||
return RemoteAsset(
|
||||
id: id,
|
||||
name: originalFileName,
|
||||
checksum: checksum,
|
||||
createdAt: fileCreatedAt,
|
||||
updatedAt: updatedAt,
|
||||
ownerId: ownerId,
|
||||
visibility: switch (visibility) {
|
||||
api.AssetVisibility.timeline => AssetVisibility.timeline,
|
||||
api.AssetVisibility.hidden => AssetVisibility.hidden,
|
||||
api.AssetVisibility.archive => AssetVisibility.archive,
|
||||
api.AssetVisibility.locked => AssetVisibility.locked,
|
||||
_ => AssetVisibility.timeline,
|
||||
},
|
||||
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
||||
height: exifInfo?.exifImageHeight?.toInt(),
|
||||
width: exifInfo?.exifImageWidth?.toInt(),
|
||||
isFavorite: isFavorite,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
thumbHash: thumbhash,
|
||||
localId: null,
|
||||
type: type.toAssetType(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetTypeEnum {
|
||||
AssetType toAssetType() => switch (this) {
|
||||
AssetTypeEnum.IMAGE => AssetType.image,
|
||||
AssetTypeEnum.VIDEO => AssetType.video,
|
||||
AssetTypeEnum.AUDIO => AssetType.audio,
|
||||
AssetTypeEnum.OTHER => AssetType.other,
|
||||
_ => throw Exception('Unknown AssetType value: $this'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,6 +186,23 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateDateTime(List<String> ids, DateTime dateTime) {
|
||||
return _db.batch((batch) async {
|
||||
for (final id in ids) {
|
||||
batch.update(
|
||||
_db.remoteExifEntity,
|
||||
RemoteExifEntityCompanion(dateTimeOriginal: Value(dateTime)),
|
||||
where: (e) => e.assetId.equals(id),
|
||||
);
|
||||
batch.update(
|
||||
_db.remoteAssetEntity,
|
||||
RemoteAssetEntityCompanion(createdAt: Value(dateTime)),
|
||||
where: (e) => e.id.equals(id),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stack(String userId, StackResponse stack) {
|
||||
return _db.transaction(() async {
|
||||
final stackIds = await _db.managers.stackEntity
|
||||
|
||||
@@ -66,4 +66,14 @@ class StorageRepository {
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
try {
|
||||
await PhotoManager.clearFileCache();
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error clearing cache", error, stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
enum SharedLinkSource { album, individual }
|
||||
@@ -14,6 +16,8 @@ class SharedLink {
|
||||
final String key;
|
||||
final bool showMetadata;
|
||||
final SharedLinkSource type;
|
||||
final List<RemoteAsset> assets;
|
||||
final String? albumId;
|
||||
|
||||
const SharedLink({
|
||||
required this.id,
|
||||
@@ -27,6 +31,8 @@ class SharedLink {
|
||||
required this.key,
|
||||
required this.showMetadata,
|
||||
required this.type,
|
||||
this.assets = const [],
|
||||
this.albumId,
|
||||
});
|
||||
|
||||
SharedLink copyWith({
|
||||
@@ -74,7 +80,9 @@ class SharedLink {
|
||||
? dto.album?.albumThumbnailAssetId
|
||||
: dto.assets.isNotEmpty
|
||||
? dto.assets[0].id
|
||||
: null;
|
||||
: null,
|
||||
assets = dto.assets.map((asset) => asset.toDto()).toList(),
|
||||
albumId = dto.album?.id;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
|
||||
218
mobile/lib/presentation/pages/remote_shared_link.dart
Normal file
218
mobile/lib/presentation/pages/remote_shared_link.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/domain/models/album/shared_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_shared_album.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/shared_link.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/widgets/common/shared_link_password_dialog.dart';
|
||||
// ignore: import_rule_openapi
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@RoutePage()
|
||||
class RemoteSharedLinkPage extends ConsumerStatefulWidget {
|
||||
final String shareKey;
|
||||
final String endpoint;
|
||||
|
||||
const RemoteSharedLinkPage({super.key, required this.shareKey, required this.endpoint});
|
||||
|
||||
@override
|
||||
ConsumerState<RemoteSharedLinkPage> createState() => _RemoteSharedLinkPageState();
|
||||
}
|
||||
|
||||
class _RemoteSharedLinkPageState extends ConsumerState<RemoteSharedLinkPage> {
|
||||
late final ApiService _apiService;
|
||||
|
||||
SharedLink? sharedLink;
|
||||
List<RemoteAsset>? assets;
|
||||
SharedRemoteAlbum? sharedAlbum;
|
||||
|
||||
late final RemoteSharedAlbumService sharedAlbumService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
String endpoint = widget.endpoint;
|
||||
if (!endpoint.endsWith('/api')) {
|
||||
endpoint += '/api';
|
||||
}
|
||||
ImageUrlBuilder.setHost(endpoint);
|
||||
ImageUrlBuilder.setParameter('key', widget.shareKey);
|
||||
_apiService = ApiService.shared(endpoint, widget.shareKey);
|
||||
|
||||
final assetApiRepository = AssetApiRepository(
|
||||
_apiService.assetsApi,
|
||||
_apiService.searchApi,
|
||||
_apiService.stacksApi,
|
||||
_apiService.trashApi,
|
||||
);
|
||||
final driftApiRepository = DriftAlbumApiRepository(_apiService.albumsApi);
|
||||
sharedAlbumService = RemoteSharedAlbumService(driftApiRepository, assetApiRepository);
|
||||
|
||||
retrieveSharedLink();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ImageUrlBuilder.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> retrieveSharedLink() async {
|
||||
try {
|
||||
sharedLink = await SharedLinkService(_apiService).getMySharedLink();
|
||||
} on ApiException catch (error, _) {
|
||||
if (error.code == 401 && error.message != null && error.message!.contains("Invalid password")) {
|
||||
final password = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const SharedLinkPasswordDialog(),
|
||||
);
|
||||
|
||||
if (password == null) {
|
||||
context.pop();
|
||||
}
|
||||
|
||||
try {
|
||||
sharedLink = await SharedLinkService(_apiService).getMySharedLink(password: password);
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "errors.shared_link_invalid_password".t(context: context),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
|
||||
context.pop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sharedLink == null) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "errors.unable_to_get_shared_link".t(context: context),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
context.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
_refreshAssets();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<List<RemoteAsset>> retrieveSharedAlbumAssets() async {
|
||||
try {
|
||||
sharedAlbum = await sharedAlbumService.getSharedAlbum(sharedLink!.albumId!);
|
||||
|
||||
return sharedAlbum?.assets ?? [];
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "errors.failed_to_load_assets".t(context: context),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshAssets() async {
|
||||
// Retrieve assets from the shared link
|
||||
switch (sharedLink!.type) {
|
||||
case SharedLinkSource.album:
|
||||
assets = await retrieveSharedAlbumAssets();
|
||||
break;
|
||||
case SharedLinkSource.individual:
|
||||
assets = sharedLink!.assets;
|
||||
|
||||
if (!(sharedLink!.allowUpload)) {
|
||||
context.replaceRoute(
|
||||
AssetViewerRoute(
|
||||
initialIndex: 0,
|
||||
timelineService: ref.read(timelineFactoryProvider).fromAssets(sharedLink!.assets),
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Future<void> addAssets() async {
|
||||
final List<XFile> uploadAssets = await ImagePicker().pickMultipleMedia();
|
||||
|
||||
if (uploadAssets.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text("uploading".t(context: context)),
|
||||
content: const SizedBox(height: 48, child: Center(child: CircularProgressIndicator())),
|
||||
),
|
||||
);
|
||||
|
||||
await sharedAlbumService.uploadAssets(sharedAlbum!.id, uploadAssets);
|
||||
sharedAlbum = await sharedAlbumService.getSharedAlbum(sharedAlbum!.id);
|
||||
|
||||
_refreshAssets();
|
||||
|
||||
// close the dialog
|
||||
context.pop();
|
||||
}
|
||||
|
||||
if (assets == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return ProviderScope(
|
||||
key: ValueKey(assets?.length),
|
||||
overrides: [
|
||||
apiServiceProvider.overrideWith((ref) => _apiService),
|
||||
timelineServiceProvider.overrideWith((ref) {
|
||||
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets!);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
}),
|
||||
],
|
||||
child: Timeline(
|
||||
topSliverWidgetHeight: 0,
|
||||
topSliverWidget: const SliverToBoxAdapter(child: SizedBox.shrink()),
|
||||
groupBy: GroupAssetsBy.none,
|
||||
bottomSheet: null,
|
||||
appBar: SliverToBoxAdapter(
|
||||
child: AppBar(
|
||||
title: Text(sharedAlbum?.name ?? "shared_link".t(context: context)),
|
||||
actions: [
|
||||
if (sharedLink!.allowUpload)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_upload),
|
||||
onPressed: () => addAssets(),
|
||||
tooltip: "shared_link_upload".t(context: context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class EditDateTimeActionButton extends ConsumerWidget {
|
||||
const EditDateTimeActionButton({super.key});
|
||||
final ActionSource source;
|
||||
|
||||
const EditDateTimeActionButton({super.key, required this.source});
|
||||
|
||||
_onTap(BuildContext context, WidgetRef ref) async {
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await ref.read(actionProvider.notifier).editDateTime(source, context);
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
|
||||
final successMessage = 'edit_date_and_time_action_prompt'.t(
|
||||
context: context,
|
||||
args: {'count': result.count.toString()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: result.success ? ToastType.success : ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -12,6 +46,7 @@ class EditDateTimeActionButton extends ConsumerWidget {
|
||||
maxWidth: 95.0,
|
||||
iconData: Icons.edit_calendar_outlined,
|
||||
label: "control_bottom_app_bar_edit_time".t(context: context),
|
||||
onPressed: () => _onTap(context, ref),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,12 +143,18 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||
|
||||
Future<void> editDateTime() async {
|
||||
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
|
||||
}
|
||||
|
||||
return SliverList.list(
|
||||
children: [
|
||||
// Asset Date and Time
|
||||
_SheetTile(
|
||||
title: _getDateTime(context, asset),
|
||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null,
|
||||
onTap: asset.hasRemote ? () async => await editDateTime() : null,
|
||||
),
|
||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
|
||||
const SheetPeopleDetails(),
|
||||
@@ -194,11 +200,21 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
||||
class _SheetTile extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final String? subtitle;
|
||||
final TextStyle? titleStyle;
|
||||
final TextStyle? subtitleStyle;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SheetTile({required this.title, this.titleStyle, this.leading, this.subtitle, this.subtitleStyle});
|
||||
const _SheetTile({
|
||||
required this.title,
|
||||
this.titleStyle,
|
||||
this.leading,
|
||||
this.subtitle,
|
||||
this.subtitleStyle,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -234,8 +250,10 @@ class _SheetTile extends StatelessWidget {
|
||||
title: titleWidget,
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||
subtitle: subtitleWidget,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
||||
context.back();
|
||||
return;
|
||||
}
|
||||
context.back();
|
||||
context.pop();
|
||||
context.pushRoute(DriftPersonRoute(person: person));
|
||||
},
|
||||
onNameTap: () => showNameEditModal(person),
|
||||
|
||||
@@ -40,7 +40,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
const EditDateTimeActionButton(),
|
||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
const StackActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -40,7 +40,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
const EditDateTimeActionButton(),
|
||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
const StackActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -76,7 +76,7 @@ class GeneralBottomSheet extends ConsumerWidget {
|
||||
if (multiselect.hasLocal || multiselect.hasMerged) ...[
|
||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
const EditDateTimeActionButton(),
|
||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
const StackActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -43,7 +43,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
|
||||
isTrashEnable
|
||||
? const TrashActionButton(source: ActionSource.timeline)
|
||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||
const EditDateTimeActionButton(),
|
||||
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||
const EditLocationActionButton(source: ActionSource.timeline),
|
||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||
const StackActionButton(source: ActionSource.timeline),
|
||||
|
||||
@@ -4,5 +4,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'api.provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
@Riverpod(keepAlive: true, dependencies: [])
|
||||
ApiService apiService(Ref _) => ApiService();
|
||||
|
||||
6
mobile/lib/providers/api.provider.g.dart
generated
6
mobile/lib/providers/api.provider.g.dart
generated
@@ -6,7 +6,7 @@ part of 'api.provider.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$apiServiceHash() => r'187a7de59b064fab1104c23717f18ce0ae3e426c';
|
||||
String _$apiServiceHash() => r'46d8a043f41b85f36f56d81e5261c842cb5c0c06';
|
||||
|
||||
/// See also [apiService].
|
||||
@ProviderFor(apiService)
|
||||
@@ -16,8 +16,8 @@ final apiServiceProvider = Provider<ApiService>.internal(
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$apiServiceHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
dependencies: const <ProviderOrFamily>[],
|
||||
allTransitiveDependencies: const <ProviderOrFamily>{},
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
|
||||
@@ -28,7 +28,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
ref.watch(secureStorageServiceProvider),
|
||||
ref.watch(widgetServiceProvider),
|
||||
);
|
||||
});
|
||||
}, dependencies: [apiServiceProvider]);
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
final AuthService _authService;
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart' as old_asset_entity;
|
||||
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/services/gcast.service.dart';
|
||||
|
||||
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
|
||||
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
|
||||
dependencies: [apiServiceProvider],
|
||||
);
|
||||
|
||||
class CastNotifier extends StateNotifier<CastManagerState> {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
import 'package:immich_mobile/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -30,6 +33,7 @@ class ActionNotifier extends Notifier<void> {
|
||||
final Logger _logger = Logger('ActionNotifier');
|
||||
late ActionService _service;
|
||||
late UploadService _uploadService;
|
||||
late DownloadService _downloadService;
|
||||
|
||||
ActionNotifier() : super();
|
||||
|
||||
@@ -37,6 +41,29 @@ class ActionNotifier extends Notifier<void> {
|
||||
void build() {
|
||||
_uploadService = ref.watch(uploadServiceProvider);
|
||||
_service = ref.watch(actionServiceProvider);
|
||||
_downloadService = ref.watch(downloadServiceProvider);
|
||||
_downloadService.onImageDownloadStatus = _downloadImageCallback;
|
||||
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
|
||||
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
|
||||
}
|
||||
|
||||
void _downloadImageCallback(TaskStatusUpdate update) {
|
||||
if (update.status == TaskStatus.complete) {
|
||||
_downloadService.saveImageWithPath(update.task);
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadVideoCallback(TaskStatusUpdate update) {
|
||||
if (update.status == TaskStatus.complete) {
|
||||
_downloadService.saveVideo(update.task);
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadLivePhotoCallback(TaskStatusUpdate update) async {
|
||||
if (update.status == TaskStatus.complete) {
|
||||
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
|
||||
_downloadService.saveLivePhotos(update.task, livePhotosId);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _getRemoteIdsForSource(ActionSource source) {
|
||||
@@ -239,6 +266,21 @@ class ActionNotifier extends Notifier<void> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult?> editDateTime(ActionSource source, BuildContext context) async {
|
||||
final ids = _getOwnedRemoteIdsForSource(source);
|
||||
try {
|
||||
final isEdited = await _service.editDateTime(ids, context);
|
||||
if (!isEdited) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ActionResult(count: ids.length, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to edit date and time for assets', error, stack);
|
||||
return ActionResult(count: ids.length, success: false, error: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
|
||||
final ids = _getRemoteIdsForSource(source);
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/local_album.service.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
@@ -31,7 +32,7 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
|
||||
|
||||
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
|
||||
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
|
||||
dependencies: [remoteAlbumRepository],
|
||||
dependencies: [remoteAlbumRepository, apiServiceProvider],
|
||||
);
|
||||
|
||||
final remoteAlbumProvider = NotifierProvider<RemoteAlbumNotifier, RemoteAlbumState>(
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/stack.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart' hide AssetType;
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:openapi/api.dart' as api show AssetVisibility;
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
|
||||
final assetApiRepositoryProvider = Provider(
|
||||
(ref) => AssetApiRepository(
|
||||
@@ -66,6 +70,10 @@ class AssetApiRepository extends ApiRepository {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
|
||||
}
|
||||
|
||||
Future<void> updateDateTime(List<String> ids, DateTime dateTime) async {
|
||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: dateTime.toIso8601String()));
|
||||
}
|
||||
|
||||
Future<StackResponse> stack(List<String> ids) async {
|
||||
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
|
||||
|
||||
@@ -97,6 +105,20 @@ class AssetApiRepository extends ApiRepository {
|
||||
Future<void> updateDescription(String assetId, String description) {
|
||||
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
|
||||
}
|
||||
|
||||
Future<String?> uploadAsset(XFile file) async {
|
||||
final lastModified = await file.lastModified();
|
||||
final deviceAssetId = "MOBILE-${file.name}-${lastModified.millisecondsSinceEpoch}";
|
||||
|
||||
final multipart = MultipartFile.fromBytes(
|
||||
'assetData', // field should be 'assetData' to match the backend API
|
||||
await file.readAsBytes(),
|
||||
filename: file.name,
|
||||
);
|
||||
|
||||
final asset = await _api.uploadAsset(multipart, deviceAssetId, "MOBILE", lastModified, lastModified);
|
||||
return asset?.id;
|
||||
}
|
||||
}
|
||||
|
||||
extension on StackResponseDto {
|
||||
@@ -104,3 +126,41 @@ extension on StackResponseDto {
|
||||
return StackResponse(id: id, primaryAssetId: primaryAssetId, assetIds: assets.map((asset) => asset.id).toList());
|
||||
}
|
||||
}
|
||||
|
||||
extension RemoteAssetDtoExt on AssetResponseDto {
|
||||
RemoteAsset toDto() {
|
||||
return RemoteAsset(
|
||||
id: id,
|
||||
name: originalFileName,
|
||||
checksum: checksum,
|
||||
createdAt: fileCreatedAt,
|
||||
updatedAt: updatedAt,
|
||||
ownerId: ownerId,
|
||||
visibility: switch (visibility) {
|
||||
api.AssetVisibility.timeline => AssetVisibility.timeline,
|
||||
api.AssetVisibility.hidden => AssetVisibility.hidden,
|
||||
api.AssetVisibility.archive => AssetVisibility.archive,
|
||||
api.AssetVisibility.locked => AssetVisibility.locked,
|
||||
_ => AssetVisibility.timeline,
|
||||
},
|
||||
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
|
||||
height: exifInfo?.exifImageHeight?.toInt(),
|
||||
width: exifInfo?.exifImageWidth?.toInt(),
|
||||
isFavorite: isFavorite,
|
||||
livePhotoVideoId: livePhotoVideoId,
|
||||
thumbHash: thumbhash,
|
||||
localId: null,
|
||||
type: type.toType(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension on AssetTypeEnum {
|
||||
AssetType toType() => switch (this) {
|
||||
AssetTypeEnum.IMAGE => AssetType.image,
|
||||
AssetTypeEnum.VIDEO => AssetType.video,
|
||||
AssetTypeEnum.AUDIO => AssetType.audio,
|
||||
AssetTypeEnum.OTHER => AssetType.other,
|
||||
_ => throw Exception('Unknown AssetType value: $this'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/album/shared_album.model.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
// ignore: import_rule_openapi
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -87,6 +89,11 @@ class DriftAlbumApiRepository extends ApiRepository {
|
||||
final response = await checkNull(_api.addUsersToAlbum(albumId, AddUsersDto(albumUsers: albumUsers)));
|
||||
return response.toRemoteAlbum();
|
||||
}
|
||||
|
||||
Future<SharedRemoteAlbum?> getShared(String albumId) async {
|
||||
final responseDto = await checkNull(_api.getAlbumInfo(albumId));
|
||||
return responseDto.toSharedRemoteAlbum();
|
||||
}
|
||||
}
|
||||
|
||||
extension on AlbumResponseDto {
|
||||
@@ -105,4 +112,21 @@ extension on AlbumResponseDto {
|
||||
ownerName: owner.name,
|
||||
);
|
||||
}
|
||||
|
||||
SharedRemoteAlbum toSharedRemoteAlbum() {
|
||||
return SharedRemoteAlbum(
|
||||
id: id,
|
||||
name: albumName,
|
||||
ownerId: owner.id,
|
||||
description: description,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
thumbnailAssetId: albumThumbnailAssetId,
|
||||
isActivityEnabled: isActivityEnabled,
|
||||
order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
|
||||
assetCount: assetCount,
|
||||
ownerName: owner.name,
|
||||
assets: assets.map((e) => e.toDto()).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
@@ -17,6 +18,11 @@ class AuthGuard extends AutoRouteGuard {
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) async {
|
||||
resolver.next(true);
|
||||
|
||||
if (ImageUrlBuilder.isSharedLink()) {
|
||||
// If the URL is a shared link, we don't need to validate the access token
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Look in the store for an access token
|
||||
Store.get(StoreKey.accessToken);
|
||||
|
||||
@@ -23,11 +23,11 @@ import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart'
|
||||
import 'package:immich_mobile/pages/album/album_viewer.page.dart';
|
||||
import 'package:immich_mobile/pages/albums/albums.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/album_preview.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/drift_backup.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/drift_backup.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/drift_backup_options.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart';
|
||||
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
|
||||
@@ -95,11 +95,12 @@ import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_place.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/remote_shared_link.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
@@ -329,6 +330,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
|
||||
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: RemoteSharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
// required to handle all deeplinks in deep_link.service.dart
|
||||
// auto_route_library#1722
|
||||
RedirectRoute(path: '*', redirectTo: '/'),
|
||||
|
||||
@@ -2167,6 +2167,58 @@ class RemoteMediaSummaryRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [RemoteSharedLinkPage]
|
||||
class RemoteSharedLinkRoute extends PageRouteInfo<RemoteSharedLinkRouteArgs> {
|
||||
RemoteSharedLinkRoute({
|
||||
Key? key,
|
||||
required String shareKey,
|
||||
required String endpoint,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
RemoteSharedLinkRoute.name,
|
||||
args: RemoteSharedLinkRouteArgs(
|
||||
key: key,
|
||||
shareKey: shareKey,
|
||||
endpoint: endpoint,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'RemoteSharedLinkRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<RemoteSharedLinkRouteArgs>();
|
||||
return RemoteSharedLinkPage(
|
||||
key: args.key,
|
||||
shareKey: args.shareKey,
|
||||
endpoint: args.endpoint,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class RemoteSharedLinkRouteArgs {
|
||||
const RemoteSharedLinkRouteArgs({
|
||||
this.key,
|
||||
required this.shareKey,
|
||||
required this.endpoint,
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final String shareKey;
|
||||
|
||||
final String endpoint;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'RemoteSharedLinkRouteArgs{key: $key, shareKey: $shareKey, endpoint: $endpoint}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [SearchPage]
|
||||
class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
|
||||
import 'package:immich_mobile/widgets/common/location_picker.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@@ -159,6 +160,44 @@ class ActionService {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> editDateTime(List<String> remoteIds, BuildContext context) async {
|
||||
DateTime? initialDate;
|
||||
String? timeZone;
|
||||
Duration? offset;
|
||||
|
||||
if (remoteIds.length == 1) {
|
||||
final assetId = remoteIds.first;
|
||||
final asset = await _remoteAssetRepository.get(assetId);
|
||||
if (asset == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final exifData = await _remoteAssetRepository.getExif(assetId);
|
||||
initialDate = asset.createdAt.toLocal();
|
||||
offset = initialDate.timeZoneOffset;
|
||||
timeZone = exifData?.timeZone;
|
||||
}
|
||||
|
||||
final dateTime = await showDateTimePicker(
|
||||
context: context,
|
||||
initialDateTime: initialDate,
|
||||
initialTZ: timeZone,
|
||||
initialTZOffset: offset,
|
||||
);
|
||||
|
||||
if (dateTime == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// convert dateTime to DateTime object
|
||||
final parsedDateTime = DateTime.parse(dateTime);
|
||||
|
||||
await _assetApiRepository.updateDateTime(remoteIds, parsedDateTime);
|
||||
await _remoteAssetRepository.updateDateTime(remoteIds, parsedDateTime);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
|
||||
int removedCount = 0;
|
||||
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
|
||||
|
||||
@@ -8,9 +8,9 @@ import 'package:http/http.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:immich_mobile/utils/user_agent.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:immich_mobile/utils/user_agent.dart';
|
||||
|
||||
class ApiService implements Authentication {
|
||||
late ApiClient _apiClient;
|
||||
@@ -45,7 +45,14 @@ class ApiService implements Authentication {
|
||||
setEndpoint(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
ApiService.shared(String endpoint, String sharedKey) {
|
||||
setEndpoint(endpoint);
|
||||
_queryParams = {'key': sharedKey};
|
||||
}
|
||||
|
||||
String? _accessToken;
|
||||
Map<String, String>? _queryParams;
|
||||
final _log = Logger("ApiService");
|
||||
|
||||
setEndpoint(String endpoint) {
|
||||
@@ -208,6 +215,8 @@ class ApiService implements Authentication {
|
||||
return Future<void>(() {
|
||||
var headers = ApiService.getRequestHeaders();
|
||||
headerParams.addAll(headers);
|
||||
|
||||
queryParams.addAll(_queryParams?.entries.map((e) => QueryParam(e.key, e.value)) ?? []);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
|
||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -80,6 +80,7 @@ class DeepLinkService {
|
||||
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
|
||||
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? ''),
|
||||
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
|
||||
"sharedlink" => await _buildSharedLinkDeepLink(queryParams['key'] ?? '', queryParams['instanceUrl'] ?? ''),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -99,18 +100,24 @@ class DeepLinkService {
|
||||
final path = link.uri.path;
|
||||
|
||||
const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
||||
const b64Regex = r'^[A-Za-z0-9_-]+$';
|
||||
final assetRegex = RegExp('/photos/($uuidRegex)');
|
||||
final albumRegex = RegExp('/albums/($uuidRegex)');
|
||||
final sharedLinkRegex = RegExp('/share/($b64Regex)');
|
||||
|
||||
PageRouteInfo<dynamic>? deepLinkRoute;
|
||||
|
||||
if (assetRegex.hasMatch(path)) {
|
||||
final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
|
||||
deepLinkRoute = await _buildAssetDeepLink(assetId);
|
||||
} else if (albumRegex.hasMatch(path)) {
|
||||
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
|
||||
deepLinkRoute = await _buildAlbumDeepLink(albumId);
|
||||
} else if (sharedLinkRegex.hasMatch(path)) {
|
||||
final shareKey = sharedLinkRegex.firstMatch(path)?.group(1) ?? '';
|
||||
final instanceUrl = link.uri.queryParameters['instanceUrl'] ?? '';
|
||||
deepLinkRoute = await _buildSharedLinkDeepLink(shareKey, instanceUrl);
|
||||
}
|
||||
|
||||
// Deep link resolution failed, safely handle it based on the app state
|
||||
if (deepLinkRoute == null) {
|
||||
if (isColdStart) return DeepLink.defaultPath;
|
||||
@@ -185,4 +192,8 @@ class DeepLinkService {
|
||||
return AlbumViewerRoute(albumId: album.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PageRouteInfo?> _buildSharedLinkDeepLink(String shareKey, String instanceUrl) async {
|
||||
return RemoteSharedLinkRoute(shareKey: shareKey, endpoint: instanceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,4 +111,13 @@ class SharedLinkService {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<SharedLink?> getMySharedLink({String? password}) async {
|
||||
final responseDto = await _apiService.sharedLinksApi.getMySharedLink(password: password);
|
||||
if (responseDto != null) {
|
||||
return SharedLink.fromDto(responseDto);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ class UploadService {
|
||||
}
|
||||
|
||||
Future<void> manualBackup(List<LocalAsset> localAssets) async {
|
||||
await _storageRepository.clearCache();
|
||||
List<UploadTask> tasks = [];
|
||||
for (final asset in localAssets) {
|
||||
final task = await _getUploadTask(
|
||||
@@ -120,6 +121,8 @@ class UploadService {
|
||||
/// Build the upload tasks
|
||||
/// Enqueue the tasks
|
||||
Future<void> startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async {
|
||||
await _storageRepository.clearCache();
|
||||
|
||||
shouldAbortQueuingTasks = false;
|
||||
|
||||
final candidates = await _backupRepository.getCandidates(userId);
|
||||
@@ -159,6 +162,7 @@ class UploadService {
|
||||
Future<int> cancelBackup() async {
|
||||
shouldAbortQueuingTasks = true;
|
||||
|
||||
await _storageRepository.clearCache();
|
||||
await _uploadRepository.reset(kBackupGroup);
|
||||
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ String getAlbumThumbNailCacheKey(final Album album, {AssetMediaSize type = Asset
|
||||
}
|
||||
|
||||
String getOriginalUrlForRemoteId(final String id) {
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original';
|
||||
return ImageUrlBuilder.build('/assets/$id/original');
|
||||
}
|
||||
|
||||
String getImageCacheKey(final Asset asset) {
|
||||
@@ -46,16 +46,51 @@ String getImageCacheKey(final Asset asset) {
|
||||
}
|
||||
|
||||
String getThumbnailUrlForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}';
|
||||
return ImageUrlBuilder.build('/assets/$id/thumbnail?size=${type.value}');
|
||||
}
|
||||
|
||||
String getPreviewUrlForRemoteId(final String id) =>
|
||||
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}';
|
||||
String getPreviewUrlForRemoteId(final String id) {
|
||||
return ImageUrlBuilder.build('/assets/$id/thumbnail?size=${AssetMediaSize.preview}');
|
||||
}
|
||||
|
||||
String getPlaybackUrlForRemoteId(final String id) {
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
|
||||
return ImageUrlBuilder.build('/assets/$id/video/playback?');
|
||||
}
|
||||
|
||||
String getFaceThumbnailUrl(final String personId) {
|
||||
return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail';
|
||||
return ImageUrlBuilder.build('/people/$personId/thumbnail');
|
||||
}
|
||||
|
||||
class ImageUrlBuilder {
|
||||
static String? host;
|
||||
static Map<String, String>? queryParams;
|
||||
|
||||
static void setHost(String? host) {
|
||||
ImageUrlBuilder.host = host;
|
||||
}
|
||||
|
||||
static bool isSharedLink() {
|
||||
return ImageUrlBuilder.host != null && ImageUrlBuilder.queryParams!.containsKey('key');
|
||||
}
|
||||
|
||||
static void setParameter(String key, String value) {
|
||||
ImageUrlBuilder.queryParams ??= {};
|
||||
ImageUrlBuilder.queryParams![key] = value;
|
||||
}
|
||||
|
||||
static String build(String path) {
|
||||
final endpoint = host ?? Store.get(StoreKey.serverEndpoint);
|
||||
|
||||
final uri = Uri.parse('$endpoint$path');
|
||||
if (queryParams == null || queryParams!.isEmpty) {
|
||||
return uri.toString();
|
||||
}
|
||||
final updatedUri = uri.replace(queryParameters: {...uri.queryParameters, ...queryParams!});
|
||||
return updatedUri.toString();
|
||||
}
|
||||
|
||||
static void clear() {
|
||||
ImageUrlBuilder.host = null;
|
||||
ImageUrlBuilder.queryParams = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ class _DateTimePicker extends HookWidget {
|
||||
1,
|
||||
),
|
||||
trailing: Icon(Icons.edit_outlined, size: 18, color: context.primaryColor),
|
||||
title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium).tr(),
|
||||
title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium),
|
||||
onTap: pickDate,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
72
mobile/lib/widgets/common/shared_link_password_dialog.dart
Normal file
72
mobile/lib/widgets/common/shared_link_password_dialog.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
class SharedLinkPasswordDialog extends StatefulWidget {
|
||||
const SharedLinkPasswordDialog({super.key});
|
||||
|
||||
@override
|
||||
State<SharedLinkPasswordDialog> createState() => _SharedLinkPasswordDialogState();
|
||||
}
|
||||
|
||||
class _SharedLinkPasswordDialogState extends State<SharedLinkPasswordDialog> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
bool isNotEmpty = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller.addListener(() {
|
||||
setState(() {
|
||||
isNotEmpty = controller.text.isNotEmpty;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
|
||||
title: const Text("shared_link_password_dialog_title").t(context: context),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text("shared_link_password_dialog_content").t(context: context),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(hintText: "password".t(context: context)),
|
||||
obscureText: true,
|
||||
autofocus: true,
|
||||
onSubmitted: (value) {
|
||||
Navigator.pop(context, value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.secondary),
|
||||
child: const Text("cancel").t(context: context),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: isNotEmpty
|
||||
? () {
|
||||
Navigator.pop(context, controller.text);
|
||||
}
|
||||
: null,
|
||||
child: Text(
|
||||
"submit".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||
import 'package:immich_mobile/providers/sync_status.provider.dart';
|
||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
@@ -104,6 +105,10 @@ class BetaSyncSettings extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearFileCache() async {
|
||||
await ref.read(storageRepositoryProvider).clearCache();
|
||||
}
|
||||
|
||||
return FutureBuilder<List<dynamic>>(
|
||||
future: loadCounts(),
|
||||
builder: (context, snapshot) {
|
||||
@@ -241,6 +246,14 @@ class BetaSyncSettings extends HookConsumerWidget {
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
const SizedBox(height: 24),
|
||||
_SectionHeaderText(text: "actions".t(context: context)),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"clear_file_cache".t(context: context),
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
leading: const Icon(Icons.playlist_remove_rounded),
|
||||
onTap: clearFileCache,
|
||||
),
|
||||
ListTile(
|
||||
title: Text(
|
||||
"export_database".t(context: context),
|
||||
|
||||
@@ -40,6 +40,7 @@ void main() {
|
||||
registerFallbackValue(LocalAssetStub.image1);
|
||||
|
||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||
when(() => mockStorageRepo.clearCache()).thenAnswer((_) async => {});
|
||||
});
|
||||
|
||||
group('HashService hashAssets', () {
|
||||
|
||||
8
web/package-lock.json
generated
8
web/package-lock.json
generated
@@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@immich/ui": "^0.23.5",
|
||||
"@immich/ui": "^0.23.6",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.11.5",
|
||||
@@ -1357,9 +1357,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@immich/ui": {
|
||||
"version": "0.23.5",
|
||||
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.5.tgz",
|
||||
"integrity": "sha512-1wlFMmfDmtGC+Kcc8cYTT00mQaSumR41KEOOOmVn5Rw/8z9pUhpNY8mGl1AxY4qhtnaz+G3dH6vowYzL23D+YQ==",
|
||||
"version": "0.23.6",
|
||||
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.6.tgz",
|
||||
"integrity": "sha512-HYIguDx/nCXcvqLKhY1R/+Aks6mn8B9jIiNVQH6WODDPbvGFrvQT5uINhXHrjsdyuzKBVS6dps+lx9+9Z6z4rA==",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"dependencies": {
|
||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@immich/ui": "^0.23.5",
|
||||
"@immich/ui": "^0.23.6",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.11.5",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
import Portal from '../portal/portal.svelte';
|
||||
|
||||
interface Props {
|
||||
initialAssetId?: string;
|
||||
assets: (TimelineAsset | AssetResponseDto)[];
|
||||
assetInteraction: AssetInteraction;
|
||||
disableAssetSelect?: boolean;
|
||||
@@ -44,6 +45,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
initialAssetId = undefined,
|
||||
assets = $bindable(),
|
||||
assetInteraction,
|
||||
disableAssetSelect = false,
|
||||
@@ -117,7 +119,14 @@
|
||||
};
|
||||
});
|
||||
|
||||
let currentViewAssetIndex = 0;
|
||||
let currentIndex = 0;
|
||||
if (initialAssetId && assets.length > 0) {
|
||||
const index = assets.findIndex(({ id }) => id === initialAssetId);
|
||||
if (index !== -1) {
|
||||
currentIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
let shiftKeyIsDown = $state(false);
|
||||
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
||||
let slidingWindow = $state({ top: 0, bottom: 0 });
|
||||
@@ -150,8 +159,8 @@
|
||||
}
|
||||
});
|
||||
const viewAssetHandler = async (asset: TimelineAsset) => {
|
||||
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
|
||||
await setAssetId(assets[currentViewAssetIndex].id);
|
||||
currentIndex = assets.findIndex((a) => a.id == asset.id);
|
||||
await setAssetId(assets[currentIndex].id);
|
||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||
};
|
||||
|
||||
@@ -324,12 +333,12 @@
|
||||
if (onNext) {
|
||||
asset = await onNext();
|
||||
} else {
|
||||
if (currentViewAssetIndex >= assets.length - 1) {
|
||||
if (currentIndex >= assets.length - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentViewAssetIndex = currentViewAssetIndex + 1;
|
||||
asset = currentViewAssetIndex < assets.length ? assets[currentViewAssetIndex] : undefined;
|
||||
currentIndex = currentIndex + 1;
|
||||
asset = currentIndex < assets.length ? assets[currentIndex] : undefined;
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
@@ -374,12 +383,12 @@
|
||||
if (onPrevious) {
|
||||
asset = await onPrevious();
|
||||
} else {
|
||||
if (currentViewAssetIndex <= 0) {
|
||||
if (currentIndex <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentViewAssetIndex = currentViewAssetIndex - 1;
|
||||
asset = currentViewAssetIndex >= 0 ? assets[currentViewAssetIndex] : undefined;
|
||||
currentIndex = currentIndex - 1;
|
||||
asset = currentIndex >= 0 ? assets[currentIndex] : undefined;
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
@@ -412,10 +421,10 @@
|
||||
);
|
||||
if (assets.length === 0) {
|
||||
await goto(AppRoute.PHOTOS);
|
||||
} else if (currentViewAssetIndex === assets.length) {
|
||||
} else if (currentIndex === assets.length) {
|
||||
await handlePrevious();
|
||||
} else {
|
||||
await setAssetId(assets[currentViewAssetIndex].id);
|
||||
await setAssetId(assets[currentIndex].id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
||||
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { foldersStore } from '$lib/stores/folders.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
@@ -40,7 +40,6 @@
|
||||
let { data }: Props = $props();
|
||||
|
||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||
|
||||
const assetInteraction = new AssetInteraction();
|
||||
|
||||
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
|
||||
@@ -104,6 +103,7 @@
|
||||
{#if data.pathAssets && data.pathAssets.length > 0}
|
||||
<div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2">
|
||||
<GalleryViewer
|
||||
initialAssetId={data.asset?.id}
|
||||
assets={data.pathAssets}
|
||||
{assetInteraction}
|
||||
{viewport}
|
||||
|
||||
Reference in New Issue
Block a user