lazy_loading_gallery.dart 15 KB

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