Add discovery page in search page.

This commit is contained in:
stonegate 2020-09-14 17:51:04 +08:00
parent d99e7a2e04
commit f7dfb0b005
10 changed files with 874 additions and 255 deletions

View File

@ -8,6 +8,7 @@ import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:line_icons/line_icons.dart'; import 'package:line_icons/line_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:tsacdop/state/search_state.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
import '../local_storage/key_value_storage.dart'; import '../local_storage/key_value_storage.dart';

View File

@ -0,0 +1,385 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../local_storage/key_value_storage.dart';
import '../service/api_search.dart';
import '../state/search_state.dart';
import '../type/search_genre.dart';
import '../type/searchpodcast.dart';
import '../util/custom_widget.dart';
import '../util/extension_helper.dart';
import 'search_podcast.dart';
class DiscoveryPage extends StatefulWidget {
DiscoveryPage({this.onTap, Key key}) : super(key: key);
final ValueChanged<String> onTap;
@override
DiscoveryPageState createState() => DiscoveryPageState();
}
class DiscoveryPageState extends State<DiscoveryPage> {
Genre _selectedGenre;
Genre get selectedGenre => _selectedGenre;
final List<OnlinePodcast> _podcastList = [];
bool _loading;
Future _searchTopPodcast;
int _page;
Future<List<String>> _getSearchHistory() {
final storage = KeyValueStorage(searchHistoryKey);
final history = storage.getStringList();
return history;
}
void backToHome() {
setState(() {
_selectedGenre = null;
});
}
@override
void initState() {
super.initState();
_searchTopPodcast = _getTopPodcasts(page: 1);
}
Widget _loadTopPodcasts() => Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), color: context.primaryColor),
width: 120,
margin: EdgeInsets.fromLTRB(10, 10, 0, 10),
padding: EdgeInsets.all(4),
alignment: Alignment.topCenter,
child: Column(
children: [
Expanded(
flex: 2,
child: Center(
child: Container(
height: 50,
width: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.primaryColorDark,
),
alignment: Alignment.center,
child: SizedBox(
width: 20,
height: 2,
child: LinearProgressIndicator(value: 0),
),
),
),
),
Expanded(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: context.textTheme.bodyText1.fontSize,
decoration: BoxDecoration(
color: context.primaryColorDark,
borderRadius: BorderRadius.circular(4)),
),
SizedBox(height: 10),
Container(
width: 40,
height: context.textTheme.bodyText1.fontSize,
decoration: BoxDecoration(
color: context.primaryColorDark,
borderRadius: BorderRadius.circular(4)),
),
],
),
),
Expanded(
flex: 1,
child: Center(
child: SizedBox(
height: 32,
child: OutlineButton(
color: context.accentColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100.0),
side: BorderSide(color: Colors.grey[500])),
highlightedBorderColor: Colors.grey[500],
disabledTextColor: Colors.grey[500],
child: Text(context.s.subscribe),
disabledBorderColor: Colors.grey[500],
onPressed: () {}),
),
),
),
],
));
Future<List<OnlinePodcast>> _getTopPodcasts({int page}) async {
final searchEngine = SearchEngine();
var searchResult = await searchEngine.fetchBestPodcast(
genre: '',
page: page,
);
final podcastTopList =
searchResult.podcasts.map((e) => e?.toOnlinePodcast).toList();
_podcastList.addAll(podcastTopList.cast());
_loading = false;
return _podcastList;
}
@override
Widget build(BuildContext context) {
final searchState = context.watch<SearchState>();
return PodcastSlideup(
child: _selectedGenre == null
? SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
FutureBuilder<List<String>>(
future: _getSearchHistory(),
initialData: [],
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data.isNotEmpty) {
final history = snapshot.data;
return SizedBox(
height: 50,
child: Row(
children: history
.map<Widget>((e) => Padding(
padding: const EdgeInsets.all(8.0),
child: FlatButton.icon(
color: Colors.accents[
math.Random().nextInt(10)]
.withAlpha(70),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(100.0),
),
onPressed: () => widget.onTap(e),
label: Text(e),
icon: Icon(
Icons.bookmark_border,
size: 20,
),
),
))
.toList(),
),
);
}
return SizedBox(
height: 1,
);
}),
SizedBox(
height: 200,
child: FutureBuilder<List<OnlinePodcast>>(
future: _searchTopPodcast,
builder: (context, snapshot) {
return ScrollConfiguration(
behavior: NoGrowBehavior(),
child: ListView(
scrollDirection: Axis.horizontal,
children: snapshot.hasData
? snapshot.data.map<Widget>((podcast) {
return Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(10),
color: context.primaryColor),
width: 120,
margin: EdgeInsets.fromLTRB(
10, 10, 0, 10),
child: Material(
color: Colors.transparent,
borderRadius:
BorderRadius.circular(10),
clipBehavior: Clip.hardEdge,
child: InkWell(
onTap: () => searchState
.selectedPodcast = podcast,
child: Padding(
padding:
const EdgeInsets.all(4.0),
child: Column(
children: [
Expanded(
flex: 2,
child: Center(
child: PodcastAvatar(
podcast)),
),
Expanded(
flex: 1,
child: Text(
podcast.title,
textAlign:
TextAlign.center,
maxLines: 2,
overflow:
TextOverflow.fade,
style: TextStyle(
fontWeight:
FontWeight
.bold),
),
),
Expanded(
flex: 1,
child: Center(
child: SizedBox(
height: 32,
child:
SubscribeButton(
podcast)),
),
),
],
),
),
),
),
);
}).toList()
: [
_loadTopPodcasts(),
_loadTopPodcasts(),
_loadTopPodcasts(),
_loadTopPodcasts(),
]),
);
}),
),
ListView(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
children: genres
.map<Widget>((e) => ListTile(
onTap: () => setState(() => _selectedGenre = e),
title: Text(e.name),
))
.toList(),
),
SizedBox(
height: 40,
child: Center(
child: Image(
image: context.brightness == Brightness.light
? AssetImage('assets/listennotes.png')
: AssetImage('assets/listennotes_light.png'),
height: 15,
),
),
)
],
),
)
: _TopPodcastList(genre: _selectedGenre),
);
}
}
class _TopPodcastList extends StatefulWidget {
final Genre genre;
_TopPodcastList({this.genre, Key key}) : super(key: key);
@override
__TopPodcastListState createState() => __TopPodcastListState();
}
class __TopPodcastListState extends State<_TopPodcastList> {
final List<OnlinePodcast> _podcastList = [];
Future _searchFuture;
bool _loading;
int _page;
Future<List<OnlinePodcast>> _getTopPodcasts({Genre genre, int page}) async {
final searchEngine = SearchEngine();
var searchResult = await searchEngine.fetchBestPodcast(
genre: genre.id,
page: page,
);
final podcastTopList =
searchResult.podcasts.map((e) => e?.toOnlinePodcast).toList();
_podcastList.addAll(podcastTopList.cast());
_loading = false;
return _podcastList;
}
@override
void initState() {
_page = 1;
_searchFuture = _getTopPodcasts(genre: widget.genre, page: _page);
super.initState();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _searchFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Container(
padding: EdgeInsets.only(top: 200),
alignment: Alignment.topCenter,
child: CircularProgressIndicator(),
);
}
final content = snapshot.data;
return CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return SearchResult(
onlinePodcast: content[index],
);
},
childCount: content.length,
),
),
SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 10.0, bottom: 20.0),
child: OutlineButton(
highlightedBorderColor: context.accentColor,
splashColor: context.accentColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(100))),
child: _loading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
))
: Text(context.s.loadMore),
onPressed: () => _loading
? null
: setState(
() {
_loading = true;
_page++;
print(_page);
_searchFuture = _getTopPodcasts(
genre: widget.genre, page: _page);
},
),
),
)
],
),
)
],
);
},
);
}
}

