mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
add star rating, network cache, advanced search option, loginWithCookies, loadNext; fix some minor issues
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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<SliverSearchBar> createState() => _SliverSearchBarState();
|
||||
}
|
||||
@@ -613,6 +616,7 @@ class _SliverSearchBarState extends State<SliverSearchBar>
|
||||
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,
|
||||
|
@@ -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<String>? 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<ComicList> {
|
||||
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<ComicList> {
|
||||
(_maxPage == null || page <= _maxPage!)) {
|
||||
setState(() {
|
||||
_error = null;
|
||||
this._page = page;
|
||||
_page = page;
|
||||
});
|
||||
} else {
|
||||
context.showMessage(
|
||||
@@ -777,10 +781,10 @@ class ComicListState extends State<ComicList> {
|
||||
Future<void> _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<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;
|
||||
}
|
||||
}
|
||||
|
@@ -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';
|
||||
|
@@ -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,
|
||||
|
@@ -21,11 +21,11 @@ class AnimatedImage extends StatefulWidget {
|
||||
this.gaplessPlayback = false,
|
||||
this.filterQuality = FilterQuality.medium,
|
||||
this.isAntiAlias = false,
|
||||
this.part,
|
||||
Map<String, String>? 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<AnimatedImage> createState() => _AnimatedImageState();
|
||||
}
|
||||
|
||||
class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserver {
|
||||
class _AnimatedImageState extends State<AnimatedImage>
|
||||
with WidgetsBindingObserver {
|
||||
ImageStream? _imageStream;
|
||||
ImageInfo? _imageInfo;
|
||||
ImageChunkEvent? _loadingProgress;
|
||||
@@ -138,8 +141,8 @@ class _AnimatedImageState extends State<AnimatedImage> 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<AnimatedImage> 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<AnimatedImage> 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<AnimatedImage> with WidgetsBindingObserv
|
||||
}
|
||||
|
||||
if (!widget.gaplessPlayback) {
|
||||
setState(() { _replaceImage(info: null); });
|
||||
setState(() {
|
||||
_replaceImage(info: null);
|
||||
});
|
||||
}
|
||||
|
||||
setState(() {
|
||||
@@ -247,7 +256,9 @@ class _AnimatedImageState extends State<AnimatedImage> 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<AnimatedImage> 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<AnimatedImage> with WidgetsBindingObserv
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
} else{
|
||||
} else {
|
||||
result = const Center();
|
||||
}
|
||||
|
||||
@@ -307,8 +330,59 @@ class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserv
|
||||
super.debugFillProperties(description);
|
||||
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
@@ -62,6 +62,7 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
|
||||
return Listener(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onPointerDown: (event) {
|
||||
_futurePosition = null;
|
||||
if (_isMouseScroll) {
|
||||
setState(() {
|
||||
_isMouseScroll = false;
|
||||
|
@@ -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();
|
||||
}
|
@@ -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<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 LoadComicFunc = Future<Res<ComicDetails>> Function(String id);
|
||||
@@ -40,7 +44,7 @@ typedef CommentsLoader = Future<Res<List<Comment>>> Function(
|
||||
typedef SendCommentFunc = Future<Res<bool>> Function(
|
||||
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)?;
|
||||
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
|
||||
String imageKey)?;
|
||||
@@ -64,6 +68,9 @@ typedef VoteCommentFunc = Future<Res<int?>> Function(
|
||||
typedef HandleClickTagEvent = Map<String, String> Function(
|
||||
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 {
|
||||
static final List<ComicSource> _sources = [];
|
||||
|
||||
@@ -163,8 +170,7 @@ class ComicSource {
|
||||
/// Load comic pages.
|
||||
final LoadComicPagesFunc? loadComicPages;
|
||||
|
||||
final Map<String, dynamic> Function(
|
||||
String imageKey, String comicId, String epId)? getImageLoadingConfig;
|
||||
final GetImageLoadingConfigFunc? getImageLoadingConfig;
|
||||
|
||||
final Map<String, dynamic> Function(String imageKey)?
|
||||
getThumbnailLoadingConfig;
|
||||
@@ -203,6 +209,8 @@ class ComicSource {
|
||||
|
||||
final bool enableTagsTranslate;
|
||||
|
||||
final StarRatingFunc? starRatingFunc;
|
||||
|
||||
Future<void> 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<String>? cookieFields;
|
||||
|
||||
final Future<bool> Function(List<String>)? 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<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
|
||||
|
||||
/// return a `List` contains `List<Comic>` 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<Res<List<Comic>>> Function(
|
||||
@@ -401,7 +426,7 @@ class CategoryComicsData {
|
||||
final List<CategoryComicsOptions> 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<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 {
|
||||
@@ -434,11 +462,10 @@ class CategoryComicsOptions {
|
||||
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
|
||||
}
|
||||
|
||||
|
||||
class LinkHandler {
|
||||
final List<String> domains;
|
||||
|
||||
final String? Function(String url) linkToId;
|
||||
|
||||
const LinkHandler(this.domains, this.linkToId);
|
||||
}
|
||||
}
|
||||
|
@@ -1,20 +1,26 @@
|
||||
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 title;
|
||||
|
||||
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
|
||||
///
|
||||
/// 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
|
||||
final Future<Res<bool>> 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;
|
||||
}
|
||||
}
|
||||
|
@@ -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<String, dynamic> 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<String, List<String>> _generateMap(Map<dynamic, dynamic> map) {
|
||||
var res = <String, List<String>>{};
|
||||
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<String, dynamic> toJson() {
|
||||
return {
|
||||
|
@@ -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<Res<bool>> login(account, pwd) async {
|
||||
try {
|
||||
await JsEngine().runCode("""
|
||||
Future<Res<bool>> 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"] = <String>[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"] = <String>[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<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() {
|
||||
@@ -237,6 +267,7 @@ class ComicSourceParser {
|
||||
final String type = _getValue("explore[$i].type");
|
||||
Future<Res<List<ExplorePagePart>>> Function()? loadMultiPart;
|
||||
Future<Res<List<Comic>>> Function(int page)? loadPage;
|
||||
Future<Res<List<Comic>>> Function(String? next)? loadNext;
|
||||
Future<Res<List<Object>>> 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<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 {
|
||||
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<Res<List<Comic>>> loadComic(int page, [String? folder]) async {
|
||||
Future<Res<List<Comic>>> func() async {
|
||||
try {
|
||||
var res = await JsEngine().runCode("""
|
||||
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 {
|
||||
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<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["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<Res<Map<String, String>>> 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<String, dynamic>;
|
||||
""");
|
||||
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());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -55,6 +55,12 @@ abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
_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;
|
||||
|
@@ -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<String, dynamic> 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;
|
||||
}
|
||||
}
|
||||
|
@@ -127,6 +127,9 @@ class LocalComic with HistoryMixin implements Comic {
|
||||
|
||||
@override
|
||||
String? get favoriteId => null;
|
||||
|
||||
@override
|
||||
double? get stars => null;
|
||||
}
|
||||
|
||||
class LocalManager with ChangeNotifier {
|
||||
|
@@ -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() {
|
||||
|
197
lib/network/cache.dart
Normal file
197
lib/network/cache.dart
Normal 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;
|
||||
}
|
||||
}
|
@@ -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);
|
||||
|
@@ -7,7 +7,8 @@ import 'package:venera/foundation/consts.dart';
|
||||
import 'app_dio.dart';
|
||||
|
||||
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 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<String, dynamic>.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<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 cache = await CacheManager().findCache(cacheKey);
|
||||
|
||||
@@ -81,7 +85,8 @@ class ImageDownloader {
|
||||
var configs = <String, dynamic>{};
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -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<String, String> _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 ?? <String>[])
|
||||
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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@@ -352,6 +352,18 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
||||
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!),
|
||||
|
@@ -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,
|
||||
|
@@ -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),
|
||||
);
|
||||
}
|
||||
|
@@ -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(
|
||||
|
@@ -46,7 +46,12 @@ class _RankingPageState extends State<RankingPage> {
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@@ -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<SearchPage> {
|
||||
|
||||
String searchTarget = "";
|
||||
|
||||
var focusNode = FocusNode();
|
||||
|
||||
var options = <String>[];
|
||||
|
||||
void update() {
|
||||
@@ -137,6 +141,12 @@ class _SearchPageState extends State<SearchPage> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -152,6 +162,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
onChanged: (s) {
|
||||
findSuggestions();
|
||||
},
|
||||
focusNode: focusNode,
|
||||
);
|
||||
if (suggestions.isNotEmpty) {
|
||||
yield buildSuggestions(context);
|
||||
@@ -186,6 +197,7 @@ class _SearchPageState extends State<SearchPage> {
|
||||
onTap: () {
|
||||
setState(() {
|
||||
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() {
|
||||
var children = <Widget>[];
|
||||
|
||||
@@ -204,30 +223,21 @@ class _SearchPageState extends State<SearchPage> {
|
||||
ComicSource.find(searchTarget)!.searchPageData!.searchOptions ??
|
||||
<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<SearchPage> {
|
||||
} 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<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,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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<SearchResultPage> {
|
||||
);
|
||||
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,
|
||||
));
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user