diff --git a/lib/core/constants.dart b/lib/core/constants.dart index 469170f7b..63f62ed39 100644 --- a/lib/core/constants.dart +++ b/lib/core/constants.dart @@ -59,3 +59,9 @@ const double mobileSmallThreshold = 336; const publicLinkDeviceLimits = [50, 25, 10, 5, 2, 1]; const kilometersPerDegree = 111.16; + +const radiusValues = [2, 10, 20, 40, 80, 200, 400, 1200]; + +const defaultRadiusValueIndex = 4; + +const galleryGridSpacing = 2.0; diff --git a/lib/db/files_db.dart b/lib/db/files_db.dart index 67f09df9e..d5298f3ff 100644 --- a/lib/db/files_db.dart +++ b/lib/db/files_db.dart @@ -1,3 +1,4 @@ +import 'dart:developer' as dev; import 'dart:io' as io; import 'package:flutter/foundation.dart'; @@ -485,14 +486,20 @@ class FilesDB { bool? asc, int visibility = visibilityVisible, Set? ignoredCollectionIDs, + bool onlyFilesWithLocation = false, }) async { + final stopWatch = Stopwatch()..start(); + final db = await instance.database; final order = (asc ?? false ? 'ASC' : 'DESC'); final results = await db.query( filesTable, - where: - '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1)' - ' AND $columnMMdVisibility = ?', + where: onlyFilesWithLocation + ? '$columnLatitude IS NOT NULL AND $columnLongitude IS NOT NULL AND ($columnLatitude IS NOT 0 OR $columnLongitude IS NOT 0)' + 'AND $columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1)' + 'AND $columnMMdVisibility = ?' + : '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1)' + ' AND $columnMMdVisibility = ?', whereArgs: [startTime, endTime, ownerID, visibility], orderBy: '$columnCreationTime ' + order + ', $columnModificationTime ' + order, @@ -501,6 +508,9 @@ class FilesDB { final files = convertToFiles(results); final List deduplicatedFiles = _deduplicatedAndFilterIgnoredFiles(files, ignoredCollectionIDs); + dev.log( + "getAllPendingOrUploadedFiles time taken: ${stopWatch.elapsedMilliseconds} ms"); + stopWatch.stop(); return FileLoadResult(deduplicatedFiles, files.length == limit); } @@ -511,14 +521,18 @@ class FilesDB { int? limit, bool? asc, Set? ignoredCollectionIDs, + bool onlyFilesWithLocation = false, }) async { final db = await instance.database; final order = (asc ?? false ? 'ASC' : 'DESC'); final results = await db.query( filesTable, - where: - '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)' - ' AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))', + where: onlyFilesWithLocation + ? '$columnLatitude IS NOT NULL AND $columnLongitude IS NOT NULL AND ($columnLatitude IS NOT 0 OR $columnLongitude IS NOT 0)' + '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)' + ' AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))' + : '$columnCreationTime >= ? AND $columnCreationTime <= ? AND ($columnOwnerID IS NULL OR $columnOwnerID = ?) AND ($columnMMdVisibility IS NULL OR $columnMMdVisibility = ?)' + ' AND ($columnLocalID IS NOT NULL OR ($columnCollectionID IS NOT NULL AND $columnCollectionID IS NOT -1))', whereArgs: [startTime, endTime, ownerID, visibilityVisible], orderBy: '$columnCreationTime ' + order + ', $columnModificationTime ' + order, @@ -1387,6 +1401,10 @@ class FilesDB { row[columnCollectionID] = file.collectionID ?? -1; row[columnTitle] = file.title; row[columnDeviceFolder] = file.deviceFolder; + // if (file.location == null || + // (file.location!.latitude == null && file.location!.longitude == null)) { + // file.location = Location.randomLocation(); + // } if (file.location != null) { row[columnLatitude] = file.location!.latitude; row[columnLongitude] = file.location!.longitude; diff --git a/lib/models/typedefs.dart b/lib/models/typedefs.dart index 7a42a1fe5..f2930ee59 100644 --- a/lib/models/typedefs.dart +++ b/lib/models/typedefs.dart @@ -5,3 +5,4 @@ typedef BoolCallBack = bool Function(); typedef FutureVoidCallbackParamStr = Future Function(String); typedef VoidCallbackParamStr = void Function(String); typedef FutureOrVoidCallback = FutureOr Function(); +typedef VoidCallbackParamInt = void Function(int); diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart index 57f7059a3..4fb2a356e 100644 --- a/lib/services/location_service.dart +++ b/lib/services/location_service.dart @@ -73,6 +73,21 @@ class LocationService { return result; } + bool isFileInsideLocationTag( + List center, + List fileCoordinates, + int radius, + ) { + final a = (radius * _scaleFactor(center[0])) / kilometersPerDegree; + final b = radius / kilometersPerDegree; + final x = center[0] - fileCoordinates[0]; + final y = center[1] - fileCoordinates[1]; + if ((x * x) / (a * a) + (y * y) / (b * b) <= 1) { + return true; + } + return false; + } + Future addFileToLocation(int locationId, int fileId) async { final list = getFilesByLocation(locationId.toString()); list.add(fileId.toString()); diff --git a/lib/states/add_location_state.dart b/lib/states/add_location_state.dart new file mode 100644 index 000000000..13358174b --- /dev/null +++ b/lib/states/add_location_state.dart @@ -0,0 +1,71 @@ +import "package:flutter/material.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/models/typedefs.dart"; +import "package:photos/utils/debouncer.dart"; + +class LocationTagDataStateProvider extends StatefulWidget { + final List coordinates; + final Widget child; + const LocationTagDataStateProvider(this.coordinates, this.child, {super.key}); + + @override + State createState() => + _LocationTagDataStateProviderState(); +} + +class _LocationTagDataStateProviderState + extends State { + int selectedRaduisIndex = defaultRadiusValueIndex; + late List coordinates; + final Debouncer _selectedRadiusDebouncer = + Debouncer(const Duration(milliseconds: 300)); + @override + void initState() { + coordinates = widget.coordinates; + super.initState(); + } + + void _updateSelectedIndex(int index) { + _selectedRadiusDebouncer.cancelDebounce(); + _selectedRadiusDebouncer.run(() async { + if (mounted) { + setState(() { + selectedRaduisIndex = index; + }); + } + }); + } + + @override + Widget build(BuildContext context) { + return InheritedLocationTagData( + selectedRaduisIndex, + coordinates, + _updateSelectedIndex, + child: widget.child, + ); + } +} + +class InheritedLocationTagData extends InheritedWidget { + final int selectedRadiusIndex; + final List coordinates; + final VoidCallbackParamInt updateSelectedIndex; + const InheritedLocationTagData( + this.selectedRadiusIndex, + this.coordinates, + this.updateSelectedIndex, { + required super.child, + super.key, + }); + + static InheritedLocationTagData of(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType()!; + } + + @override + bool updateShouldNotify(InheritedLocationTagData oldWidget) { + return oldWidget.selectedRadiusIndex != selectedRadiusIndex; + } +} diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index fcf2e9f62..1cc2d151b 100644 --- a/lib/theme/colors.dart +++ b/lib/theme/colors.dart @@ -216,6 +216,7 @@ const Color tabIconDark = Color.fromRGBO(255, 255, 255, 0.80); // Fixed Colors const Color fixedStrokeMutedWhite = Color.fromRGBO(255, 255, 255, 0.50); +const Color strokeSolidMutedLight = Color.fromRGBO(147, 147, 147, 1); const Color _primary700 = Color.fromRGBO(0, 179, 60, 1); const Color _primary500 = Color.fromRGBO(29, 185, 84, 1); diff --git a/lib/ui/actions/file/file_actions.dart b/lib/ui/actions/file/file_actions.dart index e289e8e6f..52ace3840 100644 --- a/lib/ui/actions/file/file_actions.dart +++ b/lib/ui/actions/file/file_actions.dart @@ -124,7 +124,7 @@ Future showSingleFileDeleteSheet( } } -Future showInfoSheet(BuildContext context, File file) async { +Future showDetailsSheet(BuildContext context, File file) async { final colorScheme = getEnteColorScheme(context); return showBarModalBottomSheet( topControl: const SizedBox.shrink(), diff --git a/lib/ui/collection_action_sheet.dart b/lib/ui/collection_action_sheet.dart index 109d361dd..155ea2463 100644 --- a/lib/ui/collection_action_sheet.dart +++ b/lib/ui/collection_action_sheet.dart @@ -158,8 +158,9 @@ class _CollectionActionSheetState extends State { _searchQuery = value; }); }, - cancellable: true, - shouldUnfocusOnCancelOrSubmit: true, + isClearable: true, + shouldUnfocusOnClearOrSubmit: true, + borderRadius: 2, ), ), _getCollectionItems(filesCount), diff --git a/lib/ui/components/divider_widget.dart b/lib/ui/components/divider_widget.dart index de30ea04f..1e8b3a882 100644 --- a/lib/ui/components/divider_widget.dart +++ b/lib/ui/components/divider_widget.dart @@ -28,17 +28,23 @@ class DividerWidget extends StatelessWidget { : getEnteColorScheme(context).strokeFaint; if (dividerType == DividerType.solid) { - return Container( - color: getEnteColorScheme(context).strokeFaint, - width: double.infinity, - height: 1, + return Padding( + padding: padding ?? EdgeInsets.zero, + child: Container( + color: getEnteColorScheme(context).strokeFaint, + width: double.infinity, + height: 1, + ), ); } if (dividerType == DividerType.bottomBar) { - return Container( - color: dividerColor, - width: double.infinity, - height: 1, + return Padding( + padding: padding ?? EdgeInsets.zero, + child: Container( + color: dividerColor, + width: double.infinity, + height: 1, + ), ); } diff --git a/lib/ui/components/text_input_widget.dart b/lib/ui/components/text_input_widget.dart index 53ae63521..b39cd9bb7 100644 --- a/lib/ui/components/text_input_widget.dart +++ b/lib/ui/components/text_input_widget.dart @@ -16,10 +16,15 @@ class TextInputWidget extends StatefulWidget { final Alignment? alignMessage; final bool? autoFocus; final int? maxLength; + final double borderRadius; ///TextInputWidget will listen to this notifier and executes onSubmit when ///notified. final ValueNotifier? submitNotifier; + + ///TextInputWidget will listen to this notifier and clears and unfocuses the + ///textFiled when notified. + final ValueNotifier? cancelNotifier; final bool alwaysShowSuccessState; final bool showOnlyLoadingState; final FutureVoidCallbackParamStr? onSubmit; @@ -28,8 +33,11 @@ class TextInputWidget extends StatefulWidget { final bool shouldSurfaceExecutionStates; final TextCapitalization? textCapitalization; final bool isPasswordInput; - final bool cancellable; - final bool shouldUnfocusOnCancelOrSubmit; + + ///Clear comes in the form of a suffix icon. It is unrelated to onCancel. + final bool isClearable; + final bool shouldUnfocusOnClearOrSubmit; + final FocusNode? focusNode; const TextInputWidget({ this.onSubmit, this.onChange, @@ -42,14 +50,17 @@ class TextInputWidget extends StatefulWidget { this.autoFocus, this.maxLength, this.submitNotifier, + this.cancelNotifier, this.alwaysShowSuccessState = false, this.showOnlyLoadingState = false, this.popNavAfterSubmission = false, this.shouldSurfaceExecutionStates = true, this.textCapitalization = TextCapitalization.none, this.isPasswordInput = false, - this.cancellable = false, - this.shouldUnfocusOnCancelOrSubmit = false, + this.isClearable = false, + this.shouldUnfocusOnClearOrSubmit = false, + this.borderRadius = 8, + this.focusNode, super.key, }); @@ -70,6 +81,7 @@ class _TextInputWidgetState extends State { @override void initState() { widget.submitNotifier?.addListener(_onSubmit); + widget.cancelNotifier?.addListener(_onCancel); if (widget.initialValue != null) { _textController.value = TextEditingValue( @@ -90,6 +102,7 @@ class _TextInputWidgetState extends State { @override void dispose() { widget.submitNotifier?.removeListener(_onSubmit); + widget.cancelNotifier?.removeListener(_onCancel); _obscureTextNotifier.dispose(); _textController.dispose(); super.dispose(); @@ -113,12 +126,13 @@ class _TextInputWidgetState extends State { } textInputChildren.add( ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), + borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius)), child: Material( child: TextFormField( textCapitalization: widget.textCapitalization!, autofocus: widget.autoFocus ?? false, controller: _textController, + focusNode: widget.focusNode, inputFormatters: widget.maxLength != null ? [LengthLimitingTextInputFormatter(50)] : null, @@ -155,9 +169,9 @@ class _TextInputWidgetState extends State { obscureTextNotifier: _obscureTextNotifier, isPasswordInput: widget.isPasswordInput, textController: _textController, - isCancellable: widget.cancellable, - shouldUnfocusOnCancelOrSubmit: - widget.shouldUnfocusOnCancelOrSubmit, + isClearable: widget.isClearable, + shouldUnfocusOnClearOrSubmit: + widget.shouldUnfocusOnClearOrSubmit, ), ), ), @@ -224,7 +238,7 @@ class _TextInputWidgetState extends State { }); }), ); - if (widget.shouldUnfocusOnCancelOrSubmit) { + if (widget.shouldUnfocusOnClearOrSubmit) { FocusScope.of(context).unfocus(); } try { @@ -303,6 +317,11 @@ class _TextInputWidgetState extends State { } } + void _onCancel() { + _textController.clear(); + FocusScope.of(context).unfocus(); + } + void _popNavigatorStack(BuildContext context, {Exception? e}) { Navigator.of(context).canPop() ? Navigator.of(context).pop(e) : null; } @@ -315,8 +334,8 @@ class SuffixIconWidget extends StatelessWidget { final TextEditingController textController; final ValueNotifier? obscureTextNotifier; final bool isPasswordInput; - final bool isCancellable; - final bool shouldUnfocusOnCancelOrSubmit; + final bool isClearable; + final bool shouldUnfocusOnClearOrSubmit; const SuffixIconWidget({ required this.executionState, @@ -324,8 +343,8 @@ class SuffixIconWidget extends StatelessWidget { required this.textController, this.obscureTextNotifier, this.isPasswordInput = false, - this.isCancellable = false, - this.shouldUnfocusOnCancelOrSubmit = false, + this.isClearable = false, + this.shouldUnfocusOnClearOrSubmit = false, super.key, }); @@ -335,11 +354,11 @@ class SuffixIconWidget extends StatelessWidget { final colorScheme = getEnteColorScheme(context); if (executionState == ExecutionState.idle || !shouldSurfaceExecutionStates) { - if (isCancellable) { + if (isClearable) { trailingWidget = GestureDetector( onTap: () { textController.clear(); - if (shouldUnfocusOnCancelOrSubmit) { + if (shouldUnfocusOnClearOrSubmit) { FocusScope.of(context).unfocus(); } }, diff --git a/lib/ui/home/memories_widget.dart b/lib/ui/home/memories_widget.dart index fb3feeb2b..9d4a9c0f0 100644 --- a/lib/ui/home/memories_widget.dart +++ b/lib/ui/home/memories_widget.dart @@ -372,7 +372,7 @@ class _FullScreenMemoryState extends State { color: Colors.white, //same for both themes ), onPressed: () { - showInfoSheet(context, file); + showDetailsSheet(context, file); }, ), IconButton( diff --git a/lib/ui/huge_listview/huge_listview.dart b/lib/ui/huge_listview/huge_listview.dart index ce651d297..056039e1c 100644 --- a/lib/ui/huge_listview/huge_listview.dart +++ b/lib/ui/huge_listview/huge_listview.dart @@ -60,6 +60,8 @@ class HugeListView extends StatefulWidget { final EdgeInsetsGeometry? thumbPadding; + final bool disableScroll; + const HugeListView({ Key? key, this.controller, @@ -77,6 +79,7 @@ class HugeListView extends StatefulWidget { this.bottomSafeArea = 120.0, this.isDraggableScrollbarEnabled = true, this.thumbPadding, + this.disableScroll = false, }) : super(key: key); @override @@ -160,6 +163,9 @@ class HugeListViewState extends State> { isEnabled: widget.isDraggableScrollbarEnabled, padding: widget.thumbPadding, child: ScrollablePositionedList.builder( + physics: widget.disableScroll + ? const NeverScrollableScrollPhysics() + : null, itemScrollController: widget.controller, itemPositionsListener: listener, initialScrollIndex: widget.startIndex, diff --git a/lib/ui/huge_listview/lazy_loading_gallery.dart b/lib/ui/huge_listview/lazy_loading_gallery.dart index d1e012f8d..d05617304 100644 --- a/lib/ui/huge_listview/lazy_loading_gallery.dart +++ b/lib/ui/huge_listview/lazy_loading_gallery.dart @@ -32,11 +32,12 @@ class LazyLoadingGallery extends StatefulWidget { final Stream? reloadEvent; final Set removalEventTypes; final GalleryLoader asyncLoader; - final SelectedFiles selectedFiles; + final SelectedFiles? selectedFiles; final String tag; final String? logTag; final Stream currentIndexStream; final int photoGirdSize; + final bool areFilesCollatedByDay; LazyLoadingGallery( this.files, this.index, @@ -45,7 +46,8 @@ class LazyLoadingGallery extends StatefulWidget { this.asyncLoader, this.selectedFiles, this.tag, - this.currentIndexStream, { + this.currentIndexStream, + this.areFilesCollatedByDay, { this.logTag = "", this.photoGirdSize = photoGridSizeDefault, Key? key, @@ -62,7 +64,7 @@ class _LazyLoadingGalleryState extends State { late Logger _logger; late List _files; - late StreamSubscription _reloadEventSubscription; + late StreamSubscription? _reloadEventSubscription; late StreamSubscription _currentIndexSubscription; bool? _shouldRender; final ValueNotifier _toggleSelectAllFromDay = ValueNotifier(false); @@ -72,7 +74,7 @@ class _LazyLoadingGalleryState extends State { @override void initState() { //this is for removing the 'select all from day' icon on unselecting all files with 'cancel' - widget.selectedFiles.addListener(_selectedFilesListener); + widget.selectedFiles?.addListener(_selectedFilesListener); super.initState(); _init(); } @@ -81,7 +83,7 @@ class _LazyLoadingGalleryState extends State { _logger = Logger("LazyLoading_${widget.logTag}"); _shouldRender = true; _files = widget.files; - _reloadEventSubscription = widget.reloadEvent!.listen((e) => _onReload(e)); + _reloadEventSubscription = widget.reloadEvent?.listen((e) => _onReload(e)); _currentIndexSubscription = widget.currentIndexStream.listen((currentIndex) { @@ -162,9 +164,9 @@ class _LazyLoadingGalleryState extends State { @override void dispose() { - _reloadEventSubscription.cancel(); + _reloadEventSubscription?.cancel(); _currentIndexSubscription.cancel(); - widget.selectedFiles.removeListener(_selectedFilesListener); + widget.selectedFiles?.removeListener(_selectedFilesListener); _toggleSelectAllFromDay.dispose(); _showSelectAllButton.dispose(); _areAllFromDaySelected.dispose(); @@ -175,7 +177,7 @@ class _LazyLoadingGalleryState extends State { void didUpdateWidget(LazyLoadingGallery oldWidget) { super.didUpdateWidget(oldWidget); if (!listEquals(_files, widget.files)) { - _reloadEventSubscription.cancel(); + _reloadEventSubscription?.cancel(); _init(); } } @@ -190,11 +192,12 @@ class _LazyLoadingGalleryState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - getDayWidget( - context, - _files[0].creationTime!, - widget.photoGirdSize, - ), + if (widget.areFilesCollatedByDay) + getDayWidget( + context, + _files[0].creationTime!, + widget.photoGirdSize, + ), ValueListenableBuilder( valueListenable: _showSelectAllButton, builder: (context, dynamic value, _) { @@ -271,7 +274,7 @@ class _LazyLoadingGalleryState extends State { } void _selectedFilesListener() { - if (widget.selectedFiles.files.isEmpty) { + if (widget.selectedFiles!.files.isEmpty) { _showSelectAllButton.value = false; } else { _showSelectAllButton.value = true; @@ -283,7 +286,7 @@ class LazyLoadingGridView extends StatefulWidget { final String tag; final List filesInDay; final GalleryLoader asyncLoader; - final SelectedFiles selectedFiles; + final SelectedFiles? selectedFiles; final bool shouldRender; final bool shouldRecycle; final ValueNotifier toggleSelectAllFromDay; @@ -316,7 +319,7 @@ class _LazyLoadingGridViewState extends State { void initState() { _shouldRender = widget.shouldRender; _currentUserID = Configuration.instance.getUserID(); - widget.selectedFiles.addListener(_selectedFilesListener); + widget.selectedFiles?.addListener(_selectedFilesListener); _clearSelectionsEvent = Bus.instance.on().listen((event) { if (mounted) { @@ -329,7 +332,7 @@ class _LazyLoadingGridViewState extends State { @override void dispose() { - widget.selectedFiles.removeListener(_selectedFilesListener); + widget.selectedFiles?.removeListener(_selectedFilesListener); _clearSelectionsEvent.cancel(); widget.toggleSelectAllFromDay .removeListener(_toggleSelectAllFromDayListener); @@ -403,12 +406,12 @@ class _LazyLoadingGridViewState extends State { mainAxisSpacing: 2, crossAxisCount: widget.photoGridSize!, ), - padding: const EdgeInsets.all(0), + padding: const EdgeInsets.symmetric(vertical: (galleryGridSpacing / 2)), ); } Widget _buildFile(BuildContext context, File file) { - final isFileSelected = widget.selectedFiles.isFileSelected(file); + final isFileSelected = widget.selectedFiles?.isFileSelected(file) ?? false; Color selectionColor = Colors.white; if (isFileSelected && file.isUploaded && @@ -422,7 +425,7 @@ class _LazyLoadingGridViewState extends State { } return GestureDetector( onTap: () async { - if (widget.selectedFiles.files.isNotEmpty) { + if (widget.selectedFiles?.files.isNotEmpty ?? false) { _selectFile(file); } else { if (AppLifecycleService.instance.mediaExtensionAction.action == @@ -434,13 +437,15 @@ class _LazyLoadingGridViewState extends State { } } }, - onLongPress: () { - if (AppLifecycleService.instance.mediaExtensionAction.action == - IntentAction.main) { - HapticFeedback.lightImpact(); - _selectFile(file); - } - }, + onLongPress: widget.selectedFiles != null + ? () { + if (AppLifecycleService.instance.mediaExtensionAction.action == + IntentAction.main) { + HapticFeedback.lightImpact(); + _selectFile(file); + } + } + : null, child: ClipRRect( borderRadius: BorderRadius.circular(1), child: Stack( @@ -486,7 +491,7 @@ class _LazyLoadingGridViewState extends State { } void _selectFile(File file) { - widget.selectedFiles.toggleSelection(file); + widget.selectedFiles!.toggleSelection(file); } void _routeToDetailPage(File file, BuildContext context) { @@ -502,14 +507,14 @@ class _LazyLoadingGridViewState extends State { } void _selectedFilesListener() { - if (widget.selectedFiles.files.containsAll(widget.filesInDay.toSet())) { + 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)) { + if (widget.selectedFiles!.isPartOfLastSelected(file)) { shouldRefresh = true; } } @@ -519,12 +524,12 @@ class _LazyLoadingGridViewState extends State { } void _toggleSelectAllFromDayListener() { - if (widget.selectedFiles.files.containsAll(widget.filesInDay.toSet())) { + if (widget.selectedFiles!.files.containsAll(widget.filesInDay.toSet())) { setState(() { - widget.selectedFiles.unSelectAll(widget.filesInDay.toSet()); + widget.selectedFiles!.unSelectAll(widget.filesInDay.toSet()); }); } else { - widget.selectedFiles.selectAll(widget.filesInDay.toSet()); + widget.selectedFiles!.selectAll(widget.filesInDay.toSet()); } } } diff --git a/lib/ui/viewer/file/fading_bottom_bar.dart b/lib/ui/viewer/file/fading_bottom_bar.dart index cb89bb98e..1b3023e85 100644 --- a/lib/ui/viewer/file/fading_bottom_bar.dart +++ b/lib/ui/viewer/file/fading_bottom_bar.dart @@ -75,7 +75,7 @@ class FadingBottomBarState extends State { color: Colors.white, ), onPressed: () async { - await _displayInfo(widget.file); + await _displayDetails(widget.file); safeRefresh(); //to instantly show the new caption if keypad is closed after pressing 'done' - here the caption will be updated before the bottom sheet is closed await Future.delayed( const Duration(milliseconds: 500), @@ -267,7 +267,7 @@ class FadingBottomBarState extends State { ); } - Future _displayInfo(File file) async { - await showInfoSheet(context, file); + Future _displayDetails(File file) async { + await showDetailsSheet(context, file); } } diff --git a/lib/ui/viewer/file/file_caption_widget.dart b/lib/ui/viewer/file/file_caption_widget.dart index 6f6fbd093..7841a92c1 100644 --- a/lib/ui/viewer/file/file_caption_widget.dart +++ b/lib/ui/viewer/file/file_caption_widget.dart @@ -66,7 +66,7 @@ class _FileCaptionWidgetState extends State { final _focusNode = FocusNode(); String? editedCaption; String hintText = fileCaptionDefaultHint; - Widget? keyboardTopButtoms; + Widget? keyboardTopButtons; @override void initState() { @@ -172,12 +172,12 @@ class _FileCaptionWidgetState extends State { editedCaption = caption; } final bool hasFocus = _focusNode.hasFocus; - keyboardTopButtoms ??= KeyboardTopButton( + keyboardTopButtons ??= KeyboardTopButton( onDoneTap: onDoneTap, onCancelTap: onCancelTap, ); if (hasFocus) { - KeyboardOverlay.showOverlay(context, keyboardTopButtoms!); + KeyboardOverlay.showOverlay(context, keyboardTopButtons!); } else { KeyboardOverlay.removeOverlay(); } diff --git a/lib/ui/viewer/file_details/location_tags_widget.dart b/lib/ui/viewer/file_details/location_tags_widget.dart index 23c504912..b30fd2c46 100644 --- a/lib/ui/viewer/file_details/location_tags_widget.dart +++ b/lib/ui/viewer/file_details/location_tags_widget.dart @@ -5,6 +5,7 @@ import "package:photos/services/location_service.dart"; import "package:photos/ui/components/buttons/chip_button_widget.dart"; import "package:photos/ui/components/buttons/inline_button_widget.dart"; import "package:photos/ui/components/info_item_widget.dart"; +import 'package:photos/ui/viewer/location/add_location_sheet.dart'; class LocationTagsWidget extends StatefulWidget { final List coordinates; @@ -18,7 +19,7 @@ class _LocationTagsWidgetState extends State { String title = "Add location"; IconData leadingIcon = Icons.add_location_alt_outlined; bool hasChipButtons = false; - late final Future> locationTagChips; + late Future> locationTagChips; @override void initState() { locationTagChips = _getLocationTags(); @@ -46,7 +47,18 @@ class _LocationTagsWidgetState extends State { LocationService.instance.enclosingLocationTags(widget.coordinates); if (locationTags.isEmpty) { return [ - InlineButtonWidget("Group nearby photos", () {}), + InlineButtonWidget( + "Group nearby photos", + () => showAddLocationSheet( + context, + widget.coordinates, + //This callback is for reloading the locationTagsWidget after adding a new location tag + //so that it updates in file details. + () { + locationTagChips = _getLocationTags(); + }, + ), + ), ]; } setState(() { @@ -54,6 +66,22 @@ class _LocationTagsWidgetState extends State { leadingIcon = Icons.pin_drop_outlined; hasChipButtons = true; }); - return locationTags.map((e) => ChipButtonWidget(e)).toList(); + final result = locationTags.map((e) => ChipButtonWidget(e)).toList(); + result.add( + ChipButtonWidget( + null, + leadingIcon: Icons.add_outlined, + onTap: () => showAddLocationSheet( + context, + widget.coordinates, + //This callback is for reloading the locationTagsWidget after adding a new location tag + //so that it updates in file details. + () { + locationTagChips = _getLocationTags(); + }, + ), + ), + ); + return result; } } diff --git a/lib/ui/viewer/gallery/gallery.dart b/lib/ui/viewer/gallery/gallery.dart index 9fe53a590..1314267ff 100644 --- a/lib/ui/viewer/gallery/gallery.dart +++ b/lib/ui/viewer/gallery/gallery.dart @@ -33,18 +33,21 @@ class Gallery extends StatefulWidget { final Stream? reloadEvent; final List>? forceReloadEvents; final Set removalEventTypes; - final SelectedFiles selectedFiles; + final SelectedFiles? selectedFiles; final String tagPrefix; final Widget? header; final Widget? footer; final Widget emptyState; final String? albumName; final double scrollBottomSafeArea; + final bool shouldCollateFilesByDay; + final Widget loadingWidget; + final bool disableScroll; const Gallery({ required this.asyncLoader, - required this.selectedFiles, required this.tagPrefix, + this.selectedFiles, this.initialFiles, this.reloadEvent, this.forceReloadEvents, @@ -54,6 +57,9 @@ class Gallery extends StatefulWidget { this.emptyState = const EmptyState(), this.scrollBottomSafeArea = 120.0, this.albumName = '', + this.shouldCollateFilesByDay = true, + this.loadingWidget = const EnteLoadingWidget(), + this.disableScroll = false, Key? key, }) : super(key: key); @@ -168,7 +174,8 @@ class _GalleryState extends State { // Collates files and returns `true` if it resulted in a gallery reload bool _onFilesLoaded(List files) { - final updatedCollatedFiles = _collateFiles(files); + final updatedCollatedFiles = + widget.shouldCollateFilesByDay ? _collateFiles(files) : [files]; if (_collatedFiles.length != updatedCollatedFiles.length || _collatedFiles.isEmpty) { if (mounted) { @@ -198,7 +205,7 @@ class _GalleryState extends State { Widget build(BuildContext context) { _logger.finest("Building Gallery ${widget.tagPrefix}"); if (!_hasLoadedFiles) { - return const EnteLoadingWidget(); + return widget.loadingWidget; } _photoGridSize = LocalSettings.instance.getPhotoGridSize(); return _getListView(); @@ -211,6 +218,7 @@ class _GalleryState extends State { startIndex: 0, totalCount: _collatedFiles.length, isDraggableScrollbarEnabled: _collatedFiles.length > 10, + disableScroll: widget.disableScroll, waitBuilder: (_) { return const EnteLoadingWidget(); }, @@ -246,6 +254,7 @@ class _GalleryState extends State { .on() .where((event) => event.tag == widget.tagPrefix) .map((event) => event.index), + widget.shouldCollateFilesByDay, logTag: _logTag, photoGirdSize: _photoGridSize, ); diff --git a/lib/ui/viewer/location/add_location_gallery_widget.dart b/lib/ui/viewer/location/add_location_gallery_widget.dart new file mode 100644 index 000000000..11016e94c --- /dev/null +++ b/lib/ui/viewer/location/add_location_gallery_widget.dart @@ -0,0 +1,168 @@ +import "dart:developer" as dev; +import "dart:math"; + +import "package:flutter/material.dart"; +import "package:photos/core/configuration.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/db/files_db.dart"; +import "package:photos/models/file.dart"; +import "package:photos/models/file_load_result.dart"; +import "package:photos/services/collections_service.dart"; +import "package:photos/services/ignored_files_service.dart"; +import "package:photos/services/location_service.dart"; +import "package:photos/states/add_location_state.dart"; +import "package:photos/ui/viewer/gallery/gallery.dart"; +import "package:photos/utils/local_settings.dart"; + +class AddLocationGalleryWidget extends StatefulWidget { + final ValueNotifier memoriesCountNotifier; + const AddLocationGalleryWidget(this.memoriesCountNotifier, {super.key}); + + @override + State createState() => + _AddLocationGalleryWidgetState(); +} + +class _AddLocationGalleryWidgetState extends State { + late final Future fileLoadResult; + late Future removeIgnoredFiles; + double heightOfGallery = 0; + + @override + void initState() { + fileLoadResult = _fetchAllFilesWithLocationData(); + removeIgnoredFiles = _removeIgnoredFiles(fileLoadResult); + super.initState(); + } + + @override + Widget build(BuildContext context) { + const galleryFilesLimit = 1000; + final selectedRadius = _selectedRadius(); + Future filterFiles() async { + final FileLoadResult result = await fileLoadResult; + //wait for ignored files to be removed after init + await removeIgnoredFiles; + final stopWatch = Stopwatch()..start(); + final copyOfFiles = List.from(result.files); + copyOfFiles.removeWhere((f) { + assert( + f.location != null && + f.location!.latitude != null && + f.location!.longitude != null, + ); + return !LocationService.instance.isFileInsideLocationTag( + InheritedLocationTagData.of(context).coordinates, + [f.location!.latitude!, f.location!.longitude!], + selectedRadius, + ); + }); + dev.log( + "Time taken to get all files in a location tag: ${stopWatch.elapsedMilliseconds} ms", + ); + stopWatch.stop(); + widget.memoriesCountNotifier.value = copyOfFiles.length; + final limitedResults = copyOfFiles.take(galleryFilesLimit).toList(); + + return Future.value( + FileLoadResult( + limitedResults, + result.hasMore, + ), + ); + } + + return FutureBuilder( + key: ValueKey(selectedRadius), + builder: (context, snapshot) { + if (snapshot.hasData) { + return SizedBox( + height: _galleryHeight( + min( + (widget.memoriesCountNotifier.value ?? 0), + galleryFilesLimit, + ), + ), + child: Gallery( + key: ValueKey(selectedRadius), + loadingWidget: const SizedBox.shrink(), + disableScroll: true, + asyncLoader: ( + creationStartTime, + creationEndTime, { + limit, + asc, + }) async { + return snapshot.data as FileLoadResult; + }, + tagPrefix: "Add location", + shouldCollateFilesByDay: false, + ), + ); + } else { + return const SizedBox.shrink(); + } + }, + future: filterFiles(), + ); + } + + int _selectedRadius() { + return radiusValues[ + InheritedLocationTagData.of(context).selectedRadiusIndex]; + } + + Future _removeIgnoredFiles(Future result) async { + final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs; + (await result).files.removeWhere( + (f) => + f.uploadedFileID == null && + IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, f), + ); + } + + double _galleryHeight(int fileCount) { + final photoGridSize = LocalSettings.instance.getPhotoGridSize(); + final totalWhiteSpaceBetweenPhotos = + galleryGridSpacing * (photoGridSize - 1); + + final thumbnailHeight = + ((MediaQuery.of(context).size.width - totalWhiteSpaceBetweenPhotos) / + photoGridSize); + + final numberOfRows = (fileCount / photoGridSize).ceil(); + + final galleryHeight = (thumbnailHeight * numberOfRows) + + (galleryGridSpacing * (numberOfRows - 1)); + return galleryHeight + 120; + } + + Future _fetchAllFilesWithLocationData() { + final ownerID = Configuration.instance.getUserID(); + final hasSelectedAllForBackup = + Configuration.instance.hasSelectedAllFoldersForBackup(); + final collectionsToHide = + CollectionsService.instance.collectionsHiddenFromTimeline(); + if (hasSelectedAllForBackup) { + return FilesDB.instance.getAllLocalAndUploadedFiles( + galleryLoadStartTime, + galleryLoadEndTime, + ownerID!, + limit: null, + asc: true, + ignoredCollectionIDs: collectionsToHide, + onlyFilesWithLocation: true, + ); + } else { + return FilesDB.instance.getAllPendingOrUploadedFiles( + galleryLoadStartTime, + galleryLoadEndTime, + ownerID!, + limit: null, + asc: true, + ignoredCollectionIDs: collectionsToHide, + onlyFilesWithLocation: true, + ); + } + } +} diff --git a/lib/ui/viewer/location/add_location_sheet.dart b/lib/ui/viewer/location/add_location_sheet.dart new file mode 100644 index 000000000..fcd2b8df7 --- /dev/null +++ b/lib/ui/viewer/location/add_location_sheet.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/services/location_service.dart"; +import "package:photos/states/add_location_state.dart"; +import "package:photos/theme/colors.dart"; +import "package:photos/theme/ente_theme.dart"; +import "package:photos/ui/common/loading_widget.dart"; +import "package:photos/ui/components/bottom_of_title_bar_widget.dart"; +import "package:photos/ui/components/divider_widget.dart"; +import "package:photos/ui/components/keyboard/keybiard_oveylay.dart"; +import "package:photos/ui/components/keyboard/keyboard_top_button.dart"; +import "package:photos/ui/components/text_input_widget.dart"; +import "package:photos/ui/components/title_bar_title_widget.dart"; +import "package:photos/ui/viewer/location/add_location_gallery_widget.dart"; +import "package:photos/ui/viewer/location/radius_picker_widget.dart"; + +showAddLocationSheet( + BuildContext context, + List coordinates, + VoidCallback onLocationAdded, +) { + showBarModalBottomSheet( + context: context, + builder: (context) { + return LocationTagDataStateProvider( + coordinates, + AddLocationSheet(onLocationAdded), + ); + }, + shape: const RoundedRectangleBorder( + side: BorderSide(width: 0), + borderRadius: BorderRadius.vertical( + top: Radius.circular(5), + ), + ), + topControl: const SizedBox.shrink(), + backgroundColor: getEnteColorScheme(context).backgroundElevated, + barrierColor: backdropFaintDark, + enableDrag: false, + ); +} + +class AddLocationSheet extends StatefulWidget { + final VoidCallback onLocationAdded; + const AddLocationSheet(this.onLocationAdded, {super.key}); + + @override + State createState() => _AddLocationSheetState(); +} + +class _AddLocationSheetState extends State { + //The value of these notifiers has no significance. + //When memoriesCountNotifier is null, we show the loading widget in the + //memories count section which also means the gallery is loading. + final ValueNotifier _memoriesCountNotifier = ValueNotifier(null); + final ValueNotifier _submitNotifer = ValueNotifier(false); + final ValueNotifier _cancelNotifier = ValueNotifier(false); + final _focusNode = FocusNode(); + Widget? _keyboardTopButtons; + + @override + void initState() { + _focusNode.addListener(_focusNodeListener); + super.initState(); + } + + @override + void dispose() { + _focusNode.removeListener(_focusNodeListener); + _submitNotifer.dispose(); + _cancelNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textTheme = getEnteTextTheme(context); + final colorScheme = getEnteColorScheme(context); + return Padding( + padding: const EdgeInsets.fromLTRB(0, 32, 0, 8), + child: Column( + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 16), + child: BottomOfTitleBarWidget( + title: TitleBarTitleWidget(title: "Add location"), + ), + ), + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + TextInputWidget( + hintText: "Location name", + borderRadius: 2, + focusNode: _focusNode, + submitNotifier: _submitNotifer, + cancelNotifier: _cancelNotifier, + popNavAfterSubmission: true, + onSubmit: (locationName) async { + await _addLocationTag(locationName); + }, + shouldUnfocusOnClearOrSubmit: true, + alwaysShowSuccessState: true, + ), + const SizedBox(height: 24), + RadiusPickerWidget(_memoriesCountNotifier), + const SizedBox(height: 24), + Text( + "A location tag groups all photos that were taken within some radius of a photo", + style: textTheme.smallMuted, + ), + ], + ), + ), + const DividerWidget( + dividerType: DividerType.solid, + padding: EdgeInsets.only(top: 24, bottom: 20), + ), + SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ValueListenableBuilder( + valueListenable: _memoriesCountNotifier, + builder: (context, value, _) { + Widget widget; + if (value == null) { + widget = RepaintBoundary( + child: EnteLoadingWidget( + size: 14, + color: colorScheme.strokeMuted, + alignment: Alignment.centerLeft, + padding: 3, + ), + ); + } else { + widget = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value == 1 ? "1 memory" : "$value memories", + style: textTheme.body, + ), + if (value as int > 1000) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + "Up to 1000 memories shown in gallery", + style: textTheme.miniMuted, + ), + ), + ], + ); + } + return Align( + alignment: Alignment.centerLeft, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + switchInCurve: Curves.easeInOutExpo, + switchOutCurve: Curves.easeInOutExpo, + child: widget, + ), + ); + }, + ), + ), + ), + const SizedBox(height: 24), + AddLocationGalleryWidget(_memoriesCountNotifier), + ], + ), + ), + ), + ], + ), + ); + } + + Future _addLocationTag(String locationName) async { + final locationData = InheritedLocationTagData.of(context); + final coordinates = locationData.coordinates; + final radius = radiusValues[locationData.selectedRadiusIndex]; + await LocationService.instance.addLocation( + locationName, + coordinates.first, + coordinates.last, + radius, + ); + widget.onLocationAdded.call(); + } + + void _focusNodeListener() { + final bool hasFocus = _focusNode.hasFocus; + _keyboardTopButtons ??= KeyboardTopButton( + onDoneTap: () { + _submitNotifer.value = !_submitNotifer.value; + }, + onCancelTap: () { + _cancelNotifier.value = !_cancelNotifier.value; + }, + ); + if (hasFocus) { + KeyboardOverlay.showOverlay(context, _keyboardTopButtons!); + } else { + KeyboardOverlay.removeOverlay(); + } + } +} diff --git a/lib/ui/viewer/location/radius_picker_widget.dart b/lib/ui/viewer/location/radius_picker_widget.dart new file mode 100644 index 000000000..ea141d4eb --- /dev/null +++ b/lib/ui/viewer/location/radius_picker_widget.dart @@ -0,0 +1,134 @@ +import "package:flutter/material.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/states/add_location_state.dart"; +import "package:photos/theme/colors.dart"; +import "package:photos/theme/ente_theme.dart"; + +class CustomTrackShape extends RoundedRectSliderTrackShape { + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + const trackHeight = 2.0; + final trackWidth = parentBox.size.width; + return Rect.fromLTWH(0, 0, trackWidth, trackHeight); + } +} + +class RadiusPickerWidget extends StatefulWidget { + final ValueNotifier memoriesCountNotifier; + const RadiusPickerWidget(this.memoriesCountNotifier, {super.key}); + + @override + State createState() => _RadiusPickerWidgetState(); +} + +class _RadiusPickerWidgetState extends State { + //Will maintain the state of the slider using this varialbe. Can't use + //InheritedLocationData.selectedRadiusIndex as the state in the inheritedWidget + //only changes after debounce time and the slider will not reflect the change immediately. + int selectedRadiusIndex = defaultRadiusValueIndex; + @override + Widget build(BuildContext context) { + final radiusValue = radiusValues[selectedRadiusIndex]; + final textTheme = getEnteTextTheme(context); + final colorScheme = getEnteColorScheme(context); + return Row( + children: [ + Container( + height: 48, + width: 48, + decoration: BoxDecoration( + color: colorScheme.fillFaint, + borderRadius: const BorderRadius.all(Radius.circular(2)), + ), + padding: const EdgeInsets.all(4), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + flex: 6, + child: Text( + radiusValue.toString(), + style: radiusValue != 1200 + ? textTheme.largeBold + : textTheme.bodyBold, + textAlign: TextAlign.center, + ), + ), + Expanded( + flex: 5, + child: Text( + "km", + style: textTheme.miniMuted, + ), + ), + ], + ), + ), + const SizedBox(width: 4), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text("Radius", style: textTheme.body), + const SizedBox(height: 10), + SizedBox( + height: 12, + child: SliderTheme( + data: SliderThemeData( + overlayColor: Colors.transparent, + thumbColor: strokeSolidMutedLight, + activeTrackColor: strokeSolidMutedLight, + inactiveTrackColor: colorScheme.strokeFaint, + activeTickMarkColor: colorScheme.strokeMuted, + inactiveTickMarkColor: strokeSolidMutedLight, + trackShape: CustomTrackShape(), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6, + pressedElevation: 0, + elevation: 0, + ), + tickMarkShape: const RoundSliderTickMarkShape( + tickMarkRadius: 1, + ), + ), + child: RepaintBoundary( + child: Slider( + value: selectedRadiusIndex.toDouble(), + onChanged: (value) { + setState(() { + selectedRadiusIndex = value.toInt(); + }); + + InheritedLocationTagData.of( + context, + ).updateSelectedIndex( + value.toInt(), + ); + widget.memoriesCountNotifier.value = null; + }, + min: 0, + max: radiusValues.length - 1, + divisions: radiusValues.length - 1, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +}