Compare commits

...

9 Commits

Author SHA1 Message Date
bwees
cc9ec29c83 handle more interactions like uploading and single asset links 2025-08-01 08:21:35 -05:00
bwees
c8c6f86518 feat: view shared links inside of mobile app 2025-07-31 13:38:14 -05:00
Daniel Dietzler
f972b8d514 fix: modal race conditions (#20460) 2025-07-31 07:28:45 -05:00
Jason Rasmussen
6b50d958f4 fix: incorrect next/previous action after folder view refresh (#20447) 2025-07-30 14:50:52 -05:00
Alex
27c456eb75 fix: people navigation (#20450) 2025-07-30 14:47:47 -05:00
Brandon Wees
e7d051db3c feat: drift edit time and date action (#20377)
* feat: drift edit time and date action

* feat: add edit button on asset viewer bottom sheet

* update localDateTime column in addition to createdAt to keep consistency

* fix: dont update local dateTime

Server calcs this anyway and it will be synced when the change is applied. We don't use localDateTime on mobile so there is no reason to update this value

* fix: padding around edit icon in ListTile

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>

* chore: format

* fix: hide date edit control when asset does not have a remote

* fix: pull timezones correctly from image

---------

Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com>
2025-07-30 14:40:13 -05:00
Peter Ombodi
86d31d7d29 fix(download): handle completed downloads and refresh albums (#20241)
* fix(download): handle completed downloads and refresh albums

* fix(download): remove use of outdated AlbumService

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2025-07-30 14:33:55 -05:00
shenlong
f416342eff fix: clear local file cache before upload (#20448)
* clear local file cache before upload

* clear cache during hashing

* fix test

* add button to clear cache manually

* add button to clear cache manually

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-07-30 14:32:38 -05:00
Cédric
d73335ecbc docs: add config example for Authelia (#20223) 2025-07-30 19:13:19 +00:00
43 changed files with 895 additions and 90 deletions

View File

@@ -64,7 +64,7 @@ Once you have a new OAuth client application configured, Immich can be configure
| Storage Label Claim | string | preferred_username | Claim mapping for the user's storage label**¹** |
| Role Claim | string | immich_role | Claim mapping for the user's role. (should return "user" or "admin")**¹** |
| Storage Quota Claim | string | immich_quota | Claim mapping for the user's storage**¹** |
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (Enter 0 for unlimited quota) |
| Default Storage Quota (GiB) | number | 0 | Default quota for user without storage quota claim (empty for unlimited quota) |
| Button Text | string | Login with OAuth | Text for the OAuth button on the web |
| Auto Register | boolean | true | When true, will automatically register a user the first time they sign in |
| [Auto Launch](#auto-launch) | boolean | false | When true, will skip the login page and automatically start the OAuth login process |
@@ -106,6 +106,89 @@ Immich has a route (`/api/oauth/mobile-redirect`) that is already configured to
## Example Configuration
<details>
<summary>Authelia Example</summary>
### Authelia Example
Here's an example of OAuth configured for Authelia:
This assumes there exist an attribute `immichquota` in the user schema, which is used to set the user's storage quota in Immich.
The configuration concerning the quota is optional.
```yaml
authentication_backend:
ldap:
# The LDAP server configuration goes here.
# See: https://www.authelia.com/c/ldap
attributes:
extra:
immichquota: # The attribute name from LDAP
name: 'immich_quota'
multi_valued: false
value_type: 'integer'
identity_providers:
oidc:
## The other portions of the mandatory OpenID Connect 1.0 configuration go here.
## See: https://www.authelia.com/c/oidc
claims_policies:
immich_policy:
custom_claims:
immich_quota:
attribute: 'immich_quota'
scopes:
immich_scope:
claims:
- 'immich_quota'
clients:
- client_id: 'immich'
client_name: 'Immich'
# https://www.authelia.com/integration/openid-connect/frequently-asked-questions/#how-do-i-generate-a-client-identifier-or-client-secret
client_secret: $pbkdf2-sha512$310000$c8p78n7pUMln0jzvd4aK4Q$JNRBzwAo0ek5qKn50cFzzvE9RXV88h1wJn5KGiHrD0YKtZaR/nCb2CJPOsKaPK0hjf.9yHxzQGZziziccp6Yng'
public: false
require_pkce: false
redirect_uris:
- 'https://example.immich.app/auth/login'
- 'https://example.immich.app/user-settings'
- 'app.immich:///oauth-callback'
scopes:
- 'openid'
- 'profile'
- 'email'
- 'immich_scope'
claims_policy: 'immich_policy'
response_types:
- 'code'
grant_types:
- 'authorization_code'
id_token_signed_response_alg: 'RS256'
userinfo_signed_response_alg: 'RS256'
token_endpoint_auth_method: 'client_secret_post'
```
Configuration of OAuth in Immich System Settings
| Setting | Value |
| ---------------------------------- | ------------------------------------------------------------------- |
| Issuer URL | `https://example.immich.app/.well-known/openid-configuration` |
| Client ID | immich |
| Client Secret | 0v89FXkQOWO\***\*\*\*\*\***\*\*\***\*\*\*\*\***mprbvXD549HH6s1iw... |
| Token Endpoint Auth Method | client_secret_post |
| Scope | openid email profile immich_scope |
| ID Token Signed Response Algorithm | RS256 |
| Userinfo Signed Response Algorithm | RS256 |
| Storage Label Claim | uid |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Authelia (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
| Mobile Redirect URI Override | Disable |
| Mobile Redirect URI | |
</details>
<details>
<summary>Authentik Example</summary>
@@ -128,7 +211,7 @@ Configuration of OAuth in Immich System Settings
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Authentik (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled (optional) |
@@ -159,7 +242,7 @@ Configuration of OAuth in Immich System Settings
| Signing Algorithm | RS256 |
| Storage Label Claim | preferred_username |
| Storage Quota Claim | immich_quota |
| Default Storage Quota (GiB) | 0 (0 for unlimited quota) |
| Default Storage Quota (GiB) | 0 (empty for unlimited quota) |
| Button Text | Sign in with Google (optional) |
| Auto Register | Enabled (optional) |
| Auto Launch | Enabled |

View File

@@ -653,6 +653,7 @@
"clear": "Clear",
"clear_all": "Clear all",
"clear_all_recent_searches": "Clear all recent searches",
"clear_file_cache": "Clear File Cache",
"clear_message": "Clear message",
"clear_value": "Clear value",
"client_cert_dialog_msg_confirm": "OK",
@@ -834,6 +835,7 @@
"edit_birthday": "Edit Birthday",
"edit_date": "Edit date",
"edit_date_and_time": "Edit date and time",
"edit_date_and_time_action_prompt": "{count} date and time edited",
"edit_description": "Edit description",
"edit_description_prompt": "Please select a new description:",
"edit_exclusion_pattern": "Edit exclusion pattern",
@@ -1786,9 +1788,15 @@
"shared_link_expires_seconds": "Expires in {count} seconds",
"shared_link_individual_shared": "Individual shared",
"shared_link_info_chip_metadata": "EXIF",
"shared_link_invalid_password": "Invalid password",
"shared_link_manage_links": "Manage Shared links",
"shared_link_options": "Shared link options",
"shared_link_password_description": "Require a password to access this shared link",
"shared_link_password_dialog_content": "Enter the password for the shared link.",
"shared_link_password_dialog_title": "Shared Link Password",
"shared_link": "Shared link",
"shared_link_upload": "Uploaded to shared link",
"shared_link_download": "Downloaded from shared link",
"shared_links": "Shared links",
"shared_links_description": "Share photos and videos with a link",
"shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}",

View File

@@ -0,0 +1,21 @@
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
class SharedRemoteAlbum extends RemoteAlbum {
final List<RemoteAsset> assets;
const SharedRemoteAlbum({
required super.id,
required this.assets,
required super.name,
required super.ownerId,
required super.description,
required super.createdAt,
required super.updatedAt,
super.thumbnailAssetId,
required super.isActivityEnabled,
required super.order,
required super.assetCount,
required super.ownerName,
});
}

View File

@@ -104,6 +104,7 @@ class HashService {
DLog.log("Hashed ${hashed.length}/${toHash.length} assets");
await _localAssetRepository.updateHashes(hashed);
await _storageRepository.clearCache();
}
}

View File

@@ -0,0 +1,34 @@
import 'dart:async';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/domain/models/album/shared_album.model.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
class RemoteSharedAlbumService {
final DriftAlbumApiRepository _albumApiRepository;
final AssetApiRepository _assetApiRepository;
const RemoteSharedAlbumService(this._albumApiRepository, this._assetApiRepository);
Future<SharedRemoteAlbum?> getSharedAlbum(String albumId) {
return _albumApiRepository.getShared(albumId);
}
Future<int> uploadAssets(String albumId, List<XFile> files) async {
// Start all uploads concurrently
final uploadFutures = files.map((file) => _assetApiRepository.uploadAsset(file)).toList();
// Wait for all uploads to complete
final assetIds = await Future.wait(uploadFutures);
// Filter out null assetIds
final completedUploads = assetIds.whereType<String>().toList();
if (completedUploads.isNotEmpty) {
await _albumApiRepository.addAssets(albumId, completedUploads);
}
return completedUploads.length;
}
}

View File

@@ -1,10 +1,9 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/search_result.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
class SearchService {
@@ -52,41 +51,3 @@ class SearchService {
return null;
}
}
extension on AssetResponseDto {
RemoteAsset toDto() {
return RemoteAsset(
id: id,
name: originalFileName,
checksum: checksum,
createdAt: fileCreatedAt,
updatedAt: updatedAt,
ownerId: ownerId,
visibility: switch (visibility) {
api.AssetVisibility.timeline => AssetVisibility.timeline,
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toAssetType(),
);
}
}
extension on AssetTypeEnum {
AssetType toAssetType() => switch (this) {
AssetTypeEnum.IMAGE => AssetType.image,
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}

View File

@@ -186,6 +186,23 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
});
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime) {
return _db.batch((batch) async {
for (final id in ids) {
batch.update(
_db.remoteExifEntity,
RemoteExifEntityCompanion(dateTimeOriginal: Value(dateTime)),
where: (e) => e.assetId.equals(id),
);
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(createdAt: Value(dateTime)),
where: (e) => e.id.equals(id),
);
}
});
}
Future<void> stack(String userId, StackResponse stack) {
return _db.transaction(() async {
final stackIds = await _db.managers.stackEntity

View File

@@ -66,4 +66,14 @@ class StorageRepository {
}
return entity;
}
Future<void> clearCache() async {
final log = Logger('StorageRepository');
try {
await PhotoManager.clearFileCache();
} catch (error, stackTrace) {
log.warning("Error clearing cache", error, stackTrace);
}
}
}

View File

@@ -1,3 +1,5 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:openapi/api.dart';
enum SharedLinkSource { album, individual }
@@ -14,6 +16,8 @@ class SharedLink {
final String key;
final bool showMetadata;
final SharedLinkSource type;
final List<RemoteAsset> assets;
final String? albumId;
const SharedLink({
required this.id,
@@ -27,6 +31,8 @@ class SharedLink {
required this.key,
required this.showMetadata,
required this.type,
this.assets = const [],
this.albumId,
});
SharedLink copyWith({
@@ -74,7 +80,9 @@ class SharedLink {
? dto.album?.albumThumbnailAssetId
: dto.assets.isNotEmpty
? dto.assets[0].id
: null;
: null,
assets = dto.assets.map((asset) => asset.toDto()).toList(),
albumId = dto.album?.id;
@override
String toString() =>

View File

@@ -0,0 +1,218 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/domain/models/album/shared_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/remote_shared_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/shared_link/shared_link.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/shared_link.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/shared_link_password_dialog.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart';
@RoutePage()
class RemoteSharedLinkPage extends ConsumerStatefulWidget {
final String shareKey;
final String endpoint;
const RemoteSharedLinkPage({super.key, required this.shareKey, required this.endpoint});
@override
ConsumerState<RemoteSharedLinkPage> createState() => _RemoteSharedLinkPageState();
}
class _RemoteSharedLinkPageState extends ConsumerState<RemoteSharedLinkPage> {
late final ApiService _apiService;
SharedLink? sharedLink;
List<RemoteAsset>? assets;
SharedRemoteAlbum? sharedAlbum;
late final RemoteSharedAlbumService sharedAlbumService;
@override
void initState() {
super.initState();
String endpoint = widget.endpoint;
if (!endpoint.endsWith('/api')) {
endpoint += '/api';
}
ImageUrlBuilder.setHost(endpoint);
ImageUrlBuilder.setParameter('key', widget.shareKey);
_apiService = ApiService.shared(endpoint, widget.shareKey);
final assetApiRepository = AssetApiRepository(
_apiService.assetsApi,
_apiService.searchApi,
_apiService.stacksApi,
_apiService.trashApi,
);
final driftApiRepository = DriftAlbumApiRepository(_apiService.albumsApi);
sharedAlbumService = RemoteSharedAlbumService(driftApiRepository, assetApiRepository);
retrieveSharedLink();
}
@override
void dispose() {
ImageUrlBuilder.clear();
super.dispose();
}
Future<void> retrieveSharedLink() async {
try {
sharedLink = await SharedLinkService(_apiService).getMySharedLink();
} on ApiException catch (error, _) {
if (error.code == 401 && error.message != null && error.message!.contains("Invalid password")) {
final password = await showDialog<String>(
context: context,
builder: (context) => const SharedLinkPasswordDialog(),
);
if (password == null) {
context.pop();
}
try {
sharedLink = await SharedLinkService(_apiService).getMySharedLink(password: password);
} catch (e) {
ImmichToast.show(
context: context,
msg: "errors.shared_link_invalid_password".t(context: context),
toastType: ToastType.error,
);
context.pop();
return;
}
}
}
if (sharedLink == null) {
ImmichToast.show(
context: context,
msg: "errors.unable_to_get_shared_link".t(context: context),
toastType: ToastType.error,
);
context.pop();
return;
}
_refreshAssets();
setState(() {});
}
Future<List<RemoteAsset>> retrieveSharedAlbumAssets() async {
try {
sharedAlbum = await sharedAlbumService.getSharedAlbum(sharedLink!.albumId!);
return sharedAlbum?.assets ?? [];
} catch (e) {
ImmichToast.show(
context: context,
msg: "errors.failed_to_load_assets".t(context: context),
toastType: ToastType.error,
);
return [];
}
}
Future<void> _refreshAssets() async {
// Retrieve assets from the shared link
switch (sharedLink!.type) {
case SharedLinkSource.album:
assets = await retrieveSharedAlbumAssets();
break;
case SharedLinkSource.individual:
assets = sharedLink!.assets;
if (!(sharedLink!.allowUpload)) {
context.replaceRoute(
AssetViewerRoute(
initialIndex: 0,
timelineService: ref.read(timelineFactoryProvider).fromAssets(sharedLink!.assets),
),
);
}
break;
}
setState(() {});
}
@override
Widget build(BuildContext context) {
Future<void> addAssets() async {
final List<XFile> uploadAssets = await ImagePicker().pickMultipleMedia();
if (uploadAssets.isEmpty) {
return;
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text("uploading".t(context: context)),
content: const SizedBox(height: 48, child: Center(child: CircularProgressIndicator())),
),
);
await sharedAlbumService.uploadAssets(sharedAlbum!.id, uploadAssets);
sharedAlbum = await sharedAlbumService.getSharedAlbum(sharedAlbum!.id);
_refreshAssets();
// close the dialog
context.pop();
}
if (assets == null) {
return const Center(child: CircularProgressIndicator());
}
return ProviderScope(
key: ValueKey(assets?.length),
overrides: [
apiServiceProvider.overrideWith((ref) => _apiService),
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets!);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
topSliverWidgetHeight: 0,
topSliverWidget: const SliverToBoxAdapter(child: SizedBox.shrink()),
groupBy: GroupAssetsBy.none,
bottomSheet: null,
appBar: SliverToBoxAdapter(
child: AppBar(
title: Text(sharedAlbum?.name ?? "shared_link".t(context: context)),
actions: [
if (sharedLink!.allowUpload)
IconButton(
icon: const Icon(Icons.cloud_upload),
onPressed: () => addAssets(),
tooltip: "shared_link_upload".t(context: context),
),
],
),
),
),
);
}
}

View File

@@ -1,10 +1,44 @@
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class EditDateTimeActionButton extends ConsumerWidget {
const EditDateTimeActionButton({super.key});
final ActionSource source;
const EditDateTimeActionButton({super.key, required this.source});
_onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final result = await ref.read(actionProvider.notifier).editDateTime(source, context);
if (result == null) {
return;
}
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'edit_date_and_time_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},
);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -12,6 +46,7 @@ class EditDateTimeActionButton extends ConsumerWidget {
maxWidth: 95.0,
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".t(context: context),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@@ -143,12 +143,18 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final cameraTitle = _getCameraInfoTitle(exifInfo);
Future<void> editDateTime() async {
await ref.read(actionProvider.notifier).editDateTime(ActionSource.viewer, context);
}
return SliverList.list(
children: [
// Asset Date and Time
_SheetTile(
title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote ? () async => await editDateTime() : null,
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
const SheetPeopleDetails(),
@@ -194,11 +200,21 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
class _SheetTile extends StatelessWidget {
final String title;
final Widget? leading;
final Widget? trailing;
final String? subtitle;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
final VoidCallback? onTap;
const _SheetTile({required this.title, this.titleStyle, this.leading, this.subtitle, this.subtitleStyle});
const _SheetTile({
required this.title,
this.titleStyle,
this.leading,
this.subtitle,
this.subtitleStyle,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
@@ -234,8 +250,10 @@ class _SheetTile extends StatelessWidget {
title: titleWidget,
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
);
}
}

View File

@@ -79,7 +79,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
context.back();
return;
}
context.back();
context.pop();
context.pushRoute(DriftPersonRoute(person: person));
},
onNameTap: () => showNameEditModal(person),

View File

@@ -40,7 +40,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View File

@@ -40,7 +40,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View File

@@ -76,7 +76,7 @@ class GeneralBottomSheet extends ConsumerWidget {
if (multiselect.hasLocal || multiselect.hasMerged) ...[
const DeleteLocalActionButton(source: ActionSource.timeline),
],
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View File

@@ -43,7 +43,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget {
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
const EditDateTimeActionButton(),
const EditDateTimeActionButton(source: ActionSource.timeline),
const EditLocationActionButton(source: ActionSource.timeline),
const MoveToLockFolderActionButton(source: ActionSource.timeline),
const StackActionButton(source: ActionSource.timeline),

View File

@@ -4,5 +4,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'api.provider.g.dart';
@Riverpod(keepAlive: true)
@Riverpod(keepAlive: true, dependencies: [])
ApiService apiService(Ref _) => ApiService();

View File

@@ -6,7 +6,7 @@ part of 'api.provider.dart';
// RiverpodGenerator
// **************************************************************************
String _$apiServiceHash() => r'187a7de59b064fab1104c23717f18ce0ae3e426c';
String _$apiServiceHash() => r'46d8a043f41b85f36f56d81e5261c842cb5c0c06';
/// See also [apiService].
@ProviderFor(apiService)
@@ -16,8 +16,8 @@ final apiServiceProvider = Provider<ApiService>.internal(
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$apiServiceHash,
dependencies: null,
allTransitiveDependencies: null,
dependencies: const <ProviderOrFamily>[],
allTransitiveDependencies: const <ProviderOrFamily>{},
);
@Deprecated('Will be removed in 3.0. Use Ref instead')

View File

@@ -28,7 +28,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
ref.watch(secureStorageServiceProvider),
ref.watch(widgetServiceProvider),
);
});
}, dependencies: [apiServiceProvider]);
class AuthNotifier extends StateNotifier<AuthState> {
final AuthService _authService;

View File

@@ -2,10 +2,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' as old_asset_entity;
import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/gcast.service.dart';
final castProvider = StateNotifierProvider<CastNotifier, CastManagerState>(
(ref) => CastNotifier(ref.watch(gCastServiceProvider)),
dependencies: [apiServiceProvider],
);
class CastNotifier extends StateNotifier<CastManagerState> {

View File

@@ -1,10 +1,13 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
@@ -30,6 +33,7 @@ class ActionNotifier extends Notifier<void> {
final Logger _logger = Logger('ActionNotifier');
late ActionService _service;
late UploadService _uploadService;
late DownloadService _downloadService;
ActionNotifier() : super();
@@ -37,6 +41,29 @@ class ActionNotifier extends Notifier<void> {
void build() {
_uploadService = ref.watch(uploadServiceProvider);
_service = ref.watch(actionServiceProvider);
_downloadService = ref.watch(downloadServiceProvider);
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
}
void _downloadImageCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
_downloadService.saveImageWithPath(update.task);
}
}
void _downloadVideoCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
_downloadService.saveVideo(update.task);
}
}
void _downloadLivePhotoCallback(TaskStatusUpdate update) async {
if (update.status == TaskStatus.complete) {
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
_downloadService.saveLivePhotos(update.task, livePhotosId);
}
}
List<String> _getRemoteIdsForSource(ActionSource source) {
@@ -239,6 +266,21 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult?> editDateTime(ActionSource source, BuildContext context) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
final isEdited = await _service.editDateTime(ids, context);
if (!isEdited) {
return null;
}
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to edit date and time for assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
Future<ActionResult> removeFromAlbum(ActionSource source, String albumId) async {
final ids = _getRemoteIdsForSource(source);
try {

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/local_album.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
@@ -31,7 +32,7 @@ final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
final remoteAlbumServiceProvider = Provider<RemoteAlbumService>(
(ref) => RemoteAlbumService(ref.watch(remoteAlbumRepository), ref.watch(driftAlbumApiRepositoryProvider)),
dependencies: [remoteAlbumRepository],
dependencies: [remoteAlbumRepository, apiServiceProvider],
);
final remoteAlbumProvider = NotifierProvider<RemoteAlbumNotifier, RemoteAlbumState>(

View File

@@ -1,12 +1,16 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart' hide AssetType;
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
import 'package:openapi/api.dart' as api show AssetVisibility;
import 'package:openapi/api.dart' hide AssetVisibility;
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(
@@ -66,6 +70,10 @@ class AssetApiRepository extends ApiRepository {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, latitude: location.latitude, longitude: location.longitude));
}
Future<void> updateDateTime(List<String> ids, DateTime dateTime) async {
return _api.updateAssets(AssetBulkUpdateDto(ids: ids, dateTimeOriginal: dateTime.toIso8601String()));
}
Future<StackResponse> stack(List<String> ids) async {
final responseDto = await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids)));
@@ -97,6 +105,20 @@ class AssetApiRepository extends ApiRepository {
Future<void> updateDescription(String assetId, String description) {
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
}
Future<String?> uploadAsset(XFile file) async {
final lastModified = await file.lastModified();
final deviceAssetId = "MOBILE-${file.name}-${lastModified.millisecondsSinceEpoch}";
final multipart = MultipartFile.fromBytes(
'assetData', // field should be 'assetData' to match the backend API
await file.readAsBytes(),
filename: file.name,
);
final asset = await _api.uploadAsset(multipart, deviceAssetId, "MOBILE", lastModified, lastModified);
return asset?.id;
}
}
extension on StackResponseDto {
@@ -104,3 +126,41 @@ extension on StackResponseDto {
return StackResponse(id: id, primaryAssetId: primaryAssetId, assetIds: assets.map((asset) => asset.id).toList());
}
}
extension RemoteAssetDtoExt on AssetResponseDto {
RemoteAsset toDto() {
return RemoteAsset(
id: id,
name: originalFileName,
checksum: checksum,
createdAt: fileCreatedAt,
updatedAt: updatedAt,
ownerId: ownerId,
visibility: switch (visibility) {
api.AssetVisibility.timeline => AssetVisibility.timeline,
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
},
durationInSeconds: duration.toDuration()?.inSeconds ?? 0,
height: exifInfo?.exifImageHeight?.toInt(),
width: exifInfo?.exifImageWidth?.toInt(),
isFavorite: isFavorite,
livePhotoVideoId: livePhotoVideoId,
thumbHash: thumbhash,
localId: null,
type: type.toType(),
);
}
}
extension on AssetTypeEnum {
AssetType toType() => switch (this) {
AssetTypeEnum.IMAGE => AssetType.image,
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}

View File

@@ -1,7 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/shared_album.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
// ignore: import_rule_openapi
import 'package:openapi/api.dart';
@@ -87,6 +89,11 @@ class DriftAlbumApiRepository extends ApiRepository {
final response = await checkNull(_api.addUsersToAlbum(albumId, AddUsersDto(albumUsers: albumUsers)));
return response.toRemoteAlbum();
}
Future<SharedRemoteAlbum?> getShared(String albumId) async {
final responseDto = await checkNull(_api.getAlbumInfo(albumId));
return responseDto.toSharedRemoteAlbum();
}
}
extension on AlbumResponseDto {
@@ -105,4 +112,21 @@ extension on AlbumResponseDto {
ownerName: owner.name,
);
}
SharedRemoteAlbum toSharedRemoteAlbum() {
return SharedRemoteAlbum(
id: id,
name: albumName,
ownerId: owner.id,
description: description,
createdAt: createdAt,
updatedAt: updatedAt,
thumbnailAssetId: albumThumbnailAssetId,
isActivityEnabled: isActivityEnabled,
order: order == AssetOrder.asc ? AlbumAssetOrder.asc : AlbumAssetOrder.desc,
assetCount: assetCount,
ownerName: owner.name,
assets: assets.map((e) => e.toDto()).toList(),
);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/services/store.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -17,6 +18,11 @@ class AuthGuard extends AutoRouteGuard {
void onNavigation(NavigationResolver resolver, StackRouter router) async {
resolver.next(true);
if (ImageUrlBuilder.isSharedLink()) {
// If the URL is a shared link, we don't need to validate the access token
return;
}
try {
// Look in the store for an access token
Store.get(StoreKey.accessToken);

View File

@@ -23,11 +23,11 @@ import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart'
import 'package:immich_mobile/pages/album/album_viewer.page.dart';
import 'package:immich_mobile/pages/albums/albums.page.dart';
import 'package:immich_mobile/pages/backup/album_preview.page.dart';
import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart';
import 'package:immich_mobile/pages/backup/drift_backup.page.dart';
import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart';
import 'package:immich_mobile/pages/backup/backup_controller.page.dart';
import 'package:immich_mobile/pages/backup/backup_options.page.dart';
import 'package:immich_mobile/pages/backup/drift_backup.page.dart';
import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart';
import 'package:immich_mobile/pages/backup/drift_backup_options.page.dart';
import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart';
import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart';
@@ -95,11 +95,12 @@ import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
import 'package:immich_mobile/presentation/pages/drift_place.page.dart';
import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/remote_shared_link.dart';
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/api.provider.dart';
@@ -329,6 +330,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: RemoteSharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -2167,6 +2167,58 @@ class RemoteMediaSummaryRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [RemoteSharedLinkPage]
class RemoteSharedLinkRoute extends PageRouteInfo<RemoteSharedLinkRouteArgs> {
RemoteSharedLinkRoute({
Key? key,
required String shareKey,
required String endpoint,
List<PageRouteInfo>? children,
}) : super(
RemoteSharedLinkRoute.name,
args: RemoteSharedLinkRouteArgs(
key: key,
shareKey: shareKey,
endpoint: endpoint,
),
initialChildren: children,
);
static const String name = 'RemoteSharedLinkRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<RemoteSharedLinkRouteArgs>();
return RemoteSharedLinkPage(
key: args.key,
shareKey: args.shareKey,
endpoint: args.endpoint,
);
},
);
}
class RemoteSharedLinkRouteArgs {
const RemoteSharedLinkRouteArgs({
this.key,
required this.shareKey,
required this.endpoint,
});
final Key? key;
final String shareKey;
final String endpoint;
@override
String toString() {
return 'RemoteSharedLinkRouteArgs{key: $key, shareKey: $shareKey, endpoint: $endpoint}';
}
}
/// generated route for
/// [SearchPage]
class SearchRoute extends PageRouteInfo<SearchRouteArgs> {

View File

@@ -13,6 +13,7 @@ import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -159,6 +160,44 @@ class ActionService {
return true;
}
Future<bool> editDateTime(List<String> remoteIds, BuildContext context) async {
DateTime? initialDate;
String? timeZone;
Duration? offset;
if (remoteIds.length == 1) {
final assetId = remoteIds.first;
final asset = await _remoteAssetRepository.get(assetId);
if (asset == null) {
return false;
}
final exifData = await _remoteAssetRepository.getExif(assetId);
initialDate = asset.createdAt.toLocal();
offset = initialDate.timeZoneOffset;
timeZone = exifData?.timeZone;
}
final dateTime = await showDateTimePicker(
context: context,
initialDateTime: initialDate,
initialTZ: timeZone,
initialTZOffset: offset,
);
if (dateTime == null) {
return false;
}
// convert dateTime to DateTime object
final parsedDateTime = DateTime.parse(dateTime);
await _assetApiRepository.updateDateTime(remoteIds, parsedDateTime);
await _remoteAssetRepository.updateDateTime(remoteIds, parsedDateTime);
return true;
}
Future<int> removeFromAlbum(List<String> remoteIds, String albumId) async {
int removedCount = 0;
final result = await _albumApiRepository.removeAssets(albumId, remoteIds);

View File

@@ -8,9 +8,9 @@ import 'package:http/http.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/user_agent.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/user_agent.dart';
class ApiService implements Authentication {
late ApiClient _apiClient;
@@ -45,7 +45,14 @@ class ApiService implements Authentication {
setEndpoint(endpoint);
}
}
ApiService.shared(String endpoint, String sharedKey) {
setEndpoint(endpoint);
_queryParams = {'key': sharedKey};
}
String? _accessToken;
Map<String, String>? _queryParams;
final _log = Logger("ApiService");
setEndpoint(String endpoint) {
@@ -208,6 +215,8 @@ class ApiService implements Authentication {
return Future<void>(() {
var headers = ApiService.getRequestHeaders();
headerParams.addAll(headers);
queryParams.addAll(_queryParams?.entries.map((e) => QueryParam(e.key, e.value)) ?? []);
});
}

View File

@@ -6,8 +6,8 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart' as beta_asset_provider;
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -80,6 +80,7 @@ class DeepLinkService {
"memory" => await _buildMemoryDeepLink(queryParams['id'] ?? ''),
"asset" => await _buildAssetDeepLink(queryParams['id'] ?? ''),
"album" => await _buildAlbumDeepLink(queryParams['id'] ?? ''),
"sharedlink" => await _buildSharedLinkDeepLink(queryParams['key'] ?? '', queryParams['instanceUrl'] ?? ''),
_ => null,
};
@@ -99,18 +100,24 @@ class DeepLinkService {
final path = link.uri.path;
const uuidRegex = r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
const b64Regex = r'^[A-Za-z0-9_-]+$';
final assetRegex = RegExp('/photos/($uuidRegex)');
final albumRegex = RegExp('/albums/($uuidRegex)');
final sharedLinkRegex = RegExp('/share/($b64Regex)');
PageRouteInfo<dynamic>? deepLinkRoute;
if (assetRegex.hasMatch(path)) {
final assetId = assetRegex.firstMatch(path)?.group(1) ?? '';
deepLinkRoute = await _buildAssetDeepLink(assetId);
} else if (albumRegex.hasMatch(path)) {
final albumId = albumRegex.firstMatch(path)?.group(1) ?? '';
deepLinkRoute = await _buildAlbumDeepLink(albumId);
} else if (sharedLinkRegex.hasMatch(path)) {
final shareKey = sharedLinkRegex.firstMatch(path)?.group(1) ?? '';
final instanceUrl = link.uri.queryParameters['instanceUrl'] ?? '';
deepLinkRoute = await _buildSharedLinkDeepLink(shareKey, instanceUrl);
}
// Deep link resolution failed, safely handle it based on the app state
if (deepLinkRoute == null) {
if (isColdStart) return DeepLink.defaultPath;
@@ -185,4 +192,8 @@ class DeepLinkService {
return AlbumViewerRoute(albumId: album.id);
}
}
Future<PageRouteInfo?> _buildSharedLinkDeepLink(String shareKey, String instanceUrl) async {
return RemoteSharedLinkRoute(shareKey: shareKey, endpoint: instanceUrl);
}
}

View File

@@ -111,4 +111,13 @@ class SharedLinkService {
}
return null;
}
Future<SharedLink?> getMySharedLink({String? password}) async {
final responseDto = await _apiService.sharedLinksApi.getMySharedLink(password: password);
if (responseDto != null) {
return SharedLink.fromDto(responseDto);
}
return null;
}
}

View File

@@ -99,6 +99,7 @@ class UploadService {
}
Future<void> manualBackup(List<LocalAsset> localAssets) async {
await _storageRepository.clearCache();
List<UploadTask> tasks = [];
for (final asset in localAssets) {
final task = await _getUploadTask(
@@ -120,6 +121,8 @@ class UploadService {
/// Build the upload tasks
/// Enqueue the tasks
Future<void> startBackup(String userId, void Function(EnqueueStatus status) onEnqueueTasks) async {
await _storageRepository.clearCache();
shouldAbortQueuingTasks = false;
final candidates = await _backupRepository.getCandidates(userId);
@@ -159,6 +162,7 @@ class UploadService {
Future<int> cancelBackup() async {
shouldAbortQueuingTasks = true;
await _storageRepository.clearCache();
await _uploadRepository.reset(kBackupGroup);
await _uploadRepository.deleteDatabaseRecords(kBackupGroup);

View File

@@ -36,7 +36,7 @@ String getAlbumThumbNailCacheKey(final Album album, {AssetMediaSize type = Asset
}
String getOriginalUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original';
return ImageUrlBuilder.build('/assets/$id/original');
}
String getImageCacheKey(final Asset asset) {
@@ -46,16 +46,51 @@ String getImageCacheKey(final Asset asset) {
}
String getThumbnailUrlForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}';
return ImageUrlBuilder.build('/assets/$id/thumbnail?size=${type.value}');
}
String getPreviewUrlForRemoteId(final String id) =>
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}';
String getPreviewUrlForRemoteId(final String id) {
return ImageUrlBuilder.build('/assets/$id/thumbnail?size=${AssetMediaSize.preview}');
}
String getPlaybackUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
return ImageUrlBuilder.build('/assets/$id/video/playback?');
}
String getFaceThumbnailUrl(final String personId) {
return '${Store.get(StoreKey.serverEndpoint)}/people/$personId/thumbnail';
return ImageUrlBuilder.build('/people/$personId/thumbnail');
}
class ImageUrlBuilder {
static String? host;
static Map<String, String>? queryParams;
static void setHost(String? host) {
ImageUrlBuilder.host = host;
}
static bool isSharedLink() {
return ImageUrlBuilder.host != null && ImageUrlBuilder.queryParams!.containsKey('key');
}
static void setParameter(String key, String value) {
ImageUrlBuilder.queryParams ??= {};
ImageUrlBuilder.queryParams![key] = value;
}
static String build(String path) {
final endpoint = host ?? Store.get(StoreKey.serverEndpoint);
final uri = Uri.parse('$endpoint$path');
if (queryParams == null || queryParams!.isEmpty) {
return uri.toString();
}
final updatedUri = uri.replace(queryParameters: {...uri.queryParameters, ...queryParams!});
return updatedUri.toString();
}
static void clear() {
ImageUrlBuilder.host = null;
ImageUrlBuilder.queryParams = null;
}
}

View File

@@ -145,7 +145,7 @@ class _DateTimePicker extends HookWidget {
1,
),
trailing: Icon(Icons.edit_outlined, size: 18, color: context.primaryColor),
title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium).tr(),
title: Text(DateFormat("dd-MM-yyyy hh:mm a").format(date.value), style: context.textTheme.bodyMedium),
onTap: pickDate,
),
const SizedBox(height: 24),

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class SharedLinkPasswordDialog extends StatefulWidget {
const SharedLinkPasswordDialog({super.key});
@override
State<SharedLinkPasswordDialog> createState() => _SharedLinkPasswordDialogState();
}
class _SharedLinkPasswordDialogState extends State<SharedLinkPasswordDialog> {
final TextEditingController controller = TextEditingController();
bool isNotEmpty = false;
@override
void initState() {
super.initState();
controller.addListener(() {
setState(() {
isNotEmpty = controller.text.isNotEmpty;
});
});
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
title: const Text("shared_link_password_dialog_title").t(context: context),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("shared_link_password_dialog_content").t(context: context),
const SizedBox(height: 16),
TextField(
controller: controller,
decoration: InputDecoration(hintText: "password".t(context: context)),
obscureText: true,
autofocus: true,
onSubmitted: (value) {
Navigator.pop(context, value);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.secondary),
child: const Text("cancel").t(context: context),
),
TextButton(
onPressed: isNotEmpty
? () {
Navigator.pop(context, controller.text);
}
: null,
child: Text(
"submit".t(context: context),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
);
}
}

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/entity_count_tile.dart';
import 'package:path/path.dart' as path;
@@ -104,6 +105,10 @@ class BetaSyncSettings extends HookConsumerWidget {
}
}
Future<void> clearFileCache() async {
await ref.read(storageRepositoryProvider).clearCache();
}
return FutureBuilder<List<dynamic>>(
future: loadCounts(),
builder: (context, snapshot) {
@@ -241,6 +246,14 @@ class BetaSyncSettings extends HookConsumerWidget {
const Divider(height: 1, indent: 16, endIndent: 16),
const SizedBox(height: 24),
_SectionHeaderText(text: "actions".t(context: context)),
ListTile(
title: Text(
"clear_file_cache".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
leading: const Icon(Icons.playlist_remove_rounded),
onTap: clearFileCache,
),
ListTile(
title: Text(
"export_database".t(context: context),

View File

@@ -40,6 +40,7 @@ void main() {
registerFallbackValue(LocalAssetStub.image1);
when(() => mockAssetRepo.updateHashes(any())).thenAnswer((_) async => {});
when(() => mockStorageRepo.clearCache()).thenAnswer((_) async => {});
});
group('HashService hashAssets', () {

8
web/package-lock.json generated
View File

@@ -11,7 +11,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.23.5",
"@immich/ui": "^0.23.6",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
@@ -1357,9 +1357,9 @@
"link": true
},
"node_modules/@immich/ui": {
"version": "0.23.5",
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.5.tgz",
"integrity": "sha512-1wlFMmfDmtGC+Kcc8cYTT00mQaSumR41KEOOOmVn5Rw/8z9pUhpNY8mGl1AxY4qhtnaz+G3dH6vowYzL23D+YQ==",
"version": "0.23.6",
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.6.tgz",
"integrity": "sha512-HYIguDx/nCXcvqLKhY1R/+Aks6mn8B9jIiNVQH6WODDPbvGFrvQT5uINhXHrjsdyuzKBVS6dps+lx9+9Z6z4rA==",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@mdi/js": "^7.4.47",

View File

@@ -28,7 +28,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.23.5",
"@immich/ui": "^0.23.6",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",

View File

@@ -27,6 +27,7 @@
import Portal from '../portal/portal.svelte';
interface Props {
initialAssetId?: string;
assets: (TimelineAsset | AssetResponseDto)[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
@@ -44,6 +45,7 @@
}
let {
initialAssetId = undefined,
assets = $bindable(),
assetInteraction,
disableAssetSelect = false,
@@ -117,7 +119,14 @@
};
});
let currentViewAssetIndex = 0;
let currentIndex = 0;
if (initialAssetId && assets.length > 0) {
const index = assets.findIndex(({ id }) => id === initialAssetId);
if (index !== -1) {
currentIndex = index;
}
}
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let slidingWindow = $state({ top: 0, bottom: 0 });
@@ -150,8 +159,8 @@
}
});
const viewAssetHandler = async (asset: TimelineAsset) => {
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
await setAssetId(assets[currentViewAssetIndex].id);
currentIndex = assets.findIndex((a) => a.id == asset.id);
await setAssetId(assets[currentIndex].id);
await navigate({ targetRoute: 'current', assetId: $viewingAsset.id });
};
@@ -324,12 +333,12 @@
if (onNext) {
asset = await onNext();
} else {
if (currentViewAssetIndex >= assets.length - 1) {
if (currentIndex >= assets.length - 1) {
return false;
}
currentViewAssetIndex = currentViewAssetIndex + 1;
asset = currentViewAssetIndex < assets.length ? assets[currentViewAssetIndex] : undefined;
currentIndex = currentIndex + 1;
asset = currentIndex < assets.length ? assets[currentIndex] : undefined;
}
if (!asset) {
@@ -374,12 +383,12 @@
if (onPrevious) {
asset = await onPrevious();
} else {
if (currentViewAssetIndex <= 0) {
if (currentIndex <= 0) {
return false;
}
currentViewAssetIndex = currentViewAssetIndex - 1;
asset = currentViewAssetIndex >= 0 ? assets[currentViewAssetIndex] : undefined;
currentIndex = currentIndex - 1;
asset = currentIndex >= 0 ? assets[currentIndex] : undefined;
}
if (!asset) {
@@ -412,10 +421,10 @@
);
if (assets.length === 0) {
await goto(AppRoute.PHOTOS);
} else if (currentViewAssetIndex === assets.length) {
} else if (currentIndex === assets.length) {
await handlePrevious();
} else {
await setAssetId(assets[currentViewAssetIndex].id);
await setAssetId(assets[currentIndex].id);
}
break;
}

View File

@@ -21,8 +21,8 @@
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
import { preferences } from '$lib/stores/user.store';
import { cancelMultiselect } from '$lib/utils/asset-utils';
@@ -40,7 +40,6 @@
let { data }: Props = $props();
const viewport: Viewport = $state({ width: 0, height: 0 });
const assetInteraction = new AssetInteraction();
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
@@ -104,6 +103,7 @@
{#if data.pathAssets && data.pathAssets.length > 0}
<div bind:clientHeight={viewport.height} bind:clientWidth={viewport.width} class="mt-2">
<GalleryViewer
initialAssetId={data.asset?.id}
assets={data.pathAssets}
{assetInteraction}
{viewport}