manage_links_widget.dart 23 KB

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