Refactor gallery (#1074)

This commit is contained in:
Ashil 2023-05-08 12:26:57 +05:30 committed by GitHub
commit c77afae652
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 955 additions and 663 deletions

View file

@ -1,573 +0,0 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
import 'package:media_extension/media_extension.dart';
import 'package:media_extension/media_extension_action_types.dart';
import 'package:photos/core/configuration.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/events/clear_selections_event.dart';
import 'package:photos/events/files_updated_event.dart';
import 'package:photos/extensions/string_ext.dart';
import 'package:photos/models/file.dart';
import 'package:photos/models/selected_files.dart';
import 'package:photos/services/app_lifecycle_service.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/huge_listview/place_holder_widget.dart';
import 'package:photos/ui/viewer/file/detail_page.dart';
import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
import "package:photos/ui/viewer/gallery/component/day_widget.dart";
import 'package:photos/ui/viewer/gallery/gallery.dart';
import 'package:photos/utils/file_util.dart';
import 'package:photos/utils/navigation_util.dart';
import 'package:visibility_detector/visibility_detector.dart';
class LazyLoadingGallery extends StatefulWidget {
final List<File> files;
final int index;
final Stream<FilesUpdatedEvent>? reloadEvent;
final Set<EventType> removalEventTypes;
final GalleryLoader asyncLoader;
final SelectedFiles? selectedFiles;
final String tag;
final String? logTag;
final Stream<int> currentIndexStream;
final int photoGirdSize;
final bool areFilesCollatedByDay;
final bool limitSelectionToOne;
LazyLoadingGallery(
this.files,
this.index,
this.reloadEvent,
this.removalEventTypes,
this.asyncLoader,
this.selectedFiles,
this.tag,
this.currentIndexStream,
this.areFilesCollatedByDay, {
this.logTag = "",
this.photoGirdSize = photoGridSizeDefault,
this.limitSelectionToOne = false,
Key? key,
}) : super(key: key ?? UniqueKey());
@override
State<LazyLoadingGallery> createState() => _LazyLoadingGalleryState();
}
class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
static const kRecycleLimit = 400;
static const kNumberOfDaysToRenderBeforeAndAfter = 8;
late Logger _logger;
late List<File> _files;
late StreamSubscription<FilesUpdatedEvent>? _reloadEventSubscription;
late StreamSubscription<int> _currentIndexSubscription;
bool? _shouldRender;
final ValueNotifier<bool> _toggleSelectAllFromDay = ValueNotifier(false);
final ValueNotifier<bool> _showSelectAllButton = ValueNotifier(false);
final ValueNotifier<bool> _areAllFromDaySelected = ValueNotifier(false);
@override
void initState() {
//this is for removing the 'select all from day' icon on unselecting all files with 'cancel'
widget.selectedFiles?.addListener(_selectedFilesListener);
super.initState();
_init();
}
void _init() {
_logger = Logger("LazyLoading_${widget.logTag}");
_shouldRender = true;
_files = widget.files;
_reloadEventSubscription = widget.reloadEvent?.listen((e) => _onReload(e));
_currentIndexSubscription =
widget.currentIndexStream.listen((currentIndex) {
final bool shouldRender = (currentIndex - widget.index).abs() <
kNumberOfDaysToRenderBeforeAndAfter;
if (mounted && shouldRender != _shouldRender) {
setState(() {
_shouldRender = shouldRender;
});
}
});
}
Future _onReload(FilesUpdatedEvent event) async {
final galleryDate =
DateTime.fromMicrosecondsSinceEpoch(_files[0].creationTime!);
final filesUpdatedThisDay = event.updatedFiles.where((file) {
final fileDate = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
return fileDate.year == galleryDate.year &&
fileDate.month == galleryDate.month &&
fileDate.day == galleryDate.day;
});
if (filesUpdatedThisDay.isNotEmpty) {
if (kDebugMode) {
_logger.info(
filesUpdatedThisDay.length.toString() +
" files were updated due to ${event.reason} on " +
DateTime.fromMicrosecondsSinceEpoch(
galleryDate.microsecondsSinceEpoch,
).toIso8601String(),
);
}
if (event.type == EventType.addedOrUpdated) {
final dayStartTime =
DateTime(galleryDate.year, galleryDate.month, galleryDate.day);
final result = await widget.asyncLoader(
dayStartTime.microsecondsSinceEpoch,
dayStartTime.microsecondsSinceEpoch + microSecondsInDay - 1,
);
if (mounted) {
setState(() {
_files = result.files;
});
}
} else if (widget.removalEventTypes.contains(event.type)) {
// Files were removed
final generatedFileIDs = <int?>{};
final uploadedFileIds = <int?>{};
for (final file in filesUpdatedThisDay) {
if (file.generatedID != null) {
generatedFileIDs.add(file.generatedID);
} else if (file.uploadedFileID != null) {
uploadedFileIds.add(file.uploadedFileID);
}
}
final List<File> files = [];
files.addAll(_files);
files.removeWhere(
(file) =>
generatedFileIDs.contains(file.generatedID) ||
uploadedFileIds.contains(file.uploadedFileID),
);
if (kDebugMode) {
_logger.finest(
"removed ${_files.length - files.length} due to ${event.reason}",
);
}
if (mounted) {
setState(() {
_files = files;
});
}
} else {
if (kDebugMode) {
debugPrint("Unexpected event ${event.type.name}");
}
}
}
}
@override
void dispose() {
_reloadEventSubscription?.cancel();
_currentIndexSubscription.cancel();
widget.selectedFiles?.removeListener(_selectedFilesListener);
_toggleSelectAllFromDay.dispose();
_showSelectAllButton.dispose();
_areAllFromDaySelected.dispose();
super.dispose();
}
@override
void didUpdateWidget(LazyLoadingGallery oldWidget) {
super.didUpdateWidget(oldWidget);
if (!listEquals(_files, widget.files)) {
_reloadEventSubscription?.cancel();
_init();
}
}
@override
Widget build(BuildContext context) {
if (_files.isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (widget.areFilesCollatedByDay)
DayWidget(
timestamp: _files[0].creationTime!,
gridSize: widget.photoGirdSize,
),
widget.limitSelectionToOne
? const SizedBox.shrink()
: ValueListenableBuilder(
valueListenable: _showSelectAllButton,
builder: (context, dynamic value, _) {
return !value
? const SizedBox.shrink()
: GestureDetector(
behavior: HitTestBehavior.translucent,
child: SizedBox(
width: 48,
height: 44,
child: ValueListenableBuilder(
valueListenable: _areAllFromDaySelected,
builder: (context, dynamic value, _) {
return value
? const Icon(
Icons.check_circle,
size: 18,
)
: Icon(
Icons.check_circle_outlined,
color: getEnteColorScheme(context)
.strokeMuted,
size: 18,
);
},
),
),
onTap: () {
//this value has no significance
//changing only to notify the listeners
_toggleSelectAllFromDay.value =
!_toggleSelectAllFromDay.value;
},
);
},
)
],
),
_shouldRender!
? _getGallery()
: PlaceHolderWidget(
_files.length,
widget.photoGirdSize,
),
],
);
}
Widget _getGallery() {
final List<Widget> childGalleries = [];
final subGalleryItemLimit = widget.photoGirdSize * subGalleryMultiplier;
for (int index = 0; index < _files.length; index += subGalleryItemLimit) {
childGalleries.add(
LazyLoadingGridView(
widget.tag,
_files.sublist(
index,
min(index + subGalleryItemLimit, _files.length),
),
widget.asyncLoader,
widget.selectedFiles,
index == 0,
_files.length > kRecycleLimit,
_toggleSelectAllFromDay,
_areAllFromDaySelected,
widget.photoGirdSize,
limitSelectionToOne: widget.limitSelectionToOne,
),
);
}
return Column(
children: childGalleries,
);
}
void _selectedFilesListener() {
if (widget.selectedFiles!.files.isEmpty) {
_showSelectAllButton.value = false;
} else {
_showSelectAllButton.value = true;
}
}
}
class LazyLoadingGridView extends StatefulWidget {
final String tag;
final List<File> filesInDay;
final GalleryLoader asyncLoader;
final SelectedFiles? selectedFiles;
final bool shouldRender;
final bool shouldRecycle;
final ValueNotifier toggleSelectAllFromDay;
final ValueNotifier areAllFilesSelected;
final int? photoGridSize;
final bool limitSelectionToOne;
LazyLoadingGridView(
this.tag,
this.filesInDay,
this.asyncLoader,
this.selectedFiles,
this.shouldRender,
this.shouldRecycle,
this.toggleSelectAllFromDay,
this.areAllFilesSelected,
this.photoGridSize, {
this.limitSelectionToOne = false,
Key? key,
}) : super(key: key ?? UniqueKey());
@override
State<LazyLoadingGridView> createState() => _LazyLoadingGridViewState();
}
class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
bool? _shouldRender;
int? _currentUserID;
late StreamSubscription<ClearSelectionsEvent> _clearSelectionsEvent;
@override
void initState() {
_shouldRender = widget.shouldRender;
_currentUserID = Configuration.instance.getUserID();
widget.selectedFiles?.addListener(_selectedFilesListener);
_clearSelectionsEvent =
Bus.instance.on<ClearSelectionsEvent>().listen((event) {
if (mounted) {
setState(() {});
}
});
widget.toggleSelectAllFromDay.addListener(_toggleSelectAllFromDayListener);
super.initState();
}
@override
void dispose() {
widget.selectedFiles?.removeListener(_selectedFilesListener);
_clearSelectionsEvent.cancel();
widget.toggleSelectAllFromDay
.removeListener(_toggleSelectAllFromDayListener);
super.dispose();
}
@override
void didUpdateWidget(LazyLoadingGridView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!listEquals(widget.filesInDay, oldWidget.filesInDay)) {
_shouldRender = widget.shouldRender;
}
}
@override
Widget build(BuildContext context) {
if (widget.shouldRecycle) {
return _getRecyclableView();
} else {
return _getNonRecyclableView();
}
}
Widget _getRecyclableView() {
return VisibilityDetector(
key: Key("gallery" + widget.filesInDay.first.tag),
onVisibilityChanged: (visibility) {
final shouldRender = visibility.visibleFraction > 0;
if (mounted && shouldRender != _shouldRender) {
setState(() {
_shouldRender = shouldRender;
});
}
},
child: _shouldRender!
? _getGridView()
: PlaceHolderWidget(widget.filesInDay.length, widget.photoGridSize!),
);
}
Widget _getNonRecyclableView() {
if (!_shouldRender!) {
return VisibilityDetector(
key: Key("gallery" + widget.filesInDay.first.tag),
onVisibilityChanged: (visibility) {
if (mounted && visibility.visibleFraction > 0 && !_shouldRender!) {
setState(() {
_shouldRender = true;
});
}
},
child:
PlaceHolderWidget(widget.filesInDay.length, widget.photoGridSize!),
);
} else {
return _getGridView();
}
}
Widget _getGridView() {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
// to disable GridView's scrolling
itemBuilder: (context, index) {
return _buildFile(context, widget.filesInDay[index]);
},
itemCount: widget.filesInDay.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisSpacing: 2,
mainAxisSpacing: 2,
crossAxisCount: widget.photoGridSize!,
),
padding: const EdgeInsets.symmetric(vertical: (galleryGridSpacing / 2)),
);
}
Widget _buildFile(BuildContext context, File file) {
final isFileSelected = widget.selectedFiles?.isFileSelected(file) ?? false;
Color selectionColor = Colors.white;
if (isFileSelected &&
file.isUploaded &&
(file.ownerID != _currentUserID ||
file.pubMagicMetadata!.uploaderName != null)) {
final avatarColors = getEnteColorScheme(context).avatarColors;
final int randomID = file.ownerID != _currentUserID
? file.ownerID!
: file.pubMagicMetadata!.uploaderName.sumAsciiValues;
selectionColor = avatarColors[(randomID).remainder(avatarColors.length)];
}
return GestureDetector(
onTap: () {
widget.limitSelectionToOne
? _onTapWithSelectionLimit(file)
: _onTapNoSelectionLimit(file);
},
onLongPress: () {
widget.limitSelectionToOne
? _onLongPressWithSelectionLimit(file)
: _onLongPressNoSelectionLimit(file);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(1),
child: Stack(
children: [
Hero(
tag: widget.tag + file.tag,
child: ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(
isFileSelected ? 0.4 : 0,
),
BlendMode.darken,
),
child: ThumbnailWidget(
file,
diskLoadDeferDuration: thumbnailDiskLoadDeferDuration,
serverLoadDeferDuration: thumbnailServerLoadDeferDuration,
shouldShowLivePhotoOverlay: true,
key: Key(widget.tag + file.tag),
thumbnailSize: widget.photoGridSize! < photoGridSizeDefault
? thumbnailLargeSize
: thumbnailSmallSize,
shouldShowOwnerAvatar: !isFileSelected,
),
),
),
Visibility(
visible: isFileSelected,
child: Positioned(
right: 4,
top: 4,
child: Icon(
Icons.check_circle_rounded,
size: 20,
color: selectionColor, //same for both themes
),
),
)
],
),
),
);
}
void _toggleFileSelection(File file) {
widget.selectedFiles!.toggleSelection(file);
}
void _onTapNoSelectionLimit(File file) async {
if (widget.selectedFiles?.files.isNotEmpty ?? false) {
_toggleFileSelection(file);
} else {
if (AppLifecycleService.instance.mediaExtensionAction.action ==
IntentAction.pick) {
final ioFile = await getFile(file);
MediaExtension().setResult("file://${ioFile!.path}");
} else {
_routeToDetailPage(file, context);
}
}
}
void _onTapWithSelectionLimit(File file) {
if (widget.selectedFiles!.files.isNotEmpty &&
widget.selectedFiles!.files.first != file) {
widget.selectedFiles!.clearAll();
}
_toggleFileSelection(file);
}
void _onLongPressNoSelectionLimit(File file) {
if (widget.selectedFiles!.files.isNotEmpty) {
_routeToDetailPage(file, context);
} else if (AppLifecycleService.instance.mediaExtensionAction.action ==
IntentAction.main) {
HapticFeedback.lightImpact();
_toggleFileSelection(file);
}
}
Future<void> _onLongPressWithSelectionLimit(File file) async {
if (AppLifecycleService.instance.mediaExtensionAction.action ==
IntentAction.pick) {
final ioFile = await getFile(file);
MediaExtension().setResult("file://${ioFile!.path}");
} else {
_routeToDetailPage(file, context);
}
}
void _routeToDetailPage(File file, BuildContext context) {
final page = DetailPage(
DetailPageConfiguration(
List.unmodifiable(widget.filesInDay),
widget.asyncLoader,
widget.filesInDay.indexOf(file),
widget.tag,
),
);
routeToPage(context, page, forceCustomPageRoute: true);
}
void _selectedFilesListener() {
if (widget.selectedFiles!.files.containsAll(widget.filesInDay.toSet())) {
widget.areAllFilesSelected.value = true;
} else {
widget.areAllFilesSelected.value = false;
}
bool shouldRefresh = false;
for (final file in widget.filesInDay) {
if (widget.selectedFiles!.isPartOfLastSelected(file)) {
shouldRefresh = true;
}
}
if (shouldRefresh && mounted) {
setState(() {});
}
}
void _toggleSelectAllFromDayListener() {
if (widget.selectedFiles!.files.containsAll(widget.filesInDay.toSet())) {
setState(() {
widget.selectedFiles!.unSelectAll(widget.filesInDay.toSet());
});
} else {
widget.selectedFiles!.selectAll(widget.filesInDay.toSet());
}
}
}

