manage_links_widget.dart 22 KB

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