1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02: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:
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 1424 additions and 83 deletions

View File

@ -3083,6 +3083,12 @@ export interface SharedLinkEditDto {
* @memberof SharedLinkEditDto
*/
'allowUpload'?: boolean;
/**
* Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.
* @type {boolean}
* @memberof SharedLinkEditDto
*/
'changeExpiryTime'?: boolean;
/**
*
* @type {string}

View File

@ -130,6 +130,7 @@
"control_bottom_app_bar_delete": "Delete",
"control_bottom_app_bar_favorite": "Favorite",
"control_bottom_app_bar_share": "Share",
"control_bottom_app_bar_share_to": "Share To",
"control_bottom_app_bar_stack": "Stack",
"control_bottom_app_bar_unarchive": "Unarchive",
"control_bottom_app_bar_upload": "Upload",
@ -287,6 +288,7 @@
"sharing_page_description": "Create shared albums to share photos and videos with people in your network.",
"sharing_page_empty_list": "EMPTY LIST",
"sharing_silver_appbar_create_shared_album": "Create shared album",
"sharing_silver_appbar_shared_links": "Shared links",
"sharing_silver_appbar_share_partner": "Share with partner",
"tab_controller_nav_library": "Library",
"tab_controller_nav_photos": "Photos",
@ -343,5 +345,21 @@
"trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_remove_from_stack": "Remove from Stack",
"viewer_unstack": "Un-Stack"
"viewer_unstack": "Un-Stack",
"delete_shared_link_dialog_title": "Delete Shared Link",
"delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?",
"shared_link_create_app_bar_title": "Create link to share",
"shared_link_edit_app_bar_title": "Edit link",
"shared_link_create_info": "Let anyone with the link see the selected photo(s)",
"shared_link_edit_description": "Description",
"shared_link_edit_description_hint": "Enter the share description",
"shared_link_edit_show_meta": "Show metadata",
"shared_link_edit_allow_download": "Allow public user to download",
"shared_link_edit_allow_upload": "Allow public user to upload",
"shared_link_edit_change_expiry": "Change expiration time",
"shared_link_edit_submit_button": "Update link",
"shared_link_create_submit_button": "Create link",
"shared_link_app_bar_title": "Shared Links",
"shared_link_manage_links": "Manage Shared links",
"shared_link_empty": "You don't have any shared links"
}

View File

@ -210,6 +210,18 @@ class AlbumViewerAppbar extends HookConsumerWidget
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
ListTile(
leading: const Icon(Icons.share_rounded),
onTap: () {
AutoRouter.of(context)
.push(SharedLinkEditRoute(albumId: album.remoteId));
Navigator.pop(context);
},
title: const Text(
"control_bottom_app_bar_share",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
),
ListTile(
leading: const Icon(Icons.settings_rounded),
onTap: () =>

View File

@ -147,13 +147,13 @@ class SharingPage extends HookConsumerWidget {
Expanded(
child: ElevatedButton.icon(
onPressed: () =>
AutoRouter.of(context).push(const PartnerRoute()),
AutoRouter.of(context).push(const SharedLinkRoute()),
icon: const Icon(
Icons.swap_horizontal_circle_outlined,
Icons.link,
size: 20,
),
label: const Text(
"sharing_silver_appbar_share_partner",
"sharing_silver_appbar_shared_links",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 11,
@ -179,6 +179,17 @@ class SharingPage extends HookConsumerWidget {
fontSize: 22,
),
),
actions: [
IconButton(
splashRadius: 25,
iconSize: 20,
icon: const Icon(
Icons.swap_horizontal_circle_outlined,
size: 20,
),
onPressed: () => AutoRouter.of(context).push(const PartnerRoute()),
),
],
);
}

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -12,7 +10,7 @@ import 'package:immich_mobile/shared/ui/drag_sheet.dart';
import 'package:immich_mobile/shared/models/album.dart';
class ControlBottomAppBar extends ConsumerWidget {
final void Function() onShare;
final void Function(bool shareLocal) onShare;
final void Function() onFavorite;
final void Function() onArchive;
final void Function() onDelete;
@ -51,73 +49,73 @@ class ControlBottomAppBar extends ConsumerWidget {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
Widget renderActionButtons() {
return Wrap(
spacing: 10,
runSpacing: 15,
children: [
List<Widget> renderActionButtons() {
return [
if (hasRemote)
ControlBoxButton(
iconData: Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
iconData: Icons.share_rounded,
label: "control_bottom_app_bar_share".tr(),
onPressed: enabled ? onShare : null,
onPressed: enabled ? () => onShare(false) : null,
),
if (hasRemote)
ControlBoxButton(
iconData: Icons.archive,
label: "control_bottom_app_bar_archive".tr(),
onPressed: enabled ? onArchive : null,
),
if (hasRemote)
ControlBoxButton(
iconData: Icons.favorite_border_rounded,
label: "control_bottom_app_bar_favorite".tr(),
onPressed: enabled ? onFavorite : null,
),
ControlBoxButton(
iconData: Icons.ios_share_rounded,
label: "control_bottom_app_bar_share_to".tr(),
onPressed: enabled ? () => onShare(true) : null,
),
if (hasRemote)
ControlBoxButton(
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_delete".tr(),
onPressed: enabled
? () {
if (!trashEnabled) {
showDialog(
context: context,
builder: (BuildContext context) {
return DeleteDialog(
onDelete: onDelete,
);
},
);
} else {
onDelete();
}
iconData: Icons.archive,
label: "control_bottom_app_bar_archive".tr(),
onPressed: enabled ? onArchive : null,
),
if (hasRemote)
ControlBoxButton(
iconData: Icons.favorite_border_rounded,
label: "control_bottom_app_bar_favorite".tr(),
onPressed: enabled ? onFavorite : null,
),
ControlBoxButton(
iconData: Icons.delete_outline_rounded,
label: "control_bottom_app_bar_delete".tr(),
onPressed: enabled
? () {
if (!trashEnabled) {
showDialog(
context: context,
builder: (BuildContext context) {
return DeleteDialog(
onDelete: onDelete,
);
},
);
} else {
onDelete();
}
}
: null,
),
if (!hasLocal)
ControlBoxButton(
iconData: Icons.filter_none_rounded,
label: "control_bottom_app_bar_stack".tr(),
onPressed: enabled ? onStack : null,
),
if (!hasRemote)
ControlBoxButton(
iconData: Icons.backup_outlined,
label: "Upload",
onPressed: enabled
? () => showDialog(
context: context,
builder: (BuildContext context) {
return UploadDialog(
onUpload: onUpload,
);
},
)
: null,
),
if (!hasRemote)
ControlBoxButton(
iconData: Icons.backup_outlined,
label: "control_bottom_app_bar_upload".tr(),
onPressed: enabled
? () => showDialog(
context: context,
builder: (BuildContext context) {
return UploadDialog(
onUpload: onUpload,
);
},
)
: null,
),
if (!hasLocal)
ControlBoxButton(
iconData: Icons.filter_none_rounded,
label: "control_bottom_app_bar_stack".tr(),
onPressed: enabled ? onStack : null,
),
],
);
];
}
return DraggableScrollableSheet(
@ -149,7 +147,13 @@ class ControlBottomAppBar extends ConsumerWidget {
const SizedBox(height: 12),
const CustomDraggingHandle(),
const SizedBox(height: 12),
renderActionButtons(),
SizedBox(
height: 70,
child: ListView(
scrollDirection: Axis.horizontal,
children: renderActionButtons(),
),
),
if (hasRemote)
const Divider(
indent: 16,
@ -173,10 +177,6 @@ class ControlBottomAppBar extends ConsumerWidget {
enabled: enabled,
),
),
if (hasRemote)
const SliverToBoxAdapter(
child: SizedBox(height: 200),
),
],
),
);
@ -209,7 +209,10 @@ class AddToAlbumTitleRow extends StatelessWidget {
).tr(),
TextButton.icon(
onPressed: onCreateNewAlbum,
icon: const Icon(Icons.add),
icon: Icon(
Icons.add,
color: Theme.of(context).primaryColor,
),
label: Text(
"common_create_new_album",
style: TextStyle(

View File

@ -91,12 +91,6 @@ class HomePage extends HookConsumerWidget {
SelectionAssetState.fromSelection(selectedAssets);
}
void onShareAssets() {
handleShareAssets(ref, context, selection.value.toList());
selectionEnabledHook.value = false;
}
List<Asset> remoteOnlySelection({String? localErrorMessage}) {
final Set<Asset> assets = selection.value;
final bool onlyRemote = assets.every((e) => e.isRemote);
@ -113,6 +107,19 @@ class HomePage extends HookConsumerWidget {
return assets.toList();
}
void onShareAssets(bool shareLocal) {
processing.value = true;
if (shareLocal) {
handleShareAssets(ref, context, selection.value.toList());
} else {
final ids = remoteOnlySelection().map((e) => e.remoteId!);
AutoRouter.of(context)
.push(SharedLinkEditRoute(assetsList: ids.toList()));
}
processing.value = false;
selectionEnabledHook.value = false;
}
void onFavoriteAssets() async {
processing.value = true;
try {

View File

@ -0,0 +1,107 @@
import 'package:openapi/api.dart';
enum SharedLinkSource { album, individual }
class SharedLink {
final String id;
final String title;
final bool allowDownload;
final bool allowUpload;
final String? thumbAssetId;
final String? description;
final DateTime? expiresAt;
final String key;
final bool showMetadata;
final SharedLinkSource type;
const SharedLink({
required this.id,
required this.title,
required this.allowDownload,
required this.allowUpload,
required this.thumbAssetId,
required this.description,
required this.expiresAt,
required this.key,
required this.showMetadata,
required this.type,
});
SharedLink copyWith({
String? id,
String? title,
String? thumbAssetId,
bool? allowDownload,
bool? allowUpload,
String? description,
DateTime? expiresAt,
String? key,
bool? showMetadata,
SharedLinkSource? type,
}) {
return SharedLink(
id: id ?? this.id,
title: title ?? this.title,
thumbAssetId: thumbAssetId ?? this.thumbAssetId,
allowDownload: allowDownload ?? this.allowDownload,
allowUpload: allowUpload ?? this.allowUpload,
description: description ?? this.description,
expiresAt: expiresAt ?? this.expiresAt,
key: key ?? this.key,
showMetadata: showMetadata ?? this.showMetadata,
type: type ?? this.type,
);
}
SharedLink.fromDto(SharedLinkResponseDto dto)
: id = dto.id,
allowDownload = dto.allowDownload,
allowUpload = dto.allowUpload,
description = dto.description,
expiresAt = dto.expiresAt,
key = dto.key,
showMetadata = dto.showMetadata,
type = dto.type == SharedLinkType.ALBUM
? SharedLinkSource.album
: SharedLinkSource.individual,
title = dto.type == SharedLinkType.ALBUM
? dto.album?.albumName.toUpperCase() ?? "UNKNOWN SHARE"
: "INDIVIDUAL SHARE",
thumbAssetId = dto.type == SharedLinkType.ALBUM
? dto.album?.albumThumbnailAssetId
: dto.assets.isNotEmpty
? dto.assets[0].id
: null;
@override
String toString() =>
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SharedLink &&
other.id == id &&
other.title == title &&
other.thumbAssetId == thumbAssetId &&
other.allowDownload == allowDownload &&
other.allowUpload == allowUpload &&
other.description == description &&
other.expiresAt == expiresAt &&
other.key == key &&
other.showMetadata == showMetadata &&
other.type == type;
@override
int get hashCode =>
id.hashCode ^
title.hashCode ^
thumbAssetId.hashCode ^
allowDownload.hashCode ^
allowUpload.hashCode ^
description.hashCode ^
expiresAt.hashCode ^
key.hashCode ^
showMetadata.hashCode ^
type.hashCode;
}

View File

@ -0,0 +1,29 @@
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/services/shared_link.service.dart';
class SharedLinksNotifier extends StateNotifier<AsyncValue<List<SharedLink>>> {
final SharedLinkService _sharedLinkService;
SharedLinksNotifier(this._sharedLinkService) : super(const AsyncLoading()) {
fetchLinks();
}
Future<void> fetchLinks() async {
state = await _sharedLinkService.getAllSharedLinks();
}
Future<void> deleteLink(String id) async {
await _sharedLinkService.deleteSharedLink(id);
state = const AsyncLoading();
fetchLinks();
}
}
final sharedLinksStateProvider =
StateNotifierProvider<SharedLinksNotifier, AsyncValue<List<SharedLink>>>(
(ref) {
return SharedLinksNotifier(
ref.watch(sharedLinkServiceProvider),
);
});

View File

@ -0,0 +1,115 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final sharedLinkServiceProvider = Provider(
(ref) => SharedLinkService(ref.watch(apiServiceProvider)),
);
class SharedLinkService {
final ApiService _apiService;
final Logger _log = Logger("SharedLinkService");
SharedLinkService(this._apiService);
Future<AsyncValue<List<SharedLink>>> getAllSharedLinks() async {
try {
final list = await _apiService.sharedLinkApi.getAllSharedLinks();
return list != null
? AsyncData(list.map(SharedLink.fromDto).toList())
: const AsyncData([]);
} catch (e, stack) {
_log.severe("failed to fetch shared links - $e");
return AsyncError(e, stack);
}
}
Future<void> deleteSharedLink(String id) async {
try {
return await _apiService.sharedLinkApi.removeSharedLink(id);
} catch (e) {
_log.severe("failed to delete shared link id - $id with error - $e");
}
}
Future<SharedLink?> createSharedLink({
required bool showMeta,
required bool allowDownload,
required bool allowUpload,
String? description,
String? albumId,
List<String>? assetIds,
DateTime? expiresAt,
}) async {
try {
final type =
albumId != null ? SharedLinkType.ALBUM : SharedLinkType.INDIVIDUAL;
SharedLinkCreateDto? dto;
if (type == SharedLinkType.ALBUM) {
dto = SharedLinkCreateDto(
type: type,
albumId: albumId,
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
);
} else if (assetIds != null) {
dto = SharedLinkCreateDto(
type: type,
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
assetIds: assetIds,
);
}
if (dto != null) {
final responseDto =
await _apiService.sharedLinkApi.createSharedLink(dto);
if (responseDto != null) {
return SharedLink.fromDto(responseDto);
}
}
} catch (e) {
_log.severe("failed to create shared link with error - $e");
}
return null;
}
Future<SharedLink?> updateSharedLink(
String id, {
required bool? showMeta,
required bool? allowDownload,
required bool? allowUpload,
bool? changeExpiry = false,
String? description,
DateTime? expiresAt,
}) async {
try {
final responseDto = await _apiService.sharedLinkApi.updateSharedLink(
id,
SharedLinkEditDto(
showMetadata: showMeta,
allowDownload: allowDownload,
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
changeExpiryTime: changeExpiry,
),
);
if (responseDto != null) {
return SharedLink.fromDto(responseDto);
}
} catch (e) {
_log.severe("failed to update shared link id - $id with error - $e");
}
return null;
}
}

View File

@ -0,0 +1,307 @@
import 'dart:math' as math;
import 'package:auto_route/auto_route.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/modules/search/ui/thumbnail_with_info.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/routing/router.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/url_helper.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;
}
Widget getExpiryDuration(bool isDarkMode) {
var expiresText = "Expires ∞";
if (sharedLink.expiresAt != null) {
if (isExpired()) {
return Text(
"Expired",
style: TextStyle(color: Colors.red[300]),
);
}
final difference = sharedLink.expiresAt!.difference(DateTime.now());
debugPrint("Difference: $difference");
if (difference.inDays > 0) {
var dayDifference = difference.inDays;
if (difference.inHours % 24 > 12) {
dayDifference += 1;
}
expiresText = "in $dayDifference days";
} else if (difference.inHours > 0) {
expiresText = "in ${difference.inHours} hours";
} else if (difference.inMinutes > 0) {
expiresText = "in ${difference.inMinutes} minutes";
} else if (difference.inSeconds > 0) {
expiresText = "in ${difference.inSeconds} seconds";
}
}
return Text(
expiresText,
style: TextStyle(color: isDarkMode ? Colors.grey[400] : Colors.grey[600]),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeData = Theme.of(context);
final isDarkMode = themeData.brightness == Brightness.dark;
final thumbnailUrl = sharedLink.thumbAssetId != null
? getThumbnailUrlForRemoteId(sharedLink.thumbAssetId!)
: null;
final imageSize = math.min(MediaQuery.of(context).size.width / 4, 100.0);
void copyShareLinkToClipboard() {
final serverUrl = getServerUrl();
if (serverUrl == null) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: 'Cannot fetch the server url',
);
return;
}
Clipboard.setData(
ClipboardData(
text: "$serverUrl/share/${sharedLink.key}",
),
).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Copied to clipboard",
),
duration: Duration(seconds: 2),
),
);
});
}
Future<void> deleteShareLink() async {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "delete_shared_link_dialog_title",
content: "delete_shared_link_dialog_content",
onOk: () => ref
.read(sharedLinksStateProvider.notifier)
.deleteLink(sharedLink.id),
);
},
);
}
Widget buildThumbnail() {
if (thumbnailUrl == null) {
return Container(
height: imageSize * 1.2,
width: imageSize,
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[800] : Colors.grey[200],
),
child: Center(
child: Icon(
Icons.image_not_supported_outlined,
color: isDarkMode ? Colors.grey[100] : Colors.grey[700],
),
),
);
}
return SizedBox(
height: imageSize * 1.2,
width: imageSize,
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: ThumbnailWithInfo(
imageUrl: thumbnailUrl,
key: key,
textInfo: '',
noImageIcon: Icons.image_not_supported_outlined,
onTap: () {},
),
),
);
}
Widget buildInfoChip(String labelText) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: Chip(
backgroundColor: themeData.primaryColor,
label: Text(
labelText,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.black : Colors.white,
),
),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(25)),
),
),
);
}
Widget buildBottomInfo() {
return Row(
children: [
if (sharedLink.allowUpload) buildInfoChip("Upload"),
if (sharedLink.allowDownload) buildInfoChip("Download"),
if (sharedLink.showMetadata) buildInfoChip("EXIF"),
],
);
}
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: () => AutoRouter.of(context)
.push(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,
),
],
);
}
Widget buildSharedLinkDetails() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
getExpiryDuration(isDarkMode),
Padding(
padding: const EdgeInsets.only(top: 5),
child: Tooltip(
verticalOffset: 0,
decoration: BoxDecoration(
color: themeData.primaryColor.withOpacity(0.9),
borderRadius: BorderRadius.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: themeData.primaryColor,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
),
),
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Tooltip(
verticalOffset: 0,
decoration: BoxDecoration(
color: themeData.primaryColor.withOpacity(0.9),
borderRadius: BorderRadius.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(),
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 15),
child: buildThumbnail(),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 15),
child: buildSharedLinkDetails(),
),
),
],
),
const Padding(
padding: EdgeInsets.all(20),
child: Divider(
height: 0,
),
),
],
);
}
}

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

View File

@ -28,6 +28,9 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
import 'package:immich_mobile/modules/shared_link/views/shared_link_edit_page.dart';
import 'package:immich_mobile/modules/shared_link/views/shared_link_page.dart';
import 'package:immich_mobile/modules/trash/views/trash_page.dart';
import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
import 'package:immich_mobile/modules/search/views/all_people_page.dart';
@ -158,6 +161,8 @@ part 'router.gr.dart';
AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: TrashPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SharedLinkPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SharedLinkEditPage, guards: [AuthGuard, DuplicateGuard]),
],
)
class AppRouter extends _$AppRouter {

View File

@ -320,6 +320,25 @@ class _$AppRouter extends RootStackRouter {
child: const TrashPage(),
);
},
SharedLinkRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const SharedLinkPage(),
);
},
SharedLinkEditRoute.name: (routeData) {
final args = routeData.argsAs<SharedLinkEditRouteArgs>(
orElse: () => const SharedLinkEditRouteArgs());
return MaterialPageX<dynamic>(
routeData: routeData,
child: SharedLinkEditPage(
key: args.key,
existingLink: args.existingLink,
assetsList: args.assetsList,
albumId: args.albumId,
),
);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
@ -640,6 +659,22 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
SharedLinkRoute.name,
path: '/shared-link-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
SharedLinkEditRoute.name,
path: '/shared-link-edit-page',
guards: [
authGuard,
duplicateGuard,
],
),
];
}
@ -1432,6 +1467,62 @@ class TrashRoute extends PageRouteInfo<void> {
static const String name = 'TrashRoute';
}
/// generated route for
/// [SharedLinkPage]
class SharedLinkRoute extends PageRouteInfo<void> {
const SharedLinkRoute()
: super(
SharedLinkRoute.name,
path: '/shared-link-page',
);
static const String name = 'SharedLinkRoute';
}
/// generated route for
/// [SharedLinkEditPage]
class SharedLinkEditRoute extends PageRouteInfo<SharedLinkEditRouteArgs> {
SharedLinkEditRoute({
Key? key,
SharedLink? existingLink,
List<String>? assetsList,
String? albumId,
}) : super(
SharedLinkEditRoute.name,
path: '/shared-link-edit-page',
args: SharedLinkEditRouteArgs(
key: key,
existingLink: existingLink,
assetsList: assetsList,
albumId: albumId,
),
);
static const String name = 'SharedLinkEditRoute';
}
class SharedLinkEditRouteArgs {
const SharedLinkEditRouteArgs({
this.key,
this.existingLink,
this.assetsList,
this.albumId,
});
final Key? key;
final SharedLink? existingLink;
final List<String>? assetsList;
final String? albumId;
@override
String toString() {
return 'SharedLinkEditRouteArgs{key: $key, existingLink: $existingLink, assetsList: $assetsList, albumId: $albumId}';
}
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View File

@ -21,6 +21,7 @@ class ApiService {
late PartnerApi partnerApi;
late PersonApi personApi;
late AuditApi auditApi;
late SharedLinkApi sharedLinkApi;
ApiService() {
final endpoint = Store.tryGet(StoreKey.serverEndpoint);
@ -45,6 +46,7 @@ class ApiService {
partnerApi = PartnerApi(_apiClient);
personApi = PersonApi(_apiClient);
auditApi = AuditApi(_apiClient);
sharedLinkApi = SharedLinkApi(_apiClient);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {

View File

@ -41,7 +41,12 @@ ThemeData immichLightTheme = ThemeData(
fontFamily: 'WorkSans',
scaffoldBackgroundColor: immichBackgroundColor,
snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
contentTextStyle: TextStyle(
fontFamily: 'WorkSans',
color: Colors.indigo,
fontWeight: FontWeight.bold,
),
backgroundColor: Colors.white,
),
appBarTheme: AppBarTheme(
titleTextStyle: const TextStyle(
@ -156,8 +161,13 @@ ThemeData immichDarkTheme = ThemeData(
scaffoldBackgroundColor: immichDarkBackgroundColor,
hintColor: Colors.grey[600],
fontFamily: 'WorkSans',
snackBarTheme: const SnackBarThemeData(
contentTextStyle: TextStyle(fontFamily: 'WorkSans'),
snackBarTheme: SnackBarThemeData(
contentTextStyle: TextStyle(
fontFamily: 'WorkSans',
color: immichDarkThemePrimaryColor,
fontWeight: FontWeight.bold,
),
backgroundColor: Colors.grey[900],
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(

View File

@ -1,3 +1,5 @@
import 'package:immich_mobile/shared/models/store.dart';
String sanitizeUrl(String url) {
// Add schema if none is set
final urlWithSchema =
@ -6,3 +8,15 @@ String sanitizeUrl(String url) {
// Remove trailing slash(es)
return urlWithSchema.replaceFirst(RegExp(r"/+$"), "");
}
String? getServerUrl() {
final serverUrl = Store.tryGet(StoreKey.serverEndpoint);
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null) {
return null;
}
return serverUri.hasPort
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
: "${serverUri.scheme}://${serverUri.host}";
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -7902,6 +7902,10 @@
"allowUpload": {
"type": "boolean"
},
"changeExpiryTime": {
"description": "Few clients cannot send null to set the expiryTime to never.\nSetting this flag and not sending expiryAt is considered as null instead.\nClients that can send null values can ignore this.",
"type": "boolean"
},
"description": {
"type": "string"
},

View File

@ -52,4 +52,13 @@ export class SharedLinkEditDto {
@Optional()
showMetadata?: boolean;
/**
* Few clients cannot send null to set the expiryTime to never.
* Setting this flag and not sending expiryAt is considered as null instead.
* Clients that can send null values can ignore this.
*/
@Optional()
@IsBoolean()
changeExpiryTime?: boolean;
}

View File

@ -81,7 +81,7 @@ export class SharedLinkService {
id,
userId: authUser.id,
description: dto.description,
expiresAt: dto.expiresAt,
expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt,
allowUpload: dto.allowUpload,
allowDownload: dto.allowDownload,
showExif: dto.showMetadata,

View File

@ -3083,6 +3083,12 @@ export interface SharedLinkEditDto {
* @memberof SharedLinkEditDto
*/
'allowUpload'?: boolean;
/**
* Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.
* @type {boolean}
* @memberof SharedLinkEditDto
*/
'changeExpiryTime'?: boolean;
/**
*
* @type {string}