View file

@ -0,0 +1,166 @@
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:media_extension/media_extension.dart";
import "package:media_extension/media_extension_action_types.dart";
import "package:photos/core/constants.dart";
import "package:photos/extensions/string_ext.dart";
import "package:photos/models/file.dart";
import "package:photos/models/selected_files.dart";
import "package:photos/services/app_lifecycle_service.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/viewer/file/detail_page.dart";
import "package:photos/ui/viewer/file/thumbnail_widget.dart";
import "package:photos/ui/viewer/gallery/gallery.dart";
import "package:photos/utils/file_util.dart";
import "package:photos/utils/navigation_util.dart";
class GalleryFileWidget extends StatelessWidget {
final File file;
final SelectedFiles? selectedFiles;
final bool limitSelectionToOne;
final String tag;
final int photoGridSize;
final int? currentUserID;
final List<File> filesInDay;
final GalleryLoader asyncLoader;
const GalleryFileWidget({
required this.file,
required this.selectedFiles,
required this.limitSelectionToOne,
required this.tag,
required this.photoGridSize,
required this.currentUserID,
required this.filesInDay,
required this.asyncLoader,
super.key,
});
@override
Widget build(BuildContext context) {
final isFileSelected = selectedFiles?.isFileSelected(file) ?? false;
Color selectionColor = Colors.white;
if (isFileSelected &&
file.isUploaded &&
(file.ownerID != currentUserID ||
file.pubMagicMetadata!.uploaderName != null)) {
final avatarColors = getEnteColorScheme(context).avatarColors;
final int randomID = file.ownerID != currentUserID
? file.ownerID!
: file.pubMagicMetadata!.uploaderName.sumAsciiValues;
selectionColor = avatarColors[(randomID).remainder(avatarColors.length)];
}
return GestureDetector(
onTap: () {
limitSelectionToOne
? _onTapWithSelectionLimit(file)
: _onTapNoSelectionLimit(context, file);
},
onLongPress: () {
limitSelectionToOne
? _onLongPressWithSelectionLimit(context, file)
: _onLongPressNoSelectionLimit(context, file);
},
child: ClipRRect(
borderRadius: BorderRadius.circular(1),
child: Stack(
children: [
Hero(
tag: tag + file.tag,
child: ColorFiltered(
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(
isFileSelected ? 0.4 : 0,
),
BlendMode.darken,
),
child: ThumbnailWidget(
file,
diskLoadDeferDuration: thumbnailDiskLoadDeferDuration,
serverLoadDeferDuration: thumbnailServerLoadDeferDuration,
shouldShowLivePhotoOverlay: true,
key: Key(tag + file.tag),
thumbnailSize: photoGridSize < photoGridSizeDefault
? thumbnailLargeSize
: thumbnailSmallSize,
shouldShowOwnerAvatar: !isFileSelected,
),
),
),
Visibility(
visible: isFileSelected,
child: Positioned(
right: 4,
top: 4,
child: Icon(
Icons.check_circle_rounded,
size: 20,
color: selectionColor, //same for both themes
),
),
)
],
),
),
);
}
void _toggleFileSelection(File file) {
selectedFiles!.toggleSelection(file);
}
void _onTapWithSelectionLimit(File file) {
if (selectedFiles!.files.isNotEmpty && selectedFiles!.files.first != file) {
selectedFiles!.clearAll();
}
_toggleFileSelection(file);
}
void _onTapNoSelectionLimit(BuildContext context, File file) async {
if (selectedFiles?.files.isNotEmpty ?? false) {
_toggleFileSelection(file);
} else {
if (AppLifecycleService.instance.mediaExtensionAction.action ==
IntentAction.pick) {
final ioFile = await getFile(file);
MediaExtension().setResult("file://${ioFile!.path}");
} else {
_routeToDetailPage(file, context);
}
}
}
void _onLongPressNoSelectionLimit(BuildContext context, File file) {
if (selectedFiles!.files.isNotEmpty) {
_routeToDetailPage(file, context);
} else if (AppLifecycleService.instance.mediaExtensionAction.action ==
IntentAction.main) {
HapticFeedback.lightImpact();
_toggleFileSelection(file);
}
}
Future<void> _onLongPressWithSelectionLimit(
BuildContext context,
File file,
) async {
if (AppLifecycleService.instance.mediaExtensionAction.action ==
IntentAction.pick) {
final ioFile = await getFile(file);
MediaExtension().setResult("file://${ioFile!.path}");
} else {
_routeToDetailPage(file, context);
}
}
void _routeToDetailPage(File file, BuildContext context) {
final page = DetailPage(
DetailPageConfiguration(
List.unmodifiable(filesInDay),
asyncLoader,
filesInDay.indexOf(file),
tag,
),
);
routeToPage(context, page, forceCustomPageRoute: true);
}
}

