manage_links_widget.dart 21 KB

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