share_collection_widget.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. import 'dart:ui';
  2. import 'package:fast_base58/fast_base58.dart';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter/services.dart';
  5. import 'package:flutter_typeahead/flutter_typeahead.dart';
  6. import 'package:fluttercontactpicker/fluttercontactpicker.dart';
  7. import 'package:logging/logging.dart';
  8. import 'package:photos/core/configuration.dart';
  9. import 'package:photos/core/event_bus.dart';
  10. import 'package:photos/db/public_keys_db.dart';
  11. import 'package:photos/events/backup_folders_updated_event.dart';
  12. import 'package:photos/models/collection.dart';
  13. import 'package:photos/models/public_key.dart';
  14. import 'package:photos/services/collections_service.dart';
  15. import 'package:photos/services/feature_flag_service.dart';
  16. import 'package:photos/services/user_service.dart';
  17. import 'package:photos/ui/common/dialogs.dart';
  18. import 'package:photos/ui/common/gradient_button.dart';
  19. import 'package:photos/ui/common/loading_widget.dart';
  20. import 'package:photos/ui/payment/subscription.dart';
  21. import 'package:photos/ui/sharing/manage_links_widget.dart';
  22. import 'package:photos/utils/dialog_util.dart';
  23. import 'package:photos/utils/email_util.dart';
  24. import 'package:photos/utils/navigation_util.dart';
  25. import 'package:photos/utils/share_util.dart';
  26. import 'package:photos/utils/toast_util.dart';
  27. class SharingDialog extends StatefulWidget {
  28. final Collection collection;
  29. const SharingDialog(this.collection, {Key key}) : super(key: key);
  30. @override
  31. State<SharingDialog> createState() => _SharingDialogState();
  32. }
  33. class _SharingDialogState extends State<SharingDialog> {
  34. bool _showEntryField = false;
  35. List<User> _sharees;
  36. String _email;
  37. final Logger _logger = Logger("SharingDialogState");
  38. @override
  39. Widget build(BuildContext context) {
  40. _sharees = widget.collection.sharees;
  41. final children = <Widget>[];
  42. if (!_showEntryField && _sharees.isEmpty) {
  43. _showEntryField = true;
  44. } else {
  45. for (final user in _sharees) {
  46. children.add(EmailItemWidget(widget.collection, user.email));
  47. }
  48. }
  49. if (_showEntryField) {
  50. children.add(_getEmailField());
  51. }
  52. children.add(
  53. const Padding(
  54. padding: EdgeInsets.all(8),
  55. ),
  56. );
  57. if (!_showEntryField) {
  58. children.add(
  59. SizedBox(
  60. width: 220,
  61. child: GradientButton(
  62. onTap: () async {
  63. setState(() {
  64. _showEntryField = true;
  65. });
  66. },
  67. iconData: Icons.add,
  68. ),
  69. ),
  70. );
  71. } else {
  72. children.add(
  73. SizedBox(
  74. width: 240,
  75. height: 50,
  76. child: OutlinedButton(
  77. child: const Text("Add"),
  78. onPressed: () {
  79. _addEmailToCollection(_email?.trim() ?? '');
  80. },
  81. ),
  82. ),
  83. );
  84. }
  85. if (!FeatureFlagService.instance.disableUrlSharing()) {
  86. bool hasUrl = widget.collection.publicURLs?.isNotEmpty ?? false;
  87. children.addAll([
  88. const Padding(padding: EdgeInsets.all(16)),
  89. const Divider(height: 1),
  90. const Padding(padding: EdgeInsets.all(12)),
  91. SizedBox(
  92. height: 36,
  93. child: Row(
  94. mainAxisAlignment: MainAxisAlignment.center,
  95. children: [
  96. const Text("Public link"),
  97. Switch(
  98. value: hasUrl,
  99. onChanged: (enable) async {
  100. // confirm if user wants to disable the url
  101. if (!enable) {
  102. final choice = await showChoiceDialog(
  103. context,
  104. 'Disable link',
  105. 'Are you sure that you want to disable the album link?',
  106. firstAction: 'Yes, disable',
  107. secondAction: 'No',
  108. actionType: ActionType.critical,
  109. );
  110. if (choice != DialogUserChoice.firstChoice) {
  111. return;
  112. }
  113. } else {
  114. // Add local folder in backup patch before creating
  115. // sharable link
  116. if (widget.collection.type == CollectionType.folder) {
  117. final path = CollectionsService.instance
  118. .decryptCollectionPath(widget.collection);
  119. if (!Configuration.instance
  120. .getPathsToBackUp()
  121. .contains(path)) {
  122. await Configuration.instance
  123. .addPathToFoldersToBeBackedUp(path);
  124. Bus.instance.fire(BackupFoldersUpdatedEvent());
  125. }
  126. }
  127. }
  128. final dialog = createProgressDialog(
  129. context,
  130. enable ? "Creating link..." : "Disabling link...",
  131. );
  132. try {
  133. await dialog.show();
  134. enable
  135. ? await CollectionsService.instance
  136. .createShareUrl(widget.collection)
  137. : await CollectionsService.instance
  138. .disableShareUrl(widget.collection);
  139. dialog.hide();
  140. setState(() {});
  141. } catch (e) {
  142. dialog.hide();
  143. if (e is SharingNotPermittedForFreeAccountsError) {
  144. _showUnSupportedAlert();
  145. } else {
  146. _logger.severe("failed to share collection", e);
  147. showGenericErrorDialog(context);
  148. }
  149. }
  150. },
  151. ),
  152. ],
  153. ),
  154. ),
  155. const Padding(padding: EdgeInsets.all(8)),
  156. ]);
  157. if (widget.collection.publicURLs?.isNotEmpty ?? false) {
  158. children.add(
  159. const Padding(
  160. padding: EdgeInsets.all(2),
  161. ),
  162. );
  163. children.add(_getShareableUrlWidget(context));
  164. }
  165. }
  166. return AlertDialog(
  167. title: const Text("Sharing"),
  168. content: SingleChildScrollView(
  169. child: ListBody(
  170. children: <Widget>[
  171. Padding(
  172. padding: const EdgeInsets.all(4.0),
  173. child: Column(
  174. children: children,
  175. ),
  176. ),
  177. ],
  178. ),
  179. ),
  180. contentPadding: const EdgeInsets.fromLTRB(24, 24, 24, 4),
  181. );
  182. }
  183. Widget _getEmailField() {
  184. return Row(
  185. children: [
  186. Expanded(
  187. child: TypeAheadField(
  188. textFieldConfiguration: const TextFieldConfiguration(
  189. keyboardType: TextInputType.emailAddress,
  190. decoration: InputDecoration(
  191. border: InputBorder.none,
  192. hintText: "email@your-friend.com",
  193. ),
  194. ),
  195. hideOnEmpty: true,
  196. loadingBuilder: (context) {
  197. return const EnteLoadingWidget();
  198. },
  199. suggestionsCallback: (pattern) async {
  200. _email = pattern;
  201. return PublicKeysDB.instance.searchByEmail(_email);
  202. },
  203. itemBuilder: (context, suggestion) {
  204. return Container(
  205. padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
  206. child: Text(
  207. suggestion.email,
  208. overflow: TextOverflow.clip,
  209. ),
  210. );
  211. },
  212. onSuggestionSelected: (PublicKey suggestion) {
  213. _addEmailToCollection(
  214. suggestion.email,
  215. publicKey: suggestion.publicKey,
  216. );
  217. },
  218. ),
  219. ),
  220. const Padding(padding: EdgeInsets.all(8)),
  221. IconButton(
  222. icon: Icon(
  223. Icons.contact_mail_outlined,
  224. color: Theme.of(context).buttonColor.withOpacity(0.8),
  225. ),
  226. onPressed: () async {
  227. final emailContact = await FlutterContactPicker.pickEmailContact(
  228. askForPermission: true,
  229. );
  230. _addEmailToCollection(emailContact.email.email);
  231. },
  232. ),
  233. ],
  234. );
  235. }
  236. Widget _getShareableUrlWidget(BuildContext parentContext) {
  237. String collectionKey = Base58Encode(
  238. CollectionsService.instance.getCollectionKey(widget.collection.id),
  239. );
  240. String url = "${widget.collection.publicURLs.first.url}#$collectionKey";
  241. return SingleChildScrollView(
  242. child: Column(
  243. mainAxisAlignment: MainAxisAlignment.start,
  244. crossAxisAlignment: CrossAxisAlignment.start,
  245. children: [
  246. const Padding(padding: EdgeInsets.all(4)),
  247. GestureDetector(
  248. onTap: () async {
  249. await Clipboard.setData(ClipboardData(text: url));
  250. showToast(context, "Link copied to clipboard");
  251. },
  252. child: Container(
  253. padding: const EdgeInsets.all(16),
  254. color: Theme.of(context).colorScheme.onSurface.withOpacity(0.02),
  255. child: Row(
  256. crossAxisAlignment: CrossAxisAlignment.end,
  257. children: [
  258. Flexible(
  259. child: Text(
  260. url,
  261. style: TextStyle(
  262. fontSize: 16,
  263. fontFeatures: const [FontFeature.tabularFigures()],
  264. color: Theme.of(context)
  265. .colorScheme
  266. .onSurface
  267. .withOpacity(0.68),
  268. overflow: TextOverflow.ellipsis,
  269. ),
  270. ),
  271. ),
  272. const Padding(padding: EdgeInsets.all(2)),
  273. const Icon(
  274. Icons.copy,
  275. size: 18,
  276. ),
  277. ],
  278. ),
  279. ),
  280. ),
  281. const Padding(padding: EdgeInsets.all(2)),
  282. TextButton(
  283. child: Padding(
  284. padding: const EdgeInsets.all(12),
  285. child: Row(
  286. mainAxisAlignment: MainAxisAlignment.center,
  287. children: [
  288. Icon(
  289. Icons.adaptive.share,
  290. color: Theme.of(context).buttonColor,
  291. ),
  292. const Padding(
  293. padding: EdgeInsets.all(4),
  294. ),
  295. Text(
  296. "Share link",
  297. style: TextStyle(
  298. color: Theme.of(context).buttonColor,
  299. ),
  300. ),
  301. ],
  302. ),
  303. ),
  304. onPressed: () {
  305. shareText(url);
  306. },
  307. ),
  308. const Padding(padding: EdgeInsets.all(4)),
  309. TextButton(
  310. child: Center(
  311. child: Text(
  312. "Manage link",
  313. style: TextStyle(
  314. color: Theme.of(context).primaryColorLight,
  315. decoration: TextDecoration.underline,
  316. ),
  317. ),
  318. ),
  319. onPressed: () async {
  320. routeToPage(
  321. parentContext,
  322. ManageSharedLinkWidget(collection: widget.collection),
  323. );
  324. },
  325. ),
  326. ],
  327. ),
  328. );
  329. }
  330. Future<void> _addEmailToCollection(
  331. String email, {
  332. String publicKey,
  333. }) async {
  334. if (!isValidEmail(email)) {
  335. showErrorDialog(
  336. context,
  337. "Invalid email address",
  338. "Please enter a valid email address.",
  339. );
  340. return;
  341. } else if (email == Configuration.instance.getEmail()) {
  342. showErrorDialog(context, "Oops", "You cannot share with yourself");
  343. return;
  344. } else if (widget.collection.sharees.any((user) => user.email == email)) {
  345. showErrorDialog(
  346. context,
  347. "Oops",
  348. "You're already sharing this with " + email,
  349. );
  350. return;
  351. }
  352. if (publicKey == null) {
  353. final dialog = createProgressDialog(context, "Searching for user...");
  354. await dialog.show();
  355. publicKey = await UserService.instance.getPublicKey(email);
  356. await dialog.hide();
  357. }
  358. if (publicKey == null) {
  359. Navigator.of(context, rootNavigator: true).pop('dialog');
  360. final dialog = AlertDialog(
  361. title: const Text("Invite to ente?"),
  362. content: Text(
  363. "Looks like " +
  364. email +
  365. " hasn't signed up for ente yet. would you like to invite them?",
  366. style: const TextStyle(
  367. height: 1.4,
  368. ),
  369. ),
  370. actions: [
  371. TextButton(
  372. child: Text(
  373. "Invite",
  374. style: TextStyle(
  375. color: Theme.of(context).buttonColor,
  376. ),
  377. ),
  378. onPressed: () {
  379. shareText(
  380. "Hey, I have some photos to share. Please install https://ente.io so that I can share them privately.",
  381. );
  382. },
  383. ),
  384. ],
  385. );
  386. showDialog(
  387. context: context,
  388. builder: (BuildContext context) {
  389. return dialog;
  390. },
  391. );
  392. } else {
  393. final dialog = createProgressDialog(context, "Sharing...");
  394. await dialog.show();
  395. final collection = widget.collection;
  396. try {
  397. if (collection.type == CollectionType.folder) {
  398. final path =
  399. CollectionsService.instance.decryptCollectionPath(collection);
  400. if (!Configuration.instance.getPathsToBackUp().contains(path)) {
  401. await Configuration.instance.addPathToFoldersToBeBackedUp(path);
  402. }
  403. }
  404. await CollectionsService.instance
  405. .share(widget.collection.id, email, publicKey);
  406. await dialog.hide();
  407. showShortToast(context, "Shared successfully!");
  408. setState(() {
  409. _sharees.add(User(email: email));
  410. _showEntryField = false;
  411. });
  412. } catch (e) {
  413. await dialog.hide();
  414. if (e is SharingNotPermittedForFreeAccountsError) {
  415. _showUnSupportedAlert();
  416. } else {
  417. _logger.severe("failed to share collection", e);
  418. showGenericErrorDialog(context);
  419. }
  420. }
  421. }
  422. }
  423. void _showUnSupportedAlert() {
  424. AlertDialog alert = AlertDialog(
  425. title: const Text("Sorry"),
  426. content: const Text(
  427. "Sharing is not permitted for free accounts, please subscribe",
  428. ),
  429. actions: [
  430. TextButton(
  431. child: Text(
  432. "Subscribe",
  433. style: TextStyle(
  434. color: Theme.of(context).buttonColor,
  435. ),
  436. ),
  437. onPressed: () {
  438. Navigator.of(context).pushReplacement(
  439. MaterialPageRoute(
  440. builder: (BuildContext context) {
  441. return getSubscriptionPage();
  442. },
  443. ),
  444. );
  445. },
  446. ),
  447. TextButton(
  448. child: Text(
  449. "Ok",
  450. style: TextStyle(
  451. color: Theme.of(context).colorScheme.onSurface,
  452. ),
  453. ),
  454. onPressed: () {
  455. Navigator.of(context, rootNavigator: true).pop();
  456. },
  457. ),
  458. ],
  459. );
  460. showDialog(
  461. context: context,
  462. builder: (BuildContext context) {
  463. return alert;
  464. },
  465. );
  466. }
  467. }
  468. class EmailItemWidget extends StatelessWidget {
  469. final Collection collection;
  470. final String email;
  471. const EmailItemWidget(
  472. this.collection,
  473. this.email, {
  474. Key key,
  475. }) : super(key: key);
  476. @override
  477. Widget build(BuildContext context) {
  478. return Row(
  479. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  480. children: [
  481. Padding(
  482. padding: const EdgeInsets.fromLTRB(8, 0, 0, 0),
  483. child: Text(
  484. email,
  485. style: const TextStyle(fontSize: 16),
  486. ),
  487. ),
  488. const Expanded(child: SizedBox()),
  489. IconButton(
  490. icon: const Icon(Icons.delete_forever),
  491. color: Colors.redAccent,
  492. onPressed: () async {
  493. final dialog = createProgressDialog(context, "Please wait...");
  494. await dialog.show();
  495. try {
  496. await CollectionsService.instance.unshare(collection.id, email);
  497. collection.sharees.removeWhere((user) => user.email == email);
  498. await dialog.hide();
  499. showToast(context, "Stopped sharing with " + email + ".");
  500. Navigator.of(context).pop();
  501. } catch (e, s) {
  502. Logger("EmailItemWidget").severe(e, s);
  503. await dialog.hide();
  504. showGenericErrorDialog(context);
  505. }
  506. },
  507. ),
  508. ],
  509. );
  510. }
  511. }