View file

@ -0,0 +1,137 @@
import "package:flutter/material.dart";
import "package:logging/logging.dart";
import "package:photos/core/event_bus.dart";
import "package:photos/ente_theme_data.dart";
import "package:photos/events/files_updated_event.dart";
import "package:photos/models/file.dart";
import "package:photos/models/selected_files.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/huge_listview/huge_listview.dart";
import "package:photos/ui/viewer/gallery/component/lazy_loading_gallery.dart";
import "package:photos/ui/viewer/gallery/gallery.dart";
import "package:photos/utils/date_time_util.dart";
import "package:photos/utils/local_settings.dart";
import "package:scrollable_positioned_list/scrollable_positioned_list.dart";
class GalleryListView extends StatelessWidget {
final GlobalKey<HugeListViewState<dynamic>> hugeListViewKey;
final ItemScrollController itemScroller;
final List<List<File>> collatedFiles;
final bool disableScroll;
final Widget? header;
final Widget? footer;
final Widget emptyState;
final GalleryLoader asyncLoader;
final Stream<FilesUpdatedEvent>? reloadEvent;
final Set<EventType> removalEventTypes;
final String tagPrefix;
final double scrollBottomSafeArea;
final bool limitSelectionToOne;
final SelectedFiles? selectedFiles;
final bool shouldCollateFilesByDay;
final String logTag;
final Logger logger;
const GalleryListView({
required this.hugeListViewKey,
required this.itemScroller,
required this.collatedFiles,
required this.disableScroll,
this.header,
this.footer,
required this.emptyState,
required this.asyncLoader,
this.reloadEvent,
required this.removalEventTypes,
required this.tagPrefix,
required this.scrollBottomSafeArea,
required this.limitSelectionToOne,
this.selectedFiles,
required this.shouldCollateFilesByDay,
required this.logTag,
required this.logger,
super.key,
});
@override
Widget build(BuildContext context) {
return HugeListView<List<File>>(
key: hugeListViewKey,
controller: itemScroller,
startIndex: 0,
totalCount: collatedFiles.length,
isDraggableScrollbarEnabled: collatedFiles.length > 10,
disableScroll: disableScroll,
waitBuilder: (_) {
return const EnteLoadingWidget();
},
emptyResultBuilder: (_) {
final List<Widget> children = [];
if (header != null) {
children.add(header!);
}
children.add(
Expanded(
child: emptyState,
),
);
if (footer != null) {
children.add(footer!);
}
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: children,
);
},
itemBuilder: (context, index) {
Widget gallery;
gallery = LazyLoadingGallery(
collatedFiles[index],
index,
reloadEvent,
removalEventTypes,
asyncLoader,
selectedFiles,
tagPrefix,
Bus.instance
.on<GalleryIndexUpdatedEvent>()
.where((event) => event.tag == tagPrefix)
.map((event) => event.index),
shouldCollateFilesByDay,
logTag: logTag,
photoGridSize: LocalSettings.instance.getPhotoGridSize(),
limitSelectionToOne: limitSelectionToOne,
);
if (header != null && index == 0) {
gallery = Column(children: [header!, gallery]);
}
if (footer != null && index == collatedFiles.length - 1) {
gallery = Column(children: [gallery, footer!]);
}
return gallery;
},
labelTextBuilder: (int index) {
try {
return getMonthAndYear(
DateTime.fromMicrosecondsSinceEpoch(
collatedFiles[index][0].creationTime!,
),
);
} catch (e) {
logger.severe("label text builder failed", e);
return "";
}
},
thumbBackgroundColor:
Theme.of(context).colorScheme.galleryThumbBackgroundColor,
thumbDrawColor: Theme.of(context).colorScheme.galleryThumbDrawColor,
thumbPadding: header != null
? const EdgeInsets.only(top: 60)
: const EdgeInsets.all(0),
bottomSafeArea: scrollBottomSafeArea,
firstShown: (int firstIndex) {
Bus.instance.fire(GalleryIndexUpdatedEvent(tagPrefix, firstIndex));
},
);
}
}

