manage_links_widget.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. import 'dart:convert';
  2. import 'package:collection/collection.dart';
  3. import "package:fast_base58/fast_base58.dart";
  4. import 'package:flutter/material.dart';
  5. import "package:flutter/services.dart";
  6. import "package:photos/generated/l10n.dart";
  7. import "package:photos/models/api/collection/public_url.dart";
  8. import 'package:photos/models/collection/collection.dart';
  9. import 'package:photos/services/collections_service.dart';
  10. import 'package:photos/theme/colors.dart';
  11. import 'package:photos/theme/ente_theme.dart';
  12. import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
  13. import 'package:photos/ui/components/captioned_text_widget.dart';
  14. import 'package:photos/ui/components/divider_widget.dart';
  15. import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
  16. import 'package:photos/ui/components/menu_section_description_widget.dart';
  17. import "package:photos/ui/components/toggle_switch_widget.dart";
  18. import 'package:photos/ui/sharing/pickers/device_limit_picker_page.dart';
  19. import 'package:photos/ui/sharing/pickers/link_expiry_picker_page.dart';
  20. import 'package:photos/utils/crypto_util.dart';
  21. import 'package:photos/utils/date_time_util.dart';
  22. import 'package:photos/utils/dialog_util.dart';
  23. import 'package:photos/utils/navigation_util.dart';
  24. import "package:photos/utils/share_util.dart";
  25. import 'package:photos/utils/toast_util.dart';
  26. class ManageSharedLinkWidget extends StatefulWidget {
  27. final Collection? collection;
  28. const ManageSharedLinkWidget({Key? key, this.collection}) : super(key: key);
  29. @override
  30. State<ManageSharedLinkWidget> createState() => _ManageSharedLinkWidgetState();
  31. }
  32. class _ManageSharedLinkWidgetState extends State<ManageSharedLinkWidget> {
  33. final CollectionActions sharingActions =
  34. CollectionActions(CollectionsService.instance);
  35. @override
  36. void initState() {
  37. super.initState();
  38. }
  39. @override
  40. Widget build(BuildContext context) {
  41. final isCollectEnabled =
  42. widget.collection!.publicURLs?.firstOrNull?.enableCollect ?? false;
  43. final isDownloadEnabled =
  44. widget.collection!.publicURLs?.firstOrNull?.enableDownload ?? true;
  45. final isPasswordEnabled =
  46. widget.collection!.publicURLs?.firstOrNull?.passwordEnabled ?? false;
  47. final enteColorScheme = getEnteColorScheme(context);
  48. final PublicURL url = widget.collection!.publicURLs!.firstOrNull!;
  49. final String collectionKey = Base58Encode(
  50. CollectionsService.instance.getCollectionKey(widget.collection!.id),
  51. );
  52. final String urlValue = "${url.url}#$collectionKey";
  53. return Scaffold(
  54. appBar: AppBar(
  55. elevation: 0,
  56. title: Text(
  57. S.of(context).manageLink,
  58. ),
  59. ),
  60. body: SingleChildScrollView(
  61. child: ListBody(
  62. children: <Widget>[
  63. Padding(
  64. padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
  65. child: Column(
  66. crossAxisAlignment: CrossAxisAlignment.start,
  67. children: [
  68. MenuItemWidget(
  69. key: ValueKey("Allow collect $isCollectEnabled"),
  70. captionedTextWidget: CaptionedTextWidget(
  71. title: S.of(context).allowAddingPhotos,
  72. ),
  73. alignCaptionedTextToLeft: true,
  74. menuItemColor: getEnteColorScheme(context).fillFaint,
  75. trailingWidget: ToggleSwitchWidget(
  76. value: () => isCollectEnabled,
  77. onChanged: () async {
  78. await _updateUrlSettings(
  79. context,
  80. {'enableCollect': !isCollectEnabled},
  81. );
  82. },
  83. ),
  84. ),
  85. MenuSectionDescriptionWidget(
  86. content: S.of(context).allowAddPhotosDescription,
  87. ),
  88. const SizedBox(height: 24),
  89. MenuItemWidget(
  90. alignCaptionedTextToLeft: true,
  91. captionedTextWidget: CaptionedTextWidget(
  92. title: S.of(context).linkExpiry,
  93. subTitle: (url.hasExpiry
  94. ? (url.isExpired
  95. ? S.of(context).linkExpired
  96. : S.of(context).linkEnabled)
  97. : S.of(context).linkNeverExpires),
  98. subTitleColor: url.isExpired ? warning500 : null,
  99. ),
  100. trailingIcon: Icons.chevron_right,
  101. menuItemColor: enteColorScheme.fillFaint,
  102. surfaceExecutionStates: false,
  103. onTap: () async {
  104. // ignore: unawaited_futures
  105. routeToPage(
  106. context,
  107. LinkExpiryPickerPage(widget.collection!),
  108. ).then((value) {
  109. setState(() {});
  110. });
  111. },
  112. ),
  113. url.hasExpiry
  114. ? MenuSectionDescriptionWidget(
  115. content: url.isExpired
  116. ? S.of(context).expiredLinkInfo
  117. : S.of(context).linkExpiresOn(
  118. getFormattedTime(
  119. context,
  120. DateTime.fromMicrosecondsSinceEpoch(
  121. url.validTill,
  122. ),
  123. ),
  124. ),
  125. )
  126. : const SizedBox.shrink(),
  127. const Padding(padding: EdgeInsets.only(top: 24)),
  128. MenuItemWidget(
  129. captionedTextWidget: CaptionedTextWidget(
  130. title: S.of(context).linkDeviceLimit,
  131. subTitle: url.deviceLimit == 0
  132. ? S.of(context).noDeviceLimit
  133. : "${url.deviceLimit}",
  134. ),
  135. trailingIcon: Icons.chevron_right,
  136. menuItemColor: enteColorScheme.fillFaint,
  137. alignCaptionedTextToLeft: true,
  138. isBottomBorderRadiusRemoved: true,
  139. onTap: () async {
  140. // ignore: unawaited_futures
  141. routeToPage(
  142. context,
  143. DeviceLimitPickerPage(widget.collection!),
  144. ).then((value) {
  145. setState(() {});
  146. });
  147. },
  148. surfaceExecutionStates: false,
  149. ),
  150. DividerWidget(
  151. dividerType: DividerType.menuNoIcon,
  152. bgColor: getEnteColorScheme(context).fillFaint,
  153. ),
  154. MenuItemWidget(
  155. key: ValueKey("Allow downloads $isDownloadEnabled"),
  156. captionedTextWidget: CaptionedTextWidget(
  157. title: S.of(context).allowDownloads,
  158. ),
  159. alignCaptionedTextToLeft: true,
  160. isBottomBorderRadiusRemoved: true,
  161. isTopBorderRadiusRemoved: true,
  162. menuItemColor: getEnteColorScheme(context).fillFaint,
  163. trailingWidget: ToggleSwitchWidget(
  164. value: () => isDownloadEnabled,
  165. onChanged: () async {
  166. await _updateUrlSettings(
  167. context,
  168. {'enableDownload': !isDownloadEnabled},
  169. );
  170. if (!isDownloadEnabled) {
  171. // ignore: unawaited_futures
  172. showErrorDialog(
  173. context,
  174. S.of(context).disableDownloadWarningTitle,
  175. S.of(context).disableDownloadWarningBody,
  176. );
  177. }
  178. },
  179. ),
  180. ),
  181. DividerWidget(
  182. dividerType: DividerType.menuNoIcon,
  183. bgColor: getEnteColorScheme(context).fillFaint,
  184. ),
  185. MenuItemWidget(
  186. key: ValueKey("Password lock $isPasswordEnabled"),
  187. captionedTextWidget: CaptionedTextWidget(
  188. title: S.of(context).passwordLock,
  189. ),
  190. alignCaptionedTextToLeft: true,
  191. isTopBorderRadiusRemoved: true,
  192. menuItemColor: getEnteColorScheme(context).fillFaint,
  193. trailingWidget: ToggleSwitchWidget(
  194. value: () => isPasswordEnabled,
  195. onChanged: () async {
  196. if (!isPasswordEnabled) {
  197. // ignore: unawaited_futures
  198. showTextInputDialog(
  199. context,
  200. title: S.of(context).setAPassword,
  201. submitButtonLabel: S.of(context).lockButtonLabel,
  202. hintText: S.of(context).enterPassword,
  203. isPasswordInput: true,
  204. alwaysShowSuccessState: true,
  205. onSubmit: (String password) async {
  206. if (password.trim().isNotEmpty) {
  207. final propToUpdate =
  208. await _getEncryptedPassword(
  209. password,
  210. );
  211. await _updateUrlSettings(
  212. context,
  213. propToUpdate,
  214. showProgressDialog: false,
  215. );
  216. }
  217. },
  218. );
  219. } else {
  220. await _updateUrlSettings(
  221. context,
  222. {'disablePassword': true},
  223. );
  224. }
  225. },
  226. ),
  227. ),
  228. const SizedBox(
  229. height: 24,
  230. ),
  231. if (url.isExpired)
  232. MenuItemWidget(
  233. captionedTextWidget: CaptionedTextWidget(
  234. title: S.of(context).linkHasExpired,
  235. textColor: getEnteColorScheme(context).warning500,
  236. ),
  237. leadingIcon: Icons.error_outline,
  238. leadingIconColor: getEnteColorScheme(context).warning500,
  239. menuItemColor: getEnteColorScheme(context).fillFaint,
  240. isBottomBorderRadiusRemoved: true,
  241. ),
  242. if (!url.isExpired)
  243. MenuItemWidget(
  244. captionedTextWidget: CaptionedTextWidget(
  245. title: S.of(context).copyLink,
  246. makeTextBold: true,
  247. ),
  248. leadingIcon: Icons.copy,
  249. menuItemColor: getEnteColorScheme(context).fillFaint,
  250. showOnlyLoadingState: true,
  251. onTap: () async {
  252. await Clipboard.setData(ClipboardData(text: urlValue));
  253. showShortToast(
  254. context,
  255. S.of(context).linkCopiedToClipboard,
  256. );
  257. },
  258. isBottomBorderRadiusRemoved: true,
  259. ),
  260. if (!url.isExpired)
  261. DividerWidget(
  262. dividerType: DividerType.menu,
  263. bgColor: getEnteColorScheme(context).fillFaint,
  264. ),
  265. if (!url.isExpired)
  266. MenuItemWidget(
  267. captionedTextWidget: CaptionedTextWidget(
  268. title: S.of(context).sendLink,
  269. makeTextBold: true,
  270. ),
  271. leadingIcon: Icons.adaptive.share,
  272. menuItemColor: getEnteColorScheme(context).fillFaint,
  273. onTap: () async {
  274. // ignore: unawaited_futures
  275. shareText(urlValue);
  276. },
  277. isTopBorderRadiusRemoved: true,
  278. ),
  279. const SizedBox(
  280. height: 24,
  281. ),
  282. MenuItemWidget(
  283. captionedTextWidget: CaptionedTextWidget(
  284. title: S.of(context).removeLink,
  285. textColor: warning500,
  286. makeTextBold: true,
  287. ),
  288. leadingIcon: Icons.remove_circle_outline,
  289. leadingIconColor: warning500,
  290. menuItemColor: getEnteColorScheme(context).fillFaint,
  291. surfaceExecutionStates: false,
  292. onTap: () async {
  293. final bool result = await sharingActions.disableUrl(
  294. context,
  295. widget.collection!,
  296. );
  297. if (result && mounted) {
  298. Navigator.of(context).pop();
  299. if (widget.collection!.isQuickLinkCollection()) {
  300. Navigator.of(context).pop();
  301. }
  302. }
  303. },
  304. ),
  305. ],
  306. ),
  307. ),
  308. ],
  309. ),
  310. ),
  311. );
  312. }
  313. Future<Map<String, dynamic>> _getEncryptedPassword(String pass) async {
  314. final kekSalt = CryptoUtil.getSaltToDeriveKey();
  315. final result = await CryptoUtil.deriveInteractiveKey(
  316. utf8.encode(pass) as Uint8List,
  317. kekSalt,
  318. );
  319. return {
  320. 'passHash': CryptoUtil.bin2base64(result.key),
  321. 'nonce': CryptoUtil.bin2base64(kekSalt),
  322. 'memLimit': result.memLimit,
  323. 'opsLimit': result.opsLimit,
  324. };
  325. }
  326. Future<void> _updateUrlSettings(
  327. BuildContext context,
  328. Map<String, dynamic> prop, {
  329. bool showProgressDialog = true,
  330. }) async {
  331. final dialog = showProgressDialog
  332. ? createProgressDialog(context, S.of(context).pleaseWait)
  333. : null;
  334. await dialog?.show();
  335. try {
  336. await CollectionsService.instance
  337. .updateShareUrl(widget.collection!, prop);
  338. await dialog?.hide();
  339. showShortToast(context, S.of(context).albumUpdated);
  340. if (mounted) {
  341. setState(() {});
  342. }
  343. } catch (e) {
  344. await dialog?.hide();
  345. await showGenericErrorDialog(context: context, error: e);
  346. rethrow;
  347. }
  348. }
  349. }