manage_links_widget.dart 21 KB

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