manage_links_widget.dart 20 KB

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