lazy_loading_gallery.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  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! < photoGridSizeDefault
  231. ? subGalleryLimitMin
  232. : subGalleryLimitDefault;
  233. for (int index = 0; index < _files.length; index += subGalleryItemLimit) {
  234. childGalleries.add(
  235. LazyLoadingGridView(
  236. widget.tag,
  237. _files.sublist(
  238. index,
  239. min(index + subGalleryItemLimit, _files.length),
  240. ),
  241. widget.asyncLoader,
  242. widget.selectedFiles,
  243. index == 0,
  244. _files.length > kRecycleLimit,
  245. _toggleSelectAllFromDay,
  246. _areAllFromDaySelected,
  247. widget.photoGirdSize,
  248. ),
  249. );
  250. }
  251. return Column(
  252. children: childGalleries,
  253. );
  254. }
  255. void _selectedFilesListener() {
  256. if (widget.selectedFiles.files.isEmpty) {
  257. _showSelectAllButton.value = false;
  258. } else {
  259. _showSelectAllButton.value = true;
  260. }
  261. }
  262. }
  263. class LazyLoadingGridView extends StatefulWidget {
  264. final String tag;
  265. final List<File> filesInDay;
  266. final GalleryLoader asyncLoader;
  267. final SelectedFiles selectedFiles;
  268. final bool shouldRender;
  269. final bool shouldRecycle;
  270. final ValueNotifier toggleSelectAllFromDay;
  271. final ValueNotifier areAllFilesSelected;
  272. final int? photoGridSize;
  273. LazyLoadingGridView(
  274. this.tag,
  275. this.filesInDay,
  276. this.asyncLoader,
  277. this.selectedFiles,
  278. this.shouldRender,
  279. this.shouldRecycle,
  280. this.toggleSelectAllFromDay,
  281. this.areAllFilesSelected,
  282. this.photoGridSize, {
  283. Key? key,
  284. }) : super(key: key ?? UniqueKey());
  285. @override
  286. State<LazyLoadingGridView> createState() => _LazyLoadingGridViewState();
  287. }
  288. class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
  289. bool? _shouldRender;
  290. int? _currentUserID;
  291. late StreamSubscription<ClearSelectionsEvent> _clearSelectionsEvent;
  292. @override
  293. void initState() {
  294. _shouldRender = widget.shouldRender;
  295. _currentUserID = Configuration.instance.getUserID();
  296. widget.selectedFiles.addListener(_selectedFilesListener);
  297. _clearSelectionsEvent =
  298. Bus.instance.on<ClearSelectionsEvent>().listen((event) {
  299. if (mounted) {
  300. setState(() {});
  301. }
  302. });
  303. widget.toggleSelectAllFromDay.addListener(_toggleSelectAllFromDayListener);
  304. super.initState();
  305. }
  306. @override
  307. void dispose() {
  308. widget.selectedFiles.removeListener(_selectedFilesListener);
  309. _clearSelectionsEvent.cancel();
  310. widget.toggleSelectAllFromDay
  311. .removeListener(_toggleSelectAllFromDayListener);
  312. super.dispose();
  313. }
  314. @override
  315. void didUpdateWidget(LazyLoadingGridView oldWidget) {
  316. super.didUpdateWidget(oldWidget);
  317. if (!listEquals(widget.filesInDay, oldWidget.filesInDay)) {
  318. _shouldRender = widget.shouldRender;
  319. }
  320. }
  321. @override
  322. Widget build(BuildContext context) {
  323. if (widget.shouldRecycle) {
  324. return _getRecyclableView();
  325. } else {
  326. return _getNonRecyclableView();
  327. }
  328. }
  329. Widget _getRecyclableView() {
  330. return VisibilityDetector(
  331. key: UniqueKey(),
  332. onVisibilityChanged: (visibility) {
  333. final shouldRender = visibility.visibleFraction > 0;
  334. if (mounted && shouldRender != _shouldRender) {
  335. setState(() {
  336. _shouldRender = shouldRender;
  337. });
  338. }
  339. },
  340. child: _shouldRender!
  341. ? _getGridView()
  342. : PlaceHolderWidget(widget.filesInDay.length, widget.photoGridSize!),
  343. );
  344. }
  345. Widget _getNonRecyclableView() {
  346. if (!_shouldRender!) {
  347. return VisibilityDetector(
  348. key: UniqueKey(),
  349. onVisibilityChanged: (visibility) {
  350. if (mounted && visibility.visibleFraction > 0 && !_shouldRender!) {
  351. setState(() {
  352. _shouldRender = true;
  353. });
  354. }
  355. },
  356. child:
  357. PlaceHolderWidget(widget.filesInDay.length, widget.photoGridSize!),
  358. );
  359. } else {
  360. return _getGridView();
  361. }
  362. }
  363. Widget _getGridView() {
  364. return GridView.builder(
  365. shrinkWrap: true,
  366. physics: const NeverScrollableScrollPhysics(),
  367. // to disable GridView's scrolling
  368. itemBuilder: (context, index) {
  369. return _buildFile(context, widget.filesInDay[index]);
  370. },
  371. itemCount: widget.filesInDay.length,
  372. gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
  373. crossAxisSpacing: 2,
  374. mainAxisSpacing: 2,
  375. crossAxisCount: widget.photoGridSize!,
  376. ),
  377. padding: const EdgeInsets.all(0),
  378. );
  379. }
  380. Widget _buildFile(BuildContext context, File file) {
  381. final isFileSelected = widget.selectedFiles.isFileSelected(file);
  382. Color selectionColor = Colors.white;
  383. if (isFileSelected &&
  384. file.isUploaded &&
  385. (file.ownerID != _currentUserID ||
  386. file.pubMagicMetadata!.uploaderName != null)) {
  387. final avatarColors = getEnteColorScheme(context).avatarColors;
  388. final int randomID = file.ownerID != _currentUserID
  389. ? file.ownerID!
  390. : file.pubMagicMetadata!.uploaderName.sumAsciiValues;
  391. selectionColor = avatarColors[(randomID).remainder(avatarColors.length)];
  392. }
  393. return GestureDetector(
  394. onTap: () {
  395. if (widget.selectedFiles.files.isNotEmpty) {
  396. _selectFile(file);
  397. } else {
  398. _routeToDetailPage(file, context);
  399. }
  400. },
  401. onLongPress: () {
  402. HapticFeedback.lightImpact();
  403. _selectFile(file);
  404. },
  405. child: ClipRRect(
  406. borderRadius: BorderRadius.circular(1),
  407. child: Stack(
  408. children: [
  409. Hero(
  410. tag: widget.tag + file.tag,
  411. child: ColorFiltered(
  412. colorFilter: ColorFilter.mode(
  413. Colors.black.withOpacity(
  414. isFileSelected ? 0.4 : 0,
  415. ),
  416. BlendMode.darken,
  417. ),
  418. child: ThumbnailWidget(
  419. file,
  420. diskLoadDeferDuration: thumbnailDiskLoadDeferDuration,
  421. serverLoadDeferDuration: thumbnailServerLoadDeferDuration,
  422. shouldShowLivePhotoOverlay: true,
  423. key: Key(widget.tag + file.tag),
  424. thumbnailSize: widget.photoGridSize! < photoGridSizeDefault
  425. ? thumbnailLargeSize
  426. : thumbnailSmallSize,
  427. shouldShowOwnerAvatar: !isFileSelected,
  428. ),
  429. ),
  430. ),
  431. Visibility(
  432. visible: isFileSelected,
  433. child: Positioned(
  434. right: 4,
  435. top: 4,
  436. child: Icon(
  437. Icons.check_circle_rounded,
  438. size: 20,
  439. color: selectionColor, //same for both themes
  440. ),
  441. ),
  442. )
  443. ],
  444. ),
  445. ),
  446. );
  447. }
  448. void _selectFile(File file) {
  449. widget.selectedFiles.toggleSelection(file);
  450. }
  451. void _routeToDetailPage(File file, BuildContext context) {
  452. final page = DetailPage(
  453. DetailPageConfiguration(
  454. List.unmodifiable(widget.filesInDay),
  455. widget.asyncLoader,
  456. widget.filesInDay.indexOf(file),
  457. widget.tag,
  458. ),
  459. );
  460. routeToPage(context, page, forceCustomPageRoute: true);
  461. }
  462. void _selectedFilesListener() {
  463. if (widget.selectedFiles.files.containsAll(widget.filesInDay.toSet())) {
  464. widget.areAllFilesSelected.value = true;
  465. } else {
  466. widget.areAllFilesSelected.value = false;
  467. }
  468. bool shouldRefresh = false;
  469. for (final file in widget.filesInDay) {
  470. if (widget.selectedFiles.isPartOfLastSelected(file)) {
  471. shouldRefresh = true;
  472. }
  473. }
  474. if (shouldRefresh && mounted) {
  475. setState(() {});
  476. }
  477. }
  478. void _toggleSelectAllFromDayListener() {
  479. if (widget.selectedFiles.files.containsAll(widget.filesInDay.toSet())) {
  480. setState(() {
  481. widget.selectedFiles.unSelectAll(widget.filesInDay.toSet());
  482. });
  483. } else {
  484. widget.selectedFiles.selectAll(widget.filesInDay.toSet());
  485. }
  486. }
  487. }