Compare commits
9 Commits
| 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**¹** |
|
| 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")**¹** |
|
| 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**¹** |
|
| 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 |
|
| 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 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 |
|
| [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
|
## 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>
|
<details>
|
||||||
<summary>Authentik Example</summary>
|
<summary>Authentik Example</summary>
|
||||||
|
|
||||||
@@ -128,7 +211,7 @@ Configuration of OAuth in Immich System Settings
|
|||||||
| Signing Algorithm | RS256 |
|
| Signing Algorithm | RS256 |
|
||||||
| Storage Label Claim | preferred_username |
|
| Storage Label Claim | preferred_username |
|
||||||
| Storage Quota Claim | immich_quota |
|
| 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) |
|
| Button Text | Sign in with Authentik (optional) |
|
||||||
| Auto Register | Enabled (optional) |
|
| Auto Register | Enabled (optional) |
|
||||||
| Auto Launch | Enabled (optional) |
|
| Auto Launch | Enabled (optional) |
|
||||||
@@ -159,7 +242,7 @@ Configuration of OAuth in Immich System Settings
|
|||||||
| Signing Algorithm | RS256 |
|
| Signing Algorithm | RS256 |
|
||||||
| Storage Label Claim | preferred_username |
|
| Storage Label Claim | preferred_username |
|
||||||
| Storage Quota Claim | immich_quota |
|
| 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) |
|
| Button Text | Sign in with Google (optional) |
|
||||||
| Auto Register | Enabled (optional) |
|
| Auto Register | Enabled (optional) |
|
||||||
| Auto Launch | Enabled |
|
| Auto Launch | Enabled |
|
||||||
|
|||||||
@@ -653,6 +653,7 @@
|
|||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"clear_all": "Clear all",
|
"clear_all": "Clear all",
|
||||||
"clear_all_recent_searches": "Clear all recent searches",
|
"clear_all_recent_searches": "Clear all recent searches",
|
||||||
|
"clear_file_cache": "Clear File Cache",
|
||||||
"clear_message": "Clear message",
|
"clear_message": "Clear message",
|
||||||
"clear_value": "Clear value",
|
"clear_value": "Clear value",
|
||||||
"client_cert_dialog_msg_confirm": "OK",
|
"client_cert_dialog_msg_confirm": "OK",
|
||||||
@@ -834,6 +835,7 @@
|
|||||||
"edit_birthday": "Edit Birthday",
|
"edit_birthday": "Edit Birthday",
|
||||||
"edit_date": "Edit date",
|
"edit_date": "Edit date",
|
||||||
"edit_date_and_time": "Edit date and time",
|
"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": "Edit description",
|
||||||
"edit_description_prompt": "Please select a new description:",
|
"edit_description_prompt": "Please select a new description:",
|
||||||
"edit_exclusion_pattern": "Edit exclusion pattern",
|
"edit_exclusion_pattern": "Edit exclusion pattern",
|
||||||
@@ -1786,9 +1788,15 @@
|
|||||||
"shared_link_expires_seconds": "Expires in {count} seconds",
|
"shared_link_expires_seconds": "Expires in {count} seconds",
|
||||||
"shared_link_individual_shared": "Individual shared",
|
"shared_link_individual_shared": "Individual shared",
|
||||||
"shared_link_info_chip_metadata": "EXIF",
|
"shared_link_info_chip_metadata": "EXIF",
|
||||||
|
"shared_link_invalid_password": "Invalid password",
|
||||||
"shared_link_manage_links": "Manage Shared links",
|
"shared_link_manage_links": "Manage Shared links",
|
||||||
"shared_link_options": "Shared link options",
|
"shared_link_options": "Shared link options",
|
||||||
"shared_link_password_description": "Require a password to access this shared link",
|
"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": "Shared links",
|
||||||
"shared_links_description": "Share photos and videos with a link",
|
"shared_links_description": "Share photos and videos with a link",
|
||||||
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
|
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",
|
||||||
|
|||||||
@@ -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");
|
DLog.log("Hashed ${hashed.length}/${toHash.length} assets");
|
||||||
|
|
||||||
await _localAssetRepository.updateHashes(hashed);
|
await _localAssetRepository.updateHashes(hashed);
|
||||||
|
await _storageRepository.clearCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/domain/models/search_result.model.dart';
|
||||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.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/models/search/search_filter.model.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart' as api show AssetVisibility;
|
|
||||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||||
|
|
||||||
class SearchService {
|
class SearchService {
|
||||||
@@ -52,41 +51,3 @@ class SearchService {
|
|||||||
return null;
|
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) {
|
Future<void> stack(String userId, StackResponse stack) {
|
||||||
return _db.transaction(() async {
|
return _db.transaction(() async {
|
||||||
final stackIds = await _db.managers.stackEntity
|
final stackIds = await _db.managers.stackEntity
|
||||||
|
|||||||
@@ -66,4 +66,14 @@ class StorageRepository {
|
|||||||
}
|
}
|
||||||
return entity;
|
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';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
enum SharedLinkSource { album, individual }
|
enum SharedLinkSource { album, individual }
|
||||||
@@ -14,6 +16,8 @@ class SharedLink {
|
|||||||
final String key;
|
final String key;
|
||||||
final bool showMetadata;
|
final bool showMetadata;
|
||||||
final SharedLinkSource type;
|
final SharedLinkSource type;
|
||||||
|
final List<RemoteAsset> assets;
|
||||||
|
final String? albumId;
|
||||||
|
|
||||||
const SharedLink({
|
const SharedLink({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -27,6 +31,8 @@ class SharedLink {
|
|||||||
required this.key,
|
required this.key,
|
||||||
required this.showMetadata,
|
required this.showMetadata,
|
||||||
required this.type,
|
required this.type,
|
||||||
|
this.assets = const [],
|
||||||
|
this.albumId,
|
||||||
});
|
});
|
||||||
|
|
||||||
SharedLink copyWith({
|
SharedLink copyWith({
|
||||||
@@ -74,7 +80,9 @@ class SharedLink {
|
|||||||
? dto.album?.albumThumbnailAssetId
|
? dto.album?.albumThumbnailAssetId
|
||||||
: dto.assets.isNotEmpty
|
: dto.assets.isNotEmpty
|
||||||
? dto.assets[0].id
|
? dto.assets[0].id
|
||||||
: null;
|
: null,
|
||||||
|
assets = dto.assets.map((asset) => asset.toDto()).toList(),
|
||||||
|
albumId = dto.album?.id;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() =>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-1
@@ -1,10 +1,44 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.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 {
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -12,6 +46,7 @@ class EditDateTimeActionButton extends ConsumerWidget {
|
|||||||
maxWidth: 95.0,
|
maxWidth: 95.0,
|
||||||
iconData: Icons.edit_calendar_outlined,
|
iconData: Icons.edit_calendar_outlined,
|
||||||
label: "control_bottom_app_bar_edit_time".t(context: context),
|
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 exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||||
|
|
||||||
|
Future<void> editDateTime() async {
|
||||||
|
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
|
||||||
|
}
|
||||||
|
|
||||||
return SliverList.list(
|
return SliverList.list(
|
||||||
children: [
|
children: [
|
||||||
// Asset Date and Time
|
// Asset Date and Time
|
||||||
_SheetTile(
|
_SheetTile(
|
||||||
title: _getDateTime(context, asset),
|
title: _getDateTime(context, asset),
|
||||||
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
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),
|
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
|
||||||
const SheetPeopleDetails(),
|
const SheetPeopleDetails(),
|
||||||
@@ -194,11 +200,21 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
class _SheetTile extends StatelessWidget {
|
class _SheetTile extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final Widget? leading;
|
final Widget? leading;
|
||||||
|
final Widget? trailing;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final TextStyle? titleStyle;
|
final TextStyle? titleStyle;
|
||||||
final TextStyle? subtitleStyle;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -234,8 +250,10 @@ class _SheetTile extends StatelessWidget {
|
|||||||
title: titleWidget,
|
title: titleWidget,
|
||||||
titleAlignment: ListTileTitleAlignment.center,
|
titleAlignment: ListTileTitleAlignment.center,
|
||||||
leading: leading,
|
leading: leading,
|
||||||
|
trailing: trailing,
|
||||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||||
subtitle: subtitleWidget,
|
subtitle: subtitleWidget,
|
||||||
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -79,7 +79,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
|
|||||||
context.back();
|
context.back();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.back();
|
context.pop();
|
||||||
context.pushRoute(DriftPersonRoute(person: person));
|
context.pushRoute(DriftPersonRoute(person: person));
|
||||||
},
|
},
|
||||||
onNameTap: () => showNameEditModal(person),
|
onNameTap: () => showNameEditModal(person),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
|
|||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
const EditDateTimeActionButton(),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
const StackActionButton(source: ActionSource.timeline),
|
const StackActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
const EditDateTimeActionButton(),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
const StackActionButton(source: ActionSource.timeline),
|
const StackActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class GeneralBottomSheet extends ConsumerWidget {
|
|||||||
if (multiselect.hasLocal || multiselect.hasMerged) ...[
|
if (multiselect.hasLocal || multiselect.hasMerged) ...[
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
const EditDateTimeActionButton(),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
const StackActionButton(source: ActionSource.timeline),
|
const StackActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
|
|||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
const EditDateTimeActionButton(),
|
const EditDateTimeActionButton(source: ActionSource.timeline),
|
||||||
const EditLocationActionButton(source: ActionSource.timeline),
|
const EditLocationActionButton(source: ActionSource.timeline),
|
||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
const StackActionButton(source: ActionSource.timeline),
|
const StackActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
|
|
||||||
part 'api.provider.g.dart';
|
part 'api.provider.g.dart';
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true, dependencies: [])
|
||||||
ApiService apiService(Ref _) => ApiService();
|
ApiService apiService(Ref _) => ApiService();
|
||||||
|
|||||||
Generated
+3
-3
@@ -6,7 +6,7 @@ part of 'api.provider.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$apiServiceHash() => r'187a7de59b064fab1104c23717f18ce0ae3e426c';
|
String _$apiServiceHash() => r'46d8a043f41b85f36f56d81e5261c842cb5c0c06';
|
||||||
|
|
||||||
/// See also [apiService].
|
/// See also [apiService].
|
||||||
@ProviderFor(apiService)
|
@ProviderFor(apiService)
|
||||||
@@ -16,8 +16,8 @@ final apiServiceProvider = Provider<ApiService>.internal(
|
|||||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||||
? null
|
? null
|
||||||
: _$apiServiceHash,
|
: _$apiServiceHash,
|
||||||
dependencies: null,
|
dependencies: const <ProviderOrFamily>[],
|
||||||
allTransitiveDependencies: null,
|
allTransitiveDependencies: const <ProviderOrFamily>{},
|
||||||
);
|
);
|
||||||
|
|
||||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
@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(secureStorageServiceProvider),
|
||||||
ref.watch(widgetServiceProvider),
|
ref.watch(widgetServiceProvider),
|
||||||
);
|
);
|
||||||
});
|
}, dependencies: [apiServiceProvider]);
|
||||||
|
|
||||||
class AuthNotifier extends StateNotifier<AuthState> {
|
class AuthNotifier extends StateNotifier<AuthState> {
|
||||||
final AuthService _authService;
|
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/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/entities/asset.entity.dart' as old_asset_entity;
|
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/models/cast/cast_manager_state.dart';
|
||||||
|
import 'package:immich_mobile/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/services/gcast.service.dart';
|
import 'package:immich_mobile/services/gcast.service.dart';
|
||||||
|
|
||||||
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
|
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
|
||||||
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
|
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
|
||||||
|
dependencies: [apiServiceProvider],
|
||||||
);
|
);
|
||||||
|
|
||||||
class CastNotifier extends StateNotifier<CastManagerState> {
|
class CastNotifier extends StateNotifier<CastManagerState> {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:immich_mobile/constants/enums.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/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/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
import 'package:immich_mobile/services/action.service.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/timeline.service.dart';
|
||||||
import 'package:immich_mobile/services/upload.service.dart';
|
import 'package:immich_mobile/services/upload.service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -30,6 +33,7 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
final Logger _logger = Logger('ActionNotifier');
|
final Logger _logger = Logger('ActionNotifier');
|
||||||
late ActionService _service;
|
late ActionService _service;
|
||||||
late UploadService _uploadService;
|
late UploadService _uploadService;
|
||||||
|
late DownloadService _downloadService;
|
||||||
|
|
||||||
ActionNotifier() : super();
|
ActionNotifier() : super();
|
||||||
|
|
||||||
@@ -37,6 +41,29 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
void build() {
|
void build() {
|
||||||
_uploadService = ref.watch(uploadServiceProvider);
|
_uploadService = ref.watch(uploadServiceProvider);
|
||||||
_service = ref.watch(actionServiceProvider);
|
_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) {
|
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 {
|
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
|
||||||
final ids = _getRemoteIdsForSource(source);
|
final ids = _getRemoteIdsForSource(source);
|
||||||
try {
|
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/domain/services/remote_album.service.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.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/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/db.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
@@ -31,7 +32,7 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
|
|||||||
|
|
||||||
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
|
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
|
||||||
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
|
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
|
||||||
dependencies: [remoteAlbumRepository],
|
dependencies: [remoteAlbumRepository, apiServiceProvider],
|
||||||
);
|
);
|
||||||
|
|
||||||
final remoteAlbumProvider = NotifierProvider<RemoteAlbumNotifier, RemoteAlbumState>(
|
final remoteAlbumProvider = NotifierProvider<RemoteAlbumNotifier, RemoteAlbumState>(
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:immich_mobile/constants/enums.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/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/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.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(
|
final assetApiRepositoryProvider = Provider(
|
||||||
(ref) => AssetApiRepository(
|
(ref) => AssetApiRepository(
|
||||||
@@ -66,6 +70,10 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
|
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 {
|
Future<StackResponse> stack(List<String> ids) async {
|
||||||
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
|
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
|
||||||
|
|
||||||
@@ -97,6 +105,20 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
Future<void> updateDescription(String assetId, String description) {
|
Future<void> updateDescription(String assetId, String description) {
|
||||||
return _api.updateAsset(assetId, UpdateAssetDto(description: 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 {
|
extension on StackResponseDto {
|
||||||
@@ -104,3 +126,41 @@ extension on StackResponseDto {
|
|||||||
return StackResponse(id: id, primaryAssetId: primaryAssetId, assetIds: assets.map((asset) => asset.id).toList());
|
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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.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/providers/api.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/api.repository.dart';
|
import 'package:immich_mobile/repositories/api.repository.dart';
|
||||||
|
import 'package:immich_mobile/repositories/asset_api.repository.dart';
|
||||||
// ignore: import_rule_openapi
|
// ignore: import_rule_openapi
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -87,6 +89,11 @@ class DriftAlbumApiRepository extends ApiRepository {
|
|||||||
final response = await checkNull(_api.addUsersToAlbum(albumId, AddUsersDto(albumUsers: albumUsers)));
|
final response = await checkNull(_api.addUsersToAlbum(albumId, AddUsersDto(albumUsers: albumUsers)));
|
||||||
return response.toRemoteAlbum();
|
return response.toRemoteAlbum();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<SharedRemoteAlbum?> getShared(String albumId) async {
|
||||||
|
final responseDto = await checkNull(_api.getAlbumInfo(albumId));
|
||||||
|
return responseDto.toSharedRemoteAlbum();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on AlbumResponseDto {
|
extension on AlbumResponseDto {
|
||||||
@@ -105,4 +112,21 @@ extension on AlbumResponseDto {
|
|||||||
ownerName: owner.name,
|
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/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/api.service.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:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
@@ -17,6 +18,11 @@ class AuthGuard extends AutoRouteGuard {
|
|||||||
void onNavigation(NavigationResolver resolver, StackRouter router) async {
|
void onNavigation(NavigationResolver resolver, StackRouter router) async {
|
||||||
resolver.next(true);
|
resolver.next(true);
|
||||||
|
|
||||||
|
if (ImageUrlBuilder.isSharedLink()) {
|
||||||
|
// If the URL is a shared link, we don't need to validate the access token
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Look in the store for an access token
|
// Look in the store for an access token
|
||||||
Store.get(StoreKey.accessToken);
|
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/album/album_viewer.page.dart';
|
||||||
import 'package:immich_mobile/pages/albums/albums.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/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_album_selection.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/backup_controller.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/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_backup_options.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart';
|
import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart';
|
||||||
import 'package:immich_mobile/pages/backup/failed_backup_status.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.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_place_detail.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_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_remote_album.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_trash.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/drift_video.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/local_timeline.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/pages/search/drift_search.page.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||||
import 'package:immich_mobile/providers/api.provider.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: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
|
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
|
||||||
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
|
AutoRoute(page: RemoteSharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
// required to handle all deeplinks in deep_link.service.dart
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
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
|
/// generated route for
|
||||||
/// [SearchPage]
|
/// [SearchPage]
|
||||||
class SearchRoute extends PageRouteInfo<SearchRouteArgs> {
|
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/asset_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
|
||||||
import 'package:immich_mobile/routing/router.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:immich_mobile/widgets/common/location_picker.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
@@ -159,6 +160,44 @@ class ActionService {
|
|||||||
return true;
|
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 {
|
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
|
||||||
int removedCount = 0;
|
int removedCount = 0;
|
||||||
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);
|
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/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/store.entity.dart';
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/utils/url_helper.dart';
|
import 'package:immich_mobile/utils/url_helper.dart';
|
||||||
|
import 'package:immich_mobile/utils/user_agent.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:openapi/api.dart';
|
import 'package:openapi/api.dart';
|
||||||
import 'package:immich_mobile/utils/user_agent.dart';
|
|
||||||
|
|
||||||
class ApiService implements Authentication {
|
class ApiService implements Authentication {
|
||||||
late ApiClient _apiClient;
|
late ApiClient _apiClient;
|
||||||
@@ -45,7 +45,14 @@ class ApiService implements Authentication {
|
|||||||
setEndpoint(endpoint);
|
setEndpoint(endpoint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApiService.shared(String endpoint, String sharedKey) {
|
||||||
|
setEndpoint(endpoint);
|
||||||
|
_queryParams = {'key': sharedKey};
|
||||||
|
}
|
||||||
|
|
||||||
String? _accessToken;
|
String? _accessToken;
|
||||||
|
Map<String, String>? _queryParams;
|
||||||
final _log = Logger("ApiService");
|
final _log = Logger("ApiService");
|
||||||
|
|
||||||
setEndpoint(String endpoint) {
|
setEndpoint(String endpoint) {
|
||||||
@@ -208,6 +215,8 @@ class ApiService implements Authentication {
|
|||||||
return Future<void>(() {
|
return Future<void>(() {
|
||||||
var headers = ApiService.getRequestHeaders();
|
var headers = ApiService.getRequestHeaders();
|
||||||
headerParams.addAll(headers);
|
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/entities/store.entity.dart';
|
||||||
import 'package:immich_mobile/providers/album/current_album.provider.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/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/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/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
@@ -80,6 +80,7 @@ class DeepLinkService {
|
|||||||
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
|
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
|
||||||
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? ''),
|
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? ''),
|
||||||
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
|
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
|
||||||
|
"sharedlink" => await _buildSharedLinkDeepLink(queryParams['key'] ?? '', queryParams['instanceUrl'] ?? ''),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,18 +100,24 @@ class DeepLinkService {
|
|||||||
final path = link.uri.path;
|
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 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 assetRegex = RegExp('/photos/($uuidRegex)');
|
||||||
final albumRegex = RegExp('/albums/($uuidRegex)');
|
final albumRegex = RegExp('/albums/($uuidRegex)');
|
||||||
|
final sharedLinkRegex = RegExp('/share/($b64Regex)');
|
||||||
|
|
||||||
PageRouteInfo<dynamic>? deepLinkRoute;
|
PageRouteInfo<dynamic>? deepLinkRoute;
|
||||||
|
|
||||||
if (assetRegex.hasMatch(path)) {
|
if (assetRegex.hasMatch(path)) {
|
||||||
final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
|
final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
|
||||||
deepLinkRoute = await _buildAssetDeepLink(assetId);
|
deepLinkRoute = await _buildAssetDeepLink(assetId);
|
||||||
} else if (albumRegex.hasMatch(path)) {
|
} else if (albumRegex.hasMatch(path)) {
|
||||||
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
|
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
|
||||||
deepLinkRoute = await _buildAlbumDeepLink(albumId);
|
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
|
// Deep link resolution failed, safely handle it based on the app state
|
||||||
if (deepLinkRoute == null) {
|
if (deepLinkRoute == null) {
|
||||||
if (isColdStart) return DeepLink.defaultPath;
|
if (isColdStart) return DeepLink.defaultPath;
|
||||||
@@ -185,4 +192,8 @@ class DeepLinkService {
|
|||||||
return AlbumViewerRoute(albumId: album.id);
|
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;
|
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 {
|
Future<void> manualBackup(List<LocalAsset> localAssets) async {
|
||||||
|
await _storageRepository.clearCache();
|
||||||
List<UploadTask> tasks = [];
|
List<UploadTask> tasks = [];
|
||||||
for (final asset in localAssets) {
|
for (final asset in localAssets) {
|
||||||
final task = await _getUploadTask(
|
final task = await _getUploadTask(
|
||||||
@@ -120,6 +121,8 @@ class UploadService {
|
|||||||
/// Build the upload tasks
|
/// Build the upload tasks
|
||||||
/// Enqueue the tasks
|
/// Enqueue the tasks
|
||||||
Future<void> startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async {
|
Future<void> startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async {
|
||||||
|
await _storageRepository.clearCache();
|
||||||
|
|
||||||
shouldAbortQueuingTasks = false;
|
shouldAbortQueuingTasks = false;
|
||||||
|
|
||||||
final candidates = await _backupRepository.getCandidates(userId);
|
final candidates = await _backupRepository.getCandidates(userId);
|
||||||
@@ -159,6 +162,7 @@ class UploadService {
|
|||||||
Future<int> cancelBackup() async {
|
Future<int> cancelBackup() async {
|
||||||
shouldAbortQueuingTasks = true;
|
shouldAbortQueuingTasks = true;
|
||||||
|
|
||||||
|
await _storageRepository.clearCache();
|
||||||
await _uploadRepository.reset(kBackupGroup);
|
await _uploadRepository.reset(kBackupGroup);
|
||||||
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ String getAlbumThumbNailCacheKey(final Album album, {AssetMediaSize type = Asset
|
|||||||
}
|
}
|
||||||
|
|
||||||
String getOriginalUrlForRemoteId(final String id) {
|
String getOriginalUrlForRemoteId(final String id) {
|
||||||
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original';
|
return ImageUrlBuilder.build('/assets/$id/original');
|
||||||
}
|
}
|
||||||
|
|
||||||
String getImageCacheKey(final Asset asset) {
|
String getImageCacheKey(final Asset asset) {
|
||||||
@@ -46,16 +46,51 @@ String getImageCacheKey(final Asset asset) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String getThumbnailUrlForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
|
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) =>
|
String getPreviewUrlForRemoteId(final String id) {
|
||||||
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}';
|
return ImageUrlBuilder.build('/assets/$id/thumbnail?size=${AssetMediaSize.preview}');
|
||||||
|
}
|
||||||
|
|
||||||
String getPlaybackUrlForRemoteId(final String id) {
|
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) {
|
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,
|
1,
|
||||||
),
|
),
|
||||||
trailing: Icon(Icons.edit_outlined, size: 18, color: context.primaryColor),
|
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,
|
onTap: pickDate,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|||||||
@@ -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/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.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/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/providers/sync_status.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
|
||||||
import 'package:path/path.dart' as path;
|
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>>(
|
return FutureBuilder<List<dynamic>>(
|
||||||
future: loadCounts(),
|
future: loadCounts(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
@@ -241,6 +246,14 @@ class BetaSyncSettings extends HookConsumerWidget {
|
|||||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
_SectionHeaderText(text: "actions".t(context: context)),
|
_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(
|
ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
"export_database".t(context: context),
|
"export_database".t(context: context),
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ void main() {
|
|||||||
registerFallbackValue(LocalAssetStub.image1);
|
registerFallbackValue(LocalAssetStub.image1);
|
||||||
|
|
||||||
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
|
||||||
|
when(() => mockStorageRepo.clearCache()).thenAnswer((_) async => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('HashService hashAssets', () {
|
group('HashService hashAssets', () {
|
||||||
|
|||||||
Generated
+4
-4
@@ -11,7 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@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",
|
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.11.5",
|
"@photo-sphere-viewer/core": "^5.11.5",
|
||||||
@@ -1357,9 +1357,9 @@
|
|||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@immich/ui": {
|
"node_modules/@immich/ui": {
|
||||||
"version": "0.23.5",
|
"version": "0.23.6",
|
||||||
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.5.tgz",
|
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.6.tgz",
|
||||||
"integrity": "sha512-1wlFMmfDmtGC+Kcc8cYTT00mQaSumR41KEOOOmVn5Rw/8z9pUhpNY8mGl1AxY4qhtnaz+G3dH6vowYzL23D+YQ==",
|
"integrity": "sha512-HYIguDx/nCXcvqLKhY1R/+Aks6mn8B9jIiNVQH6WODDPbvGFrvQT5uINhXHrjsdyuzKBVS6dps+lx9+9Z6z4rA==",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@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",
|
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.11.5",
|
"@photo-sphere-viewer/core": "^5.11.5",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
import Portal from '../portal/portal.svelte';
|
import Portal from '../portal/portal.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
initialAssetId?: string;
|
||||||
assets: (TimelineAsset | AssetResponseDto)[];
|
assets: (TimelineAsset | AssetResponseDto)[];
|
||||||
assetInteraction: AssetInteraction;
|
assetInteraction: AssetInteraction;
|
||||||
disableAssetSelect?: boolean;
|
disableAssetSelect?: boolean;
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
initialAssetId = undefined,
|
||||||
assets = $bindable(),
|
assets = $bindable(),
|
||||||
assetInteraction,
|
assetInteraction,
|
||||||
disableAssetSelect = false,
|
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 shiftKeyIsDown = $state(false);
|
||||||
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
|
||||||
let slidingWindow = $state({ top: 0, bottom: 0 });
|
let slidingWindow = $state({ top: 0, bottom: 0 });
|
||||||
@@ -150,8 +159,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const viewAssetHandler = async (asset: TimelineAsset) => {
|
const viewAssetHandler = async (asset: TimelineAsset) => {
|
||||||
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
|
currentIndex = assets.findIndex((a) => a.id == asset.id);
|
||||||
await setAssetId(assets[currentViewAssetIndex].id);
|
await setAssetId(assets[currentIndex].id);
|
||||||
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -324,12 +333,12 @@
|
|||||||
if (onNext) {
|
if (onNext) {
|
||||||
asset = await onNext();
|
asset = await onNext();
|
||||||
} else {
|
} else {
|
||||||
if (currentViewAssetIndex >= assets.length - 1) {
|
if (currentIndex >= assets.length - 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentViewAssetIndex = currentViewAssetIndex + 1;
|
currentIndex = currentIndex + 1;
|
||||||
asset = currentViewAssetIndex < assets.length ? assets[currentViewAssetIndex] : undefined;
|
asset = currentIndex < assets.length ? assets[currentIndex] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -374,12 +383,12 @@
|
|||||||
if (onPrevious) {
|
if (onPrevious) {
|
||||||
asset = await onPrevious();
|
asset = await onPrevious();
|
||||||
} else {
|
} else {
|
||||||
if (currentViewAssetIndex <= 0) {
|
if (currentIndex <= 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentViewAssetIndex = currentViewAssetIndex - 1;
|
currentIndex = currentIndex - 1;
|
||||||
asset = currentViewAssetIndex >= 0 ? assets[currentViewAssetIndex] : undefined;
|
asset = currentIndex >= 0 ? assets[currentIndex] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
@@ -412,10 +421,10 @@
|
|||||||
);
|
);
|
||||||
if (assets.length === 0) {
|
if (assets.length === 0) {
|
||||||
await goto(AppRoute.PHOTOS);
|
await goto(AppRoute.PHOTOS);
|
||||||
} else if (currentViewAssetIndex === assets.length) {
|
} else if (currentIndex === assets.length) {
|
||||||
await handlePrevious();
|
await handlePrevious();
|
||||||
} else {
|
} else {
|
||||||
await setAssetId(assets[currentViewAssetIndex].id);
|
await setAssetId(assets[currentIndex].id);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
||||||
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
|
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
|
||||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
|
||||||
import type { Viewport } from '$lib/managers/timeline-manager/types';
|
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 { foldersStore } from '$lib/stores/folders.svelte';
|
||||||
import { preferences } from '$lib/stores/user.store';
|
import { preferences } from '$lib/stores/user.store';
|
||||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||||
@@ -40,7 +40,6 @@
|
|||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const viewport: Viewport = $state({ width: 0, height: 0 });
|
const viewport: Viewport = $state({ width: 0, height: 0 });
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
|
|
||||||
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
|
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
|
||||||
@@ -104,6 +103,7 @@
|
|||||||
{#if data.pathAssets && data.pathAssets.length > 0}
|
{#if data.pathAssets && data.pathAssets.length > 0}
|
||||||
<div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2">
|
<div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2">
|
||||||
<GalleryViewer
|
<GalleryViewer
|
||||||
|
initialAssetId={data.asset?.id}
|
||||||
assets={data.pathAssets}
|
assets={data.pathAssets}
|
||||||
{assetInteraction}
|
{assetInteraction}
|
||||||
{viewport}
|
{viewport}
|
||||||
|
|||||||
Reference in New Issue
Block a user