Searching improvements (#1625)

This commit is contained in:
Vishnu Mohandas 2023-12-26 22:18:52 +05:30 committed by GitHub
commit c25212d9d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 470 additions and 232 deletions

View file

@ -0,0 +1,47 @@
import "package:flutter/material.dart";
enum SearchState {
empty,
searching,
notEmpty,
}
class IndexOfStackNotifier with ChangeNotifier {
int _prevIndex = 0;
int _index = 0;
bool _isSearchQueryEmpty = true;
SearchState _searchState = SearchState.empty;
static IndexOfStackNotifier? _instance;
IndexOfStackNotifier._();
factory IndexOfStackNotifier() => _instance ??= IndexOfStackNotifier._();
set isSearchQueryEmpty(bool value) {
_isSearchQueryEmpty = value;
setIndex();
}
set searchState(SearchState value) {
_searchState = value;
setIndex();
}
setIndex() {
_prevIndex = _index;
if (_isSearchQueryEmpty) {
_index = 0;
} else {
if (_searchState == SearchState.empty) {
_index = 2;
} else {
_index = 1;
}
}
_prevIndex != _index ? notifyListeners() : null;
}
get index => _index;
}

View file

@ -11,7 +11,9 @@ typedef VoidCallbackParamDouble = Function(double);
typedef VoidCallbackParamBool = void Function(bool);
typedef VoidCallbackParamListDouble = void Function(List<double>);
typedef VoidCallbackParamLocation = void Function(Location);
typedef VoidCallbackParamSearchResults = void Function(List<SearchResult>);
typedef VoidCallbackParamSearchResutlsStream = void Function(
Stream<List<SearchResult>>,
);
typedef FutureVoidCallback = Future<void> Function();
typedef FutureOrVoidCallback = FutureOr<void> Function();

View file

@ -1,53 +0,0 @@
import "package:flutter/cupertino.dart";
import "package:photos/models/search/search_result.dart";
import "package:photos/models/typedefs.dart";
class SearchResultsProvider extends StatefulWidget {
final Widget child;
const SearchResultsProvider({
required this.child,
super.key,
});
@override
State<SearchResultsProvider> createState() => _SearchResultsProviderState();
}
class _SearchResultsProviderState extends State<SearchResultsProvider> {
var searchResults = <SearchResult>[];
@override
Widget build(BuildContext context) {
return InheritedSearchResults(
searchResults,
updateSearchResults,
child: widget.child,
);
}
void updateSearchResults(List<SearchResult> newResult) {
setState(() {
searchResults = newResult;
});
}
}
class InheritedSearchResults extends InheritedWidget {
final List<SearchResult> results;
final VoidCallbackParamSearchResults updateResults;
const InheritedSearchResults(
this.results,
this.updateResults, {
required super.child,
super.key,
});
static InheritedSearchResults of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<InheritedSearchResults>()!;
}
@override
bool updateShouldNotify(covariant InheritedSearchResults oldWidget) {
return results != oldWidget.results;
}
}

View file

