share_collection_widget.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  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_collection.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 DeviceCollection deviceCollection;
  34. const SharingDialog(this.collection, {Key key, this.deviceCollection})
  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. final 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.deviceCollection != null &&
  124. !widget.deviceCollection.shouldBackup) {
  125. await FilesDB.instance.updateDevicePathSyncStatus(
  126. {widget.deviceCollection.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. final String collectionKey = Base58Encode(
  243. CollectionsService.instance.getCollectionKey(widget.collection.id),
  244. );
  245. final String url =
  246. "${widget.collection.publicURLs.first.url}#$collectionKey";
  247. return SingleChildScrollView(
  248. child: Column(
  249. mainAxisAlignment: MainAxisAlignment.start,
  250. crossAxisAlignment: CrossAxisAlignment.start,
  251. children: [
  252. const Padding(padding: EdgeInsets.all(4)),
  253. GestureDetector(
  254. onTap: () async {
  255. await Clipboard.setData(ClipboardData(text: url));
  256. showToast(context, "Link copied to clipboard");
  257. },
  258. child: Container(
  259. padding: const EdgeInsets.all(16),
  260. color: Theme.of(context).colorScheme.onSurface.withOpacity(0.02),
  261. child: Row(
  262. crossAxisAlignment: CrossAxisAlignment.end,
  263. children: [
  264. Flexible(
  265. child: Text(
  266. url,
  267. style: TextStyle(
  268. fontSize: 16,
  269. fontFeatures: const [FontFeature.tabularFigures()],
  270. color: Theme.of(context)
  271. .colorScheme
  272. .onSurface
  273. .withOpacity(0.68),
  274. overflow: TextOverflow.ellipsis,
  275. ),
  276. ),
  277. ),
  278. const Padding(padding: EdgeInsets.all(2)),
  279. const Icon(
  280. Icons.copy,
  281. size: 18,
  282. ),
  283. ],
  284. ),
  285. ),
  286. ),
  287. const Padding(padding: EdgeInsets.all(2)),
  288. TextButton(
  289. child: Padding(
  290. padding: const EdgeInsets.all(12),
  291. child: Row(
  292. mainAxisAlignment: MainAxisAlignment.center,
  293. children: [
  294. Icon(
  295. Icons.adaptive.share,
  296. color: Theme.of(context).colorScheme.greenAlternative,
  297. ),
  298. const Padding(
  299. padding: EdgeInsets.all(4),
  300. ),
  301. Text(
  302. "Share link",
  303. style: TextStyle(
  304. color: Theme.of(context).colorScheme.greenAlternative,
  305. ),
  306. ),
  307. ],
  308. ),
  309. ),
  310. onPressed: () {
  311. shareText(url);
  312. },
  313. ),
  314. const Padding(padding: EdgeInsets.all(4)),
  315. TextButton(
  316. child: Center(
  317. child: Text(
  318. "Manage link",
  319. style: TextStyle(
  320. color: Theme.of(context).primaryColorLight,
  321. decoration: TextDecoration.underline,
  322. ),
  323. ),
  324. ),
  325. onPressed: () async {
  326. routeToPage(
  327. parentContext,
  328. ManageSharedLinkWidget(collection: widget.collection),
  329. );
  330. },
  331. ),
  332. ],
  333. ),
  334. );
  335. }
  336. Future<void> _addEmailToCollection(
  337. String email, {
  338. String publicKey,
  339. }) async {
  340. if (!isValidEmail(email)) {
  341. showErrorDialog(
  342. context,
  343. "Invalid email address",
  344. "Please enter a valid email address.",
  345. );
  346. return;
  347. } else if (email == Configuration.instance.getEmail()) {
  348. showErrorDialog(context, "Oops", "You cannot share with yourself");
  349. return;
  350. } else if (widget.collection.sharees.any((user) => user.email == email)) {
  351. showErrorDialog(
  352. context,
  353. "Oops",
  354. "You're already sharing this with " + email,
  355. );
  356. return;
  357. }
  358. if (publicKey == null) {
  359. final dialog = createProgressDialog(context, "Searching for user...");
  360. await dialog.show();
  361. publicKey = await UserService.instance.getPublicKey(email);
  362. await dialog.hide();
  363. }
  364. if (publicKey == null) {
  365. Navigator.of(context, rootNavigator: true).pop('dialog');
  366. final dialog = AlertDialog(
  367. title: const Text("Invite to ente?"),
  368. content: Text(
  369. "Looks like " +
  370. email +
  371. " hasn't signed up for ente yet. would you like to invite them?",
  372. style: const TextStyle(
  373. height: 1.4,
  374. ),
  375. ),
  376. actions: [
  377. TextButton(
  378. child: Text(
  379. "Invite",
  380. style: TextStyle(
  381. color: Theme.of(context).colorScheme.greenAlternative,
  382. ),
  383. ),
  384. onPressed: () {
  385. shareText(
  386. "Hey, I have some photos to share. Please install https://ente.io so that I can share them privately.",
  387. );
  388. },
  389. ),
  390. ],
  391. );
  392. showDialog(
  393. context: context,
  394. builder: (BuildContext context) {
  395. return dialog;
  396. },
  397. );
  398. } else {
  399. final dialog = createProgressDialog(context, "Sharing...");
  400. await dialog.show();
  401. final collection = widget.collection;
  402. try {
  403. if (collection.type == CollectionType.folder) {
  404. if (widget.deviceCollection != null &&
  405. !widget.deviceCollection.shouldBackup) {
  406. await FilesDB.instance.updateDevicePathSyncStatus(
  407. {widget.deviceCollection.id: true},
  408. );
  409. Bus.instance.fire(BackupFoldersUpdatedEvent());
  410. }
  411. }
  412. await CollectionsService.instance
  413. .share(widget.collection.id, email, publicKey);
  414. await dialog.hide();
  415. showShortToast(context, "Shared successfully!");
  416. setState(() {
  417. _sharees.add(User(email: email));
  418. _showEntryField = false;
  419. });
  420. } catch (e) {
  421. await dialog.hide();
  422. if (e is SharingNotPermittedForFreeAccountsError) {
  423. _showUnSupportedAlert();
  424. } else {
  425. _logger.severe("failed to share collection", e);
  426. showGenericErrorDialog(context);
  427. }
  428. }
  429. }
  430. }
  431. void _showUnSupportedAlert() {
  432. final AlertDialog alert = AlertDialog(
  433. title: const Text("Sorry"),
  434. content: const Text(
  435. "Sharing is not permitted for free accounts, please subscribe",
  436. ),
  437. actions: [
  438. TextButton(
  439. child: Text(
  440. "Subscribe",
  441. style: TextStyle(
  442. color: Theme.of(context).colorScheme.greenAlternative,
  443. ),
  444. ),
  445. onPressed: () {
  446. Navigator.of(context).pushReplacement(
  447. MaterialPageRoute(
  448. builder: (BuildContext context) {
  449. return getSubscriptionPage();
  450. },
  451. ),
  452. );
  453. },
  454. ),
  455. TextButton(
  456. child: Text(
  457. "Ok",
  458. style: TextStyle(
  459. color: Theme.of(context).colorScheme.onSurface,
  460. ),
  461. ),
  462. onPressed: () {
  463. Navigator.of(context, rootNavigator: true).pop();
  464. },
  465. ),
  466. ],
  467. );
  468. showDialog(
  469. context: context,
  470. builder: (BuildContext context) {
  471. return alert;
  472. },
  473. );
  474. }
  475. }
  476. class EmailItemWidget extends StatelessWidget {
  477. final Collection collection;
  478. final String email;
  479. const EmailItemWidget(
  480. this.collection,
  481. this.email, {
  482. Key key,
  483. }) : super(key: key);
  484. @override
  485. Widget build(BuildContext context) {
  486. return Row(
  487. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  488. children: [
  489. Padding(
  490. padding: const EdgeInsets.fromLTRB(8, 0, 0, 0),
  491. child: Text(
  492. email,
  493. style: const TextStyle(fontSize: 16),
  494. ),
  495. ),
  496. const Expanded(child: SizedBox()),
  497. IconButton(
  498. icon: const Icon(Icons.delete_forever),
  499. color: Colors.redAccent,
  500. onPressed: () async {
  501. final dialog = createProgressDialog(context, "Please wait...");
  502. await dialog.show();
  503. try {
  504. await CollectionsService.instance.unshare(collection.id, email);
  505. collection.sharees.removeWhere((user) => user.email == email);
  506. await dialog.hide();
  507. showToast(context, "Stopped sharing with " + email + ".");
  508. Navigator.of(context).pop();
  509. } catch (e, s) {
  510. Logger("EmailItemWidget").severe(e, s);
  511. await dialog.hide();
  512. showGenericErrorDialog(context);
  513. }
  514. },
  515. ),
  516. ],
  517. );
  518. }
  519. }