add more js api & improve ui

This commit is contained in:
nyne
2024-10-15 20:45:12 +08:00
parent c0a0dc59e1
commit fc86b8bbc6
22 changed files with 609 additions and 140 deletions

View File

@@ -1,5 +1,35 @@
/*
Venera JavaScript Library
*/
/// encode, decode, hash, decrypt /// encode, decode, hash, decrypt
let Convert = { let Convert = {
/**
* @param str {string}
* @returns {ArrayBuffer}
*/
encodeUtf8: (str) => {
return sendMessage({
method: "convert",
type: "utf8",
value: str,
isEncode: true
});
},
/**
* @param value {ArrayBuffer}
* @returns {string}
*/
decodeUtf8: (value) => {
return sendMessage({
method: "convert",
type: "utf8",
value: value,
isEncode: false
});
},
/** /**
* @param {ArrayBuffer} value * @param {ArrayBuffer} value
* @returns {string} * @returns {string}
@@ -78,6 +108,41 @@ let Convert = {
}); });
}, },
/**
* @param key {ArrayBuffer}
* @param value {ArrayBuffer}
* @param hash {string} - md5, sha1, sha256, sha512
* @returns {ArrayBuffer}
*/
hmac: (key, value, hash) => {
return sendMessage({
method: "convert",
type: "hmac",
value: value,
key: key,
hash: hash,
isEncode: true
});
},
/**
* @param key {ArrayBuffer}
* @param value {ArrayBuffer}
* @param hash {string} - md5, sha1, sha256, sha512
* @returns {string} - hex string
*/
hmacString: (key, value, hash) => {
return sendMessage({
method: "convert",
type: "hmac",
value: value,
key: key,
hash: hash,
isEncode: true,
isString: true
});
},
/** /**
* @param {ArrayBuffer} value * @param {ArrayBuffer} value
* @param {ArrayBuffer} key * @param {ArrayBuffer} key
@@ -160,6 +225,21 @@ let Convert = {
} }
} }
/**
* create a time-based uuid
*
* Note: the engine will generate a new uuid every time it is called
*
* To get the same uuid, please save it to the local storage
*
* @returns {string}
*/
function createUuid() {
return sendMessage({
method: "uuid"
});
}
function randomInt(min, max) { function randomInt(min, max) {
return sendMessage({ return sendMessage({
method: 'random', method: 'random',
@@ -520,6 +600,91 @@ let console = {
}, },
}; };
/**
* Create a comic object
* @param id {string}
* @param title {string}
* @param subtitle {string}
* @param cover {string}
* @param tags {string[]}
* @param description {string}
* @param maxPage {number | null}
* @constructor
*/
function Comic({id, title, subtitle, cover, tags, description, maxPage}) {
this.id = id;
this.title = title;
this.subtitle = subtitle;
this.cover = cover;
this.tags = tags;
this.description = description;
this.maxPage = maxPage;
}
/**
* Create a comic details object
* @param title {string}
* @param cover {string}
* @param description {string | null}
* @param tags {Map<string, string[]> | {} | null}
* @param chapters {Map<string, string> | {} | null} - key: chapter id, value: chapter title
* @param isFavorite {boolean | null} - favorite status. If the comic source supports multiple folders, this field should be null
* @param subId {string | null} - a param which is passed to comments api
* @param thumbnails {string[] | null} - for multiple page thumbnails, set this to null, and use `loadThumbnails` api to load thumbnails
* @param recommend {Comic[] | null} - related comics
* @param commentCount {number | null}
* @param likesCount {number | null}
* @param isLiked {boolean | null}
* @param uploader {string | null}
* @param updateTime {string | null}
* @param uploadTime {string | null}
* @param url {string | null}
* @constructor
*/
function ComicDetails({title, cover, description, tags, chapters, isFavorite, subId, thumbnails, recommend, commentCount, likesCount, isLiked, uploader, updateTime, uploadTime, url}) {
this.title = title;
this.cover = cover;
this.description = description;
this.tags = tags;
this.chapters = chapters;
this.isFavorite = isFavorite;
this.subId = subId;
this.thumbnails = thumbnails;
this.recommend = recommend;
this.commentCount = commentCount;
this.likesCount = likesCount;
this.isLiked = isLiked;
this.uploader = uploader;
this.updateTime = updateTime;
this.uploadTime = uploadTime;
this.url = url;
}
/**
* Create a comment object
* @param userName {string}
* @param avatar {string?}
* @param content {string}
* @param time {string?}
* @param replyCount {number?}
* @param id {string?}
* @param isLiked {boolean?}
* @param score {number?}
* @param voteStatus {number?} - 1: upvote, -1: downvote, 0: none
* @constructor
*/
function Comment({userName, avatar, content, time, replyCount, id, isLiked, score, voteStatus}) {
this.userName = userName;
this.avatar = avatar;
this.content = content;
this.time = time;
this.replyCount = replyCount;
this.id = id;
this.isLiked = isLiked;
this.score = score;
this.voteStatus = voteStatus;
}
class ComicSource { class ComicSource {
name = "" name = ""
@@ -544,6 +709,19 @@ class ComicSource {
}) })
} }
/**
* load a setting with its key
* @param key {string}
* @returns {any}
*/
loadSetting(key) {
return sendMessage({
method: 'load_setting',
key: this.key,
setting_key: key
})
}
/** /**
* save data * save data
* @param {string} dataKey * @param {string} dataKey
@@ -570,6 +748,17 @@ class ComicSource {
}) })
} }
/**
*
* @returns {boolean}
*/
get isLogged() {
return sendMessage({
method: 'isLogged',
key: this.key,
});
}
init() { } init() { }
static sources = {} static sources = {}

View File

@@ -116,10 +116,10 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
}); });
loadData().then((value) async { loadData().then((value) async {
if (value.success) { if (value.success) {
data = value.data;
await onDataLoaded(); await onDataLoaded();
setState(() { setState(() {
isLoading = false; isLoading = false;
data = value.data;
}); });
} else { } else {
setState(() { setState(() {
@@ -131,22 +131,10 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
} }
Widget buildError() { Widget buildError() {
return Center( return NetworkError(
child: Column( message: error!,
mainAxisSize: MainAxisSize.min, retry: retry,
children: [ );
Text(
error!,
maxLines: 3,
),
const SizedBox(height: 12),
Button.text(
onPressed: retry,
child: const Text("Retry"),
)
],
),
).paddingHorizontal(16);
} }
@override @override
@@ -154,11 +142,12 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
void initState() { void initState() {
isLoading = true; isLoading = true;
Future.microtask(() { Future.microtask(() {
loadData().then((value) { loadData().then((value) async {
if (value.success) { if (value.success) {
data = value.data;
await onDataLoaded();
setState(() { setState(() {
isLoading = false; isLoading = false;
data = value.data;
}); });
} else { } else {
setState(() { setState(() {

View File

@@ -295,20 +295,21 @@ class ContentDialog extends StatelessWidget {
} }
} }
void showInputDialog({ Future<void> showInputDialog({
required BuildContext context, required BuildContext context,
required String title, required String title,
required String hintText, String? hintText,
required FutureOr<Object?> Function(String) onConfirm, required FutureOr<Object?> Function(String) onConfirm,
String? initialValue, String? initialValue,
String confirmText = "Confirm", String confirmText = "Confirm",
String cancelText = "Cancel", String cancelText = "Cancel",
RegExp? inputValidator,
}) { }) {
var controller = TextEditingController(text: initialValue); var controller = TextEditingController(text: initialValue);
bool isLoading = false; bool isLoading = false;
String? error; String? error;
showDialog( return showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
return StatefulBuilder( return StatefulBuilder(
@@ -327,6 +328,11 @@ void showInputDialog({
Button.filled( Button.filled(
isLoading: isLoading, isLoading: isLoading,
onPressed: () async { onPressed: () async {
if (inputValidator != null &&
!inputValidator.hasMatch(controller.text)) {
setState(() => error = "Invalid input");
return;
}
var futureOr = onConfirm(controller.text); var futureOr = onConfirm(controller.text);
Object? result; Object? result;
if (futureOr is Future) { if (futureOr is Future) {

View File

@@ -1,7 +1,8 @@
part of 'components.dart'; part of 'components.dart';
class SmoothCustomScrollView extends StatelessWidget { class SmoothCustomScrollView extends StatelessWidget {
const SmoothCustomScrollView({super.key, required this.slivers, this.controller}); const SmoothCustomScrollView(
{super.key, required this.slivers, this.controller});
final ScrollController? controller; final ScrollController? controller;
@@ -22,9 +23,9 @@ class SmoothCustomScrollView extends StatelessWidget {
} }
} }
class SmoothScrollProvider extends StatefulWidget { class SmoothScrollProvider extends StatefulWidget {
const SmoothScrollProvider({super.key, this.controller, required this.builder}); const SmoothScrollProvider(
{super.key, this.controller, required this.builder});
final ScrollController? controller; final ScrollController? controller;
@@ -77,13 +78,15 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
} }
if (!_isMouseScroll) return; if (!_isMouseScroll) return;
var currentLocation = _controller.position.pixels; var currentLocation = _controller.position.pixels;
var old = _futurePosition;
_futurePosition ??= currentLocation; _futurePosition ??= currentLocation;
double k = (_futurePosition! - currentLocation).abs() / 1600 + 1; double k = (_futurePosition! - currentLocation).abs() / 1600 + 1;
_futurePosition = _futurePosition = _futurePosition! + pointerSignal.scrollDelta.dy * k;
_futurePosition! + pointerSignal.scrollDelta.dy * k;
_futurePosition = _futurePosition!.clamp( _futurePosition = _futurePosition!.clamp(
_controller.position.minScrollExtent, _controller.position.minScrollExtent,
_controller.position.maxScrollExtent); _controller.position.maxScrollExtent,
);
if(_futurePosition == old) return;
_controller.animateTo(_futurePosition!, _controller.animateTo(_futurePosition!,
duration: _fastAnimationDuration, curve: Curves.linear); duration: _fastAnimationDuration, curve: Curves.linear);
} }

View File

@@ -17,8 +17,11 @@ import '../js_engine.dart';
import '../log.dart'; import '../log.dart';
part 'category.dart'; part 'category.dart';
part 'favorites.dart'; part 'favorites.dart';
part 'parser.dart'; part 'parser.dart';
part 'models.dart'; 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.
@@ -50,11 +53,16 @@ typedef LikeOrUnlikeComicFunc = Future<Res<bool>> Function(
/// [isLiking] is true if the user is liking the comment, false if unliking. /// [isLiking] is true if the user is liking the comment, false if unliking.
/// return the new likes count or null. /// return the new likes count or null.
typedef LikeCommentFunc = Future<Res<int?>> Function(String comicId, String? subId, String commentId, bool isLiking); typedef LikeCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isLiking);
/// [isUp] is true if the user is upvoting the comment, false if downvoting. /// [isUp] is true if the user is upvoting the comment, false if downvoting.
/// return the new vote count or null. /// return the new vote count or null.
typedef VoteCommentFunc = Future<Res<int?>> Function(String comicId, String? subId, String commentId, bool isUp, bool isCancel); typedef VoteCommentFunc = Future<Res<int?>> Function(
String comicId, String? subId, String commentId, bool isUp, bool isCancel);
typedef HandleClickTagEvent = Map<String, String> Function(
String namespace, String tag);
class ComicSource { class ComicSource {
static final List<ComicSource> _sources = []; static final List<ComicSource> _sources = [];
@@ -147,9 +155,6 @@ class ComicSource {
/// Search page. /// Search page.
final SearchPageData? searchPageData; final SearchPageData? searchPageData;
/// Settings.
final List<SettingItem> settings;
/// Load comic info. /// Load comic info.
final LoadComicFunc? loadComicInfo; final LoadComicFunc? loadComicInfo;
@@ -186,6 +191,12 @@ class ComicSource {
final LikeCommentFunc? likeCommentFunc; final LikeCommentFunc? likeCommentFunc;
final Map<String, dynamic>? settings;
final Map<String, Map<String, String>>? translations;
final HandleClickTagEvent? handleClickTagEvent;
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()) {
@@ -246,8 +257,11 @@ class ComicSource {
this.sendCommentFunc, this.sendCommentFunc,
this.likeOrUnlikeComic, this.likeOrUnlikeComic,
this.voteCommentFunc, this.voteCommentFunc,
this.likeCommentFunc,) this.likeCommentFunc,
: idMatcher = null; this.idMatcher,
this.translations,
this.handleClickTagEvent,
);
} }
class AccountConfig { class AccountConfig {
@@ -368,21 +382,6 @@ class SearchOptions {
String get defaultValue => options.keys.first; String get defaultValue => options.keys.first;
} }
class SettingItem {
final String name;
final String iconName;
final SettingType type;
final List<String>? options;
const SettingItem(this.name, this.iconName, this.type, this.options);
}
enum SettingType {
switcher,
selector,
input,
}
typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function( typedef CategoryComicsLoader = Future<Res<List<Comic>>> Function(
String category, String? param, List<String> options, int page); String category, String? param, List<String> options, int page);
@@ -423,4 +422,3 @@ class CategoryComicsOptions {
const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen); const CategoryComicsOptions(this.options, this.notShowWhen, this.showWhen);
} }

View File

@@ -107,6 +107,8 @@ class ComicDetails with HistoryMixin {
final String? updateTime; final String? updateTime;
final String? url;
static Map<String, List<String>> _generateMap(Map<String, dynamic> map) { static Map<String, List<String>> _generateMap(Map<String, dynamic> map) {
var res = <String, List<String>>{}; var res = <String, List<String>>{};
map.forEach((key, value) { map.forEach((key, value) {
@@ -137,7 +139,8 @@ class ComicDetails with HistoryMixin {
commentsCount = json["commentsCount"], commentsCount = json["commentsCount"],
uploader = json["uploader"], uploader = json["uploader"],
uploadTime = json["uploadTime"], uploadTime = json["uploadTime"],
updateTime = json["updateTime"]; updateTime = json["updateTime"],
url = json["url"];
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
@@ -159,6 +162,7 @@ class ComicDetails with HistoryMixin {
"uploader": uploader, "uploader": uploader,
"uploadTime": uploadTime, "uploadTime": uploadTime,
"updateTime": updateTime, "updateTime": updateTime,
"url": url,
}; };
} }

View File

@@ -130,7 +130,7 @@ class ComicSourceParser {
_loadFavoriteData(), _loadFavoriteData(),
_loadExploreData(), _loadExploreData(),
_loadSearchData(), _loadSearchData(),
[], _parseSettings(),
_parseLoadComicFunc(), _parseLoadComicFunc(),
_parseThumbnailLoader(), _parseThumbnailLoader(),
_parseLoadComicPagesFunc(), _parseLoadComicPagesFunc(),
@@ -144,6 +144,9 @@ class ComicSourceParser {
_parseLikeFunc(), _parseLikeFunc(),
_parseVoteCommentFunc(), _parseVoteCommentFunc(),
_parseLikeCommentFunc(), _parseLikeCommentFunc(),
_parseIdMatch(),
_parseTranslation(),
_parseClickTagEvent(),
); );
await source.loadData(); await source.loadData();
@@ -639,13 +642,13 @@ class ComicSourceParser {
} }
LikeOrUnlikeComicFunc? _parseLikeFunc() { LikeOrUnlikeComicFunc? _parseLikeFunc() {
if (!_checkExists("comic.likeOrUnlikeComic")) { if (!_checkExists("comic.likeComic")) {
return null; return null;
} }
return (id, isLiking) async { return (id, isLiking) async {
try { try {
await JsEngine().runCode(""" await JsEngine().runCode("""
ComicSource.sources.$_key.comic.likeOrUnlikeComic(${jsonEncode(id)}, ${jsonEncode(isLiking)}) ComicSource.sources.$_key.comic.likeComic(${jsonEncode(id)}, ${jsonEncode(isLiking)})
"""); """);
return const Res(true); return const Res(true);
} catch (e, s) { } catch (e, s) {
@@ -688,4 +691,41 @@ class ComicSourceParser {
} }
}; };
} }
Map<String, dynamic> _parseSettings() {
return _getValue("settings") ?? {};
}
RegExp? _parseIdMatch() {
if (!_checkExists("comic.idMatch")) {
return null;
}
return RegExp(_getValue("comic.idMatch"));
}
Map<String, Map<String, String>>? _parseTranslation() {
if (!_checkExists("translation")) {
return null;
}
var data = _getValue("translation");
var res = <String, Map<String, String>>{};
for (var e in data.entries) {
res[e.key] = Map<String, String>.from(e.value);
}
return res;
}
HandleClickTagEvent? _parseClickTagEvent() {
if (!_checkExists("comic.onClickTag")) {
return null;
}
return (namespace, tag) {
var res = JsEngine().runCode("""
ComicSource.sources.$_key.comic.onClickTag(${jsonEncode(namespace)}, ${jsonEncode(tag)})
""");
var r = Map<String, String?>.from(res);
r.removeWhere((key, value) => value == null);
return Map.from(r);
};
}
} }

View File

@@ -19,6 +19,7 @@ import 'package:pointycastle/block/modes/cbc.dart';
import 'package:pointycastle/block/modes/cfb.dart'; import 'package:pointycastle/block/modes/cfb.dart';
import 'package:pointycastle/block/modes/ecb.dart'; 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:venera/network/app_dio.dart'; import 'package:venera/network/app_dio.dart';
import 'package:venera/network/cookie_jar.dart'; import 'package:venera/network/cookie_jar.dart';
@@ -112,6 +113,9 @@ class JsEngine with _JSEngineApi{
{ {
String key = message["key"]; String key = message["key"];
String dataKey = message["data_key"]; String dataKey = message["data_key"];
if(dataKey == 'setting'){
throw "setting is not allowed to be saved";
}
var data = message["data"]; var data = message["data"];
var source = ComicSource.find(key)!; var source = ComicSource.find(key)!;
source.data[dataKey] = data; source.data[dataKey] = data;
@@ -145,6 +149,23 @@ class JsEngine with _JSEngineApi{
{ {
return handleCookieCallback(Map.from(message)); return handleCookieCallback(Map.from(message));
} }
case "uuid":
{
return const Uuid().v1();
}
case "load_setting":
{
String key = message["key"];
String settingKey = message["setting_key"];
var source = ComicSource.find(key)!;
return source.data["setting"]?[settingKey]
?? source.settings?[settingKey]['default']
?? (throw "Setting not found: $settingKey");
}
case "isLogged":
{
return ComicSource.find(message["key"])!.isLogged;
}
} }
} }
} }
@@ -303,6 +324,10 @@ mixin class _JSEngineApi{
bool isEncode = data["isEncode"]; bool isEncode = data["isEncode"];
try { try {
switch (type) { switch (type) {
case "utf8":
return isEncode
? utf8.encode(value)
: utf8.decode(value);
case "base64": case "base64":
if(value is String){ if(value is String){
value = utf8.encode(value); value = utf8.encode(value);
@@ -318,6 +343,21 @@ mixin class _JSEngineApi{
return Uint8List.fromList(sha256.convert(value).bytes); return Uint8List.fromList(sha256.convert(value).bytes);
case "sha512": case "sha512":
return Uint8List.fromList(sha512.convert(value).bytes); return Uint8List.fromList(sha512.convert(value).bytes);
case "hmac":
var key = data["key"];
var hash = data["hash"];
var hmac = Hmac(switch(hash) {
"md5" => md5,
"sha1" => sha1,
"sha256" => sha256,
"sha512" => sha512,
_ => throw "Unsupported hash: $hash"
}, key);
if(data['isString'] == true){
return hmac.convert(value).toString();
} else {
return Uint8List.fromList(hmac.convert(value).bytes);
}
case "aes-ecb": case "aes-ecb":
if(!isEncode){ if(!isEncode){
var key = data["key"]; var key = data["key"];

View File

@@ -114,7 +114,8 @@ class AppDio with DioMixin {
client.connectionTimeout = const Duration(seconds: 5); client.connectionTimeout = const Duration(seconds: 5);
client.findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy"; client.findProxy = (uri) => proxy == null ? "DIRECT" : "PROXY $proxy";
client.idleTimeout = const Duration(seconds: 100); client.idleTimeout = const Duration(seconds: 100);
client.badCertificateCallback = (X509Certificate cert, String host, int port) { client.badCertificateCallback =
(X509Certificate cert, String host, int port) {
if (host.contains("cdn")) return true; if (host.contains("cdn")) return true;
final ipv4RegExp = RegExp( final ipv4RegExp = RegExp(
r'^((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})$'); r'^((25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(25[0-5]|2[0-4]\d|[0-1]?\d?\d)){3})$');
@@ -129,7 +130,8 @@ class AppDio with DioMixin {
static String? proxy; static String? proxy;
static Future<String?> getProxy() async { static Future<String?> getProxy() async {
if ((appdata.settings['proxy'] as String).removeAllBlank == "direct") return null; if ((appdata.settings['proxy'] as String).removeAllBlank == "direct")
return null;
if (appdata.settings['proxy'] != "system") return appdata.settings['proxy']; if (appdata.settings['proxy'] != "system") return appdata.settings['proxy'];
String res; String res;
@@ -181,8 +183,15 @@ class AppDio with DioMixin {
if (_proxy != proxy) { if (_proxy != proxy) {
_proxy = proxy; _proxy = proxy;
(httpClientAdapter as IOHttpClientAdapter).close(); (httpClientAdapter as IOHttpClientAdapter).close();
httpClientAdapter = IOHttpClientAdapter(createHttpClient: createHttpClient); httpClientAdapter =
IOHttpClientAdapter(createHttpClient: createHttpClient);
} }
Log.info(
"Network",
"${options?.method ?? 'GET'} $path\n"
"Headers: ${options?.headers}\n"
"Data: $data\n",
);
return super.request( return super.request(
path, path,
data: data, data: data,

View File

@@ -133,7 +133,7 @@ class _CategoryPage extends StatelessWidget {
children: [ children: [
if (data.enableRankingPage) if (data.enableRankingPage)
buildTag("Ranking".tl, (p0, p1) { buildTag("Ranking".tl, (p0, p1) {
context.to(() => RankingPage(sourceKey: findComicSourceKey())); context.to(() => RankingPage(categoryKey: data.key));
}), }),
for (var buttonData in data.buttons) for (var buttonData in data.buttons)
buildTag(buttonData.label.tl, (p0, p1) => buttonData.onTap()) buildTag(buttonData.label.tl, (p0, p1) => buttonData.onTap())

View File

@@ -26,6 +26,7 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
late final CategoryComicsData data; late final CategoryComicsData data;
late final List<CategoryComicsOptions> options; late final List<CategoryComicsOptions> options;
late List<String> optionsValue; late List<String> optionsValue;
late String sourceKey;
void findData() { void findData() {
for (final source in ComicSource.all()) { for (final source in ComicSource.all()) {
@@ -40,6 +41,7 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
return true; return true;
}).toList(); }).toList();
optionsValue = options.map((e) => e.options.keys.first).toList(); optionsValue = options.map((e) => e.options.keys.first).toList();
sourceKey = source.key;
return; return;
} }
} }
@@ -60,7 +62,7 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
), ),
body: ComicList( body: ComicList(
key: Key(widget.category + optionsValue.toString()), key: Key(widget.category + optionsValue.toString()),
leadingSliver: buildOptions(), leadingSliver: buildOptions().toSliver(),
loadPage: (i) => data.load( loadPage: (i) => data.load(
widget.category, widget.category,
widget.param, widget.param,
@@ -74,7 +76,7 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
Widget buildOptionItem( Widget buildOptionItem(
String text, String value, int group, BuildContext context) { String text, String value, int group, BuildContext context) {
return OptionChip( return OptionChip(
text: text, text: text.ts(sourceKey),
isSelected: value == optionsValue[group], isSelected: value == optionsValue[group],
onTap: () { onTap: () {
if (value == optionsValue[group]) return; if (value == optionsValue[group]) return;
@@ -105,12 +107,10 @@ class _CategoryComicsPageState extends State<CategoryComicsPage> {
children.add(const SizedBox(height: 8)); children.add(const SizedBox(height: 8));
} }
} }
return SliverToBoxAdapter( return Column(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [...children, const Divider()], children: [...children, const Divider()],
).paddingLeft(8).paddingRight(8), ).paddingLeft(8).paddingRight(8);
);
} }
} }

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.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';
import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_source/comic_source.dart';
@@ -10,8 +12,10 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart'; import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart'; import 'package:venera/network/download.dart';
import 'package:venera/pages/category_comics_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/utils/io.dart'; import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart'; import 'package:venera/utils/translations.dart';
import 'dart:math' as math; import 'dart:math' as math;
@@ -149,8 +153,9 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(comic.title, style: ts.s18), SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null) Text(comic.subTitle!, style: ts.s14), if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14),
Text( Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '', (ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12, style: ts.s12,
@@ -345,7 +350,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
for (var e in comic.tags.entries) for (var e in comic.tags.entries)
buildWrap( buildWrap(
children: [ children: [
buildTag(text: e.key, isTitle: true), buildTag(text: e.key.ts(comicSource.key), isTitle: true),
for (var tag in e.value) for (var tag in e.value)
buildTag(text: tag, onTap: () => onTapTag(tag, e.key)), buildTag(text: tag, onTap: () => onTapTag(tag, e.key)),
], ],
@@ -407,7 +412,6 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
} }
} }
// TODO: Implement the _ComicPageActions mixin
abstract mixin class _ComicPageActions { abstract mixin class _ComicPageActions {
void update(); void update();
@@ -546,9 +550,74 @@ abstract mixin class _ComicPageActions {
update(); update();
} }
void onTapTag(String tag, String namespace) {} void onTapTag(String tag, String namespace) {
var config = comicSource.handleClickTagEvent?.call(namespace, tag) ??
{
'action': 'search',
'keyword': tag,
};
var context = App.mainNavigatorKey!.currentContext!;
if (config['action'] == 'search') {
context.to(() => SearchResultPage(
text: config['keyword'] ?? '',
sourceKey: comicSource.key,
options: const [],
));
} else if (config['action'] == 'category') {
context.to(
() => CategoryComicsPage(
category: config['keyword'] ?? '',
categoryKey: comicSource.categoryData!.key,
param: config['param'],
),
);
}
}
void showMoreActions() {} void showMoreActions() {
var context = App.rootContext;
showMenuX(
context,
Offset(
context.width - 16,
context.padding.top,
),
[
MenuEntry(
icon: Icons.copy,
text: "Copy Title".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.title));
context.showMessage(message: "Copied".tl);
},
),
MenuEntry(
icon: Icons.copy_rounded,
text: "Copy ID".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.id));
context.showMessage(message: "Copied".tl);
},
),
if (comic.url != null)
MenuEntry(
icon: Icons.link,
text: "Copy URL".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.url!));
context.showMessage(message: "Copied".tl);
},
),
if (comic.url != null)
MenuEntry(
icon: Icons.open_in_browser,
text: "Open in Browser".tl,
onClick: () {
launchUrlString(comic.url!);
},
),
]);
}
void showComments() { void showComments() {
showSideBar( showSideBar(
@@ -1217,7 +1286,10 @@ class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: Appbar(title: Text("Download".tl), backgroundColor: context.colorScheme.surfaceContainerLow,), appBar: Appbar(
title: Text("Download".tl),
backgroundColor: context.colorScheme.surfaceContainerLow,
),
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

@@ -146,12 +146,90 @@ class _BodyState extends State<_Body> {
ListTile( ListTile(
title: const Text("Version"), title: const Text("Version"),
subtitle: Text(source.version), subtitle: Text(source.version),
) ),
...buildSourceSettings(source),
], ],
), ),
); );
} }
Iterable<Widget> buildSourceSettings(ComicSource source) sync* {
if (source.settings == null) {
return;
} else if (source.data['settings'] == null) {
source.data['settings'] = {};
}
for (var item in source.settings!.entries) {
var key = item.key;
String type = item.value['type'];
if (type == "select") {
var current = source.data['settings'][key];
if (current == null) {
var d = item.value['default'];
for (var option in item.value['options']) {
if (option['value'] == d) {
current = option['text'] ?? option['value'];
break;
}
}
}
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Select(
current: (current as String).ts(source.key),
values: (item.value['options'] as List)
.map<String>(
(e) => ((e['text'] ?? e['value']) as String).ts(source.key))
.toList(),
onTap: (i) {
source.data['settings'][key] = item.value['options'][i]['value'];
source.saveData();
setState(() {});
},
),
);
} else if (type == "switch") {
var current = source.data['settings'][key] ?? item.value['default'];
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: Switch(
value: current,
onChanged: (v) {
source.data['settings'][key] = v;
source.saveData();
setState(() {});
},
),
);
} else if (type == "input") {
var current =
source.data['settings'][key] ?? item.value['default'] ?? '';
yield ListTile(
title: Text((item.value['title'] as String).ts(source.key)),
trailing: IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
showInputDialog(
context: context,
title: (item.value['title'] as String).ts(source.key),
initialValue: current,
inputValidator: item.value['validator'] == null
? null
: RegExp(item.value['validator']),
onConfirm: (value) {
source.data['settings'][key] = value;
source.saveData();
setState(() {});
return null;
},
);
},
),
);
}
}
}
void delete(ComicSource source) { void delete(ComicSource source) {
showConfirmDialog( showConfirmDialog(
context: App.rootContext, context: App.rootContext,
@@ -280,11 +358,12 @@ class _BodyState extends State<_Body> {
if (file == null) return; if (file == null) return;
try { try {
var fileName = file.name; var fileName = file.name;
var bytes = file.bytes!; var bytes = await File(file.path!).readAsBytes();
var content = utf8.decode(bytes); var content = utf8.decode(bytes);
await addSource(content, fileName); await addSource(content, fileName);
} catch (e) { } catch (e, s) {
App.rootContext.showMessage(message: e.toString()); App.rootContext.showMessage(message: e.toString());
Log.error("Add comic source", "$e\n$s");
} }
} }

View File

@@ -88,6 +88,9 @@ class _CommentsPageState extends State<CommentsPage> {
withAppbar: false, withAppbar: false,
); );
} else { } else {
var showAvatar = _comments!.any((e) {
return e.avatar != null;
});
return Column( return Column(
children: [ children: [
Expanded( Expanded(
@@ -109,6 +112,7 @@ class _CommentsPageState extends State<CommentsPage> {
comment: _comments![index], comment: _comments![index],
source: widget.source, source: widget.source,
comic: widget.data, comic: widget.data,
showAvatar: showAvatar,
); );
}, },
), ),
@@ -204,6 +208,7 @@ class _CommentTile extends StatefulWidget {
required this.comment, required this.comment,
required this.source, required this.source,
required this.comic, required this.comic,
required this.showAvatar,
}); });
final Comment comment; final Comment comment;
@@ -212,6 +217,8 @@ class _CommentTile extends StatefulWidget {
final ComicDetails comic; final ComicDetails comic;
final bool showAvatar;
@override @override
State<_CommentTile> createState() => _CommentTileState(); State<_CommentTile> createState() => _CommentTileState();
} }
@@ -239,7 +246,7 @@ class _CommentTileState extends State<_CommentTile> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (widget.comment.avatar != null) if (widget.showAvatar)
Container( Container(
width: 40, width: 40,
height: 40, height: 40,
@@ -247,7 +254,9 @@ class _CommentTileState extends State<_CommentTile> {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.secondaryContainer), color: Theme.of(context).colorScheme.secondaryContainer),
child: AnimatedImage( child: widget.comment.avatar == null
? null
: AnimatedImage(
image: CachedImageProvider( image: CachedImageProvider(
widget.comment.avatar!, widget.comment.avatar!,
sourceKey: widget.source.key, sourceKey: widget.source.key,
@@ -313,6 +322,7 @@ class _CommentTileState extends State<_CommentTile> {
source: widget.source, source: widget.source,
replyId: widget.comment.id, replyId: widget.comment.id,
), ),
showBarrier: false,
); );
}, },
child: Row( child: Row(
@@ -376,7 +386,11 @@ class _CommentTileState extends State<_CommentTile> {
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
) )
else if (isLiked) else if (isLiked)
const Icon(Icons.favorite, size: 16) Icon(
Icons.favorite,
size: 16,
color: context.useTextColor(Colors.red),
)
else else
const Icon(Icons.favorite_border, size: 16), const Icon(Icons.favorite_border, size: 16),
const SizedBox(width: 8), const SizedBox(width: 8),

View File

@@ -27,7 +27,7 @@ class _LeftBarState extends State<_LeftBar> implements FolderList {
favPage.folderList = this; favPage.folderList = this;
folders = LocalFavoritesManager().folderNames; folders = LocalFavoritesManager().folderNames;
networkFolders = ComicSource.all() networkFolders = ComicSource.all()
.where((e) => e.favoriteData != null) .where((e) => e.favoriteData != null && e.isLogged)
.map((e) => e.favoriteData!.key) .map((e) => e.favoriteData!.key)
.toList(); .toList();
super.initState(); super.initState();

View File

@@ -813,11 +813,14 @@ class _AccountsWidgetState extends State<_AccountsWidget> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: Wrap( child: Wrap(
runSpacing: 8,
spacing: 8,
children: accounts.map((e) { children: accounts.map((e) {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2), horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -825,7 +828,7 @@ class _AccountsWidgetState extends State<_AccountsWidget> {
child: Text(e), child: Text(e),
); );
}).toList(), }).toList(),
), ).paddingHorizontal(16).paddingBottom(16),
), ),
], ],
), ),

View File

@@ -5,9 +5,9 @@ import "package:venera/foundation/comic_source/comic_source.dart";
import "package:venera/utils/translations.dart"; import "package:venera/utils/translations.dart";
class RankingPage extends StatefulWidget { class RankingPage extends StatefulWidget {
const RankingPage({required this.sourceKey, super.key}); const RankingPage({required this.categoryKey, super.key});
final String sourceKey; final String categoryKey;
@override @override
State<RankingPage> createState() => _RankingPageState(); State<RankingPage> createState() => _RankingPageState();
@@ -20,14 +20,14 @@ class _RankingPageState extends State<RankingPage> {
void findData() { void findData() {
for (final source in ComicSource.all()) { for (final source in ComicSource.all()) {
if (source.categoryData?.key == widget.sourceKey) { if (source.categoryData?.key == widget.categoryKey) {
data = source.categoryComicsData!; data = source.categoryComicsData!;
options = data.rankingData!.options; options = data.rankingData!.options;
optionValue = options.keys.first; optionValue = options.keys.first;
return; return;
} }
} }
throw "${widget.sourceKey} Not found"; throw "${widget.categoryKey} Not found";
} }
@override @override

View File

@@ -317,6 +317,14 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
height = constrains.maxHeight; height = constrains.maxHeight;
width = height * cacheSize.width / cacheSize.height; width = height * cacheSize.width / cacheSize.height;
} }
} else {
if(width == double.infinity) {
width = constrains.maxWidth;
height = 300;
} else if(height == double.infinity) {
height = constrains.maxHeight;
width = 300;
}
} }
if(_imageInfo != null){ if(_imageInfo != null){
@@ -371,6 +379,7 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
height: 24, height: 24,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 3, strokeWidth: 3,
backgroundColor: context.colorScheme.surfaceContainerLow,
value: (_loadingProgress != null && value: (_loadingProgress != null &&
_loadingProgress!.expectedTotalBytes!=null && _loadingProgress!.expectedTotalBytes!=null &&
_loadingProgress!.expectedTotalBytes! != 0) _loadingProgress!.expectedTotalBytes! != 0)

View File

@@ -112,6 +112,7 @@ class _SearchPageState extends State<SearchPage> {
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(ListTile(
contentPadding: EdgeInsets.zero,
title: Text(option.label.tl), title: Text(option.label.tl),
)); ));
children.add(Wrap( children.add(Wrap(
@@ -119,7 +120,7 @@ class _SearchPageState extends State<SearchPage> {
spacing: 8, spacing: 8,
children: option.options.entries.map((e) { children: option.options.entries.map((e) {
return OptionChip( return OptionChip(
text: e.value.tl, text: e.value.ts(searchTarget),
isSelected: options[i] == e.key, isSelected: options[i] == e.key,
onTap: () { onTap: () {
options[i] = e.key; options[i] = e.key;
@@ -127,7 +128,7 @@ class _SearchPageState extends State<SearchPage> {
}, },
); );
}).toList(), }).toList(),
).paddingHorizontal(16)); ));
} }
return SliverToBoxAdapter( return SliverToBoxAdapter(
@@ -136,13 +137,7 @@ class _SearchPageState extends State<SearchPage> {
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: children,
ListTile(
contentPadding: EdgeInsets.zero,
title: Text("Search Options".tl),
),
...children,
],
), ),
), ),
); );

View File

@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import '../foundation/app.dart'; import '../foundation/app.dart';
extension AppTranslation on String { extension AppTranslation on String {
@@ -30,7 +31,24 @@ extension AppTranslation on String {
static Future<void> init() async { static Future<void> init() async {
var data = await rootBundle.load("assets/translation.json"); var data = await rootBundle.load("assets/translation.json");
var json = jsonDecode(utf8.decode(data.buffer.asUint8List())); var json = jsonDecode(utf8.decode(data.buffer.asUint8List()));
translations = { for (var e in json.entries) e.key : Map<String, String>.from(e.value) }; translations = {
for (var e in json.entries) e.key: Map<String, String>.from(e.value)
};
}
/// Translate a string using specified comic source
String ts(String sourceKey) {
var comicSource = ComicSource.find(sourceKey);
if (comicSource == null || comicSource.translations == null) {
return this;
}
var locale = App.locale;
var lc = locale.languageCode;
var cc = locale.countryCode;
var key = "$lc${cc == null ? "" : "_$cc"}";
return (comicSource.translations![key] ??
comicSource.translations![lc])?[this] ??
this;
} }
} }

View File

@@ -550,7 +550,7 @@ packages:
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
uuid: uuid:
dependency: transitive dependency: "direct main"
description: description:
name: uuid name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff

View File

@@ -42,6 +42,7 @@ dependencies:
path: packages/scrollable_positioned_list path: packages/scrollable_positioned_list
flutter_reorderable_grid_view: 5.0.1 flutter_reorderable_grid_view: 5.0.1
yaml: any yaml: any
uuid: ^4.5.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: