shared_collections_gallery.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. import 'dart:async';
  2. import 'dart:math';
  3. import 'package:flutter/material.dart';
  4. import 'package:fluttertoast/fluttertoast.dart';
  5. import 'package:logging/logging.dart';
  6. import 'package:photos/core/configuration.dart';
  7. import 'package:photos/core/event_bus.dart';
  8. import 'package:photos/db/files_db.dart';
  9. import 'package:photos/events/collection_updated_event.dart';
  10. import 'package:photos/events/local_photos_updated_event.dart';
  11. import 'package:photos/events/tab_changed_event.dart';
  12. import 'package:photos/events/user_logged_out_event.dart';
  13. import 'package:photos/models/collection.dart';
  14. import 'package:photos/models/collection_items.dart';
  15. import 'package:photos/models/gallery_type.dart';
  16. import 'package:photos/services/collections_service.dart';
  17. import 'package:photos/theme/colors.dart';
  18. import 'package:photos/ui/collections/section_title.dart';
  19. import 'package:photos/ui/common/gradient_button.dart';
  20. import 'package:photos/ui/common/loading_widget.dart';
  21. import 'package:photos/ui/sharing/user_avator_widget.dart';
  22. import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
  23. import 'package:photos/ui/viewer/gallery/collection_page.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 SharedCollectionGallery extends StatefulWidget {
  28. const SharedCollectionGallery({Key? key}) : super(key: key);
  29. @override
  30. State<SharedCollectionGallery> createState() =>
  31. _SharedCollectionGalleryState();
  32. }
  33. class _SharedCollectionGalleryState extends State<SharedCollectionGallery>
  34. with AutomaticKeepAliveClientMixin {
  35. final Logger _logger = Logger("SharedCollectionGallery");
  36. late StreamSubscription<LocalPhotosUpdatedEvent> _localFilesSubscription;
  37. late StreamSubscription<CollectionUpdatedEvent>
  38. _collectionUpdatesSubscription;
  39. late StreamSubscription<UserLoggedOutEvent> _loggedOutEvent;
  40. @override
  41. void initState() {
  42. _localFilesSubscription =
  43. Bus.instance.on<LocalPhotosUpdatedEvent>().listen((event) {
  44. debugPrint("SetState Shared Collections on ${event.reason}");
  45. setState(() {});
  46. });
  47. _collectionUpdatesSubscription =
  48. Bus.instance.on<CollectionUpdatedEvent>().listen((event) {
  49. debugPrint("SetState Shared Collections on ${event.reason}");
  50. setState(() {});
  51. });
  52. _loggedOutEvent = Bus.instance.on<UserLoggedOutEvent>().listen((event) {
  53. setState(() {});
  54. });
  55. super.initState();
  56. }
  57. @override
  58. Widget build(BuildContext context) {
  59. super.build(context);
  60. return FutureBuilder<SharedCollections>(
  61. future:
  62. Future.value(CollectionsService.instance.getLatestCollectionFiles())
  63. .then((files) async {
  64. final List<CollectionWithThumbnail> outgoing = [];
  65. final List<CollectionWithThumbnail> incoming = [];
  66. for (final file in files) {
  67. if (file.collectionID == null) {
  68. _logger.severe("collection id should not be null");
  69. continue;
  70. }
  71. final Collection? c =
  72. CollectionsService.instance.getCollectionByID(file.collectionID!);
  73. if (c == null) {
  74. _logger
  75. .severe("shared collection is not cached ${file.collectionID}");
  76. CollectionsService.instance
  77. .fetchCollectionByID(file.collectionID!)
  78. .ignore();
  79. continue;
  80. }
  81. if (c.owner!.id == Configuration.instance.getUserID()) {
  82. if (c.hasSharees || c.hasLink || c.isSharedFilesCollection()) {
  83. outgoing.add(
  84. CollectionWithThumbnail(
  85. c,
  86. file,
  87. ),
  88. );
  89. }
  90. } else {
  91. incoming.add(
  92. CollectionWithThumbnail(
  93. c,
  94. file,
  95. ),
  96. );
  97. }
  98. }
  99. outgoing.sort((first, second) {
  100. if (second.collection.isSharedFilesCollection() ==
  101. first.collection.isSharedFilesCollection()) {
  102. return second.collection.updationTime
  103. .compareTo(first.collection.updationTime);
  104. } else {
  105. if (first.collection.isSharedFilesCollection()) {
  106. return 1;
  107. }
  108. return -1;
  109. }
  110. });
  111. incoming.sort((first, second) {
  112. return second.collection.updationTime
  113. .compareTo(first.collection.updationTime);
  114. });
  115. return SharedCollections(outgoing, incoming);
  116. }),
  117. builder: (context, snapshot) {
  118. if (snapshot.hasData) {
  119. return _getSharedCollectionsGallery(snapshot.data!);
  120. } else if (snapshot.hasError) {
  121. _logger.severe(
  122. "critical: failed to load share gallery",
  123. snapshot.error,
  124. snapshot.stackTrace,
  125. );
  126. return const Center(child: Text("Something went wrong."));
  127. } else {
  128. return const EnteLoadingWidget();
  129. }
  130. },
  131. );
  132. }
  133. Widget _getSharedCollectionsGallery(SharedCollections collections) {
  134. const double horizontalPaddingOfGridRow = 16;
  135. const double crossAxisSpacingOfGrid = 9;
  136. final Size size = MediaQuery.of(context).size;
  137. final int albumsCountInOneRow = max(size.width ~/ 220.0, 2);
  138. final double totalWhiteSpaceOfRow = (horizontalPaddingOfGridRow * 2) +
  139. (albumsCountInOneRow - 1) * crossAxisSpacingOfGrid;
  140. final double sideOfThumbnail = (size.width / albumsCountInOneRow) -
  141. (totalWhiteSpaceOfRow / albumsCountInOneRow);
  142. return SingleChildScrollView(
  143. child: Container(
  144. margin: const EdgeInsets.only(bottom: 50),
  145. child: Column(
  146. children: [
  147. const SizedBox(height: 12),
  148. const SectionTitle(title: "Shared with me"),
  149. const SizedBox(height: 12),
  150. collections.incoming.isNotEmpty
  151. ? Padding(
  152. padding: const EdgeInsets.symmetric(horizontal: 16),
  153. child: GridView.builder(
  154. shrinkWrap: true,
  155. physics: const NeverScrollableScrollPhysics(),
  156. itemBuilder: (context, index) {
  157. return IncomingCollectionItem(
  158. collections.incoming[index],
  159. );
  160. },
  161. itemCount: collections.incoming.length,
  162. gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
  163. crossAxisCount: albumsCountInOneRow,
  164. mainAxisSpacing: 12,
  165. crossAxisSpacing: crossAxisSpacingOfGrid,
  166. childAspectRatio:
  167. sideOfThumbnail / (sideOfThumbnail + 24),
  168. ), //24 is height of album title
  169. ),
  170. )
  171. : _getIncomingCollectionEmptyState(),
  172. const SectionTitle(title: "Shared by me"),
  173. const SizedBox(height: 12),
  174. collections.outgoing.isNotEmpty
  175. ? ListView.builder(
  176. shrinkWrap: true,
  177. padding: const EdgeInsets.only(bottom: 12),
  178. physics: const NeverScrollableScrollPhysics(),
  179. itemBuilder: (context, index) {
  180. return OutgoingCollectionItem(
  181. collections.outgoing[index],
  182. );
  183. },
  184. itemCount: collections.outgoing.length,
  185. )
  186. : _getOutgoingCollectionEmptyState(),
  187. const SizedBox(height: 32),
  188. ],
  189. ),
  190. ),
  191. );
  192. }
  193. Widget _getIncomingCollectionEmptyState() {
  194. return SizedBox(
  195. height: 220,
  196. child: Column(
  197. mainAxisAlignment: MainAxisAlignment.center,
  198. children: [
  199. Text(
  200. "Ask your loved ones to share",
  201. style: Theme.of(context).textTheme.caption,
  202. ),
  203. const Padding(padding: EdgeInsets.only(top: 14)),
  204. SizedBox(
  205. width: 200,
  206. height: 50,
  207. child: GradientButton(
  208. onTap: () async {
  209. shareText("Check out https://ente.io");
  210. },
  211. iconData: Icons.outgoing_mail,
  212. text: "Invite",
  213. ),
  214. ),
  215. const SizedBox(height: 60),
  216. ],
  217. ),
  218. );
  219. }
  220. Widget _getOutgoingCollectionEmptyState() {
  221. return SizedBox(
  222. height: 200,
  223. child: Column(
  224. mainAxisAlignment: MainAxisAlignment.center,
  225. children: [
  226. Text(
  227. "Share your first album",
  228. style: Theme.of(context).textTheme.caption,
  229. ),
  230. const Padding(padding: EdgeInsets.only(top: 14)),
  231. SizedBox(
  232. width: 200,
  233. height: 50,
  234. child: GradientButton(
  235. onTap: () async {
  236. await showToast(
  237. context,
  238. "Open an album and tap the share button on the top right to share.",
  239. toastLength: Toast.LENGTH_LONG,
  240. );
  241. Bus.instance.fire(
  242. TabChangedEvent(1, TabChangedEventSource.collectionsPage),
  243. );
  244. },
  245. iconData: Icons.person_add,
  246. text: "Share",
  247. ),
  248. ),
  249. const SizedBox(height: 60),
  250. ],
  251. ),
  252. );
  253. }
  254. @override
  255. void dispose() {
  256. _localFilesSubscription.cancel();
  257. _collectionUpdatesSubscription.cancel();
  258. _loggedOutEvent.cancel();
  259. super.dispose();
  260. }
  261. @override
  262. bool get wantKeepAlive => true;
  263. }
  264. class OutgoingCollectionItem extends StatelessWidget {
  265. final CollectionWithThumbnail c;
  266. const OutgoingCollectionItem(
  267. this.c, {
  268. Key? key,
  269. }) : super(key: key);
  270. @override
  271. Widget build(BuildContext context) {
  272. final shareesName = <String>[];
  273. if (c.collection.hasSharees) {
  274. for (int index = 0; index < c.collection.sharees!.length; index++) {
  275. final sharee = c.collection.sharees![index]!;
  276. final String name =
  277. (sharee.name?.isNotEmpty ?? false) ? sharee.name! : sharee.email;
  278. if (index < 2) {
  279. shareesName.add(name);
  280. } else {
  281. final remaining = c.collection.sharees!.length - index;
  282. if (remaining == 1) {
  283. // If it's the last sharee
  284. shareesName.add(name);
  285. } else {
  286. shareesName.add(
  287. "and " +
  288. remaining.toString() +
  289. " other" +
  290. (remaining > 1 ? "s" : ""),
  291. );
  292. }
  293. break;
  294. }
  295. }
  296. }
  297. return GestureDetector(
  298. behavior: HitTestBehavior.opaque,
  299. child: Container(
  300. margin: const EdgeInsets.fromLTRB(16, 12, 16, 12),
  301. child: Row(
  302. children: <Widget>[
  303. ClipRRect(
  304. borderRadius: BorderRadius.circular(1),
  305. child: SizedBox(
  306. height: 60,
  307. width: 60,
  308. child: Hero(
  309. tag: "outgoing_collection" + c.thumbnail!.tag,
  310. child: ThumbnailWidget(
  311. c.thumbnail,
  312. key: Key("outgoing_collection" + c.thumbnail!.tag),
  313. ),
  314. ),
  315. ),
  316. ),
  317. const Padding(padding: EdgeInsets.all(8)),
  318. Expanded(
  319. child: Column(
  320. crossAxisAlignment: CrossAxisAlignment.start,
  321. children: [
  322. Row(
  323. children: [
  324. Text(
  325. c.collection.name!,
  326. style: const TextStyle(
  327. fontSize: 16,
  328. ),
  329. ),
  330. const Padding(padding: EdgeInsets.all(2)),
  331. c.collection.hasLink
  332. ? (c.collection.publicURLs!.first!.isExpired
  333. ? const Icon(
  334. Icons.link,
  335. color: warning500,
  336. )
  337. : const Icon(Icons.link))
  338. : Container(),
  339. ],
  340. ),
  341. shareesName.isEmpty
  342. ? Container()
  343. : Padding(
  344. padding: const EdgeInsets.fromLTRB(0, 4, 0, 0),
  345. child: Text(
  346. "Shared with " + shareesName.join(", "),
  347. style: TextStyle(
  348. fontSize: 14,
  349. color: Theme.of(context).primaryColorLight,
  350. ),
  351. textAlign: TextAlign.left,
  352. overflow: TextOverflow.ellipsis,
  353. ),
  354. ),
  355. ],
  356. ),
  357. ),
  358. ],
  359. ),
  360. ),
  361. onTap: () {
  362. final page = CollectionPage(
  363. c,
  364. appBarType: GalleryType.ownedCollection,
  365. tagPrefix: "outgoing_collection",
  366. );
  367. routeToPage(context, page);
  368. },
  369. );
  370. }
  371. }
  372. class IncomingCollectionItem extends StatelessWidget {
  373. final CollectionWithThumbnail c;
  374. const IncomingCollectionItem(
  375. this.c, {
  376. Key? key,
  377. }) : super(key: key);
  378. @override
  379. Widget build(BuildContext context) {
  380. const double horizontalPaddingOfGridRow = 16;
  381. const double crossAxisSpacingOfGrid = 9;
  382. final TextStyle albumTitleTextStyle =
  383. Theme.of(context).textTheme.subtitle1!.copyWith(fontSize: 14);
  384. final Size size = MediaQuery.of(context).size;
  385. final int albumsCountInOneRow = max(size.width ~/ 220.0, 2);
  386. final double totalWhiteSpaceOfRow = (horizontalPaddingOfGridRow * 2) +
  387. (albumsCountInOneRow - 1) * crossAxisSpacingOfGrid;
  388. final double sideOfThumbnail = (size.width / albumsCountInOneRow) -
  389. (totalWhiteSpaceOfRow / albumsCountInOneRow);
  390. return GestureDetector(
  391. child: Column(
  392. crossAxisAlignment: CrossAxisAlignment.start,
  393. children: <Widget>[
  394. ClipRRect(
  395. borderRadius: BorderRadius.circular(1),
  396. child: SizedBox(
  397. height: sideOfThumbnail,
  398. width: sideOfThumbnail,
  399. child: Stack(
  400. children: [
  401. Hero(
  402. tag: "shared_collection" + c.thumbnail!.tag,
  403. child: ThumbnailWidget(
  404. c.thumbnail,
  405. key: Key("shared_collection" + c.thumbnail!.tag),
  406. ),
  407. ),
  408. Align(
  409. alignment: Alignment.bottomRight,
  410. child: Padding(
  411. padding: const EdgeInsets.only(right: 8.0, bottom: 8.0),
  412. child: UserAvatarWidget(
  413. c.collection.owner!,
  414. thumbnailView: true,
  415. ),
  416. ),
  417. ),
  418. ],
  419. ),
  420. ),
  421. ),
  422. const SizedBox(height: 4),
  423. Row(
  424. children: [
  425. Container(
  426. constraints: BoxConstraints(maxWidth: sideOfThumbnail - 40),
  427. child: Text(
  428. c.collection.name!,
  429. style: albumTitleTextStyle,
  430. overflow: TextOverflow.ellipsis,
  431. ),
  432. ),
  433. FutureBuilder<int>(
  434. future: FilesDB.instance.collectionFileCount(c.collection.id),
  435. builder: (context, snapshot) {
  436. if (snapshot.hasData && snapshot.data! > 0) {
  437. return RichText(
  438. text: TextSpan(
  439. style: albumTitleTextStyle.copyWith(
  440. color: albumTitleTextStyle.color!.withOpacity(0.5),
  441. ),
  442. children: [
  443. const TextSpan(text: " \u2022 "),
  444. TextSpan(text: snapshot.data.toString()),
  445. ],
  446. ),
  447. );
  448. } else {
  449. return Container();
  450. }
  451. },
  452. ),
  453. ],
  454. ),
  455. ],
  456. ),
  457. onTap: () {
  458. routeToPage(
  459. context,
  460. CollectionPage(
  461. c,
  462. appBarType: GalleryType.sharedCollection,
  463. tagPrefix: "shared_collection",
  464. ),
  465. );
  466. },
  467. );
  468. }
  469. }