manage_links_widget.dart 19 KB

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