From 0040513380a230874ae7ba1f975da78deb102a8e Mon Sep 17 00:00:00 2001 From: stonegate Date: Thu, 23 Apr 2020 02:10:57 +0800 Subject: [PATCH] Skip at beginning Add new episode to playlist at one click --- lib/class/audiostate.dart | 94 ++- lib/class/episodebrief.dart | 16 +- lib/class/refresh_podcast.dart | 22 +- lib/class/settingstate.dart | 25 +- lib/episodes/episodedetail.dart | 31 +- lib/home/appbar/addpodcast.dart | 15 +- lib/home/appbar/importompl.dart | 7 +- lib/home/appbar/popupmenu.dart | 114 +--- lib/home/home_groups.dart | 168 ++--- lib/home/nested_home.dart | 49 +- lib/intro_slider/app_intro.dart | 10 +- lib/local_storage/key_value_storage.dart | 54 +- lib/local_storage/sqflite_localpodcast.dart | 342 +++++++--- lib/podcasts/podcastdetail.dart | 25 +- lib/podcasts/podcastgroup.dart | 161 ++++- lib/podcasts/podcastmanage.dart | 20 +- lib/settings/play_setting.dart | 89 +++ lib/settings/settting.dart | 28 +- lib/settings/storage.dart | 2 +- lib/settings/syncing.dart | 4 +- lib/util/day_night_switch.dart | 464 -------------- lib/util/duraiton_picker.dart | 663 ++++++++++++++++++++ 22 files changed, 1506 insertions(+), 897 deletions(-) create mode 100644 lib/settings/play_setting.dart delete mode 100644 lib/util/day_night_switch.dart create mode 100644 lib/util/duraiton_picker.dart diff --git a/lib/class/audiostate.dart b/lib/class/audiostate.dart index 7cbabdc..8b64c38 100644 --- a/lib/class/audiostate.dart +++ b/lib/class/audiostate.dart @@ -111,7 +111,10 @@ enum SleepTimerMode { endOfEpisode, timer, undefined } class AudioPlayerNotifier extends ChangeNotifier { DBHelper dbHelper = DBHelper(); - KeyValueStorage storage = KeyValueStorage('audioposition'); + KeyValueStorage positionStorage = KeyValueStorage(audioPositionKey); + KeyValueStorage autoPlayStorage = KeyValueStorage(autoPlayKey); + KeyValueStorage autoAddStorage = KeyValueStorage(autoAddKey); + EpisodeBrief _episode; Playlist _queue = Playlist(); bool _queueUpdate = false; @@ -130,7 +133,10 @@ class AudioPlayerNotifier extends ChangeNotifier { bool _startSleepTimer = false; double _switchValue = 0; SleepTimerMode _sleepTimerMode = SleepTimerMode.undefined; + //set autoplay episode in playlist bool _autoPlay = true; + //Set auto add episodes to playlist + bool _autoAdd = false; DateTime _current; int _currentPosition; double _currentSpeed = 1; @@ -151,6 +157,7 @@ class AudioPlayerNotifier extends ChangeNotifier { bool get startSleepTimer => _startSleepTimer; SleepTimerMode get sleepTimerMode => _sleepTimerMode; bool get autoPlay => _autoPlay; + bool get autoAdd => _autoAdd; int get timeLeft => _timeLeft; double get switchValue => _switchValue; double get currentSpeed => _currentSpeed; @@ -163,6 +170,31 @@ class AudioPlayerNotifier extends ChangeNotifier { set autoPlaySwitch(bool boo) { _autoPlay = boo; notifyListeners(); + _setAutoPlay(); + } + + Future _getAutoPlay() async { + int i = await autoPlayStorage.getInt(); + _autoAdd = i == 0 ? true : false; + } + + Future _setAutoPlay() async { + await autoPlayStorage.saveInt(_autoPlay ? 1 : 0); + } + + set autoAddSwitch(bool boo) { + _autoAdd = boo; + notifyListeners(); + _setAutoAdd(); + } + + Future _getAutoAdd() async { + int i = await autoAddStorage.getInt(); + _autoAdd = i == 0 ? false : true; + } + + Future _setAutoAdd() async { + await autoAddStorage.saveInt(_autoAdd ? 1 : 0); } set setSleepTimerMode(SleepTimerMode timer) { @@ -181,7 +213,10 @@ class AudioPlayerNotifier extends ChangeNotifier { loadPlaylist() async { await _queue.getPlaylist(); - _lastPostion = await storage.getInt(); + await _getAutoPlay(); + // await _getAutoAdd(); + // await addNewEpisode('all'); + _lastPostion = await positionStorage.getInt(); if (_lastPostion > 0 && _queue.playlist.length > 0) { final EpisodeBrief episode = _queue.playlist.first; final int duration = episode.duration * 1000; @@ -190,6 +225,8 @@ class AudioPlayerNotifier extends ChangeNotifier { episode.title, episode.enclosureUrl, _lastPostion / 1000, seekValue); await dbHelper.saveHistory(history); } + KeyValueStorage lastWorkStorage = KeyValueStorage(lastWorkKey); + await lastWorkStorage.saveInt(0); } episodeLoad(EpisodeBrief episode, {int startPosition = 0}) async { @@ -268,7 +305,7 @@ class AudioPlayerNotifier extends ChangeNotifier { if (event.length == 0 || _stopOnComplete == true) { _queue.delFromPlaylist(_episode); _lastPostion = 0; - storage.saveInt(_lastPostion); + positionStorage.saveInt(_lastPostion); final PlayHistory history = PlayHistory( _episode.title, _episode.enclosureUrl, @@ -330,7 +367,7 @@ class AudioPlayerNotifier extends ChangeNotifier { if (_backgroundAudioPosition > 0) { _lastPostion = _backgroundAudioPosition; - storage.saveInt(_lastPostion); + positionStorage.saveInt(_lastPostion); } notifyListeners(); } @@ -358,11 +395,13 @@ class AudioPlayerNotifier extends ChangeNotifier { } addToPlaylist(EpisodeBrief episode) async { - if (_playerRunning) { - await AudioService.addQueueItem(episode.toMediaItem()); + if (!_queue.playlist.contains(episode)) { + if (_playerRunning) { + await AudioService.addQueueItem(episode.toMediaItem()); + } + await _queue.addToPlayList(episode); + notifyListeners(); } - await _queue.addToPlayList(episode); - notifyListeners(); } addToPlaylistAt(EpisodeBrief episode, int index) async { @@ -374,6 +413,22 @@ class AudioPlayerNotifier extends ChangeNotifier { notifyListeners(); } + addNewEpisode(List group) async { + List newEpisodes = []; + if (group.first == 'All') + newEpisodes = await dbHelper.getRecentNewRssItem(); + else + newEpisodes = await dbHelper.getGroupNewRssItem(group); + if (newEpisodes.length > 0) + await Future.forEach(newEpisodes, (episode) async { + await addToPlaylist(episode); + }); + if (group.first == 'All') + await dbHelper.removeAllNewMark(); + else + await dbHelper.removeGroupNewMark(group); + } + updateMediaItem(EpisodeBrief episode) async { int index = _queue.playlist .indexWhere((item) => item.enclosureUrl == episode.enclosureUrl); @@ -402,7 +457,7 @@ class AudioPlayerNotifier extends ChangeNotifier { } else { await addToPlaylistAt(episode, 0); _lastPostion = 0; - storage.saveInt(_lastPostion); + positionStorage.saveInt(_lastPostion); } notifyListeners(); } @@ -596,12 +651,12 @@ class AudioPlayerTask extends BackgroundAudioTask { _skipState = null; onStop(); } else { - // AudioServiceBackground.setQueue(_queue); - AudioServiceBackground.setMediaItem(mediaItem); - await _audioPlayer.setUrl(mediaItem.id); - Duration duration = await _audioPlayer.durationFuture ?? Duration.zero; - AudioServiceBackground.setMediaItem( - mediaItem.copyWith(duration: duration.inMilliseconds)); + AudioServiceBackground.setQueue(_queue); + AudioServiceBackground.setMediaItem(mediaItem); + await _audioPlayer.setUrl(mediaItem.id); + Duration duration = await _audioPlayer.durationFuture ?? Duration.zero; + AudioServiceBackground.setMediaItem( + mediaItem.copyWith(duration: duration.inMilliseconds)); _skipState = null; // Resume playback if we were playing // if (_playing) { @@ -623,8 +678,17 @@ class AudioPlayerTask extends BackgroundAudioTask { AudioServiceBackground.setMediaItem( mediaItem.copyWith(duration: duration.inMilliseconds)); } + // if (mediaItem.extras['skip'] > 0) { + // await _audioPlayer.setClip( + // start: Duration(seconds: 60)); + // print(mediaItem.extras['skip']); + // print('set clip success'); + // } _playing = true; _audioPlayer.play(); + if (mediaItem.extras['skip'] > 0) { + _audioPlayer.seek(Duration(seconds: mediaItem.extras['skip'])); + } } } diff --git a/lib/class/episodebrief.dart b/lib/class/episodebrief.dart index ebc5f81..e7b52e7 100644 --- a/lib/class/episodebrief.dart +++ b/lib/class/episodebrief.dart @@ -16,6 +16,7 @@ class EpisodeBrief { final String imagePath; final String mediaId; final int isNew; + final int skipSeconds; EpisodeBrief( this.title, this.enclosureUrl, @@ -29,10 +30,11 @@ class EpisodeBrief { this.explicit, this.imagePath, this.mediaId, - this.isNew); + this.isNew, + this.skipSeconds); String dateToString() { - DateTime date = DateTime.fromMillisecondsSinceEpoch(pubDate, isUtc: true); + DateTime date = DateTime.fromMillisecondsSinceEpoch(pubDate,isUtc: true); var diffrence = DateTime.now().toUtc().difference(date); if (diffrence.inHours < 1) { return '1 hour ago'; @@ -43,18 +45,18 @@ class EpisodeBrief { } else if (diffrence.inDays < 7) { return '${diffrence.inDays} days ago'; } else { - return DateFormat.yMMMd() - .format(DateTime.fromMillisecondsSinceEpoch(pubDate, isUtc: true).toLocal()); + return DateFormat.yMMMd().format( + DateTime.fromMillisecondsSinceEpoch(pubDate, isUtc: true).toLocal()); } } - + MediaItem toMediaItem() { return MediaItem( id: mediaId, title: title, artist: feedTitle, album: feedTitle, - artUri: 'file://$imagePath'); + artUri: 'file://$imagePath', + extras: {'skip': skipSeconds}); } - } diff --git a/lib/class/refresh_podcast.dart b/lib/class/refresh_podcast.dart index f6737e4..baf22bc 100644 --- a/lib/class/refresh_podcast.dart +++ b/lib/class/refresh_podcast.dart @@ -67,22 +67,18 @@ class RefreshWorker extends ChangeNotifier { } Future refreshIsolateEntryPoint(SendPort sendPort) async { + KeyValueStorage refreshstorage = KeyValueStorage(refreshdateKey); + await refreshstorage.saveInt(DateTime.now().millisecondsSinceEpoch); var dbHelper = DBHelper(); List podcastList = await dbHelper.getPodcastLocalAll(); - int i = 0; - await Future.forEach(podcastList, (podcastLocal) async { + //int i = 0; + await Future.forEach(podcastList, (podcastLocal) async { sendPort.send([podcastLocal.title, 1]); - try { - i += await dbHelper.updatePodcastRss(podcastLocal); - print('Refresh ' + podcastLocal.title); - } catch (e) { - sendPort.send([podcastLocal.title, 2]); - await Future.delayed(Duration(seconds: 1)); - } + await dbHelper.updatePodcastRss(podcastLocal); + print('Refresh ' + podcastLocal.title); }); - KeyValueStorage refreshstorage = KeyValueStorage('refreshdate'); - await refreshstorage.saveInt(DateTime.now().millisecondsSinceEpoch); - KeyValueStorage refreshcountstorage = KeyValueStorage('refreshcount'); - await refreshcountstorage.saveInt(i); + + // KeyValueStorage refreshcountstorage = KeyValueStorage('refreshcount'); +// await refreshcountstorage.saveInt(i); sendPort.send("done"); } diff --git a/lib/class/settingstate.dart b/lib/class/settingstate.dart index 59f4723..c99d7dc 100644 --- a/lib/class/settingstate.dart +++ b/lib/class/settingstate.dart @@ -10,25 +10,30 @@ void callbackDispatcher() { Workmanager.executeTask((task, inputData) async { var dbHelper = DBHelper(); List podcastList = await dbHelper.getPodcastLocalAll(); - await Future.forEach(podcastList, (podcastLocal) async { - await dbHelper.updatePodcastRss(podcastLocal); + //lastWork is a indicator for if the app was opened since last backgroundwork + //if the app wes opend,then the old marked new episode would be marked not new. + KeyValueStorage lastWorkStorage = KeyValueStorage(lastWorkKey); + int lastWork = await lastWorkStorage.getInt(); + await Future.forEach(podcastList, (podcastLocal) async { + await dbHelper.updatePodcastRss(podcastLocal, removeMark: lastWork); print('Refresh ' + podcastLocal.title); }); - KeyValueStorage refreshstorage = KeyValueStorage('refreshdate'); + await lastWorkStorage.saveInt(1); + KeyValueStorage refreshstorage = KeyValueStorage(refreshdateKey); await refreshstorage.saveInt(DateTime.now().millisecondsSinceEpoch); return Future.value(true); }); } class SettingState extends ChangeNotifier { - KeyValueStorage themeStorage = KeyValueStorage('themes'); - KeyValueStorage accentStorage = KeyValueStorage('accents'); - KeyValueStorage autoupdateStorage = KeyValueStorage('autoupdate'); - KeyValueStorage intervalStorage = KeyValueStorage('updateInterval'); + KeyValueStorage themeStorage = KeyValueStorage(themesKey); + KeyValueStorage accentStorage = KeyValueStorage(accentsKey); + KeyValueStorage autoupdateStorage = KeyValueStorage(autoAddKey); + KeyValueStorage intervalStorage = KeyValueStorage(updateIntervalKey); KeyValueStorage downloadUsingDataStorage = - KeyValueStorage('downloadUsingData'); - KeyValueStorage introStorage = KeyValueStorage('intro'); - KeyValueStorage realDarkStorage = KeyValueStorage('realDark'); + KeyValueStorage(downloadUsingDataKey); + KeyValueStorage introStorage = KeyValueStorage(introKey); + KeyValueStorage realDarkStorage = KeyValueStorage(realDarkKey); Future initData() async { await _getTheme(); diff --git a/lib/episodes/episodedetail.dart b/lib/episodes/episodedetail.dart index 1760579..10b52fc 100644 --- a/lib/episodes/episodedetail.dart +++ b/lib/episodes/episodedetail.dart @@ -9,7 +9,6 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:intl/intl.dart'; import 'package:tuple/tuple.dart'; -import 'package:audio_service/audio_service.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -217,7 +216,7 @@ class _EpisodeDetailState extends State { data: _description, linkStyle: TextStyle( color: Theme.of(context).accentColor, - decoration: TextDecoration.underline, + // decoration: TextDecoration.underline, textBaseline: TextBaseline.ideographic), onLinkTap: (url) { _launchUrl(url); @@ -244,8 +243,8 @@ class _EpisodeDetailState extends State { linkStyle: TextStyle( color: Theme.of(context).accentColor, - decoration: - TextDecoration.underline, + // decoration: + // TextDecoration.underline, ), ), ) @@ -539,12 +538,18 @@ class _MenuBarState extends State { : Center(); }), Spacer(), - Selector>( - selector: (_, audio) => Tuple2(audio.episode, audio.audioState), + Selector>( + selector: (_, audio) => Tuple2(audio.episode, audio.playerRunning), builder: (_, data, __) { - return (widget.episodeItem.title != data.item1?.title) - ? Material( + return (widget.episodeItem.title == data.item1?.title && + data.item2) + ? Container( + padding: EdgeInsets.only(right: 30), + child: SizedBox( + width: 20, + height: 15, + child: WaveLoader(color: context.accentColor))) + : Material( color: Colors.transparent, child: InkWell( onTap: () { @@ -570,13 +575,7 @@ class _MenuBarState extends State { ), ), ), - ) - : Container( - padding: EdgeInsets.only(right: 30), - child: SizedBox( - width: 20, - height: 15, - child: WaveLoader(color: context.accentColor))); + ); }, ), ], diff --git a/lib/home/appbar/addpodcast.dart b/lib/home/appbar/addpodcast.dart index 0dd8760..09b7df7 100644 --- a/lib/home/appbar/addpodcast.dart +++ b/lib/home/appbar/addpodcast.dart @@ -8,21 +8,10 @@ import 'package:color_thief_flutter/color_thief_flutter.dart'; import 'package:dio/dio.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:image/image.dart' as img; import 'package:fluttertoast/fluttertoast.dart'; -import 'package:tsacdop/class/audiostate.dart'; -import 'package:tsacdop/class/download_state.dart'; -import 'package:tsacdop/class/fireside_data.dart'; -import 'package:tsacdop/class/refresh_podcast.dart'; -import 'package:uuid/uuid.dart'; -import '../../class/importompl.dart'; -import '../../class/podcast_group.dart'; import '../../class/searchpodcast.dart'; -import '../../class/podcastlocal.dart'; import '../../class/subscribe_podcast.dart'; -import '../../local_storage/sqflite_localpodcast.dart'; import '../../home/appbar/popupmenu.dart'; import '../../util/context_extension.dart'; import '../../webfeed/webfeed.dart'; @@ -479,6 +468,10 @@ class _SearchResultState extends State onPressed: () { savePodcast(widget.onlinePodcast); setState(() => _issubscribe = true); + Fluttertoast.showToast( + msg: 'Podcast subscribed', + gravity: ToastGravity.TOP, + ); }) : OutlineButton( color: context.accentColor.withOpacity(0.8), diff --git a/lib/home/appbar/importompl.dart b/lib/home/appbar/importompl.dart index 40b8299..c8895f0 100644 --- a/lib/home/appbar/importompl.dart +++ b/lib/home/appbar/importompl.dart @@ -55,10 +55,13 @@ class Import extends StatelessWidget { Consumer( builder: (context, refreshWorker, child) { RefreshItem item = refreshWorker.currentRefreshItem; - if (refreshWorker.complete) groupList.updateGroups(); + if (refreshWorker.complete) { + groupList.updateGroups(); + // audio.addNewEpisode('all'); + } switch (item.refreshState) { case RefreshState.fetch: - return importColumn("Fetch data ${item.title}", context); + return importColumn("Update ${item.title}", context); case RefreshState.error: return importColumn("Update error ${item.title}", context); default: diff --git a/lib/home/appbar/popupmenu.dart b/lib/home/appbar/popupmenu.dart index 24a135b..fd6400f 100644 --- a/lib/home/appbar/popupmenu.dart +++ b/lib/home/appbar/popupmenu.dart @@ -2,30 +2,19 @@ import 'dart:io'; import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:dio/dio.dart'; import 'package:provider/provider.dart'; -import 'package:tsacdop/class/fireside_data.dart'; import 'package:tsacdop/class/refresh_podcast.dart'; import 'package:tsacdop/class/subscribe_podcast.dart'; import 'package:tsacdop/local_storage/key_value_storage.dart'; import 'package:xml/xml.dart' as xml; import 'package:file_picker/file_picker.dart'; import 'package:flutter/services.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:color_thief_flutter/color_thief_flutter.dart'; -import 'package:image/image.dart' as img; -import 'package:uuid/uuid.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:line_icons/line_icons.dart'; import 'package:intl/intl.dart'; -import 'package:tsacdop/class/podcast_group.dart'; import 'package:tsacdop/settings/settting.dart'; import 'about.dart'; -import 'package:tsacdop/class/podcastlocal.dart'; -import 'package:tsacdop/local_storage/sqflite_localpodcast.dart'; -import 'package:tsacdop/class/importompl.dart'; -import 'package:tsacdop/webfeed/webfeed.dart'; class OmplOutline { final String text; @@ -47,14 +36,6 @@ class PopupMenu extends StatefulWidget { } class _PopupMenuState extends State { - Future _getColor(File file) async { - final imageProvider = FileImage(file); - var colorImage = await getImageFromProvider(imageProvider); - var color = await getColorFromImage(colorImage); - String primaryColor = color.toString(); - return primaryColor; - } - Future _getRefreshDate() async { int refreshDate; KeyValueStorage refreshstorage = KeyValueStorage('refreshdate'); @@ -68,14 +49,16 @@ class _PopupMenuState extends State { } DateTime date = DateTime.fromMillisecondsSinceEpoch(refreshDate); var diffrence = DateTime.now().difference(date); - if (diffrence.inMinutes < 10) { - return 'Just now'; + if (diffrence.inSeconds < 60) { + return '${diffrence.inSeconds} seconds ago'; + } else if (diffrence.inMinutes < 10) { + return '${diffrence.inMinutes} minutes ago'; } else if (diffrence.inHours < 1) { - return '1 hour ago'; - } else if (diffrence.inHours < 24) { + return 'an hour ago'; + } else if (diffrence.inHours <= 24) { return '${diffrence.inHours} hours ago'; } else if (diffrence.inDays < 7) { - return '${diffrence.inDays} day ago'; + return '${diffrence.inDays} days ago'; } else { return DateFormat.yMMMd() .format(DateTime.fromMillisecondsSinceEpoch(refreshDate)); @@ -84,8 +67,6 @@ class _PopupMenuState extends State { @override Widget build(BuildContext context) { -// ImportOmpl importOmpl = Provider.of(context, listen: false); -// GroupList groupList = Provider.of(context, listen: false); var refreshWorker = Provider.of(context, listen: false); var subscribeWorker = Provider.of(context, listen: false); // _refreshAll() async { @@ -105,87 +86,6 @@ class _PopupMenuState extends State { // importOmpl.importState = ImportState.complete; // groupList.updateGroups(); // } -// -// saveOmpl(String rss) async { -// var dbHelper = DBHelper(); -// importOmpl.importState = ImportState.import; -// BaseOptions options = new BaseOptions( -// connectTimeout: 20000, -// receiveTimeout: 20000, -// ); -// Response response = await Dio(options).get(rss); -// if (response.statusCode == 200) { -// var _p = RssFeed.parse(response.data); -// -// var dir = await getApplicationDocumentsDirectory(); -// -// String _realUrl = -// response.redirects.isEmpty ? rss : response.realUri.toString(); -// -// print(_realUrl); -// bool _checkUrl = await dbHelper.checkPodcast(_realUrl); -// -// if (_checkUrl) { -// Response> imageResponse = await Dio().get>( -// _p.itunes.image.href, -// options: Options(responseType: ResponseType.bytes)); -// img.Image image = img.decodeImage(imageResponse.data); -// img.Image thumbnail = img.copyResize(image, width: 300); -// String _uuid = Uuid().v4(); -// File("${dir.path}/$_uuid.png") -// ..writeAsBytesSync(img.encodePng(thumbnail)); -// -// String _imagePath = "${dir.path}/$_uuid.png"; -// String _primaryColor = -// await _getColor(File("${dir.path}/$_uuid.png")); -// String _author = _p.itunes.author ?? _p.author ?? ''; -// String _provider = _p.generator ?? ''; -// String _link = _p.link ?? ''; -// PodcastLocal podcastLocal = PodcastLocal( -// _p.title, -// _p.itunes.image.href, -// _realUrl, -// _primaryColor, -// _author, -// _uuid, -// _imagePath, -// _provider, -// _link, -// description: _p.description); -// -// await groupList.subscribe(podcastLocal); -// -// if (_provider.contains('fireside')) { -// FiresideData data = FiresideData(_uuid, _link); -// await data.fatchData(); -// } -// -// importOmpl.importState = ImportState.parse; -// -// await dbHelper.savePodcastRss(_p, _uuid); -// groupList.updatePodcast(podcastLocal.id); -// importOmpl.importState = ImportState.complete; -// } else { -// importOmpl.importState = ImportState.error; -// -// Fluttertoast.showToast( -// msg: 'Podcast Subscribed Already', -// gravity: ToastGravity.TOP, -// ); -// await Future.delayed(Duration(seconds: 5)); -// importOmpl.importState = ImportState.stop; -// } -// } else { -// importOmpl.importState = ImportState.error; -// -// Fluttertoast.showToast( -// msg: 'Network error, Subscribe failed', -// gravity: ToastGravity.TOP, -// ); -// await Future.delayed(Duration(seconds: 5)); -// importOmpl.importState = ImportState.stop; -// } -// } // void _saveOmpl(String path) async { File file = File(path); diff --git a/lib/home/home_groups.dart b/lib/home/home_groups.dart index f88ea80..e70ef77 100644 --- a/lib/home/home_groups.dart +++ b/lib/home/home_groups.dart @@ -28,6 +28,13 @@ class ScrollPodcasts extends StatefulWidget { class _ScrollPodcastsState extends State { int _groupIndex; + + Future getPodcastCounts(String id) async { + var dbHelper = DBHelper(); + List list = await dbHelper.getPodcastCounts(id); + return list.first; + } + @override void initState() { super.initState(); @@ -260,20 +267,29 @@ class _ScrollPodcastsState extends State { child: Image.file(File( "${podcastLocal.imagePath}")), ), - podcastLocal.upateCount > 0 - ? Container( - alignment: Alignment.center, - height: 10, - width: 40, - color: Colors.black54, - child: Text('New', - style: TextStyle( - color: Colors.red, - fontSize: 8, - fontStyle: FontStyle - .italic)), - ) - : Center(), + FutureBuilder( + future: getPodcastCounts( + podcastLocal.id), + initialData: 0, + builder: (context, snapshot) { + return snapshot.data > 0 + ? Container( + alignment: + Alignment.center, + height: 10, + width: 40, + color: Colors.black54, + child: Text('New', + style: TextStyle( + color: + Colors.red, + fontSize: 8, + fontStyle: + FontStyle + .italic)), + ) + : Center(); + }), ], ), ), @@ -344,14 +360,16 @@ class _PodcastPreviewState extends State { builder: (context, snapshot) { if (snapshot.hasError) { print(snapshot.error); - Center(child: CircularProgressIndicator()); + Center(); } return (snapshot.hasData) ? ShowEpisode( episodes: snapshot.data, podcastLocal: widget.podcastLocal, ) - : Center(child: CircularProgressIndicator()); + : Container( + padding: EdgeInsets.all(5.0), + ); }, ), ), @@ -409,75 +427,75 @@ class ShowEpisode extends StatelessWidget { return '${(seconds ~/ 60)}:${(seconds.truncate() % 60).toString().padLeft(2, '0')}'; } - @override - Widget build(BuildContext context) { - double _width = MediaQuery.of(context).size.width; - Offset offset; - _showPopupMenu(Offset offset, EpisodeBrief episode, BuildContext context, - bool isPlaying, bool isInPlaylist) async { - var audio = Provider.of(context, listen: false); - double left = offset.dx; - double top = offset.dy; - await showMenu( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(10))), - context: context, - position: RelativeRect.fromLTRB(left, top, _width - left, 0), - items: >[ - PopupMenuItem( - value: 0, + _showPopupMenu(Offset offset, EpisodeBrief episode, BuildContext context, + bool isPlaying, bool isInPlaylist) async { + var audio = Provider.of(context, listen: false); + double left = offset.dx; + double top = offset.dy; + await showMenu( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10))), + context: context, + position: RelativeRect.fromLTRB(left, top, context.width - left, 0), + items: >[ + PopupMenuItem( + value: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Icon( + LineIcons.play_circle_solid, + color: Theme.of(context).accentColor, + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + ), + !isPlaying ? Text('Play') : Text('Playing'), + ], + ), + ), + PopupMenuItem( + value: 1, child: Row( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, children: [ Icon( - LineIcons.play_circle_solid, - color: Theme.of(context).accentColor, + LineIcons.clock_solid, + color: Colors.red, ), Padding( padding: EdgeInsets.symmetric(horizontal: 2), ), - !isPlaying ? Text('Play') : Text('Playing'), + !isInPlaylist ? Text('Later') : Text('Remove') ], - ), - ), - PopupMenuItem( - value: 1, - child: Row( - children: [ - Icon( - LineIcons.clock_solid, - color: Colors.red, - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 2), - ), - !isInPlaylist ? Text('Later') : Text('Remove') - ], - )), - ], - elevation: 5.0, - ).then((value) { - if (value == 0) { - if (!isPlaying) audio.episodeLoad(episode); - } else if (value == 1) { - if (!isInPlaylist) { - audio.addToPlaylist(episode); - Fluttertoast.showToast( - msg: 'Added to playlist', - gravity: ToastGravity.BOTTOM, - ); - } else { - audio.delFromPlaylist(episode); - Fluttertoast.showToast( - msg: 'Removed from playlist', - gravity: ToastGravity.BOTTOM, - ); - } + )), + ], + elevation: 5.0, + ).then((value) { + if (value == 0) { + if (!isPlaying) audio.episodeLoad(episode); + } else if (value == 1) { + if (!isInPlaylist) { + audio.addToPlaylist(episode); + Fluttertoast.showToast( + msg: 'Added to playlist', + gravity: ToastGravity.BOTTOM, + ); + } else { + audio.delFromPlaylist(episode); + Fluttertoast.showToast( + msg: 'Removed from playlist', + gravity: ToastGravity.BOTTOM, + ); } - }); - } + } + }); + } + @override + Widget build(BuildContext context) { + double _width = context.width; + Offset offset; return CustomScrollView( physics: NeverScrollableScrollPhysics(), primary: false, diff --git a/lib/home/nested_home.dart b/lib/home/nested_home.dart index 964ba8a..e622177 100644 --- a/lib/home/nested_home.dart +++ b/lib/home/nested_home.dart @@ -9,6 +9,7 @@ import 'package:tsacdop/home/playlist.dart'; import 'package:tuple/tuple.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:line_icons/line_icons.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:tsacdop/class/audiostate.dart'; import 'package:tsacdop/class/episodebrief.dart'; @@ -332,7 +333,7 @@ class _RecentUpdate extends StatefulWidget { } class _RecentUpdateState extends State<_RecentUpdate> - with AutomaticKeepAliveClientMixin , SingleTickerProviderStateMixin{ + with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { Future> _getRssItem(int top, List group) async { var dbHelper = DBHelper(); List episodes; @@ -343,6 +344,16 @@ class _RecentUpdateState extends State<_RecentUpdate> return episodes; } + Future _getUpdateCounts(List group) async { + var dbHelper = DBHelper(); + List episodes = []; + if (group.first == 'All') + episodes = await dbHelper.getRecentNewRssItem(); + else + episodes = await dbHelper.getGroupNewRssItem(group); + return episodes.length; + } + _loadMoreEpisode() async { if (mounted) setState(() => _loadMore = true); await Future.delayed(Duration(seconds: 3)); @@ -370,6 +381,7 @@ class _RecentUpdateState extends State<_RecentUpdate> @override Widget build(BuildContext context) { super.build(context); + var audio = Provider.of(context, listen: false); return FutureBuilder>( future: _getRssItem(_top, _group), builder: (context, snapshot) { @@ -450,9 +462,38 @@ class _RecentUpdateState extends State<_RecentUpdate> ), ), Spacer(), + FutureBuilder( + future: _getUpdateCounts(_group), + initialData: 0, + builder: (context, snapshot) { + return snapshot.data > 0 + ? Material( + color: Colors.transparent, + child: IconButton( + tooltip: + 'Add new episodes to playlist', + icon: Icon( + LineIcons.tasks_solid), + onPressed: () async { + await audio + .addNewEpisode(_group); + if (mounted) + setState(() {}); + Fluttertoast.showToast( + msg: _groupName == 'All' + ? '${snapshot.data} episode added to playlist' + : '${snapshot.data} episode in $_groupName added to playlist', + gravity: + ToastGravity.BOTTOM, + ); + }), + ) + : Center(); + }), Material( color: Colors.transparent, child: IconButton( + tooltip: 'Change layout', padding: EdgeInsets.zero, onPressed: () { if (_layout == Layout.three) @@ -717,8 +758,8 @@ class _MyDownloadState extends State<_MyDownload> child: Row( children: [ Container( - padding: EdgeInsets.symmetric(horizontal: 20), - child: Text('Downloaded')), + padding: EdgeInsets.symmetric(horizontal: 20), + child: Text('Downloaded')), Spacer(), Material( color: Colors.transparent, @@ -777,4 +818,4 @@ class _MyDownloadState extends State<_MyDownload> @override bool get wantKeepAlive => true; -} \ No newline at end of file +} diff --git a/lib/intro_slider/app_intro.dart b/lib/intro_slider/app_intro.dart index 48f56c3..5332b23 100644 --- a/lib/intro_slider/app_intro.dart +++ b/lib/intro_slider/app_intro.dart @@ -199,7 +199,10 @@ class _SlideIntroState extends State { child: SizedBox( height: 40, width: 80, - child: Center(child: Text('Next')))) + child: Center( + child: Text('Next', + style: TextStyle( + color: Colors.black))))) : InkWell( borderRadius: BorderRadius.all(Radius.circular(20)), @@ -217,7 +220,10 @@ class _SlideIntroState extends State { child: SizedBox( height: 40, width: 80, - child: Center(child: Text('Done')))), + child: Center( + child: Text('Done', + style: TextStyle( + color: Colors.black))))), ), ), ], diff --git a/lib/local_storage/key_value_storage.dart b/lib/local_storage/key_value_storage.dart index 6ea8ef8..e49ecf9 100644 --- a/lib/local_storage/key_value_storage.dart +++ b/lib/local_storage/key_value_storage.dart @@ -3,10 +3,22 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:tsacdop/class/podcast_group.dart'; +const String autoPlayKey = 'autoPlay'; +const String autoAddKey = 'autoAdd'; +const String audioPositionKey = 'audioposition'; +const String lastWorkKey = 'lastWork'; +const String refreshdateKey = 'refreshdate'; +const String themesKey = 'themes'; +const String accentsKey = 'accents'; +const String autoUpdateKey = 'autoupdate'; +const String updateIntervalKey = 'updateInterval'; +const String downloadUsingDataKey = 'downloadUsingData'; +const String introKey = 'intro'; +const String realDarkKey = 'realDark'; + class KeyValueStorage { final String key; KeyValueStorage(this.key); - Future> getGroups() async { SharedPreferences prefs = await SharedPreferences.getInstance(); if (prefs.getString(key) == null) { @@ -15,15 +27,16 @@ class KeyValueStorage { key, json.encode({ 'groups': [home.toEntity().toJson()] - }));} - print(prefs.getString(key)); - return json - .decode(prefs.getString(key))['groups'] - .cast>() - .map(GroupEntity.fromJson) - .toList(growable: false); + })); } - + print(prefs.getString(key)); + return json + .decode(prefs.getString(key))['groups'] + .cast>() + .map(GroupEntity.fromJson) + .toList(growable: false); + } + Future saveGroup(List groupList) async { SharedPreferences prefs = await SharedPreferences.getInstance(); return prefs.setString( @@ -32,37 +45,40 @@ class KeyValueStorage { {'groups': groupList.map((group) => group.toJson()).toList()})); } - Future saveInt(int setting) async{ + Future saveInt(int setting) async { SharedPreferences prefs = await SharedPreferences.getInstance(); return prefs.setInt(key, setting); } - Future getInt() async{ + Future getInt() async { SharedPreferences prefs = await SharedPreferences.getInstance(); - if(prefs.getInt(key) == null) await prefs.setInt(key, 0); + if (prefs.getInt(key) == null) await prefs.setInt(key, 0); return prefs.getInt(key); } - Future saveStringList(List playList) async{ + Future saveStringList(List playList) async { SharedPreferences prefs = await SharedPreferences.getInstance(); return prefs.setStringList(key, playList); } - Future> getStringList() async{ + Future> getStringList() async { SharedPreferences prefs = await SharedPreferences.getInstance(); - if(prefs.getStringList(key) == null) {await prefs.setStringList(key, []);} + if (prefs.getStringList(key) == null) { + await prefs.setStringList(key, []); + } return prefs.getStringList(key); } - Future saveString(String string) async{ + Future saveString(String string) async { SharedPreferences prefs = await SharedPreferences.getInstance(); return prefs.setString(key, string); } - Future getString() async{ + Future getString() async { SharedPreferences prefs = await SharedPreferences.getInstance(); - if(prefs.getString(key) == null) {await prefs.setString(key, '');} + if (prefs.getString(key) == null) { + await prefs.setString(key, ''); + } return prefs.getString(key); } - } diff --git a/lib/local_storage/sqflite_localpodcast.dart b/lib/local_storage/sqflite_localpodcast.dart index 5ebb02f..ef05fc5 100644 --- a/lib/local_storage/sqflite_localpodcast.dart +++ b/lib/local_storage/sqflite_localpodcast.dart @@ -22,7 +22,7 @@ class DBHelper { var documentsDirectory = await getDatabasesPath(); String path = join(documentsDirectory, "podcasts.db"); Database theDb = await openDatabase(path, - version: 1, onCreate: _onCreate, onUpgrade: _onUpgrade); + version: 2, onCreate: _onCreate, onUpgrade: _onUpgrade); return theDb; } @@ -32,7 +32,7 @@ class DBHelper { imageUrl TEXT,rssUrl TEXT UNIQUE, primaryColor TEXT, author TEXT, description TEXT, add_date INTEGER, imagePath TEXT, provider TEXT, link TEXT, background_image TEXT DEFAULT '',hosts TEXT DEFAULT '',update_count INTEGER DEFAULT 0, - episode_count INTEGER DEFAULT 0)"""); + episode_count INTEGER DEFAULT 0, skip_seconds INTEGER DEFAULT 0)"""); await db .execute("""CREATE TABLE Episodes(id INTEGER PRIMARY KEY,title TEXT, enclosure_url TEXT UNIQUE, enclosure_length INTEGER, pubDate TEXT, @@ -50,9 +50,8 @@ class DBHelper { void _onUpgrade(Database db, int oldVersion, int newVersion) async { if (oldVersion == 1) { - await db.execute("ALTER TABLE Episodes ADD liked_date INTEGER DEFAULT 0"); - await db - .execute("ALTER TABLE PlayHistory ADD listen_time INTEGER DEFAULT 0"); + await db.execute( + "ALTER TABLE PodcastLocal ADD skip_seconds INTEGER DEFAULT 0"); } } @@ -111,6 +110,19 @@ class DBHelper { return [list.first['update_count'], list.first['episode_count']]; } + Future getSkipSeconds(String id) async { + var dbClient = await database; + List list = await dbClient + .rawQuery('SELECT skip_seconds FROM PodcastLocal WHERE id = ?', [id]); + return list.first['skip_seconds']; + } + + Future saveSkipSeconds(String id, int seconds) async { + var dbClient = await database; + return await dbClient.rawUpdate( + "UPDATE PodcastLocal SET skip_seconds = ? WHERE id = ?", [seconds, id]); + } + Future checkPodcast(String url) async { var dbClient = await database; List list = await dbClient @@ -298,7 +310,6 @@ class DBHelper { DateTime _parsePubDate(String pubDate) { if (pubDate == null) return DateTime.now(); - print(pubDate); DateTime date; RegExp yyyy = RegExp(r'[1-2][0-9]{3}'); RegExp hhmm = RegExp(r'[0-2][0-9]\:[0-5][0-9]'); @@ -338,12 +349,15 @@ class DBHelper { print(month); print(date.toString()); } else { - date = DateTime.now().toUtc(); + date = DateTime.now(); } } } } - return date.add(Duration(hours: timezoneInt)); + DateTime result = date + .add(Duration(hours: timezoneInt)) + .add(DateTime.now().timeZoneOffset); + return result; } int _getExplicit(bool b) { @@ -394,8 +408,8 @@ class DBHelper { final title = feed.items[i].itunes.title ?? feed.items[i].title; final length = feed.items[i]?.enclosure?.length; final pubDate = feed.items[i].pubDate; - print(pubDate); final date = _parsePubDate(pubDate); + print(date); final milliseconds = date.millisecondsSinceEpoch; final duration = feed.items[i].itunes.duration?.inSeconds ?? 0; final explicit = _getExplicit(feed.items[i].itunes.explicit); @@ -429,78 +443,82 @@ class DBHelper { return result; } - Future updatePodcastRss(PodcastLocal podcastLocal) async { - BaseOptions options = new BaseOptions( + Future updatePodcastRss(PodcastLocal podcastLocal, + {int removeMark = 0}) async { + BaseOptions options = BaseOptions( connectTimeout: 20000, receiveTimeout: 20000, ); - Response response = await Dio(options).get(podcastLocal.rssUrl); - if (response.statusCode == 200) { - var feed = RssFeed.parse(response.data); - String url, description; - feed.items.removeWhere((item) => item == null); - int result = feed.items.length; + try { + Response response = await Dio(options).get(podcastLocal.rssUrl); + if (response.statusCode == 200) { + var feed = RssFeed.parse(response.data); + String url, description; + feed.items.removeWhere((item) => item == null); + int result = feed.items.length; - var dbClient = await database; - int count = Sqflite.firstIntValue(await dbClient.rawQuery( - 'SELECT COUNT(*) FROM Episodes WHERE feed_id = ?', - [podcastLocal.id])); + var dbClient = await database; + int count = Sqflite.firstIntValue(await dbClient.rawQuery( + 'SELECT COUNT(*) FROM Episodes WHERE feed_id = ?', + [podcastLocal.id])); + if (removeMark == 0) + await dbClient.rawUpdate( + "UPDATE Episodes SET is_new = 0 WHERE feed_id = ?", + [podcastLocal.id]); + for (int i = 0; i < result; i++) { + print(feed.items[i].title); + description = _getDescription( + feed.items[i].content.value ?? '', + feed.items[i].description ?? '', + feed.items[i].itunes.summary ?? ''); - await dbClient.rawUpdate( - "UPDATE Episodes SET is_new = 0 WHERE feed_id = ?", - [podcastLocal.id]); + if (feed.items[i].enclosure?.url != null) { + _isXimalaya(feed.items[i].enclosure.url) + ? url = feed.items[i].enclosure.url.split('=').last + : url = feed.items[i].enclosure.url; + } - for (int i = 0; i < result; i++) { - print(feed.items[i].title); - description = _getDescription( - feed.items[i].content.value ?? '', - feed.items[i].description ?? '', - feed.items[i].itunes.summary ?? ''); + final title = feed.items[i].itunes.title ?? feed.items[i].title; + final length = feed.items[i]?.enclosure?.length ?? 0; + final pubDate = feed.items[i].pubDate; + final date = _parsePubDate(pubDate); + final milliseconds = date.millisecondsSinceEpoch; + final duration = feed.items[i].itunes.duration?.inSeconds ?? 0; + final explicit = _getExplicit(feed.items[i].itunes.explicit); - if (feed.items[i].enclosure?.url != null) { - _isXimalaya(feed.items[i].enclosure.url) - ? url = feed.items[i].enclosure.url.split('=').last - : url = feed.items[i].enclosure.url; - } - - final title = feed.items[i].itunes.title ?? feed.items[i].title; - final length = feed.items[i]?.enclosure?.length ?? 0; - final pubDate = feed.items[i].pubDate; - final date = _parsePubDate(pubDate); - final milliseconds = date.millisecondsSinceEpoch; - final duration = feed.items[i].itunes.duration?.inSeconds ?? 0; - final explicit = _getExplicit(feed.items[i].itunes.explicit); - - if (url != null) { - await dbClient.transaction((txn) async { - await txn.rawInsert( - """INSERT OR IGNORE INTO Episodes(title, enclosure_url, enclosure_length, pubDate, + if (url != null) { + await dbClient.transaction((txn) async { + await txn.rawInsert( + """INSERT OR IGNORE INTO Episodes(title, enclosure_url, enclosure_length, pubDate, description, feed_id, milliseconds, duration, explicit, media_id, is_new) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)""", - [ - title, - url, - length, - pubDate, - description, - podcastLocal.id, - milliseconds, - duration, - explicit, - url, - ]); - }); + [ + title, + url, + length, + pubDate, + description, + podcastLocal.id, + milliseconds, + duration, + explicit, + url, + ]); + }); + } } - } - int countUpdate = Sqflite.firstIntValue(await dbClient.rawQuery( - 'SELECT COUNT(*) FROM Episodes WHERE feed_id = ?', - [podcastLocal.id])); + int countUpdate = Sqflite.firstIntValue(await dbClient.rawQuery( + 'SELECT COUNT(*) FROM Episodes WHERE feed_id = ?', + [podcastLocal.id])); - await dbClient.rawUpdate( - """UPDATE PodcastLocal SET update_count = ?, episode_count = ? WHERE id = ?""", - [countUpdate - count, countUpdate, podcastLocal.id]); - return countUpdate - count; - } else { - throw ("network error"); + await dbClient.rawUpdate( + """UPDATE PodcastLocal SET update_count = ?, episode_count = ? WHERE id = ?""", + [countUpdate - count, countUpdate, podcastLocal.id]); + return countUpdate - count; + } + return 0; + } catch (e) { + print(e); + return 0; } } @@ -511,7 +529,7 @@ class DBHelper { List list = await dbClient .rawQuery("""SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feedTitle, E.duration, E.explicit, E.liked, - E.downloaded, P.primaryColor , E.media_id, E.is_new + E.downloaded, P.primaryColor , E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id WHERE P.id = ? ORDER BY E.milliseconds ASC LIMIT ?""", [id, i]); for (int x = 0; x < list.length; x++) { @@ -528,13 +546,14 @@ class DBHelper { list[x]['explicit'], list[x]['imagePath'], list[x]['media_id'], - list[x]['is_new'])); + list[x]['is_new'], + list[x]['skip_seconds'])); } } else { List list = await dbClient .rawQuery("""SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feedTitle, E.duration, E.explicit, E.liked, - E.downloaded, P.primaryColor , E.media_id, E.is_new + E.downloaded, P.primaryColor , E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id WHERE P.id = ? ORDER BY E.milliseconds DESC LIMIT ?""", [id, i]); for (int x = 0; x < list.length; x++) { @@ -551,21 +570,64 @@ class DBHelper { list[x]['explicit'], list[x]['imagePath'], list[x]['media_id'], - list[x]['is_new'])); + list[x]['is_new'], + list[x]['skip_seconds'])); } } return episodes; } + + Future> getNewEpisodes(String id) async { + var dbClient = await database; + List episodes = []; + List list; + if (id == 'all') + list = await dbClient.rawQuery( + """SELECT E.title, E.enclosure_url, E.enclosure_length, + E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, E.liked, + E.downloaded, P.primaryColor, E.media_id, E.is_new, P.skip_seconds + FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + WHERE E.is_new = 1 ORDER BY E.milliseconds ASC""", + ); + else + list = await dbClient.rawQuery( + """SELECT E.title, E.enclosure_url, E.enclosure_length, + E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, E.liked, + E.downloaded, P.primaryColor, E.media_id, E.is_new, P.skip_seconds + FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + WHERE E.is_new = 1 AND E.feed_id = ? ORDER BY E.milliseconds ASC""", + [id]); + if (list.length > 0) + for (int x = 0; x < list.length; x++) { + episodes.add(EpisodeBrief( + list[x]['title'], + list[x]['enclosure_url'], + list[x]['enclosure_length'], + list[x]['milliseconds'], + list[x]['feed_title'], + list[x]['primaryColor'], + list[x]['liked'], + list[x]['downloaded'], + list[x]['duration'], + list[x]['explicit'], + list[x]['imagePath'], + list[x]['media_id'], + list[x]['is_new'], + list[x]['skip_seconds'])); + } + return episodes; + } + Future> getRssItemTop(String id) async { var dbClient = await database; List episodes = List(); List list = await dbClient .rawQuery("""SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, E.liked, - E.downloaded, P.primaryColor, E.media_id, E.is_new + E.downloaded, P.primaryColor, E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id - where E.feed_id = ? ORDER BY E.milliseconds DESC LIMIT 3""", [id]); + where E.feed_id = ? ORDER BY E.milliseconds DESC LIMIT 2""", [id]); for (int x = 0; x < list.length; x++) { episodes.add(EpisodeBrief( list[x]['title'], @@ -580,7 +642,8 @@ class DBHelper { list[x]['explicit'], list[x]['imagePath'], list[x]['media_id'], - list[x]['is_new'])); + list[x]['is_new'], + list[x]['skip_seconds'])); } return episodes; } @@ -591,7 +654,7 @@ class DBHelper { List list = await dbClient.rawQuery( """SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, E.liked, - E.downloaded, P.primaryColor, E.media_id, E.is_new + E.downloaded, P.primaryColor, E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id where E.enclosure_url = ? ORDER BY E.milliseconds DESC LIMIT 3""", [url]); @@ -610,7 +673,8 @@ class DBHelper { list.first['explicit'], list.first['imagePath'], list.first['media_id'], - list.first['is_new']); + list.first['is_new'], + list.first['skip_seconds']); return episode; } @@ -620,7 +684,7 @@ class DBHelper { List list = await dbClient .rawQuery("""SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.title as feed_title, E.duration, E.explicit, E.liked, - E.downloaded, P.imagePath, P.primaryColor, E.media_id, E.is_new + E.downloaded, P.imagePath, P.primaryColor, E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id ORDER BY E.milliseconds DESC LIMIT ? """, [top]); for (int x = 0; x < list.length; x++) { @@ -632,12 +696,13 @@ class DBHelper { list[x]['feed_title'], list[x]['primaryColor'], list[x]['liked'], - list[x]['doanloaded'], + list[x]['downloaded'], list[x]['duration'], list[x]['explicit'], list[x]['imagePath'], list[x]['media_id'], - list[x]['is_new'])); + list[x]['is_new'], + list[x]['skip_seconds'])); } return episodes; } @@ -651,7 +716,7 @@ class DBHelper { List list = await dbClient .rawQuery("""SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.title as feed_title, E.duration, E.explicit, E.liked, - E.downloaded, P.imagePath, P.primaryColor, E.media_id, E.is_new + E.downloaded, P.imagePath, P.primaryColor, E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id WHERE P.id in (${s.join(',')}) ORDER BY E.milliseconds DESC LIMIT ? """, [top]); @@ -664,17 +729,97 @@ class DBHelper { list[x]['feed_title'], list[x]['primaryColor'], list[x]['liked'], - list[x]['doanloaded'], + list[x]['downloaded'], list[x]['duration'], list[x]['explicit'], list[x]['imagePath'], list[x]['media_id'], - list[x]['is_new'])); + list[x]['is_new'], + list[x]['skip_seconds'])); } } return episodes; } + Future> getRecentNewRssItem() async { + var dbClient = await database; + List episodes = []; + List list = await dbClient.rawQuery( + """SELECT E.title, E.enclosure_url, E.enclosure_length, + E.milliseconds, P.title as feed_title, E.duration, E.explicit, E.liked, + E.downloaded, P.imagePath, P.primaryColor, E.media_id, E.is_new, P.skip_seconds + FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + WHERE is_new = 1 ORDER BY E.milliseconds DESC """, + ); + for (int x = 0; x < list.length; x++) { + episodes.add(EpisodeBrief( + list[x]['title'], + list[x]['enclosure_url'], + list[x]['enclosure_length'], + list[x]['milliseconds'], + list[x]['feed_title'], + list[x]['primaryColor'], + list[x]['liked'], + list[x]['downloaded'], + list[x]['duration'], + list[x]['explicit'], + list[x]['imagePath'], + list[x]['media_id'], + list[x]['is_new'], + list[x]['skip_seconds'])); + } + return episodes; + } + + Future removeAllNewMark() async { + var dbClient = await database; + return await dbClient.rawUpdate("UPDATE Episodes SET is_new = 0 "); + } + + Future> getGroupNewRssItem(List group) async { + var dbClient = await database; + List episodes = []; + if (group.length > 0) { + List s = group.map((e) => "'$e'").toList(); + List list = await dbClient.rawQuery( + """SELECT E.title, E.enclosure_url, E.enclosure_length, + E.milliseconds, P.title as feed_title, E.duration, E.explicit, E.liked, + E.downloaded, P.imagePath, P.primaryColor, E.media_id, E.is_new, P.skip_seconds + FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + WHERE P.id in (${s.join(',')}) AND is_new = 1 + ORDER BY E.milliseconds DESC""", + ); + for (int x = 0; x < list.length; x++) { + episodes.add(EpisodeBrief( + list[x]['title'], + list[x]['enclosure_url'], + list[x]['enclosure_length'], + list[x]['milliseconds'], + list[x]['feed_title'], + list[x]['primaryColor'], + list[x]['liked'], + list[x]['downloaded'], + list[x]['duration'], + list[x]['explicit'], + list[x]['imagePath'], + list[x]['media_id'], + list[x]['is_new'], + list[x]['skip_seconds'])); + } + } + return episodes; + } + + Future removeGroupNewMark(List group) async { + var dbClient = await database; + if (group.length > 0) { + List s = group.map((e) => "'$e'").toList(); + return await dbClient.rawUpdate( + "UPDATE Episodes SET is_new = 0 WHERE feed_id in (${s.join(',')})"); + } + return 0; + } + Future> getLikedRssItem(int i, int sortBy) async { var dbClient = await database; List episodes = List(); @@ -682,7 +827,7 @@ class DBHelper { List list = await dbClient.rawQuery( """SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, E.liked, E.downloaded, - P.primaryColor, E.media_id, E.is_new FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + P.primaryColor, E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id WHERE E.liked = 1 ORDER BY E.milliseconds DESC LIMIT ?""", [i]); for (int x = 0; x < list.length; x++) { episodes.add(EpisodeBrief( @@ -698,13 +843,14 @@ class DBHelper { list[x]['explicit'], list[x]['imagePath'], list[x]['media_id'], - list[x]['is_new'])); + list[x]['is_new'], + list[x]['skip_seconds'])); } } else { List list = await dbClient.rawQuery( """SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, E.liked, E.downloaded, - P.primaryColor, E.media_id, E.is_new FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + P.primaryColor, E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id WHERE E.liked = 1 ORDER BY E.liked_date DESC LIMIT ?""", [i]); for (int x = 0; x < list.length; x++) { episodes.add(EpisodeBrief( @@ -720,7 +866,8 @@ class DBHelper { list[x]['explicit'], list[x]['imagePath'], list[x]['media_id'], - list[x]['is_new'])); + list[x]['is_new'], + list[x]['skip_seconds'])); } } return episodes; @@ -782,7 +929,7 @@ class DBHelper { List list = await dbClient.rawQuery( """SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, E.liked, E.downloaded, - P.primaryColor, E.media_id, E.is_new FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + P.primaryColor, E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id WHERE E.downloaded != 'ND' ORDER BY E.download_date DESC"""); for (int x = 0; x < list.length; x++) { episodes.add(EpisodeBrief( @@ -798,7 +945,8 @@ class DBHelper { list[x]['explicit'], list[x]['imagePath'], list[x]['media_id'], - list[x]['is_new'])); + list[x]['is_new'], + list[x]['skip_seconds'])); } return episodes; } @@ -825,7 +973,7 @@ class DBHelper { List list = await dbClient.rawQuery( """SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, E.liked, E.downloaded, - P.primaryColor, E.media_id, E.is_new FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + P.primaryColor, E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id WHERE E.enclosure_url = ?""", [url]); if (list.length == 0) { return null; @@ -843,7 +991,8 @@ class DBHelper { list.first['explicit'], list.first['imagePath'], list.first['media_id'], - list.first['is_new']); + list.first['is_new'], + list.first['skip_seconds']); return episode; } } @@ -854,7 +1003,7 @@ class DBHelper { List list = await dbClient.rawQuery( """SELECT E.title, E.enclosure_url, E.enclosure_length, E.milliseconds, P.imagePath, P.title as feed_title, E.duration, E.explicit, E.liked, E.downloaded, - P.primaryColor, E.media_id, E.is_new FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id + P.primaryColor, E.media_id, E.is_new, P.skip_seconds FROM Episodes E INNER JOIN PodcastLocal P ON E.feed_id = P.id WHERE E.media_id = ?""", [id]); episode = EpisodeBrief( list.first['title'], @@ -869,7 +1018,8 @@ class DBHelper { list.first['explicit'], list.first['imagePath'], list.first['media_id'], - list.first['is_new']); + list.first['is_new'], + list.first['skip_seconds']); return episode; } } diff --git a/lib/podcasts/podcastdetail.dart b/lib/podcasts/podcastdetail.dart index cf2eff7..f781f89 100644 --- a/lib/podcasts/podcastdetail.dart +++ b/lib/podcasts/podcastdetail.dart @@ -12,17 +12,17 @@ import 'package:fluttertoast/fluttertoast.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:provider/provider.dart'; import 'package:line_icons/line_icons.dart'; - import 'package:cached_network_image/cached_network_image.dart'; -import 'package:tsacdop/class/podcastlocal.dart'; -import 'package:tsacdop/class/episodebrief.dart'; -import 'package:tsacdop/local_storage/sqflite_localpodcast.dart'; -import 'package:tsacdop/util/episodegrid.dart'; -import 'package:tsacdop/home/audioplayer.dart'; -import 'package:tsacdop/class/fireside_data.dart'; -import 'package:tsacdop/util/colorize.dart'; -import 'package:tsacdop/util/context_extension.dart'; -import 'package:tsacdop/util/custompaint.dart'; + +import '../class/podcastlocal.dart'; +import '../class/episodebrief.dart'; +import '../local_storage/sqflite_localpodcast.dart'; +import '../util/episodegrid.dart'; +import '../home/audioplayer.dart'; +import '../class/fireside_data.dart'; +import '../util/colorize.dart'; +import '../util/context_extension.dart'; +import '../util/custompaint.dart'; class PodcastDetail extends StatefulWidget { PodcastDetail({Key key, this.podcastLocal}) : super(key: key); @@ -215,7 +215,10 @@ class _PodcastDetailState extends State { child: RefreshIndicator( key: _refreshIndicatorKey, color: Theme.of(context).accentColor, - onRefresh: () => _updateRssItem(widget.podcastLocal), + onRefresh: () async { + await _updateRssItem(widget.podcastLocal); + // audio.addNewEpisode(widget.podcastLocal.id); + }, child: Stack( children: [ Column( diff --git a/lib/podcasts/podcastgroup.dart b/lib/podcasts/podcastgroup.dart index f9fb882..b349f6b 100644 --- a/lib/podcasts/podcastgroup.dart +++ b/lib/podcasts/podcastgroup.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -6,11 +7,14 @@ import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:provider/provider.dart'; -import 'package:tsacdop/class/podcast_group.dart'; -import 'package:tsacdop/class/podcastlocal.dart'; -import 'package:tsacdop/podcasts/podcastdetail.dart'; -import 'package:tsacdop/util/pageroute.dart'; -import 'package:tsacdop/util/colorize.dart'; +import '../class/podcast_group.dart'; +import '../class/podcastlocal.dart'; +import '../local_storage/sqflite_localpodcast.dart'; +import '../podcasts/podcastdetail.dart'; +import '../util/pageroute.dart'; +import '../util/colorize.dart'; +import '../util/duraiton_picker.dart'; +import '../util/context_extension.dart'; class PodcastGroupList extends StatefulWidget { final PodcastGroup group; @@ -67,11 +71,32 @@ class PodcastCard extends StatefulWidget { _PodcastCardState createState() => _PodcastCardState(); } -class _PodcastCardState extends State { +class _PodcastCardState extends State + with SingleTickerProviderStateMixin { bool _loadMenu; bool _addGroup; List _selectedGroups; List _belongGroups; + AnimationController _controller; + Animation _animation; + double _value; + int _seconds; + + Future getSkipSecond(String id) async { + var dbHelper = DBHelper(); + int seconds = await dbHelper.getSkipSeconds(id); + return seconds; + } + + saveSkipSeconds(String id, int seconds) async { + var dbHelper = DBHelper(); + await dbHelper.saveSkipSeconds(id, seconds); + } + + String _stringForSeconds(double seconds) { + if (seconds == null) return null; + return '${(seconds ~/ 60)}:${(seconds.truncate() % 60).toString().padLeft(2, '0')}'; + } @override void initState() { @@ -79,6 +104,16 @@ class _PodcastCardState extends State { _loadMenu = false; _addGroup = false; _selectedGroups = [widget.group]; + _value = 0; + _seconds = 0; + _controller = + AnimationController(vsync: this, duration: Duration(milliseconds: 300)); + _animation = Tween(begin: 0.0, end: 1.0).animate(_controller) + ..addListener(() { + setState(() { + _value = _animation.value; + }); + }); } Widget _buttonOnMenu(Widget widget, VoidCallback onTap) => Material( @@ -112,7 +147,15 @@ class _PodcastCardState extends State { mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: () => setState(() => _loadMenu = !_loadMenu), + onTap: () => setState( + () { + _loadMenu = !_loadMenu; + if (_value == 0) + _controller.forward(); + else + _controller.reverse(); + }, + ), child: Container( padding: EdgeInsets.symmetric(horizontal: 12), height: 100, @@ -162,12 +205,30 @@ class _PodcastCardState extends State { child: Text(group.name)); }).toList(), ), + FutureBuilder( + future: getSkipSecond(widget.podcastLocal.id), + initialData: 0, + builder: (context, snapshot) { + return snapshot.data == 0 + ? Center() + : Container( + alignment: Alignment.centerLeft, + child: Text('Skip ' + + _stringForSeconds( + snapshot.data.toDouble())), + ); + }, + ), ], )), Spacer(), - Icon(_loadMenu - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down), + Transform.rotate( + angle: math.pi * _value, + child: Icon(Icons.keyboard_arrow_down), + ), + // Icon(_loadMenu + // ? Icons.keyboard_arrow_up + // : Icons.keyboard_arrow_down), Padding( padding: EdgeInsets.symmetric(horizontal: 5.0), ), @@ -262,7 +323,7 @@ class _PodcastCardState extends State { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buttonOnMenu( - Icon(Icons.fullscreen), + Icon(Icons.fullscreen, size: 20 * _value), () => Navigator.push( context, ScaleRoute( @@ -270,16 +331,90 @@ class _PodcastCardState extends State { podcastLocal: widget.podcastLocal, )), )), - _buttonOnMenu(Icon(Icons.add), () { + _buttonOnMenu(Icon(Icons.add, size: 20 * _value), + () { setState(() { _addGroup = true; }); }), - // _buttonOnMenu(Icon(Icons.notifications), () {}), + _buttonOnMenu( + Icon( + Icons.fast_forward, + size: 20 * (_value), + ), () { + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: + MaterialLocalizations.of(context) + .modalBarrierDismissLabel, + barrierColor: Colors.black54, + transitionDuration: + const Duration(milliseconds: 200), + pageBuilder: (BuildContext context, + Animation animaiton, + Animation secondaryAnimation) => + AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: + Theme.of(context).brightness == + Brightness.light + ? Color.fromRGBO(113, 113, 113, 1) + : Color.fromRGBO(15, 15, 15, 1), + ), + child: AlertDialog( + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(10.0))), + titlePadding: EdgeInsets.only( + top: 20, + left: 20, + right: 100, + bottom: 20), + title: Text('Skip seconds at the beginning'), + content: DurationPicker( + duration: Duration.zero, + onChange: (value) => + _seconds = value.inSeconds, + ), + // content: Text('test'), + actionsPadding: EdgeInsets.all(10), + actions: [ + FlatButton( + onPressed: () { + Navigator.of(context).pop(); + _seconds = 0; + }, + child: Text( + 'CANCEL', + style: TextStyle( + color: Colors.grey[600]), + ), + ), + FlatButton( + onPressed: () { + Navigator.of(context).pop(); + saveSkipSeconds( + widget.podcastLocal.id, + _seconds); + }, + child: Text( + 'CONFIRM', + style: TextStyle(color: context.accentColor), + ), + ) + ], + ), + ), + ); + }), _buttonOnMenu( Icon( Icons.delete, color: Colors.red, + size: 20 * (_value), ), () { showGeneralDialog( context: context, diff --git a/lib/podcasts/podcastmanage.dart b/lib/podcasts/podcastmanage.dart index 4efff03..ce6874a 100644 --- a/lib/podcasts/podcastmanage.dart +++ b/lib/podcasts/podcastmanage.dart @@ -164,7 +164,8 @@ class _PodcastManageState extends State ), body: WillPopScope( onWillPop: () async { - await Provider.of(context, listen: false).clearOrderChanged(); + await Provider.of(context, listen: false) + .clearOrderChanged(); return true; }, child: Consumer(builder: (_, groupList, __) { @@ -347,17 +348,6 @@ class _PodcastManageState extends State 15, 15, 1), - // statusBarColor: Theme.of( - // context) - // .brightness == - // Brightness.light - // ? Color.fromRGBO( - // 113, - // 113, - // 113, - // 1) - // : Color.fromRGBO( - // 5, 5, 5, 1), ), child: AlertDialog( elevation: 1, @@ -375,7 +365,8 @@ class _PodcastManageState extends State title: Text( 'Delete confirm'), content: Text( - 'Are you sure you want to delete this group? Podcasts will be moved to Home group.'), + 'Are you sure you want to delete this group?' + + 'Podcasts will be moved to Home group.'), actions: [ FlatButton( onPressed: () => @@ -533,9 +524,6 @@ class _AddGroupState extends State { Theme.of(context).brightness == Brightness.light ? Color.fromRGBO(113, 113, 113, 1) : Color.fromRGBO(5, 5, 5, 1), - // statusBarColor: Theme.of(context).brightness == Brightness.light - // ? Color.fromRGBO(113, 113, 113, 1) - // : Color.fromRGBO(15, 15, 15, 1), ), child: AlertDialog( shape: RoundedRectangleBorder( diff --git a/lib/settings/play_setting.dart b/lib/settings/play_setting.dart new file mode 100644 index 0000000..b4e8593 --- /dev/null +++ b/lib/settings/play_setting.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../class/audiostate.dart'; + +class PlaySetting extends StatelessWidget { + @override + Widget build(BuildContext context) { + var audio = Provider.of(context, listen: false); + return AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarIconBrightness: Theme.of(context).accentColorBrightness, + systemNavigationBarColor: Theme.of(context).primaryColor, + systemNavigationBarIconBrightness: + Theme.of(context).accentColorBrightness, + ), + child: Scaffold( + appBar: AppBar( + title: Text('Player Setting'), + elevation: 0, + backgroundColor: Theme.of(context).primaryColor, + ), + body: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.all(10.0), + ), + Container( + height: 30.0, + padding: EdgeInsets.symmetric(horizontal: 80), + alignment: Alignment.centerLeft, + child: Text('Playlist', + style: Theme.of(context) + .textTheme + .bodyText1 + .copyWith(color: Theme.of(context).accentColor)), + ), + ListView( + physics: const BouncingScrollPhysics(), + shrinkWrap: true, + scrollDirection: Axis.vertical, + children: [ + ListTile( + contentPadding: + EdgeInsets.only(left: 80.0, right: 20, bottom: 0), + title: Text('Autoplay'), + subtitle: Text('Autoplay next episode in playlist'), + trailing: Selector( + selector: (_, audio) => audio.autoPlay, + builder: (_, data, __) => Switch( + value: data, + onChanged: (boo) => audio.autoPlaySwitch = boo), + ), + ), + Divider(height: 2), + // ListTile( + // contentPadding: + // EdgeInsets.only(left: 80.0, right: 20, bottom: 0), + // title: Text('Autoadd'), + // subtitle: + // Text('Autoadd new updated episodes to playlist'), + // trailing: Selector( + // selector: (_, audio) => audio.autoAdd, + // builder: (_, data, __) => Switch( + // value: data, + // onChanged: (boo) => audio.autoAddSwitch = boo), + // ), + // ), + // Divider(height: 2), + ], + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/settings/settting.dart b/lib/settings/settting.dart index 6bb0600..21b8407 100644 --- a/lib/settings/settting.dart +++ b/lib/settings/settting.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; import 'package:path/path.dart'; import 'package:line_icons/line_icons.dart'; import 'package:tsacdop/class/podcastlocal.dart'; @@ -11,7 +10,6 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:path_provider/path_provider.dart'; import 'package:flutter_file_dialog/flutter_file_dialog.dart'; -import 'package:tsacdop/class/audiostate.dart'; import 'package:tsacdop/util/ompl_build.dart'; import 'package:tsacdop/util/context_extension.dart'; import 'package:tsacdop/intro_slider/app_intro.dart'; @@ -20,6 +18,7 @@ import 'storage.dart'; import 'history.dart'; import 'syncing.dart'; import 'libries.dart'; +import 'play_setting.dart'; class Settings extends StatelessWidget { _launchUrl(String url) async { @@ -46,7 +45,6 @@ class Settings extends StatelessWidget { @override Widget build(BuildContext context) { - var audio = Provider.of(context, listen: false); return AnnotatedRegion( value: SystemUiOverlayStyle( statusBarIconBrightness: Theme.of(context).accentColorBrightness, @@ -103,17 +101,21 @@ class Settings extends StatelessWidget { ), Divider(height: 2), ListTile( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaySetting())), contentPadding: EdgeInsets.symmetric(horizontal: 25.0), leading: Icon(LineIcons.play_circle), - title: Text('AutoPlay'), - subtitle: Text('Autoplay next episode in playlist'), - trailing: Selector( - selector: (_, audio) => audio.autoPlay, - builder: (_, data, __) => Switch( - value: data, - onChanged: (boo) => audio.autoPlaySwitch = boo), - ), + title: Text('Play'), + subtitle: Text('Playlist and player'), + // trailing: Selector( + // selector: (_, audio) => audio.autoPlay, + // builder: (_, data, __) => Switch( + // value: data, + // onChanged: (boo) => audio.autoPlaySwitch = boo), + // ), ), Divider(height: 2), ListTile( @@ -160,7 +162,7 @@ class Settings extends StatelessWidget { EdgeInsets.symmetric(horizontal: 25.0), leading: Icon(LineIcons.file_code_solid), title: Text('Export'), - subtitle: Text('Export ompl file'), + subtitle: Text('Export ompl file of all podcasts'), ), Divider(height: 2), ], @@ -214,7 +216,7 @@ class Settings extends StatelessWidget { Divider(height: 2), ListTile( onTap: () => _launchUrl( - 'mailto:?subject=Tsacdop Feedback'), + 'mailto:?subject=Tsacdop Feedback'), contentPadding: EdgeInsets.symmetric(horizontal: 25.0), leading: Icon(LineIcons.bug_solid), diff --git a/lib/settings/storage.dart b/lib/settings/storage.dart index e47464c..8923262 100644 --- a/lib/settings/storage.dart +++ b/lib/settings/storage.dart @@ -55,7 +55,7 @@ class StorageSetting extends StatelessWidget { MaterialPageRoute( builder: (context) => DownloadsManage())), contentPadding: - EdgeInsets.only(left: 80.0, right: 25), + EdgeInsets.only(left: 80.0, right: 25, bottom: 10), title: Text('Ask before using cellular data'), subtitle: Text( 'Ask to confirm when using cellular data to download episodes.'), diff --git a/lib/settings/syncing.dart b/lib/settings/syncing.dart index bcf1dbd..cba076b 100644 --- a/lib/settings/syncing.dart +++ b/lib/settings/syncing.dart @@ -63,10 +63,10 @@ class SyncingSetting extends StatelessWidget { } }, contentPadding: EdgeInsets.only( - left: 80.0, right: 20, bottom: 20), + left: 80.0, right: 20, bottom: 10), title: Text('Enable syncing'), subtitle: Text( - 'Refresh all podcasts in the background to get leatest episodes.'), + 'Refresh all podcasts in the background to get leatest episodes'), trailing: Switch( value: data.item1, onChanged: (boo) async { diff --git a/lib/util/day_night_switch.dart b/lib/util/day_night_switch.dart deleted file mode 100644 index 87939b5..0000000 --- a/lib/util/day_night_switch.dart +++ /dev/null @@ -1,464 +0,0 @@ -//Fork from https://github.com/divyanshub024/day_night_switch -//Copyright https://github.com/divyanshub024 -//Apache License 2.0 https://github.com/divyanshub024/day_night_switch/blob/master/LICENSE - - -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -const double _kTrackHeight = 80.0; -const double _kTrackWidth = 160.0; -const double _kTrackRadius = _kTrackHeight / 2.0; -const double _kThumbRadius = 36.0; -const double _kSwitchWidth = - _kTrackWidth - 2 * _kTrackRadius + 2 * kRadialReactionRadius; -const double _kSwitchHeight = 2 * kRadialReactionRadius + 8.0; - -class DayNightSwitch extends StatefulWidget { - const DayNightSwitch({ - @required this.value, - @required this.onChanged, - @required this.onDrag, - this.dragStartBehavior = DragStartBehavior.start, - this.height, - this.moonImage, - this.sunImage, - this.sunColor, - this.moonColor, - this.dayColor, - this.nightColor, - }); - - final bool value; - final ValueChanged onChanged; - final ValueChanged onDrag; - final DragStartBehavior dragStartBehavior; - final double height; - final ImageProvider sunImage; - final ImageProvider moonImage; - final Color sunColor; - final Color moonColor; - final Color dayColor; - final Color nightColor; - - @override - _DayNightSwitchState createState() => _DayNightSwitchState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(FlagProperty('value', - value: value, ifTrue: 'on', ifFalse: 'off', showName: true)); - properties.add(ObjectFlagProperty>( - 'onChanged', - onChanged, - ifNull: 'disabled', - )); - } -} - -class _DayNightSwitchState extends State - with TickerProviderStateMixin { - @override - Widget build(BuildContext context) { - final Color moonColor = widget.moonColor ?? const Color(0xFFf5f3ce); - final Color nightColor = widget.nightColor ?? const Color(0xFF003366); - - Color sunColor = widget.sunColor ?? const Color(0xFFFDB813); - Color dayColor = widget.dayColor ?? const Color(0xFF87CEEB); - - return _SwitchRenderObjectWidget( - dragStartBehavior: widget.dragStartBehavior, - value: widget.value, - activeColor: moonColor, - inactiveColor: sunColor, - moonImage: widget.moonImage, - sunImage: widget.sunImage, - activeTrackColor: nightColor, - inactiveTrackColor: dayColor, - configuration: createLocalImageConfiguration(context), - onChanged: widget.onChanged, - onDrag: widget.onDrag, - additionalConstraints: - BoxConstraints.tight(Size(_kSwitchWidth, _kSwitchHeight)), - vsync: this, - ); - } -} - -class _SwitchRenderObjectWidget extends LeafRenderObjectWidget { - const _SwitchRenderObjectWidget({ - Key key, - this.value, - this.activeColor, - this.inactiveColor, - this.moonImage, - this.sunImage, - this.activeTrackColor, - this.inactiveTrackColor, - this.configuration, - this.onChanged, - this.onDrag, - this.vsync, - this.additionalConstraints, - this.dragStartBehavior, - }) : super(key: key); - - final bool value; - final Color activeColor; - final Color inactiveColor; - final ImageProvider moonImage; - final ImageProvider sunImage; - final Color activeTrackColor; - final Color inactiveTrackColor; - final ImageConfiguration configuration; - final ValueChanged onChanged; - final ValueChanged onDrag; - final TickerProvider vsync; - final BoxConstraints additionalConstraints; - final DragStartBehavior dragStartBehavior; - - @override - _RenderSwitch createRenderObject(BuildContext context) { - return _RenderSwitch( - dragStartBehavior: dragStartBehavior, - value: value, - activeColor: activeColor, - inactiveColor: inactiveColor, - moonImage: moonImage, - sunImage: sunImage, - activeTrackColor: activeTrackColor, - inactiveTrackColor: inactiveTrackColor, - configuration: configuration, - onChanged: onChanged, - onDrag: onDrag, - textDirection: Directionality.of(context), - additionalConstraints: additionalConstraints, - vSync: vsync, - ); - } - - @override - void updateRenderObject(BuildContext context, _RenderSwitch renderObject) { - renderObject - ..value = value - ..activeColor = activeColor - ..inactiveColor = inactiveColor - ..activeThumbImage = moonImage - ..inactiveThumbImage = sunImage - ..activeTrackColor = activeTrackColor - ..inactiveTrackColor = inactiveTrackColor - ..configuration = configuration - ..onChanged = onChanged - ..onDrag = onDrag - ..textDirection = Directionality.of(context) - ..additionalConstraints = additionalConstraints - ..dragStartBehavior = dragStartBehavior - ..vsync = vsync; - } -} - -class _RenderSwitch extends RenderToggleable { - ValueChanged onDrag; - _RenderSwitch({ - bool value, - Color activeColor, - Color inactiveColor, - ImageProvider moonImage, - ImageProvider sunImage, - Color activeTrackColor, - Color inactiveTrackColor, - ImageConfiguration configuration, - BoxConstraints additionalConstraints, - @required TextDirection textDirection, - ValueChanged onChanged, - this.onDrag, - @required TickerProvider vSync, - DragStartBehavior dragStartBehavior, - }) : assert(textDirection != null), - _activeThumbImage = moonImage, - _inactiveThumbImage = sunImage, - _activeTrackColor = activeTrackColor, - _inactiveTrackColor = inactiveTrackColor, - _configuration = configuration, - _textDirection = textDirection, - super( - value: value, - tristate: false, - activeColor: activeColor, - inactiveColor: inactiveColor, - onChanged: onChanged, - additionalConstraints: additionalConstraints, - vsync: vSync, - ) { - _drag = HorizontalDragGestureRecognizer() - ..onStart = _handleDragStart - ..onUpdate = _handleDragUpdate - ..onEnd = _handleDragEnd - ..dragStartBehavior = dragStartBehavior; - } - - ImageProvider get activeThumbImage => _activeThumbImage; - ImageProvider _activeThumbImage; - set activeThumbImage(ImageProvider value) { - if (value == _activeThumbImage) return; - _activeThumbImage = value; - markNeedsPaint(); - } - - ImageProvider get inactiveThumbImage => _inactiveThumbImage; - ImageProvider _inactiveThumbImage; - set inactiveThumbImage(ImageProvider value) { - if (value == _inactiveThumbImage) return; - _inactiveThumbImage = value; - markNeedsPaint(); - } - - Color get activeTrackColor => _activeTrackColor; - Color _activeTrackColor; - set activeTrackColor(Color value) { - assert(value != null); - if (value == _activeTrackColor) return; - _activeTrackColor = value; - markNeedsPaint(); - } - - Color get inactiveTrackColor => _inactiveTrackColor; - Color _inactiveTrackColor; - set inactiveTrackColor(Color value) { - assert(value != null); - if (value == _inactiveTrackColor) return; - _inactiveTrackColor = value; - markNeedsPaint(); - } - - ImageConfiguration get configuration => _configuration; - ImageConfiguration _configuration; - set configuration(ImageConfiguration value) { - assert(value != null); - if (value == _configuration) return; - _configuration = value; - markNeedsPaint(); - } - - TextDirection get textDirection => _textDirection; - TextDirection _textDirection; - set textDirection(TextDirection value) { - assert(value != null); - if (_textDirection == value) return; - _textDirection = value; - markNeedsPaint(); - } - - DragStartBehavior get dragStartBehavior => _drag.dragStartBehavior; - set dragStartBehavior(DragStartBehavior value) { - assert(value != null); - if (_drag.dragStartBehavior == value) return; - _drag.dragStartBehavior = value; - } - - @override - void detach() { - _cachedThumbPainter?.dispose(); - _cachedThumbPainter = null; - super.detach(); - } - - double get _trackInnerLength => size.width - 2.0 * kRadialReactionRadius; - - HorizontalDragGestureRecognizer _drag; - - void _handleDragStart(DragStartDetails details) { - if (isInteractive) reactionController.forward(); - } - - void _handleDragUpdate(DragUpdateDetails details) { - if (isInteractive) { - position - ..curve = null - ..reverseCurve = null; - final double delta = details.primaryDelta / _trackInnerLength; - switch (textDirection) { - case TextDirection.rtl: - positionController.value -= delta; - break; - case TextDirection.ltr: - positionController.value += delta; - break; - } - positionController.addListener(() { - onDrag(positionController.value); - }); - } - } - - void _handleDragEnd(DragEndDetails details) { - if (position.value >= 0.5) - positionController.forward(); - else - positionController.reverse(); - reactionController.reverse(); - } - - @override - void handleEvent(PointerEvent event, BoxHitTestEntry entry) { - assert(debugHandleEvent(event, entry)); - if (event is PointerDownEvent && onChanged != null) _drag.addPointer(event); - super.handleEvent(event, entry); - } - - Color _cachedThumbColor; - ImageProvider _cachedThumbImage; - BoxPainter _cachedThumbPainter; - - BoxDecoration _createDefaultThumbDecoration( - Color color, ImageProvider image) { - return BoxDecoration( - color: color, - image: image == null ? null : DecorationImage(image: image), - shape: BoxShape.circle, - boxShadow: kElevationToShadow[1], - ); - } - - bool _isPainting = false; - - void _handleDecorationChanged() { - // If the image decoration is available synchronously, we'll get called here - // during paint. There's no reason to mark ourselves as needing paint if we - // are already in the middle of painting. (In fact, doing so would trigger - // an assert). - if (!_isPainting) markNeedsPaint(); - } - - @override - void describeSemanticsConfiguration(SemanticsConfiguration config) { - super.describeSemanticsConfiguration(config); - config.isToggled = value == true; - } - - @override - void paint(PaintingContext context, Offset offset) { - final Canvas canvas = context.canvas; - final bool isEnabled = onChanged != null; - final double currentValue = position.value; - - double visualPosition; - switch (textDirection) { - case TextDirection.rtl: - visualPosition = 1.0 - currentValue; - break; - case TextDirection.ltr: - visualPosition = currentValue; - break; - } - - final Color trackColor = isEnabled - ? Color.lerp(inactiveTrackColor, activeTrackColor, currentValue) - : inactiveTrackColor; - - final Color thumbColor = isEnabled - ? Color.lerp(inactiveColor, activeColor, currentValue) - : inactiveColor; - - final ImageProvider thumbImage = isEnabled - ? (currentValue < 0.5 ? inactiveThumbImage : activeThumbImage) - : inactiveThumbImage; - - // Paint the track - final Paint paint = Paint()..color = trackColor; - const double trackHorizontalPadding = kRadialReactionRadius - _kTrackRadius; - final Rect trackRect = Rect.fromLTWH( - offset.dx + trackHorizontalPadding, - offset.dy + (size.height - _kTrackHeight) / 2.0, - size.width - 2.0 * trackHorizontalPadding, - _kTrackHeight, - ); - final RRect trackRRect = RRect.fromRectAndRadius( - trackRect, const Radius.circular(_kTrackRadius)); - canvas.drawRRect(trackRRect, paint); - - final Offset thumbPosition = Offset( - kRadialReactionRadius + visualPosition * _trackInnerLength, - size.height / 2.0, - ); - - paintRadialReaction(canvas, offset, thumbPosition); - - var linePaint = Paint() - ..color = Colors.white - ..strokeWidth = 4 + (6 * (1 - currentValue)) - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; - - canvas.drawLine( - Offset(offset.dx + _kSwitchWidth * 0.1, offset.dy), - Offset( - offset.dx + - (_kSwitchWidth * 0.1) + - (_kSwitchWidth / 2 * (1 - currentValue)), - offset.dy), - linePaint, - ); - - canvas.drawLine( - Offset(offset.dx + _kSwitchWidth * 0.2, offset.dy + _kSwitchHeight), - Offset( - offset.dx + - (_kSwitchWidth * 0.2) + - (_kSwitchWidth / 2 * (1 - currentValue)), - offset.dy + _kSwitchHeight), - linePaint, - ); - - var starPaint = Paint() - ..strokeWidth = 2 + (6 * (1 - currentValue)) - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke - ..color = Color.fromARGB((255 * currentValue).floor(), 255, 255, 255); - - canvas.drawLine( - Offset(offset.dx, offset.dy + _kSwitchHeight * 0.7), - Offset(offset.dx, offset.dy + _kSwitchHeight * 0.7), - starPaint, - ); - - try { - _isPainting = true; - BoxPainter thumbPainter; - if (_cachedThumbPainter == null || - thumbColor != _cachedThumbColor || - thumbImage != _cachedThumbImage) { - _cachedThumbColor = thumbColor; - _cachedThumbImage = thumbImage; - _cachedThumbPainter = - _createDefaultThumbDecoration(thumbColor, thumbImage) - .createBoxPainter(_handleDecorationChanged); - } - thumbPainter = _cachedThumbPainter; - - // The thumb contracts slightly during the animation - final double inset = 1.0 - (currentValue - 0.5).abs() * 2.0; - final double radius = _kThumbRadius - inset; - thumbPainter.paint( - canvas, - thumbPosition + offset - Offset(radius, radius), - configuration.copyWith(size: Size.fromRadius(radius)), - ); - } finally { - _isPainting = false; - } - - canvas.drawLine( - Offset(offset.dx + _kSwitchWidth * 0.3, offset.dy + _kSwitchHeight * 0.5), - Offset( - offset.dx + - (_kSwitchWidth * 0.3) + - (_kSwitchWidth / 2 * (1 - currentValue)), - offset.dy + _kSwitchHeight * 0.5), - linePaint, - ); - } -} diff --git a/lib/util/duraiton_picker.dart b/lib/util/duraiton_picker.dart new file mode 100644 index 0000000..b120304 --- /dev/null +++ b/lib/util/duraiton_picker.dart @@ -0,0 +1,663 @@ +//Forked from https://github.com/cdharris/flutter_duration_picker +//Copyright https://github.com/cdharris +//License MIT https://github.com/cdharris/flutter_duration_picker/blob/master/LICENSE + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +const Duration _kDialAnimateDuration = const Duration(milliseconds: 200); + +const double _kDurationPickerWidthPortrait = 328.0; +const double _kDurationPickerWidthLandscape = 512.0; + +const double _kDurationPickerHeightPortrait = 380.0; +const double _kDurationPickerHeightLandscape = 304.0; + +const double _kTwoPi = 2 * math.pi; +const double _kPiByTwo = math.pi / 2; + +const double _kCircleTop = _kPiByTwo; + +class _DialPainter extends CustomPainter { + const _DialPainter({ + @required this.context, + @required this.labels, + @required this.backgroundColor, + @required this.accentColor, + @required this.theta, + @required this.textDirection, + @required this.selectedValue, + @required this.pct, + @required this.multiplier, + @required this.secondHand, + }); + + final List labels; + final Color backgroundColor; + final Color accentColor; + final double theta; + final TextDirection textDirection; + final int selectedValue; + final BuildContext context; + + final double pct; + final int multiplier; + final int secondHand; + + @override + void paint(Canvas canvas, Size size) { + const double _epsilon = .001; + const double _sweep = _kTwoPi - _epsilon; + const double _startAngle = -math.pi / 2.0; + + final double radius = size.shortestSide / 2.0; + final Offset center = new Offset(size.width / 2.0, size.height / 2.0); + final Offset centerPoint = center; + + double pctTheta = (0.25 - (theta % _kTwoPi) / _kTwoPi) % 1.0; + + // Draw the background outer ring + canvas.drawCircle( + centerPoint, radius, new Paint()..color = backgroundColor); + + // Draw a translucent circle for every hour + for (int i = 0; i < multiplier; i = i + 1) { + canvas.drawCircle(centerPoint, radius, + new Paint()..color = accentColor.withOpacity((i == 0) ? 0.3 : 0.1)); + } + + // Draw the inner background circle + canvas.drawCircle(centerPoint, radius * 0.88, + new Paint()..color = Theme.of(context).canvasColor); + + // Get the offset point for an angle value of theta, and a distance of _radius + Offset getOffsetForTheta(double theta, double _radius) { + return center + + new Offset(_radius * math.cos(theta), -_radius * math.sin(theta)); + } + + // Draw the handle that is used to drag and to indicate the position around the circle + final Paint handlePaint = new Paint()..color = accentColor; + final Offset handlePoint = getOffsetForTheta(theta, radius - 10.0); + canvas.drawCircle(handlePoint, 20.0, handlePaint); + + // Draw the Text in the center of the circle which displays hours and mins + String minutes = (multiplier == 0) ? '' : "${multiplier}min "; +// int minutes = (pctTheta * 60).round(); +// minutes = minutes == 60 ? 0 : minutes; + String seconds = "$secondHand"; + + TextPainter textDurationValuePainter = new TextPainter( + textAlign: TextAlign.center, + text: new TextSpan( + text: '$minutes$seconds', + style: Theme.of(context) + .textTheme + .headline4 + .copyWith(fontSize: size.shortestSide * 0.15)), + textDirection: TextDirection.ltr) + ..layout(); + Offset middleForValueText = new Offset( + centerPoint.dx - (textDurationValuePainter.width / 2), + centerPoint.dy - textDurationValuePainter.height / 2); + textDurationValuePainter.paint(canvas, middleForValueText); + + TextPainter textMinPainter = new TextPainter( + textAlign: TextAlign.center, + text: new TextSpan( + text: 'sec', //th: ${theta}', + style: Theme.of(context).textTheme.bodyText1), + textDirection: TextDirection.ltr) + ..layout(); + textMinPainter.paint( + canvas, + new Offset( + centerPoint.dx - (textMinPainter.width / 2), + centerPoint.dy + + (textDurationValuePainter.height / 2) - + textMinPainter.height / 2)); + + // Draw an arc around the circle for the amount of the circle that has elapsed. + var elapsedPainter = new Paint() + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..color = accentColor.withOpacity(0.3) + ..isAntiAlias = true + ..strokeWidth = radius * 0.12; + + canvas.drawArc( + new Rect.fromCircle( + center: centerPoint, + radius: radius - radius * 0.12 / 2, + ), + _startAngle, + _sweep * pctTheta, + false, + elapsedPainter, + ); + + // Paint the labels (the minute strings) + void paintLabels(List labels) { + if (labels == null) return; + final double labelThetaIncrement = -_kTwoPi / labels.length; + double labelTheta = _kPiByTwo; + + for (TextPainter label in labels) { + final Offset labelOffset = + new Offset(-label.width / 2.0, -label.height / 2.0); + + label.paint( + canvas, getOffsetForTheta(labelTheta, radius - 40.0) + labelOffset); + + labelTheta += labelThetaIncrement; + } + } + + paintLabels(labels); + } + + @override + bool shouldRepaint(_DialPainter oldPainter) { + return oldPainter.labels != labels || + oldPainter.backgroundColor != backgroundColor || + oldPainter.accentColor != accentColor || + oldPainter.theta != theta; + } +} + +class _Dial extends StatefulWidget { + const _Dial( + {@required this.duration, + @required this.onChanged, + this.snapToMins = 1.0}) + : assert(duration != null); + + final Duration duration; + final ValueChanged onChanged; + + /// The resolution of mins of the dial, i.e. if snapToMins = 5.0, only durations of 5min intervals will be selectable. + final double snapToMins; + @override + _DialState createState() => new _DialState(); +} + +class _DialState extends State<_Dial> with SingleTickerProviderStateMixin { + @override + void initState() { + super.initState(); + _thetaController = new AnimationController( + duration: _kDialAnimateDuration, + vsync: this, + ); + _thetaTween = + new Tween(begin: _getThetaForDuration(widget.duration)); + _theta = _thetaTween.animate(new CurvedAnimation( + parent: _thetaController, curve: Curves.fastOutSlowIn)) + ..addListener(() => setState(() {})); + _thetaController.addStatusListener((status) { +// if (status == AnimationStatus.completed && _hours != _snappedHours) { +// _hours = _snappedHours; + if (status == AnimationStatus.completed) { + _minutes = _minuteHand(_turningAngle); + _seconds = _secondHand(_turningAngle); + setState(() {}); + } + }); +// _hours = widget.duration.inHours; + + _turningAngle = _kPiByTwo - widget.duration.inSeconds / 60.0 * _kTwoPi; + _minutes = _minuteHand(_turningAngle); + _seconds = _secondHand(_turningAngle); + } + + ThemeData themeData; + MaterialLocalizations localizations; + MediaQueryData media; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + assert(debugCheckHasMediaQuery(context)); + themeData = Theme.of(context); + localizations = MaterialLocalizations.of(context); + media = MediaQuery.of(context); + } + + @override + void dispose() { + _thetaController.dispose(); + super.dispose(); + } + + Tween _thetaTween; + Animation _theta; + AnimationController _thetaController; + + double _pct = 0.0; + int _seconds = 0; + bool _dragging = false; + int _minutes = 0; + double _turningAngle = 0.0; + + static double _nearest(double target, double a, double b) { + return ((target - a).abs() < (target - b).abs()) ? a : b; + } + + void _animateTo(double targetTheta) { + final double currentTheta = _theta.value; + double beginTheta = + _nearest(targetTheta, currentTheta, currentTheta + _kTwoPi); + beginTheta = _nearest(targetTheta, beginTheta, currentTheta - _kTwoPi); + _thetaTween + ..begin = beginTheta + ..end = targetTheta; + _thetaController + ..value = 0.0 + ..forward(); + } + + double _getThetaForDuration(Duration duration) { + return (_kPiByTwo - (duration.inSeconds % 60) / 60.0 * _kTwoPi) % _kTwoPi; + } + + Duration _getTimeForTheta(double theta) { + return _angleToDuration(_turningAngle); + } + + Duration _notifyOnChangedIfNeeded() { +// final Duration current = _getTimeForTheta(_theta.value); +// var d = Duration(hours: _hours, minutes: current.inMinutes % 60); + _minutes = _minuteHand(_turningAngle); + _seconds = _secondHand(_turningAngle); + + var d = _angleToDuration(_turningAngle); + + widget.onChanged(d); + + return d; + } + + void _updateThetaForPan() { + setState(() { + final Offset offset = _position - _center; + final double angle = + (math.atan2(offset.dx, offset.dy) - _kPiByTwo) % _kTwoPi; + + // Stop accidental abrupt pans from making the dial seem like it starts from 1h. + // (happens when wanting to pan from 0 clockwise, but when doing so quickly, one actually pans from before 0 (e.g. setting the duration to 59mins, and then crossing 0, which would then mean 1h 1min). + if (angle >= _kCircleTop && + _theta.value <= _kCircleTop && + _theta.value >= 0.1 && // to allow the radians sign change at 15mins. + _minutes == 0) return; + + _thetaTween + ..begin = angle + ..end = angle; + }); + } + + Offset _position; + Offset _center; + + void _handlePanStart(DragStartDetails details) { + assert(!_dragging); + _dragging = true; + final RenderBox box = context.findRenderObject(); + _position = box.globalToLocal(details.globalPosition); + _center = box.size.center(Offset.zero); + //_updateThetaForPan(); + _notifyOnChangedIfNeeded(); + } + + void _handlePanUpdate(DragUpdateDetails details) { + double oldTheta = _theta.value; + _position += details.delta; + _updateThetaForPan(); + double newTheta = _theta.value; +// _updateRotations(oldTheta, newTheta); + _updateTurningAngle(oldTheta, newTheta); + _notifyOnChangedIfNeeded(); + } + + int _minuteHand(double angle) { + return _angleToDuration(angle).inMinutes.toInt(); + } + + int _secondHand(double angle) { + // Result is in [0; 59], even if overall time is >= 1 hour + return (_angleToSeconds(angle) % 60.0).toInt(); + } + + Duration _angleToDuration(double angle) { + return _secondToDuration(_angleToSeconds(angle)); + } + + Duration _secondToDuration(seconds) { + return Duration( + minutes: (seconds ~/ 60).toInt(), seconds: (seconds % 60.0).toInt()); + } + + double _angleToSeconds(double angle) { + // Coordinate transformation from mathematical COS to dial COS + double dialAngle = _kPiByTwo - angle; + + // Turn dial angle into minutes, may go beyond 60 minutes (multiple turns) + return dialAngle / _kTwoPi * 60.0; + } + + void _updateTurningAngle(double oldTheta, double newTheta) { + // Register any angle by which the user has turned the dial. + // + // The resulting turning angle fully captures the state of the dial, + // including multiple turns (= full hours). The [_turningAngle] is in + // mathematical coordinate system, i.e. 3-o-clock position being zero, and + // increasing counter clock wise. + + // From positive to negative (in mathematical COS) + if (newTheta > 1.5 * math.pi && oldTheta < 0.5 * math.pi) { + _turningAngle = _turningAngle - ((_kTwoPi - newTheta) + oldTheta); + } + // From negative to positive (in mathematical COS) + else if (newTheta < 0.5 * math.pi && oldTheta > 1.5 * math.pi) { + _turningAngle = _turningAngle + ((_kTwoPi - oldTheta) + newTheta); + } else { + _turningAngle = _turningAngle + (newTheta - oldTheta); + } + } + + void _handlePanEnd(DragEndDetails details) { + assert(_dragging); + _dragging = false; + _position = null; + _center = null; + //_notifyOnChangedIfNeeded(); + //_animateTo(_getThetaForDuration(widget.duration)); + } + + void _handleTapUp(TapUpDetails details) { + final RenderBox box = context.findRenderObject(); + _position = box.globalToLocal(details.globalPosition); + _center = box.size.center(Offset.zero); + _updateThetaForPan(); + _notifyOnChangedIfNeeded(); + + _animateTo(_getThetaForDuration(_getTimeForTheta(_theta.value))); + _dragging = false; + _position = null; + _center = null; + } + + List _buildSeconds(TextTheme textTheme) { + final TextStyle style = textTheme.subtitle1; + + const List _secondsMarkerValues = const [ + const Duration(seconds: 0), + const Duration(seconds: 5), + const Duration(seconds: 10), + const Duration(seconds: 15), + const Duration(seconds: 20), + const Duration(seconds: 25), + const Duration(seconds: 30), + const Duration(seconds: 35), + const Duration(seconds: 40), + const Duration(seconds: 45), + const Duration(seconds: 50), + const Duration(seconds: 55), + ]; + + final List labels = []; + for (Duration duration in _secondsMarkerValues) { + var painter = new TextPainter( + text: new TextSpan(style: style, text: duration.inSeconds.toString()), + textDirection: TextDirection.ltr, + )..layout(); + labels.add(painter); + } + return labels; + } + + @override + Widget build(BuildContext context) { + Color backgroundColor; + switch (themeData.brightness) { + case Brightness.light: + backgroundColor = Colors.grey[200]; + break; + case Brightness.dark: + backgroundColor = themeData.backgroundColor; + break; + } + + final ThemeData theme = Theme.of(context); + + int selectedDialValue; + _minutes = _minuteHand(_turningAngle); + _seconds = _secondHand(_turningAngle); + + return new GestureDetector( + excludeFromSemantics: true, + onPanStart: _handlePanStart, + onPanUpdate: _handlePanUpdate, + onPanEnd: _handlePanEnd, + onTapUp: _handleTapUp, + child: new CustomPaint( + painter: new _DialPainter( + pct: _pct, + multiplier: _minutes, + secondHand: _seconds, + context: context, + selectedValue: selectedDialValue, + labels: _buildSeconds(theme.textTheme), + backgroundColor: backgroundColor, + accentColor: themeData.accentColor, + theta: _theta.value, + textDirection: Directionality.of(context), + ), + )); + } +} + +/// A duration picker designed to appear inside a popup dialog. +/// +/// Pass this widget to [showDialog]. The value returned by [showDialog] is the +/// selected [Duration] if the user taps the "OK" button, or null if the user +/// taps the "CANCEL" button. The selected time is reported by calling +/// [Navigator.pop]. +class _DurationPickerDialog extends StatefulWidget { + /// Creates a duration picker. + /// + /// [initialTime] must not be null. + const _DurationPickerDialog( + {Key key, @required this.initialTime, this.snapToMins}) + : assert(initialTime != null), + super(key: key); + + /// The duration initially selected when the dialog is shown. + final Duration initialTime; + final double snapToMins; + + @override + _DurationPickerDialogState createState() => new _DurationPickerDialogState(); +} + +class _DurationPickerDialogState extends State<_DurationPickerDialog> { + @override + void initState() { + super.initState(); + _selectedDuration = widget.initialTime; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + localizations = MaterialLocalizations.of(context); + } + + Duration get selectedDuration => _selectedDuration; + Duration _selectedDuration; + + MaterialLocalizations localizations; + + void _handleTimeChanged(Duration value) { + setState(() { + _selectedDuration = value; + }); + } + + void _handleCancel() { + Navigator.pop(context); + } + + void _handleOk() { + Navigator.pop(context, _selectedDuration); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + final ThemeData theme = Theme.of(context); + + final Widget picker = new Padding( + padding: const EdgeInsets.all(16.0), + child: new AspectRatio( + aspectRatio: 1.0, + child: new _Dial( + duration: _selectedDuration, + onChanged: _handleTimeChanged, + snapToMins: widget.snapToMins, + ))); + + final Widget actions = new ButtonTheme.bar( + child: new ButtonBar(children: [ + new FlatButton( + child: new Text(localizations.cancelButtonLabel), + onPressed: _handleCancel), + new FlatButton( + child: new Text(localizations.okButtonLabel), onPressed: _handleOk), + ])); + + final Dialog dialog = new Dialog(child: new OrientationBuilder( + builder: (BuildContext context, Orientation orientation) { + final Widget pickerAndActions = new Container( + color: theme.dialogBackgroundColor, + child: new Column( + mainAxisSize: MainAxisSize.min, + children: [ + new Expanded( + child: + picker), // picker grows and shrinks with the available space + actions, + ], + ), + ); + + assert(orientation != null); + switch (orientation) { + case Orientation.portrait: + return new SizedBox( + width: _kDurationPickerWidthPortrait, + height: _kDurationPickerHeightPortrait, + child: new Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + new Expanded( + child: pickerAndActions, + ), + ])); + case Orientation.landscape: + return new SizedBox( + width: _kDurationPickerWidthLandscape, + height: _kDurationPickerHeightLandscape, + child: new Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + new Flexible( + child: pickerAndActions, + ), + ])); + } + return null; + })); + + return new Theme( + data: theme.copyWith( + dialogBackgroundColor: Colors.transparent, + ), + child: dialog, + ); + } + + @override + void dispose() { + super.dispose(); + } +} + +/// Shows a dialog containing the duration picker. +/// +/// The returned Future resolves to the duration selected by the user when the user +/// closes the dialog. If the user cancels the dialog, null is returned. +/// +/// To show a dialog with [initialTime] equal to the current time: +/// +/// ```dart +/// showDurationPicker( +/// initialTime: new Duration.now(), +/// context: context, +/// ); +/// ``` +Future showDurationPicker( + {@required BuildContext context, + @required Duration initialTime, + double snapToMins}) async { + assert(context != null); + assert(initialTime != null); + + return await showDialog( + context: context, + builder: (BuildContext context) => new _DurationPickerDialog( + initialTime: initialTime, snapToMins: snapToMins), + ); +} + +class DurationPicker extends StatelessWidget { + final Duration duration; + final ValueChanged onChange; + final double snapToMins; + + final double width; + final double height; + + DurationPicker( + {this.duration = const Duration(minutes: 0), + @required this.onChange, + this.snapToMins, + this.width, + this.height}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width ?? _kDurationPickerWidthPortrait / 1.5, + height: height ?? _kDurationPickerHeightPortrait / 1.5, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: _Dial( + duration: duration, + onChanged: onChange, + snapToMins: snapToMins, + ), + ), + ])); + } +}