From bd98e577796b81d2ad80e81978464f221263c4c4 Mon Sep 17 00:00:00 2001 From: github-actions Date: Sun, 9 Aug 2020 00:35:35 +0000 Subject: [PATCH] =?UTF-8?q?Github=20Action=E8=87=AA=E5=8A=A8=E5=8F=91?= =?UTF-8?q?=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 21 ++ README.md | 11 + assets/logo.png | Bin 0 -> 5151 bytes lib/activities/book.dart | 246 +++++++++++++++ lib/activities/chapter.dart | 352 ++++++++++++++++++++++ lib/activities/checkData.dart | 136 +++++++++ lib/activities/home.dart | 297 ++++++++++++++++++ lib/activities/hot.dart | 254 ++++++++++++++++ lib/activities/search/search.dart | 94 ++++++ lib/activities/search/source.dart | 46 +++ lib/activities/search/tab.dart | 186 ++++++++++++ lib/activities/setting/hideStatusBar.dart | 40 +++ lib/activities/setting/setting.dart | 283 +++++++++++++++++ lib/activities/test.dart | 48 +++ lib/classes/book.dart | 138 +++++++++ lib/classes/data.dart | 155 ++++++++++ lib/classes/networkImageSSL.dart | 93 ++++++ lib/crawler/http.dart | 71 +++++ lib/main.dart | 101 +++++++ lib/utils.dart | 50 +++ lib/widgets/book.dart | 196 ++++++++++++ lib/widgets/checkConnect.dart | 138 +++++++++ lib/widgets/dbSourceListWidget.dart | 13 + lib/widgets/favorites.dart | 272 +++++++++++++++++ lib/widgets/histories.dart | 131 ++++++++ lib/widgets/pullToRefreshHeader.dart | 74 +++++ lib/widgets/quick.dart | 251 +++++++++++++++ lib/widgets/sliverExpandableGroup.dart | 95 ++++++ lib/widgets/utils.dart | 45 +++ pubspec.yaml | 106 +++++++ 30 files changed, 3943 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/logo.png create mode 100644 lib/activities/book.dart create mode 100644 lib/activities/chapter.dart create mode 100644 lib/activities/checkData.dart create mode 100644 lib/activities/home.dart create mode 100644 lib/activities/hot.dart create mode 100644 lib/activities/search/search.dart create mode 100644 lib/activities/search/source.dart create mode 100644 lib/activities/search/tab.dart create mode 100644 lib/activities/setting/hideStatusBar.dart create mode 100644 lib/activities/setting/setting.dart create mode 100644 lib/activities/test.dart create mode 100644 lib/classes/book.dart create mode 100644 lib/classes/data.dart create mode 100644 lib/classes/networkImageSSL.dart create mode 100644 lib/crawler/http.dart create mode 100644 lib/main.dart create mode 100644 lib/utils.dart create mode 100644 lib/widgets/book.dart create mode 100644 lib/widgets/checkConnect.dart create mode 100644 lib/widgets/dbSourceListWidget.dart create mode 100644 lib/widgets/favorites.dart create mode 100644 lib/widgets/histories.dart create mode 100644 lib/widgets/pullToRefreshHeader.dart create mode 100644 lib/widgets/quick.dart create mode 100644 lib/widgets/sliverExpandableGroup.dart create mode 100644 lib/widgets/utils.dart create mode 100644 pubspec.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c06738e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 nrop19 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..25ce9d4 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# 微漫 v1.1.1 [宣传页面](https://nrop19.github.io/weiman_app) + +### 微漫脱敏后的开源代码 + +#### 不解答任何代码上的问题 + +#### App的问题请到 [Telegram群](https://t.me/boring_programer) 讨论 + +- 删除了android端文件夹,涉及到apk签名等敏感文件 +- 删除了ios端文件夹 +- 删除了lib/crawler/里的其它文件,保护被爬网站的同时防止被爬网站加大防爬难度。 \ No newline at end of file diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7ea19f54eae2348b857dc3454aa3ad84aae3a52b GIT binary patch literal 5151 zcmaJ_c{r5q+n%wLeT&I5#xBcDj2UBR#ukxm*|N-7W5&$L5HFD$WC;l&Mj|O&q^x62 zT12SGQZ%xZC6uj?_xHZ<@B8C>zvnof=f3XixX$xh?(?|+c#`Z+ne%XgxBvhEkEI3L z{$RB@d^y+-zK+ZF<_C*78RJ4egZCpxcoT5|V=UeW2eu6H_Q%=dys?pC{Wv`UfLSTf z!G-K%V~z5~hp2lW#;8+52nT2YK+lju@b(SHk-j^s-WB#;B~q2NPCZy$U(Ss!vx>3>=XA^bxdO8RR|2LscfcoQ^W>d?cM z{tmRU`Ts*hLjH*+k?nE+t@r;VCOJeBa2obF5% zLdy)HW2UKf0tUnAAQ4(H6R0*EgNAFGnw~)XjYZ>q!$WYP#Z}0f#js;zPiHHjWDX_gWDD%J&x*`|q_N{}rooP>jam@&2#l{@Zn+p+oZ@@g8jc zkv}f1kb1x>nlo^H*R<%1G_Vf^bU6gM`sHqiZtB93uB1@o}o}``g-L z+Sd&kZAU##m}cZlunLTw{r(unB-F0bdKIIBhJjsc_)q2)vM8U$ChM+!Fv^o|P zw+-omlI#@TMA6(X&IWN-x7fd1UxSr2iD{PGV(#jT2pDIGD+$Ke4_~UR=B0gm=l!Ut z64OK9n-2(G*xMgVl9EB8+$FT!0cHI9=8sfzN-QlCe6}HC>g{Y?DcQ=9T?j~G+O+|*SxLeMddB~Dv#&u!@{ z&Pu#$Y>%Iw8L6axss|GP@N%Wr6?yV@0)f3t;_`m*86#z?-6BTL4WO_8&cq~YHfxXa zxjg+7K6d}iZr80+qeKm}xL*Ec2xtJl+GMHJv=eyQFPhmZm1&O4@VT0xY7p?jK6IC5 zFw&X+gDHDZOv~c2GO}W4%W=Al9pA&yZR{ypc~X0=m4dRqS2FP=1{*0rappzG~R z2a=omha?^tpYc87BuTuIXeT`` zTgk{UEj}ThU7Oyvi=qSt4}Lo0`(8}e=}Z2bX->NNQnkb%=c5Q`GE_g`g#V%1d_lQr z_jY@4!jI>POw`(C(Qx1w&Z{6fVq)4T z8)2kjfG}>W-9y@spJ53&LP~ylhEYQ3BCU+*%n+MPh(=RQtI!Xyj~^8{=r5(K=5 z3t8+ZUWQApwb(QTpLzVNJ*5Jx1bmwK?CwiYN))dy&9=EeaC*nk=SJ~Hu&Bq!;0 z50(~Bg9MM}nN=6{6lEJRt~e*cw|?cgkktt4MN~0AOX$5Xcf`G%IHNyB)+&CH9T`i6 zg&2$bsd)lCX-%`4lJcyBWoSV)W6ZvA4o%b=;5%8BSSw}4M$c#~@>UUtk%Z@^2k|PiH&0lghlnanU%A<6Q!;gg3(X)_ z9aj#>C5n`k_L2M}G!V^|G&#``)c9~38~LWN{n zqjuM>r&3l8i!Q&RU9uN`bv8|0%pjYpY%qkma{7IS_n$DcE?e!cKRU5AJbzj%MqA}sxP6v^9eY1! zUPNQ)YePT)moH<~aAk@#AnLn6QMRW40QkM-F>CWwq>v)-C|K6;YPS-(x8rh>Bj{_x zUGlMqZEGNx0$x@5u0e7ObAy5=)1C1bq0-sdOJyvd9erri(1(GD``2SDftqPCiL;xc zna4G>J)1#IU$t2_m+517u}im*BF6Ub*s9abHpsG$-C>fBsR5NYBC4*v6u+926ZLb~ zbJp)D-N)Lvyq>2pwH(@*%^l=sOJGtF(F2JnNTZU6wu7o#)`555#_3CYL3nZp@{^e( zKNFM;r7b4`4rC31jBfO=@`2*sY~McE#qSU2U!;D4YXuR8vb*1KHuR+?jVNY(S?E0H zcaE*y)h3m@s1YI;`{K_12Gmb?Df?caOV*i4WTJ;|!E#+>i9e)OyB_*#RRDQ2ww4cb zbyMh8ru7q#wO)vz?dIB@QL`5z6~=WJ*)H5XmH0-;(qdL{$T+l6O2=au*6#34O60aBecgf2otL`liFV7+py}{KnuO~z9w98?y zy~J!baT2yZ+?XEa-NvR+evFR9fgtKK3LVMeTPsuWaK)NAzFRes1DZ(k>~}r1N3zRa_l*hcMvu5^dbC`I(w8v@6RfBeTV%BhE@My9C2#b&6Lt9hz{y9@Wl9?tm!N%zs$0dmY&%~FBnrV6o8HO(iJQc`&> zZ@?x6v!k zNu*~#%IPi}F7DcVGRKjXW~`ekcUBFmk)Kxp2!EIoCtJNa`1b72x*BQ~wH+UEBdN+h zzHbKOk$SWN{YqN{XTJS$E@yU?_93)SsxHkP7Lmbc^bJ^j>w5Y_T~{6U7^J^U&{AN_ z3)c07T&cRIXe7>tE`3xTV>x0utA*(J1h%s<{KakdmLWQg_2X3;)~pBvs)L3y)YPpe zfO=Zm(htlud&iu-t%HTQ)!D~z>M0`O_vDe8S?OP}M|3g_DtDNohU^g*QWQ8SS}zR3 z-R9#W$;$*mZ_}w4N_ka4)}sQ6!r~@`*{69;F3+Aea@O!x{hsKYiA%X=rnl z3eGFK&UU&X_orCAt+du9KvKcd=Ho2ePSER?QDH`4j4aP^oz`O0rEEf`vkBFmDtbS4 z5dt5tf1|ceC&)xFJz#eA-7Vl}U@sE?!xfyHM!r z@5g}E+(&f&9dQZP3TL0&MK?Sen2vvInW`QQkUDg&HGP~tnYi-fbz^3tvR8aPcS%Q!nX(w8_cZ9N=xjRCDV1pF?sMGVX5`~J!UuCq zRrp$N1zTFX2%U^w#uU8!t*m#79esl<=Mnb{_cb+DSCW2Rh{_+K zHsxm~+p^AQ@Y$g}N0LHr#is5_?~0@~`Y|anCFqFv)T{T<8XkKMVIvXI&9|Md0h&8a=M+c7n*=bW_4 z?-WOGq~Ps@ zh2sHGPcu^-rhSg9+}iY41Rni$g4N>naTB)~wYCrSLb|_ys!- zD~w^Q&8gVAj}Xnb%km`YMx942%0&U!zEZ5M-f!8=p{G*5I?FdW`fGJEF33tOym7j~ z)wNCe__lfoG_6coN=TQ61tx1>8w8H4by(Ba(Ly`o)@;H%+S!raPmttXa)7u~P6%Uy=U4r=eFGV _ActivityBook(); +} + +class _ActivityBook extends State { + final GlobalKey _refresh = GlobalKey(); + ScrollController _scrollController; + + bool _reverse = false; + Book book; + + @override + void initState() { + book = widget.book; + SchedulerBinding.instance.addPostFrameCallback((_) { + _refresh.currentState + .show(notificationDragOffset: SliverPullToRefreshHeader.height); + }); + _scrollController = ScrollController(); + print('${widget.book.toJson()}'); + super.initState(); + } + + @override + dispose() { + _scrollController.dispose(); + super.dispose(); + } + + Future loadBook() async { + book = await book.http.getBook(book.aid); + book.history = Data.getHistories()[book.aid]?.history; + if (mounted) setState(() {}); + return true; + } + + _openChapter(Chapter chapter) { + setState(() { + book.history = History(cid: chapter.cid, cname: chapter.cname, time: 0); + openChapter(context, book, chapter); + }); + } + + favoriteBook() async { + final fav = Provider.of(context, listen: false); + if (book.isFavorite()) { + final sure = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('确认取消收藏?'), + // content: Text('删除这本藏书后,首页的快速导航也会删除这本藏书'), + actions: [ + FlatButton( + child: Text('确认'), + onPressed: () => Navigator.pop(context, true), + ), + RaisedButton( + child: Text('取消'), + onPressed: () => Navigator.pop(context, false), + ), + ], + )); + if (sure == true) { + final inQuickList = Data.quickIdList().contains(book.aid); + if (inQuickList) {} + fav.remove(book); + } + } else { + fav.add(book); + } + setState(() {}); + } + + List _sort() { + final List list = List.from(book.chapters); + if (_reverse) return list.reversed.toList(); + return list; + } + + IndexedWidgetBuilder buildChapters(List chapters) { + IndexedWidgetBuilder builder = (BuildContext context, int index) { + final chapter = chapters[index]; + Widget child = WidgetChapter( + chapter: chapter, + onTap: _openChapter, + read: chapter.cid == book.history?.cid, + ); + if (index < chapters.length - 1) + child = DecoratedBox( + decoration: border, + child: child, + ); + return child; + }; + return builder; + } + + @override + Widget build(BuildContext context) { + final isFavorite = book.isFavorite(); + Color color = isFavorite ? Colors.red : Colors.white; + IconData icon = isFavorite ? Icons.favorite : Icons.favorite_border; + final List chapters = _sort(); + final history = []; + if (book.history != null && book.chapters.length > 0) { + final chapter = book.chapters + .firstWhere((chapter) => chapter.cid == book.history.cid); + history.add(ListTile(title: Text('阅读历史'))); + history.add(WidgetChapter( + chapter: chapter, + onTap: _openChapter, + read: true, + )); + history.add(ListTile(title: Text('下一章'))); + final nextIndex = book.chapters.indexOf(chapter) + 1; + if (nextIndex < book.chapterCount) { + history.add(WidgetChapter( + chapter: book.chapters[nextIndex], + onTap: _openChapter, + read: false, + )); + } else { + history.add(ListTile(subtitle: Text('没有了'))); + } + history.add(SizedBox(height: 20)); + } + history.add(ListTile(title: Text('章节列表'))); + + return Scaffold( + body: PullToRefreshNotification( + key: _refresh, + onRefresh: loadBook, + maxDragOffset: kToolbarHeight * 2, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + SliverAppBar( + floating: true, + pinned: true, + title: Text(book.name), + expandedHeight: 200, + actions: [ + IconButton( + onPressed: () { + setState(() { + _reverse = !_reverse; + setState(() {}); + }); + }, + icon: Icon(_reverse + ? FontAwesomeIcons.sortNumericDown + : FontAwesomeIcons.sortNumericDownAlt)), + IconButton( + onPressed: favoriteBook, icon: Icon(icon, color: color)) + ], + flexibleSpace: FlexibleSpaceBar( + background: SafeArea( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: EdgeInsets.only( + top: 50, left: 20, right: 10, bottom: 20), + height: 160, + child: Hero( + tag: widget.heroTag, + child: ExtendedImage( + width: 100, + image: NetworkImageSSL( + widget.book.http, + widget.book.avatar, + ), + ), + ), + ), + Expanded( + child: Container( + padding: EdgeInsets.only(top: 50, right: 20), + child: ListView( + children: [ + Text( + '作者:' + (book.author ?? ''), + style: TextStyle(color: Colors.white), + ), + Container( + margin: EdgeInsets.only(top: 10), + ), + Text( + '简介:\n' + (book.description ?? ''), + softWrap: true, + style: + TextStyle(color: Colors.white, height: 1.2), + ), + ], + ), + )), + ], + ), + ), + ), + ), + PullToRefreshContainer((info) => SliverPullToRefreshHeader( + info: info, + onTap: () => _refresh.currentState.show( + notificationDragOffset: SliverPullToRefreshHeader.height), + )), + SliverToBoxAdapter( + child: Column( + children: history, + crossAxisAlignment: CrossAxisAlignment.start, + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + buildChapters(chapters), + childCount: chapters.length, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/activities/chapter.dart b/lib/activities/chapter.dart new file mode 100644 index 0000000..a124732 --- /dev/null +++ b/lib/activities/chapter.dart @@ -0,0 +1,352 @@ +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import 'package:oktoast/oktoast.dart'; +import 'package:provider/provider.dart'; +import 'package:pull_to_refresh_notification/pull_to_refresh_notification.dart'; +import 'package:sticky_headers/sticky_headers/widget.dart'; +import 'package:weiman/activities/setting/hideStatusBar.dart'; +import 'package:weiman/activities/setting/setting.dart'; + +import '../classes/book.dart'; +import '../classes/data.dart'; +import '../classes/networkImageSSL.dart'; +import '../utils.dart'; +import '../widgets/book.dart'; +import '../widgets/pullToRefreshHeader.dart'; + +class ActivityChapter extends StatefulWidget { + final Book book; + final Chapter chapter; + + ActivityChapter(this.book, this.chapter); + + @override + _ActivityChapter createState() => _ActivityChapter(); +} + +class _ActivityChapter extends State { + final _scaffoldKey = GlobalKey(); + PageController _pageController; + int showIndex = 0; + bool hasNextImage = true; + + @override + void initState() { + _pageController = PageController( + keepPage: false, + initialPage: widget.book.chapters.indexOf(widget.chapter)); + super.initState(); + saveHistory(widget.chapter); + SchedulerBinding.instance.addPostFrameCallback((timeStamp) { + final hide = Provider.of(context, listen: false).hide; + if (hide == HideOption.always) { + hideStatusBar(); + } + }); + } + + @override + void dispose() { + _pageController?.dispose(); + showStatusBar(); + super.dispose(); + } + + void pageChanged(int page) { + saveHistory(widget.book.chapters[page]); + } + + void saveHistory(Chapter chapter) { + Data.addHistory(widget.book, chapter); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _scaffoldKey, + endDrawer: ChapterDrawer( + book: widget.book, + onTap: (chapter) { + _pageController.jumpToPage(widget.book.chapters.indexOf(chapter)); + }, + ), + body: PageView.builder( + physics: AlwaysScrollableClampingScrollPhysics(), + controller: _pageController, + itemCount: widget.book.chapters.length, + onPageChanged: pageChanged, + itemBuilder: (ctx, index) { + return ChapterContentView( + actions: [ + IconButton( + icon: Icon(Icons.menu), + onPressed: () { + _scaffoldKey.currentState.openEndDrawer(); + }, + ), + ], + book: widget.book, + chapter: widget.book.chapters[index], + ); + }, + ), + ); + } +} + +class ChapterDrawer extends StatefulWidget { + final Book book; + final void Function(Chapter chapter) onTap; + + const ChapterDrawer({ + Key key, + @required this.book, + @required this.onTap, + }) : super(key: key); + + @override + _ChapterDrawer createState() => _ChapterDrawer(); +} + +class _ChapterDrawer extends State { + ScrollController _controller; + int read; + + @override + void initState() { + super.initState(); + updateRead(); + _controller = + ScrollController(initialScrollOffset: WidgetChapter.height * read); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void updateRead() { + final readChapter = widget.book.chapters + .firstWhere((chapter) => widget.book.history?.cid == chapter.cid); + read = widget.book.chapters.indexOf(readChapter); + } + + void scrollToRead() { + setState(() { + updateRead(); + }); + _controller.animateTo( + WidgetChapter.height * read, + duration: Duration(milliseconds: 200), + curve: Curves.linear, + ); + } + + @override + Widget build(BuildContext context) { + return Drawer( + child: SafeArea( + child: ListView( + controller: _controller, + children: ListTile.divideTiles( + context: context, + tiles: widget.book.chapters.map((chapter) { + final isRead = widget.book.history?.cid == chapter.cid; + return WidgetChapter( + chapter: chapter, + onTap: (chapter) { + if (widget.onTap != null) widget.onTap(chapter); + SchedulerBinding.instance.addPostFrameCallback((_) { + scrollToRead(); + }); + }, + read: isRead, + ); + }), + ).toList(), + ), + ), + ); + } +} + +class ChapterContentView extends StatefulWidget { + final Book book; + final Chapter chapter; + final List actions; + + const ChapterContentView({Key key, this.book, this.chapter, this.actions}) + : super(key: key); + + @override + _ChapterContentView createState() => _ChapterContentView(); +} + +class _ChapterContentView extends State { + final GlobalKey _refresh = GlobalKey(); + final List images = []; + TextStyle _style = TextStyle(color: Colors.white); + BoxDecoration _decoration = + BoxDecoration(color: Colors.black.withOpacity(0.4)); + + bool loading = true; + + ScrollController scrollController; + + @override + initState() { + scrollController = ScrollController(); + super.initState(); + Data.addHistory(widget.book, widget.chapter); + SchedulerBinding.instance.addPostFrameCallback((_) { + _refresh?.currentState + ?.show(notificationDragOffset: SliverPullToRefreshHeader.height); + final hide = Provider.of(context, listen: false).hide; + if (hide == HideOption.auto) { + scrollController.addListener(() { + final isUp = scrollController.position.userScrollDirection == + ScrollDirection.forward; + if (isUp) + showStatusBar(); + else + hideStatusBar(); + }); + } + }); + } + + @override + dispose() { + scrollController.dispose(); + super.dispose(); + } + + Future fetchImages() async { + print('fetchImages'); + loading = true; + images.clear(); + if (mounted) setState(() {}); + try { + images.addAll(await widget.book.http + .getChapterImages(widget.book, widget.chapter) + .timeout(Duration(seconds: 10))); + } catch (e) { + print('错误 $e'); + showToastWidget( + GestureDetector( + child: Container( + child: Text('读取章节内容出现错误\n点击复制错误内容'), + color: Colors.black.withOpacity(0.5), + padding: EdgeInsets.all(10), + ), + onTap: () async { + await Clipboard.setData(ClipboardData(text: e.toString())); + final content = await Clipboard.getData(Clipboard.kTextPlain); + print('粘贴板 ${content.text}'); + }, + ), + duration: Duration(seconds: 5), + handleTouch: true, + ); + return false; + // throw(e); + } + loading = false; + if (mounted) setState(() {}); + return true; + } + + @override + Widget build(BuildContext context) { + return PullToRefreshNotification( + key: _refresh, + onRefresh: fetchImages, + maxDragOffset: kToolbarHeight * 2, + child: CustomScrollView( + physics: AlwaysScrollableClampingScrollPhysics(), + controller: scrollController, + slivers: [ + SliverAppBar( + title: Text(widget.chapter.cname), + pinned: false, + floating: true, + actions: widget.actions, + ), + PullToRefreshContainer( + (info) => SliverPullToRefreshHeader( + info: info, + onTap: () => _refresh.currentState.show( + notificationDragOffset: SliverPullToRefreshHeader.height), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (ctx, i) { + return StickyHeader( + overlapHeaders: true, + header: SafeArea( + top: true, + bottom: false, + child: Row( + children: [ + Container( + padding: EdgeInsets.all(5), + decoration: _decoration, + child: Text( + '${i + 1} / ${images.length}', + style: _style, + ), + ), + ], + ), + ), + content: ExtendedImage( + image: NetworkImageSSL(widget.book.http, images[i]), + loadStateChanged: (state) { + switch (state.extendedImageLoadState) { + case LoadState.loading: + return SizedBox( + height: 300, + child: Center( + child: CircularProgressIndicator(), + ), + ); + break; + case LoadState.failed: + return SizedBox( + width: double.infinity, + height: 300, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('图片读取失败'), + RaisedButton( + child: Text('重试'), + onPressed: state.reLoadImage, + ), + ], + ), + ); + break; + default: + return ExtendedRawImage( + image: state.extendedImageInfo?.image, + ); + } + }, + ), + ); + }, + childCount: images.length, + ), + ), + ], + ), + ); + } +} diff --git a/lib/activities/checkData.dart b/lib/activities/checkData.dart new file mode 100644 index 0000000..35d1eeb --- /dev/null +++ b/lib/activities/checkData.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:oktoast/oktoast.dart'; + +import '../classes/data.dart'; + +class ActivityCheckData extends StatefulWidget { + @override + _State createState() => _State(); +} + +enum CheckState { + Uncheck, + Pass, + Fail, +} + +final titleTextStyle = TextStyle(fontSize: 14, color: Colors.blue), + passStyle = TextStyle(color: Colors.green), + failStyle = TextStyle(color: Colors.red); + +class _State extends State { + CheckState firstState; + int firstLength = 0; + final TextSpan secondResults = TextSpan(); + TextEditingController _outputController, _inputController; + + @override + void initState() { + super.initState(); + _outputController = TextEditingController(); + _inputController = TextEditingController(); + } + + TextSpan first() { + String text; + switch (firstState) { + case CheckState.Pass: + text = '有数据, 一共 $firstLength 本收藏'; + break; + case CheckState.Fail: + text = '没有收藏数据'; + break; + default: + text = '未检查'; + } + return TextSpan( + text: text, + style: firstState == CheckState.Pass ? passStyle : failStyle); + } + + @override + Widget build(BuildContext context) { + final firstChildren = [ + Text('检查漫画收藏列表'), + RaisedButton( + child: Text('检查'), + color: Colors.blue, + textColor: Colors.white, + onPressed: () { + final has = Data.has(Data.favoriteBooksKey); + if (has) { + final String str = Data.instance.getString(Data.favoriteBooksKey); + final Map map = jsonDecode(str); + firstLength = map.keys.length; + _outputController.text = str; + } + firstState = firstLength > 0 ? CheckState.Pass : CheckState.Fail; + + setState(() {}); + }, + ), + RichText( + text: TextSpan( + text: '结果:', + children: [first()], + style: TextStyle(color: Colors.black)), + ), + ]; + if (firstState == CheckState.Pass) { + firstChildren.add(Text('点击复制')); + firstChildren.add(TextField( + maxLines: 8, + controller: _outputController, + onTap: () { + showToast('已经复制'); + Clipboard.setData(ClipboardData(text: _outputController.text)); + }, + )); + } + return Scaffold( + appBar: AppBar( + title: Text('收藏数据检修'), + ), + body: ListView(children: [ + Card( + child: Padding( + padding: EdgeInsets.all(5), + child: Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: firstChildren, + ), + ), + ), + ), + Card( + child: Padding( + padding: EdgeInsets.all(5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('导入收藏数据'), + TextField( + controller: _inputController, + maxLines: 8, + ), + RaisedButton( + child: Text('导入'), + onPressed: () { + if (_inputController.text.length > 0) { + Data.instance.setString( + Data.favoriteBooksKey, _inputController.text); + } + }, + ), + ], + ), + ), + ), + ]), + ); + } +} diff --git a/lib/activities/home.dart b/lib/activities/home.dart new file mode 100644 index 0000000..d36ac9e --- /dev/null +++ b/lib/activities/home.dart @@ -0,0 +1,297 @@ +import 'package:dynamic_theme/dynamic_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:oktoast/oktoast.dart'; +import 'package:package_info/package_info.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../activities/checkData.dart'; +import '../activities/hot.dart'; +import '../activities/search/search.dart'; +import '../activities/test.dart'; +import '../classes/book.dart'; +import '../main.dart'; +import '../widgets/checkConnect.dart'; +import '../widgets/favorites.dart'; +import '../widgets/histories.dart'; +import '../widgets/quick.dart'; +import 'setting/setting.dart'; + +class ActivityHome extends StatefulWidget { + final PackageInfo packageInfo; + + const ActivityHome(this.packageInfo, {Key key}) : super(key: key); + + @override + State createState() => HomeState(); +} + +class HomeState extends State { + final _scaffoldKey = GlobalKey(); + final List histories = []; + final List quick = []; + final GlobalKey _quickState = GlobalKey(); + + bool showFavorite = true; + + @override + void initState() { + super.initState(); + analytics.setCurrentScreen(screenName: '/activity_home'); + + /// 提前检查一次藏书的更新情况 + SchedulerBinding.instance.addPostFrameCallback((_) async { + autoSwitchTheme(); + FavoriteData favData = Provider.of(context, listen: false); + await favData.loadBooksList(); + await favData.checkNews( + Provider.of(context, listen: false).autoCheck); + final updated = + favData.hasNews.values.where((int count) => count > 0).length; + if (updated > 0) + showToast( + '$updated 本藏书有更新', + backgroundColor: Colors.black.withOpacity(0.5), + ); + }); + } + + void autoSwitchTheme() async { + final isDark = await DynamicTheme.of(context).loadBrightness(); + final nowIsDark = DynamicTheme.of(context).brightness == Brightness.dark; + if (isDark != nowIsDark) + DynamicTheme.of(context) + .setBrightness(isDark ? Brightness.dark : Brightness.light); + } + + void gotoSearch() { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: '/activity_search'), + builder: (context) => ActivitySearch())); + } + + void gotoRecommend() { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: '/activity_recommend'), + builder: (_) => ActivityRank(), + )); + } + + void gotoPatreon() { + launch('https://www.patreon.com/nrop19'); + } + + bool isEdit = false; + + void _draggableModeChanged(bool mode) { + print('mode changed $mode'); + isEdit = mode; + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final width = (media.size.width * 0.8).roundToDouble(); + return Scaffold( + key: _scaffoldKey, + appBar: AppBar( + title: Text('微漫 v' + widget.packageInfo.version), + automaticallyImplyLeading: false, + leading: isEdit + ? IconButton( + icon: Icon(Icons.arrow_back_ios), + onPressed: () { + _quickState.currentState.exit(); + }, + ) + : null, + actions: [ + /// 黑白样式切换 + IconButton( + onPressed: () { + DynamicTheme.of(context).setBrightness( + Theme.of(context).brightness == Brightness.dark + ? Brightness.light + : Brightness.dark); + }, + icon: Icon(Theme.of(context).brightness == Brightness.light + ? FontAwesomeIcons.lightbulb + : FontAwesomeIcons.solidLightbulb), + ), + SizedBox(width: 20), + + /// 设置界面 + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: '/activity_setting'), + builder: (_) => ActivitySetting())); + }, + icon: Icon(FontAwesomeIcons.cog), + ), + + /// 收藏列表 + IconButton( + onPressed: () { + showFavorite = true; + _scaffoldKey.currentState.openEndDrawer(); + }, + icon: Icon( + Icons.favorite, + color: Colors.red, + ), + ), + + /// 浏览历史列表 + IconButton( + onPressed: () { + showFavorite = false; + // getHistory(); + _scaffoldKey.currentState.openEndDrawer(); + }, + icon: Icon(Icons.history), + ), + ], + ), + endDrawer: Drawer( + child: LayoutBuilder( + builder: (_, constraints) { + if (showFavorite) { + return FavoriteList(); + } else { + return Histories(); + } + }, + ), + ), + body: Center( + child: SingleChildScrollView( + padding: EdgeInsets.only(left: 40, right: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Container( + child: OutlineButton( + onPressed: gotoSearch, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search, + color: Colors.blue, + ), + Text( + '搜索漫画', + style: TextStyle(color: Colors.blue), + ) + ], + ), + borderSide: BorderSide(color: Colors.blue, width: 2), + shape: StadiumBorder(), + ), + ), + Row( + children: [ + Expanded( + flex: 7, + child: OutlineButton( + onPressed: gotoRecommend, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.whatshot, + color: Colors.red, + ), + Text( + '热门漫画', + style: TextStyle(color: Colors.red), + ) + ], + ), + borderSide: BorderSide(color: Colors.red, width: 2), + shape: StadiumBorder(), + ), + ), + ], + ), + Center( + child: Quick( + key: _quickState, + width: width, + draggableModeChanged: _draggableModeChanged, + ), + ), + CheckConnectWidget(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () async { + launch('https://bbs.level-plus.net/'); + }, + child: Text( + '魂+论坛首发', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.blue[200], + decoration: TextDecoration.underline, + ), + ), + ), + SizedBox(width: 20), + GestureDetector( + onTap: () async { + if (await canLaunch('tg://resolve?domain=weiman_app')) + launch('tg://resolve?domain=weiman_app'); + else + launch('https://t.me/weiman_app'); + }, + child: Text( + 'Telegram 广播频道', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.blue[200], + decoration: TextDecoration.underline, + ), + ), + ), + ], + ), + Visibility( + visible: isDevMode, + child: FlatButton( + onPressed: () { + Navigator.push(context, + MaterialPageRoute(builder: (_) => ActivityCheckData())); + }, + child: Text('操作 收藏列表数据'), + ), + ), + ], + ), + ), + ), + floatingActionButton: isDevMode + ? FloatingActionButton( + child: Text('测试'), + onPressed: () { + Navigator.push( + context, MaterialPageRoute(builder: (_) => ActivityTest())); + }, + ) + : null, + ); + } +} diff --git a/lib/activities/hot.dart b/lib/activities/hot.dart new file mode 100644 index 0000000..4f301b4 --- /dev/null +++ b/lib/activities/hot.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:loading_more_list/loading_more_list.dart'; +import 'package:pull_to_refresh_notification/pull_to_refresh_notification.dart'; + +import '../classes/book.dart'; +import '../crawler/http.dart'; +import '../crawler/http18Comic.dart'; +import '../widgets/book.dart'; +import '../widgets/pullToRefreshHeader.dart'; + +class ActivityRank extends StatefulWidget { + @override + _ActivityRank createState() => _ActivityRank(); +} + +class _ActivityRank extends State + with SingleTickerProviderStateMixin { + TabController controller; + + @override + void initState() { + controller = TabController(length: 2, vsync: this); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('热门漫画'), + bottom: TabBar(controller: controller, tabs: [ + Tab(text: '韩漫'), + Tab(text: '全部'), + ]), + ), + body: TabBarView(controller: controller, children: [ + HotTab(http: Http18Comic.instance, type: '/hanman'), + HotTab(http: Http18Comic.instance, type: ''), + ]), + ); + } +} + +class SourceList extends LoadingMoreBase { + final String type; + final HttpBook http; + int page = 1; + String firstBookId = null; + + bool hasMore = true; + + SourceList({this.type, this.http}); + + @override + Future loadData([bool isloadMoreAction = false]) async { + try { + final books = await http.hotBooks(type, page); + if (books.isEmpty) { + hasMore = false; + } else { + if (firstBookId == books[0].aid) { + hasMore = false; + } else { + firstBookId = books[0].aid; + page++; + this.addAll(books); + } + } + return true; + } catch (e) { + return false; + } + } + + @override + Future refresh([bool notifyStateChanged = false]) { + hasMore = true; + page = 1; + return super.refresh(notifyStateChanged); + } +} + +class HotTab extends StatefulWidget { + final String type; + final HttpBook http; + + const HotTab({Key key, this.type, this.http}) : super(key: key); + + @override + _HotTab createState() => _HotTab(); +} + +class _HotTab extends State { + final GlobalKey _refresh = GlobalKey(); + SourceList sourceList; + + @override + void initState() { + sourceList = SourceList(type: widget.type, http: widget.http); + super.initState(); + + SchedulerBinding.instance.addPostFrameCallback((_) => _refresh?.currentState + ?.show(notificationDragOffset: SliverPullToRefreshHeader.height)); + } + + @override + Widget build(BuildContext context) { + return PullToRefreshNotification( + key: _refresh, + pullBackOnRefresh: true, + onRefresh: () => sourceList.refresh(), + child: CustomScrollView( + slivers: [ + PullToRefreshContainer( + (info) => SliverPullToRefreshHeader(info: info), + ), + LoadingMoreSliverList(SliverListConfig( + sourceList: sourceList, + indicatorBuilder: indicatorBuilder, + itemBuilder: (_, book, __) => WidgetBook( + book, + subtitle: book.author, + ), + )), + ], + ), + ); + LoadingMoreList(ListConfig( + sourceList: sourceList, + autoLoadMore: true, + itemBuilder: (_, item, index) => book(item), + indicatorBuilder: indicatorBuilder, + )); + } + + Widget book(Book book) { + return WidgetBook(book, subtitle: book.author); + } + + Widget indicatorBuilder(context, IndicatorStatus status) { + print('indicatorBuilder $status'); + bool isSliver = true; + Widget widget; + switch (status) { + case IndicatorStatus.none: + widget = SizedBox(); + break; + case IndicatorStatus.loadingMoreBusying: + widget = Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + margin: EdgeInsets.only(right: 5.0), + height: 15.0, + width: 15.0, + child: getIndicator(context), + ), + Text("正在读取") + ], + ); + widget = _setbackground(false, widget, 35.0); + break; + case IndicatorStatus.fullScreenBusying: + widget = SizedBox(); + if (isSliver) { + widget = SliverFillRemaining( + child: widget, + ); + } + break; + case IndicatorStatus.error: + widget = Text( + '读取失败,如果失败的次数太多可能需要用梯子', + ); + widget = _setbackground(false, widget, 35.0); + + widget = GestureDetector( + onTap: () { + sourceList.errorRefresh(); + }, + child: widget, + ); + break; + case IndicatorStatus.fullScreenError: + widget = Text( + '读取失败,如果失败的次数太多可能需要用梯子', + ); + widget = _setbackground(true, widget, double.infinity); + widget = GestureDetector( + onTap: () { + sourceList.errorRefresh(); + }, + child: widget, + ); + if (isSliver) { + widget = SliverFillRemaining( + child: widget, + ); + } else { + widget = CustomScrollView( + slivers: [ + SliverFillRemaining( + child: widget, + ) + ], + ); + } + break; + case IndicatorStatus.noMoreLoad: + widget = Text("已经显示全部搜索结果"); + widget = _setbackground(false, widget, 35.0); + break; + case IndicatorStatus.empty: + widget = Text( + '没有内容', + ); + widget = _setbackground(true, widget, double.infinity); + if (isSliver) { + widget = SliverToBoxAdapter( + child: widget, + ); + } else { + widget = CustomScrollView( + slivers: [ + SliverFillRemaining( + child: widget, + ) + ], + ); + } + break; + } + return widget; + } + + Widget _setbackground(bool full, Widget widget, double height) { + widget = Container( + width: double.infinity, + height: kToolbarHeight, + child: widget, + alignment: Alignment.center, + ); + return widget; + } + + Widget getIndicator(BuildContext context) { + return CircularProgressIndicator( + strokeWidth: 2.0, + valueColor: AlwaysStoppedAnimation(Theme.of(context).primaryColor), + ); + } +} diff --git a/lib/activities/search/search.dart b/lib/activities/search/search.dart new file mode 100644 index 0000000..67cfd52 --- /dev/null +++ b/lib/activities/search/search.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:focus_widget/focus_widget.dart'; + +import '../../crawler/http18Comic.dart'; +import 'tab.dart'; + +class ActivitySearch extends StatefulWidget { + final String search; + + const ActivitySearch({Key key, this.search = ''}) : super(key: key); + + @override + State createState() { + return SearchState(); + } +} + +class SearchState extends State + with SingleTickerProviderStateMixin { + TextEditingController _controller; + GlobalKey key = GlobalKey(); + + @override + initState() { + _controller = TextEditingController(text: widget.search); + super.initState(); + } + + @override + dispose() { + _controller.dispose(); + super.dispose(); + } + + void search() { + key.currentState.search = _controller.text; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: RawKeyboardListener( + focusNode: FocusNode(), + onKey: (RawKeyEvent event) { + print('is enter: ${LogicalKeyboardKey.enter == event.logicalKey}'); + if (_controller.text.isEmpty) return; + if (event.runtimeType == RawKeyUpEvent && + LogicalKeyboardKey.enter == event.logicalKey) { + print('回车键搜索'); + search(); + } + }, + child: FocusWidget.builder( + context, + builder: (_, focusNode) => TextField( + focusNode: focusNode, + style: TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: '搜索书名', + prefixIcon: IconButton( + onPressed: search, + icon: Icon(Icons.search, color: Colors.white), + ), + ), + textAlign: TextAlign.left, + controller: _controller, + autofocus: widget.search.isEmpty, + textInputAction: TextInputAction.search, + onSubmitted: (String name) { + focusNode.unfocus(); + print('onSubmitted'); + search(); + }, + keyboardType: TextInputType.text, + onEditingComplete: () { + focusNode.unfocus(); + print('onEditingComplete'); + search(); + }, + ), + ), + ), + ), + body: SearchTab( + name: Http18Comic.instance.name, + http: Http18Comic.instance, + search: _controller.text, + key: key, + ), + ); + } +} diff --git a/lib/activities/search/source.dart b/lib/activities/search/source.dart new file mode 100644 index 0000000..a71bb0c --- /dev/null +++ b/lib/activities/search/source.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:loading_more_list/loading_more_list.dart'; + +import '../../classes/book.dart'; +import '../../crawler/http.dart'; + +class SearchSourceList extends LoadingMoreBase { + final HttpBook http; + String search; + int page = 1; + bool hasMore = true; + String eachPageFirstBookId; + + SearchSourceList({ + @required this.http, + this.search = '', + }); + + @override + Future loadData([bool isloadMoreAction = false]) async { + print('搜书 $search'); + if (search == null || search.isEmpty) return true; + final list = await http.searchBook(search, page); + if (list.isEmpty) { + hasMore = false; + } else if (list[0].aid == eachPageFirstBookId) { + hasMore = false; + } else { + eachPageFirstBookId = list[0].aid; + hasMore = true; + page++; + this.addAll(list); + } + return true; + } + + @override + Future refresh([bool notifyStateChanged = false]) { + page = 1; + hasMore = true; + eachPageFirstBookId = null; + clear(); + print('refresh $page $hasMore'); + return super.refresh(notifyStateChanged); + } +} diff --git a/lib/activities/search/tab.dart b/lib/activities/search/tab.dart new file mode 100644 index 0000000..3e2b419 --- /dev/null +++ b/lib/activities/search/tab.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:loading_more_list/loading_more_list.dart'; + +import './source.dart'; +import '../../classes/book.dart'; +import '../../crawler/http.dart'; +import '../../widgets/book.dart'; + +class SearchTab extends StatefulWidget { + final String name; + final HttpBook http; + final String search; + + const SearchTab({ + Key key, + @required this.name, + @required this.http, + this.search, + }) : super(key: key); + + @override + SearchTabState createState() => SearchTabState(); +} + +class SearchTabState extends State + with AutomaticKeepAliveClientMixin { + SearchSourceList sourceList; + + @override + void initState() { + sourceList = SearchSourceList(http: widget.http, search: widget.search); + super.initState(); + } + + Widget book(Book book) { + return WidgetBook(book, subtitle: book.author); + } + + Future refresh() async { + return sourceList.refresh(true); + } + + get search => sourceList.search; + + set search(String value) { + print('tab search $value'); + sourceList.search = value; + sourceList.refresh(true); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return LoadingMoreList( + ListConfig( + sourceList: sourceList, + itemBuilder: (_, item, index) => book(item), + autoLoadMore: true, + indicatorBuilder: indicatorBuilder, + ), + ); + } + + Widget indicatorBuilder(context, IndicatorStatus status) { + bool isSliver = false; + Widget widget; + switch (status) { + case IndicatorStatus.none: + widget = SizedBox(); + break; + case IndicatorStatus.loadingMoreBusying: + widget = Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + margin: EdgeInsets.only(right: 5.0), + height: 15.0, + width: 15.0, + child: getIndicator(context), + ), + Text("正在读取") + ], + ); + widget = _setbackground(false, widget, 35.0); + break; + case IndicatorStatus.fullScreenBusying: + widget = widget = _setbackground( + false, + Text( + '正在读取', + ), + 35.0); + if (isSliver) { + widget = SliverFillRemaining( + child: widget, + ); + } + break; + case IndicatorStatus.error: + widget = _setbackground( + false, + Text( + '网络错误\n点击重试', + ), + 35.0); + + widget = GestureDetector( + onTap: () { + sourceList.errorRefresh(); + }, + child: widget, + ); + break; + case IndicatorStatus.fullScreenError: + widget = Text( + '读取失败,如果失败的次数太多可能需要用梯子', + ); + widget = _setbackground(true, widget, double.infinity); + widget = GestureDetector( + onTap: () { + sourceList.errorRefresh(); + }, + child: widget, + ); + if (isSliver) { + widget = SliverFillRemaining( + child: widget, + ); + } else { + widget = CustomScrollView( + slivers: [ + SliverFillRemaining( + child: widget, + ) + ], + ); + } + break; + case IndicatorStatus.noMoreLoad: + widget = Text("已经显示全部搜索结果"); + widget = _setbackground(false, widget, 35.0); + break; + case IndicatorStatus.empty: + widget = Text( + sourceList.search.isEmpty ? '请输入搜索内容' : '搜索不到任何内容', + ); + widget = _setbackground(true, widget, double.infinity); + if (isSliver) { + widget = SliverToBoxAdapter( + child: widget, + ); + } else { + widget = CustomScrollView( + slivers: [ + SliverFillRemaining( + child: widget, + ) + ], + ); + } + break; + } + return widget; + } + + Widget _setbackground(bool full, Widget widget, double height) { + widget = Container( + width: double.infinity, + height: kToolbarHeight, + child: widget, + alignment: Alignment.center, + ); + return widget; + } + + Widget getIndicator(BuildContext context) { + return CircularProgressIndicator( + strokeWidth: 2.0, + valueColor: AlwaysStoppedAnimation(Theme.of(context).primaryColor), + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/activities/setting/hideStatusBar.dart b/lib/activities/setting/hideStatusBar.dart new file mode 100644 index 0000000..691b764 --- /dev/null +++ b/lib/activities/setting/hideStatusBar.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +enum HideOption { + none, + auto, + always, +} + +class HideStatusBar extends StatelessWidget { + final options = { + '自动': HideOption.auto, + '全程隐藏': HideOption.always, + '不隐藏': HideOption.none, + }; + final Function(HideOption option) onChanged; + final HideOption option; + + HideStatusBar({Key key, @required this.onChanged, @required this.option}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text('看漫画时隐藏状态栏'), + subtitle: Text('自动:随着图片列表的上下滚动而自动显示或隐藏状态栏\n' + '全程隐藏:进入看图界面就隐藏状态栏,退出就显示状态栏\n' + '不隐藏:就是不隐藏状态栏咯'), + trailing: DropdownButton( + value: option, + items: options.keys + .map((key) => DropdownMenuItem( + child: Text(key), + value: options[key], + )) + .toList(), + onChanged: onChanged, + ), + ); + } +} diff --git a/lib/activities/setting/setting.dart b/lib/activities/setting/setting.dart new file mode 100644 index 0000000..8706984 --- /dev/null +++ b/lib/activities/setting/setting.dart @@ -0,0 +1,283 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:oktoast/oktoast.dart'; +import 'package:provider/provider.dart'; +import 'package:weiman/activities/setting/hideStatusBar.dart'; + +import '../../classes/data.dart'; +import '../../crawler/http.dart'; +import '../../main.dart'; + +enum AutoCheckLevel { + none, + onlyInWeek, + all, +} + +class SettingData extends ChangeNotifier { + static final String key = 'setting_data'; + AutoCheckLevel _autoCheck; + HideOption _hide; + String _proxy; + Directory imageCacheDir; + + SettingData() { + final Map data = + jsonDecode(Data.instance.getString(key) ?? '{}'); + print('SettingData $data'); + _autoCheck = data['autoCheck'] == null + ? AutoCheckLevel.onlyInWeek + : AutoCheckLevel.values[data['autoCheck']]; + _hide = data['hide'] == null + ? HideOption.auto + : HideOption.values[data['hide']]; + _proxy = data['proxy']; + + MyHttpClient.init(_proxy, 10000, 30000); + } + + get autoCheck => _autoCheck; + + set autoCheck(AutoCheckLevel val) { + _autoCheck = val; + notifyListeners(); + save(); + } + + String get proxy => _proxy; + + set proxy(String value) { + print('set proxy $value'); + _proxy = value; + notifyListeners(); + save(); + } + + HideOption get hide => _hide; + + set hide(HideOption value) { + _hide = value; + notifyListeners(); + save(); + } + + Map toJson() { + return { + 'autoCheck': _autoCheck.index, + 'proxy': _proxy, + 'hide': _hide.index + }; + } + + void save() { + MyHttpClient.init(_proxy, 10000, 30000); + print('save ${toJson()}'); + Data.instance.setString(key, jsonEncode(toJson())); + } +} + +class ActivitySetting extends StatefulWidget { + @override + _ActivitySetting createState() => _ActivitySetting(); +} + +class _ActivitySetting extends State { + static final Map levels = { + '不检查': AutoCheckLevel.none, + '7天内看过': AutoCheckLevel.onlyInWeek, + '全部': AutoCheckLevel.all + }; + int imagesCount, sizeCount; + bool isClearing = false; + + @override + void initState() { + super.initState(); + imageCaches(); + } + + Future imageCaches() async { + final files = imageCacheDir.listSync(); + imagesCount = files.length; + sizeCount = 0; + files.forEach((file) => sizeCount += file.statSync().size); + if (mounted) setState(() {}); + } + + Future clearDiskCachedImages() async { + await imageCacheDir.delete(recursive: true); + await imageCacheDir.create(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('设置')), + body: Consumer(builder: (_, data, __) { + print('代理 ${data.proxy}'); + return ListView( + children: ListTile.divideTiles( + context: context, + tiles: [ + /// 更新设置 + autoCheck(data), + + /// 隐藏状态栏设置 + HideStatusBar( + option: data.hide, + onChanged: (option) => data.hide = option, + ), + + /// 设置代理 + ListTile( + title: Text('设置代理'), + subtitle: Text(data.proxy ?? '无'), + onTap: () async { + var proxy = await showDialog( + context: context, + builder: (_) { + final _c = TextEditingController(text: data.proxy); + return WillPopScope( + child: AlertDialog( + title: Text('设置网络代理'), + content: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '只支持http代理\nSS,SSR,V2Ray,Trojan(Clash)\n这些梯子App都有提供Http代理功能'), + TextField( + controller: _c, + decoration: InputDecoration( + hintText: '例如Clash提供的127.0.0.1:7890'), + ), + ]), + actions: [ + FlatButton( + child: Text('清空'), + onPressed: () { + _c.clear(); + }, + ), + FlatButton( + child: Text('确定'), + onPressed: () { + Navigator.pop(context, _c.text); + }, + ), + ], + ), + onWillPop: () { + Navigator.pop(context, '-1'); + return Future.value(false); + }, + ); + }); + print('用户输入 $proxy'); + if (proxy == '-1') return; + if (proxy != null) { + proxy = proxy + .trim() + .replaceFirst('http://', '') + .replaceFirst('https://', ''); + } + if (proxy == null || proxy.isEmpty) { + proxy = null; + } + print('设置代理 $proxy'); + data.proxy = proxy; + }, + ), + + /// 清空图片缓存 + ListTile( + title: Text('清除所有图片缓存'), + subtitle: isClearing + ? Text('清理中') + : Text.rich( + TextSpan( + children: [ + TextSpan(text: '图片数量:'), + TextSpan( + text: imagesCount == null + ? '读取中' + : '$imagesCount 张'), + TextSpan(text: '\n'), + TextSpan(text: '存储容量:'), + TextSpan( + text: sizeCount == null + ? '读取中' + : '${filesize(sizeCount)}'), + ], + ), + ), + onTap: () async { + if (isClearing == true) return; + final sure = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('确认清除所有图片缓存?'), + actions: [ + RaisedButton( + child: Text('确认'), + onPressed: () => Navigator.pop(context, true), + ), + ], + ), + ); + if (sure == true) { + showToast('正在清理图片缓存'); + isClearing = true; + setState(() {}); + await clearDiskCachedImages(); + isClearing = false; + if (mounted) { + setState(() {}); + await imageCaches(); + } + showToast('成功清理图片缓存'); + } + }, + ), + + /// 清空数据缓存 + ListTile( + title: Text('清空漫画数据缓存'), + subtitle: Text('正常情况是不需要清空的'), + onTap: () async { + await HttpBook.dataCache.clearAll(); + showToast('成功清空漫画数据缓存', textPadding: EdgeInsets.all(10)); + }, + ), + ], + ).toList(), + ); + }), + ); + } + + Widget autoCheck(SettingData data) { + return ListTile( + title: Text('自动检查收藏漫画的更新'), + subtitle: Text('每次启动App后检查一次更新\n有很多漫画收藏的建议只检查7天内看过的漫画'), + trailing: DropdownButton( + value: data.autoCheck, + items: levels.keys + .map( + (key) => DropdownMenuItem( + child: Text(key), + value: levels[key], + ), + ) + .toList(), + onChanged: (level) { + data.autoCheck = level; +// setState(() {}); + }, + ), + ); + } +} diff --git a/lib/activities/test.dart b/lib/activities/test.dart new file mode 100644 index 0000000..15a2968 --- /dev/null +++ b/lib/activities/test.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +import '../classes/data.dart'; +import '../crawler/http18Comic.dart'; + +class ActivityTest extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('测试'), + ), + body: Column( + children: [ + FlatButton( + onPressed: read, + child: Text('读取'), + ), + FlatButton( + onPressed: clear, + child: Text('清空数据'), + ), + FlatButton( + onPressed: httpTest, + child: Text('Http请求参数测试'), + ), + ], + ), + ); + } + + void read() { + var books = Data.getFavorites(); + print(jsonEncode(books)); + } + + void clear() { + Data.clear(); + } + + Future httpTest() async { + final books = await Http18Comic.instance.searchBook('冲突'); + print('搜索漫画 ${books[0].toJson()}'); + } +} diff --git a/lib/classes/book.dart b/lib/classes/book.dart new file mode 100644 index 0000000..9c8b1ff --- /dev/null +++ b/lib/classes/book.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../crawler/http.dart'; +import '../main.dart' as main; +import 'data.dart'; + +class Author { + final int id; + final String name; + + const Author(this.id, this.name); +} + +class Book { + final String _http; + final String aid; // 漫画的数据库ID + final String name; // 书本名称 + final String avatar; // 书本封面 + final String author; // 画家 + final String description; // 描述 + final List chapters; + final int chapterCount; + final int version; + + History history; + + Book({ + @required String http, + @required this.name, + @required this.aid, + @required this.avatar, + this.author, + this.description, + this.chapters: const [], + this.chapterCount: 0, + this.history, + this.version: 0, + }) : _http = http; + + HttpBook get http => MyHttpClient.clients[_http]; + + @override + String toString() { + return jsonEncode(toJson()); + } + + bool isFavorite() { + var books = Data.getFavorites(); + return books.containsKey(aid); + } + + Map toJson() { + print('book toJson'); + final Map data = { + 'http': _http, + 'aid': aid, + 'name': name, + 'avatar': avatar, + 'author': author, + 'chapterCount': chapterCount, + 'version': version, + }; + if (history != null) data['history'] = history.toJson(); + return data; + } + + factory Book.fromJson(Map json) { + final book = Book( + http: json['http'], + aid: json['aid'], + name: json['name'], + avatar: json['avatar'], + author: json['author'], + description: json['description'], + chapterCount: json['chapterCount'] ?? 0, + version: json['version'] ?? 0); + if (json.containsKey('history')) + book.history = History.fromJson(json['history']); + return book; + } +} + +class Chapter { + final HttpBook http; + final String cid; // 章节cid + final String cname; // 章节名称 + final String avatar; // 章节封面 + + Chapter({ + @required this.http, + @required this.cid, + @required this.cname, + @required this.avatar, + }); + + @override + String toString() { + final Map data = { + 'cid': cid, + 'cname': cname, + 'avatar': avatar, + }; + return jsonEncode(data); + } +} + +class History { + final String cid; + final String cname; + final int time; + + History({@required this.cid, @required this.cname, @required this.time}); + + @override + String toString() => jsonEncode(toJson()); + + Map toJson() { + return { + 'cid': cid, + 'cname': cname, + 'time': time, + }; + } + + static History fromJson(Map json) { + return History(cid: json['cid'], cname: json['cname'], time: json['time']); + } + + static History fromChapter(Chapter chapter) { + return History( + cid: chapter.cid, + cname: chapter.cname, + time: DateTime.now().millisecondsSinceEpoch, + ); + } +} diff --git a/lib/classes/data.dart b/lib/classes/data.dart new file mode 100644 index 0000000..d338261 --- /dev/null +++ b/lib/classes/data.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'book.dart'; + +class Data { + static SharedPreferences instance; + static final favoriteBooksKey = 'favorite_books'; + static final viewHistoryKey = 'view_history'; + static final quickKey = 'quick_list'; + + static Future init() async { + instance = await SharedPreferences.getInstance(); + } + + static set(String key, T value) { + if (value is String) { + instance.setString(key, value); + } else if (value is int) { + instance.setInt(key, value); + } else if (value is bool) { + instance.setBool(key, value); + } else if (value is List) { + instance.setStringList(key, value); + } else if (value is double) { + instance.setDouble(key, value); + } else if (value is Map) { + instance.setString(key, json.encode(value)); + } + } + + static dynamic get(String key) { + return instance.get(key); + } + + static Map getFavorites() { + if (has(favoriteBooksKey)) { + final String str = instance.getString(favoriteBooksKey); + Map data = jsonDecode(str); + Map res = {}; + data.keys.forEach((key) { + res[key] = Book.fromJson(data[key]); + }); + return res; + } + return {}; + } + + static void addFavorite(Book book) { + var books = getFavorites(); + books[book.aid] = book; + set(favoriteBooksKey, books); + } + + static void removeFavorite(Book book) { + var books = getFavorites(); + if (books.containsKey(book.aid)) { + books.remove(book.aid); + set(favoriteBooksKey, books); + reQuick(); + } + } + + static clear() { + instance.clear(); + } + + static bool has(String key) { + return instance.containsKey(key); + } + + static remove(String key) { + instance.remove(key); + } + + static Map getHistories() { + if (has(viewHistoryKey)) { + var data = + jsonDecode(instance.getString(viewHistoryKey)) as Map; + final Map histories = {}; + data.forEach((key, value) { + histories[key] = Book.fromJson(value); + }); + return histories; + } + return {}; + } + + static addHistory(Book book, Chapter chapter) { + book.history = History( + cid: chapter.cid, + cname: chapter.cname, + time: DateTime.now().millisecondsSinceEpoch); + final books = getHistories(); + books[book.aid] = book; + set(viewHistoryKey, books); + // print('保存历史\n' + books.toString()); + } + + static removeHistory(bool Function(Book book) isDelete) { + var books = getHistories(); + books.keys + .where((key) => isDelete(books[key])) + .toList() + .forEach(books.remove); + set(viewHistoryKey, books); + } + + static removeHistoryFromBook(Book book) { + final books = getHistories(); + books.remove(book.aid); + set(viewHistoryKey, books); + } + + /// 快速导航 id 列表,内部方法 + static List quickIdList() { + if (instance.containsKey(quickKey)) { + return instance.getStringList(quickKey); + } + return []; + } + + /// 快速导航列表 + static List quickList() { + final books = getFavorites(); + final ids = books.keys; + final List quickIds = quickIdList(); + print('快捷 $quickIds'); + return quickIds + .where((id) => ids.contains(id)) + .map((id) => books[id]) + .toList(); + } + + /// 增加快速导航 + static addQuick(Book book) { + final list = quickIdList(); + list.add(book.aid); + instance.setStringList(quickKey, list.toSet().toList()); + } + + static addQuickAll(List id) { + print('保存qid $id'); + instance.setStringList(quickKey, id.toSet().toList()); + } + + /// 重新整理Quick的id列表 + static reQuick() { + final books = getFavorites(); + final quickIds = quickIdList(); + instance.setStringList( + quickKey, quickIds.where(books.keys.contains).toSet().toList()); + } +} diff --git a/lib/classes/networkImageSSL.dart b/lib/classes/networkImageSSL.dart new file mode 100644 index 0000000..9649ab1 --- /dev/null +++ b/lib/classes/networkImageSSL.dart @@ -0,0 +1,93 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../crawler/http.dart'; + +/// The dart:io implementation of [image_provider.NetworkImage]. +class NetworkImageSSL extends ImageProvider + implements NetworkImage { + /// Creates an object that fetches the image at the given URL. + /// + /// The arguments [url] and [scale] must not be null. + const NetworkImageSSL( + this.http, + this.url, { + this.scale = 1.0, + this.headers, + this.timeout = 8, + }) : assert(url != null), + assert(scale != null); + + final HttpBook http; + + final int timeout; + @override + final String url; + + @override + final double scale; + + @override + final Map headers; + + static void init(ByteData data) {} + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter load(NetworkImage key, DecoderCallback decode) { + // Ownership of this controller is handed off to [_loadAsync]; it is that + // method's responsibility to close the controller's stream when the image + // has been loaded or an error is thrown. + final StreamController chunkEvents = + StreamController(); + + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key, chunkEvents, decode), + chunkEvents: chunkEvents.stream, + scale: key.scale, + informationCollector: () { + return [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Image key', key), + ]; + }, + ); + } + + Future _loadAsync( + NetworkImageSSL key, + StreamController chunkEvents, + DecoderCallback decode, + ) async { + try { + assert(key == this); + final Uint8List bytes = await http.getImage(url); + if (bytes.lengthInBytes == 0) + throw Exception('NetworkImage is an empty file: $url'); + return decode(bytes); + } finally { + chunkEvents.close(); + } + } + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) return false; + final NetworkImageSSL typedOther = other; + return url == typedOther.url && scale == typedOther.scale; + } + + @override + int get hashCode => hashValues(url, scale); + + @override + String toString() => '$runtimeType("$url", scale: $scale)'; +} diff --git a/lib/crawler/http.dart b/lib/crawler/http.dart new file mode 100644 index 0000000..c893181 --- /dev/null +++ b/lib/crawler/http.dart @@ -0,0 +1,71 @@ +import 'dart:io'; + +import 'package:dio/adapter.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_http_cache/dio_http_cache.dart'; + +import '../classes/book.dart'; +import 'http18Comic.dart'; + +class MyHttpClient { + static Map clients = {}; + + static init(String proxy, int timeout, int imageTimeout) { + final headers = { + "user-agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36", + "accept": + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", + "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,zh-HK;q=0.7", + "cache-control": "no-cache", + "pragma": "no-cache", + }; + + var http = Http18Comic( + proxy: proxy, + headers: headers, + timeout: timeout, + ); + clients[http.id] = http; + } +} + +abstract class HttpBook { + static final DioCacheManager dataCache = DioCacheManager(CacheConfig( + databaseName: 'data', + defaultMaxAge: Duration(days: 30), + )); + final String id; + final String name; + + final Dio dio; + + HttpBook(this.id, this.name, this.dio); + + Future> searchBook(String name, [int page]); + + Future getBook(String aid); + + Future> getChapterImages(Book book, Chapter chapter); + + Future> getImage(String url); + + Future> hotBooks([String type = '', int page]); +} + +void SetProxy(Dio dio, String proxy) { + if (proxy != null) { + proxy = 'PROXY $proxy'; + // print('setProxy $proxy'); + final adapter = DefaultHttpClientAdapter(); + adapter.onHttpClientCreate = (HttpClient client) { + client.findProxy = (uri) { + //proxy all request to localhost:8888 + return proxy; + }; + client.badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + }; + dio.httpClientAdapter = adapter; + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..5386f5c --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dynamic_theme/dynamic_theme.dart'; +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_analytics/observer.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:oktoast/oktoast.dart'; +import 'package:package_info/package_info.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; + +import 'activities/home.dart'; +import 'activities/setting/setting.dart'; +import 'classes/data.dart'; +import 'widgets/favorites.dart'; + +FirebaseAnalytics analytics; +FirebaseAnalyticsObserver observer; + +const bool isDevMode = !bool.fromEnvironment('dart.vm.product'); + +int version; +BoxDecoration border; + +Directory imageCacheDir; +String imageCacheDirPath; + +void main() async { + FlutterError.onError = (FlutterErrorDetails details) {}; + WidgetsFlutterBinding.ensureInitialized(); + + getTemporaryDirectory().then((dir) { + imageCacheDir = Directory(path.join(dir.path, 'images')); + imageCacheDirPath = imageCacheDir.path; + if (imageCacheDir.existsSync() == false) imageCacheDir.createSync(); + print('图片缓存目录 $imageCacheDirPath'); + }); + + try { + analytics = FirebaseAnalytics(); + observer = FirebaseAnalyticsObserver(analytics: analytics); + } catch (e) {} + + await Future.wait([ + Data.init(), + SystemChrome.setPreferredOrientations( + [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]) + ]); + final PackageInfo packageInfo = await PackageInfo.fromPlatform(); + version = int.parse(packageInfo.buildNumber); + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => SettingData(), + lazy: false, + ), + ChangeNotifierProvider( + create: (_) => FavoriteData(), lazy: false), + ], + child: Main(packageInfo: packageInfo), + ), + ); +} + +class Main extends StatefulWidget { + final PackageInfo packageInfo; + + const Main({Key key, this.packageInfo}) : super(key: key); + + @override + _Main createState() => _Main(); +} + +class _Main extends State
with WidgetsBindingObserver { + @override + Widget build(BuildContext context) { + border = BoxDecoration( + border: Border( + bottom: Divider.createBorderSide(context, color: Colors.grey))); + return DynamicTheme( + defaultBrightness: Brightness.dark, + data: (brightness) => new ThemeData( + brightness: brightness, + ), + themedWidgetBuilder: (context, theme) { + return OKToast( + child: MaterialApp( + title: '微漫 v${widget.packageInfo.version}', + theme: theme, + home: ActivityHome(widget.packageInfo), + debugShowCheckedModeBanner: isDevMode, + ), + ); + }); + } +} diff --git a/lib/utils.dart b/lib/utils.dart new file mode 100644 index 0000000..49c58d6 --- /dev/null +++ b/lib/utils.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import './main.dart'; +import 'activities/book.dart'; +import 'activities/chapter.dart'; +import 'activities/search/search.dart'; +import 'classes/book.dart'; + +final weekTime = Duration.millisecondsPerDay * 7; + +void openBook(BuildContext context, Book book, String heroTag) { + print('openBook ${book.name} version:${book.version}'); + if (book.version == null || book.version < version || book.http == null) { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: '/activity_search/${book.name}'), + builder: (_) => ActivitySearch(search: book.name), + ), + ); + return; + } + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: '/activity_book/${book.name}'), + builder: (_) => ActivityBook(book: book, heroTag: heroTag), + ), + ); +} + +void openChapter(BuildContext context, Book book, Chapter chapter) { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings( + name: '/activity_chapter/${book.name}/${chapter.cname}'), + builder: (_) => ActivityChapter(book, chapter), + ), + ); +} + +void showStatusBar() { + SystemChrome.setEnabledSystemUIOverlays(SystemUiOverlay.values); +} + +void hideStatusBar() { + SystemChrome.setEnabledSystemUIOverlays([]); +} diff --git a/lib/widgets/book.dart b/lib/widgets/book.dart new file mode 100644 index 0000000..a40944f --- /dev/null +++ b/lib/widgets/book.dart @@ -0,0 +1,196 @@ +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; + +import '../classes/book.dart'; +import '../classes/networkImageSSL.dart'; +import '../utils.dart'; + +class WidgetBook extends StatelessWidget { + final Book book; + final String subtitle; + final Function(Book) onTap; + + const WidgetBook( + this.book, { + Key key, + @required this.subtitle, + this.onTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + var isLiked = book.isFavorite(); + return ListTile( + title: Text( + book.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + dense: true, + leading: Hero( + tag: 'bookAvatar${book.aid}', + child: ExtendedImage(image: NetworkImageSSL(book.http, book.avatar)), + ), + trailing: Icon( + isLiked ? Icons.favorite : Icons.favorite_border, + color: isLiked ? Colors.red : Colors.grey, + size: 12, + ), + onTap: () { + if (onTap != null) return onTap(book); + openBook(context, book, 'bookAvatar${book.aid}'); + }, + ); + } +} + +class WidgetChapter extends StatelessWidget { + static final double height = kToolbarHeight; + final Chapter chapter; + final Function(Chapter) onTap; + final bool read; + + WidgetChapter({ + Key key, + this.chapter, + this.onTap, + this.read = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final children = [TextSpan(text: chapter.cname)]; + if (read) { + children.insert( + 0, + TextSpan( + text: '[已看]', + style: TextStyle(color: Colors.orange), + )); + } + return ListTile( + onTap: () { + if (onTap != null) onTap(chapter); + }, + title: RichText( + text: TextSpan( + children: children, + style: Theme.of(context).textTheme.body1, + ), + softWrap: true, + maxLines: 2, + ), + leading: chapter.avatar == null + ? null + : Image( + image: ExtendedNetworkImageProvider( + chapter.avatar, + cache: true, + ), + fit: BoxFit.fitWidth, + width: 100, + ), + ); + } +} + +class WidgetHistory extends StatelessWidget { + final Book book; + final Function(Book book) onTap; + + WidgetHistory(this.book, this.onTap); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: ListTile( + onTap: () { + if (onTap != null) onTap(book); + }, + title: Text(book.name), + leading: Image( + image: ExtendedNetworkImageProvider(book.avatar, cache: true), + fit: BoxFit.fitHeight, + ), + subtitle: Text(book.history.cname), + ), + ); + } +} + +class WidgetBookCheckNew extends StatefulWidget { + final Book book; + + const WidgetBookCheckNew({Key key, this.book}) : super(key: key); + + @override + _WidgetBookCheckNew createState() => _WidgetBookCheckNew(); +} + +class _WidgetBookCheckNew extends State { + bool loading = true, hasError = false; + int news; + + @override + void initState() { + super.initState(); + load(); + } + + void load() async { +// loading = true; +// try { +// final book = await Http18Comic.instance +// .getBook(widget.book.aid) +// .timeout(Duration(seconds: 2)); +// news = book.chapterCount - widget.book.chapterCount; +// hasError = false; +// } catch (e) { +// hasError = true; +// } +// loading = false; +// setState(() {}); + } + + @override + Widget build(BuildContext context) { + final children = []; + if (widget.book.history != null) + children.add(Text( + widget.book.history.cname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + )); + + if (loading) + children.add(Text('检查更新中')); + else if (hasError) + children.add(Text('网络错误')); + else if (news > 0) + children.add(Text('有 $news 章更新')); + else + children.add(Text('没有更新')); + return ListTile( + onTap: () => + openBook(context, widget.book, 'checkBook${widget.book.aid}'), + leading: Hero( + tag: 'checkBook${widget.book.aid}', + child: Image( + image: + ExtendedNetworkImageProvider(widget.book.avatar, cache: true)), + ), + dense: true, + isThreeLine: true, + title: Text(widget.book.name), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ); + } +} diff --git a/lib/widgets/checkConnect.dart b/lib/widgets/checkConnect.dart new file mode 100644 index 0000000..1cb02b7 --- /dev/null +++ b/lib/widgets/checkConnect.dart @@ -0,0 +1,138 @@ +import 'package:dio/dio.dart'; +import 'package:dio_http_cache/dio_http_cache.dart'; +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; + +import '../crawler/http18Comic.dart'; + +class CheckConnectWidget extends StatefulWidget { + @override + _CheckConnectWidget createState() => _CheckConnectWidget(); +} + +class _CheckConnectWidget extends State { + LoadState state = LoadState.loading; + String error; + + @override + void initState() { + super.initState(); + check(); + } + + Future check() async { + setState(() { + state = LoadState.loading; + }); + try { + final res = await Http18Comic.instance.dio.head( + '/', + options: buildCacheOptions( + Duration(seconds: 1), + forceRefresh: true, + ), + ); + assert(res.statusCode == 200); + setState(() { + state = LoadState.completed; + }); + } catch (e) { + if (e.runtimeType == DioError) { + final DioError error = e as DioError; + switch (error.type) { + case DioErrorType.CONNECT_TIMEOUT: + case DioErrorType.RECEIVE_TIMEOUT: + case DioErrorType.SEND_TIMEOUT: + this.error = '连接超时'; + break; + default: + this.error = error.error.toString(); + } + } else { + this.error = e.toString(); + } + setState(() { + state = LoadState.failed; + }); + } + } + + void showError() async { + showDialog( + context: context, + builder: (_) { + return AlertDialog( + title: Text('错误内容'), + content: Text(error.toString()), + actions: [ + FlatButton( + child: Text('再次尝试'), + onPressed: () { + check(); + Navigator.pop(context); + }, + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + Widget row; + switch (state) { + case LoadState.loading: + row = Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 10), + Text('正在尝试连接漫画网站'), + ], + ); + break; + case LoadState.failed: + row = GestureDetector( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: Icon(Icons.error, color: Colors.red), + ), + SizedBox(width: 10), + Text('连接不上漫画网站,点击查看错误'), + ], + ), + onTap: showError, + ); + break; + default: + row = GestureDetector( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 20, + height: 20, + child: Icon(Icons.check_circle, color: Colors.green), + ), + SizedBox(width: 10), + Text('成功连接到漫画网站,点击重新测试'), + ], + ), + onTap: check, + ); + } + return Padding( + padding: EdgeInsets.only(top: 10, bottom: 15), + child: row, + ); + } +} diff --git a/lib/widgets/dbSourceListWidget.dart b/lib/widgets/dbSourceListWidget.dart new file mode 100644 index 0000000..e9025fe --- /dev/null +++ b/lib/widgets/dbSourceListWidget.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class DBSourceListWidget extends StatefulWidget { + @override + _DBSourceListWidget createState() => _DBSourceListWidget(); +} + +class _DBSourceListWidget extends State { + @override + Widget build(BuildContext context) { + return ListView(children: []); + } +} diff --git a/lib/widgets/favorites.dart b/lib/widgets/favorites.dart new file mode 100644 index 0000000..c748e3e --- /dev/null +++ b/lib/widgets/favorites.dart @@ -0,0 +1,272 @@ +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:provider/provider.dart'; + +import '../activities/search/search.dart'; +import '../activities/setting/setting.dart'; +import '../classes/book.dart'; +import '../classes/data.dart'; +import '../classes/networkImageSSL.dart'; +import '../utils.dart'; +import '../widgets/sliverExpandableGroup.dart'; +import '../widgets/utils.dart'; + +class FavoriteData extends ChangeNotifier { + /// -3 旧的收藏数据,跳过检查,-2 在队列中等待检查,-1读取错误,0 没有更新,> 0 更新的章节数量 + final Map hasNews = {}; // 漫画的状态 + final Map all = {}, // 所有收藏 + inWeek = {}, // 7天内看过的收藏 + other = {}; // 其他收藏 + + FavoriteData() { + loadBooksList(); + } + + Future loadBooksList() async { + all + ..clear() + ..addAll(Data.getFavorites()); + calcBookHistory(); + } + + void add(Book book) { + Data.addFavorite(book); + all[book.aid] = book; + calcBookHistory(); + } + + void remove(Book book) { + Data.removeFavorite(book); + all.remove(book.aid); + calcBookHistory(); + } + + void calcBookHistory() { + inWeek.clear(); + other.clear(); + if (all.isNotEmpty) { + final now = DateTime.now().millisecondsSinceEpoch; + all.forEach((aid, book) { + if (book.history != null && (now - book.history.time) < weekTime) { + inWeek[aid] = book; + } else { + other[aid] = book; + } + }); + } + notifyListeners(); + } + + Future checkNews(AutoCheckLevel level) async { + if (level == AutoCheckLevel.none) return; + final books = level == AutoCheckLevel.onlyInWeek ? inWeek : all; + final keys = books.keys; + hasNews + ..clear() + ..addAll(books.map((aid, book) => MapEntry(aid, -2))); + notifyListeners(); + Book currentBook, newBook; + for (var i = 0; i < books.length; i++) { + currentBook = books[keys.elementAt(i)]; + if (currentBook.version == 0 || currentBook.http == null) { + hasNews[currentBook.aid] = -3; + continue; + } + try { + newBook = await currentBook.http + .getBook(currentBook.aid) + .timeout(Duration(seconds: 8)); + int different = newBook.chapterCount - currentBook.chapterCount; + hasNews[currentBook.aid] = different; + } catch (e) { + hasNews[currentBook.aid] = -1; + } + } + notifyListeners(); + } +} + +class FavoriteList extends StatefulWidget { + @override + _FavoriteList createState() => _FavoriteList(); +} + +class _FavoriteList extends State { + static bool showTip = false; + + static final loadFailTextSpan = TextSpan( + text: '读取失败,下拉刷新', style: TextStyle(color: Colors.redAccent)), + waitToCheck = + TextSpan(text: '等待检查更新', style: TextStyle(color: Colors.grey)), + unCheck = + TextSpan(text: '请下拉列表检查更新', style: TextStyle(color: Colors.grey)), + noUpdate = TextSpan(text: '没有更新', style: TextStyle(color: Colors.grey)), + outDate = TextSpan( + text: '旧版本的收藏数据,不检查更新', style: TextStyle(color: Colors.redAccent)); + + Widget bookBuilder(Book book, int state) { + TextSpan _state = unCheck; + if (state == null) { + _state = unCheck; + } else if (state > 0) { + _state = + TextSpan(text: '有 $state 章更新', style: TextStyle(color: Colors.green)); + } else if (state == 0) { + _state = noUpdate; + } else if (state == -1) { + _state = loadFailTextSpan; + } else if (state == -2) { + _state = waitToCheck; + } else if (state == -3) { + _state = outDate; + } + return FBookItem( + book: book, + subtitle: _state, + onDelete: deleteBook, + ); + } + + deleteBook(Book book) async { + final sure = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('确认删除 ${book.name} ?'), + actions: [ + FlatButton( + child: Text('确认'), + onPressed: () { + Navigator.pop(context, true); + }), + ], + ), + ); + if (sure == true) + Provider.of(context, listen: false).remove(book); + } + + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (_, setting, favorite, __) { + if (favorite.all.isEmpty) return Center(child: Text('没有收藏')); + List inWeekUpdated = [], + inWeekUnUpdated = [], + otherUpdated = [], + otherUnUpdated = []; + favorite.inWeek.forEach((aid, book) { + if (favorite.hasNews.containsKey(book.aid) && + favorite.hasNews[book.aid] > 0) + inWeekUpdated.add(book); + else + inWeekUnUpdated.add(book); + }); + favorite.other.forEach((aid, book) { + if (favorite.hasNews.containsKey(book.aid) && + favorite.hasNews[book.aid] > 0) + otherUpdated.add(book); + else + otherUnUpdated.add(book); + }); + return ClipRect( + child: RefreshIndicator( + onRefresh: () async { + favorite.checkNews(AutoCheckLevel.all); + }, + child: SafeArea( + child: CustomScrollView( + slivers: [ + SliverExpandableGroup( + title: Text('7天内看过并且有更新的藏书(${inWeekUpdated.length})'), + expanded: true, + count: inWeekUpdated.length, + builder: (ctx, i) => bookBuilder( + inWeekUpdated[i], + favorite.hasNews[inWeekUpdated[i].aid], + ), + ), + SliverExpandableGroup( + title: Text('7天内看过的藏书(${inWeekUnUpdated.length})'), + count: inWeekUnUpdated.length, + builder: (ctx, i) => bookBuilder( + inWeekUnUpdated[i], + favorite.hasNews[inWeekUnUpdated[i].aid], + ), + ), + SliverExpandableGroup( + title: Text('有更新的藏书(${otherUpdated.length})'), + count: otherUpdated.length, + builder: (ctx, i) => bookBuilder( + otherUpdated[i], + favorite.hasNews[otherUpdated[i].aid], + ), + ), + SliverExpandableGroup( + title: Text('没有更新的藏书(${otherUnUpdated.length})'), + count: otherUnUpdated.length, + builder: (ctx, i) => bookBuilder( + otherUnUpdated[i], + favorite.hasNews[otherUnUpdated[i].aid], + ), + ), + ], + )), + ), + ); + }); + } +} + +class FBookItem extends StatelessWidget { + final Book book; + final TextSpan subtitle; + final void Function(Book book) onDelete; + + const FBookItem({ + Key key, + @required this.book, + @required this.subtitle, + @required this.onDelete, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Slidable( + actionPane: SlidableDrawerActionPane(), + closeOnScroll: true, + actionExtentRatio: 0.25, + secondaryActions: [ + IconSlideAction( + caption: '删除', + color: Colors.red, + icon: Icons.delete, + onTap: () => onDelete(book), + ), + ], + child: ListTile( + onTap: () { + if (book.http != null) + return openBook(context, book, 'fb ${book.aid}'); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ActivitySearch(search: book.name), + )); + }, + // onLongPress: () => onDelete(book), + leading: Hero( + tag: 'fb ${book.aid}', + child: book.http == null + ? oldBookAvatar(text: '旧书', width: 50.0, height: 80.0) + : ExtendedImage( + image: NetworkImageSSL(book.http, book.avatar), + width: 50.0, + height: 80.0), + ), + title: Text(book.name), + subtitle: RichText(text: subtitle), + ), + ); + } +} diff --git a/lib/widgets/histories.dart b/lib/widgets/histories.dart new file mode 100644 index 0000000..b6e72e7 --- /dev/null +++ b/lib/widgets/histories.dart @@ -0,0 +1,131 @@ +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +import '../classes/book.dart'; +import '../classes/data.dart'; +import '../classes/networkImageSSL.dart'; +import '../utils.dart'; +import '../widgets/sliverExpandableGroup.dart'; +import '../widgets/utils.dart'; + +class Histories extends StatefulWidget { + @override + _Histories createState() => _Histories(); +} + +class _Histories extends State { + final List inWeek = [], other = []; + + @override + void initState() { + super.initState(); + loadBook(); + } + + void loadBook() { + inWeek.clear(); + other.clear(); + final list = Data.getHistories().values.toList(); + final now = DateTime.now().millisecondsSinceEpoch; + list.sort((a, b) => b.history.time.compareTo(a.history.time)); + list.forEach((book) { + if ((now - book.history.time) < weekTime) { + inWeek.add(book); + } else { + other.add(book); + } + }); + } + + void clear(bool inWeek) async { + final title = '确认清空 ' + (inWeek ? '7天内的' : '更早的') + '浏览记录 ?'; + final res = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text(title), + actions: [ + FlatButton( + textColor: Colors.grey, + child: Text('取消'), + onPressed: () => Navigator.pop(context, false), + ), + FlatButton( + child: Text('确认'), + onPressed: () => Navigator.pop(context, true), + ), + ], + )); + print('清理历史 $inWeek $res'); + if (res == false) return; + List list = inWeek ? this.inWeek : this.other; + list.forEach((book) => Data.removeHistoryFromBook(book)); + setState(() { + loadBook(); + }); + } + + Widget book(List array, int index) { + final Book book = array[index]; + return Slidable( + child: ListTile( + leading: book.http == null + ? oldBookAvatar(text: '旧\n书', width: 50.0, height: 80.0) + : ExtendedImage( + image: NetworkImageSSL(book.http, book.avatar), + width: 50.0, + height: 80.0), + title: Text(book.name), + subtitle: Text(book.history.cname), + onTap: () => openBook(context, book, 'fb ${book.aid}'), + ), + actionPane: SlidableDrawerActionPane(), + secondaryActions: [ + IconSlideAction( + caption: '删除', + color: Colors.red, + icon: Icons.delete, + onTap: () => setState(() { + array.removeAt(index); + Data.removeHistoryFromBook(book); + }), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: ClipRect( + child: CustomScrollView( + slivers: [ + SliverExpandableGroup( + title: Text('7天内的浏览历史 (${inWeek.length})'), + expanded: true, + actions: [ + FlatButton( + child: Text('清空'), + onPressed: inWeek.length == 0 ? null : () => clear(true), + ), + ], + count: inWeek.length, + builder: (ctx, i) => book(inWeek, i), + ), + SliverExpandableGroup( + title: Text('更早的浏览历史 (${other.length})'), + actions: [ + FlatButton( + child: Text('清空'), + onPressed: other.length == 0 ? null : () => clear(false), + ), + ], + count: other.length, + builder: (ctx, i) => book(other, i), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/pullToRefreshHeader.dart b/lib/widgets/pullToRefreshHeader.dart new file mode 100644 index 0000000..2540587 --- /dev/null +++ b/lib/widgets/pullToRefreshHeader.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:pull_to_refresh_notification/pull_to_refresh_notification.dart'; + +class SliverPullToRefreshHeader extends StatelessWidget { + static final double height = kToolbarHeight * 2; + final PullToRefreshScrollNotificationInfo info; + final void Function() onTap; + final double fontSize; + + const SliverPullToRefreshHeader({ + Key key, + @required this.info, + this.onTap, + this.fontSize = 16, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (info == null) return SliverToBoxAdapter(child: SizedBox()); + double dragOffset = info?.dragOffset ?? 0.0; + TextSpan text = TextSpan( + style: Theme.of(context).textTheme.body1.copyWith( + fontSize: fontSize, + ), + children: [ + WidgetSpan( + baseline: TextBaseline.alphabetic, + child: Padding( + child: Image.asset("assets/logo.png", height: 20), + padding: EdgeInsets.only(right: 5), + ), + ), + ]); + if (info.mode == RefreshIndicatorMode.error) { + text.children.addAll([ + TextSpan( + text: '读取失败\n当失败次数太多可能是网络出现问题\n', + style: TextStyle( + color: Colors.red, + ), + ), + WidgetSpan( + child: RaisedButton.icon( + icon: Icon(Icons.refresh), + onPressed: onTap, + label: Text('再次尝试'))), + ]); + } else if (info.mode == RefreshIndicatorMode.refresh || + info.mode == RefreshIndicatorMode.snap) { + text.children.addAll([ + TextSpan(text: '读取中,请稍候'), + ]); + } else if ([ + RefreshIndicatorMode.drag, + RefreshIndicatorMode.armed, + RefreshIndicatorMode.snap + ].contains(info.mode)) { + text.children.add(TextSpan(text: '重新读取')); + } else { + text.children.add(TextSpan(text: 'Bye~')); + } + return SliverToBoxAdapter( + child: Container( + height: dragOffset, + child: Center( + child: Text.rich( + text, + textAlign: TextAlign.center, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/quick.dart b/lib/widgets/quick.dart new file mode 100644 index 0000000..dabc650 --- /dev/null +++ b/lib/widgets/quick.dart @@ -0,0 +1,251 @@ +import 'package:draggable_container/draggable_container.dart'; +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +import '../classes/book.dart'; +import '../classes/data.dart'; +import '../classes/networkImageSSL.dart'; +import '../utils.dart'; +import '../widgets/favorites.dart'; +import '../widgets/utils.dart'; + +class QuickBook extends DraggableItem { + static const heroTag = 'quickBookAvatar'; + Widget child; + final BuildContext context; + final Book book; + final double width, height; + + QuickBook(this.width, this.height, + {@required this.book, @required this.context}) { + child = GestureDetector( + onTap: () { + openBook(context, book, '$heroTag ${book.aid}'); + }, + child: Stack( + children: [ + book.http == null + ? oldBookAvatar(width: width, height: height) + : SizedBox( + width: width, + height: height, + child: Hero( + tag: '$heroTag ${book.aid}', + child: Image( + image: NetworkImageSSL(book.http, book.avatar), + fit: BoxFit.cover, + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: EdgeInsets.only(left: 2, right: 2, top: 2, bottom: 2), + color: Colors.black.withOpacity(0.5), + child: Text( + book.name, + softWrap: true, + maxLines: 1, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white, fontSize: 10), + overflow: TextOverflow.ellipsis, + ), + ), + ) + ], + ), + ); + } +} + +class Quick extends StatefulWidget { + final double width, height; + final Function(bool mode) draggableModeChanged; + + const Quick( + {Key key, this.width, this.height, @required this.draggableModeChanged}) + : super(key: key); + + @override + QuickState createState() => QuickState(); +} + +class QuickState extends State { + final List id = []; + final int count = 8; + final List _draggableItems = []; + DraggableItem _addButton; + GlobalKey _key = + GlobalKey(); + double width = 0, height = 0; + + void exit() { + _key.currentState.draggableMode = false; + } + + _showSelectBookDialog() async { + print('添加漫画到快速导航'); + final books = Data.getFavorites(); + final list = books.values + .where((book) => !id.contains(book.aid)) + .map((book) => ListTile( + title: Text(book.name), + leading: ExtendedImage( + image: NetworkImageSSL(book.http, book.avatar), + fit: BoxFit.cover, + width: 40, + ), + onTap: () { + Navigator.pop(context, book); + }, + )); + return showDialog( + context: context, + builder: (_) { + return AlertDialog( + title: Text('将收藏的漫画添加到快速导航'), + content: Container( + width: double.maxFinite, + height: 300, + child: list.isNotEmpty + ? ListView( + children: ListTile.divideTiles( + context: context, + tiles: list, + ).toList(), + ) + : Center(child: Text('没有了')), + ), + ); + }); + } + + QuickState() { + _addButton = DraggableItem( + deletable: false, + fixed: true, + child: FlatButton( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add, + color: Colors.grey, + ), + Text( + '添加', + style: TextStyle( + fontSize: 10, + color: Colors.grey, + ), + ) + ], + ), + onPressed: () async { + final items = _key.currentState.items; + final buttonIndex = items.indexOf(_addButton); + print('add $buttonIndex'); + if (buttonIndex > -1) { + final book = await _showSelectBookDialog(); + print('选择了 $book'); + if (book == null) return; + _key.currentState.insteadOfIndex(buttonIndex, + QuickBook(width, height, book: book, context: context), + force: true); + } + }, + ), + ); + } + + int length() { + return _key.currentState.items.where((item) => item is QuickBook).length; + } + + @override + void initState() { + super.initState(); + + width = widget.width / 4 - 10; + height = (width / 0.7).roundToDouble(); + _draggableItems.addAll(Data.quickList().map((book) { + id.add(book.aid); + return QuickBook(width, height, book: book, context: context); + })); + if (_draggableItems.length < count) _draggableItems.add(_addButton); + for (var i = count - _draggableItems.length; i > 0; i--) { + _draggableItems.add(null); + } + + SchedulerBinding.instance.addPostFrameCallback((_) { + print('添加监听'); + Provider.of(context, listen: false).addListener(refresh); + }); + } + + void refresh() { + final id = Data.quickIdList(); + // print('refresh $id'); + for (var i = 0; i < _draggableItems.length; i++) { + final item = _draggableItems[i]; + if (item is QuickBook) { + // print('is QuickBook,delete : ${id.contains(item.book.aid)}'); + if (!id.contains(item.book.aid)) { + _key.currentState.insteadOfIndex(i, null); + } + } + } + } + + @override + Widget build(BuildContext context) { + print('quick build'); + return Column( + children: [ + Container( + margin: EdgeInsets.only(top: 8, bottom: 4, left: 8), + width: widget.width, + child: Text( + '快速导航(长按编辑)', + textAlign: TextAlign.left, + style: TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + DraggableContainer( + key: _key, + slotMargin: EdgeInsets.only(bottom: 8, left: 6, right: 6), + slotSize: Size(width, height), + slotDecoration: BoxDecoration(color: Colors.grey.withOpacity(0.3)), + dragDecoration: BoxDecoration( + boxShadow: [BoxShadow(color: Colors.black, blurRadius: 10)]), + items: _draggableItems, + onDraggableModeChanged: widget.draggableModeChanged, + onChanged: (List items) { + id.clear(); + items.forEach((item) { + if (item is QuickBook) id.add(item.book.aid); + }); + Data.addQuickAll(id); + final nullIndex = items.indexOf(null); + final buttonIndex = items.indexOf(_addButton); + print('null $nullIndex, button $buttonIndex'); + if (nullIndex > -1 && buttonIndex == -1) { + _key.currentState + .insteadOfIndex(nullIndex, _addButton, triggerEvent: false); + } else if (nullIndex > -1 && + buttonIndex > -1 && + nullIndex < buttonIndex) { + _key.currentState.removeItem(_addButton); + _key.currentState + .insteadOfIndex(nullIndex, _addButton, triggerEvent: false); + } + }, + ), + ], + ); + } +} diff --git a/lib/widgets/sliverExpandableGroup.dart b/lib/widgets/sliverExpandableGroup.dart new file mode 100644 index 0000000..cff0181 --- /dev/null +++ b/lib/widgets/sliverExpandableGroup.dart @@ -0,0 +1,95 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_sticky_header/flutter_sticky_header.dart'; + +class SliverExpandableBuilder { + final int count; + final WidgetBuilder builder; + + const SliverExpandableBuilder(this.count, this.builder); +} + +class SliverExpandableGroup extends StatefulWidget { + final Widget title; + final bool expanded; + final List actions; + final Color divideColor; + final double height; + final int count; + final IndexedWidgetBuilder builder; + + const SliverExpandableGroup({ + Key key, + @required this.title, + @required this.count, + @required this.builder, + this.expanded = false, + this.actions = const [], + this.divideColor = Colors.grey, + this.height = kToolbarHeight, + }) : assert(title != null), + assert(builder != null), + super(key: key); + + @override + _SliverExpandableGroup createState() => _SliverExpandableGroup(); +} + +class _SliverExpandableGroup extends State { + bool _expanded; + + @override + initState() { + super.initState(); + _expanded = widget.expanded; + } + + @override + Widget build(BuildContext context) { + Decoration _decoration = BoxDecoration( + border: Border( + bottom: Divider.createBorderSide(context, color: widget.divideColor), + ), + ); + return SliverStickyHeader( + header: InkWell( + child: Container( + height: widget.height, + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + color: Theme.of(context).dialogBackgroundColor, + ), + child: Row(children: [ + Transform.rotate( + angle: _expanded ? 0 : math.pi, + child: Icon( + Icons.arrow_drop_down, + color: Colors.grey, + ), + ), + Expanded(child: widget.title), + ...widget.actions, + ]), + ), + onTap: () { + setState(() { + _expanded = !_expanded; + }); + }, + ), + sliver: _expanded + ? SliverList( + delegate: SliverChildBuilderDelegate((ctx, i) { + if (i < widget.count - 1) { + return DecoratedBox( + decoration: _decoration, + child: widget.builder(context, i), + ); + } + return widget.builder(context, i); + }, childCount: widget.count)) + : null, + ); + } +} diff --git a/lib/widgets/utils.dart b/lib/widgets/utils.dart new file mode 100644 index 0000000..3edcf46 --- /dev/null +++ b/lib/widgets/utils.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class TextDivider extends StatelessWidget { + final String text; + final double leftPadding, padding; + final List actions; + + const TextDivider({ + Key key, + @required this.text, + this.padding = 5, + this.leftPadding = 15, + this.actions = const [], + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: + EdgeInsets.only(left: leftPadding, top: padding, bottom: padding), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded(child: Text(text, style: TextStyle(color: Colors.grey))), + ...actions, + ], + ), + ); + } +} + +Widget oldBookAvatar({ + String text = '旧\n藏\n书', + width = double.infinity, + height = double.infinity, +}) { + return Container( + width: width, + height: height, + alignment: Alignment.center, + color: Colors.greenAccent, + child: Text(text), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b156c46 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,106 @@ +name: weiman +description: 微漫App + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.1.1+2007 + +environment: + sdk: ">=2.3.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + + dio: any + dio_http_cache: any + cupertino_icons: any + async: any + http: any + encrypt: any + html: any + shared_preferences: any + random_string: any + filesize: any + oktoast: any + path_provider: any + draggable_container: any + sticky_headers: any + flutter_sticky_header: any + extended_nested_scroll_view: any + dynamic_theme: any + package_info: any + url_launcher: any + font_awesome_flutter: any + loadmore: any + pull_to_refresh_notification: any + http_client_helper: any + extended_image: any + screenshot: any + focus_widget: any + provider: any + loading_more_list: any + flutter_slidable: any + + firebase_core: any + firebase_analytics: any + +dev_dependencies: + flutter_test: + sdk: flutter + floor_generator: any + build_runner: any + + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/logo.png + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages