lazy_loading_gallery.dart 17 KB

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