manage_links_widget.dart 21 KB

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