diff --git a/mobile/lib/models/shared_link/shared_link.model.dart b/mobile/lib/models/shared_link/shared_link.model.dart index 57a1f441eb0ea..4315cf616aa70 100644 --- a/mobile/lib/models/shared_link/shared_link.model.dart +++ b/mobile/lib/models/shared_link/shared_link.model.dart @@ -14,6 +14,7 @@ class SharedLink { final String key; final bool showMetadata; final SharedLinkSource type; + final String? slug; const SharedLink({ required this.id, @@ -27,6 +28,7 @@ class SharedLink { required this.key, required this.showMetadata, required this.type, + required this.slug, }); SharedLink copyWith({ @@ -41,6 +43,7 @@ class SharedLink { String? key, bool? showMetadata, SharedLinkSource? type, + String? slug, }) { return SharedLink( id: id ?? this.id, @@ -54,6 +57,7 @@ class SharedLink { key: key ?? this.key, showMetadata: showMetadata ?? this.showMetadata, type: type ?? this.type, + slug: slug ?? this.slug, ); } @@ -66,6 +70,7 @@ class SharedLink { expiresAt = dto.expiresAt, key = dto.key, showMetadata = dto.showMetadata, + slug = dto.slug, type = dto.type == SharedLinkType.ALBUM ? SharedLinkSource.album : SharedLinkSource.individual, title = dto.type == SharedLinkType.ALBUM ? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE" @@ -78,7 +83,7 @@ class SharedLink { @override String toString() => - 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)'; + 'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type, slug=$slug)'; @override bool operator ==(Object other) => @@ -94,7 +99,8 @@ class SharedLink { other.expiresAt == expiresAt && other.key == key && other.showMetadata == showMetadata && - other.type == type; + other.type == type && + other.slug == slug; @override int get hashCode => @@ -108,5 +114,6 @@ class SharedLink { expiresAt.hashCode ^ key.hashCode ^ showMetadata.hashCode ^ - type.hashCode; + type.hashCode ^ + slug.hashCode; } diff --git a/mobile/lib/pages/library/shared_link/shared_link.page.dart b/mobile/lib/pages/library/shared_link/shared_link.page.dart index 66a77fb761a31..7a152727f69bb 100644 --- a/mobile/lib/pages/library/shared_link/shared_link.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link.page.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/providers/shared_link.provider.dart'; import 'package:immich_mobile/widgets/shared_link/shared_link_item.dart'; @@ -26,71 +25,41 @@ class SharedLinkPage extends HookConsumerWidget { }, []); 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("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(), - ), - ), - Expanded( - child: Center( - child: Icon(Icons.link_off, size: 100, color: context.themeData.iconTheme.color?.withValues(alpha: 0.5)), - ), - ), - ], + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.link_off, size: 100, color: Theme.of(context).colorScheme.onSurface.withAlpha(128)), + const SizedBox(height: 20), + const Text("you_dont_have_any_shared_links", style: TextStyle(fontSize: 14)).tr(), + ], + ), ); } 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: Text( - "shared_link_manage_links", - style: context.textTheme.labelLarge?.copyWith(color: context.textTheme.labelLarge?.color?.withAlpha(200)), - ).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 LayoutBuilder( + builder: (context, constraints) => constraints.maxWidth > 600 + ? GridView.builder( + key: const PageStorageKey('shared-links-grid'), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisExtent: 180, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + padding: const EdgeInsets.all(12), + itemCount: links.length, + itemBuilder: (context, index) => SharedLinkItem(links[index]), + ) + : ListView.separated( + key: const PageStorageKey('shared-links-list'), + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: links.length, + itemBuilder: (context, index) => SharedLinkItem(links[index]), + separatorBuilder: (context, index) => const Divider(height: 1), + ), ); } diff --git a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart index 1d7eaef080717..0c1045a8efe29 100644 --- a/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart +++ b/mobile/lib/pages/library/shared_link/shared_link_edit.page.dart @@ -11,10 +11,25 @@ import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/shared_link.provider.dart'; import 'package:immich_mobile/services/shared_link.service.dart'; import 'package:immich_mobile/utils/url_helper.dart'; +import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:share_plus/share_plus.dart'; @RoutePage() class SharedLinkEditPage extends HookConsumerWidget { + static const List<(Duration, String)> expiryPresetsWithLabels = [ + (Duration.zero, 'never'), + (Duration(minutes: 30), 'shared_link_edit_expire_after_option_minutes'), + (Duration(hours: 1), 'shared_link_edit_expire_after_option_hour'), + (Duration(hours: 6), 'shared_link_edit_expire_after_option_hours'), + (Duration(days: 1), 'shared_link_edit_expire_after_option_day'), + (Duration(days: 7), 'shared_link_edit_expire_after_option_days'), + (Duration(days: 30), 'shared_link_edit_expire_after_option_days'), + (Duration(days: 90), 'shared_link_edit_expire_after_option_months'), + (Duration(days: 365), 'shared_link_edit_expire_after_option_year'), + ]; + static const int maxFutureDate = 365 * 2; + final SharedLink? existingLink; final List? assetsList; final String? albumId; @@ -23,47 +38,48 @@ class SharedLinkEditPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - const padding = 20.0; final themeData = context.themeData; final colorScheme = context.colorScheme; final descriptionController = useTextEditingController(text: existingLink?.description ?? ""); final descriptionFocusNode = useFocusNode(); final passwordController = useTextEditingController(text: existingLink?.password ?? ""); + final slugController = useTextEditingController(text: existingLink?.slug ?? ""); + final slugFocusNode = useFocusNode(); + useListenable(slugController); 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 expiryAfter = useState(existingLink?.expiresAt?.toLocal()); final newShareLink = useState(""); + Widget buildSharedLinkRow({required String leading, required String content}) { + return Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + content, + style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold), + ), + ), + ), + const SizedBox(width: 8), + Text(leading, style: const TextStyle(fontWeight: FontWeight.bold)).tr(), + ], + ); + } + Widget buildLinkTitle() { if (existingLink != null) { if (existingLink!.type == SharedLinkSource.album) { - return Row( - children: [ - const Text('public_album', style: TextStyle(fontWeight: FontWeight.bold)).tr(), - const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)), - Text( - existingLink!.title, - style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold), - ), - ], - ); + return buildSharedLinkRow(leading: 'public_album', content: existingLink!.title); } if (existingLink!.type == SharedLinkSource.individual) { - return Row( - children: [ - const Text('shared_link_individual_shared', style: TextStyle(fontWeight: FontWeight.bold)).tr(), - const Text(" | ", style: TextStyle(fontWeight: FontWeight.bold)), - Expanded( - child: Text( - existingLink!.description ?? "--", - style: TextStyle(color: colorScheme.primary, fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - ), - ), - ], + return buildSharedLinkRow( + leading: 'shared_link_individual_shared', + content: existingLink!.description ?? "--", ); } } @@ -74,18 +90,16 @@ class SharedLinkEditPage extends HookConsumerWidget { Widget buildDescriptionField() { return TextField( controller: descriptionController, - enabled: newShareLink.value.isEmpty, focusNode: descriptionFocusNode, textInputAction: TextInputAction.done, autofocus: false, decoration: InputDecoration( labelText: 'description'.tr(), - labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary), + labelStyle: const TextStyle(fontWeight: FontWeight.bold), 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.withValues(alpha: 0.5))), ), onTapOutside: (_) => descriptionFocusNode.unfocus(), ); @@ -94,25 +108,49 @@ class SharedLinkEditPage extends HookConsumerWidget { Widget buildPasswordField() { return TextField( controller: passwordController, - enabled: newShareLink.value.isEmpty, autofocus: false, decoration: InputDecoration( labelText: 'password'.tr(), - labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary), + labelStyle: const TextStyle(fontWeight: FontWeight.bold), floatingLabelBehavior: FloatingLabelBehavior.always, border: const OutlineInputBorder(), hintText: 'shared_link_edit_password_hint'.tr(), hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), - disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))), ), ); } + Widget buildSlugField() { + return TextField( + controller: slugController, + focusNode: slugFocusNode, + textInputAction: TextInputAction.done, + autofocus: false, + decoration: InputDecoration( + hintText: 'custom_url'.tr(), + labelText: slugController.text.isNotEmpty ? 'custom_url'.tr() : null, + labelStyle: const TextStyle(fontWeight: FontWeight.bold), + border: const OutlineInputBorder(), + prefixText: slugController.text.isNotEmpty ? '/s/' : null, + prefixStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), + ), + onTapOutside: (_) => slugFocusNode.unfocus(), + ); + } + + String getShareLinkUrl(SharedLink link) { + var serverUrl = getServerUrl(); + if (serverUrl != null && !serverUrl.endsWith('/')) serverUrl += '/'; + if (serverUrl == null) return ''; + + final urlPath = link.slug?.isNotEmpty == true ? link.slug : link.key; + return '${serverUrl}s/$urlPath'; + } + Widget buildShowMetaButton() { return SwitchListTile.adaptive( value: showMetadata.value, - onChanged: newShareLink.value.isEmpty ? (value) => showMetadata.value = value : null, - activeThumbColor: colorScheme.primary, + onChanged: (value) => showMetadata.value = value, dense: true, title: Text("show_metadata", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(), ); @@ -121,8 +159,7 @@ class SharedLinkEditPage extends HookConsumerWidget { Widget buildAllowDownloadButton() { return SwitchListTile.adaptive( value: allowDownload.value, - onChanged: newShareLink.value.isEmpty ? (value) => allowDownload.value = value : null, - activeThumbColor: colorScheme.primary, + onChanged: (value) => allowDownload.value = value, dense: true, title: Text( "allow_public_user_to_download", @@ -134,8 +171,7 @@ class SharedLinkEditPage extends HookConsumerWidget { Widget buildAllowUploadButton() { return SwitchListTile.adaptive( value: allowUpload.value, - onChanged: newShareLink.value.isEmpty ? (value) => allowUpload.value = value : null, - activeThumbColor: colorScheme.primary, + onChanged: (value) => allowUpload.value = value, dense: true, title: Text( "allow_public_user_to_upload", @@ -144,68 +180,102 @@ class SharedLinkEditPage extends HookConsumerWidget { ); } - Widget buildEditExpiryButton() { - return SwitchListTile.adaptive( - value: editExpiry.value, - onChanged: newShareLink.value.isEmpty ? (value) => editExpiry.value = value : null, - activeThumbColor: colorScheme.primary, - dense: true, - title: Text( - "change_expiration_time", - style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), - ).tr(), + String formatDateTime(DateTime dateTime) => DateFormat.yMMMd(context.locale.toString()).add_Hm().format(dateTime); + + String getPresetLabel(String labelKey) => switch (labelKey) { + 'shared_link_edit_expire_after_option_minutes' => labelKey.tr(namedArgs: {'count': '30'}), + 'shared_link_edit_expire_after_option_hours' => labelKey.tr(namedArgs: {'count': '6'}), + 'shared_link_edit_expire_after_option_days' => labelKey.tr(namedArgs: {'count': '7'}), + 'shared_link_edit_expire_after_option_months' => labelKey.tr(namedArgs: {'count': '3'}), + 'shared_link_edit_expire_after_option_year' => labelKey.tr(namedArgs: {'count': '1'}), + _ => labelKey.tr(), + }; + + DateTime? getExpiresAtFromPreset(Duration preset) => preset == Duration.zero ? null : DateTime.now().add(preset); + + Future selectDate() async { + final today = DateTime.now(); + final safeInitialDate = expiryAfter.value ?? today.add(const Duration(days: 7)); + final initialDate = safeInitialDate.isBefore(today) ? today : safeInitialDate; + + final selectedDate = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: today, + lastDate: today.add(const Duration(days: maxFutureDate)), ); + + if (selectedDate != null && context.mounted) { + final isToday = + selectedDate.year == today.year && selectedDate.month == today.month && selectedDate.day == today.day; + final initialTime = isToday ? TimeOfDay.fromDateTime(today) : const TimeOfDay(hour: 12, minute: 0); + + final selectedTime = await showTimePicker(context: context, initialTime: initialTime); + + if (selectedTime != null) { + final now = DateTime.now(); + var finalDateTime = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, + selectedTime.hour, + selectedTime.minute, + ); + + if (finalDateTime.isBefore(now) && isToday) finalDateTime = now; + + expiryAfter.value = finalDateTime; + } + } } Widget buildExpiryAfterButton() { - return DropdownMenu( - label: Text( - "expire_after", - style: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary), - ).tr(), - enableSearch: false, - enableFilter: false, - width: context.width - 40, - initialSelection: expiryAfter.value, - enabled: newShareLink.value.isEmpty && (existingLink == null || editExpiry.value), - onSelected: (value) { - expiryAfter.value = value!; - }, - dropdownMenuEntries: [ - DropdownMenuEntry(value: 0, label: "never".tr()), - DropdownMenuEntry( - value: 30, - label: "shared_link_edit_expire_after_option_minutes".tr(namedArgs: {'count': "30"}), - ), - DropdownMenuEntry(value: 60, label: "shared_link_edit_expire_after_option_hour".tr()), - DropdownMenuEntry( - value: 60 * 6, - label: "shared_link_edit_expire_after_option_hours".tr(namedArgs: {'count': "6"}), - ), - DropdownMenuEntry(value: 60 * 24, label: "shared_link_edit_expire_after_option_day".tr()), - DropdownMenuEntry( - value: 60 * 24 * 7, - label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "7"}), - ), - DropdownMenuEntry( - value: 60 * 24 * 30, - label: "shared_link_edit_expire_after_option_days".tr(namedArgs: {'count': "30"}), - ), - DropdownMenuEntry( - value: 60 * 24 * 30 * 3, - label: "shared_link_edit_expire_after_option_months".tr(namedArgs: {'count': "3"}), - ), - DropdownMenuEntry( - value: 60 * 24 * 30 * 12, - label: "shared_link_edit_expire_after_option_year".tr(namedArgs: {'count': "1"}), + return ExpansionTile( + title: Text("expire_after", style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(), + subtitle: Text( + expiryAfter.value == null ? "shared_link_expires_never".tr() : formatDateTime(expiryAfter.value!), + style: TextStyle(color: themeData.colorScheme.primary), + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: expiryPresetsWithLabels + .map( + (preset) => ChoiceChip( + label: Text(getPresetLabel(preset.$2)), + selected: expiryAfter.value == getExpiresAtFromPreset(preset.$1), + onSelected: (_) => expiryAfter.value = getExpiresAtFromPreset(preset.$1), + ), + ) + .toList(), + ), + if (expiryAfter.value != null) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: selectDate, + icon: const Icon(Icons.edit_calendar), + label: const Text('edit_date_and_time').tr(), + ), + ), + ], + ], + ), ), ], ); } - void copyLinkToClipboard() { - Clipboard.setData(ClipboardData(text: newShareLink.value)).then((_) { - context.scaffoldMessenger.showSnackBar( + void copyToClipboard(String link) { + Clipboard.setData(ClipboardData(text: link)).then( + (_) => context.scaffoldMessenger.showSnackBar( SnackBar( content: Text( "shared_link_clipboard_copied_massage", @@ -213,43 +283,44 @@ class SharedLinkEditPage extends HookConsumerWidget { ).tr(), duration: const 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: () { - context.maybePop(); - }, - child: const Text("done", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(), - ), - ), - ), - ], + Widget buildLinkCopyField(String link) { + return TextFormField( + readOnly: true, + onTap: () => copyToClipboard(link), + initialValue: link, + decoration: InputDecoration( + border: const OutlineInputBorder(), + enabledBorder: themeData.inputDecorationTheme.focusedBorder, + suffixIcon: IconButton(onPressed: () => Share.share(link), icon: const Icon(Icons.share)), + ), ); } - DateTime calculateExpiry() { - return DateTime.now().add(Duration(minutes: expiryAfter.value)); + Widget buildNewLinkReadyScreen() { + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_link, size: 100, color: themeData.colorScheme.primary), + const SizedBox(height: 20), + buildLinkCopyField(newShareLink.value), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => context.maybePop(), + child: const Text("done", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(), + ), + ], + ), + ); } + DateTime? calculateExpiry() => expiryAfter.value; + Future handleNewLink() async { final newLink = await ref .read(sharedLinkServiceProvider) @@ -261,7 +332,8 @@ class SharedLinkEditPage extends HookConsumerWidget { allowUpload: allowUpload.value, description: descriptionController.text.isEmpty ? null : descriptionController.text, password: passwordController.text.isEmpty ? null : passwordController.text, - expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(), + slug: slugController.text.isEmpty ? null : slugController.text, + expiresAt: calculateExpiry()?.toUtc(), ); ref.invalidate(sharedLinksStateProvider); @@ -269,13 +341,12 @@ class SharedLinkEditPage extends HookConsumerWidget { final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain)); var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl(); - if (serverUrl != null && !serverUrl.endsWith('/')) { - serverUrl += '/'; - } + if (serverUrl != null && !serverUrl.endsWith('/')) serverUrl += '/'; if (newLink != null && serverUrl != null) { - newShareLink.value = "${serverUrl}share/${newLink.key}"; - copyLinkToClipboard(); + final urlPath = newLink.slug?.isNotEmpty == true ? newLink.slug : newLink.key; + newShareLink.value = "${serverUrl}s/$urlPath"; + copyToClipboard(newShareLink.value); } else if (newLink == null) { ImmichToast.show( context: context, @@ -292,6 +363,7 @@ class SharedLinkEditPage extends HookConsumerWidget { bool? meta; String? desc; String? password; + String? slug; DateTime? expiry; bool? changeExpiry; @@ -315,8 +387,13 @@ class SharedLinkEditPage extends HookConsumerWidget { password = passwordController.text; } - if (editExpiry.value) { - expiry = expiryAfter.value == 0 ? null : calculateExpiry(); + if (slugController.text != (existingLink!.slug ?? "")) { + slug = slugController.text.isEmpty ? null : slugController.text; + } + + final newExpiry = expiryAfter.value; + if (newExpiry != existingLink!.expiresAt) { + expiry = newExpiry; changeExpiry = true; } @@ -329,13 +406,29 @@ class SharedLinkEditPage extends HookConsumerWidget { allowUpload: upload, description: desc, password: password, - expiresAt: expiry, + slug: slug, + expiresAt: expiry?.toUtc(), changeExpiry: changeExpiry, ); ref.invalidate(sharedLinksStateProvider); await context.maybePop(); } + Future handleDeleteLink() async { + return showDialog( + context: context, + builder: (BuildContext context) => ConfirmDialog( + title: "delete_shared_link_dialog_title", + content: "confirm_delete_shared_link", + onOk: () async { + await ref.read(sharedLinkServiceProvider).deleteSharedLink(existingLink!.id); + ref.invalidate(sharedLinksStateProvider); + if (context.mounted) await context.maybePop(); + }, + ), + ); + } + return Scaffold( appBar: AppBar( title: Text(existingLink == null ? "create_link_to_share" : "edit_link").tr(), @@ -344,53 +437,73 @@ class SharedLinkEditPage extends HookConsumerWidget { centerTitle: false, ), body: SafeArea( - child: ListView( - children: [ - Padding(padding: const EdgeInsets.all(padding), child: buildLinkTitle()), - Padding(padding: const EdgeInsets.all(padding), child: buildDescriptionField()), - Padding(padding: const EdgeInsets.all(padding), child: buildPasswordField()), - 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, bottom: padding), - child: ElevatedButton( - onPressed: existingLink != null ? handleEditLink : handleNewLink, - child: Text( - existingLink != null ? "shared_link_edit_submit_button" : "create_link", - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - ).tr(), - ), + child: newShareLink.value.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: ListView( + children: [ + const SizedBox(height: 20), + buildLinkTitle(), + if (existingLink != null) + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 16), + buildLinkCopyField(getShareLinkUrl(existingLink!)), + const SizedBox(height: 24), + const Divider(), + ], + ), + const SizedBox(height: 24), + buildDescriptionField(), + const SizedBox(height: 16), + buildPasswordField(), + const SizedBox(height: 16), + buildSlugField(), + const SizedBox(height: 16), + buildShowMetaButton(), + const SizedBox(height: 16), + buildAllowDownloadButton(), + const SizedBox(height: 16), + buildAllowUploadButton(), + const SizedBox(height: 16), + buildExpiryAfterButton(), + const SizedBox(height: 24), + Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + if (existingLink != null) + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: themeData.colorScheme.error, + side: BorderSide(color: themeData.colorScheme.error), + ), + onPressed: handleDeleteLink, + icon: const Icon(Icons.delete_outline), + label: const Text( + "delete", + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ).tr(), + ), + ElevatedButton.icon( + icon: const Icon(Icons.check), + onPressed: existingLink != null ? handleEditLink : handleNewLink, + label: Text( + existingLink != null ? "shared_link_edit_submit_button" : "create_link", + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ).tr(), + ), + ], + ), + ), + const SizedBox(height: 40), + ], ), - ), - if (newShareLink.value.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: padding, right: padding, bottom: padding), - child: buildNewLinkField(), - ), - ], - ), + ) + : Center(child: buildNewLinkReadyScreen()), ), ); } diff --git a/mobile/lib/services/shared_link.service.dart b/mobile/lib/services/shared_link.service.dart index 25151c234fec6..46e83f0fc4032 100644 --- a/mobile/lib/services/shared_link.service.dart +++ b/mobile/lib/services/shared_link.service.dart @@ -37,6 +37,7 @@ class SharedLinkService { required bool allowUpload, String? description, String? password, + String? slug, String? albumId, List? assetIds, DateTime? expiresAt, @@ -54,6 +55,7 @@ class SharedLinkService { expiresAt: expiresAt, description: description, password: password, + slug: slug, ); } else if (assetIds != null) { dto = SharedLinkCreateDto( @@ -64,6 +66,7 @@ class SharedLinkService { expiresAt: expiresAt, description: description, password: password, + slug: slug, assetIds: assetIds, ); } @@ -88,6 +91,7 @@ class SharedLinkService { bool? changeExpiry = false, String? description, String? password, + String? slug, DateTime? expiresAt, }) async { try { @@ -100,6 +104,7 @@ class SharedLinkService { expiresAt: expiresAt, description: description, password: password, + slug: slug, changeExpiryTime: changeExpiry, ), ); diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart index cbd6e1f07705d..1045cd2a63804 100644 --- a/mobile/lib/widgets/shared_link/shared_link_item.dart +++ b/mobile/lib/widgets/shared_link/shared_link_item.dart @@ -23,26 +23,24 @@ class SharedLinkItem extends ConsumerWidget { const SharedLinkItem(this.sharedLink, {super.key}); - bool isExpired() { - if (sharedLink.expiresAt != null) { - return DateTime.now().isAfter(sharedLink.expiresAt!); - } - return false; - } + bool isExpired() => sharedLink.expiresAt?.isBefore(DateTime.now()) ?? false; - Widget getExpiryDuration(bool isDarkMode) { + Widget buildExpiryDuration() { var expiresText = "shared_link_expires_never".tr(); + IconData expiryIcon = Icons.schedule; + if (sharedLink.expiresAt != null) { if (isExpired()) { - return Text("expired", style: TextStyle(color: Colors.red[300])).tr(); + expiresText = "expired".tr(); + expiryIcon = Icons.timer_off_outlined; } + final difference = sharedLink.expiresAt!.difference(DateTime.now()); dPrint(() => "Difference: $difference"); + if (difference.inDays > 0) { var dayDifference = difference.inDays; - if (difference.inHours % 24 > 12) { - dayDifference += 1; - } + if (difference.inHours % 24 > 12) dayDifference += 1; expiresText = "shared_link_expires_days".tr(namedArgs: {'count': dayDifference.toString()}); } else if (difference.inHours > 0) { expiresText = "shared_link_expires_hours".tr(namedArgs: {'count': difference.inHours.toString()}); @@ -52,22 +50,20 @@ class SharedLinkItem extends ConsumerWidget { expiresText = "shared_link_expires_seconds".tr(namedArgs: {'count': difference.inSeconds.toString()}); } } - return Text(expiresText, style: TextStyle(color: isDarkMode ? Colors.grey[400] : Colors.grey[600])); + + return Row(children: [Icon(expiryIcon, size: 12), const SizedBox(width: 4), Text(expiresText)]); } @override Widget build(BuildContext context, WidgetRef ref) { - final colorScheme = context.colorScheme; - final isDarkMode = colorScheme.brightness == Brightness.dark; final thumbnailUrl = sharedLink.thumbAssetId != null ? getThumbnailUrlForRemoteId(sharedLink.thumbAssetId!) : null; final imageSize = math.min(context.width / 4, 100.0); void copyShareLinkToClipboard() { final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain)); var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl(); - if (serverUrl != null && !serverUrl.endsWith('/')) { - serverUrl += '/'; - } + if (serverUrl != null && !serverUrl.endsWith('/')) serverUrl += '/'; + if (serverUrl == null) { ImmichToast.show( context: context, @@ -78,8 +74,10 @@ class SharedLinkItem extends ConsumerWidget { return; } - Clipboard.setData(ClipboardData(text: "${serverUrl}share/${sharedLink.key}")).then((_) { - context.scaffoldMessenger.showSnackBar( + final urlPath = sharedLink.slug?.isNotEmpty == true ? sharedLink.slug : sharedLink.key; + + Clipboard.setData(ClipboardData(text: "${serverUrl}s/$urlPath")).then( + (_) => context.scaffoldMessenger.showSnackBar( SnackBar( content: Text( "shared_link_clipboard_copied_massage", @@ -87,70 +85,41 @@ class SharedLinkItem extends ConsumerWidget { ).tr(), duration: const Duration(seconds: 2), ), - ); - }); - } - - Future deleteShareLink() async { - return showDialog( - context: context, - builder: (BuildContext context) { - return ConfirmDialog( - title: "delete_shared_link_dialog_title", - content: "confirm_delete_shared_link", - 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: () {}, - ), - ), + child: thumbnailUrl == null + ? const Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + child: Icon(Icons.image_not_supported_outlined), + ) + : ThumbnailWithInfo( + imageUrl: thumbnailUrl, + key: key, + textInfo: '', + noImageIcon: Icons.image_not_supported_outlined, + onTap: () => context.pushRoute(SharedLinkEditRoute(existingLink: sharedLink)), + ), ); } Widget buildInfoChip(String labelText) { - return Padding( - padding: const EdgeInsets.only(right: 10), - child: Chip( - backgroundColor: colorScheme.primary, - label: Text( - labelText, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.black : Colors.white, - ), - ), - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(25))), + return Card.outlined( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Text(labelText, style: const TextStyle(fontSize: 11)), ), ); } - Widget buildBottomInfo() { + Widget buildShareParameterInfos() { return Row( + spacing: 4, children: [ if (sharedLink.allowUpload) buildInfoChip("upload".tr()), if (sharedLink.allowDownload) buildInfoChip("download".tr()), @@ -159,111 +128,68 @@ class SharedLinkItem extends ConsumerWidget { ); } - Widget buildSharedLinkActions() { - const actionIconSize = 20.0; - return Row( + Widget buildSharedLinkDetails() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, 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: () => context.pushRoute(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 + const SizedBox(height: 5), + Text( + sharedLink.title, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, ), - onPressed: copyShareLinkToClipboard, ), + if (sharedLink.description?.isNotEmpty ?? false) + Text(sharedLink.description!, overflow: TextOverflow.ellipsis), + buildExpiryDuration(), + buildShareParameterInfos(), ], ); } - Widget buildSharedLinkDetails() { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - getExpiryDuration(isDarkMode), - Padding( - padding: const EdgeInsets.only(top: 5), - child: Tooltip( - verticalOffset: 0, - decoration: BoxDecoration( - color: colorScheme.primary.withValues(alpha: 0.9), - borderRadius: const BorderRadius.all(Radius.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: colorScheme.primary, - fontWeight: FontWeight.bold, - overflow: TextOverflow.ellipsis, - ), - ), - ), + return Dismissible( + key: ValueKey(sharedLink.id), + direction: DismissDirection.endToStart, + background: Container( + color: Theme.of(context).colorScheme.error, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: Icon(Icons.delete, color: Theme.of(context).colorScheme.onError), + ), + confirmDismiss: (_) async { + final confirmed = await showDialog( + context: context, + builder: (BuildContext context) => ConfirmDialog( + title: "delete_shared_link_dialog_title", + content: "confirm_delete_shared_link", + onOk: () {}, ), - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, + ); + + if (confirmed == true) { + await ref.read(sharedLinksStateProvider.notifier).deleteLink(sharedLink.id); + return true; + } + + return false; + }, + child: InkWell( + onTap: () => context.pushRoute(SharedLinkEditRoute(existingLink: sharedLink)), + onLongPress: copyShareLinkToClipboard, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Tooltip( - verticalOffset: 0, - decoration: BoxDecoration( - color: colorScheme.primary.withValues(alpha: 0.9), - borderRadius: const BorderRadius.all(Radius.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()), + buildThumbnail(), + const SizedBox(width: 12), + Expanded(child: buildSharedLinkDetails()), ], ), - 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)), - ], + ), ); } }