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>
pull/28479/head
Lauritz Tieste 2026-05-18 16:59:56 +02:00 committed by GitHub
parent 24b1dae9f2
commit 8f662fc459
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 463 additions and 453 deletions

View File

@ -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<SharedLink> 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),
),
);
}

View File

@ -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<String>? 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<DateTime?>(existingLink?.expiresAt?.toLocal());
final selectedPresetIndex = useState<int?>(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<void> 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<void> 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<void> 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<void> 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()),
),
);
}

View File

@ -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),

View File

@ -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<void> 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<void> 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<bool>(
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)),
],
),
);
}
}