View file

@ -0,0 +1,359 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/events/files_updated_event.dart';
import 'package:photos/models/file.dart';
import 'package:photos/models/selected_files.dart';
import 'package:photos/theme/ente_theme.dart';
import 'package:photos/ui/huge_listview/place_holder_widget.dart';
import "package:photos/ui/viewer/gallery/component/day_widget.dart";
import "package:photos/ui/viewer/gallery/component/gallery_file_widget.dart";
import 'package:photos/ui/viewer/gallery/component/lazy_loading_grid_view.dart';
import 'package:photos/ui/viewer/gallery/gallery.dart';
class LazyLoadingGallery extends StatefulWidget {
final List<File> files;
final int index;
final Stream<FilesUpdatedEvent>? reloadEvent;
final Set<EventType> removalEventTypes;
final GalleryLoader asyncLoader;
final SelectedFiles? selectedFiles;
final String tag;
final String? logTag;
final Stream<int> currentIndexStream;
final int photoGridSize;
final bool areFilesCollatedByDay;
final bool limitSelectionToOne;
LazyLoadingGallery(
this.files,
this.index,
this.reloadEvent,
this.removalEventTypes,
this.asyncLoader,
this.selectedFiles,
this.tag,
this.currentIndexStream,
this.areFilesCollatedByDay, {
this.logTag = "",
this.photoGridSize = photoGridSizeDefault,
this.limitSelectionToOne = false,
Key? key,
}) : super(key: key ?? UniqueKey());
@override
State<LazyLoadingGallery> createState() => _LazyLoadingGalleryState();
}
class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
static const kNumberOfDaysToRenderBeforeAndAfter = 8;
late Logger _logger;
late List<File> _files;
late StreamSubscription<FilesUpdatedEvent>? _reloadEventSubscription;
late StreamSubscription<int> _currentIndexSubscription;
bool? _shouldRender;
final ValueNotifier<bool> _toggleSelectAllFromDay = ValueNotifier(false);
final ValueNotifier<bool> _showSelectAllButton = ValueNotifier(false);
final ValueNotifier<bool> _areAllFromDaySelected = ValueNotifier(false);
@override
void initState() {
//this is for removing the 'select all from day' icon on unselecting all files with 'cancel'
widget.selectedFiles?.addListener(_selectedFilesListener);
super.initState();
_init();
}
void _init() {
_logger = Logger("LazyLoading_${widget.logTag}");
_shouldRender = true;
_files = widget.files;
_reloadEventSubscription = widget.reloadEvent?.listen((e) => _onReload(e));
_currentIndexSubscription =
widget.currentIndexStream.listen((currentIndex) {
final bool shouldRender = (currentIndex - widget.index).abs() <
kNumberOfDaysToRenderBeforeAndAfter;
if (mounted && shouldRender != _shouldRender) {
setState(() {
_shouldRender = shouldRender;
});
}
});
}
Future _onReload(FilesUpdatedEvent event) async {
final galleryDate =
DateTime.fromMicrosecondsSinceEpoch(_files[0].creationTime!);
final filesUpdatedThisDay = event.updatedFiles.where((file) {
final fileDate = DateTime.fromMicrosecondsSinceEpoch(file.creationTime!);
return fileDate.year == galleryDate.year &&
fileDate.month == galleryDate.month &&
fileDate.day == galleryDate.day;
});
if (filesUpdatedThisDay.isNotEmpty) {
if (kDebugMode) {
_logger.info(
filesUpdatedThisDay.length.toString() +
" files were updated due to ${event.reason} on " +
DateTime.fromMicrosecondsSinceEpoch(
galleryDate.microsecondsSinceEpoch,
).toIso8601String(),
);
}
if (event.type == EventType.addedOrUpdated) {
final dayStartTime =
DateTime(galleryDate.year, galleryDate.month, galleryDate.day);
final result = await widget.asyncLoader(
dayStartTime.microsecondsSinceEpoch,
dayStartTime.microsecondsSinceEpoch + microSecondsInDay - 1,
);
if (mounted) {
setState(() {
_files = result.files;
});
}
} else if (widget.removalEventTypes.contains(event.type)) {
// Files were removed
final generatedFileIDs = <int?>{};
final uploadedFileIds = <int?>{};
for (final file in filesUpdatedThisDay) {
if (file.generatedID != null) {
generatedFileIDs.add(file.generatedID);
} else if (file.uploadedFileID != null) {
uploadedFileIds.add(file.uploadedFileID);
}
}
final List<File> files = [];
files.addAll(_files);
files.removeWhere(
(file) =>
generatedFileIDs.contains(file.generatedID) ||
uploadedFileIds.contains(file.uploadedFileID),
);
if (kDebugMode) {
_logger.finest(
"removed ${_files.length - files.length} due to ${event.reason}",
);
}
if (mounted) {
setState(() {
_files = files;
});
}
} else {
if (kDebugMode) {
debugPrint("Unexpected event ${event.type.name}");
}
}
}
}
@override
void dispose() {
_reloadEventSubscription?.cancel();
_currentIndexSubscription.cancel();
widget.selectedFiles?.removeListener(_selectedFilesListener);
_toggleSelectAllFromDay.dispose();
_showSelectAllButton.dispose();
_areAllFromDaySelected.dispose();
super.dispose();
}
@override
void didUpdateWidget(LazyLoadingGallery oldWidget) {
super.didUpdateWidget(oldWidget);
if (!listEquals(_files, widget.files)) {
_reloadEventSubscription?.cancel();
_init();
}
}
@override
Widget build(BuildContext context) {
if (_files.isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (widget.areFilesCollatedByDay)
DayWidget(
timestamp: _files[0].creationTime!,
gridSize: widget.photoGridSize,
),
widget.limitSelectionToOne
? const SizedBox.shrink()
: ValueListenableBuilder(
valueListenable: _showSelectAllButton,
builder: (context, dynamic value, _) {
return !value
? const SizedBox.shrink()
: GestureDetector(
behavior: HitTestBehavior.translucent,
child: SizedBox(
width: 48,
height: 44,
child: ValueListenableBuilder(
valueListenable: _areAllFromDaySelected,
builder: (context, dynamic value, _) {
return value
? const Icon(
Icons.check_circle,
size: 18,
)
: Icon(
Icons.check_circle_outlined,
color: getEnteColorScheme(context)
.strokeMuted,
size: 18,
);
},
),
),
onTap: () {
//this value has no significance
//changing only to notify the listeners
_toggleSelectAllFromDay.value =
!_toggleSelectAllFromDay.value;
},
);
},
)
],
),
_shouldRender!
? GetGallery(
photoGridSize: widget.photoGridSize,
files: _files,
tag: widget.tag,
asyncLoader: widget.asyncLoader,
selectedFiles: widget.selectedFiles,
toggleSelectAllFromDay: _toggleSelectAllFromDay,
areAllFromDaySelected: _areAllFromDaySelected,
limitSelectionToOne: widget.limitSelectionToOne,
)
: PlaceHolderWidget(
_files.length,
widget.photoGridSize,
),
],
);
}
void _selectedFilesListener() {
if (widget.selectedFiles!.files.isEmpty) {
_showSelectAllButton.value = false;
} else {
_showSelectAllButton.value = true;
}
}
}
class GetGallery extends StatelessWidget {
final int photoGridSize;
final List<File> files;
final String tag;
final GalleryLoader asyncLoader;
final SelectedFiles? selectedFiles;
final ValueNotifier<bool> toggleSelectAllFromDay;
final ValueNotifier<bool> areAllFromDaySelected;
final bool limitSelectionToOne;
const GetGallery({
required this.photoGridSize,
required this.files,
required this.tag,
required this.asyncLoader,
required this.selectedFiles,
required this.toggleSelectAllFromDay,
required this.areAllFromDaySelected,
required this.limitSelectionToOne,
super.key,
});
@override
Widget build(BuildContext context) {
const kRecycleLimit = 400;
final List<Widget> childGalleries = [];
final subGalleryItemLimit = photoGridSize * subGalleryMultiplier;
for (int index = 0; index < files.length; index += subGalleryItemLimit) {
childGalleries.add(
LazyLoadingGridView(
tag,
files.sublist(
index,
min(index + subGalleryItemLimit, files.length),
),
asyncLoader,
selectedFiles,
index == 0,
files.length > kRecycleLimit,
toggleSelectAllFromDay,
areAllFromDaySelected,
photoGridSize,
limitSelectionToOne: limitSelectionToOne,
),
);
}
return Column(
children: childGalleries,
);
}
}
class GalleryGridViewWidget extends StatelessWidget {
final List<File> filesInDay;
final int photoGridSize;
final SelectedFiles? selectedFiles;
final bool limitSelectionToOne;
final String tag;
final int? currentUserID;
final GalleryLoader asyncLoader;
const GalleryGridViewWidget({
required this.filesInDay,
required this.photoGridSize,
this.selectedFiles,
required this.limitSelectionToOne,
required this.tag,
super.key,
this.currentUserID,
required this.asyncLoader,
});
@override
Widget build(BuildContext context) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
// to disable GridView's scrolling
itemBuilder: (context, index) {
return GalleryFileWidget(
file: filesInDay[index],
selectedFiles: selectedFiles,
limitSelectionToOne: limitSelectionToOne,
tag: tag,
photoGridSize: photoGridSize,
currentUserID: currentUserID,
filesInDay: filesInDay,
asyncLoader: asyncLoader,
);
},
itemCount: filesInDay.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisSpacing: 2,
mainAxisSpacing: 2,
crossAxisCount: photoGridSize,
),
padding: const EdgeInsets.symmetric(vertical: (galleryGridSpacing / 2)),
);
}
}

View file

@ -0,0 +1,134 @@
import "dart:async";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart";
import "package:photos/core/configuration.dart";
import "package:photos/core/event_bus.dart";
import "package:photos/events/clear_selections_event.dart";
import "package:photos/models/file.dart";
import "package:photos/models/selected_files.dart";
import "package:photos/ui/viewer/gallery/component/non_recyclable_view_widget.dart";
import "package:photos/ui/viewer/gallery/component/recyclable_view_widget.dart";
import "package:photos/ui/viewer/gallery/gallery.dart";
class LazyLoadingGridView extends StatefulWidget {
final String tag;
final List<File> filesInDay;
final GalleryLoader asyncLoader;
final SelectedFiles? selectedFiles;
final bool shouldRender;
final bool shouldRecycle;
final ValueNotifier toggleSelectAllFromDay;
final ValueNotifier areAllFilesSelected;
final int? photoGridSize;
final bool limitSelectionToOne;
LazyLoadingGridView(
this.tag,
this.filesInDay,
this.asyncLoader,
this.selectedFiles,
this.shouldRender,
this.shouldRecycle,
this.toggleSelectAllFromDay,
this.areAllFilesSelected,
this.photoGridSize, {
this.limitSelectionToOne = false,
Key? key,
}) : super(key: key ?? UniqueKey());
@override
State<LazyLoadingGridView> createState() => _LazyLoadingGridViewState();
}
class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
late bool _shouldRender;
int? _currentUserID;
late StreamSubscription<ClearSelectionsEvent> _clearSelectionsEvent;
@override
void initState() {
_shouldRender = widget.shouldRender;
_currentUserID = Configuration.instance.getUserID();
widget.selectedFiles?.addListener(_selectedFilesListener);
_clearSelectionsEvent =
Bus.instance.on<ClearSelectionsEvent>().listen((event) {
if (mounted) {
setState(() {});
}
});
widget.toggleSelectAllFromDay.addListener(_toggleSelectAllFromDayListener);
super.initState();
}
@override
void dispose() {
widget.selectedFiles?.removeListener(_selectedFilesListener);
_clearSelectionsEvent.cancel();
widget.toggleSelectAllFromDay
.removeListener(_toggleSelectAllFromDayListener);
super.dispose();
}
@override
void didUpdateWidget(LazyLoadingGridView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!listEquals(widget.filesInDay, oldWidget.filesInDay)) {
_shouldRender = widget.shouldRender;
}
}
@override
Widget build(BuildContext context) {
if (widget.shouldRecycle) {
return RecyclableViewWidget(
shouldRender: _shouldRender,
filesInDay: widget.filesInDay,
photoGridSize: widget.photoGridSize!,
limitSelectionToOne: widget.limitSelectionToOne,
tag: widget.tag,
asyncLoader: widget.asyncLoader,
selectedFiles: widget.selectedFiles,
currentUserID: _currentUserID,
);
} else {
return NonRecyclableViewWidget(
shouldRender: _shouldRender,
filesInDay: widget.filesInDay,
photoGridSize: widget.photoGridSize!,
limitSelectionToOne: widget.limitSelectionToOne,
tag: widget.tag,
asyncLoader: widget.asyncLoader,
selectedFiles: widget.selectedFiles,
currentUserID: _currentUserID,
);
}
}
void _selectedFilesListener() {
if (widget.selectedFiles!.files.containsAll(widget.filesInDay.toSet())) {
widget.areAllFilesSelected.value = true;
} else {
widget.areAllFilesSelected.value = false;
}
bool shouldRefresh = false;
for (final file in widget.filesInDay) {
if (widget.selectedFiles!.isPartOfLastSelected(file)) {
shouldRefresh = true;
}
}
if (shouldRefresh && mounted) {
setState(() {});
}
}
void _toggleSelectAllFromDayListener() {
if (widget.selectedFiles!.files.containsAll(widget.filesInDay.toSet())) {
setState(() {
widget.selectedFiles!.unSelectAll(widget.filesInDay.toSet());
});
} else {
widget.selectedFiles!.selectAll(widget.filesInDay.toSet());
}
}
}

View file

@ -0,0 +1,70 @@
import "package:flutter/material.dart";
import "package:photos/models/file.dart";
import "package:photos/models/selected_files.dart";
import "package:photos/ui/huge_listview/place_holder_widget.dart";
import "package:photos/ui/viewer/gallery/component/lazy_loading_gallery.dart";
import "package:photos/ui/viewer/gallery/gallery.dart";
import "package:visibility_detector/visibility_detector.dart";
class NonRecyclableViewWidget extends StatefulWidget {
final bool shouldRender;
final List<File> filesInDay;
final int photoGridSize;
final bool limitSelectionToOne;
final String tag;
final GalleryLoader asyncLoader;
final int? currentUserID;
final SelectedFiles? selectedFiles;
const NonRecyclableViewWidget({
required this.shouldRender,
required this.filesInDay,
required this.photoGridSize,
required this.limitSelectionToOne,
required this.tag,
required this.asyncLoader,
this.currentUserID,
this.selectedFiles,
super.key,
});
@override
State<NonRecyclableViewWidget> createState() =>
_NonRecyclableViewWidgetState();
}
class _NonRecyclableViewWidgetState extends State<NonRecyclableViewWidget> {
late bool _shouldRender;
@override
void initState() {
_shouldRender = widget.shouldRender;
super.initState();
}
@override
Widget build(BuildContext context) {
if (!_shouldRender!) {
return VisibilityDetector(
key: Key("gallery" + widget.filesInDay.first.tag),
onVisibilityChanged: (visibility) {
if (mounted && visibility.visibleFraction > 0 && !_shouldRender) {
setState(() {
_shouldRender = true;
});
}
},
child:
PlaceHolderWidget(widget.filesInDay.length, widget.photoGridSize),
);
} else {
return GalleryGridViewWidget(
filesInDay: widget.filesInDay,
photoGridSize: widget.photoGridSize,
limitSelectionToOne: widget.limitSelectionToOne,
tag: widget.tag,
asyncLoader: widget.asyncLoader,
selectedFiles: widget.selectedFiles,
currentUserID: widget.currentUserID,
);
}
}
}

