manage_links_widget.dart 23 KB

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