manage_links_widget.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import 'dart:convert';
  2. import 'dart:typed_data';
  3. import 'package:collection/collection.dart';
  4. import 'package:flutter/cupertino.dart';
  5. import 'package:flutter/material.dart';
  6. import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';
  7. import 'package:flutter_sodium/flutter_sodium.dart';
  8. import 'package:photos/ente_theme_data.dart';
  9. import 'package:photos/models/collection.dart';
  10. import 'package:photos/services/collections_service.dart';
  11. import 'package:photos/theme/colors.dart';
  12. import 'package:photos/theme/ente_theme.dart';
  13. import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
  14. import 'package:photos/ui/components/captioned_text_widget.dart';
  15. import 'package:photos/ui/components/divider_widget.dart';
  16. import 'package:photos/ui/components/menu_item_widget/menu_item_widget.dart';
  17. import 'package:photos/ui/components/menu_section_description_widget.dart';
  18. import 'package:photos/ui/sharing/device_limit_picker_page.dart';
  19. import 'package:photos/ui/sharing/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/toast_util.dart';
  25. import 'package:tuple/tuple.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. // index, title, milliseconds in future post which link should expire (when >0)
  34. final List<Tuple3<int, String, int>> _expiryOptions = [
  35. const Tuple3(0, "Never", 0),
  36. Tuple3(1, "After 1 hour", const Duration(hours: 1).inMicroseconds),
  37. Tuple3(2, "After 1 day", const Duration(days: 1).inMicroseconds),
  38. Tuple3(3, "After 1 week", const Duration(days: 7).inMicroseconds),
  39. // todo: make this time calculation perfect
  40. Tuple3(4, "After 1 month", const Duration(days: 30).inMicroseconds),
  41. Tuple3(5, "After 1 year", const Duration(days: 365).inMicroseconds),
  42. const Tuple3(6, "Custom", -1),
  43. ];
  44. late Tuple3<int, String, int> _selectedExpiry;
  45. final CollectionActions sharingActions =
  46. CollectionActions(CollectionsService.instance);
  47. @override
  48. void initState() {
  49. _selectedExpiry = _expiryOptions.first;
  50. super.initState();
  51. }
  52. @override
  53. Widget build(BuildContext context) {
  54. final enteColorScheme = getEnteColorScheme(context);
  55. final PublicURL url = widget.collection!.publicURLs!.firstOrNull!;
  56. return Scaffold(
  57. backgroundColor: Theme.of(context).backgroundColor,
  58. appBar: AppBar(
  59. elevation: 0,
  60. title: const Text(
  61. "Manage link",
  62. ),
  63. ),
  64. body: SingleChildScrollView(
  65. child: ListBody(
  66. children: <Widget>[
  67. Padding(
  68. padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
  69. child: Column(
  70. crossAxisAlignment: CrossAxisAlignment.start,
  71. children: [
  72. MenuItemWidget(
  73. captionedTextWidget: const CaptionedTextWidget(
  74. title: "Allow adding photos",
  75. ),
  76. alignCaptionedTextToLeft: true,
  77. menuItemColor: getEnteColorScheme(context).fillFaint,
  78. trailingWidget: Switch.adaptive(
  79. value: widget.collection!.publicURLs?.firstOrNull
  80. ?.enableCollect ??
  81. false,
  82. onChanged: (value) async {
  83. await _updateUrlSettings(
  84. context,
  85. {'enableCollect': value},
  86. );
  87. setState(() {});
  88. },
  89. ),
  90. ),
  91. const MenuSectionDescriptionWidget(
  92. content:
  93. "Allow people with the link to also add photos to the shared "
  94. "album.",
  95. ),
  96. const SizedBox(height: 24),
  97. MenuItemWidget(
  98. alignCaptionedTextToLeft: true,
  99. captionedTextWidget: CaptionedTextWidget(
  100. title: "Link expiry",
  101. subTitle: (url.hasExpiry
  102. ? (url.isExpired ? "Expired" : "Enabled")
  103. : "Never"),
  104. subTitleColor: url.isExpired ? warning500 : null,
  105. ),
  106. trailingIcon: Icons.chevron_right,
  107. menuItemColor: enteColorScheme.fillFaint,
  108. surfaceExecutionStates: false,
  109. onTap: () async {
  110. // await showPicker();
  111. routeToPage(
  112. context,
  113. LinkExpiryPickerPage(widget.collection!),
  114. ).then((value) {
  115. setState(() {});
  116. });
  117. },
  118. ),
  119. url.hasExpiry
  120. ? MenuSectionDescriptionWidget(
  121. content: url.isExpired
  122. ? "This link has expired. Please select a new expiry time or disable link expiry."
  123. : 'Link will expire on '
  124. '${getFormattedTime(DateTime.fromMicrosecondsSinceEpoch(url.validTill))}',
  125. )
  126. : const SizedBox.shrink(),
  127. const Padding(padding: EdgeInsets.only(top: 24)),
  128. MenuItemWidget(
  129. captionedTextWidget: CaptionedTextWidget(
  130. title: "Device limit",
  131. subTitle: widget
  132. .collection!.publicURLs!.first!.deviceLimit
  133. .toString(),
  134. ),
  135. trailingIcon: Icons.chevron_right,
  136. menuItemColor: enteColorScheme.fillFaint,
  137. alignCaptionedTextToLeft: true,
  138. isBottomBorderRadiusRemoved: true,
  139. onTap: () async {
  140. routeToPage(
  141. context,
  142. DeviceLimitPickerPage(widget.collection!),
  143. ).then((value) {
  144. setState(() {});
  145. });
  146. },
  147. surfaceExecutionStates: false,
  148. ),
  149. DividerWidget(
  150. dividerType: DividerType.menuNoIcon,
  151. bgColor: getEnteColorScheme(context).fillFaint,
  152. ),
  153. MenuItemWidget(
  154. captionedTextWidget: const CaptionedTextWidget(
  155. title: "Allow downloads",
  156. ),
  157. alignCaptionedTextToLeft: true,
  158. isBottomBorderRadiusRemoved: true,
  159. isTopBorderRadiusRemoved: true,
  160. menuItemColor: getEnteColorScheme(context).fillFaint,
  161. trailingWidget: Switch.adaptive(
  162. value: widget.collection!.publicURLs?.firstOrNull
  163. ?.enableDownload ??
  164. true,
  165. onChanged: (value) async {
  166. await _updateUrlSettings(
  167. context,
  168. {'enableDownload': value},
  169. );
  170. if (!value) {
  171. showErrorDialog(
  172. context,
  173. "Please note",
  174. "Viewers can still take screenshots or save a copy of your photos using external tools",
  175. );
  176. }
  177. setState(() {});
  178. },
  179. ),
  180. ),
  181. DividerWidget(
  182. dividerType: DividerType.menuNoIcon,
  183. bgColor: getEnteColorScheme(context).fillFaint,
  184. ),
  185. MenuItemWidget(
  186. captionedTextWidget: const CaptionedTextWidget(
  187. title: "Password lock",
  188. ),
  189. alignCaptionedTextToLeft: true,
  190. isTopBorderRadiusRemoved: true,
  191. menuItemColor: getEnteColorScheme(context).fillFaint,
  192. trailingWidget: Switch.adaptive(
  193. value: widget.collection!.publicURLs?.firstOrNull
  194. ?.passwordEnabled ??
  195. false,
  196. onChanged: (enablePassword) async {
  197. if (enablePassword) {
  198. final inputResult =
  199. await _displayLinkPasswordInput(context);
  200. if (inputResult != null &&
  201. inputResult == 'ok' &&
  202. _textFieldController.text.trim().isNotEmpty) {
  203. final propToUpdate = await _getEncryptedPassword(
  204. _textFieldController.text,
  205. );
  206. await _updateUrlSettings(context, propToUpdate);
  207. }
  208. } else {
  209. await _updateUrlSettings(
  210. context,
  211. {'disablePassword': true},
  212. );
  213. }
  214. setState(() {});
  215. },
  216. ),
  217. ),
  218. const SizedBox(
  219. height: 24,
  220. ),
  221. MenuItemWidget(
  222. captionedTextWidget: const CaptionedTextWidget(
  223. title: "Remove link",
  224. textColor: warning500,
  225. makeTextBold: true,
  226. ),
  227. leadingIcon: Icons.remove_circle_outline,
  228. leadingIconColor: warning500,
  229. menuItemColor: getEnteColorScheme(context).fillFaint,
  230. surfaceExecutionStates: false,
  231. onTap: () async {
  232. final bool result = await sharingActions.disableUrl(
  233. context,
  234. widget.collection!,
  235. );
  236. if (result && mounted) {
  237. Navigator.of(context).pop();
  238. // setState(() => {});
  239. }
  240. },
  241. ),
  242. ],
  243. ),
  244. ),
  245. ],
  246. ),
  247. ),
  248. );
  249. }
  250. Future<void> showPicker() async {
  251. return showCupertinoModalPopup(
  252. context: context,
  253. builder: (context) {
  254. return Column(
  255. mainAxisAlignment: MainAxisAlignment.end,
  256. children: <Widget>[
  257. Container(
  258. decoration: BoxDecoration(
  259. color: Theme.of(context).colorScheme.cupertinoPickerTopColor,
  260. border: const Border(
  261. bottom: BorderSide(
  262. color: Color(0xff999999),
  263. width: 0.0,
  264. ),
  265. ),
  266. ),
  267. child: Row(
  268. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  269. children: <Widget>[
  270. CupertinoButton(
  271. onPressed: () {
  272. Navigator.of(context).pop('cancel');
  273. },
  274. padding: const EdgeInsets.symmetric(
  275. horizontal: 8.0,
  276. vertical: 5.0,
  277. ),
  278. child: Text(
  279. 'Cancel',
  280. style: Theme.of(context).textTheme.subtitle1,
  281. ),
  282. ),
  283. CupertinoButton(
  284. onPressed: () async {
  285. int newValidTill = -1;
  286. bool hasSelectedCustom = false;
  287. final int expireAfterInMicroseconds =
  288. _selectedExpiry.item3;
  289. // need to manually select time
  290. if (expireAfterInMicroseconds < 0) {
  291. hasSelectedCustom = true;
  292. Navigator.of(context).pop('');
  293. final timeInMicrosecondsFromEpoch =
  294. await _showDateTimePicker();
  295. if (timeInMicrosecondsFromEpoch != null) {
  296. newValidTill = timeInMicrosecondsFromEpoch;
  297. }
  298. } else if (expireAfterInMicroseconds == 0) {
  299. // no expiry
  300. newValidTill = 0;
  301. } else {
  302. newValidTill = DateTime.now().microsecondsSinceEpoch +
  303. expireAfterInMicroseconds;
  304. }
  305. if (!hasSelectedCustom) {
  306. Navigator.of(context).pop('');
  307. }
  308. if (newValidTill >= 0) {
  309. debugPrint("Setting expirty $newValidTill");
  310. await updateTime(newValidTill);
  311. }
  312. },
  313. padding: const EdgeInsets.symmetric(
  314. horizontal: 16.0,
  315. vertical: 2.0,
  316. ),
  317. child: Text(
  318. 'Confirm',
  319. style: Theme.of(context).textTheme.subtitle1,
  320. ),
  321. )
  322. ],
  323. ),
  324. ),
  325. Container(
  326. height: 220.0,
  327. color: const Color(0xfff7f7f7),
  328. child: CupertinoPicker(
  329. backgroundColor:
  330. Theme.of(context).backgroundColor.withOpacity(0.95),
  331. onSelectedItemChanged: (value) {
  332. final firstWhere = _expiryOptions
  333. .firstWhere((element) => element.item1 == value);
  334. setState(() {
  335. _selectedExpiry = firstWhere;
  336. });
  337. },
  338. magnification: 1.3,
  339. useMagnifier: true,
  340. itemExtent: 25,
  341. diameterRatio: 1,
  342. children: _expiryOptions
  343. .map(
  344. (e) => Text(
  345. e.item2,
  346. style: Theme.of(context).textTheme.subtitle1,
  347. ),
  348. )
  349. .toList(),
  350. ),
  351. )
  352. ],
  353. );
  354. },
  355. );
  356. }
  357. Future<void> updateTime(int newValidTill) async {
  358. await _updateUrlSettings(
  359. context,
  360. {'validTill': newValidTill},
  361. );
  362. if (mounted) {
  363. // reset to default value. THis is needed will we move to
  364. // new selection menu as per figma/
  365. _selectedExpiry = _expiryOptions.first;
  366. setState(() {});
  367. }
  368. }
  369. // _showDateTimePicker return null if user doesn't select date-time
  370. Future<int?> _showDateTimePicker() async {
  371. final dateResult = await DatePicker.showDatePicker(
  372. context,
  373. minTime: DateTime.now(),
  374. currentTime: DateTime.now(),
  375. locale: LocaleType.en,
  376. theme: Theme.of(context).colorScheme.dateTimePickertheme,
  377. );
  378. if (dateResult == null) {
  379. return null;
  380. }
  381. final dateWithTimeResult = await DatePicker.showTime12hPicker(
  382. context,
  383. showTitleActions: true,
  384. currentTime: dateResult,
  385. locale: LocaleType.en,
  386. theme: Theme.of(context).colorScheme.dateTimePickertheme,
  387. );
  388. if (dateWithTimeResult == null) {
  389. return null;
  390. } else {
  391. return dateWithTimeResult.microsecondsSinceEpoch;
  392. }
  393. }
  394. final TextEditingController _textFieldController = TextEditingController();
  395. Future<String?> _displayLinkPasswordInput(BuildContext context) async {
  396. _textFieldController.clear();
  397. return showDialog<String>(
  398. context: context,
  399. builder: (context) {
  400. bool passwordVisible = false;
  401. return StatefulBuilder(
  402. builder: (context, setState) {
  403. return AlertDialog(
  404. title: const Text('Enter password'),
  405. content: TextFormField(
  406. autofillHints: const [AutofillHints.newPassword],
  407. decoration: InputDecoration(
  408. hintText: "Password",
  409. contentPadding: const EdgeInsets.all(12),
  410. suffixIcon: IconButton(
  411. icon: Icon(
  412. passwordVisible ? Icons.visibility : Icons.visibility_off,
  413. color: Colors.white.withOpacity(0.5),
  414. size: 20,
  415. ),
  416. onPressed: () {
  417. passwordVisible = !passwordVisible;
  418. setState(() {});
  419. },
  420. ),
  421. ),
  422. obscureText: !passwordVisible,
  423. controller: _textFieldController,
  424. autofocus: true,
  425. autocorrect: false,
  426. keyboardType: TextInputType.visiblePassword,
  427. onChanged: (_) {
  428. setState(() {});
  429. },
  430. ),
  431. actions: <Widget>[
  432. TextButton(
  433. child: Text(
  434. 'Cancel',
  435. style: Theme.of(context).textTheme.subtitle2,
  436. ),
  437. onPressed: () {
  438. Navigator.pop(context, 'cancel');
  439. },
  440. ),
  441. TextButton(
  442. child:
  443. Text('Ok', style: Theme.of(context).textTheme.subtitle2),
  444. onPressed: () {
  445. if (_textFieldController.text.trim().isEmpty) {
  446. return;
  447. }
  448. Navigator.pop(context, 'ok');
  449. },
  450. ),
  451. ],
  452. );
  453. },
  454. );
  455. },
  456. );
  457. }
  458. Future<Map<String, dynamic>> _getEncryptedPassword(String pass) async {
  459. assert(
  460. Sodium.cryptoPwhashAlgArgon2id13 == Sodium.cryptoPwhashAlgDefault,
  461. "mismatch in expected default pw hashing algo",
  462. );
  463. final int memLimit = Sodium.cryptoPwhashMemlimitInteractive;
  464. final int opsLimit = Sodium.cryptoPwhashOpslimitInteractive;
  465. final kekSalt = CryptoUtil.getSaltToDeriveKey();
  466. final result = await CryptoUtil.deriveKey(
  467. utf8.encode(pass) as Uint8List,
  468. kekSalt,
  469. memLimit,
  470. opsLimit,
  471. );
  472. return {
  473. 'passHash': Sodium.bin2base64(result),
  474. 'nonce': Sodium.bin2base64(kekSalt),
  475. 'memLimit': memLimit,
  476. 'opsLimit': opsLimit,
  477. };
  478. }
  479. Future<void> _updateUrlSettings(
  480. BuildContext context,
  481. Map<String, dynamic> prop,
  482. ) async {
  483. final dialog = createProgressDialog(context, "Please wait...");
  484. await dialog.show();
  485. try {
  486. await CollectionsService.instance
  487. .updateShareUrl(widget.collection!, prop);
  488. await dialog.hide();
  489. showShortToast(context, "Album updated");
  490. } catch (e) {
  491. await dialog.hide();
  492. await showGenericErrorDialog(context: context);
  493. }
  494. }
  495. }