lazy_loading_gallery.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. import 'dart:async';
  2. import 'dart:math';
  3. import 'package:flutter/foundation.dart';
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter/services.dart';
  6. import 'package:logging/logging.dart';
  7. import 'package:photos/core/configuration.dart';
  8. import 'package:photos/core/constants.dart';
  9. import 'package:photos/core/event_bus.dart';
  10. import 'package:photos/events/clear_selections_event.dart';
  11. import 'package:photos/events/files_updated_event.dart';
  12. import 'package:photos/extensions/string_ext.dart';
  13. import 'package:photos/models/file.dart';
  14. import 'package:photos/models/selected_files.dart';
  15. import 'package:photos/theme/ente_theme.dart';
  16. import 'package:photos/ui/huge_listview/place_holder_widget.dart';
  17. import 'package:photos/ui/viewer/file/detail_page.dart';
  18. import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
  19. import 'package:photos/ui/viewer/gallery/gallery.dart';
  20. import 'package:photos/utils/date_time_util.dart';
  21. import 'package:photos/utils/navigation_util.dart';
  22. import 'package:visibility_detector/visibility_detector.dart';
  23. class LazyLoadingGallery extends StatefulWidget {
  24. final List<File> files;
  25. final int index;
  26. final Stream<FilesUpdatedEvent>? reloadEvent;
  27. final Set<EventType> removalEventTypes;
  28. final GalleryLoader asyncLoader;
  29. final SelectedFiles selectedFiles;
  30. final String tag;
  31. final String? logTag;
  32. final Stream<int> currentIndexStream;
  33. final int photoGirdSize;
  34. LazyLoadingGallery(
  35. this.files,
  36. this.index,
  37. this.reloadEvent,
  38. this.removalEventTypes,
  39. this.asyncLoader,
  40. this.selectedFiles,
  41. this.tag,
  42. this.currentIndexStream, {
  43. this.logTag = "",
  44. this.photoGirdSize = photoGridSizeDefault,
  45. Key? key,
  46. }) : super(key: key ?? UniqueKey());
  47. @override
  48. State<LazyLoadingGallery> createState() => _LazyLoadingGalleryState();
  49. }
  50. class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
  51. static const kRecycleLimit = 400;
  52. static const kNumberOfDaysToRenderBeforeAndAfter = 8;
  53. late Logger _logger;
  54. late List<File> _files;
  55. late StreamSubscription<FilesUpdatedEvent> _reloadEventSubscription;
  56. late StreamSubscription<int> _currentIndexSubscription;
  57. bool? _shouldRender;
  58. final ValueNotifier<bool> _toggleSelectAllFromDay = ValueNotifier(false);
  59. final ValueNotifier<bool> _showSelectAllButton = ValueNotifier(false);
  60. final ValueNotifier<bool> _areAllFromDaySelected = ValueNotifier(false);
  61. @override
  62. void initState() {
  63. //this is for removing the 'select all from day' icon on unselecting all files with 'cancel'
  64. widget.selectedFiles.addListener(_selectedFilesListener);
  65. super.initState();
  66. _init();
  67. }
  68. void _init() {
  69. _logger = Logger("LazyLoading_${widget.logTag}");
  70. _shouldRender = true;
  71. _files = widget.files;
  72. _reloadEventSubscription = widget.reloadEvent!.listen((e) => _onReload(e));
  73. _currentIndexSubscription =
  74. widget.currentIndexStream.listen((currentIndex) {
  75. final bool shouldRender = (currentIndex - widget.index).abs() <
  76. kNumberOfDaysToRenderBeforeAndAfter;
  77. if (mounted && shouldRender != _shouldRender) {
  78. setState(() {
  79. _shouldRender = shouldRender;
  80. });
  81. }
  82. });
  83. }
  84. Future _onReload(FilesUpdatedEvent event) async {
  85. final galleryDate =
  86. DateTime.fromMicrosecondsSinceEpoch(_files[0].creationTime!);
  87. final filesUpdatedThisDay = event.updatedFiles.where((file) {
  88. final fileDate = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
  89. return fileDate.year == galleryDate.year &&
  90. fileDate.month == galleryDate.month &&
  91. fileDate.day == galleryDate.day;
  92. });
  93. if (filesUpdatedThisDay.isNotEmpty) {
  94. if (kDebugMode) {
  95. _logger.info(
  96. filesUpdatedThisDay.length.toString() +
  97. " files were updated due to ${event.reason} on " +
  98. getDayTitle(galleryDate.microsecondsSinceEpoch),
  99. );
  100. }
  101. if (event.type == EventType.addedOrUpdated) {
  102. final dayStartTime =
  103. DateTime(galleryDate.year, galleryDate.month, galleryDate.day);
  104. final result = await widget.asyncLoader(
  105. dayStartTime.microsecondsSinceEpoch,
  106. dayStartTime.microsecondsSinceEpoch + microSecondsInDay - 1,
  107. );
  108. if (mounted) {
  109. setState(() {
  110. _files = result.files;
  111. });
  112. }
  113. } else if (widget.removalEventTypes.contains(event.type)) {
  114. // Files were removed
  115. final generatedFileIDs = <int?>{};
  116. final uploadedFileIds = <int?>{};
  117. for (final file in filesUpdatedThisDay) {
  118. if (file.generatedID != null) {
  119. generatedFileIDs.add(file.generatedID);
  120. } else if (file.uploadedFileID != null) {
  121. uploadedFileIds.add(file.uploadedFileID);
  122. }
  123. }
  124. final List<File> files = [];
  125. files.addAll(_files);
  126. files.removeWhere(
  127. (file) =>
  128. generatedFileIDs.contains(file.generatedID) ||
  129. uploadedFileIds.contains(file.uploadedFileID),
  130. );
  131. if (kDebugMode) {
  132. _logger.finest(
  133. "removed ${_files.length - files.length} due to ${event.reason}",
  134. );
  135. }
  136. if (mounted) {
  137. setState(() {
  138. _files = files;
  139. });
  140. }
  141. } else {
  142. if (kDebugMode) {
  143. debugPrint("Unexpected event ${event.type.name}");
  144. }
  145. }
  146. }
  147. }
  148. @override
  149. void dispose() {
  150. _reloadEventSubscription.cancel();
  151. _currentIndexSubscription.cancel();
  152. widget.selectedFiles.removeListener(_selectedFilesListener);
  153. _toggleSelectAllFromDay.dispose();
  154. _showSelectAllButton.dispose();
  155. _areAllFromDaySelected.dispose();
  156. super.dispose();
  157. }
  158. @override
  159. void didUpdateWidget(LazyLoadingGallery oldWidget) {
  160. super.didUpdateWidget(oldWidget);
  161. if (!listEquals(_files, widget.files)) {
  162. _reloadEventSubscription.cancel();
  163. _init();
  164. }
  165. }
  166. @override
  167. Widget build(BuildContext context) {
  168. if (_files.isEmpty) {
  169. return const SizedBox.shrink();
  170. }
  171. return Column(
  172. children: [
  173. Row(
  174. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  175. children: [
  176. getDayWidget(
  177. context,
  178. _files[0].creationTime!,
  179. widget.photoGirdSize,
  180. ),
  181. ValueListenableBuilder(
  182. valueListenable: _showSelectAllButton,
  183. builder: (context, dynamic value, _) {
  184. return !value
  185. ? const SizedBox.shrink()
  186. : GestureDetector(
  187. behavior: HitTestBehavior.translucent,
  188. child: SizedBox(
  189. width: 48,
  190. height: 44,
  191. child: ValueListenableBuilder(
  192. valueListenable: _areAllFromDaySelected,
  193. builder: (context, dynamic value, _) {
  194. return value
  195. ? const Icon(
  196. Icons.check_circle,
  197. size: 18,
  198. )
  199. : Icon(
  200. Icons.check_circle_outlined,
  201. color: getEnteColorScheme(context)
  202. .strokeMuted,
  203. size: 18,
  204. );
  205. },
  206. ),
  207. ),
  208. onTap: () {
  209. //this value has no significance
  210. //changing only to notify the listeners
  211. _toggleSelectAllFromDay.value =
  212. !_toggleSelectAllFromDay.value;
  213. },
  214. );
  215. },
  216. )
  217. ],
  218. ),
  219. _shouldRender!
  220. ? _getGallery()
  221. : PlaceHolderWidget(
  222. _files.length,
  223. widget.photoGirdSize,
  224. ),
  225. ],
  226. );
  227. }
  228. Widget _getGallery() {
  229. final List<Widget> childGalleries = [];
  230. final subGalleryItemLimit = widget.photoGirdSize * subGalleryMultiplier;
  231. for (int index = 0; index < _files.length; index += subGalleryItemLimit) {
  232. childGalleries.add(
  233. LazyLoadingGridView(
  234. widget.tag,
  235. _files.sublist(
  236. index,
  237. min(index + subGalleryItemLimit, _files.length),
  238. ),
  239. widget.asyncLoader,
  240. widget.selectedFiles,
  241. index == 0,
  242. _files.length > kRecycleLimit,
  243. _toggleSelectAllFromDay,
  244. _areAllFromDaySelected,
  245. widget.photoGirdSize,
  246. ),
  247. );
  248. }
  249. return Column(
  250. children: childGalleries,
  251. );
  252. }
  253. void _selectedFilesListener() {
  254. if (widget.selectedFiles.files.isEmpty) {
  255. _showSelectAllButton.value = false;
  256. } else {
  257. _showSelectAllButton.value = true;
  258. }
  259. }
  260. }
  261. class LazyLoadingGridView extends StatefulWidget {
  262. final String tag;
  263. final List<File> filesInDay;
  264. final GalleryLoader asyncLoader;
  265. final SelectedFiles selectedFiles;
  266. final bool shouldRender;
  267. final bool shouldRecycle;
  268. final ValueNotifier toggleSelectAllFromDay;
  269. final ValueNotifier areAllFilesSelected;
  270. final int? photoGridSize;
  271. LazyLoadingGridView(
  272. this.tag,
  273. this.filesInDay,
  274. this.asyncLoader,
  275. this.selectedFiles,
  276. this.shouldRender,
  277. this.shouldRecycle,
  278. this.toggleSelectAllFromDay,
  279. this.areAllFilesSelected,
  280. this.photoGridSize, {
  281. Key? key,
  282. }) : super(key: key ?? UniqueKey());
  283. @override
  284. State<LazyLoadingGridView> createState() => _LazyLoadingGridViewState();
  285. }
  286. class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
  287. bool? _shouldRender;
  288. int? _currentUserID;
  289. late StreamSubscription<ClearSelectionsEvent> _clearSelectionsEvent;
  290. @override
  291. void initState() {
  292. _shouldRender = widget.shouldRender;
  293. _currentUserID = Configuration.instance.getUserID();
  294. widget.selectedFiles.addListener(_selectedFilesListener);
  295. _clearSelectionsEvent =
  296. Bus.instance.on<ClearSelectionsEvent>().listen((event) {
  297. if (mounted) {
  298. setState(() {});
  299. }
  300. });
  301. widget.toggleSelectAllFromDay.addListener(_toggleSelectAllFromDayListener);
  302. super.initState();
  303. }
  304. @override
  305. void dispose() {
  306. widget.selectedFiles.removeListener(_selectedFilesListener);
  307. _clearSelectionsEvent.cancel();
  308. widget.toggleSelectAllFromDay
  309. .removeListener(_toggleSelectAllFromDayListener);
  310. super.dispose();
  311. }
  312. @override
  313. void didUpdateWidget(LazyLoadingGridView oldWidget) {
  314. super.didUpdateWidget(oldWidget);
  315. if (!listEquals(widget.filesInDay, oldWidget.filesInDay)) {
  316. _shouldRender = widget.shouldRender;
  317. }
  318. }
  319. @override
  320. Widget build(BuildContext context) {
  321. if (widget.shouldRecycle) {
  322. return _getRecyclableView();
  323. } else {
  324. return _getNonRecyclableView();
  325. }
  326. }
  327. Widget _getRecyclableView() {
  328. return VisibilityDetector(
  329. key: UniqueKey(),
  330. onVisibilityChanged: (visibility) {
  331. final shouldRender = visibility.visibleFraction > 0;
  332. if (mounted && shouldRender != _shouldRender) {
  333. setState(() {
  334. _shouldRender = shouldRender;
  335. });
  336. }
  337. },
  338. child: _shouldRender!
  339. ? _getGridView()
  340. : PlaceHolderWidget(widget.filesInDay.length, widget.photoGridSize!),
  341. );
  342. }
  343. Widget _getNonRecyclableView() {
  344. if (!_shouldRender!) {
  345. return VisibilityDetector(
  346. key: UniqueKey(),
  347. onVisibilityChanged: (visibility) {
  348. if (mounted && visibility.visibleFraction > 0 && !_shouldRender!) {
  349. setState(() {
  350. _shouldRender = true;
  351. });
  352. }
  353. },
  354. child:
  355. PlaceHolderWidget(widget.filesInDay.length, widget.photoGridSize!),
  356. );
  357. } else {
  358. return _getGridView();
  359. }
  360. }
  361. Widget _getGridView() {
  362. return GridView.builder(
  363. shrinkWrap: true,
  364. physics: const NeverScrollableScrollPhysics(),
  365. // to disable GridView's scrolling
  366. itemBuilder: (context, index) {
  367. return _buildFile(context, widget.filesInDay[index]);
  368. },
  369. itemCount: widget.filesInDay.length,
  370. gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
  371. crossAxisSpacing: 2,
  372. mainAxisSpacing: 2,
  373. crossAxisCount: widget.photoGridSize!,
  374. ),
  375. padding: const EdgeInsets.all(0),
  376. );
  377. }
  378. Widget _buildFile(BuildContext context, File file) {
  379. final isFileSelected = widget.selectedFiles.isFileSelected(file);
  380. Color selectionColor = Colors.white;
  381. if (isFileSelected &&
  382. file.isUploaded &&
  383. (file.ownerID != _currentUserID ||
  384. file.pubMagicMetadata!.uploaderName != null)) {
  385. final avatarColors = getEnteColorScheme(context).avatarColors;
  386. final int randomID = file.ownerID != _currentUserID
  387. ? file.ownerID!
  388. : file.pubMagicMetadata!.uploaderName.sumAsciiValues;
  389. selectionColor = avatarColors[(randomID).remainder(avatarColors.length)];
  390. }
  391. return GestureDetector(
  392. onTap: () {
  393. if (widget.selectedFiles.files.isNotEmpty) {
  394. _selectFile(file);
  395. } else {
  396. _routeToDetailPage(file, context);
  397. }
  398. },
  399. onLongPress: () {
  400. HapticFeedback.lightImpact();
  401. _selectFile(file);
  402. },
  403. child: ClipRRect(
  404. borderRadius: BorderRadius.circular(1),
  405. child: Stack(
  406. children: [
  407. Hero(
  408. tag: widget.tag + file.tag,
  409. child: ColorFiltered(
  410. colorFilter: ColorFilter.mode(
  411. Colors.black.withOpacity(
  412. isFileSelected ? 0.4 : 0,
  413. ),
  414. BlendMode.darken,
  415. ),
  416. child: ThumbnailWidget(
  417. file,
  418. diskLoadDeferDuration: thumbnailDiskLoadDeferDuration,
  419. serverLoadDeferDuration: thumbnailServerLoadDeferDuration,
  420. shouldShowLivePhotoOverlay: true,
  421. key: Key(widget.tag + file.tag),
  422. thumbnailSize: widget.photoGridSize! < photoGridSizeDefault
  423. ? thumbnailLargeSize
  424. : thumbnailSmallSize,
  425. shouldShowOwnerAvatar: !isFileSelected,
  426. ),
  427. ),
  428. ),
  429. Visibility(
  430. visible: isFileSelected,
  431. child: Positioned(
  432. right: 4,
  433. top: 4,
  434. child: Icon(
  435. Icons.check_circle_rounded,
  436. size: 20,
  437. color: selectionColor, //same for both themes
  438. ),
  439. ),
  440. )
  441. ],
  442. ),
  443. ),
  444. );
  445. }
  446. void _selectFile(File file) {
  447. widget.selectedFiles.toggleSelection(file);
  448. }
  449. void _routeToDetailPage(File file, BuildContext context) {
  450. final page = DetailPage(
  451. DetailPageConfiguration(
  452. List.unmodifiable(widget.filesInDay),
  453. widget.asyncLoader,
  454. widget.filesInDay.indexOf(file),
  455. widget.tag,
  456. ),
  457. );
  458. routeToPage(context, page, forceCustomPageRoute: true);
  459. }
  460. void _selectedFilesListener() {
  461. if (widget.selectedFiles.files.containsAll(widget.filesInDay.toSet())) {
  462. widget.areAllFilesSelected.value = true;
  463. } else {
  464. widget.areAllFilesSelected.value = false;
  465. }
  466. bool shouldRefresh = false;
  467. for (final file in widget.filesInDay) {
  468. if (widget.selectedFiles.isPartOfLastSelected(file)) {
  469. shouldRefresh = true;
  470. }
  471. }
  472. if (shouldRefresh && mounted) {
  473. setState(() {});
  474. }
  475. }
  476. void _toggleSelectAllFromDayListener() {
  477. if (widget.selectedFiles.files.containsAll(widget.filesInDay.toSet())) {
  478. setState(() {
  479. widget.selectedFiles.unSelectAll(widget.filesInDay.toSet());
  480. });
  481. } else {
  482. widget.selectedFiles.selectAll(widget.filesInDay.toSet());
  483. }
  484. }
  485. }