manage_links_widget.dart 19 KB

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