share_collection_widget.dart 17 KB

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