diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 91bb2f88c3..8d278b44ce 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -3083,6 +3083,12 @@ export interface SharedLinkEditDto { * @memberof SharedLinkEditDto */ 'allowUpload'?: boolean; + /** + * Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. + * @type {boolean} + * @memberof SharedLinkEditDto + */ + 'changeExpiryTime'?: boolean; /** * * @type {string} diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 3aadda4b7f..3175e1f5c8 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -130,6 +130,7 @@ "control_bottom_app_bar_delete": "Delete", "control_bottom_app_bar_favorite": "Favorite", "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_share_to": "Share To", "control_bottom_app_bar_stack": "Stack", "control_bottom_app_bar_unarchive": "Unarchive", "control_bottom_app_bar_upload": "Upload", @@ -287,6 +288,7 @@ "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", "sharing_page_empty_list": "EMPTY LIST", "sharing_silver_appbar_create_shared_album": "Create shared album", + "sharing_silver_appbar_shared_links": "Shared links", "sharing_silver_appbar_share_partner": "Share with partner", "tab_controller_nav_library": "Library", "tab_controller_nav_photos": "Photos", @@ -343,5 +345,21 @@ "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", "viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_remove_from_stack": "Remove from Stack", - "viewer_unstack": "Un-Stack" + "viewer_unstack": "Un-Stack", + "delete_shared_link_dialog_title": "Delete Shared Link", + "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", + "shared_link_create_app_bar_title": "Create link to share", + "shared_link_edit_app_bar_title": "Edit link", + "shared_link_create_info": "Let anyone with the link see the selected photo(s)", + "shared_link_edit_description": "Description", + "shared_link_edit_description_hint": "Enter the share description", + "shared_link_edit_show_meta": "Show metadata", + "shared_link_edit_allow_download": "Allow public user to download", + "shared_link_edit_allow_upload": "Allow public user to upload", + "shared_link_edit_change_expiry": "Change expiration time", + "shared_link_edit_submit_button": "Update link", + "shared_link_create_submit_button": "Create link", + "shared_link_app_bar_title": "Shared Links", + "shared_link_manage_links": "Manage Shared links", + "shared_link_empty": "You don't have any shared links" } diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index 5ca9bb81fd..f369a35d13 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -210,6 +210,18 @@ class AlbumViewerAppbar extends HookConsumerWidget style: TextStyle(fontWeight: FontWeight.bold), ).tr(), ), + ListTile( + leading: const Icon(Icons.share_rounded), + onTap: () { + AutoRouter.of(context) + .push(SharedLinkEditRoute(albumId: album.remoteId)); + Navigator.pop(context); + }, + title: const Text( + "control_bottom_app_bar_share", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), ListTile( leading: const Icon(Icons.settings_rounded), onTap: () => diff --git a/mobile/lib/modules/album/views/sharing_page.dart b/mobile/lib/modules/album/views/sharing_page.dart index 4e62b16c25..9d0593d286 100644 --- a/mobile/lib/modules/album/views/sharing_page.dart +++ b/mobile/lib/modules/album/views/sharing_page.dart @@ -147,13 +147,13 @@ class SharingPage extends HookConsumerWidget { Expanded( child: ElevatedButton.icon( onPressed: () => - AutoRouter.of(context).push(const PartnerRoute()), + AutoRouter.of(context).push(const SharedLinkRoute()), icon: const Icon( - Icons.swap_horizontal_circle_outlined, + Icons.link, size: 20, ), label: const Text( - "sharing_silver_appbar_share_partner", + "sharing_silver_appbar_shared_links", style: TextStyle( fontWeight: FontWeight.bold, fontSize: 11, @@ -179,6 +179,17 @@ class SharingPage extends HookConsumerWidget { fontSize: 22, ), ), + actions: [ + IconButton( + splashRadius: 25, + iconSize: 20, + icon: const Icon( + Icons.swap_horizontal_circle_outlined, + size: 20, + ), + onPressed: () => AutoRouter.of(context).push(const PartnerRoute()), + ), + ], ); } diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 3d8e165e2a..2c7f6ad632 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,7 +10,7 @@ import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/models/album.dart'; class ControlBottomAppBar extends ConsumerWidget { - final void Function() onShare; + final void Function(bool shareLocal) onShare; final void Function() onFavorite; final void Function() onArchive; final void Function() onDelete; @@ -51,73 +49,73 @@ class ControlBottomAppBar extends ConsumerWidget { final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); - Widget renderActionButtons() { - return Wrap( - spacing: 10, - runSpacing: 15, - children: [ + List renderActionButtons() { + return [ + if (hasRemote) ControlBoxButton( - iconData: Platform.isAndroid - ? Icons.share_rounded - : Icons.ios_share_rounded, + iconData: Icons.share_rounded, label: "control_bottom_app_bar_share".tr(), - onPressed: enabled ? onShare : null, + onPressed: enabled ? () => onShare(false) : null, ), - if (hasRemote) - ControlBoxButton( - iconData: Icons.archive, - label: "control_bottom_app_bar_archive".tr(), - onPressed: enabled ? onArchive : null, - ), - if (hasRemote) - ControlBoxButton( - iconData: Icons.favorite_border_rounded, - label: "control_bottom_app_bar_favorite".tr(), - onPressed: enabled ? onFavorite : null, - ), + ControlBoxButton( + iconData: Icons.ios_share_rounded, + label: "control_bottom_app_bar_share_to".tr(), + onPressed: enabled ? () => onShare(true) : null, + ), + if (hasRemote) ControlBoxButton( - iconData: Icons.delete_outline_rounded, - label: "control_bottom_app_bar_delete".tr(), - onPressed: enabled - ? () { - if (!trashEnabled) { - showDialog( - context: context, - builder: (BuildContext context) { - return DeleteDialog( - onDelete: onDelete, - ); - }, - ); - } else { - onDelete(); - } + iconData: Icons.archive, + label: "control_bottom_app_bar_archive".tr(), + onPressed: enabled ? onArchive : null, + ), + if (hasRemote) + ControlBoxButton( + iconData: Icons.favorite_border_rounded, + label: "control_bottom_app_bar_favorite".tr(), + onPressed: enabled ? onFavorite : null, + ), + ControlBoxButton( + iconData: Icons.delete_outline_rounded, + label: "control_bottom_app_bar_delete".tr(), + onPressed: enabled + ? () { + if (!trashEnabled) { + showDialog( + context: context, + builder: (BuildContext context) { + return DeleteDialog( + onDelete: onDelete, + ); + }, + ); + } else { + onDelete(); } + } + : null, + ), + if (!hasLocal) + ControlBoxButton( + iconData: Icons.filter_none_rounded, + label: "control_bottom_app_bar_stack".tr(), + onPressed: enabled ? onStack : null, + ), + if (!hasRemote) + ControlBoxButton( + iconData: Icons.backup_outlined, + label: "Upload", + onPressed: enabled + ? () => showDialog( + context: context, + builder: (BuildContext context) { + return UploadDialog( + onUpload: onUpload, + ); + }, + ) : null, ), - if (!hasRemote) - ControlBoxButton( - iconData: Icons.backup_outlined, - label: "control_bottom_app_bar_upload".tr(), - onPressed: enabled - ? () => showDialog( - context: context, - builder: (BuildContext context) { - return UploadDialog( - onUpload: onUpload, - ); - }, - ) - : null, - ), - if (!hasLocal) - ControlBoxButton( - iconData: Icons.filter_none_rounded, - label: "control_bottom_app_bar_stack".tr(), - onPressed: enabled ? onStack : null, - ), - ], - ); + ]; } return DraggableScrollableSheet( @@ -149,7 +147,13 @@ class ControlBottomAppBar extends ConsumerWidget { const SizedBox(height: 12), const CustomDraggingHandle(), const SizedBox(height: 12), - renderActionButtons(), + SizedBox( + height: 70, + child: ListView( + scrollDirection: Axis.horizontal, + children: renderActionButtons(), + ), + ), if (hasRemote) const Divider( indent: 16, @@ -173,10 +177,6 @@ class ControlBottomAppBar extends ConsumerWidget { enabled: enabled, ), ), - if (hasRemote) - const SliverToBoxAdapter( - child: SizedBox(height: 200), - ), ], ), ); @@ -209,7 +209,10 @@ class AddToAlbumTitleRow extends StatelessWidget { ).tr(), TextButton.icon( onPressed: onCreateNewAlbum, - icon: const Icon(Icons.add), + icon: Icon( + Icons.add, + color: Theme.of(context).primaryColor, + ), label: Text( "common_create_new_album", style: TextStyle( diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index d20501caa3..35670a93cf 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -91,12 +91,6 @@ class HomePage extends HookConsumerWidget { SelectionAssetState.fromSelection(selectedAssets); } - void onShareAssets() { - handleShareAssets(ref, context, selection.value.toList()); - - selectionEnabledHook.value = false; - } - List remoteOnlySelection({String? localErrorMessage}) { final Set assets = selection.value; final bool onlyRemote = assets.every((e) => e.isRemote); @@ -113,6 +107,19 @@ class HomePage extends HookConsumerWidget { return assets.toList(); } + void onShareAssets(bool shareLocal) { + processing.value = true; + if (shareLocal) { + handleShareAssets(ref, context, selection.value.toList()); + } else { + final ids = remoteOnlySelection().map((e) => e.remoteId!); + AutoRouter.of(context) + .push(SharedLinkEditRoute(assetsList: ids.toList())); + } + processing.value = false; + selectionEnabledHook.value = false; + } + void onFavoriteAssets() async { processing.value = true; try { diff --git a/mobile/lib/modules/shared_link/models/shared_link.dart b/mobile/lib/modules/shared_link/models/shared_link.dart new file mode 100644 index 0000000000..5beabb566c --- /dev/null +++ b/mobile/lib/modules/shared_link/models/shared_link.dart @@ -0,0 +1,107 @@ +import 'package:openapi/api.dart'; + +enum SharedLinkSource { album, individual } + +class SharedLink { + final String id; + final String title; + final bool allowDownload; + final bool allowUpload; + final String? thumbAssetId; + final String? description; + final DateTime? expiresAt; + final String key; + final bool showMetadata; + final SharedLinkSource type; + + const SharedLink({ + required this.id, + required this.title, + required this.allowDownload, + required this.allowUpload, + required this.thumbAssetId, + required this.description, + required this.expiresAt, + required this.key, + required this.showMetadata, + required this.type, + }); + + SharedLink copyWith({ + String? id, + String? title, + String? thumbAssetId, + bool? allowDownload, + bool? allowUpload, + String? description, + DateTime? expiresAt, + String? key, + bool? showMetadata, + SharedLinkSource? type, + }) { + return SharedLink( + id: id ?? this.id, + title: title ?? this.title, + thumbAssetId: thumbAssetId ?? this.thumbAssetId, + allowDownload: allowDownload ?? this.allowDownload, + allowUpload: allowUpload ?? this.allowUpload, + description: description ?? this.description, + expiresAt: expiresAt ?? this.expiresAt, + key: key ?? this.key, + showMetadata: showMetadata ?? this.showMetadata, + type: type ?? this.type, + ); + } + + SharedLink.fromDto(SharedLinkResponseDto dto) + : id = dto.id, + allowDownload = dto.allowDownload, + allowUpload = dto.allowUpload, + description = dto.description, + expiresAt = dto.expiresAt, + key = dto.key, + showMetadata = dto.showMetadata, + type = dto.type == SharedLinkType.ALBUM + ? SharedLinkSource.album + : SharedLinkSource.individual, + title = dto.type == SharedLinkType.ALBUM + ? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE" + : "INDIVIDUAL SHARE", + thumbAssetId = dto.type == SharedLinkType.ALBUM + ? dto.album?.albumThumbnailAssetId + : dto.assets.isNotEmpty + ? dto.assets[0].id + : null; + + @override + String toString() => + 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SharedLink && + other.id == id && + other.title == title && + other.thumbAssetId == thumbAssetId && + other.allowDownload == allowDownload && + other.allowUpload == allowUpload && + other.description == description && + other.expiresAt == expiresAt && + other.key == key && + other.showMetadata == showMetadata && + other.type == type; + + @override + int get hashCode => + id.hashCode ^ + title.hashCode ^ + thumbAssetId.hashCode ^ + allowDownload.hashCode ^ + allowUpload.hashCode ^ + description.hashCode ^ + expiresAt.hashCode ^ + key.hashCode ^ + showMetadata.hashCode ^ + type.hashCode; +} diff --git a/mobile/lib/modules/shared_link/providers/shared_link.provider.dart b/mobile/lib/modules/shared_link/providers/shared_link.provider.dart new file mode 100644 index 0000000000..d72b88dd87 --- /dev/null +++ b/mobile/lib/modules/shared_link/providers/shared_link.provider.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/modules/shared_link/services/shared_link.service.dart'; + +class SharedLinksNotifier extends StateNotifier>> { + final SharedLinkService _sharedLinkService; + + SharedLinksNotifier(this._sharedLinkService) : super(const AsyncLoading()) { + fetchLinks(); + } + + Future fetchLinks() async { + state = await _sharedLinkService.getAllSharedLinks(); + } + + Future deleteLink(String id) async { + await _sharedLinkService.deleteSharedLink(id); + state = const AsyncLoading(); + fetchLinks(); + } +} + +final sharedLinksStateProvider = + StateNotifierProvider>>( + (ref) { + return SharedLinksNotifier( + ref.watch(sharedLinkServiceProvider), + ); +}); diff --git a/mobile/lib/modules/shared_link/services/shared_link.service.dart b/mobile/lib/modules/shared_link/services/shared_link.service.dart new file mode 100644 index 0000000000..2e28c20dac --- /dev/null +++ b/mobile/lib/modules/shared_link/services/shared_link.service.dart @@ -0,0 +1,115 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart'; + +final sharedLinkServiceProvider = Provider( + (ref) => SharedLinkService(ref.watch(apiServiceProvider)), +); + +class SharedLinkService { + final ApiService _apiService; + final Logger _log = Logger("SharedLinkService"); + + SharedLinkService(this._apiService); + + Future>> getAllSharedLinks() async { + try { + final list = await _apiService.sharedLinkApi.getAllSharedLinks(); + return list != null + ? AsyncData(list.map(SharedLink.fromDto).toList()) + : const AsyncData([]); + } catch (e, stack) { + _log.severe("failed to fetch shared links - $e"); + return AsyncError(e, stack); + } + } + + Future deleteSharedLink(String id) async { + try { + return await _apiService.sharedLinkApi.removeSharedLink(id); + } catch (e) { + _log.severe("failed to delete shared link id - $id with error - $e"); + } + } + + Future createSharedLink({ + required bool showMeta, + required bool allowDownload, + required bool allowUpload, + String? description, + String? albumId, + List? assetIds, + DateTime? expiresAt, + }) async { + try { + final type = + albumId != null ? SharedLinkType.ALBUM : SharedLinkType.INDIVIDUAL; + SharedLinkCreateDto? dto; + if (type == SharedLinkType.ALBUM) { + dto = SharedLinkCreateDto( + type: type, + albumId: albumId, + showMetadata: showMeta, + allowDownload: allowDownload, + allowUpload: allowUpload, + expiresAt: expiresAt, + description: description, + ); + } else if (assetIds != null) { + dto = SharedLinkCreateDto( + type: type, + showMetadata: showMeta, + allowDownload: allowDownload, + allowUpload: allowUpload, + expiresAt: expiresAt, + description: description, + assetIds: assetIds, + ); + } + + if (dto != null) { + final responseDto = + await _apiService.sharedLinkApi.createSharedLink(dto); + if (responseDto != null) { + return SharedLink.fromDto(responseDto); + } + } + } catch (e) { + _log.severe("failed to create shared link with error - $e"); + } + return null; + } + + Future updateSharedLink( + String id, { + required bool? showMeta, + required bool? allowDownload, + required bool? allowUpload, + bool? changeExpiry = false, + String? description, + DateTime? expiresAt, + }) async { + try { + final responseDto = await _apiService.sharedLinkApi.updateSharedLink( + id, + SharedLinkEditDto( + showMetadata: showMeta, + allowDownload: allowDownload, + allowUpload: allowUpload, + expiresAt: expiresAt, + description: description, + changeExpiryTime: changeExpiry, + ), + ); + if (responseDto != null) { + return SharedLink.fromDto(responseDto); + } + } catch (e) { + _log.severe("failed to update shared link id - $id with error - $e"); + } + return null; + } +} diff --git a/mobile/lib/modules/shared_link/ui/shared_link_item.dart b/mobile/lib/modules/shared_link/ui/shared_link_item.dart new file mode 100644 index 0000000000..907006f77d --- /dev/null +++ b/mobile/lib/modules/shared_link/ui/shared_link_item.dart @@ -0,0 +1,307 @@ +import 'dart:math' as math; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/ui/confirm_dialog.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; + +class SharedLinkItem extends ConsumerWidget { + final SharedLink sharedLink; + + const SharedLinkItem(this.sharedLink, {super.key}); + + bool isExpired() { + if (sharedLink.expiresAt != null) { + return DateTime.now().isAfter(sharedLink.expiresAt!); + } + return false; + } + + Widget getExpiryDuration(bool isDarkMode) { + var expiresText = "Expires ∞"; + if (sharedLink.expiresAt != null) { + if (isExpired()) { + return Text( + "Expired", + style: TextStyle(color: Colors.red[300]), + ); + } + final difference = sharedLink.expiresAt!.difference(DateTime.now()); + debugPrint("Difference: $difference"); + if (difference.inDays > 0) { + var dayDifference = difference.inDays; + if (difference.inHours % 24 > 12) { + dayDifference += 1; + } + expiresText = "in $dayDifference days"; + } else if (difference.inHours > 0) { + expiresText = "in ${difference.inHours} hours"; + } else if (difference.inMinutes > 0) { + expiresText = "in ${difference.inMinutes} minutes"; + } else if (difference.inSeconds > 0) { + expiresText = "in ${difference.inSeconds} seconds"; + } + } + return Text( + expiresText, + style: TextStyle(color: isDarkMode ? Colors.grey[400] : Colors.grey[600]), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeData = Theme.of(context); + final isDarkMode = themeData.brightness == Brightness.dark; + final thumbnailUrl = sharedLink.thumbAssetId != null + ? getThumbnailUrlForRemoteId(sharedLink.thumbAssetId!) + : null; + final imageSize = math.min(MediaQuery.of(context).size.width / 4, 100.0); + + void copyShareLinkToClipboard() { + final serverUrl = getServerUrl(); + if (serverUrl == null) { + ImmichToast.show( + context: context, + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + msg: 'Cannot fetch the server url', + ); + return; + } + + Clipboard.setData( + ClipboardData( + text: "$serverUrl/share/${sharedLink.key}", + ), + ).then((_) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Copied to clipboard", + ), + duration: Duration(seconds: 2), + ), + ); + }); + } + + Future deleteShareLink() async { + return showDialog( + context: context, + builder: (BuildContext context) { + return ConfirmDialog( + title: "delete_shared_link_dialog_title", + content: "delete_shared_link_dialog_content", + onOk: () => ref + .read(sharedLinksStateProvider.notifier) + .deleteLink(sharedLink.id), + ); + }, + ); + } + + Widget buildThumbnail() { + if (thumbnailUrl == null) { + return Container( + height: imageSize * 1.2, + width: imageSize, + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.grey[200], + ), + child: Center( + child: Icon( + Icons.image_not_supported_outlined, + color: isDarkMode ? Colors.grey[100] : Colors.grey[700], + ), + ), + ); + } + return SizedBox( + height: imageSize * 1.2, + width: imageSize, + child: Padding( + padding: const EdgeInsets.only(right: 4.0), + child: ThumbnailWithInfo( + imageUrl: thumbnailUrl, + key: key, + textInfo: '', + noImageIcon: Icons.image_not_supported_outlined, + onTap: () {}, + ), + ), + ); + } + + Widget buildInfoChip(String labelText) { + return Padding( + padding: const EdgeInsets.only(right: 10), + child: Chip( + backgroundColor: themeData.primaryColor, + label: Text( + labelText, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: isDarkMode ? Colors.black : Colors.white, + ), + ), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(25)), + ), + ), + ); + } + + Widget buildBottomInfo() { + return Row( + children: [ + if (sharedLink.allowUpload) buildInfoChip("Upload"), + if (sharedLink.allowDownload) buildInfoChip("Download"), + if (sharedLink.showMetadata) buildInfoChip("EXIF"), + ], + ); + } + + Widget buildSharedLinkActions() { + const actionIconSize = 20.0; + return Row( + children: [ + IconButton( + splashRadius: 25, + constraints: const BoxConstraints(), + iconSize: actionIconSize, + icon: const Icon(Icons.delete_outline), + style: const ButtonStyle( + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, // the '2023' part + ), + onPressed: deleteShareLink, + ), + IconButton( + splashRadius: 25, + constraints: const BoxConstraints(), + iconSize: actionIconSize, + icon: const Icon(Icons.edit_outlined), + style: const ButtonStyle( + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, // the '2023' part + ), + onPressed: () => AutoRouter.of(context) + .push(SharedLinkEditRoute(existingLink: sharedLink)), + ), + IconButton( + splashRadius: 25, + constraints: const BoxConstraints(), + iconSize: actionIconSize, + icon: const Icon(Icons.copy_outlined), + style: const ButtonStyle( + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, // the '2023' part + ), + onPressed: copyShareLinkToClipboard, + ), + ], + ); + } + + Widget buildSharedLinkDetails() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + getExpiryDuration(isDarkMode), + Padding( + padding: const EdgeInsets.only(top: 5), + child: Tooltip( + verticalOffset: 0, + decoration: BoxDecoration( + color: themeData.primaryColor.withOpacity(0.9), + borderRadius: BorderRadius.circular(10), + ), + textStyle: TextStyle( + color: isDarkMode ? Colors.black : Colors.white, + fontWeight: FontWeight.bold, + ), + message: sharedLink.title, + preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + child: Text( + sharedLink.title, + style: TextStyle( + color: themeData.primaryColor, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Tooltip( + verticalOffset: 0, + decoration: BoxDecoration( + color: themeData.primaryColor.withOpacity(0.9), + borderRadius: BorderRadius.circular(10), + ), + textStyle: TextStyle( + color: isDarkMode ? Colors.black : Colors.white, + fontWeight: FontWeight.bold, + ), + message: sharedLink.description ?? "", + preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + child: Text( + sharedLink.description ?? "", + overflow: TextOverflow.ellipsis, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 15), + child: buildSharedLinkActions(), + ), + ], + ), + buildBottomInfo(), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 15), + child: buildThumbnail(), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 15), + child: buildSharedLinkDetails(), + ), + ), + ], + ), + const Padding( + padding: EdgeInsets.all(20), + child: Divider( + height: 0, + ), + ), + ], + ); + } +} diff --git a/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart b/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart new file mode 100644 index 0000000000..d2a1aaeed4 --- /dev/null +++ b/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart @@ -0,0 +1,459 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart'; +import 'package:immich_mobile/modules/shared_link/services/shared_link.service.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; + +class SharedLinkEditPage extends HookConsumerWidget { + final SharedLink? existingLink; + final List? assetsList; + final String? albumId; + + const SharedLinkEditPage({ + super.key, + this.existingLink, + this.assetsList, + this.albumId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + const padding = 20.0; + final themeData = Theme.of(context); + final descriptionController = + useTextEditingController(text: existingLink?.description ?? ""); + final descriptionFocusNode = useFocusNode(); + final showMetadata = useState(existingLink?.showMetadata ?? true); + final allowDownload = useState(existingLink?.allowDownload ?? true); + final allowUpload = useState(existingLink?.allowUpload ?? false); + final editExpiry = useState(false); + final expiryAfter = useState(0); + final newShareLink = useState(""); + + Widget buildLinkTitle() { + if (existingLink != null) { + if (existingLink!.type == SharedLinkSource.album) { + return Row( + children: [ + const Text( + "Public album | ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text( + existingLink!.title, + style: TextStyle( + color: themeData.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + if (existingLink!.type == SharedLinkSource.individual) { + return Row( + children: [ + const Text( + "Individual shared | ", + style: TextStyle(fontWeight: FontWeight.bold), + ), + Expanded( + child: Text( + existingLink!.description ?? "--", + style: TextStyle( + color: themeData.primaryColor, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + } + + return const Text( + "shared_link_create_info", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(); + } + + Widget buildDescriptionField() { + return TextField( + controller: descriptionController, + enabled: newShareLink.value.isEmpty, + focusNode: descriptionFocusNode, + textInputAction: TextInputAction.done, + autofocus: false, + decoration: InputDecoration( + labelText: 'shared_link_edit_description'.tr(), + labelStyle: TextStyle( + fontWeight: FontWeight.bold, + color: themeData.primaryColor, + ), + floatingLabelBehavior: FloatingLabelBehavior.always, + border: const OutlineInputBorder(), + hintText: 'shared_link_edit_description_hint'.tr(), + hintStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)), + ), + ), + onTapOutside: (_) => descriptionFocusNode.unfocus(), + ); + } + + Widget buildShowMetaButton() { + return SwitchListTile.adaptive( + value: showMetadata.value, + onChanged: newShareLink.value.isEmpty + ? (value) => showMetadata.value = value + : null, + activeColor: themeData.primaryColor, + dense: true, + title: Text( + "shared_link_edit_show_meta", + style: themeData.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ).tr(), + ); + } + + Widget buildAllowDownloadButton() { + return SwitchListTile.adaptive( + value: allowDownload.value, + onChanged: newShareLink.value.isEmpty + ? (value) => allowDownload.value = value + : null, + activeColor: themeData.primaryColor, + dense: true, + title: Text( + "shared_link_edit_allow_download", + style: themeData.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ).tr(), + ); + } + + Widget buildAllowUploadButton() { + return SwitchListTile.adaptive( + value: allowUpload.value, + onChanged: newShareLink.value.isEmpty + ? (value) => allowUpload.value = value + : null, + activeColor: themeData.primaryColor, + dense: true, + title: Text( + "shared_link_edit_allow_upload", + style: themeData.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ).tr(), + ); + } + + Widget buildEditExpiryButton() { + return SwitchListTile.adaptive( + value: editExpiry.value, + onChanged: newShareLink.value.isEmpty + ? (value) => editExpiry.value = value + : null, + activeColor: themeData.primaryColor, + dense: true, + title: Text( + "shared_link_edit_change_expiry", + style: themeData.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.bold), + ).tr(), + ); + } + + Widget buildExpiryAfterButton() { + return DropdownMenu( + enableSearch: false, + enableFilter: false, + width: MediaQuery.of(context).size.width - 40, + initialSelection: expiryAfter.value, + enabled: newShareLink.value.isEmpty && + (existingLink == null || editExpiry.value), + onSelected: (value) { + expiryAfter.value = value!; + }, + inputDecorationTheme: themeData.inputDecorationTheme.copyWith( + disabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)), + ), + enabledBorder: const OutlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ), + ), + dropdownMenuEntries: const [ + DropdownMenuEntry(value: 0, label: "Never"), + DropdownMenuEntry( + value: 30, + label: '30 minutes', + ), + DropdownMenuEntry( + value: 60, + label: '1 hour', + ), + DropdownMenuEntry( + value: 60 * 6, + label: '6 hours', + ), + DropdownMenuEntry( + value: 60 * 24, + label: '1 day', + ), + DropdownMenuEntry( + value: 60 * 24 * 7, + label: '7 days', + ), + DropdownMenuEntry( + value: 60 * 24 * 30, + label: '30 days', + ), + ], + ); + } + + void copyLinkToClipboard() { + Clipboard.setData( + ClipboardData( + text: newShareLink.value, + ), + ).then((_) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Copied to clipboard", + ), + duration: Duration(seconds: 2), + ), + ); + }); + } + + Widget buildNewLinkField() { + return Column( + children: [ + const Padding( + padding: EdgeInsets.only( + top: 20, + bottom: 20, + ), + child: Divider(), + ), + TextFormField( + readOnly: true, + initialValue: newShareLink.value, + decoration: InputDecoration( + border: const OutlineInputBorder(), + enabledBorder: themeData.inputDecorationTheme.focusedBorder, + suffixIcon: IconButton( + onPressed: copyLinkToClipboard, + icon: const Icon(Icons.copy), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Align( + alignment: Alignment.bottomRight, + child: ElevatedButton( + onPressed: () { + AutoRouter.of(context).pop(); + }, + child: const Text( + "Done", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ), + ), + ], + ); + } + + DateTime calculateExpiry() { + return DateTime.now().add(Duration(minutes: expiryAfter.value)); + } + + Future handleNewLink() async { + final newLink = + await ref.read(sharedLinkServiceProvider).createSharedLink( + albumId: albumId, + assetIds: assetsList, + showMeta: showMetadata.value, + allowDownload: allowDownload.value, + allowUpload: allowUpload.value, + description: descriptionController.text.isEmpty + ? null + : descriptionController.text, + expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(), + ); + ref.invalidate(sharedLinksStateProvider); + final serverUrl = getServerUrl(); + if (newLink != null && serverUrl != null) { + newShareLink.value = "$serverUrl/share/${newLink.key}"; + copyLinkToClipboard(); + } else if (newLink == null) { + ImmichToast.show( + context: context, + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + msg: 'Error while creating shared link', + ); + } + } + + Future handleEditLink() async { + bool? download; + bool? upload; + bool? meta; + String? desc; + DateTime? expiry; + bool? changeExpiry; + + if (allowDownload.value != existingLink!.allowDownload) { + download = allowDownload.value; + } + + if (allowUpload.value != existingLink!.allowUpload) { + upload = allowUpload.value; + } + + if (showMetadata.value != existingLink!.showMetadata) { + meta = showMetadata.value; + } + + if (descriptionController.text != existingLink!.description) { + desc = descriptionController.text; + } + + if (editExpiry.value) { + expiry = expiryAfter.value == 0 ? null : calculateExpiry(); + changeExpiry = true; + } + + await ref.read(sharedLinkServiceProvider).updateSharedLink( + existingLink!.id, + showMeta: meta, + allowDownload: download, + allowUpload: upload, + description: desc, + expiresAt: expiry, + changeExpiry: changeExpiry, + ); + ref.invalidate(sharedLinksStateProvider); + AutoRouter.of(context).pop(); + } + + return Scaffold( + appBar: AppBar( + title: Text( + existingLink == null + ? "shared_link_create_app_bar_title" + : "shared_link_edit_app_bar_title", + ).tr(), + elevation: 0, + leading: const CloseButton(), + centerTitle: false, + ), + resizeToAvoidBottomInset: false, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(padding), + child: buildLinkTitle(), + ), + Padding( + padding: const EdgeInsets.all(padding), + child: buildDescriptionField(), + ), + Padding( + padding: const EdgeInsets.only( + left: padding, + right: padding, + bottom: padding, + ), + child: buildShowMetaButton(), + ), + Padding( + padding: const EdgeInsets.only( + left: padding, + right: padding, + bottom: padding, + ), + child: buildAllowDownloadButton(), + ), + Padding( + padding: + const EdgeInsets.only(left: padding, right: 20, bottom: 20), + child: buildAllowUploadButton(), + ), + if (existingLink != null) + Padding( + padding: const EdgeInsets.only( + left: padding, + right: padding, + bottom: padding, + ), + child: buildEditExpiryButton(), + ), + Padding( + padding: const EdgeInsets.only( + left: padding, + right: padding, + bottom: padding, + ), + child: buildExpiryAfterButton(), + ), + if (newShareLink.value.isEmpty) + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(right: padding + 10), + child: ElevatedButton( + onPressed: + existingLink != null ? handleEditLink : handleNewLink, + child: Text( + existingLink != null + ? "shared_link_edit_submit_button" + : "shared_link_create_submit_button", + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ), + ), + if (newShareLink.value.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: padding, + right: padding, + ), + child: buildNewLinkField(), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/shared_link/views/shared_link_page.dart b/mobile/lib/modules/shared_link/views/shared_link_page.dart new file mode 100644 index 0000000000..19bede4bdc --- /dev/null +++ b/mobile/lib/modules/shared_link/views/shared_link_page.dart @@ -0,0 +1,126 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart'; +import 'package:immich_mobile/modules/shared_link/ui/shared_link_item.dart'; +import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; + +class SharedLinkPage extends HookConsumerWidget { + const SharedLinkPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sharedLinks = ref.watch(sharedLinksStateProvider); + + useEffect( + () { + ref.read(sharedLinksStateProvider.notifier).fetchLinks(); + return () => ref.invalidate(sharedLinksStateProvider); + }, + [], + ); + + Widget buildNoShares() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 16.0), + child: const Text( + "shared_link_manage_links", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: const Text( + "shared_link_empty", + style: TextStyle(fontSize: 14), + ).tr(), + ), + ), + Expanded( + child: Center( + child: Icon( + Icons.link_off, + size: 100, + color: Theme.of(context).iconTheme.color?.withOpacity(0.5), + ), + ), + ), + ], + ); + } + + Widget buildSharesList(List links) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 16.0, bottom: 30.0), + child: const Text( + "shared_link_manage_links", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth > 600) { + // Two column + return GridView.builder( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisExtent: 180, + ), + itemCount: links.length, + itemBuilder: (context, index) { + return SharedLinkItem(links.elementAt(index)); + }, + ); + } + + // Single column + return ListView.builder( + itemCount: links.length, + itemBuilder: (context, index) { + return SharedLinkItem(links.elementAt(index)); + }, + ); + }, + ), + ), + ], + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text("shared_link_app_bar_title").tr(), + elevation: 0, + centerTitle: false, + ), + body: SafeArea( + child: sharedLinks.when( + data: (links) => + links.isNotEmpty ? buildSharesList(links) : buildNoShares(), + error: (error, stackTrace) => buildNoShares(), + loading: () => const Center(child: ImmichLoadingIndicator()), + ), + ), + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b7971c9d19..0c3da5c022 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -28,6 +28,9 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart'; +import 'package:immich_mobile/modules/shared_link/models/shared_link.dart'; +import 'package:immich_mobile/modules/shared_link/views/shared_link_edit_page.dart'; +import 'package:immich_mobile/modules/shared_link/views/shared_link_page.dart'; import 'package:immich_mobile/modules/trash/views/trash_page.dart'; import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart'; import 'package:immich_mobile/modules/search/views/all_people_page.dart'; @@ -158,6 +161,8 @@ part 'router.gr.dart'; AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]), AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]), + AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]), + AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]), ], ) class AppRouter extends _$AppRouter { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 651bec033f..6e49df9f4d 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -320,6 +320,25 @@ class _$AppRouter extends RootStackRouter { child: const TrashPage(), ); }, + SharedLinkRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, + child: const SharedLinkPage(), + ); + }, + SharedLinkEditRoute.name: (routeData) { + final args = routeData.argsAs( + orElse: () => const SharedLinkEditRouteArgs()); + return MaterialPageX( + routeData: routeData, + child: SharedLinkEditPage( + key: args.key, + existingLink: args.existingLink, + assetsList: args.assetsList, + albumId: args.albumId, + ), + ); + }, HomeRoute.name: (routeData) { return MaterialPageX( routeData: routeData, @@ -640,6 +659,22 @@ class _$AppRouter extends RootStackRouter { duplicateGuard, ], ), + RouteConfig( + SharedLinkRoute.name, + path: '/shared-link-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), + RouteConfig( + SharedLinkEditRoute.name, + path: '/shared-link-edit-page', + guards: [ + authGuard, + duplicateGuard, + ], + ), ]; } @@ -1432,6 +1467,62 @@ class TrashRoute extends PageRouteInfo { static const String name = 'TrashRoute'; } +/// generated route for +/// [SharedLinkPage] +class SharedLinkRoute extends PageRouteInfo { + const SharedLinkRoute() + : super( + SharedLinkRoute.name, + path: '/shared-link-page', + ); + + static const String name = 'SharedLinkRoute'; +} + +/// generated route for +/// [SharedLinkEditPage] +class SharedLinkEditRoute extends PageRouteInfo { + SharedLinkEditRoute({ + Key? key, + SharedLink? existingLink, + List? assetsList, + String? albumId, + }) : super( + SharedLinkEditRoute.name, + path: '/shared-link-edit-page', + args: SharedLinkEditRouteArgs( + key: key, + existingLink: existingLink, + assetsList: assetsList, + albumId: albumId, + ), + ); + + static const String name = 'SharedLinkEditRoute'; +} + +class SharedLinkEditRouteArgs { + const SharedLinkEditRouteArgs({ + this.key, + this.existingLink, + this.assetsList, + this.albumId, + }); + + final Key? key; + + final SharedLink? existingLink; + + final List? assetsList; + + final String? albumId; + + @override + String toString() { + return 'SharedLinkEditRouteArgs{key: $key, existingLink: $existingLink, assetsList: $assetsList, albumId: $albumId}'; + } +} + /// generated route for /// [HomePage] class HomeRoute extends PageRouteInfo { diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index b19860cc89..7c1dfc8fcb 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -21,6 +21,7 @@ class ApiService { late PartnerApi partnerApi; late PersonApi personApi; late AuditApi auditApi; + late SharedLinkApi sharedLinkApi; ApiService() { final endpoint = Store.tryGet(StoreKey.serverEndpoint); @@ -45,6 +46,7 @@ class ApiService { partnerApi = PartnerApi(_apiClient); personApi = PersonApi(_apiClient); auditApi = AuditApi(_apiClient); + sharedLinkApi = SharedLinkApi(_apiClient); } Future resolveAndSetEndpoint(String serverUrl) async { diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index 5eb3551738..670a7660d9 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -41,7 +41,12 @@ ThemeData immichLightTheme = ThemeData( fontFamily: 'WorkSans', scaffoldBackgroundColor: immichBackgroundColor, snackBarTheme: const SnackBarThemeData( - contentTextStyle: TextStyle(fontFamily: 'WorkSans'), + contentTextStyle: TextStyle( + fontFamily: 'WorkSans', + color: Colors.indigo, + fontWeight: FontWeight.bold, + ), + backgroundColor: Colors.white, ), appBarTheme: AppBarTheme( titleTextStyle: const TextStyle( @@ -156,8 +161,13 @@ ThemeData immichDarkTheme = ThemeData( scaffoldBackgroundColor: immichDarkBackgroundColor, hintColor: Colors.grey[600], fontFamily: 'WorkSans', - snackBarTheme: const SnackBarThemeData( - contentTextStyle: TextStyle(fontFamily: 'WorkSans'), + snackBarTheme: SnackBarThemeData( + contentTextStyle: TextStyle( + fontFamily: 'WorkSans', + color: immichDarkThemePrimaryColor, + fontWeight: FontWeight.bold, + ), + backgroundColor: Colors.grey[900], ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( diff --git a/mobile/lib/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index 66ce723a95..b771a6f705 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -1,3 +1,5 @@ +import 'package:immich_mobile/shared/models/store.dart'; + String sanitizeUrl(String url) { // Add schema if none is set final urlWithSchema = @@ -6,3 +8,15 @@ String sanitizeUrl(String url) { // Remove trailing slash(es) return urlWithSchema.replaceFirst(RegExp(r"/+$"), ""); } + +String? getServerUrl() { + final serverUrl = Store.tryGet(StoreKey.serverEndpoint); + final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null; + if (serverUri == null) { + return null; + } + + return serverUri.hasPort + ? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}" + : "${serverUri.scheme}://${serverUri.host}"; +} diff --git a/mobile/openapi/doc/SharedLinkEditDto.md b/mobile/openapi/doc/SharedLinkEditDto.md index f035e23c63..ccd0d3b543 100644 --- a/mobile/openapi/doc/SharedLinkEditDto.md +++ b/mobile/openapi/doc/SharedLinkEditDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **allowDownload** | **bool** | | [optional] **allowUpload** | **bool** | | [optional] +**changeExpiryTime** | **bool** | Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. | [optional] **description** | **String** | | [optional] **expiresAt** | [**DateTime**](DateTime.md) | | [optional] **showMetadata** | **bool** | | [optional] diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index 6b72e025db..108734999d 100644 --- a/mobile/openapi/lib/model/shared_link_edit_dto.dart +++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart @@ -15,6 +15,7 @@ class SharedLinkEditDto { SharedLinkEditDto({ this.allowDownload, this.allowUpload, + this.changeExpiryTime, this.description, this.expiresAt, this.showMetadata, @@ -36,6 +37,15 @@ class SharedLinkEditDto { /// bool? allowUpload; + /// Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? changeExpiryTime; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -58,6 +68,7 @@ class SharedLinkEditDto { bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto && other.allowDownload == allowDownload && other.allowUpload == allowUpload && + other.changeExpiryTime == changeExpiryTime && other.description == description && other.expiresAt == expiresAt && other.showMetadata == showMetadata; @@ -67,12 +78,13 @@ class SharedLinkEditDto { // ignore: unnecessary_parenthesis (allowDownload == null ? 0 : allowDownload!.hashCode) + (allowUpload == null ? 0 : allowUpload!.hashCode) + + (changeExpiryTime == null ? 0 : changeExpiryTime!.hashCode) + (description == null ? 0 : description!.hashCode) + (expiresAt == null ? 0 : expiresAt!.hashCode) + (showMetadata == null ? 0 : showMetadata!.hashCode); @override - String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]'; + String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, changeExpiryTime=$changeExpiryTime, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]'; Map toJson() { final json = {}; @@ -86,6 +98,11 @@ class SharedLinkEditDto { } else { // json[r'allowUpload'] = null; } + if (this.changeExpiryTime != null) { + json[r'changeExpiryTime'] = this.changeExpiryTime; + } else { + // json[r'changeExpiryTime'] = null; + } if (this.description != null) { json[r'description'] = this.description; } else { @@ -114,6 +131,7 @@ class SharedLinkEditDto { return SharedLinkEditDto( allowDownload: mapValueOfType(json, r'allowDownload'), allowUpload: mapValueOfType(json, r'allowUpload'), + changeExpiryTime: mapValueOfType(json, r'changeExpiryTime'), description: mapValueOfType(json, r'description'), expiresAt: mapDateTime(json, r'expiresAt', ''), showMetadata: mapValueOfType(json, r'showMetadata'), diff --git a/mobile/openapi/test/shared_link_edit_dto_test.dart b/mobile/openapi/test/shared_link_edit_dto_test.dart index 26fbb92fde..893d12efe0 100644 --- a/mobile/openapi/test/shared_link_edit_dto_test.dart +++ b/mobile/openapi/test/shared_link_edit_dto_test.dart @@ -26,6 +26,12 @@ void main() { // TODO }); + // Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. + // bool changeExpiryTime + test('to test the property `changeExpiryTime`', () async { + // TODO + }); + // String description test('to test the property `description`', () async { // TODO diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 73ec9d1e7c..e6230fcc32 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -7902,6 +7902,10 @@ "allowUpload": { "type": "boolean" }, + "changeExpiryTime": { + "description": "Few clients cannot send null to set the expiryTime to never.\nSetting this flag and not sending expiryAt is considered as null instead.\nClients that can send null values can ignore this.", + "type": "boolean" + }, "description": { "type": "string" }, diff --git a/server/src/domain/shared-link/shared-link.dto.ts b/server/src/domain/shared-link/shared-link.dto.ts index 4c86afb628..ed38cf984d 100644 --- a/server/src/domain/shared-link/shared-link.dto.ts +++ b/server/src/domain/shared-link/shared-link.dto.ts @@ -52,4 +52,13 @@ export class SharedLinkEditDto { @Optional() showMetadata?: boolean; + + /** + * Few clients cannot send null to set the expiryTime to never. + * Setting this flag and not sending expiryAt is considered as null instead. + * Clients that can send null values can ignore this. + */ + @Optional() + @IsBoolean() + changeExpiryTime?: boolean; } diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts index 06b5b78978..9e2a0fc8a0 100644 --- a/server/src/domain/shared-link/shared-link.service.ts +++ b/server/src/domain/shared-link/shared-link.service.ts @@ -81,7 +81,7 @@ export class SharedLinkService { id, userId: authUser.id, description: dto.description, - expiresAt: dto.expiresAt, + expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt, allowUpload: dto.allowUpload, allowDownload: dto.allowDownload, showExif: dto.showMetadata, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 91bb2f88c3..8d278b44ce 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -3083,6 +3083,12 @@ export interface SharedLinkEditDto { * @memberof SharedLinkEditDto */ 'allowUpload'?: boolean; + /** + * Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. + * @type {boolean} + * @memberof SharedLinkEditDto + */ + 'changeExpiryTime'?: boolean; /** * * @type {string}