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:
shenlong 2023-10-22 15:05:10 +00:00 committed by GitHub
parent cf08ac7538
commit 8dcc01b2be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1450 additions and 84 deletions

View 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(),
),
],
),
),
);
}
}

View 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()),
),
),
);
}
}