diff --git a/lib/screens/home/search/search.dart b/lib/screens/home/search/search.dart index 1a14dafa..a6a63061 100644 --- a/lib/screens/home/search/search.dart +++ b/lib/screens/home/search/search.dart @@ -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 { + 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 { 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 { 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 { 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 { @override void dispose() { - widget.searchStore.dispose(); + textEditingController.dispose(); super.dispose(); } } diff --git a/lib/screens/home/search/search_results_categories.dart b/lib/screens/home/search/search_results_categories.dart index b7cd377f..11608a26 100644 --- a/lib/screens/home/search/search_results_categories.dart +++ b/lib/screens/home/search/search_results_categories.dart @@ -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 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(), + ); + } + }, ); } } diff --git a/lib/screens/home/search/search_results_channels.dart b/lib/screens/home/search/search_results_channels.dart index fe0628c6..d2eb13c3 100644 --- a/lib/screens/home/search/search_results_channels.dart +++ b/lib/screens/home/search/search_results_channels.dart @@ -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 _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 _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 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), + ) + ]), + ); + } + }, ); } } diff --git a/lib/screens/home/search/stores/search_store.dart b/lib/screens/home/search/stores/search_store.dart index 3b7b7ae1..3c9be1ec 100644 --- a/lib/screens/home/search/stores/search_store.dart +++ b/lib/screens/home/search/stores/search_store.dart @@ -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(); @readonly - var _channelSearchResults = []; + ObservableFuture>? _channelFuture; @readonly - var _categorySearchResults = []; + ObservableFuture? _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 searchChannel(String query) async { @@ -68,16 +71,4 @@ abstract class _SearchStoreBase with Store { rethrow; } } - - @action - void clearSearch() { - textController.clear(); - FocusManager.instance.primaryFocus?.unfocus(); - _channelSearchResults = []; - _categorySearchResults = []; - } - - void dispose() { - textController.dispose(); - } } diff --git a/lib/screens/home/search/stores/search_store.g.dart b/lib/screens/home/search/stores/search_store.g.dart index ea135da6..f3ed38bc 100644 --- a/lib/screens/home/search/stores/search_store.g.dart +++ b/lib/screens/home/search/stores/search_store.g.dart @@ -26,41 +26,37 @@ mixin _$SearchStore on _SearchStoreBase, Store { }); } - final _$_channelSearchResultsAtom = - Atom(name: '_SearchStoreBase._channelSearchResults'); + final _$_channelFutureAtom = Atom(name: '_SearchStoreBase._channelFuture'); - List get channelSearchResults { - _$_channelSearchResultsAtom.reportRead(); - return super._channelSearchResults; + ObservableFuture>? get channelFuture { + _$_channelFutureAtom.reportRead(); + return super._channelFuture; } @override - List get _channelSearchResults => channelSearchResults; + ObservableFuture>? get _channelFuture => channelFuture; @override - set _channelSearchResults(List value) { - _$_channelSearchResultsAtom.reportWrite(value, super._channelSearchResults, - () { - super._channelSearchResults = value; + set _channelFuture(ObservableFuture>? value) { + _$_channelFutureAtom.reportWrite(value, super._channelFuture, () { + super._channelFuture = value; }); } - final _$_categorySearchResultsAtom = - Atom(name: '_SearchStoreBase._categorySearchResults'); + final _$_categoryFutureAtom = Atom(name: '_SearchStoreBase._categoryFuture'); - List get categorySearchResults { - _$_categorySearchResultsAtom.reportRead(); - return super._categorySearchResults; + ObservableFuture? get categoryFuture { + _$_categoryFutureAtom.reportRead(); + return super._categoryFuture; } @override - List get _categorySearchResults => categorySearchResults; + ObservableFuture? get _categoryFuture => categoryFuture; @override - set _categorySearchResults(List value) { - _$_categorySearchResultsAtom - .reportWrite(value, super._categorySearchResults, () { - super._categorySearchResults = value; + set _categoryFuture(ObservableFuture? 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 '''