add star rating, network cache, advanced search option, loginWithCookies, loadNext; fix some minor issues

This commit is contained in:
nyne
2024-10-25 22:51:23 +08:00
parent b682d7d87b
commit 897f92f4c9
27 changed files with 1420 additions and 319 deletions

View File

@@ -308,14 +308,17 @@ function setInterval(callback, delay) {
return timer; return timer;
} }
function Cookie(name, value, domain = null) { /**
let obj = {}; * Create a cookie object.
obj.name = name; * @param name {string}
obj.value = value; * @param value {string}
if (domain) { * @param domain {string}
obj.domain = domain; * @constructor
} */
return obj; 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. * Query a single element from the HTML document.
* @param {string} query - The query string. * @param {string} query - The query string.
* @returns {HtmlElement} The first matching element. * @returns {HtmlElement | null} The first matching element.
*/ */
querySelector(query) { querySelector(query) {
let k = sendMessage({ let k = sendMessage({
@@ -530,6 +533,22 @@ class HtmlDocument {
key: this.key 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, 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 { class HtmlNode {
@@ -789,9 +838,10 @@ let console = {
* @param maxPage {number?} * @param maxPage {number?}
* @param language {string?} * @param language {string?}
* @param favoriteId {string?} - Only set this field if the comic is from favorites page * @param favoriteId {string?} - Only set this field if the comic is from favorites page
* @param stars {number?} - 0-5, double
* @constructor * @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.id = id;
this.title = title; this.title = title;
this.subtitle = subtitle; this.subtitle = subtitle;
@@ -801,6 +851,7 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage, language
this.maxPage = maxPage; this.maxPage = maxPage;
this.language = language; this.language = language;
this.favoriteId = favoriteId; this.favoriteId = favoriteId;
this.stars = stars;
} }
/** /**
@@ -821,9 +872,11 @@ function Comic({id, title, subtitle, cover, tags, description, maxPage, language
* @param updateTime {string?} * @param updateTime {string?}
* @param uploadTime {string?} * @param uploadTime {string?}
* @param url {string?} * @param url {string?}
* @param stars {number?} - 0-5, double
* @param maxPage {number?}
* @constructor * @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.title = title;
this.cover = cover; this.cover = cover;
this.description = description; this.description = description;
@@ -840,6 +893,8 @@ function ComicDetails({title, cover, description, tags, chapters, isFavorite, su
this.updateTime = updateTime; this.updateTime = updateTime;
this.uploadTime = uploadTime; this.uploadTime = uploadTime;
this.url = url; this.url = url;
this.stars = stars;
this.maxPage = maxPage;
} }
/** /**

View File

@@ -567,6 +567,7 @@ class SliverSearchBar extends StatefulWidget {
required this.controller, required this.controller,
this.onChanged, this.onChanged,
this.action, this.action,
this.focusNode,
}); });
final SearchBarController controller; final SearchBarController controller;
@@ -575,6 +576,8 @@ class SliverSearchBar extends StatefulWidget {
final Widget? action; final Widget? action;
final FocusNode? focusNode;
@override @override
State<SliverSearchBar> createState() => _SliverSearchBarState(); State<SliverSearchBar> createState() => _SliverSearchBarState();
} }
@@ -613,6 +616,7 @@ class _SliverSearchBarState extends State<SliverSearchBar>
topPadding: MediaQuery.of(context).padding.top, topPadding: MediaQuery.of(context).padding.top,
onChanged: widget.onChanged, onChanged: widget.onChanged,
action: widget.action, action: widget.action,
focusNode: widget.focusNode,
), ),
); );
} }
@@ -629,12 +633,15 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate {
final Widget? action; final Widget? action;
final FocusNode? focusNode;
const _SliverSearchBarDelegate({ const _SliverSearchBarDelegate({
required this.editingController, required this.editingController,
required this.controller, required this.controller,
required this.topPadding, required this.topPadding,
this.onChanged, this.onChanged,
this.action, this.action,
this.focusNode,
}); });
static const _kAppBarHeight = 52.0; static const _kAppBarHeight = 52.0;
@@ -662,6 +669,7 @@ class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: TextField( child: TextField(
focusNode: focusNode,
controller: editingController, controller: editingController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Search".tl, hintText: "Search".tl,

View File

@@ -195,6 +195,7 @@ class ComicTile extends StatelessWidget {
enableTranslate: ComicSource.find(comic.sourceKey) enableTranslate: ComicSource.find(comic.sourceKey)
?.enableTagsTranslate ?? ?.enableTagsTranslate ??
false, false,
rating: comic.stars,
), ),
), ),
], ],
@@ -285,6 +286,7 @@ class _ComicDescription extends StatelessWidget {
this.badge, this.badge,
this.maxLines = 2, this.maxLines = 2,
this.tags, this.tags,
this.rating,
}); });
final String title; final String title;
@@ -294,6 +296,7 @@ class _ComicDescription extends StatelessWidget {
final List<String>? tags; final List<String>? tags;
final int maxLines; final int maxLines;
final bool enableTranslate; final bool enableTranslate;
final double? rating;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -358,6 +361,7 @@ class _ComicDescription extends StatelessWidget {
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
const Spacer(), const Spacer(),
if (rating != null) StarRating(value: rating!, size: 18),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
@@ -623,9 +627,9 @@ class ComicListState extends State<ComicList> {
String? _nextUrl; String? _nextUrl;
void remove(Comic c) { void remove(Comic c) {
if(_data[_page] == null || !_data[_page]!.remove(c)) { if (_data[_page] == null || !_data[_page]!.remove(c)) {
for(var page in _data.values) { for (var page in _data.values) {
if(page.remove(c)) { if (page.remove(c)) {
break; break;
} }
} }
@@ -685,7 +689,7 @@ class ComicListState extends State<ComicList> {
(_maxPage == null || page <= _maxPage!)) { (_maxPage == null || page <= _maxPage!)) {
setState(() { setState(() {
_error = null; _error = null;
this._page = page; _page = page;
}); });
} else { } else {
context.showMessage( context.showMessage(
@@ -777,10 +781,10 @@ class ComicListState extends State<ComicList> {
Future<void> _fetchNext() async { Future<void> _fetchNext() async {
var res = await widget.loadNext!(_nextUrl); var res = await widget.loadNext!(_nextUrl);
_data[_data.length + 1] = res.data; _data[_data.length + 1] = res.data;
if (res.subData['next'] == null) { if (res.subData == null) {
_maxPage = _data.length; _maxPage = _data.length;
} else { } else {
_nextUrl = res.subData['next']; _nextUrl = res.subData;
} }
} }
@@ -828,3 +832,286 @@ class ComicListState extends State<ComicList> {
); );
} }
} }
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<Rect> {
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<Rect> 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<double> 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<RatingWidget> createState() => _RatingWidgetState();
}
class _RatingWidgetState extends State<RatingWidget> {
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<Widget> buildRow() {
int full = fullStars();
List<Widget> 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<Widget> buildNormalRow() {
List<Widget> 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: <Widget>[
Row(
children: buildNormalRow(),
),
Row(
children: buildRow(),
)
],
);
}
@override
void initState() {
super.initState();
value = widget.value;
}
}
class SMClipper extends CustomClipper<Rect> {
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;
}
}

View File

@@ -3,7 +3,7 @@ library components;
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:ui'; import 'dart:ui' as ui;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View File

@@ -19,7 +19,7 @@ class BlurEffect extends StatelessWidget {
return ClipRRect( return ClipRRect(
borderRadius: borderRadius ?? BorderRadius.zero, borderRadius: borderRadius ?? BorderRadius.zero,
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur( filter: ui.ImageFilter.blur(
sigmaX: blur, sigmaX: blur,
sigmaY: blur, sigmaY: blur,
tileMode: TileMode.mirror, tileMode: TileMode.mirror,

View File

@@ -21,11 +21,11 @@ class AnimatedImage extends StatefulWidget {
this.gaplessPlayback = false, this.gaplessPlayback = false,
this.filterQuality = FilterQuality.medium, this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false, this.isAntiAlias = false,
this.part,
Map<String, String>? headers, Map<String, String>? headers,
int? cacheWidth, int? cacheWidth,
int? cacheHeight, int? cacheHeight,
} }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0), assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0); assert(cacheHeight == null || cacheHeight > 0);
@@ -61,13 +61,16 @@ class AnimatedImage extends StatefulWidget {
final bool isAntiAlias; final bool isAntiAlias;
final ImagePart? part;
static void clear() => _AnimatedImageState.clear(); static void clear() => _AnimatedImageState.clear();
@override @override
State<AnimatedImage> createState() => _AnimatedImageState(); State<AnimatedImage> createState() => _AnimatedImageState();
} }
class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserver { class _AnimatedImageState extends State<AnimatedImage>
with WidgetsBindingObserver {
ImageStream? _imageStream; ImageStream? _imageStream;
ImageInfo? _imageInfo; ImageInfo? _imageInfo;
ImageChunkEvent? _loadingProgress; ImageChunkEvent? _loadingProgress;
@@ -138,8 +141,8 @@ class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserv
} }
void _updateInvertColors() { void _updateInvertColors() {
_invertColors = MediaQuery.maybeInvertColorsOf(context) _invertColors = MediaQuery.maybeInvertColorsOf(context) ??
?? SemanticsBinding.instance.accessibilityFeatures.invertColors; SemanticsBinding.instance.accessibilityFeatures.invertColors;
} }
void _resolveImage() { void _resolveImage() {
@@ -150,14 +153,17 @@ class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserv
final ImageStream newStream = final ImageStream newStream =
provider.resolve(createLocalImageConfiguration( provider.resolve(createLocalImageConfiguration(
context, 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); _updateSourceStream(newStream);
} }
ImageStreamListener? _imageStreamListener; ImageStreamListener? _imageStreamListener;
ImageStreamListener _getListener({bool recreateListener = false}) { ImageStreamListener _getListener({bool recreateListener = false}) {
if(_imageStreamListener == null || recreateListener) { if (_imageStreamListener == null || recreateListener) {
_lastException = null; _lastException = null;
_imageStreamListener = ImageStreamListener( _imageStreamListener = ImageStreamListener(
_handleImageFrame, _handleImageFrame,
@@ -191,7 +197,8 @@ class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserv
void _replaceImage({required ImageInfo? info}) { void _replaceImage({required ImageInfo? info}) {
final ImageInfo? oldImageInfo = _imageInfo; final ImageInfo? oldImageInfo = _imageInfo;
SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose()); SchedulerBinding.instance
.addPostFrameCallback((_) => oldImageInfo?.dispose());
_imageInfo = info; _imageInfo = info;
} }
@@ -208,7 +215,9 @@ class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserv
} }
if (!widget.gaplessPlayback) { if (!widget.gaplessPlayback) {
setState(() { _replaceImage(info: null); }); setState(() {
_replaceImage(info: null);
});
} }
setState(() { setState(() {
@@ -247,7 +256,9 @@ class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserv
return; return;
} }
if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) { if (keepStreamAlive &&
_completerHandle == null &&
_imageStream?.completer != null) {
_completerHandle = _imageStream!.completer!.keepAlive(); _completerHandle = _imageStream!.completer!.keepAlive();
} }
@@ -259,7 +270,19 @@ class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserv
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget result; 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( result = RawImage(
image: _imageInfo?.image, image: _imageInfo?.image,
width: widget.width, width: widget.width,
@@ -291,7 +314,7 @@ class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserv
child: result, child: result,
); );
} }
} else{ } else {
result = const Center(); result = const Center();
} }
@@ -307,8 +330,59 @@ class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserv
super.debugFillProperties(description); super.debugFillProperties(description);
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream)); description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo)); description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress)); description.add(DiagnosticsProperty<ImageChunkEvent>(
'loadingProgress', _loadingProgress));
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber)); description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded)); description.add(DiagnosticsProperty<bool>(
'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;
} }
} }

View File

@@ -62,6 +62,7 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
return Listener( return Listener(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onPointerDown: (event) { onPointerDown: (event) {
_futurePosition = null;
if (_isMouseScroll) { if (_isMouseScroll) {
setState(() { setState(() {
_isMouseScroll = false; _isMouseScroll = false;

View File

@@ -2,8 +2,10 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/foundation/app.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:venera/foundation/state_controller.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
@@ -97,8 +99,12 @@ class WindowFrame extends StatelessWidget {
).toAlign(Alignment.centerLeft).paddingLeft(4), ).toAlign(Alignment.centerLeft).paddingLeft(4),
), ),
), ),
if (!App.isMacOS) if (kDebugMode)
const WindowButtons() const TextButton(
onPressed: debug,
child: Text('Debug'),
),
if (!App.isMacOS) const WindowButtons()
], ],
), ),
); );
@@ -622,3 +628,7 @@ TransitionBuilder VirtualWindowFrameInit() {
); );
}; };
} }
void debug() {
ComicSource.reload();
}

View File

@@ -27,6 +27,10 @@ part 'models.dart';
/// build comic list, [Res.subData] should be maxPage or null if there is no limit. /// build comic list, [Res.subData] should be maxPage or null if there is no limit.
typedef ComicListBuilder = Future<Res<List<Comic>>> Function(int page); typedef ComicListBuilder = Future<Res<List<Comic>>> 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<Res<List<Comic>>> Function(
String? next);
typedef LoginFunction = Future<Res<bool>> Function(String, String); typedef LoginFunction = Future<Res<bool>> Function(String, String);
typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id); typedef LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
@@ -40,7 +44,7 @@ typedef CommentsLoader = Future<Res<List<Comment>>> Function(
typedef SendCommentFunc = Future<Res<bool>> Function( typedef SendCommentFunc = Future<Res<bool>> Function(
String id, String? subId, String content, String? replyTo); String id, String? subId, String content, String? replyTo);
typedef GetImageLoadingConfigFunc = Map<String, dynamic> Function( typedef GetImageLoadingConfigFunc = Future<Map<String, dynamic>> Function(
String imageKey, String comicId, String epId)?; String imageKey, String comicId, String epId)?;
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function( typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?; String imageKey)?;
@@ -64,6 +68,9 @@ typedef VoteCommentFunc = Future<Res<int?>> Function(
typedef HandleClickTagEvent = Map<String, String> Function( typedef HandleClickTagEvent = Map<String, String> Function(
String namespace, String tag); String namespace, String tag);
/// [rating] is the rating value, 0-10. 1 represents 0.5 star.
typedef StarRatingFunc = Future<Res<bool>> Function(String comicId, int rating);
class ComicSource { class ComicSource {
static final List<ComicSource> _sources = []; static final List<ComicSource> _sources = [];
@@ -163,8 +170,7 @@ class ComicSource {
/// Load comic pages. /// Load comic pages.
final LoadComicPagesFunc? loadComicPages; final LoadComicPagesFunc? loadComicPages;
final Map<String, dynamic> Function( final GetImageLoadingConfigFunc? getImageLoadingConfig;
String imageKey, String comicId, String epId)? getImageLoadingConfig;
final Map<String, dynamic> Function(String imageKey)? final Map<String, dynamic> Function(String imageKey)?
getThumbnailLoadingConfig; getThumbnailLoadingConfig;
@@ -203,6 +209,8 @@ class ComicSource {
final bool enableTagsTranslate; final bool enableTagsTranslate;
final StarRatingFunc? starRatingFunc;
Future<void> loadData() async { Future<void> loadData() async {
var file = File("${App.dataPath}/comic_source/$key.data"); var file = File("${App.dataPath}/comic_source/$key.data");
if (await file.exists()) { if (await file.exists()) {
@@ -270,6 +278,7 @@ class ComicSource {
this.linkHandler, this.linkHandler,
this.enableTagsSuggestions, this.enableTagsSuggestions,
this.enableTagsTranslate, this.enableTagsTranslate,
this.starRatingFunc,
); );
} }
@@ -288,12 +297,21 @@ class AccountConfig {
final bool Function(String url, String title)? checkLoginStatus; final bool Function(String url, String title)? checkLoginStatus;
final void Function()? onLoginWithWebviewSuccess;
final List<String>? cookieFields;
final Future<bool> Function(List<String>)? validateCookies;
const AccountConfig( const AccountConfig(
this.login, this.login,
this.loginWebsite, this.loginWebsite,
this.registerWebsite, this.registerWebsite,
this.logout, this.logout,
this.checkLoginStatus, this.checkLoginStatus,
this.onLoginWithWebviewSuccess,
this.cookieFields,
this.validateCookies,
) : allowReLogin = true, ) : allowReLogin = true,
infoItems = const []; infoItems = const [];
} }
@@ -322,6 +340,8 @@ class ExplorePageData {
final ComicListBuilder? loadPage; final ComicListBuilder? loadPage;
final ComicListBuilderWithNext? loadNext;
final Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart; final Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
/// return a `List` contains `List<Comic>` or `ExplorePagePart` /// return a `List` contains `List<Comic>` or `ExplorePagePart`
@@ -331,6 +351,7 @@ class ExplorePageData {
this.title, this.title,
this.type, this.type,
this.loadPage, this.loadPage,
this.loadNext,
this.loadMultiPart, this.loadMultiPart,
this.loadMixed, this.loadMixed,
); );
@@ -388,9 +409,13 @@ class SearchOptions {
final String label; 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<Res<List<Comic>>> Function( typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
@@ -401,7 +426,7 @@ class CategoryComicsData {
final List<CategoryComicsOptions> options; final List<CategoryComicsOptions> options;
/// [category] is the one clicked by the user on the category page. /// [category] is the one clicked by the user on the category page.
///
/// if [BaseCategoryPart.categoryParams] is not null, [param] will be not null. /// if [BaseCategoryPart.categoryParams] is not null, [param] will be not null.
/// ///
/// [Res.subData] should be maxPage or null if there is no limit. /// [Res.subData] should be maxPage or null if there is no limit.
@@ -415,9 +440,12 @@ class CategoryComicsData {
class RankingData { class RankingData {
final Map<String, String> options; final Map<String, String> options;
final Future<Res<List<Comic>>> Function(String option, int page) load; final Future<Res<List<Comic>>> Function(String option, int page)? load;
const RankingData(this.options, this.load); final Future<Res<List<Comic>>> Function(String option, String? next)?
loadWithNext;
const RankingData(this.options, this.load, this.loadWithNext);
} }
class CategoryComicsOptions { class CategoryComicsOptions {
@@ -434,7 +462,6 @@ class CategoryComicsOptions {
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen); const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
} }
class LinkHandler { class LinkHandler {
final List<String> domains; final List<String> domains;

View File

@@ -1,20 +1,26 @@
part of 'comic_source.dart'; part of 'comic_source.dart';
typedef AddOrDelFavFunc = Future<Res<bool>> Function(String comicId, String folderId, bool isAdding, String? favId); typedef AddOrDelFavFunc = Future<Res<bool>> Function(
String comicId, String folderId, bool isAdding, String? favId);
class FavoriteData{ class FavoriteData {
final String key; final String key;
final String title; final String title;
final bool multiFolder; final bool multiFolder;
final Future<Res<List<Comic>>> Function(int page, [String? folder]) loadComic; final Future<Res<List<Comic>>> Function(int page, [String? folder])?
loadComic;
final Future<Res<List<Comic>>> Function(String? next, [String? folder])?
loadNext;
/// key-id, value-name /// key-id, value-name
/// ///
/// if comicId is not null, Res.subData is the folders that the comic is in /// if comicId is not null, Res.subData is the folders that the comic is in
final Future<Res<Map<String, String>>> Function([String? comicId])? loadFolders; final Future<Res<Map<String, String>>> Function([String? comicId])?
loadFolders;
/// A value of null disables this feature /// A value of null disables this feature
final Future<Res<bool>> Function(String key)? deleteFolder; final Future<Res<bool>> Function(String key)? deleteFolder;
@@ -32,19 +38,21 @@ class FavoriteData{
required this.title, required this.title,
required this.multiFolder, required this.multiFolder,
required this.loadComic, required this.loadComic,
required this.loadNext,
this.loadFolders, this.loadFolders,
this.deleteFolder, this.deleteFolder,
this.addFolder, this.addFolder,
this.allFavoritesId, this.allFavoritesId,
this.addOrDelFavorite}); this.addOrDelFavorite,
});
} }
FavoriteData getFavoriteData(String key){ FavoriteData getFavoriteData(String key) {
var source = ComicSource.find(key) ?? (throw "Unknown source key: $key"); var source = ComicSource.find(key) ?? (throw "Unknown source key: $key");
return source.favoriteData!; return source.favoriteData!;
} }
FavoriteData? getFavoriteDataOrNull(String key){ FavoriteData? getFavoriteDataOrNull(String key) {
var source = ComicSource.find(key); var source = ComicSource.find(key);
return source?.favoriteData; return source?.favoriteData;
} }

View File

@@ -7,9 +7,9 @@ class Comment {
final String? time; final String? time;
final int? replyCount; final int? replyCount;
final String? id; final String? id;
final int? score; int? score;
final bool? isLiked; 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) { static String? parseTime(dynamic value) {
if (value == null) return null; if (value == null) return null;
@@ -60,6 +60,9 @@ class Comic {
final String? favoriteId; final String? favoriteId;
/// 0-5
final double? stars;
const Comic( const Comic(
this.title, this.title,
this.cover, this.cover,
@@ -70,7 +73,7 @@ class Comic {
this.sourceKey, this.sourceKey,
this.maxPage, this.maxPage,
this.language, this.language,
): favoriteId = null; ): favoriteId = null, stars = null;
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
@@ -96,7 +99,8 @@ class Comic {
description = json["description"] ?? "", description = json["description"] ?? "",
maxPage = json["maxPage"], maxPage = json["maxPage"],
language = json["language"], language = json["language"],
favoriteId = json["favoriteId"]; favoriteId = json["favoriteId"],
stars = (json["stars"] as num?)?.toDouble();
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
@@ -151,6 +155,11 @@ class ComicDetails with HistoryMixin {
final String? url; final String? url;
final double? stars;
@override
final int? maxPage;
static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) { static Map<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
var res = <String, List<String>>{}; var res = <String, List<String>>{};
map.forEach((key, value) { map.forEach((key, value) {
@@ -182,7 +191,9 @@ class ComicDetails with HistoryMixin {
uploader = json["uploader"], uploader = json["uploader"],
uploadTime = json["uploadTime"], uploadTime = json["uploadTime"],
updateTime = json["updateTime"], updateTime = json["updateTime"],
url = json["url"]; url = json["url"],
stars = (json["stars"] as num?)?.toDouble(),
maxPage = json["maxPage"];
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {

View File

@@ -150,6 +150,7 @@ class ComicSourceParser {
_parseLinkHandler(), _parseLinkHandler(),
_getValue("search.enableTagsSuggestions") ?? false, _getValue("search.enableTagsSuggestions") ?? false,
_getValue("comic.enableTagsTranslate") ?? false, _getValue("comic.enableTagsTranslate") ?? false,
_parseStarRatingFunc(),
); );
await source.loadData(); await source.loadData();
@@ -182,7 +183,10 @@ class ComicSourceParser {
return null; return null;
} }
Future<Res<bool>> login(account, pwd) async { Future<Res<bool>> Function(String account, String pwd)? login;
if(_checkExists("account.login")) {
login = (account, pwd) async {
try { try {
await JsEngine().runCode(""" await JsEngine().runCode("""
ComicSource.sources.$_key.account.login(${jsonEncode(account)}, ComicSource.sources.$_key.account.login(${jsonEncode(account)},
@@ -196,36 +200,62 @@ class ComicSourceParser {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); return Res.error(e.toString());
} }
};
} }
void logout() { void logout() {
JsEngine().runCode("ComicSource.sources.$_key.account.logout()"); JsEngine().runCode("ComicSource.sources.$_key.account.logout()");
} }
if (!_checkExists('account.loginWithWebview')) { bool Function(String url, String title)? checkLoginStatus;
return AccountConfig(
login, void Function()? onLoginSuccess;
null,
_getValue("account.registerWebsite"), if (_checkExists('account.loginWithWebview')) {
logout, checkLoginStatus = (url, title) {
null,
);
} else {
return AccountConfig(
null,
_getValue("account.loginWithWebview.url"),
_getValue("account.registerWebsite"),
logout,
(url, title) {
return JsEngine().runCode(""" return JsEngine().runCode("""
ComicSource.sources.$_key.account.loginWithWebview.checkStatus( ComicSource.sources.$_key.account.loginWithWebview.checkStatus(
${jsonEncode(url)}, ${jsonEncode(title)}) ${jsonEncode(url)}, ${jsonEncode(title)})
"""); """);
}, };
);
if (_checkExists('account.loginWithWebview.onLoginSuccess')) {
onLoginSuccess = () {
JsEngine().runCode("""
ComicSource.sources.$_key.account.loginWithWebview.onLoginSuccess()
""");
};
} }
} }
Future<bool> Function(List<String>)? 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<ExplorePageData> _loadExploreData() { List<ExplorePageData> _loadExploreData() {
if (!_checkExists("explore")) { if (!_checkExists("explore")) {
return const []; return const [];
@@ -237,6 +267,7 @@ class ComicSourceParser {
final String type = _getValue("explore[$i].type"); final String type = _getValue("explore[$i].type");
Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart; Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
Future<Res<List<Comic>>> Function(int page)? loadPage; Future<Res<List<Comic>>> Function(int page)? loadPage;
Future<Res<List<Comic>>> Function(String? next)? loadNext;
Future<Res<List<Object>>> Function(int index)? loadMixed; Future<Res<List<Object>>> Function(int index)? loadMixed;
if (type == "singlePageWithMultiPart") { if (type == "singlePageWithMultiPart") {
loadMultiPart = () async { loadMultiPart = () async {
@@ -257,6 +288,7 @@ class ComicSourceParser {
} }
}; };
} else if (type == "multiPageComicList") { } else if (type == "multiPageComicList") {
if (_checkExists("explore[$i].load")) {
loadPage = (int page) async { loadPage = (int page) async {
try { try {
var res = await JsEngine().runCode( var res = await JsEngine().runCode(
@@ -270,6 +302,22 @@ class ComicSourceParser {
return Res.error(e.toString()); 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["next"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
};
}
} else if (type == "multiPartPage") { } else if (type == "multiPartPage") {
loadMultiPart = () async { loadMultiPart = () async {
try { try {
@@ -330,6 +378,7 @@ class ComicSourceParser {
throw ComicSourceParseException("Unknown explore page type $type") throw ComicSourceParseException("Unknown explore page type $type")
}, },
loadPage, loadPage,
loadNext,
loadMultiPart, loadMultiPart,
loadMixed, loadMixed,
)); ));
@@ -406,7 +455,11 @@ class ComicSourceParser {
var value = split.join("-"); var value = split.join("-");
options[key] = value; options[key] = value;
} }
rankingData = RankingData(options, (option, page) async { Future<Res<List<Comic>>> Function(String option, int page)? load;
Future<Res<List<Comic>>> Function(String option, String? next)?
loadWithNext;
if (_checkExists("categoryComics.ranking.load")) {
load = (option, page) async {
try { try {
var res = await JsEngine().runCode(""" var res = await JsEngine().runCode("""
ComicSource.sources.$_key.categoryComics.ranking.load( ComicSource.sources.$_key.categoryComics.ranking.load(
@@ -420,7 +473,26 @@ class ComicSourceParser {
Log.error("Network", "$e\n$s"); Log.error("Network", "$e\n$s");
return Res.error(e.toString()); 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["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 { return CategoryComicsData(options, (category, param, options, page) async {
try { try {
@@ -457,7 +529,12 @@ class ComicSourceParser {
var value = split.join("-"); var value = split.join("-");
map[key] = value; 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 { return SearchPageData(options, (keyword, page, searchOption) async {
try { try {
@@ -550,7 +627,12 @@ class ComicSourceParser {
return retryZone(func); return retryZone(func);
} }
Future<Res<List<Comic>>> loadComic(int page, [String? folder]) async { Future<Res<List<Comic>>> Function(int page, [String? folder])? loadComic;
Future<Res<List<Comic>>> Function(String? next, [String? folder])? loadNext;
if (_checkExists("favorites.loadComic")) {
loadComic = (int page, [String? folder]) async {
Future<Res<List<Comic>>> func() async { Future<Res<List<Comic>>> func() async {
try { try {
var res = await JsEngine().runCode(""" var res = await JsEngine().runCode("""
@@ -568,6 +650,30 @@ class ComicSourceParser {
} }
return retryZone(func); return retryZone(func);
};
}
if (_checkExists("favorites.loadNext")) {
loadNext = (String? next, [String? folder]) async {
Future<Res<List<Comic>>> 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["next"],
);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
}
return retryZone(func);
};
} }
Future<Res<Map<String, String>>> Function([String? comicId])? loadFolders; Future<Res<Map<String, String>>> Function([String? comicId])? loadFolders;
@@ -625,6 +731,7 @@ class ComicSourceParser {
title: _name!, title: _name!,
multiFolder: multiFolder, multiFolder: multiFolder,
loadComic: loadComic, loadComic: loadComic,
loadNext: loadNext,
loadFolders: loadFolders, loadFolders: loadFolders,
addFolder: addFolder, addFolder: addFolder,
deleteFolder: deleteFolder, deleteFolder: deleteFolder,
@@ -683,11 +790,15 @@ class ComicSourceParser {
if (!_checkExists("comic.onImageLoad")) { if (!_checkExists("comic.onImageLoad")) {
return null; return null;
} }
return (imageKey, comicId, ep) { return (imageKey, comicId, ep) async {
return JsEngine().runCode(""" var res = JsEngine().runCode("""
ComicSource.sources.$_key.comic.onImageLoad( ComicSource.sources.$_key.comic.onImageLoad(
${jsonEncode(imageKey)}, ${jsonEncode(comicId)}, ${jsonEncode(ep)}) ${jsonEncode(imageKey)}, ${jsonEncode(comicId)}, ${jsonEncode(ep)})
""") as Map<String, dynamic>; """);
if (res is Future) {
return await res;
}
return res;
}; };
} }
@@ -826,4 +937,21 @@ class ComicSourceParser {
return LinkHandler(domains, linkToId); 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());
}
};
}
} }

View File

@@ -55,6 +55,12 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
_cacheSize += data.length; _cacheSize += data.length;
} }
} catch (e) { } 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 (e.toString().contains("handshake")) {
if (retryTime < 5) { if (retryTime < 5) {
retryTime = 5; retryTime = 5;

View File

@@ -21,7 +21,6 @@ import 'package:pointycastle/block/modes/ecb.dart';
import 'package:pointycastle/block/modes/ofb.dart'; import 'package:pointycastle/block/modes/ofb.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cloudflare.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
import 'comic_source/comic_source.dart'; import 'comic_source/comic_source.dart';
@@ -66,8 +65,6 @@ class JsEngine with _JSEngineApi {
_dio ??= AppDio(BaseOptions( _dio ??= AppDio(BaseOptions(
responseType: ResponseType.plain, validateStatus: (status) => true)); responseType: ResponseType.plain, validateStatus: (status) => true));
_cookieJar ??= SingleInstanceCookieJar.instance!; _cookieJar ??= SingleInstanceCookieJar.instance!;
_dio!.interceptors.add(CookieManagerSql(_cookieJar!));
_dio!.interceptors.add(CloudflareInterceptor());
_closed = false; _closed = false;
_engine = FlutterQjs(); _engine = FlutterQjs();
_engine!.dispatch(); _engine!.dispatch();
@@ -160,7 +157,7 @@ class JsEngine with _JSEngineApi {
String key = message["key"]; String key = message["key"];
String settingKey = message["setting_key"]; String settingKey = message["setting_key"];
var source = ComicSource.find(key)!; var source = ComicSource.find(key)!;
return source.data["setting"]?[settingKey] ?? return source.data["settings"]?[settingKey] ??
source.settings?[settingKey]['default'] ?? source.settings?[settingKey]['default'] ??
(throw "Setting not found: $settingKey"); (throw "Setting not found: $settingKey");
} }
@@ -236,8 +233,14 @@ mixin class _JSEngineApi {
Object? handleHtmlCallback(Map<String, dynamic> data) { Object? handleHtmlCallback(Map<String, dynamic> data) {
switch (data["function"]) { switch (data["function"]) {
case "parse": case "parse":
if (_documents.length > 2) { if (_documents.length > 8) {
_documents.remove(_documents.keys.first); 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"]); _documents[data["key"]] = DocumentWrapper.parse(data["data"]);
return null; return null;
@@ -286,6 +289,12 @@ mixin class _JSEngineApi {
return _documents[data["doc"]]!.getId(data["key"]); return _documents[data["doc"]]!.getId(data["key"]);
case "getLocalName": case "getLocalName":
return _documents[data["doc"]]!.getLocalName(data["key"]); 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; return null;
} }
@@ -593,4 +602,25 @@ class DocumentWrapper {
String? getLocalName(int key) { String? getLocalName(int key) {
return (elements[key]).localName; 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;
}
} }

View File

@@ -127,6 +127,9 @@ class LocalComic with HistoryMixin implements Comic {
@override @override
String? get favoriteId => null; String? get favoriteId => null;
@override
double? get stars => null;
} }
class LocalManager with ChangeNotifier { class LocalManager with ChangeNotifier {

View File

@@ -6,9 +6,12 @@ import 'package:dio/io.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/log.dart';
import 'package:venera/network/cache.dart';
import 'package:venera/utils/ext.dart'; import 'package:venera/utils/ext.dart';
import '../foundation/app.dart'; import '../foundation/app.dart';
import 'cloudflare.dart';
import 'cookie_jar.dart';
export 'package:dio/dio.dart'; export 'package:dio/dio.dart';
@@ -107,6 +110,9 @@ class AppDio with DioMixin {
this.options = options ?? BaseOptions(); this.options = options ?? BaseOptions();
interceptors.add(MyLogInterceptor()); interceptors.add(MyLogInterceptor());
httpClientAdapter = IOHttpClientAdapter(createHttpClient: createHttpClient); httpClientAdapter = IOHttpClientAdapter(createHttpClient: createHttpClient);
interceptors.add(CookieManagerSql(SingleInstanceCookieJar.instance!));
interceptors.add(NetworkCacheManager());
interceptors.add(CloudflareInterceptor());
} }
static HttpClient createHttpClient() { static HttpClient createHttpClient() {

197
lib/network/cache.dart Normal file
View File

@@ -0,0 +1,197 @@
import 'dart:async';
import 'package:dio/dio.dart';
class NetworkCache {
final Uri uri;
final Map<String, dynamic> requestHeaders;
final Map<String, List<String>> 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<Uri, NetworkCache> _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 = <Uri, Completer>{};
@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<String, dynamic> a, Map<String, dynamic> 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<dynamic> 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<int>) {
return data.length;
}
if(data is String) {
return data.length * 4;
}
if(data is Map) {
return data.toString().length * 4;
}
return null;
}
}

View File

@@ -202,6 +202,9 @@ class CookieManagerSql extends Interceptor {
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
var cookies = cookieJar.loadForRequestCookieHeader(options.uri); var cookies = cookieJar.loadForRequestCookieHeader(options.uri);
if (cookies.isNotEmpty) { if (cookies.isNotEmpty) {
if(options.headers["cookie"] != null) {
cookies = "${options.headers["cookie"]}; $cookies";
}
options.headers["cookie"] = cookies; options.headers["cookie"] = cookies;
} }
handler.next(options); handler.next(options);

View File

@@ -7,7 +7,8 @@ import 'package:venera/foundation/consts.dart';
import 'app_dio.dart'; import 'app_dio.dart';
class ImageDownloader { class ImageDownloader {
static Stream<ImageDownloadProgress> loadThumbnail(String url, String? sourceKey) async* { static Stream<ImageDownloadProgress> loadThumbnail(
String url, String? sourceKey) async* {
final cacheKey = "$url@$sourceKey"; final cacheKey = "$url@$sourceKey";
final cache = await CacheManager().findCache(cacheKey); final cache = await CacheManager().findCache(cacheKey);
@@ -25,12 +26,14 @@ class ImageDownloader {
var comicSource = ComicSource.find(sourceKey); var comicSource = ComicSource.find(sourceKey);
configs = comicSource!.getThumbnailLoadingConfig?.call(url) ?? {}; configs = comicSource!.getThumbnailLoadingConfig?.call(url) ?? {};
} }
configs['headers'] ??= { configs['headers'] ??= {};
'user-agent': webUA, if(configs['headers']['user-agent'] == null
}; && configs['headers']['User-Agent'] == null) {
configs['headers']['user-agent'] = webUA;
}
var dio = AppDio(BaseOptions( var dio = AppDio(BaseOptions(
headers: configs['headers'], headers: Map<String, dynamic>.from(configs['headers']),
method: configs['method'] ?? 'GET', method: configs['method'] ?? 'GET',
responseType: ResponseType.stream, responseType: ResponseType.stream,
)); ));
@@ -53,7 +56,7 @@ class ImageDownloader {
} }
} }
if(configs['onResponse'] != null) { if (configs['onResponse'] != null) {
buffer = configs['onResponse'](buffer); buffer = configs['onResponse'](buffer);
} }
@@ -65,7 +68,8 @@ class ImageDownloader {
); );
} }
static Stream<ImageDownloadProgress> loadComicImage(String imageKey, String? sourceKey, String cid, String eid) async* { static Stream<ImageDownloadProgress> loadComicImage(
String imageKey, String? sourceKey, String cid, String eid) async* {
final cacheKey = "$imageKey@$sourceKey@$cid@$eid"; final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
final cache = await CacheManager().findCache(cacheKey); final cache = await CacheManager().findCache(cacheKey);
@@ -81,7 +85,8 @@ class ImageDownloader {
var configs = <String, dynamic>{}; var configs = <String, dynamic>{};
if (sourceKey != null) { if (sourceKey != null) {
var comicSource = ComicSource.find(sourceKey); var comicSource = ComicSource.find(sourceKey);
configs = comicSource!.getImageLoadingConfig?.call(imageKey, cid, eid) ?? {}; configs = (await comicSource!.getImageLoadingConfig
?.call(imageKey, cid, eid)) ?? {};
} }
configs['headers'] ??= { configs['headers'] ??= {
'user-agent': webUA, 'user-agent': webUA,
@@ -111,7 +116,7 @@ class ImageDownloader {
} }
} }
if(configs['onResponse'] != null) { if (configs['onResponse'] != null) {
buffer = configs['onResponse'](buffer); buffer = configs['onResponse'](buffer);
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -158,6 +159,8 @@ class _LoginPageState extends State<_LoginPage> {
String password = ""; String password = "";
bool loading = false; bool loading = false;
final Map<String, String> _cookies = {};
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -173,6 +176,7 @@ class _LoginPageState extends State<_LoginPage> {
children: [ children: [
Text("Login".tl, style: const TextStyle(fontSize: 24)), Text("Login".tl, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 32), const SizedBox(height: 32),
if (widget.config.cookieFields == null)
TextField( TextField(
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Username".tl, labelText: "Username".tl,
@@ -182,8 +186,8 @@ class _LoginPageState extends State<_LoginPage> {
onChanged: (s) { onChanged: (s) {
username = s; username = s;
}, },
), ).paddingBottom(16),
const SizedBox(height: 16), if (widget.config.cookieFields == null)
TextField( TextField(
decoration: InputDecoration( decoration: InputDecoration(
labelText: "Password".tl, labelText: "Password".tl,
@@ -195,9 +199,21 @@ class _LoginPageState extends State<_LoginPage> {
password = s; password = s;
}, },
onSubmitted: (s) => login(), onSubmitted: (s) => login(),
).paddingBottom(16),
for (var field in widget.config.cookieFields ?? <String>[])
TextField(
decoration: InputDecoration(
labelText: field,
border: const OutlineInputBorder(),
), ),
const SizedBox(height: 32), obscureText: true,
if (widget.config.login == null) enabled: widget.config.validateCookies != null,
onChanged: (s) {
_cookies[field] = s;
},
).paddingBottom(16),
if (widget.config.login == null &&
widget.config.cookieFields == null)
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -214,7 +230,7 @@ class _LoginPageState extends State<_LoginPage> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
if (widget.config.loginWebsite != null) if (widget.config.loginWebsite != null)
FilledButton( TextButton(
onPressed: loginWithWebview, onPressed: loginWithWebview,
child: Text("Login with webview".tl), child: Text("Login with webview".tl),
), ),
@@ -240,6 +256,7 @@ class _LoginPageState extends State<_LoginPage> {
} }
void login() { void login() {
if (widget.config.login != null) {
if (username.isEmpty || password.isEmpty) { if (username.isEmpty || password.isEmpty) {
showToast( showToast(
message: "Cannot be empty".tl, message: "Cannot be empty".tl,
@@ -263,48 +280,57 @@ class _LoginPageState extends State<_LoginPage> {
} }
} }
}); });
} 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 { void loginWithWebview() async {
var url = widget.config.loginWebsite!; var url = widget.config.loginWebsite!;
var title = ''; var title = '';
bool success = false; 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( await context.to(
() => AppWebview( () => AppWebview(
initialUrl: widget.config.loginWebsite!, initialUrl: widget.config.loginWebsite!,
onNavigation: (u, c) { onNavigation: (u, c) {
url = u; url = u;
print(url); validate(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();
}
}
}();
return false; return false;
}, },
onTitleChange: (t, c) { 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; title = t;
validate(c);
}, },
), ),
); );

View File

@@ -352,6 +352,18 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ListTile( ListTile(
title: Text("Information".tl), 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) for (var e in comic.tags.entries)
buildWrap( buildWrap(
children: [ 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 { class _ActionButton extends StatelessWidget {
@@ -880,11 +958,34 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
), ),
), ),
SliverGrid( SliverGrid(
delegate: SliverChildBuilderDelegate(childCount: thumbnails.length, delegate: SliverChildBuilderDelegate(
childCount: thumbnails.length,
(context, index) { (context, index) {
if (index == thumbnails.length - 1 && error == null) { if (index == thumbnails.length - 1 && error == null) {
loadNext(); 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( return Padding(
padding: context.width < changePoint padding: context.width < changePoint
? const EdgeInsets.all(4) ? const EdgeInsets.all(4)
@@ -895,7 +996,8 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
Expanded( Expanded(
child: InkWell( child: InkWell(
onTap: () => state.read(null, index + 1), onTap: () => state.read(null, index + 1),
borderRadius: const BorderRadius.all(Radius.circular(16)), borderRadius:
const BorderRadius.all(Radius.circular(16)),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: borderRadius:
@@ -911,12 +1013,13 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
const BorderRadius.all(Radius.circular(16)), const BorderRadius.all(Radius.circular(16)),
child: AnimatedImage( child: AnimatedImage(
image: CachedImageProvider( image: CachedImageProvider(
thumbnails[index], url,
sourceKey: state.widget.sourceKey, sourceKey: state.widget.sourceKey,
), ),
fit: BoxFit.contain, fit: BoxFit.contain,
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
part: part,
), ),
), ),
), ),
@@ -929,13 +1032,14 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
], ],
), ),
); );
}), },
),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200, maxCrossAxisExtent: 200,
childAspectRatio: 0.65, childAspectRatio: 0.65,
), ),
), ),
if(error != null) if (error != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(
children: [ children: [
@@ -1288,7 +1392,8 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
setState(() { setState(() {
isLoading = true; isLoading = true;
}); });
var res = await widget.comicSource.favoriteData!.addOrDelFavorite!( var res =
await widget.comicSource.favoriteData!.addOrDelFavorite!(
widget.cid, widget.cid,
selected!, selected!,
!addedFolders.contains(selected!), !addedFolders.contains(selected!),

View File

@@ -267,7 +267,7 @@ class _CommentTileState extends State<_CommentTile> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(widget.comment.userName), Text(widget.comment.userName, style: ts.bold,),
if (widget.comment.time != null) if (widget.comment.time != null)
Text(widget.comment.time!, style: ts.s12), Text(widget.comment.time!, style: ts.s12),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -403,38 +403,45 @@ class _CommentTileState extends State<_CommentTile> {
int? voteStatus; int? voteStatus;
bool isVoteUp = false; bool isVotingUp = false;
bool isVoteDown = false; bool isVotingDown = false;
void vote(bool isUp) async { void vote(bool isUp) async {
if (isVoteUp || isVoteDown) return; if (isVotingUp || isVotingDown) return;
setState(() { setState(() {
if (isUp) { if (isUp) {
isVoteUp = true; isVotingUp = true;
} else { } else {
isVoteDown = true; isVotingDown = true;
} }
}); });
var isCancel = (isUp && voteStatus == 1) || (!isUp && voteStatus == -1);
var res = await widget.source.voteCommentFunc!( var res = await widget.source.voteCommentFunc!(
widget.comic.comicId, widget.comic.comicId,
widget.comic.subId, widget.comic.subId,
widget.comment.id!, widget.comment.id!,
isUp, isUp,
(isUp && voteStatus == 1) || (!isUp && voteStatus == -1), isCancel,
); );
if (res.success) { if (res.success) {
if(isCancel) {
voteStatus = 0;
} else {
if (isUp) { if (isUp) {
voteStatus = 1; voteStatus = 1;
} else { } else {
voteStatus = -1; voteStatus = -1;
} }
}
widget.comment.voteStatus = voteStatus;
widget.comment.score = res.data ?? widget.comment.score;
} else { } else {
context.showMessage(message: res.errorMessage ?? "Error"); context.showMessage(message: res.errorMessage ?? "Error");
} }
setState(() { setState(() {
isVoteUp = false; isVotingUp = false;
isVoteDown = false; isVotingDown = false;
}); });
} }
@@ -461,7 +468,7 @@ class _CommentTileState extends State<_CommentTile> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Button.icon( Button.icon(
isLoading: isVoteUp, isLoading: isVotingUp,
icon: const Icon(Icons.arrow_upward), icon: const Icon(Icons.arrow_upward),
size: 18, size: 18,
color: upColor, color: upColor,
@@ -471,7 +478,7 @@ class _CommentTileState extends State<_CommentTile> {
Text(widget.comment.score.toString()), Text(widget.comment.score.toString()),
const SizedBox(width: 4), const SizedBox(width: 4),
Button.icon( Button.icon(
isLoading: isVoteDown, isLoading: isVotingDown,
icon: const Icon(Icons.arrow_downward), icon: const Icon(Icons.arrow_downward),
size: 18, size: 18,
color: downColor, color: downColor,

View File

@@ -179,7 +179,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (data.loadMultiPart != null) { if (data.loadMultiPart != null) {
return buildMultiPart(); return buildMultiPart();
} else if (data.loadPage != null) { } else if (data.loadPage != null || data.loadNext != null) {
return buildComicList(); return buildComicList();
} else if (data.loadMixed != null) { } else if (data.loadMixed != null) {
return _MixedExplorePage( return _MixedExplorePage(
@@ -196,7 +196,8 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage> {
Widget buildComicList() { Widget buildComicList() {
return ComicList( return ComicList(
loadPage: data.loadPage!, loadPage: data.loadPage,
loadNext: data.loadNext,
key: ValueKey(key), key: ValueKey(key),
); );
} }

View File

@@ -112,7 +112,8 @@ class _NormalFavoritePage extends StatelessWidget {
), ),
title: Text(data.title), 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) { menuBuilder: (comic) {
return [ return [
MenuEntry( MenuEntry(
@@ -393,7 +394,8 @@ class _FolderTile extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) { return StatefulBuilder(builder: (context, setState) {
return ContentDialog( return ContentDialog(
title: "Delete".tl, 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: [ actions: [
Button.filled( Button.filled(
isLoading: loading, isLoading: loading,
@@ -516,7 +518,11 @@ class _FavoriteFolder extends StatelessWidget {
errorLeading: Appbar( errorLeading: Appbar(
title: Text(title), 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) { menuBuilder: (comic) {
return [ return [
MenuEntry( MenuEntry(

View File

@@ -46,7 +46,12 @@ class _RankingPageState extends State<RankingPage> {
children: [ children: [
Expanded( Expanded(
child: ComicList( 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),
), ),
), ),
], ],

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:venera/components/components.dart'; import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app.dart';
@@ -24,6 +26,8 @@ class _SearchPageState extends State<SearchPage> {
String searchTarget = ""; String searchTarget = "";
var focusNode = FocusNode();
var options = <String>[]; var options = <String>[];
void update() { void update() {
@@ -137,6 +141,12 @@ class _SearchPageState extends State<SearchPage> {
super.initState(); super.initState();
} }
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -152,6 +162,7 @@ class _SearchPageState extends State<SearchPage> {
onChanged: (s) { onChanged: (s) {
findSuggestions(); findSuggestions();
}, },
focusNode: focusNode,
); );
if (suggestions.isNotEmpty) { if (suggestions.isNotEmpty) {
yield buildSuggestions(context); yield buildSuggestions(context);
@@ -186,6 +197,7 @@ class _SearchPageState extends State<SearchPage> {
onTap: () { onTap: () {
setState(() { setState(() {
searchTarget = e.key; searchTarget = e.key;
useDefaultOptions();
}); });
}, },
); );
@@ -197,6 +209,13 @@ class _SearchPageState extends State<SearchPage> {
); );
} }
void useDefaultOptions() {
final searchOptions =
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[];
options = searchOptions.map((e) => e.defaultValue).toList();
}
Widget buildSearchOptions() { Widget buildSearchOptions() {
var children = <Widget>[]; var children = <Widget>[];
@@ -204,30 +223,21 @@ class _SearchPageState extends State<SearchPage> {
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ?? ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
<SearchOptions>[]; <SearchOptions>[];
if (searchOptions.length != options.length) { if (searchOptions.length != options.length) {
options = searchOptions.map((e) => e.defaultValue).toList(); useDefaultOptions();
} }
if (searchOptions.isEmpty) { if (searchOptions.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox()); return const SliverToBoxAdapter(child: SizedBox());
} }
for (int i = 0; i < searchOptions.length; i++) { for (int i = 0; i < searchOptions.length; i++) {
final option = searchOptions[i]; final option = searchOptions[i];
children.add(ListTile( children.add(SearchOptionWidget(
contentPadding: EdgeInsets.zero, option: option,
title: Text(option.label.tl), value: options[i],
)); onChanged: (value) {
children.add(Wrap( options[i] = value;
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(); update();
}, },
); sourceKey: searchTarget,
}).toList(),
)); ));
} }
@@ -336,7 +346,9 @@ class _SearchPageState extends State<SearchPage> {
} else { } else {
controller.text += "$text "; controller.text += "$text ";
} }
suggestions.clear();
update(); update();
focusNode.requestFocus();
} }
bool showMethod = MediaQuery.of(context).size.width < 600; bool showMethod = MediaQuery.of(context).size.width < 600;
@@ -444,3 +456,77 @@ class _SearchPageState extends State<SearchPage> {
); );
} }
} }
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,
)
],
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.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/ext.dart';
import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
@@ -87,12 +88,27 @@ class _SearchResultPageState extends State<SearchResultPage> {
); );
sourceKey = widget.sourceKey; sourceKey = widget.sourceKey;
options = widget.options; options = widget.options;
validateOptions();
text = widget.text; text = widget.text;
appdata.addSearchHistory(text); appdata.addSearchHistory(text);
suggestionsController = _SuggestionsController(controller); suggestionsController = _SuggestionsController(controller);
super.initState(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ComicList( return ComicList(
@@ -422,25 +438,15 @@ class _SearchSettingsDialogState extends State<_SearchSettingsDialog> {
} }
for (int i = 0; i < searchOptions.length; i++) { for (int i = 0; i < searchOptions.length; i++) {
final option = searchOptions[i]; final option = searchOptions[i];
children.add(ListTile( children.add(SearchOptionWidget(
contentPadding: EdgeInsets.zero, option: option,
title: Text(option.label.tl), value: options[i],
)); onChanged: (value) {
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(() { setState(() {
options[i] = e.key; options[i] = value;
}); });
onChanged();
}, },
); sourceKey: searchTarget,
}).toList(),
)); ));
} }