|
@@ -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';
|
|
|
-
|
|
|
-class SearchSuggestionsWidget extends StatelessWidget {
|
|
|
- final List<SearchResult> results;
|
|
|
+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";
|
|
|
|
|
|
- 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();
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|