View file

@ -0,0 +1,67 @@
import "package:flutter/material.dart";
import "package:photos/models/file.dart";
import "package:photos/models/selected_files.dart";
import "package:photos/ui/huge_listview/place_holder_widget.dart";
import "package:photos/ui/viewer/gallery/component/lazy_loading_gallery.dart";
import "package:photos/ui/viewer/gallery/gallery.dart";
import "package:visibility_detector/visibility_detector.dart";
class RecyclableViewWidget extends StatefulWidget {
final bool shouldRender;
final List<File> filesInDay;
final int photoGridSize;
final bool limitSelectionToOne;
final String tag;
final GalleryLoader asyncLoader;
final int? currentUserID;
final SelectedFiles? selectedFiles;
const RecyclableViewWidget({
required this.shouldRender,
required this.filesInDay,
required this.photoGridSize,
required this.limitSelectionToOne,
required this.tag,
required this.asyncLoader,
this.currentUserID,
this.selectedFiles,
super.key,
});
@override
State<RecyclableViewWidget> createState() => _RecyclableViewWidgetState();
}
class _RecyclableViewWidgetState extends State<RecyclableViewWidget> {
late bool _shouldRender;
@override
void initState() {
_shouldRender = widget.shouldRender;
super.initState();
}
@override
Widget build(BuildContext context) {
return VisibilityDetector(
key: Key("gallery" + widget.filesInDay.first.tag),
onVisibilityChanged: (visibility) {
final shouldRender = visibility.visibleFraction > 0;
if (mounted && shouldRender != _shouldRender) {
setState(() {
_shouldRender = shouldRender;
});
}
},
child: _shouldRender
? GalleryGridViewWidget(
filesInDay: widget.filesInDay,
photoGridSize: widget.photoGridSize,
limitSelectionToOne: widget.limitSelectionToOne,
tag: widget.tag,
asyncLoader: widget.asyncLoader,
selectedFiles: widget.selectedFiles,
currentUserID: widget.currentUserID,
)
: PlaceHolderWidget(widget.filesInDay.length, widget.photoGridSize),
);
}
}

