diff --git a/assets/init.js b/assets/init.js index c485a50..9f00eb3 100644 --- a/assets/init.js +++ b/assets/init.js @@ -308,14 +308,17 @@ function setInterval(callback, delay) { return timer; } -function Cookie(name, value, domain = null) { - let obj = {}; - obj.name = name; - obj.value = value; - if (domain) { - obj.domain = domain; - } - return obj; +/** + * Create a cookie object. + * @param name {string} + * @param value {string} + * @param domain {string} + * @constructor + */ +function Cookie({name, value, domain}) { + this.name = name; + this.value = value; + this.domain = domain; } /** @@ -491,7 +494,7 @@ class HtmlDocument { /** * Query a single element from the HTML document. * @param {string} query - The query string. - * @returns {HtmlElement} The first matching element. + * @returns {HtmlElement | null} The first matching element. */ querySelector(query) { let k = sendMessage({ @@ -530,6 +533,22 @@ class HtmlDocument { key: this.key }) } + + /** + * Get the element by its id. + * @param id {string} + * @returns {HtmlElement|null} + */ + getElementById(id) { + let k = sendMessage({ + method: "html", + function: "getElementById", + key: this.key, + id: id + }) + if(k == null) return null; + return new HtmlElement(k, this.key); + } } /** @@ -703,6 +722,36 @@ class HtmlElement { doc: this.doc, }) } + + /** + * Get the previous sibling element of the element. If the element has no previous sibling, return null. + * @returns {HtmlElement|null} + */ + get previousElementSibling() { + let k = sendMessage({ + method: "html", + function: "getPreviousSibling", + key: this.key, + doc: this.doc, + }) + if(k == null) return null; + return new HtmlElement(k, this.doc); + } + + /** + * Get the next sibling element of the element. If the element has no next sibling, return null. + * @returns {HtmlElement|null} + */ + get nextElementSibling() { + let k = sendMessage({ + method: "html", + function: "getNextSibling", + key: this.key, + doc: this.doc, + }) + if (k == null) return null; + return new HtmlElement(k, this.doc); + } } class HtmlNode { @@ -789,9 +838,10 @@ let console = { * @param maxPage {number?} * @param language {string?} * @param favoriteId {string?} - Only set this field if the comic is from favorites page + * @param stars {number?} - 0-5, double * @constructor */ -function Comic({id, title, subtitle, cover, tags, description, maxPage, language, favoriteId}) { +function Comic({id, title, subtitle, cover, tags, description, maxPage, language, favoriteId, stars}) { this.id = id; this.title = title; this.subtitle = subtitle; @@ -801,6 +851,7 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage, language this.maxPage = maxPage; this.language = language; this.favoriteId = favoriteId; + this.stars = stars; } /** @@ -821,9 +872,11 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage, language * @param updateTime {string?} * @param uploadTime {string?} * @param url {string?} + * @param stars {number?} - 0-5, double + * @param maxPage {number?} * @constructor */ -function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url}) { +function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url, stars, maxPage}) { this.title = title; this.cover = cover; this.description = description; @@ -840,6 +893,8 @@ function ComicDetails({title, cover, description, tags, chapters, isFavorite, su this.updateTime = updateTime; this.uploadTime = uploadTime; this.url = url; + this.stars = stars; + this.maxPage = maxPage; } /** diff --git a/lib/components/appbar.dart b/lib/components/appbar.dart index a58d14f..91b7392 100644 --- a/lib/components/appbar.dart +++ b/lib/components/appbar.dart @@ -567,6 +567,7 @@ class SliverSearchBar extends StatefulWidget { required this.controller, this.onChanged, this.action, + this.focusNode, }); final SearchBarController controller; @@ -575,6 +576,8 @@ class SliverSearchBar extends StatefulWidget { final Widget? action; + final FocusNode? focusNode; + @override State createState() => _SliverSearchBarState(); } @@ -613,6 +616,7 @@ class _SliverSearchBarState extends State topPadding: MediaQuery.of(context).padding.top, onChanged: widget.onChanged, action: widget.action, + focusNode: widget.focusNode, ), ); } @@ -629,12 +633,15 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { final Widget? action; + final FocusNode? focusNode; + const _SliverSearchBarDelegate({ required this.editingController, required this.controller, required this.topPadding, this.onChanged, this.action, + this.focusNode, }); static const _kAppBarHeight = 52.0; @@ -662,6 +669,7 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: TextField( + focusNode: focusNode, controller: editingController, decoration: InputDecoration( hintText: "Search".tl, diff --git a/lib/components/comic.dart b/lib/components/comic.dart index b727272..c24f915 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -195,6 +195,7 @@ class ComicTile extends StatelessWidget { enableTranslate: ComicSource.find(comic.sourceKey) ?.enableTagsTranslate ?? false, + rating: comic.stars, ), ), ], @@ -285,6 +286,7 @@ class _ComicDescription extends StatelessWidget { this.badge, this.maxLines = 2, this.tags, + this.rating, }); final String title; @@ -294,6 +296,7 @@ class _ComicDescription extends StatelessWidget { final List? tags; final int maxLines; final bool enableTranslate; + final double? rating; @override Widget build(BuildContext context) { @@ -358,6 +361,7 @@ class _ComicDescription extends StatelessWidget { ), const SizedBox(height: 2), const Spacer(), + if (rating != null) StarRating(value: rating!, size: 18), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -623,9 +627,9 @@ class ComicListState extends State { String? _nextUrl; void remove(Comic c) { - if(_data[_page] == null || !_data[_page]!.remove(c)) { - for(var page in _data.values) { - if(page.remove(c)) { + if (_data[_page] == null || !_data[_page]!.remove(c)) { + for (var page in _data.values) { + if (page.remove(c)) { break; } } @@ -685,7 +689,7 @@ class ComicListState extends State { (_maxPage == null || page <= _maxPage!)) { setState(() { _error = null; - this._page = page; + _page = page; }); } else { context.showMessage( @@ -777,10 +781,10 @@ class ComicListState extends State { Future _fetchNext() async { var res = await widget.loadNext!(_nextUrl); _data[_data.length + 1] = res.data; - if (res.subData['next'] == null) { + if (res.subData == null) { _maxPage = _data.length; } else { - _nextUrl = res.subData['next']; + _nextUrl = res.subData; } } @@ -828,3 +832,286 @@ class ComicListState extends State { ); } } + +class StarRating extends StatelessWidget { + const StarRating({ + super.key, + required this.value, + this.onTap, + this.size = 20, + }); + + final double value; // 0-5 + + final VoidCallback? onTap; + + final double size; + + @override + Widget build(BuildContext context) { + var interval = size * 0.1; + var value = this.value; + if (value.isNaN) { + value = 0; + } + var child = SizedBox( + height: size, + width: size * 5 + interval * 4, + child: Row( + children: [ + for (var i = 0; i < 5; i++) + _Star( + value: (value - i).clamp(0.0, 1.0), + size: size, + ).paddingRight(i == 4 ? 0 : interval), + ], + ), + ); + return onTap == null + ? child + : GestureDetector( + onTap: onTap, + child: child, + ); + } +} + +class _Star extends StatelessWidget { + const _Star({required this.value, required this.size}); + + final double value; // 0-1 + + final double size; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: Stack( + children: [ + Icon( + Icons.star_outline, + size: size, + color: context.colorScheme.secondary, + ), + ClipRect( + clipper: _StarClipper(value), + child: Icon( + Icons.star, + size: size, + color: context.colorScheme.secondary, + ), + ), + ], + ), + ); + } +} + +class _StarClipper extends CustomClipper { + final double value; + + _StarClipper(this.value); + + @override + Rect getClip(Size size) { + return Rect.fromLTWH(0, 0, size.width * value, size.height); + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) { + return oldClipper is! _StarClipper || oldClipper.value != value; + } +} + +class RatingWidget extends StatefulWidget { + /// star number + final int count; + + /// Max score + final double maxRating; + + /// Current score value + final double value; + + /// Star size + final double size; + + /// Space between the stars + final double padding; + + /// Whether the score can be modified by sliding + final bool selectable; + + /// Callbacks when ratings change + final ValueChanged onRatingUpdate; + + const RatingWidget( + {super.key, + this.maxRating = 10.0, + this.count = 5, + this.value = 10.0, + this.size = 20, + required this.padding, + this.selectable = false, + required this.onRatingUpdate}); + + @override + State createState() => _RatingWidgetState(); +} + +class _RatingWidgetState extends State { + double value = 10; + + @override + Widget build(BuildContext context) { + return Listener( + onPointerDown: (PointerDownEvent event) { + double x = event.localPosition.dx; + if (x < 0) x = 0; + pointValue(x); + }, + onPointerMove: (PointerMoveEvent event) { + double x = event.localPosition.dx; + if (x < 0) x = 0; + pointValue(x); + }, + onPointerUp: (_) {}, + behavior: HitTestBehavior.deferToChild, + child: buildRowRating(), + ); + } + + pointValue(double dx) { + if (!widget.selectable) { + return; + } + if (dx >= + widget.size * widget.count + widget.padding * (widget.count - 1)) { + value = widget.maxRating; + } else { + for (double i = 1; i < widget.count + 1; i++) { + if (dx > widget.size * i + widget.padding * (i - 1) && + dx < widget.size * i + widget.padding * i) { + value = i * (widget.maxRating / widget.count); + break; + } else if (dx > widget.size * (i - 1) + widget.padding * (i - 1) && + dx < widget.size * i + widget.padding * i) { + value = (dx - widget.padding * (i - 1)) / + (widget.size * widget.count) * + widget.maxRating; + break; + } + } + } + if (value % 1 >= 0.5) { + value = value ~/ 1 + 1; + } else { + value = (value ~/ 1).toDouble(); + } + if (value < 0) { + value = 0; + } else if (value > 10) { + value = 10; + } + setState(() { + widget.onRatingUpdate(value); + }); + } + + int fullStars() { + return (value / (widget.maxRating / widget.count)).floor(); + } + + double star() { + if (widget.count / fullStars() == widget.maxRating / value) { + return 0; + } + return (value % (widget.maxRating / widget.count)) / + (widget.maxRating / widget.count); + } + + List buildRow() { + int full = fullStars(); + List children = []; + for (int i = 0; i < full; i++) { + children.add(Icon( + Icons.star, + size: widget.size, + color: context.colorScheme.secondary, + )); + if (i < widget.count - 1) { + children.add( + SizedBox( + width: widget.padding, + ), + ); + } + } + if (full < widget.count) { + children.add(ClipRect( + clipper: SMClipper(rating: star() * widget.size), + child: Icon( + Icons.star, + size: widget.size, + color: context.colorScheme.secondary, + ), + )); + } + + return children; + } + + List buildNormalRow() { + List children = []; + for (int i = 0; i < widget.count; i++) { + children.add(Icon( + Icons.star_border, + size: widget.size, + color: context.colorScheme.secondary, + )); + if (i < widget.count - 1) { + children.add(SizedBox( + width: widget.padding, + )); + } + } + return children; + } + + Widget buildRowRating() { + return Stack( + children: [ + Row( + children: buildNormalRow(), + ), + Row( + children: buildRow(), + ) + ], + ); + } + + @override + void initState() { + super.initState(); + value = widget.value; + } +} + +class SMClipper extends CustomClipper { + final double rating; + + SMClipper({required this.rating}); + + @override + Rect getClip(Size size) { + return Rect.fromLTRB(0.0, 0.0, rating, size.height); + } + + @override + bool shouldReclip(SMClipper oldClipper) { + return rating != oldClipper.rating; + } +} diff --git a/lib/components/components.dart b/lib/components/components.dart index 44b4b3d..bfc9d3d 100644 --- a/lib/components/components.dart +++ b/lib/components/components.dart @@ -3,7 +3,7 @@ library components; import 'dart:async'; import 'dart:collection'; import 'dart:math' as math; -import 'dart:ui'; +import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; diff --git a/lib/components/effects.dart b/lib/components/effects.dart index 01ae233..d96be30 100644 --- a/lib/components/effects.dart +++ b/lib/components/effects.dart @@ -19,7 +19,7 @@ class BlurEffect extends StatelessWidget { return ClipRRect( borderRadius: borderRadius ?? BorderRadius.zero, child: BackdropFilter( - filter: ImageFilter.blur( + filter: ui.ImageFilter.blur( sigmaX: blur, sigmaY: blur, tileMode: TileMode.mirror, diff --git a/lib/components/image.dart b/lib/components/image.dart index abf6f5f..0ec5edf 100644 --- a/lib/components/image.dart +++ b/lib/components/image.dart @@ -21,11 +21,11 @@ class AnimatedImage extends StatefulWidget { this.gaplessPlayback = false, this.filterQuality = FilterQuality.medium, this.isAntiAlias = false, + this.part, Map? headers, int? cacheWidth, int? cacheHeight, - } - ): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image), + }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image), assert(cacheWidth == null || cacheWidth > 0), assert(cacheHeight == null || cacheHeight > 0); @@ -61,13 +61,16 @@ class AnimatedImage extends StatefulWidget { final bool isAntiAlias; + final ImagePart? part; + static void clear() => _AnimatedImageState.clear(); @override State createState() => _AnimatedImageState(); } -class _AnimatedImageState extends State with WidgetsBindingObserver { +class _AnimatedImageState extends State + with WidgetsBindingObserver { ImageStream? _imageStream; ImageInfo? _imageInfo; ImageChunkEvent? _loadingProgress; @@ -138,8 +141,8 @@ class _AnimatedImageState extends State with WidgetsBindingObserv } void _updateInvertColors() { - _invertColors = MediaQuery.maybeInvertColorsOf(context) - ?? SemanticsBinding.instance.accessibilityFeatures.invertColors; + _invertColors = MediaQuery.maybeInvertColorsOf(context) ?? + SemanticsBinding.instance.accessibilityFeatures.invertColors; } void _resolveImage() { @@ -148,16 +151,19 @@ class _AnimatedImageState extends State with WidgetsBindingObserv imageProvider: widget.image, ); final ImageStream newStream = - provider.resolve(createLocalImageConfiguration( + provider.resolve(createLocalImageConfiguration( context, - size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null, + size: widget.width != null && widget.height != null + ? Size(widget.width!, widget.height!) + : null, )); _updateSourceStream(newStream); } ImageStreamListener? _imageStreamListener; + ImageStreamListener _getListener({bool recreateListener = false}) { - if(_imageStreamListener == null || recreateListener) { + if (_imageStreamListener == null || recreateListener) { _lastException = null; _imageStreamListener = ImageStreamListener( _handleImageFrame, @@ -191,7 +197,8 @@ class _AnimatedImageState extends State with WidgetsBindingObserv void _replaceImage({required ImageInfo? info}) { final ImageInfo? oldImageInfo = _imageInfo; - SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose()); + SchedulerBinding.instance + .addPostFrameCallback((_) => oldImageInfo?.dispose()); _imageInfo = info; } @@ -208,7 +215,9 @@ class _AnimatedImageState extends State with WidgetsBindingObserv } if (!widget.gaplessPlayback) { - setState(() { _replaceImage(info: null); }); + setState(() { + _replaceImage(info: null); + }); } setState(() { @@ -247,7 +256,9 @@ class _AnimatedImageState extends State with WidgetsBindingObserv return; } - if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) { + if (keepStreamAlive && + _completerHandle == null && + _imageStream?.completer != null) { _completerHandle = _imageStream!.completer!.keepAlive(); } @@ -259,7 +270,19 @@ class _AnimatedImageState extends State with WidgetsBindingObserv Widget build(BuildContext context) { Widget result; - if(_imageInfo != null){ + if (_imageInfo != null) { + if(widget.part != null) { + return CustomPaint( + painter: ImagePainter( + image: _imageInfo!.image, + part: widget.part!, + ), + child: SizedBox( + width: widget.width, + height: widget.height, + ), + ); + } result = RawImage( image: _imageInfo?.image, width: widget.width, @@ -291,7 +314,7 @@ class _AnimatedImageState extends State with WidgetsBindingObserv child: result, ); } - } else{ + } else { result = const Center(); } @@ -307,8 +330,59 @@ class _AnimatedImageState extends State with WidgetsBindingObserv super.debugFillProperties(description); description.add(DiagnosticsProperty('stream', _imageStream)); description.add(DiagnosticsProperty('pixels', _imageInfo)); - description.add(DiagnosticsProperty('loadingProgress', _loadingProgress)); + description.add(DiagnosticsProperty( + 'loadingProgress', _loadingProgress)); description.add(DiagnosticsProperty('frameNumber', _frameNumber)); - description.add(DiagnosticsProperty('wasSynchronouslyLoaded', _wasSynchronouslyLoaded)); + description.add(DiagnosticsProperty( + 'wasSynchronouslyLoaded', _wasSynchronouslyLoaded)); + } +} + +class ImagePart { + final double? x1; + final double? y1; + final double? x2; + final double? y2; + + const ImagePart({ + this.x1, + this.y1, + this.x2, + this.y2, + }); +} + +class ImagePainter extends CustomPainter { + final ui.Image image; + + final ImagePart part; + + /// Render a part of the image. + const ImagePainter({ + required this.image, + this.part = const ImagePart(), + }); + + @override + void paint(Canvas canvas, Size size) { + final Rect src = Rect.fromPoints( + Offset(part.x1 ?? 0, part.y1 ?? 0), + Offset( + part.x2 ?? image.width.toDouble(), + part.y2 ?? image.height.toDouble(), + ), + ); + final Rect dst = Offset.zero & size; + canvas.drawImageRect(image, src, dst, Paint()); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return oldDelegate is! ImagePainter || + oldDelegate.image != image || + oldDelegate.part.x1 != part.x1 || + oldDelegate.part.y1 != part.y1 || + oldDelegate.part.x2 != part.x2 || + oldDelegate.part.y2 != part.y2; } } diff --git a/lib/components/scroll.dart b/lib/components/scroll.dart index 1b7e9b2..053436e 100644 --- a/lib/components/scroll.dart +++ b/lib/components/scroll.dart @@ -62,6 +62,7 @@ class _SmoothScrollProviderState extends State { return Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { + _futurePosition = null; if (_isMouseScroll) { setState(() { _isMouseScroll = false; diff --git a/lib/components/window_frame.dart b/lib/components/window_frame.dart index 2bdfcff..37b078c 100644 --- a/lib/components/window_frame.dart +++ b/lib/components/window_frame.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/state_controller.dart'; import 'package:window_manager/window_manager.dart'; @@ -97,8 +99,12 @@ class WindowFrame extends StatelessWidget { ).toAlign(Alignment.centerLeft).paddingLeft(4), ), ), - if (!App.isMacOS) - const WindowButtons() + if (kDebugMode) + const TextButton( + onPressed: debug, + child: Text('Debug'), + ), + if (!App.isMacOS) const WindowButtons() ], ), ); @@ -622,3 +628,7 @@ TransitionBuilder VirtualWindowFrameInit() { ); }; } + +void debug() { + ComicSource.reload(); +} \ No newline at end of file diff --git a/lib/foundation/comic_source/comic_source.dart b/lib/foundation/comic_source/comic_source.dart index 099574e..f39a346 100644 --- a/lib/foundation/comic_source/comic_source.dart +++ b/lib/foundation/comic_source/comic_source.dart @@ -27,6 +27,10 @@ part 'models.dart'; /// build comic list, [Res.subData] should be maxPage or null if there is no limit. typedef ComicListBuilder = Future>> Function(int page); +/// build comic list with next param, [Res.subData] should be next page param or null if there is no next page. +typedef ComicListBuilderWithNext = Future>> Function( + String? next); + typedef LoginFunction = Future> Function(String, String); typedef LoadComicFunc = Future> Function(String id); @@ -40,7 +44,7 @@ typedef CommentsLoader = Future>> Function( typedef SendCommentFunc = Future> Function( String id, String? subId, String content, String? replyTo); -typedef GetImageLoadingConfigFunc = Map Function( +typedef GetImageLoadingConfigFunc = Future> Function( String imageKey, String comicId, String epId)?; typedef GetThumbnailLoadingConfigFunc = Map Function( String imageKey)?; @@ -64,6 +68,9 @@ typedef VoteCommentFunc = Future> Function( typedef HandleClickTagEvent = Map Function( String namespace, String tag); +/// [rating] is the rating value, 0-10. 1 represents 0.5 star. +typedef StarRatingFunc = Future> Function(String comicId, int rating); + class ComicSource { static final List _sources = []; @@ -163,8 +170,7 @@ class ComicSource { /// Load comic pages. final LoadComicPagesFunc? loadComicPages; - final Map Function( - String imageKey, String comicId, String epId)? getImageLoadingConfig; + final GetImageLoadingConfigFunc? getImageLoadingConfig; final Map Function(String imageKey)? getThumbnailLoadingConfig; @@ -203,6 +209,8 @@ class ComicSource { final bool enableTagsTranslate; + final StarRatingFunc? starRatingFunc; + Future loadData() async { var file = File("${App.dataPath}/comic_source/$key.data"); if (await file.exists()) { @@ -270,6 +278,7 @@ class ComicSource { this.linkHandler, this.enableTagsSuggestions, this.enableTagsTranslate, + this.starRatingFunc, ); } @@ -288,12 +297,21 @@ class AccountConfig { final bool Function(String url, String title)? checkLoginStatus; + final void Function()? onLoginWithWebviewSuccess; + + final List? cookieFields; + + final Future Function(List)? validateCookies; + const AccountConfig( this.login, this.loginWebsite, this.registerWebsite, this.logout, this.checkLoginStatus, + this.onLoginWithWebviewSuccess, + this.cookieFields, + this.validateCookies, ) : allowReLogin = true, infoItems = const []; } @@ -322,6 +340,8 @@ class ExplorePageData { final ComicListBuilder? loadPage; + final ComicListBuilderWithNext? loadNext; + final Future>> Function()? loadMultiPart; /// return a `List` contains `List` or `ExplorePagePart` @@ -331,6 +351,7 @@ class ExplorePageData { this.title, this.type, this.loadPage, + this.loadNext, this.loadMultiPart, this.loadMixed, ); @@ -388,9 +409,13 @@ class SearchOptions { final String label; - const SearchOptions(this.options, this.label); + final String type; - String get defaultValue => options.keys.first; + final String? defaultVal; + + const SearchOptions(this.options, this.label, this.type, this.defaultVal); + + String get defaultValue => defaultVal ?? options.keys.first; } typedef CategoryComicsLoader = Future>> Function( @@ -401,7 +426,7 @@ class CategoryComicsData { final List options; /// [category] is the one clicked by the user on the category page. - + /// /// if [BaseCategoryPart.categoryParams] is not null, [param] will be not null. /// /// [Res.subData] should be maxPage or null if there is no limit. @@ -415,9 +440,12 @@ class CategoryComicsData { class RankingData { final Map options; - final Future>> Function(String option, int page) load; + final Future>> Function(String option, int page)? load; - const RankingData(this.options, this.load); + final Future>> Function(String option, String? next)? + loadWithNext; + + const RankingData(this.options, this.load, this.loadWithNext); } class CategoryComicsOptions { @@ -434,11 +462,10 @@ class CategoryComicsOptions { const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen); } - class LinkHandler { final List domains; final String? Function(String url) linkToId; const LinkHandler(this.domains, this.linkToId); -} \ No newline at end of file +} diff --git a/lib/foundation/comic_source/favorites.dart b/lib/foundation/comic_source/favorites.dart index 28cd93b..8fe8651 100644 --- a/lib/foundation/comic_source/favorites.dart +++ b/lib/foundation/comic_source/favorites.dart @@ -1,20 +1,26 @@ part of 'comic_source.dart'; -typedef AddOrDelFavFunc = Future> Function(String comicId, String folderId, bool isAdding, String? favId); +typedef AddOrDelFavFunc = Future> Function( + String comicId, String folderId, bool isAdding, String? favId); -class FavoriteData{ +class FavoriteData { final String key; final String title; final bool multiFolder; - final Future>> Function(int page, [String? folder]) loadComic; + final Future>> Function(int page, [String? folder])? + loadComic; + + final Future>> Function(String? next, [String? folder])? + loadNext; /// key-id, value-name /// /// if comicId is not null, Res.subData is the folders that the comic is in - final Future>> Function([String? comicId])? loadFolders; + final Future>> Function([String? comicId])? + loadFolders; /// A value of null disables this feature final Future> Function(String key)? deleteFolder; @@ -32,19 +38,21 @@ class FavoriteData{ required this.title, required this.multiFolder, required this.loadComic, + required this.loadNext, this.loadFolders, this.deleteFolder, this.addFolder, this.allFavoritesId, - this.addOrDelFavorite}); + this.addOrDelFavorite, + }); } -FavoriteData getFavoriteData(String key){ +FavoriteData getFavoriteData(String key) { var source = ComicSource.find(key) ?? (throw "Unknown source key: $key"); return source.favoriteData!; } -FavoriteData? getFavoriteDataOrNull(String key){ +FavoriteData? getFavoriteDataOrNull(String key) { var source = ComicSource.find(key); return source?.favoriteData; -} \ No newline at end of file +} diff --git a/lib/foundation/comic_source/models.dart b/lib/foundation/comic_source/models.dart index a0d548e..59b7fd7 100644 --- a/lib/foundation/comic_source/models.dart +++ b/lib/foundation/comic_source/models.dart @@ -7,9 +7,9 @@ class Comment { final String? time; final int? replyCount; final String? id; - final int? score; + int? score; final bool? isLiked; - final int? voteStatus; // 1: upvote, -1: downvote, 0: none + int? voteStatus; // 1: upvote, -1: downvote, 0: none static String? parseTime(dynamic value) { if (value == null) return null; @@ -60,6 +60,9 @@ class Comic { final String? favoriteId; + /// 0-5 + final double? stars; + const Comic( this.title, this.cover, @@ -70,7 +73,7 @@ class Comic { this.sourceKey, this.maxPage, this.language, - ): favoriteId = null; + ): favoriteId = null, stars = null; Map toJson() { return { @@ -96,7 +99,8 @@ class Comic { description = json["description"] ?? "", maxPage = json["maxPage"], language = json["language"], - favoriteId = json["favoriteId"]; + favoriteId = json["favoriteId"], + stars = (json["stars"] as num?)?.toDouble(); @override bool operator ==(Object other) { @@ -151,6 +155,11 @@ class ComicDetails with HistoryMixin { final String? url; + final double? stars; + + @override + final int? maxPage; + static Map> _generateMap(Map map) { var res = >{}; map.forEach((key, value) { @@ -182,7 +191,9 @@ class ComicDetails with HistoryMixin { uploader = json["uploader"], uploadTime = json["uploadTime"], updateTime = json["updateTime"], - url = json["url"]; + url = json["url"], + stars = (json["stars"] as num?)?.toDouble(), + maxPage = json["maxPage"]; Map toJson() { return { diff --git a/lib/foundation/comic_source/parser.dart b/lib/foundation/comic_source/parser.dart index b114cc5..7a800b7 100644 --- a/lib/foundation/comic_source/parser.dart +++ b/lib/foundation/comic_source/parser.dart @@ -150,6 +150,7 @@ class ComicSourceParser { _parseLinkHandler(), _getValue("search.enableTagsSuggestions") ?? false, _getValue("comic.enableTagsTranslate") ?? false, + _parseStarRatingFunc(), ); await source.loadData(); @@ -182,48 +183,77 @@ class ComicSourceParser { return null; } - Future> login(account, pwd) async { - try { - await JsEngine().runCode(""" + Future> Function(String account, String pwd)? login; + + if(_checkExists("account.login")) { + login = (account, pwd) async { + try { + await JsEngine().runCode(""" ComicSource.sources.$_key.account.login(${jsonEncode(account)}, ${jsonEncode(pwd)}) """); - var source = ComicSource.find(_key!)!; - source.data["account"] = [account, pwd]; - source.saveData(); - return const Res(true); - } catch (e, s) { - Log.error("Network", "$e\n$s"); - return Res.error(e.toString()); - } + var source = ComicSource.find(_key!)!; + source.data["account"] = [account, pwd]; + source.saveData(); + return const Res(true); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + }; } void logout() { JsEngine().runCode("ComicSource.sources.$_key.account.logout()"); } - if (!_checkExists('account.loginWithWebview')) { - return AccountConfig( - login, - null, - _getValue("account.registerWebsite"), - logout, - null, - ); - } else { - return AccountConfig( - null, - _getValue("account.loginWithWebview.url"), - _getValue("account.registerWebsite"), - logout, - (url, title) { - return JsEngine().runCode(""" + bool Function(String url, String title)? checkLoginStatus; + + void Function()? onLoginSuccess; + + if (_checkExists('account.loginWithWebview')) { + checkLoginStatus = (url, title) { + return JsEngine().runCode(""" ComicSource.sources.$_key.account.loginWithWebview.checkStatus( ${jsonEncode(url)}, ${jsonEncode(title)}) """); - }, - ); + }; + + if (_checkExists('account.loginWithWebview.onLoginSuccess')) { + onLoginSuccess = () { + JsEngine().runCode(""" + ComicSource.sources.$_key.account.loginWithWebview.onLoginSuccess() + """); + }; + } } + + Future Function(List)? validateCookies; + + if (_checkExists('account.loginWithCookies?.validate')) { + validateCookies = (cookies) async { + try { + var res = await JsEngine().runCode(""" + ComicSource.sources.$_key.account.loginWithCookies.validate(${jsonEncode(cookies)}) + """); + return res; + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return false; + } + }; + } + + return AccountConfig( + login, + _getValue("account.loginWithWebview?.url"), + _getValue("account.registerWebsite"), + logout, + checkLoginStatus, + onLoginSuccess, + ListOrNull.from(_getValue("account.loginWithCookies?.fields")), + validateCookies, + ); } List _loadExploreData() { @@ -237,6 +267,7 @@ class ComicSourceParser { final String type = _getValue("explore[$i].type"); Future>> Function()? loadMultiPart; Future>> Function(int page)? loadPage; + Future>> Function(String? next)? loadNext; Future>> Function(int index)? loadMixed; if (type == "singlePageWithMultiPart") { loadMultiPart = () async { @@ -257,19 +288,36 @@ class ComicSourceParser { } }; } else if (type == "multiPageComicList") { - loadPage = (int page) async { - try { - var res = await JsEngine().runCode( - "ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})"); - return Res( + if (_checkExists("explore[$i].load")) { + loadPage = (int page) async { + try { + var res = await JsEngine().runCode( + "ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})"); + return Res( + List.generate(res["comics"].length, + (index) => Comic.fromJson(res["comics"][index], _key!)), + subData: res["maxPage"]); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + }; + } else { + loadNext = (next) async { + try { + var res = await JsEngine().runCode( + "ComicSource.sources.$_key.explore[$i].loadNext(${jsonEncode(next)})"); + return Res( List.generate(res["comics"].length, (index) => Comic.fromJson(res["comics"][index], _key!)), - subData: res["maxPage"]); - } catch (e, s) { - Log.error("Network", "$e\n$s"); - return Res.error(e.toString()); - } - }; + subData: res["next"], + ); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + }; + } } else if (type == "multiPartPage") { loadMultiPart = () async { try { @@ -330,6 +378,7 @@ class ComicSourceParser { throw ComicSourceParseException("Unknown explore page type $type") }, loadPage, + loadNext, loadMultiPart, loadMixed, )); @@ -406,21 +455,44 @@ class ComicSourceParser { var value = split.join("-"); options[key] = value; } - rankingData = RankingData(options, (option, page) async { - try { - var res = await JsEngine().runCode(""" + Future>> Function(String option, int page)? load; + Future>> Function(String option, String? next)? + loadWithNext; + if (_checkExists("categoryComics.ranking.load")) { + load = (option, page) async { + try { + var res = await JsEngine().runCode(""" ComicSource.sources.$_key.categoryComics.ranking.load( ${jsonEncode(option)}, ${jsonEncode(page)}) """); - return Res( + return Res( + List.generate(res["comics"].length, + (index) => Comic.fromJson(res["comics"][index], _key!)), + subData: res["maxPage"]); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + }; + } else { + loadWithNext = (option, next) async { + try { + var res = await JsEngine().runCode(""" + ComicSource.sources.$_key.categoryComics.ranking.loadWithNext( + ${jsonEncode(option)}, ${jsonEncode(next)}) + """); + return Res( List.generate(res["comics"].length, (index) => Comic.fromJson(res["comics"][index], _key!)), - subData: res["maxPage"]); - } catch (e, s) { - Log.error("Network", "$e\n$s"); - return Res.error(e.toString()); - } - }); + subData: res["next"], + ); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + }; + } + rankingData = RankingData(options, load, loadWithNext); } return CategoryComicsData(options, (category, param, options, page) async { try { @@ -457,7 +529,12 @@ class ComicSourceParser { var value = split.join("-"); map[key] = value; } - options.add(SearchOptions(map, element["label"])); + options.add(SearchOptions( + map, + element["label"], + element['type'] ?? 'select', + element['default'] == null ? null : jsonEncode(element['default']), + )); } return SearchPageData(options, (keyword, page, searchOption) async { try { @@ -550,24 +627,53 @@ class ComicSourceParser { return retryZone(func); } - Future>> loadComic(int page, [String? folder]) async { - Future>> func() async { - try { - var res = await JsEngine().runCode(""" + Future>> Function(int page, [String? folder])? loadComic; + + Future>> Function(String? next, [String? folder])? loadNext; + + if (_checkExists("favorites.loadComic")) { + loadComic = (int page, [String? folder]) async { + Future>> func() async { + try { + var res = await JsEngine().runCode(""" ComicSource.sources.$_key.favorites.loadComics( ${jsonEncode(page)}, ${jsonEncode(folder)}) """); - return Res( + return Res( + List.generate(res["comics"].length, + (index) => Comic.fromJson(res["comics"][index], _key!)), + subData: res["maxPage"]); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + } + + return retryZone(func); + }; + } + + if (_checkExists("favorites.loadNext")) { + loadNext = (String? next, [String? folder]) async { + Future>> func() async { + try { + var res = await JsEngine().runCode(""" + ComicSource.sources.$_key.favorites.loadNext( + ${jsonEncode(next)}, ${jsonEncode(folder)}) + """); + return Res( List.generate(res["comics"].length, (index) => Comic.fromJson(res["comics"][index], _key!)), - subData: res["maxPage"]); - } catch (e, s) { - Log.error("Network", "$e\n$s"); - return Res.error(e.toString()); + subData: res["next"], + ); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } } - } - return retryZone(func); + return retryZone(func); + }; } Future>> Function([String? comicId])? loadFolders; @@ -625,6 +731,7 @@ class ComicSourceParser { title: _name!, multiFolder: multiFolder, loadComic: loadComic, + loadNext: loadNext, loadFolders: loadFolders, addFolder: addFolder, deleteFolder: deleteFolder, @@ -683,11 +790,15 @@ class ComicSourceParser { if (!_checkExists("comic.onImageLoad")) { return null; } - return (imageKey, comicId, ep) { - return JsEngine().runCode(""" + return (imageKey, comicId, ep) async { + var res = JsEngine().runCode(""" ComicSource.sources.$_key.comic.onImageLoad( ${jsonEncode(imageKey)}, ${jsonEncode(comicId)}, ${jsonEncode(ep)}) - """) as Map; + """); + if (res is Future) { + return await res; + } + return res; }; } @@ -826,4 +937,21 @@ class ComicSourceParser { return LinkHandler(domains, linkToId); } + + StarRatingFunc? _parseStarRatingFunc() { + if (!_checkExists("comic.starRating")) { + return null; + } + return (id, rating) async { + try { + await JsEngine().runCode(""" + ComicSource.sources.$_key.comic.starRating(${jsonEncode(id)}, ${jsonEncode(rating)}) + """); + return const Res(true); + } catch (e, s) { + Log.error("Network", "$e\n$s"); + return Res.error(e.toString()); + } + }; + } } diff --git a/lib/foundation/image_provider/base_image_provider.dart b/lib/foundation/image_provider/base_image_provider.dart index 3257794..cdc1797 100644 --- a/lib/foundation/image_provider/base_image_provider.dart +++ b/lib/foundation/image_provider/base_image_provider.dart @@ -55,6 +55,12 @@ abstract class BaseImageProvider> _cacheSize += data.length; } } catch (e) { + if(e.toString().contains("Invalid Status Code: 404")) { + rethrow; + } + if(e.toString().contains("Invalid Status Code: 403")) { + rethrow; + } if (e.toString().contains("handshake")) { if (retryTime < 5) { retryTime = 5; diff --git a/lib/foundation/js_engine.dart b/lib/foundation/js_engine.dart index 84da898..2eefa60 100644 --- a/lib/foundation/js_engine.dart +++ b/lib/foundation/js_engine.dart @@ -21,7 +21,6 @@ import 'package:pointycastle/block/modes/ecb.dart'; import 'package:pointycastle/block/modes/ofb.dart'; import 'package:uuid/uuid.dart'; import 'package:venera/network/app_dio.dart'; -import 'package:venera/network/cloudflare.dart'; import 'package:venera/network/cookie_jar.dart'; import 'comic_source/comic_source.dart'; @@ -66,8 +65,6 @@ class JsEngine with _JSEngineApi { _dio ??= AppDio(BaseOptions( responseType: ResponseType.plain, validateStatus: (status) => true)); _cookieJar ??= SingleInstanceCookieJar.instance!; - _dio!.interceptors.add(CookieManagerSql(_cookieJar!)); - _dio!.interceptors.add(CloudflareInterceptor()); _closed = false; _engine = FlutterQjs(); _engine!.dispatch(); @@ -160,7 +157,7 @@ class JsEngine with _JSEngineApi { String key = message["key"]; String settingKey = message["setting_key"]; var source = ComicSource.find(key)!; - return source.data["setting"]?[settingKey] ?? + return source.data["settings"]?[settingKey] ?? source.settings?[settingKey]['default'] ?? (throw "Setting not found: $settingKey"); } @@ -236,8 +233,14 @@ mixin class _JSEngineApi { Object? handleHtmlCallback(Map data) { switch (data["function"]) { case "parse": - if (_documents.length > 2) { - _documents.remove(_documents.keys.first); + if (_documents.length > 8) { + var shouldDelete = _documents.keys.first; + Log.warning( + "JS Engine", + "Too many documents, deleting the oldest: $shouldDelete\n" + "Current documents: ${_documents.keys}", + ); + _documents.remove(shouldDelete); } _documents[data["key"]] = DocumentWrapper.parse(data["data"]); return null; @@ -286,6 +289,12 @@ mixin class _JSEngineApi { return _documents[data["doc"]]!.getId(data["key"]); case "getLocalName": return _documents[data["doc"]]!.getLocalName(data["key"]); + case "getElementById": + return _documents[data["key"]]!.getElementById(data["id"]); + case "getPreviousSibling": + return _documents[data["doc"]]!.getPreviousSibling(data["key"]); + case "getNextSibling": + return _documents[data["doc"]]!.getNextSibling(data["key"]); } return null; } @@ -593,4 +602,25 @@ class DocumentWrapper { String? getLocalName(int key) { return (elements[key]).localName; } + + int? getElementById(String id) { + var element = doc.getElementById(id); + if (element == null) return null; + elements.add(element); + return elements.length - 1; + } + + int? getPreviousSibling(int key) { + var res = elements[key].previousElementSibling; + if (res == null) return null; + elements.add(res); + return elements.length - 1; + } + + int? getNextSibling(int key) { + var res = elements[key].nextElementSibling; + if (res == null) return null; + elements.add(res); + return elements.length - 1; + } } diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index f5a036c..dff49bb 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -127,6 +127,9 @@ class LocalComic with HistoryMixin implements Comic { @override String? get favoriteId => null; + + @override + double? get stars => null; } class LocalManager with ChangeNotifier { diff --git a/lib/network/app_dio.dart b/lib/network/app_dio.dart index 8fba5e9..0a060a1 100644 --- a/lib/network/app_dio.dart +++ b/lib/network/app_dio.dart @@ -6,9 +6,12 @@ import 'package:dio/io.dart'; import 'package:flutter/services.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/log.dart'; +import 'package:venera/network/cache.dart'; import 'package:venera/utils/ext.dart'; import '../foundation/app.dart'; +import 'cloudflare.dart'; +import 'cookie_jar.dart'; export 'package:dio/dio.dart'; @@ -107,6 +110,9 @@ class AppDio with DioMixin { this.options = options ?? BaseOptions(); interceptors.add(MyLogInterceptor()); httpClientAdapter = IOHttpClientAdapter(createHttpClient: createHttpClient); + interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!)); + interceptors.add(NetworkCacheManager()); + interceptors.add(CloudflareInterceptor()); } static HttpClient createHttpClient() { diff --git a/lib/network/cache.dart b/lib/network/cache.dart new file mode 100644 index 0000000..9732e21 --- /dev/null +++ b/lib/network/cache.dart @@ -0,0 +1,197 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; + +class NetworkCache { + final Uri uri; + + final Map requestHeaders; + + final Map> responseHeaders; + + final Object? data; + + final DateTime time; + + final int size; + + const NetworkCache({ + required this.uri, + required this.requestHeaders, + required this.responseHeaders, + required this.data, + required this.time, + required this.size, + }); +} + +class NetworkCacheManager implements Interceptor { + NetworkCacheManager._(); + + static final NetworkCacheManager instance = NetworkCacheManager._(); + + factory NetworkCacheManager() => instance; + + final Map _cache = {}; + + int size = 0; + + NetworkCache? getCache(Uri uri) { + return _cache[uri]; + } + + static const _maxCacheSize = 10 * 1024 * 1024; + + void setCache(NetworkCache cache) { + while(size > _maxCacheSize){ + size -= _cache.values.first.size; + _cache.remove(_cache.keys.first); + } + _cache[cache.uri] = cache; + size += cache.size; + } + + void removeCache(Uri uri) { + var cache = _cache[uri]; + if(cache != null){ + size -= cache.size; + } + _cache.remove(uri); + } + + void clear() { + _cache.clear(); + size = 0; + } + + var preventParallel = {}; + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + if(err.requestOptions.method != "GET"){ + return handler.next(err); + } + if(preventParallel[err.requestOptions.uri] != null){ + preventParallel[err.requestOptions.uri]!.complete(); + preventParallel.remove(err.requestOptions.uri); + } + return handler.next(err); + } + + @override + void onRequest( + RequestOptions options, RequestInterceptorHandler handler) async { + if(options.method != "GET"){ + return handler.next(options); + } + if(preventParallel[options.uri] != null){ + await preventParallel[options.uri]!.future; + } + var cache = getCache(options.uri); + if (cache == null || !compareHeaders(options.headers, cache.requestHeaders)) { + if(options.headers['cache-time'] != null){ + options.headers.remove('cache-time'); + } + if(options.headers['prevent-parallel'] != null){ + options.headers.remove('prevent-parallel'); + preventParallel[options.uri] = Completer(); + } + return handler.next(options); + } else { + if(options.headers['cache-time'] == 'no'){ + options.headers.remove('cache-time'); + removeCache(options.uri); + return handler.next(options); + } + } + var time = DateTime.now(); + var diff = time.difference(cache.time); + if (options.headers['cache-time'] == 'long' + && diff < const Duration(hours: 2)) { + return handler.resolve(Response( + requestOptions: options, + data: cache.data, + headers: Headers.fromMap(cache.responseHeaders), + statusCode: 200, + )); + } + else if (diff < const Duration(seconds: 5)) { + return handler.resolve(Response( + requestOptions: options, + data: cache.data, + headers: Headers.fromMap(cache.responseHeaders), + statusCode: 200, + )); + } else if (diff < const Duration(hours: 1)) { + var o = options.copyWith( + method: "HEAD", + ); + var dio = Dio(); + var response = await dio.fetch(o); + if (response.statusCode == 200 && + compareHeaders(cache.responseHeaders, response.headers.map)) { + return handler.resolve(Response( + requestOptions: options, + data: cache.data, + headers: Headers.fromMap(cache.responseHeaders), + statusCode: 200, + )); + } + } + removeCache(options.uri); + handler.next(options); + } + + static bool compareHeaders(Map a, Map b) { + if (a.length != b.length) { + return false; + } + for (var key in a.keys) { + if (a[key] != b[key]) { + return false; + } + } + return true; + } + + @override + void onResponse( + Response response, ResponseInterceptorHandler handler) { + if (response.requestOptions.method != "GET") { + return handler.next(response); + } + var size = _calculateSize(response.data); + if(size != null && size < 1024 * 1024) { + var cache = NetworkCache( + uri: response.requestOptions.uri, + requestHeaders: response.requestOptions.headers, + responseHeaders: response.headers.map, + data: response.data, + time: DateTime.now(), + size: size, + ); + setCache(cache); + } + if(preventParallel[response.requestOptions.uri] != null){ + preventParallel[response.requestOptions.uri]!.complete(); + preventParallel.remove(response.requestOptions.uri); + } + handler.next(response); + } + + static int? _calculateSize(Object? data){ + if(data == null){ + return 0; + } + if(data is List) { + return data.length; + } + if(data is String) { + return data.length * 4; + } + if(data is Map) { + return data.toString().length * 4; + } + return null; + } +} diff --git a/lib/network/cookie_jar.dart b/lib/network/cookie_jar.dart index c507090..6bd7b05 100644 --- a/lib/network/cookie_jar.dart +++ b/lib/network/cookie_jar.dart @@ -202,6 +202,9 @@ class CookieManagerSql extends Interceptor { void onRequest(RequestOptions options, RequestInterceptorHandler handler) { var cookies = cookieJar.loadForRequestCookieHeader(options.uri); if (cookies.isNotEmpty) { + if(options.headers["cookie"] != null) { + cookies = "${options.headers["cookie"]}; $cookies"; + } options.headers["cookie"] = cookies; } handler.next(options); diff --git a/lib/network/images.dart b/lib/network/images.dart index 2727b3f..cfe4634 100644 --- a/lib/network/images.dart +++ b/lib/network/images.dart @@ -7,7 +7,8 @@ import 'package:venera/foundation/consts.dart'; import 'app_dio.dart'; class ImageDownloader { - static Stream loadThumbnail(String url, String? sourceKey) async* { + static Stream loadThumbnail( + String url, String? sourceKey) async* { final cacheKey = "$url@$sourceKey"; final cache = await CacheManager().findCache(cacheKey); @@ -25,12 +26,14 @@ class ImageDownloader { var comicSource = ComicSource.find(sourceKey); configs = comicSource!.getThumbnailLoadingConfig?.call(url) ?? {}; } - configs['headers'] ??= { - 'user-agent': webUA, - }; + configs['headers'] ??= {}; + if(configs['headers']['user-agent'] == null + && configs['headers']['User-Agent'] == null) { + configs['headers']['user-agent'] = webUA; + } var dio = AppDio(BaseOptions( - headers: configs['headers'], + headers: Map.from(configs['headers']), method: configs['method'] ?? 'GET', responseType: ResponseType.stream, )); @@ -53,7 +56,7 @@ class ImageDownloader { } } - if(configs['onResponse'] != null) { + if (configs['onResponse'] != null) { buffer = configs['onResponse'](buffer); } @@ -65,7 +68,8 @@ class ImageDownloader { ); } - static Stream loadComicImage(String imageKey, String? sourceKey, String cid, String eid) async* { + static Stream loadComicImage( + String imageKey, String? sourceKey, String cid, String eid) async* { final cacheKey = "$imageKey@$sourceKey@$cid@$eid"; final cache = await CacheManager().findCache(cacheKey); @@ -81,7 +85,8 @@ class ImageDownloader { var configs = {}; if (sourceKey != null) { var comicSource = ComicSource.find(sourceKey); - configs = comicSource!.getImageLoadingConfig?.call(imageKey, cid, eid) ?? {}; + configs = (await comicSource!.getImageLoadingConfig + ?.call(imageKey, cid, eid)) ?? {}; } configs['headers'] ??= { 'user-agent': webUA, @@ -111,7 +116,7 @@ class ImageDownloader { } } - if(configs['onResponse'] != null) { + if (configs['onResponse'] != null) { buffer = configs['onResponse'](buffer); } @@ -136,4 +141,4 @@ class ImageDownloadProgress { required this.totalBytes, this.imageBytes, }); -} \ No newline at end of file +} diff --git a/lib/pages/accounts_page.dart b/lib/pages/accounts_page.dart index 73c241e..9651219 100644 --- a/lib/pages/accounts_page.dart +++ b/lib/pages/accounts_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; @@ -158,6 +159,8 @@ class _LoginPageState extends State<_LoginPage> { String password = ""; bool loading = false; + final Map _cookies = {}; + @override Widget build(BuildContext context) { return Scaffold( @@ -173,31 +176,44 @@ class _LoginPageState extends State<_LoginPage> { children: [ Text("Login".tl, style: const TextStyle(fontSize: 24)), const SizedBox(height: 32), - TextField( - decoration: InputDecoration( - labelText: "Username".tl, - border: const OutlineInputBorder(), - ), - enabled: widget.config.login != null, - onChanged: (s) { - username = s; - }, - ), - const SizedBox(height: 16), - TextField( - decoration: InputDecoration( - labelText: "Password".tl, - border: const OutlineInputBorder(), - ), - obscureText: true, - enabled: widget.config.login != null, - onChanged: (s) { - password = s; - }, - onSubmitted: (s) => login(), - ), - const SizedBox(height: 32), - if (widget.config.login == null) + if (widget.config.cookieFields == null) + TextField( + decoration: InputDecoration( + labelText: "Username".tl, + border: const OutlineInputBorder(), + ), + enabled: widget.config.login != null, + onChanged: (s) { + username = s; + }, + ).paddingBottom(16), + if (widget.config.cookieFields == null) + TextField( + decoration: InputDecoration( + labelText: "Password".tl, + border: const OutlineInputBorder(), + ), + obscureText: true, + enabled: widget.config.login != null, + onChanged: (s) { + password = s; + }, + onSubmitted: (s) => login(), + ).paddingBottom(16), + for (var field in widget.config.cookieFields ?? []) + TextField( + decoration: InputDecoration( + labelText: field, + border: const OutlineInputBorder(), + ), + obscureText: true, + enabled: widget.config.validateCookies != null, + onChanged: (s) { + _cookies[field] = s; + }, + ).paddingBottom(16), + if (widget.config.login == null && + widget.config.cookieFields == null) Row( mainAxisSize: MainAxisSize.min, children: [ @@ -214,7 +230,7 @@ class _LoginPageState extends State<_LoginPage> { ), const SizedBox(height: 24), if (widget.config.loginWebsite != null) - FilledButton( + TextButton( onPressed: loginWithWebview, child: Text("Login with webview".tl), ), @@ -240,71 +256,81 @@ class _LoginPageState extends State<_LoginPage> { } void login() { - if (username.isEmpty || password.isEmpty) { - showToast( - message: "Cannot be empty".tl, - icon: const Icon(Icons.error_outline), - context: context, - ); - return; - } - setState(() { - loading = true; - }); - widget.config.login!(username, password).then((value) { - if (value.error) { - context.showMessage(message: value.errorMessage!); - setState(() { - loading = false; - }); - } else { - if (mounted) { - context.pop(); - } + if (widget.config.login != null) { + if (username.isEmpty || password.isEmpty) { + showToast( + message: "Cannot be empty".tl, + icon: const Icon(Icons.error_outline), + context: context, + ); + return; } - }); + setState(() { + loading = true; + }); + widget.config.login!(username, password).then((value) { + if (value.error) { + context.showMessage(message: value.errorMessage!); + setState(() { + loading = false; + }); + } else { + if (mounted) { + context.pop(); + } + } + }); + } else if (widget.config.validateCookies != null) { + setState(() { + loading = true; + }); + var cookies = + widget.config.cookieFields!.map((e) => _cookies[e] ?? '').toList(); + widget.config.validateCookies!(cookies).then((value) { + if (value) { + widget.source.data['account'] = 'ok'; + widget.source.saveData(); + context.pop(); + } else { + context.showMessage(message: "Invalid cookies".tl); + setState(() { + loading = false; + }); + } + }); + } } void loginWithWebview() async { var url = widget.config.loginWebsite!; var title = ''; bool success = false; + + void validate(InAppWebViewController c) async { + if (widget.config.checkLoginStatus != null + && widget.config.checkLoginStatus!(url, title)) { + var cookies = (await c.getCookies(url)) ?? []; + SingleInstanceCookieJar.instance?.saveFromResponse( + Uri.parse(url), + cookies, + ); + success = true; + widget.config.onLoginWithWebviewSuccess?.call(); + App.mainNavigatorKey?.currentContext?.pop(); + } + } + await context.to( () => AppWebview( initialUrl: widget.config.loginWebsite!, onNavigation: (u, c) { url = u; - print(url); - () async { - if (widget.config.checkLoginStatus != null) { - if (widget.config.checkLoginStatus!(url, title)) { - var cookies = (await c.getCookies(url)) ?? []; - SingleInstanceCookieJar.instance?.saveFromResponse( - Uri.parse(url), - cookies, - ); - success = true; - App.mainNavigatorKey?.currentContext?.pop(); - } - } - }(); + validate(c); return false; }, onTitleChange: (t, c) { - () async { - if (widget.config.checkLoginStatus != null) { - if (widget.config.checkLoginStatus!(url, title)) { - var cookies = (await c.getCookies(url)) ?? []; - SingleInstanceCookieJar.instance?.saveFromResponse( - Uri.parse(url), - cookies, - ); - success = true; - App.mainNavigatorKey?.currentContext?.pop(); - } - } - }(); title = t; + validate(c); }, ), ); diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index 06186e0..9383934 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -352,6 +352,18 @@ class _ComicPageState extends LoadingState ListTile( title: Text("Information".tl), ), + if (comic.stars != null) + Row( + children: [ + StarRating( + value: comic.stars!, + size: 24, + onTap: starRating, + ), + const SizedBox(width: 8), + Text(comic.stars!.toStringAsFixed(2)), + ], + ).paddingLeft(16).paddingVertical(8), for (var e in comic.tags.entries) buildWrap( children: [ @@ -641,6 +653,72 @@ abstract mixin class _ComicPageActions { ), ); } + + void starRating() { + if (!comicSource.isLogged) { + return; + } + var rating = 0.0; + var isLoading = false; + showDialog( + context: App.rootContext, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => SimpleDialog( + title: const Text("Rating"), + alignment: Alignment.center, + children: [ + SizedBox( + height: 100, + child: Center( + child: SizedBox( + width: 210, + child: Column( + children: [ + const SizedBox( + height: 10, + ), + RatingWidget( + padding: 2, + onRatingUpdate: (value) => rating = value, + value: 1, + selectable: true, + size: 40, + ), + const Spacer(), + Button.filled( + isLoading: isLoading, + onPressed: () { + setState(() { + isLoading = true; + }); + comicSource.starRatingFunc! + (comic.id, rating.round()) + .then((value) { + if (value.success) { + App.rootContext + .showMessage(message: "Success".tl); + Navigator.of(dialogContext).pop(); + } else { + App.rootContext + .showMessage(message: value.errorMessage!); + setState(() { + isLoading = false; + }); + } + }); + }, + child: Text("Submit".tl), + ) + ], + ), + ), + ), + ) + ], + ), + ), + ); + } } class _ActionButton extends StatelessWidget { @@ -880,62 +958,88 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> { ), ), SliverGrid( - delegate: SliverChildBuilderDelegate(childCount: thumbnails.length, - (context, index) { - if (index == thumbnails.length - 1 && error == null) { - loadNext(); - } - return Padding( - padding: context.width < changePoint - ? const EdgeInsets.all(4) - : const EdgeInsets.all(8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: InkWell( - onTap: () => state.read(null, index + 1), - borderRadius: const BorderRadius.all(Radius.circular(16)), - child: Container( - decoration: BoxDecoration( - borderRadius: - const BorderRadius.all(Radius.circular(16)), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - ), - ), - width: double.infinity, - height: double.infinity, - child: ClipRRect( - borderRadius: - const BorderRadius.all(Radius.circular(16)), - child: AnimatedImage( - image: CachedImageProvider( - thumbnails[index], - sourceKey: state.widget.sourceKey, + delegate: SliverChildBuilderDelegate( + childCount: thumbnails.length, + (context, index) { + if (index == thumbnails.length - 1 && error == null) { + loadNext(); + } + var url = thumbnails[index]; + ImagePart? part; + if (url.contains('@')) { + var params = url.split('@')[1].split('&'); + url = url.split('@')[0]; + double? x1, y1, x2, y2; + try { + for (var p in params) { + if (p.startsWith('x')) { + var r = p.split('=')[1]; + x1 = double.parse(r.split('-')[0]); + x2 = double.parse(r.split('-')[1]); + } + if (p.startsWith('y')) { + var r = p.split('=')[1]; + y1 = double.parse(r.split('-')[0]); + y2 = double.parse(r.split('-')[1]); + } + } + } finally {} + part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2); + } + return Padding( + padding: context.width < changePoint + ? const EdgeInsets.all(4) + : const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: InkWell( + onTap: () => state.read(null, index + 1), + borderRadius: + const BorderRadius.all(Radius.circular(16)), + child: Container( + decoration: BoxDecoration( + borderRadius: + const BorderRadius.all(Radius.circular(16)), + border: Border.all( + color: Theme.of(context).colorScheme.outline, + ), + ), + width: double.infinity, + height: double.infinity, + child: ClipRRect( + borderRadius: + const BorderRadius.all(Radius.circular(16)), + child: AnimatedImage( + image: CachedImageProvider( + url, + sourceKey: state.widget.sourceKey, + ), + fit: BoxFit.contain, + width: double.infinity, + height: double.infinity, + part: part, ), - fit: BoxFit.contain, - width: double.infinity, - height: double.infinity, ), ), ), ), - ), - const SizedBox( - height: 4, - ), - Text((index + 1).toString()), - ], - ), - ); - }), + const SizedBox( + height: 4, + ), + Text((index + 1).toString()), + ], + ), + ); + }, + ), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 200, childAspectRatio: 0.65, ), ), - if(error != null) + if (error != null) SliverToBoxAdapter( child: Column( children: [ @@ -1288,7 +1392,8 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> { setState(() { isLoading = true; }); - var res = await widget.comicSource.favoriteData!.addOrDelFavorite!( + var res = + await widget.comicSource.favoriteData!.addOrDelFavorite!( widget.cid, selected!, !addedFolders.contains(selected!), diff --git a/lib/pages/comments_page.dart b/lib/pages/comments_page.dart index 9436868..cdbd268 100644 --- a/lib/pages/comments_page.dart +++ b/lib/pages/comments_page.dart @@ -267,7 +267,7 @@ class _CommentTileState extends State<_CommentTile> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(widget.comment.userName), + Text(widget.comment.userName, style: ts.bold,), if (widget.comment.time != null) Text(widget.comment.time!, style: ts.s12), const SizedBox(height: 4), @@ -403,38 +403,45 @@ class _CommentTileState extends State<_CommentTile> { int? voteStatus; - bool isVoteUp = false; + bool isVotingUp = false; - bool isVoteDown = false; + bool isVotingDown = false; void vote(bool isUp) async { - if (isVoteUp || isVoteDown) return; + if (isVotingUp || isVotingDown) return; setState(() { if (isUp) { - isVoteUp = true; + isVotingUp = true; } else { - isVoteDown = true; + isVotingDown = true; } }); + var isCancel = (isUp && voteStatus == 1) || (!isUp && voteStatus == -1); var res = await widget.source.voteCommentFunc!( widget.comic.comicId, widget.comic.subId, widget.comment.id!, isUp, - (isUp && voteStatus == 1) || (!isUp && voteStatus == -1), + isCancel, ); if (res.success) { - if (isUp) { - voteStatus = 1; + if(isCancel) { + voteStatus = 0; } else { - voteStatus = -1; + if (isUp) { + voteStatus = 1; + } else { + voteStatus = -1; + } } + widget.comment.voteStatus = voteStatus; + widget.comment.score = res.data ?? widget.comment.score; } else { context.showMessage(message: res.errorMessage ?? "Error"); } setState(() { - isVoteUp = false; - isVoteDown = false; + isVotingUp = false; + isVotingDown = false; }); } @@ -461,7 +468,7 @@ class _CommentTileState extends State<_CommentTile> { mainAxisSize: MainAxisSize.min, children: [ Button.icon( - isLoading: isVoteUp, + isLoading: isVotingUp, icon: const Icon(Icons.arrow_upward), size: 18, color: upColor, @@ -471,7 +478,7 @@ class _CommentTileState extends State<_CommentTile> { Text(widget.comment.score.toString()), const SizedBox(width: 4), Button.icon( - isLoading: isVoteDown, + isLoading: isVotingDown, icon: const Icon(Icons.arrow_downward), size: 18, color: downColor, diff --git a/lib/pages/explore_page.dart b/lib/pages/explore_page.dart index ae1c18b..e30eb32 100644 --- a/lib/pages/explore_page.dart +++ b/lib/pages/explore_page.dart @@ -179,7 +179,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage> { Widget build(BuildContext context) { if (data.loadMultiPart != null) { return buildMultiPart(); - } else if (data.loadPage != null) { + } else if (data.loadPage != null || data.loadNext != null) { return buildComicList(); } else if (data.loadMixed != null) { return _MixedExplorePage( @@ -196,7 +196,8 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage> { Widget buildComicList() { return ComicList( - loadPage: data.loadPage!, + loadPage: data.loadPage, + loadNext: data.loadNext, key: ValueKey(key), ); } diff --git a/lib/pages/favorites/network_favorites_page.dart b/lib/pages/favorites/network_favorites_page.dart index 5f21ad7..28d5b20 100644 --- a/lib/pages/favorites/network_favorites_page.dart +++ b/lib/pages/favorites/network_favorites_page.dart @@ -112,7 +112,8 @@ class _NormalFavoritePage extends StatelessWidget { ), title: Text(data.title), ), - loadPage: (i) => data.loadComic(i), + loadPage: data.loadComic == null ? null : (i) => data.loadComic!(i), + loadNext: data.loadNext == null ? null : (next) => data.loadNext!(next), menuBuilder: (comic) { return [ MenuEntry( @@ -393,7 +394,8 @@ class _FolderTile extends StatelessWidget { return StatefulBuilder(builder: (context, setState) { return ContentDialog( title: "Delete".tl, - content: Text("Are you sure you want to delete this folder?".tl).paddingHorizontal(16), + content: Text("Are you sure you want to delete this folder?".tl) + .paddingHorizontal(16), actions: [ Button.filled( isLoading: loading, @@ -516,7 +518,11 @@ class _FavoriteFolder extends StatelessWidget { errorLeading: Appbar( title: Text(title), ), - loadPage: (i) => data.loadComic(i, folderID), + loadPage: + data.loadComic == null ? null : (i) => data.loadComic!(i, folderID), + loadNext: data.loadNext == null + ? null + : (next) => data.loadNext!(next, folderID), menuBuilder: (comic) { return [ MenuEntry( diff --git a/lib/pages/ranking_page.dart b/lib/pages/ranking_page.dart index f5fda16..96d2fc3 100644 --- a/lib/pages/ranking_page.dart +++ b/lib/pages/ranking_page.dart @@ -46,7 +46,12 @@ class _RankingPageState extends State { children: [ Expanded( child: ComicList( - loadPage: (i) => data.rankingData!.load(optionValue, i), + loadPage: data.rankingData!.load == null + ? null + : (i) => data.rankingData!.load!(optionValue, i), + loadNext: data.rankingData!.loadWithNext == null + ? null + : (i) => data.rankingData!.loadWithNext!(optionValue, i), ), ), ], diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index 9d05738..27faca3 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; @@ -24,6 +26,8 @@ class _SearchPageState extends State { String searchTarget = ""; + var focusNode = FocusNode(); + var options = []; void update() { @@ -137,6 +141,12 @@ class _SearchPageState extends State { super.initState(); } + @override + void dispose() { + focusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -152,6 +162,7 @@ class _SearchPageState extends State { onChanged: (s) { findSuggestions(); }, + focusNode: focusNode, ); if (suggestions.isNotEmpty) { yield buildSuggestions(context); @@ -186,6 +197,7 @@ class _SearchPageState extends State { onTap: () { setState(() { searchTarget = e.key; + useDefaultOptions(); }); }, ); @@ -197,6 +209,13 @@ class _SearchPageState extends State { ); } + void useDefaultOptions() { + final searchOptions = + ComicSource.find(searchTarget)!.searchPageData!.searchOptions ?? + []; + options = searchOptions.map((e) => e.defaultValue).toList(); + } + Widget buildSearchOptions() { var children = []; @@ -204,30 +223,21 @@ class _SearchPageState extends State { ComicSource.find(searchTarget)!.searchPageData!.searchOptions ?? []; if (searchOptions.length != options.length) { - options = searchOptions.map((e) => e.defaultValue).toList(); + useDefaultOptions(); } if (searchOptions.isEmpty) { return const SliverToBoxAdapter(child: SizedBox()); } for (int i = 0; i < searchOptions.length; i++) { final option = searchOptions[i]; - children.add(ListTile( - contentPadding: EdgeInsets.zero, - title: Text(option.label.tl), - )); - children.add(Wrap( - runSpacing: 8, - spacing: 8, - children: option.options.entries.map((e) { - return OptionChip( - text: e.value.ts(searchTarget), - isSelected: options[i] == e.key, - onTap: () { - options[i] = e.key; - update(); - }, - ); - }).toList(), + children.add(SearchOptionWidget( + option: option, + value: options[i], + onChanged: (value) { + options[i] = value; + update(); + }, + sourceKey: searchTarget, )); } @@ -336,7 +346,9 @@ class _SearchPageState extends State { } else { controller.text += "$text "; } + suggestions.clear(); update(); + focusNode.requestFocus(); } bool showMethod = MediaQuery.of(context).size.width < 600; @@ -444,3 +456,77 @@ class _SearchPageState extends State { ); } } + +class SearchOptionWidget extends StatelessWidget { + const SearchOptionWidget({ + super.key, + required this.option, + required this.value, + required this.onChanged, + required this.sourceKey, + }); + + final SearchOptions option; + + final String value; + + final void Function(String) onChanged; + + final String sourceKey; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(option.label.ts(sourceKey)), + ), + if(option.type == 'select') + Wrap( + runSpacing: 8, + spacing: 8, + children: option.options.entries.map((e) { + return OptionChip( + text: e.value.ts(sourceKey), + isSelected: value == e.key, + onTap: () { + onChanged(e.key); + }, + ); + }).toList(), + ), + if(option.type == 'multi-select') + Wrap( + runSpacing: 8, + spacing: 8, + children: option.options.entries.map((e) { + return OptionChip( + text: e.value.ts(sourceKey), + isSelected: (jsonDecode(value) as List).contains(e.key), + onTap: () { + var list = jsonDecode(value) as List; + if(list.contains(e.key)) { + list.remove(e.key); + } else { + list.add(e.key); + } + onChanged(jsonEncode(list)); + }, + ); + }).toList(), + ), + if(option.type == 'dropdown') + Select( + current: option.options[value], + values: option.options.values.toList(), + onTap: (index) { + onChanged(option.options.keys.elementAt(index)); + }, + minWidth: 96, + ) + ], + ); + } +} diff --git a/lib/pages/search_result_page.dart b/lib/pages/search_result_page.dart index e3b4418..178b57d 100644 --- a/lib/pages/search_result_page.dart +++ b/lib/pages/search_result_page.dart @@ -4,6 +4,7 @@ import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/state_controller.dart'; +import 'package:venera/pages/search_page.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; @@ -87,12 +88,27 @@ class _SearchResultPageState extends State { ); sourceKey = widget.sourceKey; options = widget.options; + validateOptions(); text = widget.text; appdata.addSearchHistory(text); suggestionsController = _SuggestionsController(controller); super.initState(); } + void validateOptions() { + var source = ComicSource.find(sourceKey); + if (source == null) { + return; + } + var searchOptions = source.searchPageData!.searchOptions; + if (searchOptions == null) { + return; + } + if (options.length != searchOptions.length) { + options = searchOptions.map((e) => e.defaultValue).toList(); + } + } + @override Widget build(BuildContext context) { return ComicList( @@ -422,25 +438,15 @@ class _SearchSettingsDialogState extends State<_SearchSettingsDialog> { } for (int i = 0; i < searchOptions.length; i++) { final option = searchOptions[i]; - children.add(ListTile( - contentPadding: EdgeInsets.zero, - title: Text(option.label.tl), - )); - children.add(Wrap( - runSpacing: 8, - spacing: 8, - children: option.options.entries.map((e) { - return OptionChip( - text: e.value.ts(searchTarget), - isSelected: options[i] == e.key, - onTap: () { - setState(() { - options[i] = e.key; - }); - onChanged(); - }, - ); - }).toList(), + children.add(SearchOptionWidget( + option: option, + value: options[i], + onChanged: (value) { + setState(() { + options[i] = value; + }); + }, + sourceKey: searchTarget, )); }