manage_links_widget.dart 23 KB

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