manage_links_widget.dart 22 KB

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