Add progress indicators to search

This commit is contained in:
Tommy Chow 2022-01-05 09:38:05 -05:00
parent 58bb1d49d3
commit 25cf38eeaa
5 changed files with 227 additions and 178 deletions

View file

@ -1,6 +1,5 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/screens/home/search/search_results_categories.dart';
import 'package:frosty/screens/home/search/search_results_channels.dart';
import 'package:frosty/screens/home/search/stores/search_store.dart';
@ -16,6 +15,8 @@ class Search extends StatefulWidget {
}
class _SearchState extends State<Search> {
final textEditingController = TextEditingController();
@override
Widget build(BuildContext context) {
const headerPadding = EdgeInsets.fromLTRB(15.0, 20.0, 15.0, 10.0);
@ -26,7 +27,7 @@ class _SearchState extends State<Search> {
Padding(
padding: const EdgeInsets.symmetric(vertical: 5.0, horizontal: 10.0),
child: TextField(
controller: searchStore.textController,
controller: textEditingController,
autocorrect: false,
decoration: InputDecoration(
isDense: true,
@ -36,55 +37,62 @@ class _SearchState extends State<Search> {
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
suffixIcon: IconButton(
onPressed: searchStore.clearSearch,
onPressed: () {
setState(() {
textEditingController.clear();
});
FocusManager.instance.primaryFocus?.unfocus();
},
icon: const Icon(Icons.clear),
),
),
onSubmitted: searchStore.handleQuery,
onSubmitted: (query) => setState(() {
searchStore.handleQuery(query);
}),
),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => searchStore.handleQuery(searchStore.textController.text),
child: GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: Observer(
builder: (_) {
if (searchStore.textController.text.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (searchStore.searchHistory.isNotEmpty)
const Padding(
padding: EdgeInsets.all(15.0),
child: Text(
'HISTORY',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
child: ListView(
children: searchStore.searchHistory
.mapIndexed((index, searchTerm) => ListTile(
leading: const Icon(Icons.history),
title: Text(searchTerm),
trailing: IconButton(
icon: const Icon(Icons.cancel),
onPressed: () => searchStore.searchHistory.removeAt(index),
),
onTap: () {
searchStore.textController.text = searchTerm;
searchStore.handleQuery(searchTerm);
},
))
.toList(),
child: GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: textEditingController.text.isEmpty
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (searchStore.searchHistory.isNotEmpty)
const Padding(
padding: EdgeInsets.all(15.0),
child: Text(
'HISTORY',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
);
}
return CustomScrollView(
Expanded(
child: ListView(
children: searchStore.searchHistory
.mapIndexed(
(index, searchTerm) => ListTile(
leading: const Icon(Icons.history),
title: Text(searchTerm),
trailing: IconButton(
icon: const Icon(Icons.cancel),
onPressed: () => setState(() {
searchStore.searchHistory.removeAt(index);
}),
),
onTap: () {
setState(() {
textEditingController.text = searchTerm;
searchStore.handleQuery(searchTerm);
});
},
),
)
.toList(),
),
),
],
)
: CustomScrollView(
slivers: [
const SliverToBoxAdapter(
child: SectionHeader(
@ -92,21 +100,19 @@ class _SearchState extends State<Search> {
padding: headerPadding,
),
),
SearchResultsChannels(searchStore: searchStore),
if (searchStore.categorySearchResults.isNotEmpty) ...[
const SliverToBoxAdapter(
child: SectionHeader(
'Categories',
padding: headerPadding,
),
SearchResultsChannels(
searchStore: searchStore,
query: textEditingController.text,
),
const SliverToBoxAdapter(
child: SectionHeader(
'Categories',
padding: headerPadding,
),
SearchResultsCategories(categories: searchStore.categorySearchResults),
]
),
SearchResultsCategories(searchStore: searchStore),
],
);
},
),
),
),
),
),
],
@ -115,7 +121,7 @@ class _SearchState extends State<Search> {
@override
void dispose() {
widget.searchStore.dispose();
textEditingController.dispose();
super.dispose();
}
}

View file

@ -1,18 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/category.dart';
import 'package:frosty/screens/home/search/stores/search_store.dart';
import 'package:frosty/screens/home/top/categories/category_card.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:mobx/mobx.dart';
class SearchResultsCategories extends StatelessWidget {
final List<CategoryTwitch> categories;
final SearchStore searchStore;
const SearchResultsCategories({Key? key, required this.categories}) : super(key: key);
const SearchResultsCategories({Key? key, required this.searchStore}) : super(key: key);
@override
Widget build(BuildContext context) {
return SliverGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 20,
children: categories.map((category) => GridTile(child: CategoryCard(category: category))).toList(),
return Observer(
builder: (context) {
final future = searchStore.categoryFuture;
if (future == null) {
return const CircularProgressIndicator.adaptive();
}
switch (future.status) {
case FutureStatus.pending:
return const SliverToBoxAdapter(child: LoadingIndicator(subtitle: Text('Loading categories...')));
case FutureStatus.rejected:
return const SliverToBoxAdapter(child: Text('Failed to get categories.'));
case FutureStatus.fulfilled:
final CategoriesTwitch? categories = future.result;
if (categories == null) {
return const SliverToBoxAdapter(child: Text('Failed to get categories.'));
}
if (categories.data.isEmpty) {
return const SliverToBoxAdapter(
child: SizedBox(
height: 100.0,
child: Center(child: Text('No matching categories.')),
),
);
}
return SliverGrid.count(
crossAxisCount: 2,
mainAxisSpacing: 20,
children: categories.data.map((category) => GridTile(child: CategoryCard(category: category))).toList(),
);
}
},
);
}
}

View file

@ -1,75 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:frosty/models/channel.dart';
import 'package:frosty/screens/channel/video_chat.dart';
import 'package:frosty/screens/home/search/stores/search_store.dart';
import 'package:frosty/widgets/loading_indicator.dart';
import 'package:frosty/widgets/profile_picture.dart';
import 'package:mobx/mobx.dart';
class SearchResultsChannels extends StatelessWidget {
final SearchStore searchStore;
final String query;
const SearchResultsChannels({Key? key, required this.searchStore}) : super(key: key);
const SearchResultsChannels({
Key? key,
required this.searchStore,
required this.query,
}) : super(key: key);
Future<void> _handleSearch(BuildContext context, String search) async {
try {
final channelInfo = await searchStore.searchChannel(search);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => VideoChat(
title: channelInfo!.title,
userName: channelInfo.broadcasterName,
userLogin: channelInfo.broadcasterLogin,
),
),
);
} catch (e) {
const snackBar = SnackBar(
content: Text('Failed to get channel info :('),
behavior: SnackBarBehavior.floating,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
@override
Widget build(BuildContext context) {
Future<void> _handleSearch(BuildContext context, String search) async {
try {
final channelInfo = await searchStore.searchChannel(search);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => VideoChat(
title: channelInfo!.title,
userName: channelInfo.broadcasterName,
userLogin: channelInfo.broadcasterLogin,
),
),
);
} catch (e) {
const snackBar = SnackBar(
content: Text('Failed to get channel info :('),
behavior: SnackBarBehavior.floating,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
return Observer(
builder: (context) {
final future = searchStore.channelFuture;
return SliverList(
delegate: SliverChildListDelegate.fixed([
...searchStore.channelSearchResults.map(
(channel) => ListTile(
title: Text(channel.displayName),
leading: ProfilePicture(userLogin: channel.broadcasterLogin),
trailing: channel.isLive
? ClipRRect(
borderRadius: BorderRadius.circular(5.0),
child: Container(
color: const Color(0xFFF44336),
padding: const EdgeInsets.all(10.0),
child: const Text(
'LIVE',
style: TextStyle(fontWeight: FontWeight.bold),
if (future == null) {
return const CircularProgressIndicator.adaptive();
}
switch (future.status) {
case FutureStatus.pending:
return const SliverToBoxAdapter(
child: LoadingIndicator(
subtitle: Text('Loading channels...'),
),
);
case FutureStatus.rejected:
return const SliverToBoxAdapter(
child: Center(child: Text('Failed to fetch streams.')),
);
case FutureStatus.fulfilled:
final List<ChannelQuery> results = future.result;
return SliverList(
delegate: SliverChildListDelegate.fixed([
...results.map(
(channel) => ListTile(
title: Text(channel.displayName),
leading: ProfilePicture(userLogin: channel.broadcasterLogin),
trailing: channel.isLive
? ClipRRect(
borderRadius: BorderRadius.circular(5.0),
child: Container(
color: const Color(0xFFF44336),
padding: const EdgeInsets.all(10.0),
child: const Text(
'LIVE',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
)
: null,
subtitle: channel.isLive ? Text('Uptime: ${DateTime.now().difference(DateTime.parse(channel.startedAt)).toString().split('.')[0]}') : null,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => VideoChat(
title: channel.title,
userName: channel.displayName,
userLogin: channel.broadcasterLogin,
),
),
),
)
: null,
subtitle: channel.isLive ? Text('Uptime: ${DateTime.now().difference(DateTime.parse(channel.startedAt)).toString().split('.')[0]}') : null,
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => VideoChat(
title: channel.title,
userName: channel.displayName,
userLogin: channel.broadcasterLogin,
),
),
),
),
),
),
ListTile(
title: Text('Go to channel "${searchStore.textController.text}"'),
onTap: () => _handleSearch(context, searchStore.textController.text),
trailing: Icon(Icons.adaptive.arrow_forward),
)
]),
ListTile(
title: Text('Go to channel "$query"'),
onTap: () => _handleSearch(context, query),
trailing: Icon(Icons.adaptive.arrow_forward),
)
]),
);
}
},
);
}
}

View file

@ -11,18 +11,16 @@ part 'search_store.g.dart';
class SearchStore = _SearchStoreBase with _$SearchStore;
abstract class _SearchStoreBase with Store {
final textController = TextEditingController();
final AuthStore authStore;
@readonly
var _searchHistory = ObservableList<String>();
@readonly
var _channelSearchResults = <ChannelQuery>[];
ObservableFuture<List<ChannelQuery>>? _channelFuture;
@readonly
var _categorySearchResults = <CategoryTwitch>[];
ObservableFuture<CategoriesTwitch?>? _categoryFuture;
_SearchStoreBase({required this.authStore}) {
init();
@ -48,15 +46,20 @@ abstract class _SearchStoreBase with Store {
_searchHistory.remove(query);
_searchHistory.insert(0, query);
final channelResults = await Twitch.searchChannels(query: query, headers: authStore.headersTwitch);
channelResults.sort((c1, c2) => c2.isLive ? 1 : -1);
_channelFuture = Twitch.searchChannels(
query: query,
headers: authStore.headersTwitch,
).then(
(channels) {
channels.sort((c1, c2) => c2.isLive ? 1 : -1);
return channels;
},
).asObservable();
_channelSearchResults = channelResults;
final categoryResults = await Twitch.searchCategories(query: query, headers: authStore.headersTwitch);
if (categoryResults != null) {
_categorySearchResults = categoryResults.data;
}
_categoryFuture = Twitch.searchCategories(
query: query,
headers: authStore.headersTwitch,
).asObservable();
}
Future<Channel?> searchChannel(String query) async {
@ -68,16 +71,4 @@ abstract class _SearchStoreBase with Store {
rethrow;
}
}
@action
void clearSearch() {
textController.clear();
FocusManager.instance.primaryFocus?.unfocus();
_channelSearchResults = <ChannelQuery>[];
_categorySearchResults = <CategoryTwitch>[];
}
void dispose() {
textController.dispose();
}
}

View file

@ -26,41 +26,37 @@ mixin _$SearchStore on _SearchStoreBase, Store {
});
}
final _$_channelSearchResultsAtom =
Atom(name: '_SearchStoreBase._channelSearchResults');
final _$_channelFutureAtom = Atom(name: '_SearchStoreBase._channelFuture');
List<ChannelQuery> get channelSearchResults {
_$_channelSearchResultsAtom.reportRead();
return super._channelSearchResults;
ObservableFuture<List<ChannelQuery>>? get channelFuture {
_$_channelFutureAtom.reportRead();
return super._channelFuture;
}
@override
List<ChannelQuery> get _channelSearchResults => channelSearchResults;
ObservableFuture<List<ChannelQuery>>? get _channelFuture => channelFuture;
@override
set _channelSearchResults(List<ChannelQuery> value) {
_$_channelSearchResultsAtom.reportWrite(value, super._channelSearchResults,
() {
super._channelSearchResults = value;
set _channelFuture(ObservableFuture<List<ChannelQuery>>? value) {
_$_channelFutureAtom.reportWrite(value, super._channelFuture, () {
super._channelFuture = value;
});
}
final _$_categorySearchResultsAtom =
Atom(name: '_SearchStoreBase._categorySearchResults');
final _$_categoryFutureAtom = Atom(name: '_SearchStoreBase._categoryFuture');
List<CategoryTwitch> get categorySearchResults {
_$_categorySearchResultsAtom.reportRead();
return super._categorySearchResults;
ObservableFuture<CategoriesTwitch?>? get categoryFuture {
_$_categoryFutureAtom.reportRead();
return super._categoryFuture;
}
@override
List<CategoryTwitch> get _categorySearchResults => categorySearchResults;
ObservableFuture<CategoriesTwitch?>? get _categoryFuture => categoryFuture;
@override
set _categorySearchResults(List<CategoryTwitch> value) {
_$_categorySearchResultsAtom
.reportWrite(value, super._categorySearchResults, () {
super._categorySearchResults = value;
set _categoryFuture(ObservableFuture<CategoriesTwitch?>? value) {
_$_categoryFutureAtom.reportWrite(value, super._categoryFuture, () {
super._categoryFuture = value;
});
}
@ -78,20 +74,6 @@ mixin _$SearchStore on _SearchStoreBase, Store {
return _$handleQueryAsyncAction.run(() => super.handleQuery(query));
}
final _$_SearchStoreBaseActionController =
ActionController(name: '_SearchStoreBase');
@override
void clearSearch() {
final _$actionInfo = _$_SearchStoreBaseActionController.startAction(
name: '_SearchStoreBase.clearSearch');
try {
return super.clearSearch();
} finally {
_$_SearchStoreBaseActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''