diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index e1cb1517..adcc8bf0 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -41,7 +41,7 @@ class _SearchState extends State { Positioned.fill( child: Observer( builder: (context) { - if (_searchStore.textEditingController.text.isEmpty) { + if (_searchStore.searchText.isEmpty) { if (_searchStore.searchHistory.isEmpty) { // Keep controller attached so borders stay in sync return CustomScrollView( @@ -202,6 +202,7 @@ class _SearchState extends State { controller: _searchStore.textEditingController, focusNode: _searchStore.textFieldFocusNode, autocorrect: false, + onChanged: _searchStore.onSearchTextChanged, decoration: InputDecoration( prefixIcon: const Icon(Icons.search_rounded), hintText: 'Search channels or categories', @@ -218,6 +219,7 @@ class _SearchState extends State { _searchStore.textFieldFocusNode.unfocus(); } _searchStore.textEditingController.clear(); + _searchStore.onSearchTextChanged(''); // Ensure borders reset: scroll back to top and trigger listeners if (widget.scrollController.hasClients) { widget.scrollController.animateTo( diff --git a/lib/screens/home/search/search_results_categories.dart b/lib/screens/home/search/search_results_categories.dart index 45690520..2f7634fc 100644 --- a/lib/screens/home/search/search_results_categories.dart +++ b/lib/screens/home/search/search_results_categories.dart @@ -18,7 +18,14 @@ class SearchResultsCategories extends StatelessWidget { builder: (context) { final future = searchStore.categoryFuture; + // Show skeletons immediately while waiting for debounce. if (future == null) { + if (searchStore.isSearching) { + return SliverList.builder( + itemCount: 8, + itemBuilder: (context, index) => const CategorySkeletonLoader(), + ); + } return const SliverToBoxAdapter(child: SizedBox.shrink()); } diff --git a/lib/screens/home/search/search_results_channels.dart b/lib/screens/home/search/search_results_channels.dart index cbb2bc46..f7413995 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -72,7 +72,15 @@ class _SearchResultsChannelsState extends State { builder: (context) { final future = widget.searchStore.channelFuture; + // Show skeletons immediately while waiting for debounce. if (future == null) { + if (widget.searchStore.isSearching) { + return SliverList.builder( + itemCount: 8, + itemBuilder: (context, index) => + ChannelSkeletonLoader(index: index), + ); + } return const SliverToBoxAdapter(child: SizedBox.shrink()); } diff --git a/lib/screens/home/search/search_store.dart b/lib/screens/home/search/search_store.dart index 6dc8e67f..7afe3318 100644 --- a/lib/screens/home/search/search_store.dart +++ b/lib/screens/home/search/search_store.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/models/category.dart'; @@ -19,12 +21,17 @@ abstract class SearchStoreBase with Store { final textFieldFocusNode = FocusNode(); + Timer? _debounceTimer; + @readonly var _searchText = ''; @readonly var _searchHistory = ObservableList(); + @readonly + var _isSearching = false; + @readonly ObservableFuture>? _channelFuture; @@ -57,15 +64,48 @@ abstract class SearchStoreBase with Store { ); } + /// Debounced handler for search-as-you-type. + @action + void onSearchTextChanged(String query) { + _debounceTimer?.cancel(); + + if (query.isEmpty) { + _isSearching = false; + _channelFuture = null; + _categoryFuture = null; + return; + } + + // Show loading state immediately for responsive feedback. + _isSearching = true; + + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + runInAction(() => _performSearch(query)); + }); + } + /// Obtain the channels and categories that match the provided [query]. + /// Adds the query to search history. @action void handleQuery(String query) { if (query.isEmpty) return; + // Cancel any pending debounce since we're searching immediately. + _debounceTimer?.cancel(); + // Move the query to the most recent result (top of the stack). _searchHistory.remove(query); _searchHistory.insert(0, query); + _performSearch(query); + } + + /// Performs the actual search API calls. + @action + void _performSearch(String query) { + // Futures are now pending, so isSearching can be cleared. + _isSearching = false; + // Fetch the matching channels, sort it by live status, and then set it. _channelFuture = twitchApi.searchChannels(query: query).then((channels) { channels.sort((c1, c2) => c2.isLive ? 1 : -1); @@ -96,6 +136,7 @@ abstract class SearchStoreBase with Store { } void dispose() { + _debounceTimer?.cancel(); textEditingController.dispose(); textFieldFocusNode.dispose(); } diff --git a/lib/screens/home/search/search_store.g.dart b/lib/screens/home/search/search_store.g.dart index bb576710..ace3f885 100644 --- a/lib/screens/home/search/search_store.g.dart +++ b/lib/screens/home/search/search_store.g.dart @@ -49,6 +49,26 @@ mixin _$SearchStore on SearchStoreBase, Store { }); } + late final _$_isSearchingAtom = Atom( + name: 'SearchStoreBase._isSearching', + context: context, + ); + + bool get isSearching { + _$_isSearchingAtom.reportRead(); + return super._isSearching; + } + + @override + bool get _isSearching => isSearching; + + @override + set _isSearching(bool value) { + _$_isSearchingAtom.reportWrite(value, super._isSearching, () { + super._isSearching = value; + }); + } + late final _$_channelFutureAtom = Atom( name: 'SearchStoreBase._channelFuture', context: context, @@ -104,6 +124,18 @@ mixin _$SearchStore on SearchStoreBase, Store { context: context, ); + @override + void onSearchTextChanged(String query) { + final _$actionInfo = _$SearchStoreBaseActionController.startAction( + name: 'SearchStoreBase.onSearchTextChanged', + ); + try { + return super.onSearchTextChanged(query); + } finally { + _$SearchStoreBaseActionController.endAction(_$actionInfo); + } + } + @override void handleQuery(String query) { final _$actionInfo = _$SearchStoreBaseActionController.startAction( @@ -116,6 +148,18 @@ mixin _$SearchStore on SearchStoreBase, Store { } } + @override + void _performSearch(String query) { + final _$actionInfo = _$SearchStoreBaseActionController.startAction( + name: 'SearchStoreBase._performSearch', + ); + try { + return super._performSearch(query); + } finally { + _$SearchStoreBaseActionController.endAction(_$actionInfo); + } + } + @override String toString() { return '''