From 8f662fc459e1ac9872b53062a91cfead1ff18661 Mon Sep 17 00:00:00 2001 From: Lauritz Tieste <84938977+Lauritz-Tieste@users.noreply.github.com> Date: Mon, 18 May 2026 16:59:56 +0200 Subject: [PATCH] refactor: enhance shared link UI and functionality (#26464) * feat(shared-link): enhance shared link UI and functionality with new expiry options and improved layout * rebase & cleanup --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../library/shared_link/shared_link.page.dart | 93 ++-- .../shared_link/shared_link_edit.page.dart | 516 +++++++++++------- mobile/lib/utils/url_helper.dart | 11 + .../widgets/shared_link/shared_link_item.dart | 296 ++++------ 4 files changed, 463 insertions(+), 453 deletions(-) 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 261c6975ef..a4f52ebd4c 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'; @@ -28,71 +27,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 47a3dd853d..41486d7c98 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 @@ -6,15 +6,20 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; 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 int maxFutureDate = 365 * 2; + final SharedLink? existingLink; final List? assetsList; final String? albumId; @@ -23,71 +28,82 @@ class SharedLinkEditPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - const padding = 20.0; final themeData = context.themeData; final colorScheme = context.colorScheme; + final externalDomain = ref.watch(serverInfoProvider.select((s) => s.serverConfig.externalDomain)); + final displayServerUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl(); + final expiryPresets = <(Duration, String)>[ + (Duration.zero, context.t.never), + (const Duration(minutes: 30), context.t.shared_link_edit_expire_after_option_minutes(count: 30)), + (const Duration(hours: 1), context.t.shared_link_edit_expire_after_option_hour), + (const Duration(hours: 6), context.t.shared_link_edit_expire_after_option_hours(count: 6)), + (const Duration(days: 1), context.t.shared_link_edit_expire_after_option_day), + (const Duration(days: 7), context.t.shared_link_edit_expire_after_option_days(count: 7)), + (const Duration(days: 30), context.t.shared_link_edit_expire_after_option_days(count: 30)), + (const Duration(days: 90), context.t.shared_link_edit_expire_after_option_months(count: 3)), + (const Duration(days: 365), context.t.shared_link_edit_expire_after_option_year(count: 1)), + ]; 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 selectedPresetIndex = useState(existingLink?.expiresAt == null ? 0 : null); 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)), + ], + ); + } + 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: context.t.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: context.t.shared_link_individual_shared, + content: existingLink!.description ?? "--", ); } } - return const Text("create_link_to_share_description", style: TextStyle(fontWeight: FontWeight.bold)).tr(); + return Text(context.t.create_link_to_share_description, style: const TextStyle(fontWeight: FontWeight.bold)); } 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), + labelText: context.t.description, + labelStyle: const TextStyle(fontWeight: FontWeight.bold), floatingLabelBehavior: FloatingLabelBehavior.always, border: const OutlineInputBorder(), - hintText: 'shared_link_edit_description_hint'.tr(), + hintText: context.t.shared_link_edit_description_hint, hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), - disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))), ), onTapOutside: (_) => descriptionFocusNode.unfocus(), ); @@ -96,16 +112,14 @@ 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), + labelText: context.t.password, + labelStyle: const TextStyle(fontWeight: FontWeight.bold), floatingLabelBehavior: FloatingLabelBehavior.always, border: const OutlineInputBorder(), - hintText: 'shared_link_edit_password_hint'.tr(), + hintText: context.t.shared_link_edit_password_hint, hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), - disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))), ), ); } @@ -113,18 +127,16 @@ class SharedLinkEditPage extends HookConsumerWidget { Widget buildSlugField() { return TextField( controller: slugController, - enabled: newShareLink.value.isEmpty, focusNode: slugFocusNode, textInputAction: TextInputAction.done, autofocus: false, decoration: InputDecoration( - labelText: 'custom_url'.tr(), - labelStyle: TextStyle(fontWeight: FontWeight.bold, color: colorScheme.primary), - floatingLabelBehavior: FloatingLabelBehavior.always, + labelText: slugController.text.isNotEmpty ? context.t.custom_url : null, + labelStyle: const TextStyle(fontWeight: FontWeight.bold), border: const OutlineInputBorder(), - hintText: 'custom_url'.tr(), - hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), - disabledBorder: OutlineInputBorder(borderSide: BorderSide(color: Colors.grey.withValues(alpha: 0.5))), + hintText: context.t.custom_url, + prefixText: slugController.text.isNotEmpty ? '/s/' : null, + prefixStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), ), onTapOutside: (_) => slugFocusNode.unfocus(), ); @@ -133,145 +145,182 @@ class SharedLinkEditPage extends HookConsumerWidget { 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(), + title: Text( + context.t.show_metadata, + style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), + ), ); } 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", + context.t.allow_public_user_to_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, - activeThumbColor: colorScheme.primary, + onChanged: (value) => allowUpload.value = value, dense: true, title: Text( - "allow_public_user_to_upload", + context.t.allow_public_user_to_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, - 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); + + 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; + } + + selectedPresetIndex.value = null; + 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"}), - ), - ], - ); - } - - void copyLinkToClipboard() { - Clipboard.setData(ClipboardData(text: newShareLink.value)).then((_) { - context.scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - "shared_link_clipboard_copied_massage", - style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), - ).tr(), - duration: const Duration(seconds: 2), - ), - ); - }); - } - - Widget buildNewLinkField() { - return Column( + return ExpansionTile( + title: Text( + context.t.expire_after, + style: themeData.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold), + ), + subtitle: Text( + expiryAfter.value == null ? context.t.shared_link_expires_never : formatDateTime(expiryAfter.value!), + style: TextStyle(color: themeData.colorScheme.primary), + ), 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(), - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(expiryPresets.length, (index) { + final preset = expiryPresets[index]; + return ChoiceChip( + label: Text(preset.$2), + selected: selectedPresetIndex.value == index, + onSelected: (_) { + selectedPresetIndex.value = index; + expiryAfter.value = getExpiresAtFromPreset(preset.$1); + }, + ); + }), + ), + if (expiryAfter.value != null) ...[ + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: selectDate, + icon: const Icon(Icons.edit_calendar), + label: Text(context.t.edit_date_and_time), + ), + ), + ], + ], ), ), ], ); } - DateTime calculateExpiry() { - return DateTime.now().add(Duration(minutes: expiryAfter.value)); + Future copyToClipboard(String link) async { + await Clipboard.setData(ClipboardData(text: link)); + if (!context.mounted) { + return; + } + context.scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.t.shared_link_clipboard_copied_massage, + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), + ), + duration: const Duration(seconds: 2), + ), + ); } + 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)), + ), + ); + } + + 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.icon( + onPressed: () => context.maybePop(), + icon: const Icon(Icons.check), + label: Text(context.t.done, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } + + DateTime? calculateExpiry() => expiryAfter.value; + Future handleNewLink() async { final newLink = await ref .read(sharedLinkServiceProvider) @@ -284,30 +333,30 @@ class SharedLinkEditPage extends HookConsumerWidget { description: descriptionController.text.isEmpty ? null : descriptionController.text, password: passwordController.text.isEmpty ? null : passwordController.text, slug: slugController.text.isEmpty ? null : slugController.text, - expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(), + expiresAt: calculateExpiry()?.toUtc(), ); + if (!context.mounted) { + return; + } ref.invalidate(sharedLinksStateProvider); await ref.read(serverInfoProvider.notifier).getServerConfig(); + if (!context.mounted) { + return; + } final externalDomain = ref.read(serverInfoProvider.select((s) => s.serverConfig.externalDomain)); - var serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl(); - if (serverUrl != null && !serverUrl.endsWith('/')) { - serverUrl += '/'; - } + final serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl(); - if (newLink != null && serverUrl != null) { - final hasSlug = newLink.slug?.isNotEmpty == true; - final urlPath = hasSlug ? newLink.slug : newLink.key; - final basePath = hasSlug ? 's' : 'share'; - newShareLink.value = "$serverUrl$basePath/$urlPath"; - copyLinkToClipboard(); - } else if (newLink == null) { + if (newLink != null) { + newShareLink.value = buildSharedLinkUrl(baseUrl: serverUrl, slug: newLink.slug, key: newLink.key) ?? ''; + await copyToClipboard(newShareLink.value); + } else { ImmichToast.show( context: context, gravity: ToastGravity.BOTTOM, toastType: ToastType.error, - msg: 'shared_link_create_error'.tr(), + msg: context.t.shared_link_create_error, ); } } @@ -348,8 +397,9 @@ class SharedLinkEditPage extends HookConsumerWidget { slug = existingLink!.slug; } - if (editExpiry.value) { - expiry = expiryAfter.value == 0 ? null : calculateExpiry(); + final newExpiry = expiryAfter.value; + if (newExpiry?.toUtc() != existingLink!.expiresAt?.toUtc()) { + expiry = newExpiry; changeExpiry = true; } @@ -363,69 +413,115 @@ class SharedLinkEditPage extends HookConsumerWidget { description: desc, password: password, slug: slug, - expiresAt: expiry, + expiresAt: expiry?.toUtc(), changeExpiry: changeExpiry, ); + if (!context.mounted) { + return; + } 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(), + title: Text(existingLink == null ? context.t.create_link_to_share : context.t.edit_link), elevation: 0, leading: const CloseButton(), 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.all(padding), child: buildSlugField()), - 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( + buildSharedLinkUrl( + baseUrl: displayServerUrl, + slug: existingLink!.slug, + key: existingLink!.key, + ) ?? + '', + ), + 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: Text( + context.t.delete, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ), + ElevatedButton.icon( + icon: const Icon(Icons.check), + onPressed: existingLink != null ? handleEditLink : handleNewLink, + label: Text( + existingLink != null ? context.t.shared_link_edit_submit_button : context.t.create_link, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + 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/utils/url_helper.dart b/mobile/lib/utils/url_helper.dart index fc3b4bbb3f..b7dc41c4cf 100644 --- a/mobile/lib/utils/url_helper.dart +++ b/mobile/lib/utils/url_helper.dart @@ -24,6 +24,17 @@ String? getServerUrl() { ); } +String? buildSharedLinkUrl({required String? baseUrl, required String key, String? slug}) { + if (baseUrl == null || baseUrl.isEmpty) { + return null; + } + + final normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/'; + final path = (slug != null && slug.isNotEmpty) ? 's/$slug' : 'share/$key'; + + return '$normalizedBaseUrl$path'; +} + /// Converts a Unicode URL to its ASCII-compatible encoding (Punycode). /// /// This is especially useful for internationalized domain names (IDNs), diff --git a/mobile/lib/widgets/shared_link/shared_link_item.dart b/mobile/lib/widgets/shared_link/shared_link_item.dart index 19da80b833..d419d6ead0 100644 --- a/mobile/lib/widgets/shared_link/shared_link_item.dart +++ b/mobile/lib/widgets/shared_link/shared_link_item.dart @@ -1,201 +1,140 @@ import 'dart:math' as math; 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:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/models/shared_link/shared_link.model.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/shared_link.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/debug_print.dart'; import 'package:immich_mobile/utils/image_url_builder.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:immich_mobile/widgets/search/thumbnail_with_info.dart'; -import 'package:immich_mobile/utils/debug_print.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; - } + bool isExpired() => sharedLink.expiresAt?.isBefore(DateTime.now()) ?? false; + + Widget buildExpiryDuration(BuildContext context) { + var expiresText = context.t.shared_link_expires_never; + IconData expiryIcon = Icons.schedule; - Widget getExpiryDuration(bool isDarkMode) { - var expiresText = "shared_link_expires_never".tr(); if (sharedLink.expiresAt != null) { if (isExpired()) { - return Text("expired", style: TextStyle(color: Colors.red[300])).tr(); + expiresText = context.t.expired; + 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; } - expiresText = "shared_link_expires_days".tr(namedArgs: {'count': dayDifference.toString()}); + expiresText = context.t.shared_link_expires_days(count: dayDifference); } else if (difference.inHours > 0) { - expiresText = "shared_link_expires_hours".tr(namedArgs: {'count': difference.inHours.toString()}); + expiresText = context.t.shared_link_expires_hours(count: difference.inHours); } else if (difference.inMinutes > 0) { - expiresText = "shared_link_expires_minutes".tr(namedArgs: {'count': difference.inMinutes.toString()}); + expiresText = context.t.shared_link_expires_minutes(count: difference.inMinutes); } else if (difference.inSeconds > 0) { - expiresText = "shared_link_expires_seconds".tr(namedArgs: {'count': difference.inSeconds.toString()}); + expiresText = context.t.shared_link_expires_seconds(count: difference.inSeconds); } } - return Text(expiresText, style: TextStyle(color: isDarkMode ? Colors.grey[400] : Colors.grey[600])); + + return Row( + children: [ + Icon(expiryIcon, size: 12, color: isExpired() ? context.colorScheme.error : context.colorScheme.onSurface), + const SizedBox(width: 4), + Text( + expiresText, + style: TextStyle(color: isExpired() ? context.colorScheme.error : context.colorScheme.onSurface), + ), + ], + ); } @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() { + Future copyShareLinkToClipboard() async { 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) { + final serverUrl = externalDomain.isNotEmpty ? externalDomain : getServerUrl(); + final shareUrl = buildSharedLinkUrl(baseUrl: serverUrl, slug: sharedLink.slug, key: sharedLink.key); + + if (shareUrl == null) { ImmichToast.show( context: context, gravity: ToastGravity.BOTTOM, toastType: ToastType.error, - msg: "shared_link_error_server_url_fetch".tr(), + msg: context.t.shared_link_error_server_url_fetch, ); return; } - final hasSlug = sharedLink.slug?.isNotEmpty == true; - final urlPath = hasSlug ? sharedLink.slug : sharedLink.key; - final basePath = hasSlug ? 's' : 'share'; - Clipboard.setData(ClipboardData(text: "$serverUrl$basePath/$urlPath")).then((_) { - context.scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - "shared_link_clipboard_copied_massage", - style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), - ).tr(), - duration: const Duration(seconds: 2), + await Clipboard.setData(ClipboardData(text: shareUrl)); + if (!context.mounted) { + return; + } + context.scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.t.shared_link_clipboard_copied_massage, + style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor), ), - ); - }); - } - - 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), - ); - }, + duration: const Duration(seconds: 2), + ), ); } 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()), - if (sharedLink.showMetadata) buildInfoChip("shared_link_info_chip_metadata".tr()), - ], - ); - } - - 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: () => 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 - ), - onPressed: copyShareLinkToClipboard, - ), + if (sharedLink.allowUpload) buildInfoChip(context.t.upload), + if (sharedLink.allowDownload) buildInfoChip(context.t.download), + if (sharedLink.showMetadata) buildInfoChip(context.t.shared_link_info_chip_metadata), ], ); } @@ -204,69 +143,64 @@ class SharedLinkItem extends ConsumerWidget { 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, - ), - ), + const SizedBox(height: 5), + Text( + sharedLink.title, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + overflow: TextOverflow.ellipsis, ), ), - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - 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()), - ], - ), - buildBottomInfo(), + if (sharedLink.description?.isNotEmpty ?? false) + Text(sharedLink.description!, overflow: TextOverflow.ellipsis), + buildExpiryDuration(context), + buildShareParameterInfos(), ], ); } - 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()), - ), - ], + 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: () {}, + ), + ); + + 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: [ + buildThumbnail(), + const SizedBox(width: 12), + Expanded(child: buildSharedLinkDetails()), + ], + ), ), - const Padding(padding: EdgeInsets.all(20), child: Divider(height: 0)), - ], + ), ); } }