diff --git a/i18n/en.json b/i18n/en.json index 700ff60c53..285e4c4bd7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1788,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.}}", diff --git a/mobile/lib/domain/services/remote_shared_album.service.dart b/mobile/lib/domain/services/remote_shared_album.service.dart index b72bdeb26c..480691b18c 100644 --- a/mobile/lib/domain/services/remote_shared_album.service.dart +++ b/mobile/lib/domain/services/remote_shared_album.service.dart @@ -1,14 +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); + const RemoteSharedAlbumService(this._albumApiRepository, this._assetApiRepository); Future getSharedAlbum(String albumId) { return _albumApiRepository.getShared(albumId); } + + Future uploadAssets(String albumId, List 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().toList(); + + if (completedUploads.isNotEmpty) { + await _albumApiRepository.addAssets(albumId, completedUploads); + } + + return completedUploads.length; + } } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 4665022a69..9fa4106d17 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -158,7 +158,6 @@ class TimelineService { BaseAsset getRandomAsset() => _buffer.elementAt(math.Random().nextInt(_buffer.length)); BaseAsset getAsset(int index) { - print("buffer len: " + _buffer.length.toString()); if (!hasRange(index, 1)) { throw RangeError( 'TimelineService::getAsset Index $index not in buffer range [$_bufferOffset, ${_bufferOffset + _buffer.length})', diff --git a/mobile/lib/infrastructure/repositories/shared_link.repository.dart b/mobile/lib/infrastructure/repositories/shared_link.repository.dart new file mode 100644 index 0000000000..e69de29bb2 diff --git a/mobile/lib/presentation/pages/remote_shared_link.dart b/mobile/lib/presentation/pages/remote_shared_link.dart new file mode 100644 index 0000000000..d2adae2d6a --- /dev/null +++ b/mobile/lib/presentation/pages/remote_shared_link.dart @@ -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 createState() => _RemoteSharedLinkPageState(); +} + +class _RemoteSharedLinkPageState extends ConsumerState { + late final ApiService _apiService; + + SharedLink? sharedLink; + List? 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 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( + 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> 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 _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 addAssets() async { + final List 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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/pages/shared_remote_link.page.dart b/mobile/lib/presentation/pages/shared_remote_link.page.dart deleted file mode 100644 index 67fd44b377..0000000000 --- a/mobile/lib/presentation/pages/shared_remote_link.page.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/domain/models/asset/base_asset.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/providers/user.provider.dart'; -import 'package:immich_mobile/repositories/drift_album_api_repository.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'; -// 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 createState() => _RemoteSharedLinkPageState(); -} - -class _RemoteSharedLinkPageState extends ConsumerState { - late final ApiService _apiService; - - SharedLink? sharedLink; - List? assets; - - @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); - - retrieveSharedLink(); - } - - @override - void dispose() { - ImageUrlBuilder.clear(); - super.dispose(); - } - - Future retrieveSharedLink() async { - try { - sharedLink = await SharedLinkService(_apiService).getMySharedLink(); - } on ApiException catch (error, _) { - if (error.code == 401) { - // We need a password from user. - // TODO: make password input and try to auth again - } - } - - if (sharedLink == null) { - ImmichToast.show( - context: context, - msg: "shared_link_not_found".t(context: context), - toastType: ToastType.error, - ); - context.pop(); - return; - } - - // Retrieve assets from the shared link - switch (sharedLink!.type) { - case SharedLinkSource.album: - assets = await retrieveSharedAlbumAssets(); - break; - case SharedLinkSource.individual: - assets = sharedLink!.assets; - break; - } - - // if (assets!.isEmpty) { - // context.pop(); - // } - - setState(() {}); - } - - Future> retrieveSharedAlbumAssets() async { - try { - final driftApiRepository = DriftAlbumApiRepository(_apiService.albumsApi); - final albumService = RemoteSharedAlbumService(driftApiRepository); - final sharedAlbum = await albumService.getSharedAlbum(sharedLink!.albumId!); - - return sharedAlbum?.assets ?? []; - } catch (e) { - ImmichToast.show( - context: context, - msg: "failed_to_retrieve_assets".t(context: context), - toastType: ToastType.error, - ); - return []; - } - } - - Future addAssets(BuildContext context) async { - // TODO add upload assets to shared album - } - - void showOptionSheet(BuildContext context) { - final user = ref.watch(currentUserProvider); - - // showModalBottomSheet( - // context: context, - // backgroundColor: context.colorScheme.surface, - // isScrollControlled: false, - // builder: (context) { - // return DriftRemoteAlbumOption( - // onDeleteAlbum: isOwner - // ? () async { - // await deleteAlbum(context); - // if (context.mounted) { - // context.pop(); - // } - // } - // : null, - // onAddUsers: isOwner - // ? () async { - // await addUsers(context); - // context.pop(); - // } - // : null, - // onAddPhotos: () async { - // await addAssets(context); - // context.pop(); - // }, - // onToggleAlbumOrder: () async { - // await toggleAlbumOrder(); - // context.pop(); - // }, - // onEditAlbum: () async { - // context.pop(); - // await showEditTitleAndDescription(context); - // }, - // ); - // }, - // ); - } - - @override - Widget build(BuildContext context) { - if (assets == null || assets!.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - - return ProviderScope( - overrides: [ - apiServiceProvider.overrideWith((ref) => _apiService), - timelineServiceProvider.overrideWith((ref) { - print(assets!.length); - final timelineService = ref.watch(timelineFactoryProvider).fromAssets(assets!); - ref.onDispose(timelineService.dispose); - return timelineService; - }), - ], - child: Timeline(appBar: SliverToBoxAdapter(child: AppBar())), - ); - } -} diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index eb2e906a20..568e0f8f38 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,5 +1,6 @@ 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'; @@ -104,6 +105,20 @@ class AssetApiRepository extends ApiRepository { Future updateDescription(String assetId, String description) { return _api.updateAsset(assetId, UpdateAssetDto(description: description)); } + + Future 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 { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 949b82c938..eb8b5d0d1d 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -100,8 +100,8 @@ 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/pages/shared_remote_link.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/gallery_permission.provider.dart'; diff --git a/mobile/lib/services/shared_link.service.dart b/mobile/lib/services/shared_link.service.dart index f77ca42833..2713c74cfd 100644 --- a/mobile/lib/services/shared_link.service.dart +++ b/mobile/lib/services/shared_link.service.dart @@ -112,8 +112,8 @@ class SharedLinkService { return null; } - Future getMySharedLink() async { - final responseDto = await _apiService.sharedLinksApi.getMySharedLink(); + Future getMySharedLink({String? password}) async { + final responseDto = await _apiService.sharedLinksApi.getMySharedLink(password: password); if (responseDto != null) { return SharedLink.fromDto(responseDto); } diff --git a/mobile/lib/widgets/common/shared_link_password_dialog.dart b/mobile/lib/widgets/common/shared_link_password_dialog.dart new file mode 100644 index 0000000000..6da1fabf6c --- /dev/null +++ b/mobile/lib/widgets/common/shared_link_password_dialog.dart @@ -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 createState() => _SharedLinkPasswordDialogState(); +} + +class _SharedLinkPasswordDialogState extends State { + 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), + ), + ), + ], + ); + } +}