share_collection_widget.dart 17 KB

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