@ -2,15 +2,13 @@ import "package:fade_indexed_stack/fade_indexed_stack.dart";
import "package:flutter/material.dart";
import "package:flutter_animate/flutter_animate.dart";
import "package:photos/core/constants.dart";
import "package:photos/models/search/search_result.dart";
import "package:photos/models/search/index_of_indexed_stack.dart";
import "package:photos/models/search/search_types.dart";
import "package:photos/states/all_sections_examples_state.dart";
import "package:photos/states/search_results_state.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/viewer/search/result/no_result_widget.dart";
import "package:photos/ui/viewer/search/search_section.dart";
import "package:photos/ui/viewer/search/search_suggestions.dart";
import 'package:photos/ui/viewer/search/search_widget.dart';
import "package:photos/ui/viewer/search/tab_empty_state.dart";
class SearchTab extends StatefulWidget {
@ -21,34 +19,40 @@ class SearchTab extends StatefulWidget {
}
class _SearchTabState extends State<SearchTab> {
var _searchResults = <SearchResult>[];
int index = 0;
late int index;
final indexOfStackNotifier = IndexOfStackNotifier();
@override
void didChangeDependencies() {
super.didChangeDependencies();
_searchResults = InheritedSearchResults.of(context).results;
if (_searchResults.isEmpty) {
if (isSearchQueryEmpty) {
index = 0;
} else {
index = 2;
}
} else {
index = 1;
}
void initState() {
super.initState();
index = indexOfStackNotifier.index;
indexOfStackNotifier.addListener(indexNotifierListener);
}
void indexNotifierListener() {
setState(() {
index = indexOfStackNotifier.index;
});
}
@override
void dispose() {
indexOfStackNotifier.removeListener(indexNotifierListener);
indexOfStackNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AllSectionsExamplesProvider(
child: FadeIndexedStack(
lazy: false,
duration: const Duration(milliseconds: 150),
index: index,
children: [
const AllSearchSections(),
SearchSuggestionsWidget(_searchResults),
const NoResultWidget(),
children: const [
AllSearchSections(),
SearchSuggestionsWidget(),
NoResultWidget(),
],
),
);

View file

@ -33,7 +33,6 @@ import 'package:photos/services/local_sync_service.dart';
import "package:photos/services/notification_service.dart";
import 'package:photos/services/update_service.dart';
import 'package:photos/services/user_service.dart';
import "package:photos/states/search_results_state.dart";
import 'package:photos/states/user_details_state.dart';
import 'package:photos/theme/colors.dart';
import "package:photos/theme/effects.dart";
@ -393,90 +392,88 @@ class _HomeWidgetState extends State<HomeWidget> {
!LocalSyncService.instance.hasGrantedLimitedPermissions() &&
CollectionsService.instance.getActiveCollections().isEmpty;
return SearchResultsProvider(
child: Stack(
children: [
Builder(
builder: (context) {
return ExtentsPageView(
onPageChanged: (page) {
Bus.instance.fire(
TabChangedEvent(
page,
TabChangedEventSource.pageView,
),
);
},
controller: _pageController,
openDrawer: Scaffold.of(context).openDrawer,
physics: const BouncingScrollPhysics(),
children: [
_showShowBackupHook
? const StartBackupHookWidget(headerWidget: _headerWidget)
: HomeGalleryWidget(
header: _headerWidget,
footer: const SizedBox(
height: 160,
),
selectedFiles: _selectedFiles,
),
_userCollectionsTab,
_sharedCollectionTab,
_searchTab,
],
);
},
),
Align(
alignment: Alignment.bottomCenter,
child: ValueListenableBuilder(
valueListenable: isOnSearchTabNotifier,
builder: (context, value, child) {
return Container(
decoration: value
? BoxDecoration(
color: getEnteColorScheme(context).backgroundElevated,
boxShadow: shadowFloatFaintLight,
)
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
value
? const SearchWidget()
.animate()
.fadeIn(
duration: const Duration(milliseconds: 225),
curve: Curves.easeInOutSine,
)
.scale(
begin: const Offset(0.8, 0.8),
end: const Offset(1, 1),
duration: const Duration(
milliseconds: 225,
),
curve: Curves.easeInOutSine,
)
.slide(
begin: const Offset(0, 0.4),
curve: Curves.easeInOutSine,
duration: const Duration(
milliseconds: 225,
),
)
: const SizedBox.shrink(),
HomeBottomNavigationBar(
_selectedFiles,
selectedTabIndex: _selectedTabIndex,
),
],
return Stack(
children: [
Builder(
builder: (context) {
return ExtentsPageView(
onPageChanged: (page) {
Bus.instance.fire(
TabChangedEvent(
page,
TabChangedEventSource.pageView,
),
);
},
),
controller: _pageController,
openDrawer: Scaffold.of(context).openDrawer,
physics: const BouncingScrollPhysics(),
children: [
_showShowBackupHook
? const StartBackupHookWidget(headerWidget: _headerWidget)
: HomeGalleryWidget(
header: _headerWidget,
footer: const SizedBox(
height: 160,
),
selectedFiles: _selectedFiles,
),
_userCollectionsTab,
_sharedCollectionTab,
_searchTab,
],
);
},
),
Align(
alignment: Alignment.bottomCenter,
child: ValueListenableBuilder(
valueListenable: isOnSearchTabNotifier,
builder: (context, value, child) {
return Container(
decoration: value
? BoxDecoration(
color: getEnteColorScheme(context).backgroundElevated,
boxShadow: shadowFloatFaintLight,
)
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
value
? const SearchWidget()
.animate()
.fadeIn(
duration: const Duration(milliseconds: 225),
curve: Curves.easeInOutSine,
)
.scale(
begin: const Offset(0.8, 0.8),
end: const Offset(1, 1),
duration: const Duration(
milliseconds: 225,
),
curve: Curves.easeInOutSine,
)
.slide(
begin: const Offset(0, 0.4),
curve: Curves.easeInOutSine,
duration: const Duration(
milliseconds: 225,
),
)
: const SizedBox.shrink(),
HomeBottomNavigationBar(
_selectedFiles,
selectedTabIndex: _selectedTabIndex,
),
],
),
);
},
),
],
),
),
],
);
}

View file

@ -11,8 +11,7 @@ class SearchSuffixIcon extends StatefulWidget {
State<SearchSuffixIcon> createState() => _SearchSuffixIconState();
}
class _SearchSuffixIconState extends State<SearchSuffixIcon>
with TickerProviderStateMixin {
class _SearchSuffixIconState extends State<SearchSuffixIcon> {
@override
Widget build(BuildContext context) {
final colorScheme = getEnteColorScheme(context);

View file

@ -1,29 +1,126 @@
import "dart:async";
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import "package:flutter_animate/flutter_animate.dart";
import "package:logging/logging.dart";
import "package:photos/core/event_bus.dart";
import "package:photos/events/clear_and_unfocus_search_bar_event.dart";
import "package:photos/generated/l10n.dart";
import 'package:photos/models/search/album_search_result.dart';
import 'package:photos/models/search/generic_search_result.dart';
import "package:photos/models/search/album_search_result.dart";
import "package:photos/models/search/generic_search_result.dart";
import "package:photos/models/search/index_of_indexed_stack.dart";
import 'package:photos/models/search/search_result.dart';
import "package:photos/services/collections_service.dart";
import "package:photos/theme/ente_theme.dart";
import 'package:photos/ui/viewer/gallery/collection_page.dart';
import 'package:photos/ui/viewer/search/result/search_result_widget.dart';
import 'package:photos/utils/navigation_util.dart';
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/viewer/gallery/collection_page.dart";
import "package:photos/ui/viewer/search/result/search_result_widget.dart";
import "package:photos/ui/viewer/search/search_widget.dart";
import "package:photos/utils/navigation_util.dart";
class SearchSuggestionsWidget extends StatelessWidget {
final List<SearchResult> results;
const SearchSuggestionsWidget(
this.results, {
///Not using StreamBuilder in this widget for rebuilding on every new event as
///StreamBuilder is not lossless. It misses some events if the stream fires too
///fast. Instead, we usi a queue to store the events and then generate the
///widgets from the queue at regular intervals.
class SearchSuggestionsWidget extends StatefulWidget {
const SearchSuggestionsWidget({
Key? key,
}) : super(key: key);
@override
State<SearchSuggestionsWidget> createState() =>
_SearchSuggestionsWidgetState();
}
class _SearchSuggestionsWidgetState extends State<SearchSuggestionsWidget> {
Stream<List<SearchResult>>? resultsStream;
final queueOfSearchResults = <List<SearchResult>>[];
var searchResultWidgets = <Widget>[];
StreamSubscription<List<SearchResult>>? subscription;
Timer? timer;
///This is the interval at which the queue is checked for new events and
///the search result widgets are generated from the queue.
static const _surfaceNewResultsInterval = 50;
@override
void initState() {
super.initState();
SearchWidgetState.searchResultsStreamNotifier.addListener(() {
IndexOfStackNotifier().searchState = SearchState.searching;
final resultsStream = SearchWidgetState.searchResultsStreamNotifier.value;
searchResultWidgets.clear();
releaseResources();
subscription = resultsStream!.listen(
(searchResults) {
//Currently, we add searchResults even if the list is empty. So we are adding
//empty list to the queue, which will trigger rebuilds with no change in UI
//(see [generateResultWidgetsInIntervalsFromQueue]'s setState()).
//This is needed to clear the search results in this widget when the
//search bar is cleared, and the event fired by the stream will be an
//empty list. Can optimize rebuilds if there are performance issues in future.
if (searchResults.isNotEmpty) {
IndexOfStackNotifier().searchState = SearchState.notEmpty;
}
queueOfSearchResults.add(searchResults);
},
onDone: () {
Future.delayed(
const Duration(milliseconds: _surfaceNewResultsInterval + 20),
() {
if (searchResultWidgets.isEmpty) {
IndexOfStackNotifier().searchState = SearchState.empty;
}
});
SearchWidgetState.isLoading.value = false;
},
);
generateResultWidgetsInIntervalsFromQueue();
});
}
void releaseResources() {
subscription?.cancel();
timer?.cancel();
}
///This method generates searchResultsWidgets from the queueOfEvents by checking
///every [_surfaceNewResultsInterval] if the queue is empty or not. If the
///queue is not empty, it generates the widgets and clears the queue and
///updates the UI.
void generateResultWidgetsInIntervalsFromQueue() {
timer = Timer.periodic(
const Duration(milliseconds: _surfaceNewResultsInterval), (timer) {
if (queueOfSearchResults.isNotEmpty) {
for (List<SearchResult> event in queueOfSearchResults) {
for (SearchResult result in event) {
searchResultWidgets.add(
SearchResultsWidgetGenerator(result).animate().fadeIn(
duration: const Duration(milliseconds: 80),
curve: Curves.easeIn,
),
);
}
}
queueOfSearchResults.clear();
setState(() {});
}
});
}
@override
void dispose() {
releaseResources();
super.dispose();
}
@override
Widget build(BuildContext context) {
late final String title;
final resultsCount = results.length;
String title;
final resultsCount = searchResultWidgets.length;
title = S.of(context).searchResultCount(resultsCount);
return Scaffold(
appBar: AppBar(
@ -38,53 +135,38 @@ class SearchSuggestionsWidget extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: getEnteTextTheme(context).largeBold,
SizedBox(
height: 44,
child: SearchWidgetState.isLoading.value
? EnteLoadingWidget(
size: 14,
padding: 4,
color: getEnteColorScheme(context).strokeMuted,
alignment: Alignment.topLeft,
)
: Text(
title,
style: getEnteTextTheme(context).largeBold,
).animate().fadeIn(
duration: const Duration(milliseconds: 60),
curve: Curves.easeIn,
),
),
const SizedBox(height: 20),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ListView.separated(
itemBuilder: (context, index) {
final result = results[index];
if (result is AlbumSearchResult) {
final AlbumSearchResult albumSearchResult = result;
return SearchResultWidget(
result,
resultCount: CollectionsService.instance.getFileCount(
albumSearchResult.collectionWithThumbnail.collection,
),
onResultTap: () => routeToPage(
context,
CollectionPage(
albumSearchResult.collectionWithThumbnail,
tagPrefix: result.heroTag(),
),
),
);
} else if (result is GenericSearchResult) {
return SearchResultWidget(
result,
onResultTap: result.onResultTap != null
? () => result.onResultTap!(context)
: null,
);
} else {
Logger('SearchSuggestionsWidget')
.info("Invalid/Unsupported value");
return const SizedBox.shrink();
}
return searchResultWidgets[index];
},
padding: EdgeInsets.only(
bottom: (MediaQuery.sizeOf(context).height / 2) + 50,
),
separatorBuilder: (context, index) {
return const SizedBox(height: 12);
},
itemCount: results.length,
itemCount: searchResultWidgets.length,
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.only(
bottom: (MediaQuery.sizeOf(context).height / 2) + 50,
),
),
),
),
@ -94,3 +176,38 @@ class SearchSuggestionsWidget extends StatelessWidget {
);
}
}
class SearchResultsWidgetGenerator extends StatelessWidget {
final SearchResult result;
const SearchResultsWidgetGenerator(this.result, {super.key});
@override
Widget build(BuildContext context) {
if (result is AlbumSearchResult) {
final AlbumSearchResult albumSearchResult = result as AlbumSearchResult;
return SearchResultWidget(
result,
resultCount: CollectionsService.instance.getFileCount(
albumSearchResult.collectionWithThumbnail.collection,
),
onResultTap: () => routeToPage(
context,
CollectionPage(
albumSearchResult.collectionWithThumbnail,
tagPrefix: result.heroTag(),
),
),
);
} else if (result is GenericSearchResult) {
return SearchResultWidget(
result,
onResultTap: (result as GenericSearchResult).onResultTap != null
? () => (result as GenericSearchResult).onResultTap!(context)
: null,
);
} else {
Logger('SearchResultsWidgetGenerator').info("Invalid/Unsupported value");
return const SizedBox.shrink();
}
}
}

View file

@ -6,16 +6,14 @@ import "package:logging/logging.dart";
import "package:photos/core/event_bus.dart";
import "package:photos/events/clear_and_unfocus_search_bar_event.dart";
import "package:photos/events/tab_changed_event.dart";
import "package:photos/models/search/index_of_indexed_stack.dart";
import "package:photos/models/search/search_result.dart";
import "package:photos/services/search_service.dart";
import "package:photos/states/search_results_state.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/viewer/search/search_suffix_icon_widget.dart";
import "package:photos/utils/date_time_util.dart";
import "package:photos/utils/debouncer.dart";
bool isSearchQueryEmpty = true;
class SearchWidget extends StatefulWidget {
const SearchWidget({Key? key}) : super(key: key);
@ -24,7 +22,15 @@ class SearchWidget extends StatefulWidget {
}
class SearchWidgetState extends State<SearchWidget> {
static final ValueNotifier<Stream<List<SearchResult>>?>
searchResultsStreamNotifier = ValueNotifier(null);
///This stores the query that is being searched for. When going to other tabs
///when searching, this state gets disposed and when coming back to the
///search tab, this query is used to populate the search bar.
static String query = "";
//Debouncing + querying
static final isLoading = ValueNotifier(false);
final _searchService = SearchService.instance;
final _debouncer = Debouncer(const Duration(milliseconds: 200));
final Logger _logger = Logger((SearchWidgetState).toString());
@ -64,6 +70,9 @@ class SearchWidgetState extends State<SearchWidget> {
textController.addListener(textControllerListener);
});
//Populate the serach tab with the latest query when coming back
//to the serach tab.
textController.text = query;
_clearAndUnfocusSearchBar =
@ -95,25 +104,15 @@ class SearchWidgetState extends State<SearchWidget> {
}
Future<void> textControllerListener() async {
//query in local varialbe
final value = textController.text;
isSearchQueryEmpty = value.isEmpty;
//latest query in global variable
query = textController.text;
final List<SearchResult> allResults =
await getSearchResultsForQuery(context, value);
/*checking if query == value to make sure that the results are from the current query
and not from the previous query (race condition).*/
//checking if query == value to make sure that the latest query's result
//(allResults) is passed to updateResult. Due to race condition, the previous
//query's allResults could be passed to updateResult after the lastest query's
//allResults is passed.
if (mounted && query == value) {
final inheritedSearchResults = InheritedSearchResults.of(context);
inheritedSearchResults.updateResults(allResults);
}
isLoading.value = true;
_debouncer.run(() async {
if (mounted) {
query = textController.text;
IndexOfStackNotifier().isSearchQueryEmpty = query.isEmpty;
searchResultsStreamNotifier.value =
_getSearchResultsStream(context, query);
}
});
}
@override
@ -177,14 +176,14 @@ class SearchWidgetState extends State<SearchWidget> {
/*Using valueListenableBuilder inside a stateful widget because this widget is only rebuild when
setState is called when deboucncing is over and the spinner needs to be shown while debouncing */
suffixIcon: ValueListenableBuilder(
valueListenable: _debouncer.debounceActiveNotifier,
valueListenable: isLoading,
builder: (
BuildContext context,
bool isDebouncing,
bool isSearching,
Widget? child,
) {
return SearchSuffixIcon(
isDebouncing,
isSearching,
);
},
),
@ -274,6 +273,132 @@ class SearchWidgetState extends State<SearchWidget> {
completer.complete(allResults);
}
Stream<List<SearchResult>> _getSearchResultsStream(
BuildContext context,
String query,
) {
int resultCount = 0;
final maxResultCount = _isYearValid(query) ? 11 : 10;
final streamController = StreamController<List<SearchResult>>();
if (query.isEmpty) {
streamController.sink.add([]);
streamController.close();
return streamController.stream;
}
if (_isYearValid(query)) {
_searchService.getYearSearchResults(query).then((yearSearchResults) {
streamController.sink.add(yearSearchResults);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
});
}
_searchService.getHolidaySearchResults(context, query).then(
(holidayResults) {
streamController.sink.add(holidayResults);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
},
);
_searchService.getFileTypeResults(context, query).then(
(fileTypeSearchResults) {
streamController.sink.add(fileTypeSearchResults);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
},
);
_searchService.getCaptionAndNameResults(query).then(
(captionAndDisplayNameResult) {
streamController.sink.add(captionAndDisplayNameResult);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
},
);
_searchService.getFileExtensionResults(query).then(
(fileExtnResult) {
streamController.sink.add(fileExtnResult);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
},
);
_searchService.getLocationResults(query).then(
(locationResult) {
streamController.sink.add(locationResult);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
},
);
_searchService.getCollectionSearchResults(query).then(
(collectionResults) {
streamController.sink.add(collectionResults);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
},
);
_searchService.getMonthSearchResults(context, query).then(
(monthResults) {
streamController.sink.add(monthResults);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
},
);
_searchService.getDateResults(context, query).then(
(possibleEvents) {
streamController.sink.add(possibleEvents);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
},
);
_searchService.getMagicSearchResults(context, query).then(
(magicResults) {
streamController.sink.add(magicResults);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
},
);
_searchService.getContactSearchResults(query).then(
(contactResults) {
streamController.sink.add(contactResults);
resultCount++;
if (resultCount == maxResultCount) {
streamController.close();
}
},
);
return streamController.stream;
}
bool _isYearValid(String year) {
final yearAsInt = int.tryParse(year); //returns null if cannot be parsed
return yearAsInt != null && yearAsInt <= currentYear;