diff --git a/lib/models/search/index_of_indexed_stack.dart b/lib/models/search/index_of_indexed_stack.dart new file mode 100644 index 000000000..6e09b72b7 --- /dev/null +++ b/lib/models/search/index_of_indexed_stack.dart @@ -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; +} diff --git a/lib/models/typedefs.dart b/lib/models/typedefs.dart index b5624c1bf..d358180da 100644 --- a/lib/models/typedefs.dart +++ b/lib/models/typedefs.dart @@ -11,7 +11,9 @@ typedef VoidCallbackParamDouble = Function(double); typedef VoidCallbackParamBool = void Function(bool); typedef VoidCallbackParamListDouble = void Function(List); typedef VoidCallbackParamLocation = void Function(Location); -typedef VoidCallbackParamSearchResults = void Function(List); +typedef VoidCallbackParamSearchResutlsStream = void Function( + Stream>, +); typedef FutureVoidCallback = Future Function(); typedef FutureOrVoidCallback = FutureOr Function(); diff --git a/lib/states/search_results_state.dart b/lib/states/search_results_state.dart deleted file mode 100644 index 042f6f2a8..000000000 --- a/lib/states/search_results_state.dart +++ /dev/null @@ -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 createState() => _SearchResultsProviderState(); -} - -class _SearchResultsProviderState extends State { - var searchResults = []; - @override - Widget build(BuildContext context) { - return InheritedSearchResults( - searchResults, - updateSearchResults, - child: widget.child, - ); - } - - void updateSearchResults(List newResult) { - setState(() { - searchResults = newResult; - }); - } -} - -class InheritedSearchResults extends InheritedWidget { - final List results; - final VoidCallbackParamSearchResults updateResults; - const InheritedSearchResults( - this.results, - this.updateResults, { - required super.child, - super.key, - }); - - static InheritedSearchResults of(BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType()!; - } - - @override - bool updateShouldNotify(covariant InheritedSearchResults oldWidget) { - return results != oldWidget.results; - } -} diff --git a/lib/ui/search_tab.dart b/lib/ui/search_tab.dart index 719dc6e8f..6bdb6ad7e 100644 --- a/lib/ui/search_tab.dart +++ b/lib/ui/search_tab.dart @@ -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 { - var _searchResults = []; - 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(), ], ), ); diff --git a/lib/ui/tabs/home_widget.dart b/lib/ui/tabs/home_widget.dart index e2ccdee87..9621578bd 100644 --- a/lib/ui/tabs/home_widget.dart +++ b/lib/ui/tabs/home_widget.dart @@ -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 { !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, + ), + ], + ), + ); + }, ), - ], - ), + ), + ], ); } diff --git a/lib/ui/viewer/search/search_suffix_icon_widget.dart b/lib/ui/viewer/search/search_suffix_icon_widget.dart index f5d36f499..744684422 100644 --- a/lib/ui/viewer/search/search_suffix_icon_widget.dart +++ b/lib/ui/viewer/search/search_suffix_icon_widget.dart @@ -11,8 +11,7 @@ class SearchSuffixIcon extends StatefulWidget { State createState() => _SearchSuffixIconState(); } -class _SearchSuffixIconState extends State - with TickerProviderStateMixin { +class _SearchSuffixIconState extends State { @override Widget build(BuildContext context) { final colorScheme = getEnteColorScheme(context); diff --git a/lib/ui/viewer/search/search_suggestions.dart b/lib/ui/viewer/search/search_suggestions.dart index 22f6f76bd..8742cdad3 100644 --- a/lib/ui/viewer/search/search_suggestions.dart +++ b/lib/ui/viewer/search/search_suggestions.dart @@ -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 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 createState() => + _SearchSuggestionsWidgetState(); +} + +class _SearchSuggestionsWidgetState extends State { + Stream>? resultsStream; + final queueOfSearchResults = >[]; + var searchResultWidgets = []; + StreamSubscription>? 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 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(); + } + } +} diff --git a/lib/ui/viewer/search/search_widget.dart b/lib/ui/viewer/search/search_widget.dart index 133f98e7e..de28c78dc 100644 --- a/lib/ui/viewer/search/search_widget.dart +++ b/lib/ui/viewer/search/search_widget.dart @@ -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 { + static final ValueNotifier>?> + 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 { 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 { } Future textControllerListener() async { - //query in local varialbe - final value = textController.text; - isSearchQueryEmpty = value.isEmpty; - //latest query in global variable - query = textController.text; - - final List 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 { /*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 { completer.complete(allResults); } + Stream> _getSearchResultsStream( + BuildContext context, + String query, + ) { + int resultCount = 0; + final maxResultCount = _isYearValid(query) ? 11 : 10; + final streamController = StreamController>(); + + 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;