View File

@ -7,18 +7,22 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html/flutter_html.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:webfeed/webfeed.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:webfeed/webfeed.dart';
import '../local_storage/key_value_storage.dart';
import '../service/api_search.dart'; import '../service/api_search.dart';
import '../state/podcast_group.dart'; import '../state/podcast_group.dart';
import '../state/search_state.dart';
import '../type/searchepisodes.dart'; import '../type/searchepisodes.dart';
import '../type/searchpodcast.dart'; import '../type/searchpodcast.dart';
import '../util/extension_helper.dart'; import '../util/extension_helper.dart';
import 'pocast_discovery.dart';
class MyHomePageDelegate extends SearchDelegate<int> { class MyHomePageDelegate extends SearchDelegate<int> {
final String searchFieldLabel; final String searchFieldLabel;
final GlobalKey<DiscoveryPageState> _discoveryKey =
GlobalKey<DiscoveryPageState>();
MyHomePageDelegate({this.searchFieldLabel}) MyHomePageDelegate({this.searchFieldLabel})
: super( : super(
searchFieldLabel: searchFieldLabel, searchFieldLabel: searchFieldLabel,
@ -44,35 +48,53 @@ class MyHomePageDelegate extends SearchDelegate<int> {
child: Text(context.s.searchInvalidRss), child: Text(context.s.searchInvalidRss),
); );
@override
void close(BuildContext context, int result) {
final selectedPodcast = context.read<SearchState>().selectedPodcast;
if (selectedPodcast != null) {
context.read<SearchState>().clearSelect();
} else {
if (_discoveryKey.currentState?.selectedGenre != null) {
_discoveryKey.currentState.backToHome();
} else {
context.read<SearchState>().clearList();
super.close(context, result);
}
}
}
@override @override
ThemeData appBarTheme(BuildContext context) => Theme.of(context); ThemeData appBarTheme(BuildContext context) => Theme.of(context);
@override @override
Widget buildLeading(BuildContext context) { Widget buildLeading(BuildContext context) {
return IconButton( return WillPopScope(
tooltip: context.s.back, onWillPop: () async {
icon: AnimatedIcon( close(context, null);
icon: AnimatedIcons.menu_arrow, return false;
progress: transitionAnimation,
),
onPressed: () {
close(context, 1);
}, },
child: IconButton(
tooltip: context.s.back,
icon: AnimatedIcon(
icon: AnimatedIcons.menu_arrow,
progress: transitionAnimation,
),
onPressed: () {
close(context, 1);
},
),
); );
} }
@override @override
Widget buildSuggestions(BuildContext context) { Widget buildSuggestions(BuildContext context) {
return Center( return DiscoveryPage(
child: Container( key: _discoveryKey,
padding: EdgeInsets.only(top: 100), onTap: (history) {
child: Image( query = history;
image: context.brightness == Brightness.light showResults(context);
? AssetImage('assets/listennotes.png') },
: AssetImage('assets/listennotes_light.png'), );
height: 20,
),
));
} }
@override @override
@ -94,18 +116,24 @@ class MyHomePageDelegate extends SearchDelegate<int> {
@override @override
Widget buildResults(BuildContext context) { Widget buildResults(BuildContext context) {
if (query.isEmpty) { if (query.isEmpty) {
return Container( return DiscoveryPage(
height: 10, key: _discoveryKey,
width: 10, onTap: (history) {
margin: EdgeInsets.only(top: 400), query = history;
child: SizedBox( showResults(context);
height: 10, });
child: Image.asset( // return Container(
'assets/listennote.png', // height: 10,
fit: BoxFit.fill, // width: 10,
), // margin: EdgeInsets.only(top: 400),
), // child: SizedBox(
); // height: 10,
// child: Image.asset(
// 'assets/listennotes.png',
// fit: BoxFit.fill,
// ),
// ),
// );
} else if (rssExp.stringMatch(query) != null) { } else if (rssExp.stringMatch(query) != null) {
return FutureBuilder( return FutureBuilder(
future: getRss(rssExp.stringMatch(query)), future: getRss(rssExp.stringMatch(query)),
@ -360,18 +388,30 @@ class _SearchListState extends State<SearchList> {
final List<OnlinePodcast> _podcastList = []; final List<OnlinePodcast> _podcastList = [];
int _offset; int _offset;
bool _loading; bool _loading;
OnlinePodcast _selectedPodcast;
Future _searchFuture; Future _searchFuture;
final List<OnlinePodcast> _subscribed = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_searchFuture = _getList(widget.query, _nextOffset); _searchFuture = _getList(widget.query, _nextOffset);
} }
Future<void> _saveHistory(String query) async {
final storage = KeyValueStorage(searchHistoryKey);
final history = await storage.getStringList();
if (!history.contains(query)) {
if (history.length == 10) {
history.removeLast();
}
history.insert(0, query);
await storage.saveStringList(history);
}
}
Future<List<OnlinePodcast>> _getList( Future<List<OnlinePodcast>> _getList(
String searchText, int nextOffset) async { String searchText, int nextOffset) async {
var searchEngine = SearchEngine(); if (nextOffset == 0) _saveHistory(searchText);
final searchEngine = SearchEngine();
var searchResult = await searchEngine.searchPodcasts( var searchResult = await searchEngine.searchPodcasts(
searchText: searchText, nextOffset: nextOffset); searchText: searchText, nextOffset: nextOffset);
_offset = searchResult.nextOffset; _offset = searchResult.nextOffset;
@ -382,151 +422,88 @@ class _SearchListState extends State<SearchList> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( return PodcastSlideup(
alignment: Alignment.bottomCenter, child: FutureBuilder<List>(
children: [ future: _searchFuture,
FutureBuilder<List>( builder: (context, snapshot) {
future: _searchFuture, if (!snapshot.hasData && widget.query != null) {
builder: (context, snapshot) { return Container(
if (!snapshot.hasData && widget.query != null) { padding: EdgeInsets.only(top: 200),
return Container( alignment: Alignment.topCenter,
padding: EdgeInsets.only(top: 200), child: CircularProgressIndicator(),
alignment: Alignment.topCenter,
child: CircularProgressIndicator(),
);
}
var content = snapshot.data;
return CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return SearchResult(
onlinePodcast: content[index],
isSubscribed: _subscribed.contains(content[index]),
onSelect: (onlinePodcast) {
setState(() {
_selectedPodcast = onlinePodcast;
});
},
onSubscribe: (onlinePodcast) {
setState(() {
_subscribed.add(onlinePodcast);
});
},
);
},
childCount: content.length,
),
),
SliverToBoxAdapter(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 10.0, bottom: 20.0),
child: OutlineButton(
highlightedBorderColor: context.accentColor,
splashColor: context.accentColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.all(Radius.circular(100))),
child: _loading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
))
: Text(context.s.loadMore),
onPressed: () => _loading
? null
: setState(
() {
_loading = true;
_nextOffset = _offset;
_searchFuture =
_getList(widget.query, _nextOffset);
},
),
),
)
],
),
)
],
); );
}, }
), var content = snapshot.data;
if (_selectedPodcast != null) return CustomScrollView(
Positioned.fill( slivers: [
child: GestureDetector( SliverList(
onTap: () => setState(() => _selectedPodcast = null), delegate: SliverChildBuilderDelegate(
child: Container( (context, index) {
color: context.scaffoldBackgroundColor.withOpacity(0.9), return SearchResult(onlinePodcast: content[index]);
},
childCount: content.length,
),
), ),
), SliverToBoxAdapter(
), child: Row(
if (_selectedPodcast != null) mainAxisAlignment: MainAxisAlignment.spaceEvenly,
LayoutBuilder( mainAxisSize: MainAxisSize.min,
builder: (context, constrants) => SearchResultDetail( children: [
_selectedPodcast, Padding(
maxHeight: constrants.maxHeight, padding: const EdgeInsets.only(top: 10.0, bottom: 20.0),
isSubscribed: _subscribed.contains(_selectedPodcast), child: OutlineButton(
onClose: (option) { highlightedBorderColor: context.accentColor,
setState(() => _selectedPodcast = null); splashColor: context.accentColor.withOpacity(0.5),
}, shape: RoundedRectangleBorder(
onSubscribe: (onlinePodcast) { borderRadius:
setState(() { BorderRadius.all(Radius.circular(100))),
_subscribed.add(onlinePodcast); child: _loading
}); ? SizedBox(
}, height: 20,
), width: 20,
), child: CircularProgressIndicator(
], strokeWidth: 2,
))
: Text(context.s.loadMore),
onPressed: () => _loading
? null
: setState(
() {
_loading = true;
_nextOffset = _offset;
_searchFuture =
_getList(widget.query, _nextOffset);
},
),
),
)
],
),
)
],
);
},
),
); );
} }
} }
class SearchResult extends StatelessWidget { class SearchResult extends StatelessWidget {
final OnlinePodcast onlinePodcast; final OnlinePodcast onlinePodcast;
final ValueChanged<OnlinePodcast> onSelect; SearchResult({this.onlinePodcast, Key key}) : super(key: key);
final ValueChanged<OnlinePodcast> onSubscribe;
final bool isSubscribed;
SearchResult(
{this.onlinePodcast,
this.onSelect,
this.onSubscribe,
this.isSubscribed,
Key key})
: super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var subscribeWorker = Provider.of<GroupList>(context, listen: false); var searchState = context.watch<SearchState>();
final s = context.s; return Column(
subscribePodcast(OnlinePodcast podcast) { mainAxisAlignment: MainAxisAlignment.start,
var item = SubscribeItem(podcast.rss, podcast.title, mainAxisSize: MainAxisSize.min,
imgUrl: podcast.image, group: 'Home'); children: <Widget>[
subscribeWorker.setSubscribeItem(item); ListTile(
onSubscribe(podcast);
}
return Container(
decoration: BoxDecoration(
border: Border(
// bottom: Divider.createBorderSide(context),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
contentPadding: EdgeInsets.fromLTRB(20, 10, 20, 10), contentPadding: EdgeInsets.fromLTRB(20, 10, 20, 10),
onTap: () { onTap: () {
onSelect(onlinePodcast); searchState.selectedPodcast = onlinePodcast;
// onSelect(onlinePodcast);
}, },
leading: ClipRRect( leading: ClipRRect(
borderRadius: BorderRadius.circular(25.0), borderRadius: BorderRadius.circular(25.0),
@ -559,52 +536,18 @@ class SearchResult extends StatelessWidget {
), ),
title: Text(onlinePodcast.title), title: Text(onlinePodcast.title),
subtitle: Text(onlinePodcast.publisher ?? ''), subtitle: Text(onlinePodcast.publisher ?? ''),
trailing: !isSubscribed trailing: SubscribeButton(onlinePodcast)),
? OutlineButton( ],
highlightedBorderColor: context.accentColor,
borderSide: BorderSide(color: context.accentColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100.0),
side: BorderSide(color: context.accentColor)),
splashColor: context.accentColor.withOpacity(0.5),
child: Text(s.subscribe,
style: TextStyle(color: context.accentColor)),
onPressed: () {
subscribePodcast(onlinePodcast);
Fluttertoast.showToast(
msg: s.podcastSubscribed,
gravity: ToastGravity.BOTTOM,
);
})
: OutlineButton(
color: context.accentColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100.0),
side: BorderSide(color: Colors.grey[500])),
highlightedBorderColor: Colors.grey[500],
disabledTextColor: Colors.grey[500],
child: Text(s.subscribe),
disabledBorderColor: Colors.grey[500],
onPressed: () {}),
),
],
),
); );
} }
} }
/// Search podcast detail widget
class SearchResultDetail extends StatefulWidget { class SearchResultDetail extends StatefulWidget {
SearchResultDetail(this.onlinePodcast, SearchResultDetail(this.onlinePodcast,
{this.onClose, {this.maxHeight, this.episodeList, this.isSubscribed, Key key})
this.maxHeight,
this.onSubscribe,
this.episodeList,
this.isSubscribed,
Key key})
: super(key: key); : super(key: key);
final OnlinePodcast onlinePodcast; final OnlinePodcast onlinePodcast;
final ValueChanged<bool> onClose;
final ValueChanged<OnlinePodcast> onSubscribe;
final double maxHeight; final double maxHeight;
final List<OnlineEpisode> episodeList; final List<OnlineEpisode> episodeList;
final bool isSubscribed; final bool isSubscribed;
@ -739,22 +682,16 @@ class _SearchResultDetailState extends State<SearchResultDetail>
_initSize = _animation.value > _minHeight - 50 ? _minHeight : 100; _initSize = _animation.value > _minHeight - 50 ? _minHeight : 100;
}); });
_controller.forward(); _controller.forward();
if (_animation.value < _minHeight - 50) widget.onClose(true); if (_animation.value < _minHeight - 50) {
context.read<SearchState>().clearSelect();
}
} }
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var subscribeWorker = Provider.of<GroupList>(context, listen: false);
final s = context.s; final s = context.s;
subscribePodcast(OnlinePodcast podcast) {
var item = SubscribeItem(podcast.rss, podcast.title,
imgUrl: podcast.image, group: 'Home');
subscribeWorker.setSubscribeItem(item);
widget.onSubscribe(podcast);
}
return GestureDetector( return GestureDetector(
onVerticalDragStart: _start, onVerticalDragStart: _start,
onVerticalDragUpdate: _update, onVerticalDragUpdate: _update,
@ -806,45 +743,7 @@ class _SearchResultDetailState extends State<SearchResultDetail>
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
style: TextStyle(color: context.accentColor), style: TextStyle(color: context.accentColor),
), ),
!widget.isSubscribed SubscribeButton(widget.onlinePodcast),
? OutlineButton(
highlightedBorderColor:
context.accentColor,
borderSide: BorderSide(
color: context.accentColor,
width: 2),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(100.0),
side: BorderSide(
color: context.accentColor)),
splashColor: context.accentColor
.withOpacity(0.5),
child: Text(s.subscribe,
style: TextStyle(
color: context.accentColor)),
onPressed: () {
subscribePodcast(
widget.onlinePodcast);
Fluttertoast.showToast(
msg: s.podcastSubscribed,
gravity: ToastGravity.BOTTOM,
);
})
: OutlineButton(
color: context.accentColor
.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(100.0),
side: BorderSide(
color: Colors.grey[500])),
highlightedBorderColor:
Colors.grey[500],
disabledTextColor: Colors.grey[500],
child: Text(s.subscribe),
disabledBorderColor: Colors.grey[500],
onPressed: () {})
], ],
), ),
), ),
@ -1011,3 +910,128 @@ class _SearchResultDetailState extends State<SearchResultDetail>
); );
} }
} }
class SubscribeButton extends StatelessWidget {
SubscribeButton(this.onlinePodcast, {Key key}) : super(key: key);
final OnlinePodcast onlinePodcast;
@override
Widget build(BuildContext context) {
final subscribeWorker = context.watch<GroupList>();
final searchState = context.watch<SearchState>();
final s = context.s;
subscribePodcast(OnlinePodcast podcast) {
var item = SubscribeItem(podcast.rss, podcast.title,
imgUrl: podcast.image, group: 'Home');
subscribeWorker.setSubscribeItem(item);
searchState.addPodcast(podcast);
}
return Consumer<SearchState>(builder: (_, searchState, __) {
final subscribed = searchState.isSubscribed(onlinePodcast);
return !subscribed
? OutlineButton(
highlightedBorderColor: context.accentColor,
borderSide: BorderSide(color: context.accentColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100.0),
side: BorderSide(color: context.accentColor)),
splashColor: context.accentColor.withOpacity(0.5),
child: Text(s.subscribe,
style: TextStyle(color: context.accentColor)),
onPressed: () {
subscribePodcast(onlinePodcast);
searchState.addPodcast(onlinePodcast);
Fluttertoast.showToast(
msg: s.podcastSubscribed,
gravity: ToastGravity.BOTTOM,
);
})
: OutlineButton(
color: context.accentColor.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(100.0),
side: BorderSide(color: Colors.grey[500])),
highlightedBorderColor: Colors.grey[500],
disabledTextColor: Colors.grey[500],
child: Text(s.subscribe),
disabledBorderColor: Colors.grey[500],
onPressed: () {});
});
}
}
class PodcastSlideup extends StatelessWidget {
const PodcastSlideup({this.child, Key key}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return Consumer<SearchState>(builder: (_, searchState, __) {
final selectedPodcast = searchState.selectedPodcast;
final subscribed = searchState.subscribedList;
return Stack(
alignment: Alignment.bottomCenter,
children: [
child,
if (selectedPodcast != null)
Positioned.fill(
child: GestureDetector(
onTap: searchState.clearSelect,
child: Container(
color: context.scaffoldBackgroundColor.withOpacity(0.9),
),
),
),
if (selectedPodcast != null)
LayoutBuilder(
builder: (context, constrants) => SearchResultDetail(
selectedPodcast,
maxHeight: constrants.maxHeight,
isSubscribed: subscribed.contains(selectedPodcast),
),
),
],
);
});
}
}
class PodcastAvatar extends StatelessWidget {
const PodcastAvatar(this.podcast, {Key key}) : super(key: key);
final OnlinePodcast podcast;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 50,
child: ClipRRect(
borderRadius: BorderRadius.circular(25.0),
child: CachedNetworkImage(
height: 50.0,
width: 50.0,
fit: BoxFit.fitWidth,
alignment: Alignment.center,
imageUrl: podcast.image,
progressIndicatorBuilder: (context, url, downloadProgress) =>
Container(
height: 50,
width: 50,
alignment: Alignment.center,
color: context.primaryColorDark,
child: SizedBox(
width: 20,
height: 2,
child: LinearProgressIndicator(value: downloadProgress.progress),
),
),
errorWidget: (context, url, error) => Container(
width: 50,
height: 50,
alignment: Alignment.center,
color: context.primaryColorDark,
child: Icon(Icons.error)),
),
),
);
}
}

View File

@ -45,6 +45,7 @@ const String hideListenedKey = 'hideListenedKey';
const String notificationLayoutKey = 'notificationLayoutKey'; const String notificationLayoutKey = 'notificationLayoutKey';
const String showNotesFontKey = 'showNotesFontKey'; const String showNotesFontKey = 'showNotesFontKey';
const String speedListKey = 'speedListKey'; const String speedListKey = 'speedListKey';
const String searchHistoryKey = 'searchHistoryKey';
class KeyValueStorage { class KeyValueStorage {
final String key; final String key;

View File

@ -13,6 +13,7 @@ import 'state/audio_state.dart';
import 'state/download_state.dart'; import 'state/download_state.dart';
import 'state/podcast_group.dart'; import 'state/podcast_group.dart';
import 'state/refresh_podcast.dart'; import 'state/refresh_podcast.dart';
import 'state/search_state.dart';
import 'state/setting_state.dart'; import 'state/setting_state.dart';
final SettingState themeSetting = SettingState(); final SettingState themeSetting = SettingState();
@ -30,6 +31,7 @@ Future main() async {
ChangeNotifierProvider(create: (_) => AudioPlayerNotifier()), ChangeNotifierProvider(create: (_) => AudioPlayerNotifier()),
ChangeNotifierProvider(create: (_) => GroupList()), ChangeNotifierProvider(create: (_) => GroupList()),
ChangeNotifierProvider(create: (_) => RefreshWorker()), ChangeNotifierProvider(create: (_) => RefreshWorker()),
ChangeNotifierProvider(create: (_) => SearchState()),
ChangeNotifierProvider( ChangeNotifierProvider(
lazy: false, lazy: false,
create: (_) => DownloadState(), create: (_) => DownloadState(),

View File

@ -5,11 +5,12 @@ import 'package:dio/dio.dart';
import '../.env.dart'; import '../.env.dart';
import '../type/searchepisodes.dart'; import '../type/searchepisodes.dart';
import '../type/searchpodcast.dart'; import '../type/searchpodcast.dart';
import '../type/search_top_podcast.dart';
class SearchEngine { class SearchEngine {
final apiKey = environment['apiKey'];
Future<SearchPodcast<dynamic>> searchPodcasts( Future<SearchPodcast<dynamic>> searchPodcasts(
{String searchText, int nextOffset}) async { {String searchText, int nextOffset}) async {
var apiKey = environment['apiKey'];
var url = "https://listen-api.listennotes.com/api/v2/search?q=" var url = "https://listen-api.listennotes.com/api/v2/search?q="
"${Uri.encodeComponent(searchText)}${"&sort_by_date=0&type=podcast&offset=$nextOffset"}"; "${Uri.encodeComponent(searchText)}${"&sort_by_date=0&type=podcast&offset=$nextOffset"}";
var response = await Dio().get(url, var response = await Dio().get(url,
@ -24,7 +25,6 @@ class SearchEngine {
Future<SearchEpisodes<dynamic>> fetchEpisode( Future<SearchEpisodes<dynamic>> fetchEpisode(
{String id, int nextEpisodeDate}) async { {String id, int nextEpisodeDate}) async {
var apiKey = environment['apiKey'];
var url = var url =
"https://listen-api.listennotes.com/api/v2/podcasts/$id?next_episode_pub_date=$nextEpisodeDate"; "https://listen-api.listennotes.com/api/v2/podcasts/$id?next_episode_pub_date=$nextEpisodeDate";
var response = await Dio().get(url, var response = await Dio().get(url,
@ -36,4 +36,18 @@ class SearchEngine {
var searchResult = SearchEpisodes.fromJson(searchResultMap); var searchResult = SearchEpisodes.fromJson(searchResultMap);
return searchResult; return searchResult;
} }
Future<SearchTopPodcast<dynamic>> fetchBestPodcast(
{String genre, int page, String region = 'us'}) async {
var url =
"https://listen-api.listennotes.com/api/v2/best_podcasts?genre_id=$genre&page=$page&region=$region";
var response = await Dio().get(url,
options: Options(headers: {
'X-ListenAPI-Key': "$apiKey",
'Accept': "application/json"
}));
Map searchResultMap = jsonDecode(response.toString());
var searchResult = SearchTopPodcast.fromJson(searchResultMap);
return searchResult;
}
} }

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import '../type/searchpodcast.dart';
class SearchState extends ChangeNotifier {
final List<OnlinePodcast> _subscribedList = [];
bool _update;
List<OnlinePodcast> get subscribedList => _subscribedList;
bool get update => _update;
OnlinePodcast _selectedPodcast;
OnlinePodcast get selectedPodcast => _selectedPodcast;
set selectedPodcast(OnlinePodcast podcast) {
_selectedPodcast = podcast;
notifyListeners();
}
bool isSubscribed(OnlinePodcast podcast) => _subscribedList.contains(podcast);
void clearSelect() {
_selectedPodcast = null;
notifyListeners();
}
void clearList() {
_subscribedList.clear();
}
void addPodcast(OnlinePodcast podcast) {
_subscribedList.add(podcast);
_update = !_update;
notifyListeners();
}
}

View File

@ -0,0 +1,29 @@
class Genre {
String id;
String name;
Genre({this.id, this.name});
}
var genres = [
Genre(id: '144', name: 'Personal Finance'),
Genre(id: '151', name: 'Locally Focused'),
Genre(id: '68', name: 'TV & Film'),
Genre(id: '127', name: 'Technology'),
Genre(id: '135', name: 'True Crime'),
Genre(id: '100', name: 'Arts'),
Genre(id: '93', name: 'Business'),
Genre(id: '67', name: 'Comedy'),
Genre(id: '111', name: 'Education'),
Genre(id: '168', name: 'Fiction'),
Genre(id: '117', name: 'Government'),
Genre(id: '88', name: 'Health & Fitness'),
Genre(id: '125', name: 'History'),
Genre(id: '132', name: 'Kids & Family'),
Genre(id: '82', name: 'Leisure'),
Genre(id: '134', name: 'Music'),
Genre(id: '99', name: 'News'),
Genre(id: '69', name: 'Religion & Spirituality'),
Genre(id: '107', name: 'Science'),
Genre(id: '122', name: 'Society & Culture'),
Genre(id: '77', name: 'Sports'),
];

View File

@ -0,0 +1,76 @@
import 'package:json_annotation/json_annotation.dart';
import 'searchpodcast.dart';
part 'search_top_podcast.g.dart';
@JsonSerializable()
class SearchTopPodcast<T> {
@_ConvertT()
final List<T> podcasts;
final int id;
final int page;
final int total;
@JsonKey(name: 'has_next')
final bool hasNext;
SearchTopPodcast(
{this.podcasts, this.id, this.total, this.hasNext, this.page});
factory SearchTopPodcast.fromJson(Map<String, dynamic> json) =>
_$SearchTopPodcastFromJson<T>(json);
Map<String, dynamic> toJson() => _$SearchTopPodcastToJson(this);
}
class _ConvertT<T> implements JsonConverter<T, Object> {
const _ConvertT();
@override
T fromJson(Object json) {
return OnlineTopPodcast.fromJson(json) as T;
}
@override
Object toJson(T object) {
return object;
}
}
@JsonSerializable()
class OnlineTopPodcast {
@JsonKey(name: 'earliest_pub_date_ms')
final int earliestPubDate;
@JsonKey(name: 'title')
final String title;
final String rss;
@JsonKey(name: 'latest_pub_date_ms')
final int latestPubDate;
@JsonKey(name: 'description')
final String description;
@JsonKey(name: 'total_episodes')
final int count;
final String image;
@JsonKey(name: 'publisher')
final String publisher;
final String id;
OnlineTopPodcast(
{this.earliestPubDate,
this.title,
this.count,
this.description,
this.image,
this.latestPubDate,
this.rss,
this.publisher,
this.id});
factory OnlineTopPodcast.fromJson(Map<String, dynamic> json) =>
_$OnlineTopPodcastFromJson(json);
Map<String, dynamic> toJson() => _$OnlineTopPodcastToJson(this);
OnlinePodcast get toOnlinePodcast => OnlinePodcast(
earliestPubDate: earliestPubDate,
title: title,
count: count,
description: description,
image: image,
latestPubDate: latestPubDate,
rss: rss,
publisher: publisher,
id: id);
}

View File

@ -0,0 +1,55 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'search_top_podcast.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SearchTopPodcast<T> _$SearchTopPodcastFromJson<T>(Map<String, dynamic> json) {
return SearchTopPodcast<T>(
podcasts:
(json['podcasts'] as List)?.map(_ConvertT<T>().fromJson)?.toList(),
id: json['id'] as int,
total: json['total'] as int,
hasNext: json['has_next'] as bool,
page: json['page'] as int,
);
}
Map<String, dynamic> _$SearchTopPodcastToJson<T>(
SearchTopPodcast<T> instance) =>
<String, dynamic>{
'podcasts': instance.podcasts?.map(_ConvertT<T>().toJson)?.toList(),
'id': instance.id,
'page': instance.page,
'total': instance.total,
'has_next': instance.hasNext,
};
OnlineTopPodcast _$OnlineTopPodcastFromJson(Map<String, dynamic> json) {
return OnlineTopPodcast(
earliestPubDate: json['earliest_pub_date_ms'] as int,
title: json['title'] as String,
count: json['total_episodes'] as int,
description: json['description'] as String,
image: json['image'] as String,
latestPubDate: json['latest_pub_date_ms'] as int,
rss: json['rss'] as String,
publisher: json['publisher'] as String,
id: json['id'] as String,
);
}
Map<String, dynamic> _$OnlineTopPodcastToJson(OnlineTopPodcast instance) =>
<String, dynamic>{
'earliest_pub_date_ms': instance.earliestPubDate,
'title': instance.title,
'rss': instance.rss,
'latest_pub_date_ms': instance.latestPubDate,
'description': instance.description,
'total_episodes': instance.count,
'image': instance.image,
'publisher': instance.publisher,
'id': instance.id,
};