View file

@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:photos/core/constants.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/ente_theme_data.dart';
import 'package:photos/events/event.dart';
import 'package:photos/events/files_updated_event.dart';
import 'package:photos/events/tab_changed_event.dart';
@ -14,10 +13,9 @@ import 'package:photos/models/file_load_result.dart';
import 'package:photos/models/selected_files.dart';
import 'package:photos/ui/common/loading_widget.dart';
import 'package:photos/ui/huge_listview/huge_listview.dart';
import 'package:photos/ui/huge_listview/lazy_loading_gallery.dart';
import "package:photos/ui/viewer/gallery/component/gallery_list_view_widget.dart";
import 'package:photos/ui/viewer/gallery/empty_state.dart';
import 'package:photos/utils/date_time_util.dart';
import 'package:photos/utils/local_settings.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
typedef GalleryLoader = Future<FileLoadResult> Function(
@ -79,18 +77,17 @@ class _GalleryState extends State<Gallery> {
late Logger _logger;
List<List<File>> _collatedFiles = [];
bool _hasLoadedFiles = false;
ItemScrollController? _itemScroller;
late ItemScrollController _itemScroller;
StreamSubscription<FilesUpdatedEvent>? _reloadEventSubscription;
StreamSubscription<TabDoubleTapEvent>? _tabDoubleTapEvent;
final _forceReloadEventSubscriptions = <StreamSubscription<Event>>[];
String? _logTag;
late int _photoGridSize;
late String _logTag;
@override
void initState() {
_logTag =
"Gallery_${widget.tagPrefix}${kDebugMode ? "_" + widget.albumName! : ""}";
_logger = Logger(_logTag!);
_logger = Logger(_logTag);
_logger.finest("init Gallery");
_itemScroller = ItemScrollController();
if (widget.reloadEvent != null) {
@ -112,7 +109,7 @@ class _GalleryState extends State<Gallery> {
// todo: Assign ID to Gallery and fire generic event with ID &
// target index/date
if (mounted && event.selectedIndex == 0) {
_itemScroller!.scrollTo(
_itemScroller.scrollTo(
index: 0,
duration: const Duration(milliseconds: 150),
);
@ -209,89 +206,24 @@ class _GalleryState extends State<Gallery> {
if (!_hasLoadedFiles) {
return widget.loadingWidget;
}
_photoGridSize = LocalSettings.instance.getPhotoGridSize();
return _getListView();
}
Widget _getListView() {
return HugeListView<List<File>>(
key: _hugeListViewKey,
controller: _itemScroller,
startIndex: 0,
totalCount: _collatedFiles.length,
isDraggableScrollbarEnabled: _collatedFiles.length > 10,
return GalleryListView(
hugeListViewKey: _hugeListViewKey,
itemScroller: _itemScroller,
collatedFiles: _collatedFiles,
disableScroll: widget.disableScroll,
waitBuilder: (_) {
return const EnteLoadingWidget();
},
emptyResultBuilder: (_) {
final List<Widget> children = [];
if (widget.header != null) {
children.add(widget.header!);
}
children.add(
Expanded(
child: widget.emptyState,
),
);
if (widget.footer != null) {
children.add(widget.footer!);
}
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: children,
);
},
itemBuilder: (context, index) {
Widget gallery;
gallery = LazyLoadingGallery(
_collatedFiles[index],
index,
widget.reloadEvent,
widget.removalEventTypes,
widget.asyncLoader,
widget.selectedFiles,
widget.tagPrefix,
Bus.instance
.on<GalleryIndexUpdatedEvent>()
.where((event) => event.tag == widget.tagPrefix)
.map((event) => event.index),
widget.shouldCollateFilesByDay,
logTag: _logTag,
photoGirdSize: _photoGridSize,
limitSelectionToOne: widget.limitSelectionToOne,
);
if (widget.header != null && index == 0) {
gallery = Column(children: [widget.header!, gallery]);
}
if (widget.footer != null && index == _collatedFiles.length - 1) {
gallery = Column(children: [gallery, widget.footer!]);
}
return gallery;
},
labelTextBuilder: (int index) {
try {
return getMonthAndYear(
DateTime.fromMicrosecondsSinceEpoch(
_collatedFiles[index][0].creationTime!,
),
);
} catch (e) {
_logger.severe("label text builder failed", e);
return "";
}
},
thumbBackgroundColor:
Theme.of(context).colorScheme.galleryThumbBackgroundColor,
thumbDrawColor: Theme.of(context).colorScheme.galleryThumbDrawColor,
thumbPadding: widget.header != null
? const EdgeInsets.only(top: 60)
: const EdgeInsets.all(0),
bottomSafeArea: widget.scrollBottomSafeArea,
firstShown: (int firstIndex) {
Bus.instance
.fire(GalleryIndexUpdatedEvent(widget.tagPrefix, firstIndex));
},
emptyState: widget.emptyState,
asyncLoader: widget.asyncLoader,
removalEventTypes: widget.removalEventTypes,
tagPrefix: widget.tagPrefix,
scrollBottomSafeArea: widget.scrollBottomSafeArea,
limitSelectionToOne: widget.limitSelectionToOne,
shouldCollateFilesByDay: widget.shouldCollateFilesByDay,
logTag: _logTag,
logger: _logger,
reloadEvent: widget.reloadEvent,
header: widget.header,
footer: widget.footer,
selectedFiles: widget.selectedFiles,
);
}