manage_links_widget.dart 21 KB

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