Searching improvements (#1625)
This commit is contained in:
commit
c25212d9d8
8 changed files with 470 additions and 232 deletions
47
lib/models/search/index_of_indexed_stack.dart
Normal file
47
lib/models/search/index_of_indexed_stack.dart
Normal 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;
|
||||
}
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue