mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(mobile): shared-links (#4490)
* add shared links page * feat(mobile): shared link items * feat(mobile): create / edit shared links page * server: add changeExpiryTime to SharedLinkEditDto * fix(mobile): edit expiry to never * mobile: add icon when shares list is empty * mobile: create new share from album / timeline * mobile: add translation texts * mobile: minor ui fixes * fix: handle serverURL with /api path * mobile: show share link on successful creation * mobile: shared links list - 2 column layout * mobile: use sharedlink pod class instead of dto * mobile: show error on link creation * mobile: show share icon only when remote assets are in selection * mobile: use server endpoint instead of server url * styling * styling --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
cf08ac7538
commit
8dcc01b2be
24 changed files with 1450 additions and 84 deletions
459
mobile/lib/modules/shared_link/views/shared_link_edit_page.dart
Normal file
459
mobile/lib/modules/shared_link/views/shared_link_edit_page.dart
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
|
||||
import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart';
|
||||
import 'package:immich_mobile/modules/shared_link/services/shared_link.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
|
||||
class SharedLinkEditPage extends HookConsumerWidget {
|
||||
final SharedLink? existingLink;
|
||||
final List<String>? assetsList;
|
||||
final String? albumId;
|
||||
|
||||
const SharedLinkEditPage({
|
||||
super.key,
|
||||
this.existingLink,
|
||||
this.assetsList,
|
||||
this.albumId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const padding = 20.0;
|
||||
final themeData = Theme.of(context);
|
||||
final descriptionController =
|
||||
useTextEditingController(text: existingLink?.description ?? "");
|
||||
final descriptionFocusNode = useFocusNode();
|
||||
final showMetadata = useState(existingLink?.showMetadata ?? true);
|
||||
final allowDownload = useState(existingLink?.allowDownload ?? true);
|
||||
final allowUpload = useState(existingLink?.allowUpload ?? false);
|
||||
final editExpiry = useState(false);
|
||||
final expiryAfter = useState(0);
|
||||
final newShareLink = useState("");
|
||||
|
||||
Widget buildLinkTitle() {
|
||||
if (existingLink != null) {
|
||||
if (existingLink!.type == SharedLinkSource.album) {
|
||||
return Row(
|
||||
children: [
|
||||
const Text(
|
||||
"Public album | ",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
existingLink!.title,
|
||||
style: TextStyle(
|
||||
color: themeData.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (existingLink!.type == SharedLinkSource.individual) {
|
||||
return Row(
|
||||
children: [
|
||||
const Text(
|
||||
"Individual shared | ",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
existingLink!.description ?? "--",
|
||||
style: TextStyle(
|
||||
color: themeData.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return const Text(
|
||||
"shared_link_create_info",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr();
|
||||
}
|
||||
|
||||
Widget buildDescriptionField() {
|
||||
return TextField(
|
||||
controller: descriptionController,
|
||||
enabled: newShareLink.value.isEmpty,
|
||||
focusNode: descriptionFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'shared_link_edit_description'.tr(),
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: themeData.primaryColor,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'shared_link_edit_description_hint'.tr(),
|
||||
hintStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)),
|
||||
),
|
||||
),
|
||||
onTapOutside: (_) => descriptionFocusNode.unfocus(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildShowMetaButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: showMetadata.value,
|
||||
onChanged: newShareLink.value.isEmpty
|
||||
? (value) => showMetadata.value = value
|
||||
: null,
|
||||
activeColor: themeData.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"shared_link_edit_show_meta",
|
||||
style: themeData.textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAllowDownloadButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: allowDownload.value,
|
||||
onChanged: newShareLink.value.isEmpty
|
||||
? (value) => allowDownload.value = value
|
||||
: null,
|
||||
activeColor: themeData.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"shared_link_edit_allow_download",
|
||||
style: themeData.textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAllowUploadButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: allowUpload.value,
|
||||
onChanged: newShareLink.value.isEmpty
|
||||
? (value) => allowUpload.value = value
|
||||
: null,
|
||||
activeColor: themeData.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"shared_link_edit_allow_upload",
|
||||
style: themeData.textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildEditExpiryButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: editExpiry.value,
|
||||
onChanged: newShareLink.value.isEmpty
|
||||
? (value) => editExpiry.value = value
|
||||
: null,
|
||||
activeColor: themeData.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"shared_link_edit_change_expiry",
|
||||
style: themeData.textTheme.labelLarge
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildExpiryAfterButton() {
|
||||
return DropdownMenu(
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
width: MediaQuery.of(context).size.width - 40,
|
||||
initialSelection: expiryAfter.value,
|
||||
enabled: newShareLink.value.isEmpty &&
|
||||
(existingLink == null || editExpiry.value),
|
||||
onSelected: (value) {
|
||||
expiryAfter.value = value!;
|
||||
},
|
||||
inputDecorationTheme: themeData.inputDecorationTheme.copyWith(
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)),
|
||||
),
|
||||
enabledBorder: const OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
dropdownMenuEntries: const [
|
||||
DropdownMenuEntry(value: 0, label: "Never"),
|
||||
DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: '30 minutes',
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60,
|
||||
label: '1 hour',
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 6,
|
||||
label: '6 hours',
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24,
|
||||
label: '1 day',
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24 * 7,
|
||||
label: '7 days',
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 60 * 24 * 30,
|
||||
label: '30 days',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void copyLinkToClipboard() {
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: newShareLink.value,
|
||||
),
|
||||
).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
"Copied to clipboard",
|
||||
),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildNewLinkField() {
|
||||
return Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 20,
|
||||
bottom: 20,
|
||||
),
|
||||
child: Divider(),
|
||||
),
|
||||
TextFormField(
|
||||
readOnly: true,
|
||||
initialValue: newShareLink.value,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
enabledBorder: themeData.inputDecorationTheme.focusedBorder,
|
||||
suffixIcon: IconButton(
|
||||
onPressed: copyLinkToClipboard,
|
||||
icon: const Icon(Icons.copy),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
AutoRouter.of(context).pop();
|
||||
},
|
||||
child: const Text(
|
||||
"Done",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
DateTime calculateExpiry() {
|
||||
return DateTime.now().add(Duration(minutes: expiryAfter.value));
|
||||
}
|
||||
|
||||
Future<void> handleNewLink() async {
|
||||
final newLink =
|
||||
await ref.read(sharedLinkServiceProvider).createSharedLink(
|
||||
albumId: albumId,
|
||||
assetIds: assetsList,
|
||||
showMeta: showMetadata.value,
|
||||
allowDownload: allowDownload.value,
|
||||
allowUpload: allowUpload.value,
|
||||
description: descriptionController.text.isEmpty
|
||||
? null
|
||||
: descriptionController.text,
|
||||
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
|
||||
);
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
final serverUrl = getServerUrl();
|
||||
if (newLink != null && serverUrl != null) {
|
||||
newShareLink.value = "$serverUrl/share/${newLink.key}";
|
||||
copyLinkToClipboard();
|
||||
} else if (newLink == null) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
msg: 'Error while creating shared link',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleEditLink() async {
|
||||
bool? download;
|
||||
bool? upload;
|
||||
bool? meta;
|
||||
String? desc;
|
||||
DateTime? expiry;
|
||||
bool? changeExpiry;
|
||||
|
||||
if (allowDownload.value != existingLink!.allowDownload) {
|
||||
download = allowDownload.value;
|
||||
}
|
||||
|
||||
if (allowUpload.value != existingLink!.allowUpload) {
|
||||
upload = allowUpload.value;
|
||||
}
|
||||
|
||||
if (showMetadata.value != existingLink!.showMetadata) {
|
||||
meta = showMetadata.value;
|
||||
}
|
||||
|
||||
if (descriptionController.text != existingLink!.description) {
|
||||
desc = descriptionController.text;
|
||||
}
|
||||
|
||||
if (editExpiry.value) {
|
||||
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
|
||||
changeExpiry = true;
|
||||
}
|
||||
|
||||
await ref.read(sharedLinkServiceProvider).updateSharedLink(
|
||||
existingLink!.id,
|
||||
showMeta: meta,
|
||||
allowDownload: download,
|
||||
allowUpload: upload,
|
||||
description: desc,
|
||||
expiresAt: expiry,
|
||||
changeExpiry: changeExpiry,
|
||||
);
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
AutoRouter.of(context).pop();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
existingLink == null
|
||||
? "shared_link_create_app_bar_title"
|
||||
: "shared_link_edit_app_bar_title",
|
||||
).tr(),
|
||||
elevation: 0,
|
||||
leading: const CloseButton(),
|
||||
centerTitle: false,
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(padding),
|
||||
child: buildLinkTitle(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(padding),
|
||||
child: buildDescriptionField(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: padding,
|
||||
right: padding,
|
||||
bottom: padding,
|
||||
),
|
||||
child: buildShowMetaButton(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: padding,
|
||||
right: padding,
|
||||
bottom: padding,
|
||||
),
|
||||
child: buildAllowDownloadButton(),
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: padding, right: 20, bottom: 20),
|
||||
child: buildAllowUploadButton(),
|
||||
),
|
||||
if (existingLink != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: padding,
|
||||
right: padding,
|
||||
bottom: padding,
|
||||
),
|
||||
child: buildEditExpiryButton(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: padding,
|
||||
right: padding,
|
||||
bottom: padding,
|
||||
),
|
||||
child: buildExpiryAfterButton(),
|
||||
),
|
||||
if (newShareLink.value.isEmpty)
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: padding + 10),
|
||||
child: ElevatedButton(
|
||||
onPressed:
|
||||
existingLink != null ? handleEditLink : handleNewLink,
|
||||
child: Text(
|
||||
existingLink != null
|
||||
? "shared_link_edit_submit_button"
|
||||
: "shared_link_create_submit_button",
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (newShareLink.value.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: padding,
|
||||
right: padding,
|
||||
),
|
||||
child: buildNewLinkField(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
126
mobile/lib/modules/shared_link/views/shared_link_page.dart
Normal file
126
mobile/lib/modules/shared_link/views/shared_link_page.dart
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
|
||||
import 'package:immich_mobile/modules/shared_link/providers/shared_link.provider.dart';
|
||||
import 'package:immich_mobile/modules/shared_link/ui/shared_link_item.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
class SharedLinkPage extends HookConsumerWidget {
|
||||
const SharedLinkPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sharedLinks = ref.watch(sharedLinksStateProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
ref.read(sharedLinksStateProvider.notifier).fetchLinks();
|
||||
return () => ref.invalidate(sharedLinksStateProvider);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
Widget buildNoShares() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
|
||||
child: const Text(
|
||||
"shared_link_manage_links",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: const Text(
|
||||
"shared_link_empty",
|
||||
style: TextStyle(fontSize: 14),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.link_off,
|
||||
size: 100,
|
||||
color: Theme.of(context).iconTheme.color?.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildSharesList(List<SharedLink> links) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0, top: 16.0, bottom: 30.0),
|
||||
child: const Text(
|
||||
"shared_link_manage_links",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (constraints.maxWidth > 600) {
|
||||
// Two column
|
||||
return GridView.builder(
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisExtent: 180,
|
||||
),
|
||||
itemCount: links.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SharedLinkItem(links.elementAt(index));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Single column
|
||||
return ListView.builder(
|
||||
itemCount: links.length,
|
||||
itemBuilder: (context, index) {
|
||||
return SharedLinkItem(links.elementAt(index));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("shared_link_app_bar_title").tr(),
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: sharedLinks.when(
|
||||
data: (links) =>
|
||||
links.isNotEmpty ? buildSharesList(links) : buildNoShares(),
|
||||
error: (error, stackTrace) => buildNoShares(),
|
||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue