make search page type to search

This commit is contained in:
Tommy Chow 2026-01-02 18:08:30 -05:00
parent 1ebf6f32e0
commit 8b54b3ba1b
5 changed files with 103 additions and 1 deletions

View file

@ -41,7 +41,7 @@ class _SearchState extends State<Search> {
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<Search> {
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<Search> {
_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(

View file

@ -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());
}

View file

@ -72,7 +72,15 @@ class _SearchResultsChannelsState extends State<SearchResultsChannels> {
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());
}

View file

@ -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<String>();
@readonly
var _isSearching = false;
@readonly
ObservableFuture<List<ChannelQuery>>? _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();
}

View file

@ -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 '''