Add progress indicators to search
This commit is contained in:
parent
58bb1d49d3
commit
25cf38eeaa
5 changed files with 227 additions and 178 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
]),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '''
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue